From f43312a8587248c326fd4dda89e8140954178246 Mon Sep 17 00:00:00 2001 From: TIANHE Date: Mon, 29 Dec 2025 03:06:49 +0800 Subject: [PATCH] creat Signed-off-by: TIANHE --- .gitignore | 1 + LICENSE | 203 + README.md | 175 + backend_api_python/.gitignore | 43 + backend_api_python/DEPLOY.md | 230 + backend_api_python/README.md | 138 + backend_api_python/app/__init__.py | 134 + backend_api_python/app/config/__init__.py | 36 + backend_api_python/app/config/api_keys.py | 44 + backend_api_python/app/config/data_sources.py | 187 + backend_api_python/app/config/database.py | 109 + backend_api_python/app/config/settings.py | 113 + .../app/data_sources/__init__.py | 8 + backend_api_python/app/data_sources/base.py | 154 + .../app/data_sources/cn_stock.py | 615 + backend_api_python/app/data_sources/crypto.py | 199 + .../app/data_sources/factory.py | 106 + backend_api_python/app/data_sources/forex.py | 190 + .../app/data_sources/futures.py | 243 + .../app/data_sources/us_stock.py | 194 + backend_api_python/app/routes/__init__.py | 32 + backend_api_python/app/routes/ai_chat.py | 46 + backend_api_python/app/routes/analysis.py | 330 + backend_api_python/app/routes/auth.py | 68 + backend_api_python/app/routes/backtest.py | 801 ++ backend_api_python/app/routes/credentials.py | 181 + backend_api_python/app/routes/dashboard.py | 370 + backend_api_python/app/routes/health.py | 28 + backend_api_python/app/routes/indicator.py | 433 + backend_api_python/app/routes/kline.py | 121 + backend_api_python/app/routes/market.py | 582 + backend_api_python/app/routes/strategy.py | 590 + .../app/routes/strategy_code.py | 276 + backend_api_python/app/services/__init__.py | 10 + .../app/services/agents/README.md | 148 + .../app/services/agents/__init__.py | 32 + .../app/services/agents/analyst_agents.py | 434 + .../app/services/agents/base_agent.py | 71 + .../app/services/agents/coordinator.py | 500 + .../app/services/agents/memory.py | 203 + .../app/services/agents/reflection.py | 205 + .../app/services/agents/researcher_agents.py | 192 + .../app/services/agents/risk_agents.py | 206 + .../app/services/agents/tools.py | 603 + .../app/services/agents/trader_agent.py | 129 + backend_api_python/app/services/analysis.py | 168 + backend_api_python/app/services/backtest.py | 2867 ++++ .../app/services/exchange_execution.py | 138 + backend_api_python/app/services/kline.py | 73 + .../app/services/live_trading/__init__.py | 7 + .../app/services/live_trading/base.py | 79 + .../app/services/live_trading/binance.py | 666 + .../app/services/live_trading/binance_spot.py | 385 + .../app/services/live_trading/bitget.py | 573 + .../app/services/live_trading/bitget_spot.py | 354 + .../app/services/live_trading/execution.py | 114 + .../app/services/live_trading/factory.py | 59 + .../app/services/live_trading/okx.py | 657 + .../app/services/live_trading/records.py | 204 + .../app/services/live_trading/symbols.py | 55 + backend_api_python/app/services/llm.py | 161 + .../app/services/pending_order_worker.py | 1133 ++ backend_api_python/app/services/search.py | 142 + .../app/services/signal_notifier.py | 613 + backend_api_python/app/services/strategy.py | 465 + .../app/services/strategy_compiler.py | 688 + .../app/services/symbol_name.py | 243 + .../app/services/trading_executor.py | 2341 +++ backend_api_python/app/utils/__init__.py | 9 + backend_api_python/app/utils/auth.py | 63 + backend_api_python/app/utils/cache.py | 128 + backend_api_python/app/utils/config_loader.py | 215 + backend_api_python/app/utils/db.py | 501 + backend_api_python/app/utils/http.py | 41 + backend_api_python/app/utils/language.py | 86 + backend_api_python/app/utils/logger.py | 46 + backend_api_python/app/utils/safe_exec.py | 312 + backend_api_python/env.example | 158 + backend_api_python/gunicorn_config.py | 37 + backend_api_python/requirements.txt | 13 + backend_api_python/run.py | 98 + .../scripts/backfill_zero_trades.py | 177 + .../scripts/run_reflection_task.py | 21 + .../scripts/simulate_trading_executor.py | 394 + backend_api_python/start.sh | 26 + quantdinger_vue/.browserslistrc | 3 + quantdinger_vue/.editorconfig | 39 + quantdinger_vue/.env | 3 + quantdinger_vue/.env.development | 3 + quantdinger_vue/.env.preview | 3 + quantdinger_vue/.eslintrc.js | 75 + quantdinger_vue/.eslintrc.json | 5 + quantdinger_vue/.gitattributes | 11 + quantdinger_vue/.gitignore | 26 + quantdinger_vue/.husky/.gitignore | 1 + quantdinger_vue/.lintstagedrc.json | 4 + quantdinger_vue/.prettierrc | 6 + quantdinger_vue/.stylelintrc.js | 102 + quantdinger_vue/.travis.yml | 7 + quantdinger_vue/Dockerfile | 6 + quantdinger_vue/LICENSE | 21 + quantdinger_vue/README.md | 103 + quantdinger_vue/README.zh-CN.md | 110 + quantdinger_vue/babel.config.js | 30 + quantdinger_vue/commitlint.config.js | 26 + quantdinger_vue/config/plugin.config.js | 49 + quantdinger_vue/config/themePluginConfig.js | 115 + quantdinger_vue/deploy/caddy.conf | 9 + quantdinger_vue/deploy/nginx.conf | 24 + quantdinger_vue/jest.config.js | 23 + quantdinger_vue/jsconfig.json | 11 + quantdinger_vue/package.json | 103 + quantdinger_vue/pnpm-lock.yaml | 11874 +++++++++++++++ quantdinger_vue/postcss.config.js | 5 + quantdinger_vue/public/avatar2.jpg | Bin 0 -> 80189 bytes quantdinger_vue/public/index.html | 429 + quantdinger_vue/public/logo.png | Bin 0 -> 9218 bytes quantdinger_vue/public/slogo.png | Bin 0 -> 27144 bytes quantdinger_vue/scripts/fetch_pyodide.ps1 | 40 + quantdinger_vue/src/App.vue | 49 + quantdinger_vue/src/api/ai-trading.js | 113 + quantdinger_vue/src/api/credentials.js | 40 + quantdinger_vue/src/api/dashboard.js | 30 + quantdinger_vue/src/api/login.js | 59 + quantdinger_vue/src/api/manage.js | 70 + quantdinger_vue/src/api/market.js | 243 + quantdinger_vue/src/api/strategy.js | 177 + quantdinger_vue/src/assets/background.svg | 69 + .../src/assets/icons/bx-analyse.svg | 1 + quantdinger_vue/src/assets/logo.png | Bin 0 -> 105735 bytes quantdinger_vue/src/assets/logo.svg | 29 + quantdinger_vue/src/assets/slogo.png | Bin 0 -> 27144 bytes .../ArticleListContent/ArticleListContent.vue | 89 + .../components/ArticleListContent/index.js | 3 + .../src/components/AvatarList/Item.jsx | 25 + .../src/components/AvatarList/List.jsx | 72 + .../src/components/AvatarList/index.js | 9 + .../src/components/AvatarList/index.less | 59 + .../src/components/AvatarList/index.md | 64 + quantdinger_vue/src/components/Charts/Bar.vue | 62 + .../src/components/Charts/ChartCard.vue | 120 + .../src/components/Charts/Liquid.vue | 67 + .../src/components/Charts/MiniArea.vue | 56 + .../src/components/Charts/MiniBar.vue | 57 + .../src/components/Charts/MiniProgress.vue | 75 + .../src/components/Charts/MiniSmoothArea.vue | 40 + .../src/components/Charts/Radar.vue | 68 + .../src/components/Charts/RankList.vue | 77 + .../src/components/Charts/TagCloud.vue | 113 + .../src/components/Charts/TransferBar.vue | 64 + .../src/components/Charts/Trend.vue | 82 + .../src/components/Charts/chart.less | 13 + .../src/components/Charts/smooth.area.less | 14 + quantdinger_vue/src/components/Dialog.js | 113 + .../src/components/Editor/QuillEditor.vue | 79 + .../src/components/Editor/WangEditor.vue | 57 + .../src/components/Ellipsis/Ellipsis.vue | 64 + .../src/components/Ellipsis/index.js | 3 + .../src/components/Ellipsis/index.md | 38 + .../FooterToolbar/FooterToolBar.vue | 47 + .../src/components/FooterToolbar/index.js | 4 + .../src/components/FooterToolbar/index.less | 23 + .../src/components/FooterToolbar/index.md | 48 + .../src/components/GlobalFooter/index.vue | 129 + .../GlobalHeader/AvatarDropdown.vue | 95 + .../components/GlobalHeader/RightContent.vue | 126 + .../components/IconSelector/IconSelector.vue | 86 + .../src/components/IconSelector/README.md | 48 + .../src/components/IconSelector/icons.js | 36 + .../src/components/IconSelector/index.js | 2 + .../src/components/MultiTab/MultiTab.vue | 161 + .../src/components/MultiTab/events.js | 2 + .../src/components/MultiTab/index.js | 40 + .../src/components/MultiTab/index.less | 25 + .../src/components/NProgress/nprogress.less | 70 + .../src/components/NoticeIcon/NoticeIcon.vue | 90 + .../src/components/NoticeIcon/index.js | 2 + .../src/components/NumberInfo/NumberInfo.vue | 54 + .../src/components/NumberInfo/index.js | 3 + .../src/components/NumberInfo/index.less | 55 + .../src/components/NumberInfo/index.md | 43 + .../src/components/Other/CarbonAds.vue | 62 + .../src/components/PageLoading/index.jsx | 106 + .../src/components/Search/GlobalSearch.jsx | 62 + .../src/components/Search/index.less | 25 + .../src/components/SelectLang/index.jsx | 70 + .../src/components/SelectLang/index.less | 30 + .../SettingDrawer/SettingDrawer.vue | 355 + .../components/SettingDrawer/SettingItem.vue | 38 + .../src/components/SettingDrawer/index.js | 2 + .../components/SettingDrawer/settingConfig.js | 53 + .../components/SettingDrawer/themeColor.js | 36 + .../StandardFormRow/StandardFormRow.vue | 122 + .../src/components/StandardFormRow/index.js | 3 + .../src/components/Table/README.md | 341 + quantdinger_vue/src/components/Table/index.js | 325 + .../components/TagSelect/TagSelectOption.jsx | 45 + .../src/components/TagSelect/index.jsx | 113 + .../src/components/TextArea/index.jsx | 67 + .../src/components/TextArea/style.less | 12 + quantdinger_vue/src/components/Tree/Tree.jsx | 124 + .../src/components/Trend/Trend.vue | 41 + quantdinger_vue/src/components/Trend/index.js | 3 + .../src/components/Trend/index.less | 44 + quantdinger_vue/src/components/Trend/index.md | 45 + quantdinger_vue/src/components/_util/util.js | 46 + quantdinger_vue/src/components/index.js | 56 + quantdinger_vue/src/components/index.less | 6 + .../src/components/tools/TwoStepCaptcha.vue | 102 + quantdinger_vue/src/config/aiModels.js | 41 + quantdinger_vue/src/config/defaultSettings.js | 33 + quantdinger_vue/src/config/router.config.js | 147 + quantdinger_vue/src/core/bootstrap.js | 31 + quantdinger_vue/src/core/directives/action.js | 34 + quantdinger_vue/src/core/icons.js | 11 + quantdinger_vue/src/core/lazy_use.js | 127 + .../src/core/permission/permission.js | 55 + quantdinger_vue/src/core/use.js | 27 + quantdinger_vue/src/global.less | 117 + quantdinger_vue/src/layouts/BasicLayout.less | 423 + quantdinger_vue/src/layouts/BasicLayout.vue | 1025 ++ quantdinger_vue/src/layouts/BlankLayout.vue | 16 + quantdinger_vue/src/layouts/PageView.vue | 12 + quantdinger_vue/src/layouts/RouteView.vue | 32 + quantdinger_vue/src/layouts/UserLayout.vue | 264 + quantdinger_vue/src/layouts/index.js | 7 + quantdinger_vue/src/locales/index.js | 68 + quantdinger_vue/src/locales/lang/ar-SA.js | 1767 +++ quantdinger_vue/src/locales/lang/de-DE.js | 1817 +++ quantdinger_vue/src/locales/lang/en-US.js | 1824 +++ quantdinger_vue/src/locales/lang/fr-FR.js | 1768 +++ quantdinger_vue/src/locales/lang/ja-JP.js | 1768 +++ quantdinger_vue/src/locales/lang/ko-KR.js | 1767 +++ quantdinger_vue/src/locales/lang/th-TH.js | 1768 +++ quantdinger_vue/src/locales/lang/vi-VN.js | 2896 ++++ quantdinger_vue/src/locales/lang/zh-CN.js | 1749 +++ quantdinger_vue/src/locales/lang/zh-TW.js | 1528 ++ quantdinger_vue/src/main.js | 56 + quantdinger_vue/src/mock/index.js | 20 + quantdinger_vue/src/mock/services/article.js | 88 + quantdinger_vue/src/mock/services/auth.js | 49 + quantdinger_vue/src/mock/services/manage.js | 252 + quantdinger_vue/src/mock/services/other.js | 973 ++ quantdinger_vue/src/mock/services/tagCloud.js | 9 + quantdinger_vue/src/mock/services/user.js | 577 + quantdinger_vue/src/mock/util.js | 38 + quantdinger_vue/src/permission.js | 113 + quantdinger_vue/src/router/README.md | 134 + .../src/router/generator-routers.js | 9 + quantdinger_vue/src/router/index.js | 28 + quantdinger_vue/src/store/app-mixin.js | 32 + quantdinger_vue/src/store/device-mixin.js | 11 + quantdinger_vue/src/store/getters.js | 16 + quantdinger_vue/src/store/i18n-mixin.js | 16 + quantdinger_vue/src/store/index.js | 29 + quantdinger_vue/src/store/modules/app.js | 99 + .../src/store/modules/async-router.js | 33 + .../src/store/modules/static-router.js | 54 + quantdinger_vue/src/store/modules/user.js | 410 + quantdinger_vue/src/store/mutation-types.js | 26 + quantdinger_vue/src/utils/axios.js | 34 + quantdinger_vue/src/utils/codeDecrypt.js | 138 + quantdinger_vue/src/utils/domUtil.js | 21 + quantdinger_vue/src/utils/filter.js | 20 + quantdinger_vue/src/utils/request.js | 149 + quantdinger_vue/src/utils/routeConvert.js | 30 + quantdinger_vue/src/utils/screenLog.js | 4 + quantdinger_vue/src/utils/util.js | 95 + quantdinger_vue/src/utils/utils.less | 54 + quantdinger_vue/src/views/404.vue | 15 + .../views/ai-analysis/components/index.vue | 3734 +++++ .../src/views/ai-analysis/index.vue | 2462 ++++ quantdinger_vue/src/views/dashboard/index.vue | 1049 ++ quantdinger_vue/src/views/exception/403.vue | 20 + quantdinger_vue/src/views/exception/404.vue | 20 + quantdinger_vue/src/views/exception/500.vue | 20 + .../components/BacktestHistoryDrawer.vue | 221 + .../components/BacktestModal.vue | 1361 ++ .../components/BacktestRunViewer.vue | 283 + .../components/IndicatorEditor.vue | 1119 ++ .../components/KlineChart.vue | 3863 +++++ .../src/views/indicator-analysis/index.vue | 2933 ++++ .../src/views/indicator-community/index.vue | 41 + .../components/AIDecisionRecords.vue | 539 + .../components/PositionRecords.vue | 733 + .../components/TradingRecords.vue | 1099 ++ .../src/views/trading-assistant/index.vue | 3616 +++++ quantdinger_vue/src/views/user/Login.vue | 255 + .../src/views/user/RegisterResult.vue | 26 + quantdinger_vue/tests/unit/.eslintrc.js | 5 + quantdinger_vue/vue.config.js | 151 + quantdinger_vue/yarn.lock | 11960 ++++++++++++++++ 292 files changed, 103739 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 backend_api_python/.gitignore create mode 100644 backend_api_python/DEPLOY.md create mode 100644 backend_api_python/README.md create mode 100644 backend_api_python/app/__init__.py create mode 100644 backend_api_python/app/config/__init__.py create mode 100644 backend_api_python/app/config/api_keys.py create mode 100644 backend_api_python/app/config/data_sources.py create mode 100644 backend_api_python/app/config/database.py create mode 100644 backend_api_python/app/config/settings.py create mode 100644 backend_api_python/app/data_sources/__init__.py create mode 100644 backend_api_python/app/data_sources/base.py create mode 100644 backend_api_python/app/data_sources/cn_stock.py create mode 100644 backend_api_python/app/data_sources/crypto.py create mode 100644 backend_api_python/app/data_sources/factory.py create mode 100644 backend_api_python/app/data_sources/forex.py create mode 100644 backend_api_python/app/data_sources/futures.py create mode 100644 backend_api_python/app/data_sources/us_stock.py create mode 100644 backend_api_python/app/routes/__init__.py create mode 100644 backend_api_python/app/routes/ai_chat.py create mode 100644 backend_api_python/app/routes/analysis.py create mode 100644 backend_api_python/app/routes/auth.py create mode 100644 backend_api_python/app/routes/backtest.py create mode 100644 backend_api_python/app/routes/credentials.py create mode 100644 backend_api_python/app/routes/dashboard.py create mode 100644 backend_api_python/app/routes/health.py create mode 100644 backend_api_python/app/routes/indicator.py create mode 100644 backend_api_python/app/routes/kline.py create mode 100644 backend_api_python/app/routes/market.py create mode 100644 backend_api_python/app/routes/strategy.py create mode 100644 backend_api_python/app/routes/strategy_code.py create mode 100644 backend_api_python/app/services/__init__.py create mode 100644 backend_api_python/app/services/agents/README.md create mode 100644 backend_api_python/app/services/agents/__init__.py create mode 100644 backend_api_python/app/services/agents/analyst_agents.py create mode 100644 backend_api_python/app/services/agents/base_agent.py create mode 100644 backend_api_python/app/services/agents/coordinator.py create mode 100644 backend_api_python/app/services/agents/memory.py create mode 100644 backend_api_python/app/services/agents/reflection.py create mode 100644 backend_api_python/app/services/agents/researcher_agents.py create mode 100644 backend_api_python/app/services/agents/risk_agents.py create mode 100644 backend_api_python/app/services/agents/tools.py create mode 100644 backend_api_python/app/services/agents/trader_agent.py create mode 100644 backend_api_python/app/services/analysis.py create mode 100644 backend_api_python/app/services/backtest.py create mode 100644 backend_api_python/app/services/exchange_execution.py create mode 100644 backend_api_python/app/services/kline.py create mode 100644 backend_api_python/app/services/live_trading/__init__.py create mode 100644 backend_api_python/app/services/live_trading/base.py create mode 100644 backend_api_python/app/services/live_trading/binance.py create mode 100644 backend_api_python/app/services/live_trading/binance_spot.py create mode 100644 backend_api_python/app/services/live_trading/bitget.py create mode 100644 backend_api_python/app/services/live_trading/bitget_spot.py create mode 100644 backend_api_python/app/services/live_trading/execution.py create mode 100644 backend_api_python/app/services/live_trading/factory.py create mode 100644 backend_api_python/app/services/live_trading/okx.py create mode 100644 backend_api_python/app/services/live_trading/records.py create mode 100644 backend_api_python/app/services/live_trading/symbols.py create mode 100644 backend_api_python/app/services/llm.py create mode 100644 backend_api_python/app/services/pending_order_worker.py create mode 100644 backend_api_python/app/services/search.py create mode 100644 backend_api_python/app/services/signal_notifier.py create mode 100644 backend_api_python/app/services/strategy.py create mode 100644 backend_api_python/app/services/strategy_compiler.py create mode 100644 backend_api_python/app/services/symbol_name.py create mode 100644 backend_api_python/app/services/trading_executor.py create mode 100644 backend_api_python/app/utils/__init__.py create mode 100644 backend_api_python/app/utils/auth.py create mode 100644 backend_api_python/app/utils/cache.py create mode 100644 backend_api_python/app/utils/config_loader.py create mode 100644 backend_api_python/app/utils/db.py create mode 100644 backend_api_python/app/utils/http.py create mode 100644 backend_api_python/app/utils/language.py create mode 100644 backend_api_python/app/utils/logger.py create mode 100644 backend_api_python/app/utils/safe_exec.py create mode 100644 backend_api_python/env.example create mode 100644 backend_api_python/gunicorn_config.py create mode 100644 backend_api_python/requirements.txt create mode 100644 backend_api_python/run.py create mode 100644 backend_api_python/scripts/backfill_zero_trades.py create mode 100644 backend_api_python/scripts/run_reflection_task.py create mode 100644 backend_api_python/scripts/simulate_trading_executor.py create mode 100644 backend_api_python/start.sh create mode 100644 quantdinger_vue/.browserslistrc create mode 100644 quantdinger_vue/.editorconfig create mode 100644 quantdinger_vue/.env create mode 100644 quantdinger_vue/.env.development create mode 100644 quantdinger_vue/.env.preview create mode 100644 quantdinger_vue/.eslintrc.js create mode 100644 quantdinger_vue/.eslintrc.json create mode 100644 quantdinger_vue/.gitattributes create mode 100644 quantdinger_vue/.gitignore create mode 100644 quantdinger_vue/.husky/.gitignore create mode 100644 quantdinger_vue/.lintstagedrc.json create mode 100644 quantdinger_vue/.prettierrc create mode 100644 quantdinger_vue/.stylelintrc.js create mode 100644 quantdinger_vue/.travis.yml create mode 100644 quantdinger_vue/Dockerfile create mode 100644 quantdinger_vue/LICENSE create mode 100644 quantdinger_vue/README.md create mode 100644 quantdinger_vue/README.zh-CN.md create mode 100644 quantdinger_vue/babel.config.js create mode 100644 quantdinger_vue/commitlint.config.js create mode 100644 quantdinger_vue/config/plugin.config.js create mode 100644 quantdinger_vue/config/themePluginConfig.js create mode 100644 quantdinger_vue/deploy/caddy.conf create mode 100644 quantdinger_vue/deploy/nginx.conf create mode 100644 quantdinger_vue/jest.config.js create mode 100644 quantdinger_vue/jsconfig.json create mode 100644 quantdinger_vue/package.json create mode 100644 quantdinger_vue/pnpm-lock.yaml create mode 100644 quantdinger_vue/postcss.config.js create mode 100644 quantdinger_vue/public/avatar2.jpg create mode 100644 quantdinger_vue/public/index.html create mode 100644 quantdinger_vue/public/logo.png create mode 100644 quantdinger_vue/public/slogo.png create mode 100644 quantdinger_vue/scripts/fetch_pyodide.ps1 create mode 100644 quantdinger_vue/src/App.vue create mode 100644 quantdinger_vue/src/api/ai-trading.js create mode 100644 quantdinger_vue/src/api/credentials.js create mode 100644 quantdinger_vue/src/api/dashboard.js create mode 100644 quantdinger_vue/src/api/login.js create mode 100644 quantdinger_vue/src/api/manage.js create mode 100644 quantdinger_vue/src/api/market.js create mode 100644 quantdinger_vue/src/api/strategy.js create mode 100644 quantdinger_vue/src/assets/background.svg create mode 100644 quantdinger_vue/src/assets/icons/bx-analyse.svg create mode 100644 quantdinger_vue/src/assets/logo.png create mode 100644 quantdinger_vue/src/assets/logo.svg create mode 100644 quantdinger_vue/src/assets/slogo.png create mode 100644 quantdinger_vue/src/components/ArticleListContent/ArticleListContent.vue create mode 100644 quantdinger_vue/src/components/ArticleListContent/index.js create mode 100644 quantdinger_vue/src/components/AvatarList/Item.jsx create mode 100644 quantdinger_vue/src/components/AvatarList/List.jsx create mode 100644 quantdinger_vue/src/components/AvatarList/index.js create mode 100644 quantdinger_vue/src/components/AvatarList/index.less create mode 100644 quantdinger_vue/src/components/AvatarList/index.md create mode 100644 quantdinger_vue/src/components/Charts/Bar.vue create mode 100644 quantdinger_vue/src/components/Charts/ChartCard.vue create mode 100644 quantdinger_vue/src/components/Charts/Liquid.vue create mode 100644 quantdinger_vue/src/components/Charts/MiniArea.vue create mode 100644 quantdinger_vue/src/components/Charts/MiniBar.vue create mode 100644 quantdinger_vue/src/components/Charts/MiniProgress.vue create mode 100644 quantdinger_vue/src/components/Charts/MiniSmoothArea.vue create mode 100644 quantdinger_vue/src/components/Charts/Radar.vue create mode 100644 quantdinger_vue/src/components/Charts/RankList.vue create mode 100644 quantdinger_vue/src/components/Charts/TagCloud.vue create mode 100644 quantdinger_vue/src/components/Charts/TransferBar.vue create mode 100644 quantdinger_vue/src/components/Charts/Trend.vue create mode 100644 quantdinger_vue/src/components/Charts/chart.less create mode 100644 quantdinger_vue/src/components/Charts/smooth.area.less create mode 100644 quantdinger_vue/src/components/Dialog.js create mode 100644 quantdinger_vue/src/components/Editor/QuillEditor.vue create mode 100644 quantdinger_vue/src/components/Editor/WangEditor.vue create mode 100644 quantdinger_vue/src/components/Ellipsis/Ellipsis.vue create mode 100644 quantdinger_vue/src/components/Ellipsis/index.js create mode 100644 quantdinger_vue/src/components/Ellipsis/index.md create mode 100644 quantdinger_vue/src/components/FooterToolbar/FooterToolBar.vue create mode 100644 quantdinger_vue/src/components/FooterToolbar/index.js create mode 100644 quantdinger_vue/src/components/FooterToolbar/index.less create mode 100644 quantdinger_vue/src/components/FooterToolbar/index.md create mode 100644 quantdinger_vue/src/components/GlobalFooter/index.vue create mode 100644 quantdinger_vue/src/components/GlobalHeader/AvatarDropdown.vue create mode 100644 quantdinger_vue/src/components/GlobalHeader/RightContent.vue create mode 100644 quantdinger_vue/src/components/IconSelector/IconSelector.vue create mode 100644 quantdinger_vue/src/components/IconSelector/README.md create mode 100644 quantdinger_vue/src/components/IconSelector/icons.js create mode 100644 quantdinger_vue/src/components/IconSelector/index.js create mode 100644 quantdinger_vue/src/components/MultiTab/MultiTab.vue create mode 100644 quantdinger_vue/src/components/MultiTab/events.js create mode 100644 quantdinger_vue/src/components/MultiTab/index.js create mode 100644 quantdinger_vue/src/components/MultiTab/index.less create mode 100644 quantdinger_vue/src/components/NProgress/nprogress.less create mode 100644 quantdinger_vue/src/components/NoticeIcon/NoticeIcon.vue create mode 100644 quantdinger_vue/src/components/NoticeIcon/index.js create mode 100644 quantdinger_vue/src/components/NumberInfo/NumberInfo.vue create mode 100644 quantdinger_vue/src/components/NumberInfo/index.js create mode 100644 quantdinger_vue/src/components/NumberInfo/index.less create mode 100644 quantdinger_vue/src/components/NumberInfo/index.md create mode 100644 quantdinger_vue/src/components/Other/CarbonAds.vue create mode 100644 quantdinger_vue/src/components/PageLoading/index.jsx create mode 100644 quantdinger_vue/src/components/Search/GlobalSearch.jsx create mode 100644 quantdinger_vue/src/components/Search/index.less create mode 100644 quantdinger_vue/src/components/SelectLang/index.jsx create mode 100644 quantdinger_vue/src/components/SelectLang/index.less create mode 100644 quantdinger_vue/src/components/SettingDrawer/SettingDrawer.vue create mode 100644 quantdinger_vue/src/components/SettingDrawer/SettingItem.vue create mode 100644 quantdinger_vue/src/components/SettingDrawer/index.js create mode 100644 quantdinger_vue/src/components/SettingDrawer/settingConfig.js create mode 100644 quantdinger_vue/src/components/SettingDrawer/themeColor.js create mode 100644 quantdinger_vue/src/components/StandardFormRow/StandardFormRow.vue create mode 100644 quantdinger_vue/src/components/StandardFormRow/index.js create mode 100644 quantdinger_vue/src/components/Table/README.md create mode 100644 quantdinger_vue/src/components/Table/index.js create mode 100644 quantdinger_vue/src/components/TagSelect/TagSelectOption.jsx create mode 100644 quantdinger_vue/src/components/TagSelect/index.jsx create mode 100644 quantdinger_vue/src/components/TextArea/index.jsx create mode 100644 quantdinger_vue/src/components/TextArea/style.less create mode 100644 quantdinger_vue/src/components/Tree/Tree.jsx create mode 100644 quantdinger_vue/src/components/Trend/Trend.vue create mode 100644 quantdinger_vue/src/components/Trend/index.js create mode 100644 quantdinger_vue/src/components/Trend/index.less create mode 100644 quantdinger_vue/src/components/Trend/index.md create mode 100644 quantdinger_vue/src/components/_util/util.js create mode 100644 quantdinger_vue/src/components/index.js create mode 100644 quantdinger_vue/src/components/index.less create mode 100644 quantdinger_vue/src/components/tools/TwoStepCaptcha.vue create mode 100644 quantdinger_vue/src/config/aiModels.js create mode 100644 quantdinger_vue/src/config/defaultSettings.js create mode 100644 quantdinger_vue/src/config/router.config.js create mode 100644 quantdinger_vue/src/core/bootstrap.js create mode 100644 quantdinger_vue/src/core/directives/action.js create mode 100644 quantdinger_vue/src/core/icons.js create mode 100644 quantdinger_vue/src/core/lazy_use.js create mode 100644 quantdinger_vue/src/core/permission/permission.js create mode 100644 quantdinger_vue/src/core/use.js create mode 100644 quantdinger_vue/src/global.less create mode 100644 quantdinger_vue/src/layouts/BasicLayout.less create mode 100644 quantdinger_vue/src/layouts/BasicLayout.vue create mode 100644 quantdinger_vue/src/layouts/BlankLayout.vue create mode 100644 quantdinger_vue/src/layouts/PageView.vue create mode 100644 quantdinger_vue/src/layouts/RouteView.vue create mode 100644 quantdinger_vue/src/layouts/UserLayout.vue create mode 100644 quantdinger_vue/src/layouts/index.js create mode 100644 quantdinger_vue/src/locales/index.js create mode 100644 quantdinger_vue/src/locales/lang/ar-SA.js create mode 100644 quantdinger_vue/src/locales/lang/de-DE.js create mode 100644 quantdinger_vue/src/locales/lang/en-US.js create mode 100644 quantdinger_vue/src/locales/lang/fr-FR.js create mode 100644 quantdinger_vue/src/locales/lang/ja-JP.js create mode 100644 quantdinger_vue/src/locales/lang/ko-KR.js create mode 100644 quantdinger_vue/src/locales/lang/th-TH.js create mode 100644 quantdinger_vue/src/locales/lang/vi-VN.js create mode 100644 quantdinger_vue/src/locales/lang/zh-CN.js create mode 100644 quantdinger_vue/src/locales/lang/zh-TW.js create mode 100644 quantdinger_vue/src/main.js create mode 100644 quantdinger_vue/src/mock/index.js create mode 100644 quantdinger_vue/src/mock/services/article.js create mode 100644 quantdinger_vue/src/mock/services/auth.js create mode 100644 quantdinger_vue/src/mock/services/manage.js create mode 100644 quantdinger_vue/src/mock/services/other.js create mode 100644 quantdinger_vue/src/mock/services/tagCloud.js create mode 100644 quantdinger_vue/src/mock/services/user.js create mode 100644 quantdinger_vue/src/mock/util.js create mode 100644 quantdinger_vue/src/permission.js create mode 100644 quantdinger_vue/src/router/README.md create mode 100644 quantdinger_vue/src/router/generator-routers.js create mode 100644 quantdinger_vue/src/router/index.js create mode 100644 quantdinger_vue/src/store/app-mixin.js create mode 100644 quantdinger_vue/src/store/device-mixin.js create mode 100644 quantdinger_vue/src/store/getters.js create mode 100644 quantdinger_vue/src/store/i18n-mixin.js create mode 100644 quantdinger_vue/src/store/index.js create mode 100644 quantdinger_vue/src/store/modules/app.js create mode 100644 quantdinger_vue/src/store/modules/async-router.js create mode 100644 quantdinger_vue/src/store/modules/static-router.js create mode 100644 quantdinger_vue/src/store/modules/user.js create mode 100644 quantdinger_vue/src/store/mutation-types.js create mode 100644 quantdinger_vue/src/utils/axios.js create mode 100644 quantdinger_vue/src/utils/codeDecrypt.js create mode 100644 quantdinger_vue/src/utils/domUtil.js create mode 100644 quantdinger_vue/src/utils/filter.js create mode 100644 quantdinger_vue/src/utils/request.js create mode 100644 quantdinger_vue/src/utils/routeConvert.js create mode 100644 quantdinger_vue/src/utils/screenLog.js create mode 100644 quantdinger_vue/src/utils/util.js create mode 100644 quantdinger_vue/src/utils/utils.less create mode 100644 quantdinger_vue/src/views/404.vue create mode 100644 quantdinger_vue/src/views/ai-analysis/components/index.vue create mode 100644 quantdinger_vue/src/views/ai-analysis/index.vue create mode 100644 quantdinger_vue/src/views/dashboard/index.vue create mode 100644 quantdinger_vue/src/views/exception/403.vue create mode 100644 quantdinger_vue/src/views/exception/404.vue create mode 100644 quantdinger_vue/src/views/exception/500.vue create mode 100644 quantdinger_vue/src/views/indicator-analysis/components/BacktestHistoryDrawer.vue create mode 100644 quantdinger_vue/src/views/indicator-analysis/components/BacktestModal.vue create mode 100644 quantdinger_vue/src/views/indicator-analysis/components/BacktestRunViewer.vue create mode 100644 quantdinger_vue/src/views/indicator-analysis/components/IndicatorEditor.vue create mode 100644 quantdinger_vue/src/views/indicator-analysis/components/KlineChart.vue create mode 100644 quantdinger_vue/src/views/indicator-analysis/index.vue create mode 100644 quantdinger_vue/src/views/indicator-community/index.vue create mode 100644 quantdinger_vue/src/views/trading-assistant/components/AIDecisionRecords.vue create mode 100644 quantdinger_vue/src/views/trading-assistant/components/PositionRecords.vue create mode 100644 quantdinger_vue/src/views/trading-assistant/components/TradingRecords.vue create mode 100644 quantdinger_vue/src/views/trading-assistant/index.vue create mode 100644 quantdinger_vue/src/views/user/Login.vue create mode 100644 quantdinger_vue/src/views/user/RegisterResult.vue create mode 100644 quantdinger_vue/tests/unit/.eslintrc.js create mode 100644 quantdinger_vue/vue.config.js create mode 100644 quantdinger_vue/yarn.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2adf5b8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,203 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [quantdinger.com] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..812ce84 --- /dev/null +++ b/README.md @@ -0,0 +1,175 @@ +
+ QuantDinger +

QuantDinger

+

+ A local-first quant research & trading workspace: market data, indicators, AI analysis, backtesting, and strategy execution in one place. +

+

+ Website + · + Live Demo +

+

+ License + Backend + Frontend + Demo +

+
+ +--- + +This repository is intentionally simple (no PHP gateway): it contains **one Python backend** and **one web UI**. + +- **`backend_api_python/`**: Flask API + strategy runtime + AI agents +- **`quantdinger_vue/`**: Vue 2 UI (Ant Design Vue based) with charts, backtests, and strategy management + +> This repo does not include real secrets. Configure API keys and credentials via `.env` / environment variables. + +--- + +### Why QuantDinger + +- **Local-first (SQLite) out of the box**: no external database required to run a full workflow locally. +- **AI research team in code**: multi-agent analysis produces structured reports (with optional web search + LLMs). +- **Multi-market data layer**: a factory-based data source abstraction for crypto, US stocks, CN/HK stocks, forex, and futures. +- **From signals to execution**: strategy runtime + a pending-order worker to dispatch queued actions reliably. + +--- + +### Highlights (What You Get) + +- **Multi-market market data** + - Data source factory in `backend_api_python/app/data_sources/`. + - Optional proxy support for restricted networks (see `.env`). + +- **AI multi-agent analysis** + - Coordinator + role agents in `backend_api_python/app/services/agents/`. + - Optional web search (Google/Bing) and LLM access (OpenRouter). + +- **Indicator engine + backtesting** + - Indicator code storage with safe execution utilities. + - Backtest endpoints + persisted run history (SQLite). + +- **Strategy runtime** + - Thread-based executor with startup auto-restore (configurable). + - Background pending-order worker that polls queued orders and dispatches signals (can be disabled). + +- **Live trading integrations** + - Exchange execution adapters in `backend_api_python/app/services/live_trading/` (CCXT-based where applicable). + +- **Local auth (single-user)** + - Simple login (`/login`) with env-configured admin credentials (JWT token). + +--- + +### Architecture (Current Repo) + +```text +┌─────────────────────────────┐ +│ quantdinger_vue │ +│ (Vue 2 + Ant Design Vue) │ +└──────────────┬──────────────┘ + │ HTTP (/api/*) + ▼ +┌─────────────────────────────┐ +│ backend_api_python │ +│ (Flask + strategy runtime) │ +└──────────────┬──────────────┘ + │ + ├─ SQLite (quantdinger.db) + ├─ Redis (optional cache) + └─ Data providers / LLMs / Exchanges +``` + +--- + +### Repository Layout + +```text +. +├─ backend_api_python/ # Flask API + AI + backtest + strategy runtime +│ ├─ app/ +│ ├─ env.example # Copy to .env for local config +│ ├─ requirements.txt +│ └─ run.py # Entrypoint +└─ quantdinger_vue/ # Vue 2 UI (dev server proxies /api -> backend) +``` + +--- + +### Quick Start (Local Development) + +**Prerequisites** + +- Python 3.10+ recommended +- Node.js 16+ recommended + +#### 1) Start the backend (Flask API) + +```bash +cd backend_api_python +pip install -r requirements.txt +copy env.example .env # Windows PowerShell users can use: Copy-Item env.example .env +python run.py +``` + +Backend will be available at `http://localhost:5000`. + +#### 2) Start the frontend (Vue UI) + +```bash +cd quantdinger_vue +npm install +npm run serve +``` + +Frontend dev server runs at `http://localhost:8000` and proxies `/api/*` to `http://localhost:5000` (see `quantdinger_vue/vue.config.js`). + +--- + +### Configuration (.env) + +Use `backend_api_python/env.example` as a template. Common settings include: + +- **Auth**: `SECRET_KEY`, `ADMIN_USER`, `ADMIN_PASSWORD` +- **Server**: `PYTHON_API_HOST`, `PYTHON_API_PORT`, `PYTHON_API_DEBUG` +- **Database**: `SQLITE_DATABASE_FILE` (optional; default is `backend_api_python/quantdinger.db`) +- **AI / LLM**: `OPENROUTER_API_KEY`, `OPENROUTER_MODEL`, timeouts +- **Web search**: `SEARCH_PROVIDER`, `SEARCH_GOOGLE_*`, `SEARCH_BING_API_KEY` +- **Proxy (optional)**: `PROXY_PORT` or `PROXY_URL` +- **Workers**: `ENABLE_PENDING_ORDER_WORKER`, `DISABLE_RESTORE_RUNNING_STRATEGIES` + +--- + +### API + +The backend provides REST endpoints for login, market data, indicators, backtesting, strategies, and AI analysis. + +- Health: `GET /health` +- Auth: `POST /login`, `POST /logout`, `GET /info` + +For the full route list, see `backend_api_python/app/routes/`. + +--- + +### License + +Licensed under the **Apache License 2.0**. See `LICENSE`. + +--- + +### Acknowledgements + +QuantDinger stands on the shoulders of great open-source projects, including: + +- **Flask** / **flask-cors** +- **Pandas** +- **CCXT** +- **yfinance**, **akshare**, **requests** +- **Vue 2** and **Ant Design Vue** +- Charting libraries used in the UI (e.g., **KlineCharts**) + +Thanks to maintainers and contributors across these ecosystems. + + diff --git a/backend_api_python/.gitignore b/backend_api_python/.gitignore new file mode 100644 index 0000000..bd9d9dd --- /dev/null +++ b/backend_api_python/.gitignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# 日志 +*.log + +# 环境变量 +.env +.env.local + +# Local runtime data (do not commit) +quantdinger.db +data/ + diff --git a/backend_api_python/DEPLOY.md b/backend_api_python/DEPLOY.md new file mode 100644 index 0000000..998ab6b --- /dev/null +++ b/backend_api_python/DEPLOY.md @@ -0,0 +1,230 @@ +# Python API 部署说明 + +## 1. 环境准备 + +### 安装 Python 3.8+ + +```bash +# Ubuntu/Debian +sudo apt-get update +sudo apt-get install python3 python3-pip python3-venv + +# CentOS/RHEL +sudo yum install python3 python3-pip +``` + +### 创建虚拟环境(推荐) + +```bash +cd backend_api_python +python3 -m venv venv +source venv/bin/activate # Linux/Mac +# 或 +venv\Scripts\activate # Windows +``` + +### 安装依赖 + +```bash +pip install -r requirements.txt +``` + +## 2. 开发环境运行 + +```bash +python run.py +``` + +服务将在 `http://localhost:5000` 启动 + +## 3. 生产环境部署 + +### 使用 Gunicorn + +```bash +# 安装 gunicorn(已在 requirements.txt 中) +pip install gunicorn + +# 启动服务 +gunicorn -c gunicorn_config.py "run:app" + +# 或使用命令行参数 +gunicorn -w 4 -b 0.0.0.0:5000 --timeout 120 "run:app" +``` + +### 使用 Supervisor 管理进程 + +创建 `/etc/supervisor/conf.d/quantdinger_python_api.conf`: + +```ini +[program:quantdinger_python_api] +command=/path/to/venv/bin/gunicorn -c /path/to/gunicorn_config.py "run:app" +directory=/path/to/backend_api_python +user=www-data +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/path/to/logs/supervisor.log +``` + +启动: + +```bash +sudo supervisorctl reread +sudo supervisorctl update +sudo supervisorctl start quantdinger_python_api +``` + +### 使用 Systemd 管理服务 + +创建 `/etc/systemd/system/quantdinger-python-api.service`: + +```ini +[Unit] +Description=QuantDinger Python API Service +After=network.target + +[Service] +Type=notify +User=www-data +Group=www-data +WorkingDirectory=/path/to/backend_api_python +Environment="PATH=/path/to/venv/bin" +ExecStart=/path/to/venv/bin/gunicorn -c /path/to/gunicorn_config.py "run:app" +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +启动: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable quantdinger-python-api +sudo systemctl start quantdinger-python-api +sudo systemctl status quantdinger-python-api +``` + +## 4. Nginx 反向代理配置 + +在 Nginx 配置文件中添加: + +```nginx +upstream python_api { + server 127.0.0.1:5000; + keepalive 32; +} + +server { + listen 80; + server_name api-python.quantdinger.com; + + location / { + proxy_pass http://python_api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket 支持(如果需要) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # 超时设置 + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } +} +``` + +## 5. PHP 配置 +(已移除)本仓库当前不包含 PHP 网关/后台服务,前端开发环境通过 `quantdinger_vue/vue.config.js` 将 `/api` 代理到 Python 服务即可。 + +## 6. 日志管理 + +创建日志目录: + +```bash +mkdir -p logs +chmod 755 logs +``` + +日志文件: +- `logs/access.log` - 访问日志 +- `logs/error.log` - 错误日志 +- `logs/gunicorn.pid` - Gunicorn 进程ID + +## 7. 监控和健康检查 + +### 健康检查接口 + +```bash +curl http://localhost:5000/health +``` + +### 监控脚本示例 + +```bash +#!/bin/bash +# check_api.sh + +API_URL="http://localhost:5000/health" +RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" $API_URL) + +if [ $RESPONSE -ne 200 ]; then + echo "API 服务异常,状态码: $RESPONSE" + # 发送告警通知 + # 重启服务 + systemctl restart quantdinger-python-api +fi +``` + +## 8. 常见问题 + +### AKSHARE 安装失败 + +```bash +# 可能需要安装系统依赖 +sudo apt-get install build-essential +pip install akshare --upgrade +``` + +### 端口被占用 + +```bash +# 查看端口占用 +lsof -i :5000 +# 或 +netstat -tulpn | grep 5000 + +# 修改端口 +export PYTHON_API_PORT=5001 +``` + +### 权限问题 + +```bash +# 确保日志目录有写权限 +chown -R www-data:www-data logs/ +chmod -R 755 logs/ +``` + +## 9. 性能优化 + +1. **增加 Worker 数量**:根据 CPU 核心数调整 +2. **使用异步 Worker**:`worker_class = "gevent"`(需要安装 gevent) +3. **启用缓存**:使用 Redis 缓存指数数据 +4. **数据库连接池**:如果使用数据库,配置连接池 + +## 10. 安全建议 + +1. 使用 HTTPS +2. 配置防火墙规则 +3. 限制 API 访问频率 +4. 使用 API Key 认证(如果需要) +5. 定期更新依赖包 + diff --git a/backend_api_python/README.md b/backend_api_python/README.md new file mode 100644 index 0000000..600c47c --- /dev/null +++ b/backend_api_python/README.md @@ -0,0 +1,138 @@ +# QuantDinger Python API Server + +Python 后端服务,提供金融数据获取、技术指标分析、AI 智能体分析和回测功能。 + +## 项目结构 + +``` +backend_api_python/ +├── app/ # 应用主目录 +│ ├── __init__.py # Flask 应用工厂 +│ ├── config/ # 配置模块 ⭐ (支持数据库动态配置) +│ │ ├── __init__.py # 配置统一导出 +│ │ ├── settings.py # 服务配置 +│ │ ├── api_keys.py # API 密钥集中管理 +│ │ ├── database.py # 数据库/Redis/缓存配置 +│ │ └── data_sources.py # 数据源配置 +│ ├── data_sources/ # 数据源模块 +│ │ ├── base.py # 数据源基类 +│ │ ├── factory.py # 数据源工厂 +│ │ ├── crypto.py # 加密货币 (CCXT) +│ │ ├── us_stock.py # 美股 (yfinance/Finnhub) +│ │ ├── cn_stock.py # A股/港股 (yfinance/akshare) +│ │ └── futures.py # 期货数据源 +│ ├── services/ # 业务服务层 +│ │ ├── agents/ # AI 智能体系统 (多智能体架构) ⭐ +│ │ │ ├── coordinator.py # 智能体协调器 +│ │ │ ├── tools.py # 智能体工具集 (含搜索/数据获取) +│ │ │ ├── analyst_agents.py # 各类分析师 (技术/基本面/新闻等) +│ │ │ └── researcher_agents.py # 研究员 (多空辩论) +│ │ ├── kline.py # K线数据服务 +│ │ ├── search.py # 搜索服务 (Google/Bing) +│ │ ├── llm.py # LLM 调用封装 (OpenRouter) +│ │ └── analysis.py # 分析服务入口 +│ ├── routes/ # API 路由 +│ │ ├── health.py # 健康检查 +│ │ ├── kline.py # K线数据 API +│ │ ├── analysis.py # AI 分析 API +│ │ └── market.py # 市场数据 API +│ └── utils/ # 工具模块 +│ ├── config_loader.py # 数据库配置加载器 +│ ├── logger.py # 日志工具 +│ └── http.py # HTTP 请求 +├── run.py # 入口文件 ⭐ +├── gunicorn_config.py # Gunicorn 配置 +├── requirements.txt # 依赖列表 +├── start.sh # 启动脚本 +└── README.md +``` + +## 快速开始 + +### 1. 安装依赖 + +```bash +pip install -r requirements.txt +``` + +### 2. 数据库配置 + +本系统依赖 MySQL 数据库中的 `qd_addon_config` 表进行配置管理。请确保导入 `update_config_search.sql` 等 SQL 文件以初始化配置。 + +### 3. 启动服务 + +**开发环境:** +```bash +python run.py +``` + +**生产环境(使用 Gunicorn):** +```bash +gunicorn -c gunicorn_config.py "run:app" +``` + +服务将在 `http://localhost:5000` 启动 + +## 主要功能 + +### 1. 多维度 AI 分析 +基于多智能体架构 (Multi-Agent Architecture),模拟真实投研团队: +- **市场分析师**: 技术面分析 (K线, MACD, RSI, 均线) +- **基本面分析师**: 公司财务、估值、行业地位 +- **新闻分析师**: 实时新闻、舆情分析 (集成 Finnhub + Google/Bing 搜索) +- **情绪分析师**: 市场情绪评估 +- **风险分析师**: 波动率、流动性风险评估 +- **多空辩论**: 模拟看涨/看跌研究员辩论,提供平衡观点 + +### 2. 数据获取增强 +- **美股**: 实时行情 (Finnhub/yfinance) + 深度公司资料 +- **加密货币**: 实时行情 (CCXT/Binance) + 项目资讯搜索 +- **A股/港股**: 延迟行情 + 网络资讯搜索补充 + +### 3. 智能搜索集成 +支持 Google Custom Search 和 Bing Search,自动补全传统数据源缺失的信息(如公司简介、最新突发新闻)。 + +## API 接口 + +### 健康检查 +``` +GET /health +``` + +### K线数据 +``` +GET /api/indicator/kline +参数: market (Crypto/USStock/AShare), symbol, timeframe, limit +``` + +### AI 多维度分析 +``` +POST /api/analysis/multi +{ + "market": "USStock", + "symbol": "NVDA", + "language": "zh-CN" +} +``` + +## 配置说明 + +系统配置采用 **数据库 + 环境变量** 混合模式,支持热更新。 + +### 核心环境变量 (启动参数) +| 变量名 | 说明 | +|--------|------| +| PYTHON_API_HOST | 监听地址 | +| PYTHON_API_PORT | 监听端口 | +| MYSQL_HOST | MySQL 主机 | +| REDIS_HOST | Redis 主机 | + +### 数据库配置 (`qd_addon_config` 表) +大部分业务配置已迁移至数据库,支持动态调整: +- **API Keys**: Finnhub, Google Search, Bing Search +- **AI 模型**: OpenRouter 模型选择 +- **系统参数**: 超时时间、重试次数、缓存策略 + +## License +This project is released under the Apache License 2.0. +See the repository root `LICENSE` for details. diff --git a/backend_api_python/app/__init__.py b/backend_api_python/app/__init__.py new file mode 100644 index 0000000..722abbf --- /dev/null +++ b/backend_api_python/app/__init__.py @@ -0,0 +1,134 @@ +""" +QuantDinger Python API - Flask application factory. +""" +from flask import Flask +from flask_cors import CORS +import logging +import traceback + +from app.utils.logger import setup_logger, get_logger + +logger = get_logger(__name__) + +# Global singletons (avoid duplicate strategy threads). +_trading_executor = None +_pending_order_worker = None + + +def get_trading_executor(): + """Get the trading executor singleton.""" + global _trading_executor + if _trading_executor is None: + from app.services.trading_executor import TradingExecutor + _trading_executor = TradingExecutor() + return _trading_executor + + +def get_pending_order_worker(): + """Get the pending order worker singleton.""" + global _pending_order_worker + if _pending_order_worker is None: + from app.services.pending_order_worker import PendingOrderWorker + _pending_order_worker = PendingOrderWorker() + return _pending_order_worker + + +def start_pending_order_worker(): + """Start the pending order worker (disabled by default in paper mode). + + To enable it, set ENABLE_PENDING_ORDER_WORKER=true. + """ + import os + # Local deployment: default to enabled so queued orders can be dispatched automatically. + # To disable it, set ENABLE_PENDING_ORDER_WORKER=false explicitly. + if os.getenv('ENABLE_PENDING_ORDER_WORKER', 'true').lower() != 'true': + logger.info("Pending order worker is disabled (paper mode). Set ENABLE_PENDING_ORDER_WORKER=true to enable.") + return + try: + get_pending_order_worker().start() + except Exception as e: + logger.error(f"Failed to start pending order worker: {e}") + + +def restore_running_strategies(): + """ + Restore running strategies on startup. + Local deployment: only restores IndicatorStrategy. + """ + import os + # You can disable auto-restore to avoid starting many threads on low-resource hosts. + if os.getenv('DISABLE_RESTORE_RUNNING_STRATEGIES', 'false').lower() == 'true': + logger.info("Startup strategy restore is disabled via DISABLE_RESTORE_RUNNING_STRATEGIES") + return + try: + from app.services.strategy import StrategyService + + strategy_service = StrategyService() + trading_executor = get_trading_executor() + + running_strategies = strategy_service.get_running_strategies_with_type() + + if not running_strategies: + logger.info("No running strategies to restore.") + return + + logger.info(f"Restoring {len(running_strategies)} running strategies...") + + restored_count = 0 + for strategy_info in running_strategies: + strategy_id = strategy_info['id'] + strategy_type = strategy_info.get('strategy_type', '') + + try: + if strategy_type and strategy_type != 'IndicatorStrategy': + logger.info(f"Skip restore unsupported strategy type: id={strategy_id}, type={strategy_type}") + continue + + success = trading_executor.start_strategy(strategy_id) + strategy_type_name = 'IndicatorStrategy' + + if success: + restored_count += 1 + logger.info(f"[OK] {strategy_type_name} {strategy_id} restored") + else: + logger.warning(f"[FAIL] {strategy_type_name} {strategy_id} restore failed (state may be stale)") + except Exception as e: + logger.error(f"Error restoring strategy {strategy_id}: {str(e)}") + logger.error(traceback.format_exc()) + + logger.info(f"Strategy restore completed: {restored_count}/{len(running_strategies)} restored") + + except Exception as e: + logger.error(f"Failed to restore running strategies: {str(e)}") + logger.error(traceback.format_exc()) + # Do not raise; avoid breaking app startup. + + +def create_app(config_name='default'): + """ + Flask application factory. + + Args: + config_name: config name + + Returns: + Flask app + """ + app = Flask(__name__) + + app.config['JSON_AS_ASCII'] = False + + CORS(app) + + setup_logger() + + from app.routes import register_routes + register_routes(app) + + # Startup hooks. + with app.app_context(): + start_pending_order_worker() + restore_running_strategies() + + return app + diff --git a/backend_api_python/app/config/__init__.py b/backend_api_python/app/config/__init__.py new file mode 100644 index 0000000..34d3ec9 --- /dev/null +++ b/backend_api_python/app/config/__init__.py @@ -0,0 +1,36 @@ +""" +配置模块 +统一导出所有配置 +""" +from app.config.settings import Config +from app.config.api_keys import APIKeys +from app.config.database import RedisConfig, SQLiteConfig, CacheConfig +from app.config.data_sources import ( + DataSourceConfig, + FinnhubConfig, + TiingoConfig, + YFinanceConfig, + CCXTConfig, + AkshareConfig +) + +__all__ = [ + # 主配置 + 'Config', + + # API 密钥 + 'APIKeys', + + # 数据库/缓存 + 'RedisConfig', + 'SQLiteConfig', + 'CacheConfig', + + # 数据源 + 'DataSourceConfig', + 'FinnhubConfig', + 'TiingoConfig', + 'YFinanceConfig', + 'CCXTConfig', + 'AkshareConfig', +] diff --git a/backend_api_python/app/config/api_keys.py b/backend_api_python/app/config/api_keys.py new file mode 100644 index 0000000..c7982b9 --- /dev/null +++ b/backend_api_python/app/config/api_keys.py @@ -0,0 +1,44 @@ +""" +API key configuration. +All third-party keys should be provided via environment variables (recommended: backend_api_python/.env). +""" +import os + +class MetaAPIKeys(type): + """API Keys 元类,用于支持类属性的动态获取""" + + @property + def FINNHUB_API_KEY(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('finnhub', {}).get('api_key') + return val if val else os.getenv('FINNHUB_API_KEY', '') + + @property + def TIINGO_API_KEY(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('tiingo', {}).get('api_key') + return val if val else os.getenv('TIINGO_API_KEY', '') + + @property + def OPENROUTER_API_KEY(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('openrouter', {}).get('api_key') + return val if val else os.getenv('OPENROUTER_API_KEY', '') + + +class APIKeys(metaclass=MetaAPIKeys): + """API 密钥配置类""" + + @classmethod + def get(cls, key_name: str, default: str = '') -> str: + """获取 API 密钥""" + # 尝试从类属性获取 + if hasattr(cls, key_name): + return getattr(cls, key_name) + return default + + @classmethod + def is_configured(cls, key_name: str) -> bool: + """检查 API 密钥是否已配置""" + value = cls.get(key_name) + return bool(value and value.strip()) diff --git a/backend_api_python/app/config/data_sources.py b/backend_api_python/app/config/data_sources.py new file mode 100644 index 0000000..542f348 --- /dev/null +++ b/backend_api_python/app/config/data_sources.py @@ -0,0 +1,187 @@ +""" +数据源配置 +""" +import os + +class MetaDataSourceConfig(type): + @property + def DEFAULT_TIMEOUT(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('data_source', {}).get('timeout') + return int(val) if val is not None else int(os.getenv('DATA_SOURCE_TIMEOUT', 30)) + + @property + def RETRY_COUNT(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('data_source', {}).get('retry_count') + return int(val) if val is not None else int(os.getenv('DATA_SOURCE_RETRY', 3)) + + @property + def RETRY_BACKOFF(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('data_source', {}).get('retry_backoff') + return float(val) if val is not None else float(os.getenv('DATA_SOURCE_RETRY_BACKOFF', 0.5)) + + +class DataSourceConfig(metaclass=MetaDataSourceConfig): + """数据源通用配置""" + pass + + +class MetaFinnhubConfig(type): + @property + def BASE_URL(cls): + return "https://finnhub.io/api/v1" + + @property + def TIMEOUT(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('finnhub', {}).get('timeout') + return int(val) if val is not None else int(os.getenv('FINNHUB_TIMEOUT', 10)) + + @property + def RATE_LIMIT(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('finnhub', {}).get('rate_limit') + return int(val) if val is not None else int(os.getenv('FINNHUB_RATE_LIMIT', 60)) + + @property + def RATE_LIMIT_PERIOD(cls): + return 60 + + +class FinnhubConfig(metaclass=MetaFinnhubConfig): + """Finnhub 数据源配置""" + pass + + +class MetaTiingoConfig(type): + @property + def BASE_URL(cls): + return "https://api.tiingo.com/tiingo" + + @property + def TIMEOUT(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('tiingo', {}).get('timeout') + return int(val) if val is not None else int(os.getenv('TIINGO_TIMEOUT', 10)) + + +class TiingoConfig(metaclass=MetaTiingoConfig): + """Tiingo 数据源配置""" + pass + + +class MetaYFinanceConfig(type): + @property + def TIMEOUT(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('yfinance', {}).get('timeout') + return int(val) if val is not None else int(os.getenv('YFINANCE_TIMEOUT', 30)) + + @property + def INTERVAL_MAP(cls): + return { + '1m': '1m', + '5m': '5m', + '15m': '15m', + '30m': '30m', + '1H': '1h', + '4H': '4h', + '1D': '1d', + '1W': '1wk' + } + + +class YFinanceConfig(metaclass=MetaYFinanceConfig): + """Yahoo Finance 数据源配置""" + pass + + +class MetaCCXTConfig(type): + @property + def DEFAULT_EXCHANGE(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('ccxt', {}).get('default_exchange') + return val if val else os.getenv('CCXT_DEFAULT_EXCHANGE', 'binance') + + @property + def TIMEOUT(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('ccxt', {}).get('timeout') + return int(val) if val is not None else int(os.getenv('CCXT_TIMEOUT', 10000)) + + @property + def ENABLE_RATE_LIMIT(cls): + return True + + @property + def TIMEFRAME_MAP(cls): + return { + '1m': '1m', + '5m': '5m', + '15m': '15m', + '30m': '30m', + '1H': '1h', + '4H': '4h', + '1D': '1d', + '1W': '1w' + } + + @property + def PROXY(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('ccxt', {}).get('proxy') + if val: + return val + + # 1) Direct CCXT proxy env + ccxt_proxy = (os.getenv('CCXT_PROXY') or '').strip() + if ccxt_proxy: + return ccxt_proxy + + # 2) Local proxy helpers from backend_api_python/.env + # PROXY_URL has the highest priority if provided. + proxy_url = (os.getenv('PROXY_URL') or '').strip() + if proxy_url: + return proxy_url + + # Build from parts: PROXY_SCHEME/PROXY_HOST/PROXY_PORT + proxy_port = (os.getenv('PROXY_PORT') or '').strip() + if proxy_port: + proxy_scheme = (os.getenv('PROXY_SCHEME') or 'socks5h').strip() + proxy_host = (os.getenv('PROXY_HOST') or '127.0.0.1').strip() + return f"{proxy_scheme}://{proxy_host}:{proxy_port}" + + # 3) Standard proxy envs (fallback) + for key in ['HTTPS_PROXY', 'HTTP_PROXY', 'ALL_PROXY']: + v = (os.getenv(key) or '').strip() + if v: + return v + + return '' + + +class CCXTConfig(metaclass=MetaCCXTConfig): + """CCXT 加密货币数据源配置""" + pass + + +class MetaAkshareConfig(type): + @property + def TIMEOUT(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('akshare', {}).get('timeout') + return int(val) if val is not None else int(os.getenv('AKSHARE_TIMEOUT', 30)) + + @property + def PERIOD_MAP(cls): + return { + '1D': 'daily', + '1W': 'weekly' + } + + +class AkshareConfig(metaclass=MetaAkshareConfig): + """Akshare 数据源配置""" + pass diff --git a/backend_api_python/app/config/database.py b/backend_api_python/app/config/database.py new file mode 100644 index 0000000..540ff95 --- /dev/null +++ b/backend_api_python/app/config/database.py @@ -0,0 +1,109 @@ +""" +数据库和缓存配置 +""" +import os + +class MetaRedisConfig(type): + """Redis 配置""" + + @property + def HOST(cls): + return os.getenv('REDIS_HOST', 'localhost') + + @property + def PORT(cls): + return int(os.getenv('REDIS_PORT', 6379)) + + @property + def PASSWORD(cls): + return os.getenv('REDIS_PASSWORD', None) + + @property + def DB(cls): + return int(os.getenv('REDIS_DB', 0)) + + @property + def CONNECT_TIMEOUT(cls): + return int(os.getenv('REDIS_CONNECT_TIMEOUT', 5)) + + @property + def SOCKET_TIMEOUT(cls): + return int(os.getenv('REDIS_SOCKET_TIMEOUT', 5)) + + @property + def MAX_CONNECTIONS(cls): + return int(os.getenv('REDIS_MAX_CONNECTIONS', 10)) + + +class RedisConfig(metaclass=MetaRedisConfig): + """Redis 缓存配置""" + + @classmethod + def get_url(cls) -> str: + """获取 Redis 连接 URL""" + if cls.PASSWORD: + return f"redis://:{cls.PASSWORD}@{cls.HOST}:{cls.PORT}/{cls.DB}" + return f"redis://{cls.HOST}:{cls.PORT}/{cls.DB}" + + +class MetaSQLiteConfig(type): + """SQLite 配置""" + + @property + def DATABASE_FILE(cls): + # 默认放在 backend_api_python 根目录 + base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + default_path = os.path.join(base_dir, 'quantdinger.db') + return os.getenv('SQLITE_DATABASE_FILE', default_path) + + +class SQLiteConfig(metaclass=MetaSQLiteConfig): + """SQLite 数据库配置""" + + @classmethod + def get_path(cls) -> str: + """获取数据库文件路径""" + return cls.DATABASE_FILE + + +class MetaCacheConfig(type): + """缓存业务配置""" + + @property + def ENABLED(cls): + # 强制默认关闭,除非环境变量显式开启 + return os.getenv('CACHE_ENABLED', 'False').lower() == 'true' + + @property + def DEFAULT_EXPIRE(cls): + return int(os.getenv('CACHE_EXPIRE', 300)) + + @property + def KLINE_CACHE_TTL(cls): + return { + '1m': 5, # 1分钟K线缓存5秒 + '3m': 30, # 3分钟K线缓存30秒 + '5m': 60, # 5分钟K线缓存1分钟 + '15m': 300, # 15分钟K线缓存5分钟 + '30m': 300, # 30分钟K线缓存5分钟 + '1H': 300, # 1小时K线缓存5分钟 + '4H': 300, # 4小时K线缓存5分钟 + '1D': 300, # 日K线缓存5分钟 + # 兼容小写 + '1h': 300, + '4h': 300, + '1d': 300, + } + + @property + def ANALYSIS_CACHE_TTL(cls): + return 3600 + + @property + def PRICE_CACHE_TTL(cls): + return 10 + + +class CacheConfig(metaclass=MetaCacheConfig): + """缓存配置""" + pass diff --git a/backend_api_python/app/config/settings.py b/backend_api_python/app/config/settings.py new file mode 100644 index 0000000..63bf82a --- /dev/null +++ b/backend_api_python/app/config/settings.py @@ -0,0 +1,113 @@ +""" +应用主配置 +""" +import os + +class MetaConfig(type): + # ==================== 服务配置 ==================== + # 服务启动参数通常由环境变量或命令行参数决定,不建议从数据库读取 + + @property + def HOST(cls): + return os.getenv('PYTHON_API_HOST', '0.0.0.0') + + @property + def PORT(cls): + return int(os.getenv('PYTHON_API_PORT', 5000)) + + @property + def DEBUG(cls): + return os.getenv('PYTHON_API_DEBUG', 'False').lower() == 'true' + + @property + def APP_NAME(cls): + return 'QuantDinger Python API' + + @property + def VERSION(cls): + return '2.0.0' + + # ==================== 认证配置 ==================== + @property + def SECRET_KEY(cls): + return os.getenv('SECRET_KEY', 'quantdinger-secret-key-change-me') + + @property + def ADMIN_USER(cls): + return os.getenv('ADMIN_USER', 'quantdinger') + + @property + def ADMIN_PASSWORD(cls): + return os.getenv('ADMIN_PASSWORD', '123456') + + # ==================== 日志配置 ==================== + # 日志配置通常在应用启动最早阶段需要,建议保持环境变量 + + @property + def LOG_LEVEL(cls): + return os.getenv('LOG_LEVEL', 'INFO') + + @property + def LOG_DIR(cls): + return os.getenv('LOG_DIR', 'logs') + + @property + def LOG_FILE(cls): + return os.getenv('LOG_FILE', 'app.log') + + @property + def LOG_MAX_BYTES(cls): + return int(os.getenv('LOG_MAX_BYTES', 10 * 1024 * 1024)) + + @property + def LOG_BACKUP_COUNT(cls): + return int(os.getenv('LOG_BACKUP_COUNT', 5)) + + # ==================== 安全配置 ==================== + + @property + def CORS_ORIGINS(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('app', {}).get('cors_origins') + return val if val else os.getenv('CORS_ORIGINS', '*') + + @property + def RATE_LIMIT(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('app', {}).get('rate_limit') + return int(val) if val is not None else int(os.getenv('RATE_LIMIT', 100)) + + # ==================== 功能开关 ==================== + + @property + def ENABLE_CACHE(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('app', {}).get('enable_cache') + if val is not None: + return bool(val) + return os.getenv('ENABLE_CACHE', 'False').lower() == 'true' + + @property + def ENABLE_REQUEST_LOG(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('app', {}).get('enable_request_log') + if val is not None: + return bool(val) + return os.getenv('ENABLE_REQUEST_LOG', 'True').lower() == 'true' + + @property + def ENABLE_AI_ANALYSIS(cls): + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('app', {}).get('enable_ai_analysis') + if val is not None: + return bool(val) + return os.getenv('ENABLE_AI_ANALYSIS', 'True').lower() == 'true' + + +class Config(metaclass=MetaConfig): + """应用配置类""" + + @classmethod + def get_log_path(cls) -> str: + """获取日志文件完整路径""" + return os.path.join(cls.LOG_DIR, cls.LOG_FILE) diff --git a/backend_api_python/app/data_sources/__init__.py b/backend_api_python/app/data_sources/__init__.py new file mode 100644 index 0000000..22ac2a6 --- /dev/null +++ b/backend_api_python/app/data_sources/__init__.py @@ -0,0 +1,8 @@ +""" +数据源模块 +支持多种市场的K线数据获取 +""" +from app.data_sources.factory import DataSourceFactory + +__all__ = ['DataSourceFactory'] + diff --git a/backend_api_python/app/data_sources/base.py b/backend_api_python/app/data_sources/base.py new file mode 100644 index 0000000..5eff84f --- /dev/null +++ b/backend_api_python/app/data_sources/base.py @@ -0,0 +1,154 @@ +""" +数据源基类 +定义统一的数据源接口 +""" +from abc import ABC, abstractmethod +from typing import Dict, List, Any, Optional +from datetime import datetime, timedelta + +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +# K线周期映射(秒数) +TIMEFRAME_SECONDS = { + '1m': 60, + '5m': 300, + '15m': 900, + '30m': 1800, + '1H': 3600, + '4H': 14400, + '1D': 86400, + '1W': 604800 +} + + +class BaseDataSource(ABC): + """数据源基类""" + + name: str = "base" + + @abstractmethod + def get_kline( + self, + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None + ) -> List[Dict[str, Any]]: + """ + 获取K线数据 + + Args: + symbol: 交易对/股票代码 + timeframe: 时间周期 (1m, 5m, 15m, 30m, 1H, 4H, 1D, 1W) + limit: 数据条数 + before_time: 获取此时间之前的数据(Unix时间戳,秒) + + Returns: + K线数据列表,格式: + [{"time": int, "open": float, "high": float, "low": float, "close": float, "volume": float}, ...] + """ + pass + + def get_ticker(self, symbol: str) -> Dict[str, Any]: + """ + Get latest ticker for a symbol (best-effort). + + This is an optional interface used by the strategy executor for fetching current price. + Implementations may return a dict compatible with CCXT `fetch_ticker` shape (e.g. {'last': ...}). + """ + raise NotImplementedError("get_ticker is not implemented for this data source") + + def format_kline( + self, + timestamp: int, + open_price: float, + high: float, + low: float, + close: float, + volume: float + ) -> Dict[str, Any]: + """格式化单条K线数据""" + return { + 'time': timestamp, + 'open': round(float(open_price), 4), + 'high': round(float(high), 4), + 'low': round(float(low), 4), + 'close': round(float(close), 4), + 'volume': round(float(volume), 2) + } + + def calculate_time_range( + self, + timeframe: str, + limit: int, + buffer_ratio: float = 1.2 + ) -> int: + """ + 计算获取指定数量K线所需的时间范围(秒) + + Args: + timeframe: 时间周期 + limit: K线数量 + buffer_ratio: 缓冲系数 + + Returns: + 时间范围(秒) + """ + seconds_per_candle = TIMEFRAME_SECONDS.get(timeframe, 86400) + return int(seconds_per_candle * limit * buffer_ratio) + + def filter_and_limit( + self, + klines: List[Dict[str, Any]], + limit: int, + before_time: Optional[int] = None + ) -> List[Dict[str, Any]]: + """ + 过滤和限制K线数据 + + Args: + klines: K线数据列表 + limit: 最大数量 + before_time: 过滤此时间之后的数据 + + Returns: + 处理后的K线数据 + """ + # 按时间排序 + klines.sort(key=lambda x: x['time']) + + # 过滤时间 + if before_time: + klines = [k for k in klines if k['time'] < before_time] + + # 限制数量(取最新的) + if len(klines) > limit: + klines = klines[-limit:] + + return klines + + def log_result( + self, + symbol: str, + klines: List[Dict[str, Any]], + timeframe: str + ): + """记录获取结果日志""" + if klines: + latest_time = datetime.fromtimestamp(klines[-1]['time']) + time_diff = (datetime.now() - latest_time).total_seconds() + # logger.info( + # f"{self.name}: {symbol} 获取 {len(klines)} 条数据, " + # f"最新时间: {latest_time}, 延迟: {time_diff:.0f}秒" + # ) + + # 检查数据是否过旧 + max_diff = TIMEFRAME_SECONDS.get(timeframe, 3600) * 2 + if time_diff > max_diff: + logger.warning(f"Warning: {symbol} data is delayed ({time_diff:.0f}s)") + else: + logger.warning(f"{self.name}: no data for {symbol}") + diff --git a/backend_api_python/app/data_sources/cn_stock.py b/backend_api_python/app/data_sources/cn_stock.py new file mode 100644 index 0000000..bb6d603 --- /dev/null +++ b/backend_api_python/app/data_sources/cn_stock.py @@ -0,0 +1,615 @@ +""" +CN/HK stock data source. +Supports A-Share and H-Share with multiple public sources. +Priority (AShare): Eastmoney (intraday/daily) > yfinance (daily) > akshare (daily, optional). +Priority (HShare): Tencent (intraday) > Eastmoney/Tencent (daily) > yfinance (daily) > akshare (daily, optional). +""" +import json +from typing import Dict, List, Any, Optional +from datetime import datetime, timedelta +import requests + +import yfinance as yf + +from app.data_sources.base import BaseDataSource +from app.data_sources.us_stock import USStockDataSource +from app.utils.logger import get_logger +from app.utils.http import get_retry_session + +logger = get_logger(__name__) + +# Optional dependency: akshare +try: + import akshare as ak # type: ignore + HAS_AKSHARE = True + logger.debug("akshare is available") +except ImportError: + HAS_AKSHARE = False + # Keep it quiet to avoid noisy startup logs on Windows. + logger.debug("akshare is not installed; akshare-based features are disabled") + + +class TencentDataMixin: + """Tencent quote API mixin (mostly for H-Share and legacy fallback).""" + + # 腾讯 K 线周期映射(注意:腾讯分钟级接口不支持240分钟,4H需要特殊处理) + TENCENT_PERIOD_MAP = { + '1m': 1, + '5m': 5, + '15m': 15, + '30m': 30, + '1H': 60, + '1D': 'day', + '1W': 'week' + } + + def _fetch_tencent_kline( + self, + symbol_code: str, + timeframe: str, + limit: int + ) -> List[Dict[str, Any]]: + """ + 使用腾讯财经接口获取K线数据 + + Args: + symbol_code: 腾讯格式的代码 (sh600000, sz000001, hk00700) + timeframe: 时间周期 + limit: 数据条数 + """ + klines = [] + + # 4H 需要特殊处理:获取1H数据然后聚合 + if timeframe == '4H': + return self._fetch_and_aggregate_4h(symbol_code, limit) + + try: + period = self.TENCENT_PERIOD_MAP.get(timeframe) + if period is None: + logger.warning(f"Unsupported timeframe: {timeframe}") + return [] + + # 构建请求URL + if isinstance(period, int): + # 分钟级数据 + url = f"http://ifzq.gtimg.cn/appstock/app/kline/mkline?param={symbol_code},m{period},,{limit}" + else: + # 日线/周线数据 + url = f"http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={symbol_code},{period},,,{limit},qfq" + + # logger.info(f"腾讯财经请求: {symbol_code}, 周期: {timeframe}, URL: {url[:80]}...") + + session = get_retry_session() + response = session.get(url, timeout=10) + + if response.status_code != 200: + logger.warning(f"Tencent quote returned status: {response.status_code}") + return [] + + data = response.json() + + # 解析响应数据 + if data.get('code') == 0 and 'data' in data: + stock_data = data['data'].get(symbol_code) + if stock_data: + # 分钟级数据格式 + if isinstance(period, int): + candles = stock_data.get(f'm{period}', []) + else: + # 日线/周线数据格式 + candles = stock_data.get('qfqday', stock_data.get('day', [])) + + for candle in candles: + if len(candle) >= 5: + # 解析时间 + time_str = str(candle[0]) + try: + if len(time_str) == 12: # 分钟级: 202411301430 + dt = datetime.strptime(time_str, '%Y%m%d%H%M') + elif len(time_str) == 10: # 日线: 2024-11-30 + dt = datetime.strptime(time_str, '%Y-%m-%d') + else: + continue + + klines.append(self.format_kline( + timestamp=int(dt.timestamp()), + open_price=float(candle[1]), + high=float(candle[3]), + low=float(candle[4]), + close=float(candle[2]), + volume=float(candle[5]) if len(candle) > 5 else 0 + )) + except (ValueError, IndexError) as e: + logger.debug(f"Failed to parse kline candle: {candle}, error: {e}") + continue + + # logger.info(f"腾讯财经返回 {len(klines)} 条数据") + else: + logger.warning(f"Tencent quote returned unexpected data: code={data.get('code')}") + + except Exception as e: + logger.error(f"Tencent quote fetch failed: {e}") + import traceback + logger.error(traceback.format_exc()) + + return klines + + def _fetch_and_aggregate_4h(self, symbol_code: str, limit: int) -> List[Dict[str, Any]]: + """获取1H数据并聚合为4H""" + # 获取足够多的1H数据 + hour_klines = self._fetch_tencent_kline(symbol_code, '1H', limit * 4 + 10) + + if not hour_klines: + return [] + + # 按4小时聚合 + aggregated = [] + i = 0 + while i < len(hour_klines): + # 取4根K线 + batch = hour_klines[i:i+4] + if len(batch) < 4: + break + + aggregated.append(self.format_kline( + timestamp=batch[0]['time'], + open_price=batch[0]['open'], + high=max(k['high'] for k in batch), + low=min(k['low'] for k in batch), + close=batch[-1]['close'], + volume=sum(k['volume'] for k in batch) + )) + i += 4 + + # logger.info(f"聚合生成 {len(aggregated)} 条 4H 数据") + return aggregated[-limit:] if len(aggregated) > limit else aggregated + + +class AShareDataSource(BaseDataSource, TencentDataMixin): + """A-Share data source.""" + + name = "AShare" + + # akshare 时间周期映射 + AKSHARE_PERIOD_MAP = { + '1D': 'daily', + '1W': 'weekly' + } + + # 东方财富 K 线周期映射 + EM_PERIOD_MAP = { + '1m': '1', + '5m': '5', + '15m': '15', + '30m': '30', + '1H': '60', + '4H': '240', + '1D': '101', + '1W': '102', + } + + def __init__(self): + self.us_stock_source = USStockDataSource() + + def get_kline( + self, + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None + ) -> List[Dict[str, Any]]: + """Fetch A-Share Kline data.""" + klines = [] + + # Prefer Eastmoney (supports most intraday timeframes) + klines = self._fetch_eastmoney_ashare(symbol, timeframe, limit) + if klines: + klines = self.filter_and_limit(klines, limit, before_time) + self.log_result(symbol, klines, timeframe) + return klines + + # Fallback: yfinance (daily/weekly) + if timeframe in ('1D', '1W'): + yahoo_symbol = self._to_yahoo_symbol(symbol) + if yahoo_symbol: + # logger.info(f"尝试使用 yfinance 获取A股: {yahoo_symbol}") + klines = self.us_stock_source.get_kline(yahoo_symbol, timeframe, limit, before_time) + if klines: + # logger.info(f"yfinance 成功获取 {len(klines)} 条A股数据") + return klines + + # Fallback: akshare (daily/weekly) + if HAS_AKSHARE and timeframe in self.AKSHARE_PERIOD_MAP: + klines = self._fetch_akshare(symbol, timeframe, limit, before_time) + if klines: + return klines + + logger.warning(f"AShare {symbol} data fetch failed") + return klines + + def _to_tencent_symbol(self, symbol: str) -> Optional[str]: + """转换为腾讯财经格式""" + if symbol.startswith('6'): + return f"sh{symbol}" + elif symbol.startswith('0') or symbol.startswith('3'): + return f"sz{symbol}" + elif symbol.startswith('4') or symbol.startswith('8'): + return f"bj{symbol}" # 北交所 + return None + + def _to_yahoo_symbol(self, symbol: str) -> Optional[str]: + """转换为 Yahoo Finance 格式""" + if symbol.startswith('6'): + return f"{symbol}.SS" + elif symbol.startswith('0') or symbol.startswith('3'): + return f"{symbol}.SZ" + elif symbol.startswith('4') or symbol.startswith('8'): + return f"{symbol}.BJ" + return None + + def _fetch_eastmoney_ashare( + self, + symbol: str, + timeframe: str, + limit: int + ) -> List[Dict[str, Any]]: + """使用东方财富获取A股数据""" + klines = [] + + period = self.EM_PERIOD_MAP.get(timeframe) + if not period: + logger.warning(f"Eastmoney unsupported timeframe: {timeframe}") + return [] + + try: + # 确定市场代码: 上海=1, 深圳=0, 北交所=0 + if symbol.startswith('6'): + secid = f"1.{symbol}" + else: + secid = f"0.{symbol}" + + # 东方财富K线接口 + url = f"https://push2his.eastmoney.com/api/qt/stock/kline/get" + params = { + 'secid': secid, + 'fields1': 'f1,f2,f3,f4,f5,f6', + 'fields2': 'f51,f52,f53,f54,f55,f56,f57', + 'klt': period, + 'fqt': '1', # 前复权 + 'end': '20500101', + 'lmt': limit, + } + + # logger.info(f"东方财富A股请求: {symbol}, 周期: {timeframe}") + + # 添加浏览器请求头 + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': 'https://quote.eastmoney.com/', + 'Accept': 'application/json, text/plain, */*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + } + + session = get_retry_session() + response = session.get(url, params=params, headers=headers, timeout=15) + + if response.status_code != 200: + logger.warning(f"Eastmoney HTTP status: {response.status_code}") + return [] + + data = response.json() + + # 解析响应 + if data.get('data') and data['data'].get('klines'): + for line in data['data']['klines']: + try: + parts = line.split(',') + if len(parts) >= 6: + time_str = parts[0] + if ' ' in time_str: + dt = datetime.strptime(time_str, '%Y-%m-%d %H:%M') + else: + dt = datetime.strptime(time_str, '%Y-%m-%d') + + klines.append(self.format_kline( + timestamp=int(dt.timestamp()), + open_price=float(parts[1]), + high=float(parts[3]), + low=float(parts[4]), + close=float(parts[2]), + volume=float(parts[5]) + )) + except (ValueError, IndexError) as e: + logger.debug(f"Failed to parse Eastmoney data line: {line}, error: {e}") + continue + + # logger.info(f"东方财富返回 {len(klines)} 条A股数据") + else: + logger.warning("Eastmoney returned no data") + + except Exception as e: + logger.error(f"Eastmoney A-share fetch failed: {e}") + import traceback + logger.error(traceback.format_exc()) + + return klines + + def _fetch_akshare( + self, + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] + ) -> List[Dict[str, Any]]: + """使用 akshare 获取数据""" + klines = [] + + try: + period = self.AKSHARE_PERIOD_MAP.get(timeframe, 'daily') + + # 计算日期范围 + if before_time: + end_date = datetime.fromtimestamp(before_time).strftime('%Y%m%d') + else: + end_date = datetime.now().strftime('%Y%m%d') + + days = limit * 2 if timeframe == '1D' else limit * 10 + start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d') + + # logger.info(f"使用 akshare 获取A股: {symbol}, 周期: {period}") + + df = ak.stock_zh_a_hist( + symbol=symbol, + period=period, + start_date=start_date, + end_date=end_date, + adjust="qfq" # 前复权 + ) + + if df is not None and not df.empty: + df = df.tail(limit) + for _, row in df.iterrows(): + ts = int(datetime.strptime(str(row['日期']), '%Y-%m-%d').timestamp()) + klines.append(self.format_kline( + timestamp=ts, + open_price=row['开盘'], + high=row['最高'], + low=row['最低'], + close=row['收盘'], + volume=row['成交量'] + )) + # logger.info(f"akshare 返回 {len(klines)} 条A股数据") + + except Exception as e: + logger.error(f"Akshare A-share fetch failed: {e}") + import traceback + logger.error(traceback.format_exc()) + + return klines + + +class HShareDataSource(BaseDataSource, TencentDataMixin): + """港股数据源""" + + name = "HShare" + + def __init__(self): + self.us_stock_source = USStockDataSource() + + def get_kline( + self, + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None + ) -> List[Dict[str, Any]]: + """获取港股K线数据""" + klines = [] + + # 方案1: 腾讯财经 (港股日线/周线首选,稳定可靠) + if timeframe in ('1D', '1W'): + tencent_symbol = self._to_tencent_symbol(symbol) + if tencent_symbol: + # logger.info(f"尝试使用腾讯财经获取港股: {tencent_symbol}") + klines = self._fetch_tencent_kline(tencent_symbol, timeframe, limit) + if klines: + klines = self.filter_and_limit(klines, limit, before_time) + self.log_result(symbol, klines, timeframe) + return klines + + # 方案2: 东方财富 (支持所有周期,但可能有地域限制) + klines = self._fetch_eastmoney_kline(symbol, timeframe, limit) + if klines: + klines = self.filter_and_limit(klines, limit, before_time) + self.log_result(symbol, klines, timeframe) + return klines + + # 方案3: 尝试 yfinance (日线级别备选) + if timeframe in ('1D', '1W'): + yahoo_symbol = self._to_yahoo_symbol(symbol) + if yahoo_symbol: + # logger.info(f"尝试使用 yfinance 获取港股: {yahoo_symbol}") + klines = self.us_stock_source.get_kline(yahoo_symbol, timeframe, limit, before_time) + if klines: + # logger.info(f"yfinance 成功获取 {len(klines)} 条港股数据") + return klines + + # 方案4: 尝试 akshare (日线级别) + if HAS_AKSHARE and timeframe in ('1D', '1W'): + klines = self._fetch_akshare(symbol, timeframe, limit, before_time) + if klines: + return klines + + # 分钟级数据获取失败提示 + if timeframe not in ('1D', '1W'): + logger.warning(f"HK stock {symbol}: minute-level data is not supported (data source limitations)") + else: + logger.warning(f"HK stock {symbol}: data fetch failed (timeframe: {timeframe})") + return klines + + def _to_tencent_symbol(self, symbol: str) -> str: + """转换为腾讯财经格式""" + # 港股代码补齐到5位 + padded = symbol.zfill(5) + return f"hk{padded}" + + def _to_yahoo_symbol(self, symbol: str) -> str: + """转换为 Yahoo Finance 格式""" + # 港股代码补齐到4位 + padded = symbol.zfill(4) + return f"{padded}.HK" + + def _fetch_eastmoney_kline( + self, + symbol: str, + timeframe: str, + limit: int + ) -> List[Dict[str, Any]]: + """使用东方财富获取港股分钟级数据""" + klines = [] + + # 东方财富 K 线周期映射 + em_period_map = { + '1m': '1', + '5m': '5', + '15m': '15', + '30m': '30', + '1H': '60', + '4H': '240', + '1D': '101', + '1W': '102', + } + + period = em_period_map.get(timeframe) + if not period: + logger.warning(f"Eastmoney unsupported timeframe: {timeframe}") + return [] + + try: + # 港股代码补齐到5位 + hk_symbol = symbol.zfill(5) + # 东方财富港股代码格式: 116.00700 (116是港股市场代码) + secid = f"116.{hk_symbol}" + + # 东方财富K线接口 + url = f"https://push2his.eastmoney.com/api/qt/stock/kline/get" + params = { + 'secid': secid, + 'fields1': 'f1,f2,f3,f4,f5,f6', + 'fields2': 'f51,f52,f53,f54,f55,f56,f57', + 'klt': period, # K线类型 + 'fqt': '1', # 前复权 + 'end': '20500101', + 'lmt': limit, + } + + # logger.info(f"东方财富港股请求: {hk_symbol}, 周期: {timeframe}") + + # 添加浏览器请求头,避免被拒绝 + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': 'https://quote.eastmoney.com/', + 'Accept': 'application/json, text/plain, */*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + } + + session = get_retry_session() + response = session.get(url, params=params, headers=headers, timeout=15) + + if response.status_code != 200: + logger.warning(f"Eastmoney HTTP status: {response.status_code}") + return [] + + data = response.json() + + # 解析响应 + if data.get('data') and data['data'].get('klines'): + for line in data['data']['klines']: + try: + # 格式: "2025-11-28 15:00,400.0,401.0,399.0,400.5,1000,100000" + # 日期,开盘,收盘,最高,最低,成交量,成交额 + parts = line.split(',') + if len(parts) >= 6: + time_str = parts[0] + # 解析时间 + if ' ' in time_str: + dt = datetime.strptime(time_str, '%Y-%m-%d %H:%M') + else: + dt = datetime.strptime(time_str, '%Y-%m-%d') + + klines.append(self.format_kline( + timestamp=int(dt.timestamp()), + open_price=float(parts[1]), + high=float(parts[3]), + low=float(parts[4]), + close=float(parts[2]), + volume=float(parts[5]) + )) + except (ValueError, IndexError) as e: + logger.debug(f"Failed to parse Eastmoney data line: {line}, error: {e}") + continue + + # logger.info(f"东方财富返回 {len(klines)} 条港股数据") + else: + logger.warning("Eastmoney returned no data") + + except Exception as e: + logger.error(f"Eastmoney HK stock fetch failed: {e}") + import traceback + logger.error(traceback.format_exc()) + + return klines + + def _fetch_akshare( + self, + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] + ) -> List[Dict[str, Any]]: + """使用 akshare 获取港股数据""" + klines = [] + + try: + # 计算日期范围 + if before_time: + end_date = datetime.fromtimestamp(before_time).strftime('%Y%m%d') + else: + end_date = datetime.now().strftime('%Y%m%d') + + days = limit * 2 if timeframe == '1D' else limit * 10 + start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d') + + # 港股代码补齐到5位 + hk_symbol = symbol.zfill(5) + + # logger.info(f"使用 akshare 获取港股: {hk_symbol}") + + df = ak.stock_hk_hist( + symbol=hk_symbol, + period="daily", + start_date=start_date, + end_date=end_date, + adjust="qfq" + ) + + if df is not None and not df.empty: + df = df.tail(limit) + for _, row in df.iterrows(): + ts = int(datetime.strptime(str(row['日期']), '%Y-%m-%d').timestamp()) + klines.append(self.format_kline( + timestamp=ts, + open_price=row['开盘'], + high=row['最高'], + low=row['最低'], + close=row['收盘'], + volume=row['成交量'] + )) + # logger.info(f"akshare 返回 {len(klines)} 条港股数据") + + except Exception as e: + logger.error(f"Akshare HK stock fetch failed: {e}") + import traceback + logger.error(traceback.format_exc()) + + return klines diff --git a/backend_api_python/app/data_sources/crypto.py b/backend_api_python/app/data_sources/crypto.py new file mode 100644 index 0000000..f999abf --- /dev/null +++ b/backend_api_python/app/data_sources/crypto.py @@ -0,0 +1,199 @@ +""" +加密货币数据源 +使用 CCXT (Binance) 获取数据 +""" +from typing import Dict, List, Any, Optional +from datetime import datetime, timedelta +import ccxt + +from app.data_sources.base import BaseDataSource, TIMEFRAME_SECONDS +from app.utils.logger import get_logger +from app.config import CCXTConfig, APIKeys + +logger = get_logger(__name__) + + +class CryptoDataSource(BaseDataSource): + """加密货币数据源""" + + name = "Crypto/CCXT" + + # 时间周期映射 + TIMEFRAME_MAP = CCXTConfig.TIMEFRAME_MAP + + def __init__(self): + config = { + 'timeout': CCXTConfig.TIMEOUT, + 'enableRateLimit': CCXTConfig.ENABLE_RATE_LIMIT + } + + # 如果配置了代理 + if CCXTConfig.PROXY: + config['proxies'] = { + 'http': CCXTConfig.PROXY, + 'https': CCXTConfig.PROXY + } + + self.exchange = ccxt.binance(config) + + def get_ticker(self, symbol: str) -> Dict[str, Any]: + """ + Get latest ticker for a crypto symbol via CCXT (Binance). + + Accepts common formats: + - BTC/USDT + - BTCUSDT + - BTC/USDT:USDT (swap-style suffix, will be normalized) + """ + sym = (symbol or "").strip() + if ":" in sym: + sym = sym.split(":", 1)[0] + sym = sym.upper() + if "/" not in sym: + if sym.endswith("USDT") and len(sym) > 4: + sym = f"{sym[:-4]}/USDT" + elif sym.endswith("USD") and len(sym) > 3: + sym = f"{sym[:-3]}/USD" + return self.exchange.fetch_ticker(sym) + + def get_kline( + self, + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None + ) -> List[Dict[str, Any]]: + """获取加密货币K线数据""" + klines = [] + + try: + ccxt_timeframe = self.TIMEFRAME_MAP.get(timeframe, '1d') + + # 构建交易对符号 + if not symbol.endswith('USDT') and not symbol.endswith('USD'): + symbol_pair = f'{symbol}/USDT' + else: + symbol_pair = symbol + + # logger.info(f"获取加密货币K线: {symbol_pair}, 周期: {ccxt_timeframe}, 条数: {limit}") + + ohlcv = self._fetch_ohlcv(symbol_pair, ccxt_timeframe, limit, before_time, timeframe) + + if not ohlcv: + logger.warning(f"CCXT returned no K-lines: {symbol_pair}") + return [] + + # 转换数据格式 + for candle in ohlcv: + if len(candle) < 6: + continue + klines.append(self.format_kline( + timestamp=int(candle[0] / 1000), # 毫秒转秒 + open_price=candle[1], + high=candle[2], + low=candle[3], + close=candle[4], + volume=candle[5] + )) + + # 过滤和限制 + klines = self.filter_and_limit(klines, limit, before_time) + + # 记录结果 + self.log_result(symbol, klines, timeframe) + + except Exception as e: + logger.error(f"Failed to fetch crypto K-lines {symbol}: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + + return klines + + def _fetch_ohlcv( + self, + symbol_pair: str, + ccxt_timeframe: str, + limit: int, + before_time: Optional[int], + timeframe: str + ) -> List: + """获取OHLCV数据(支持分页获取完整数据)""" + try: + if before_time: + # 计算时间范围 + total_seconds = self.calculate_time_range(timeframe, limit) + end_time = datetime.fromtimestamp(before_time) + start_time = end_time - timedelta(seconds=total_seconds) + since = int(start_time.timestamp() * 1000) + end_ms = before_time * 1000 + + # logger.info(f"历史数据请求: since={since//1000}, end={before_time}, 时间跨度={total_seconds/86400:.1f}天") + + # 分页获取数据,直到覆盖完整时间范围 + all_ohlcv = [] + batch_limit = 1000 # Binance 单次最大返回量 + current_since = since + + while current_since < end_ms: + batch = self.exchange.fetch_ohlcv( + symbol_pair, + ccxt_timeframe, + since=current_since, + limit=batch_limit + ) + + if not batch: + break + + all_ohlcv.extend(batch) + + # 获取最后一条数据的时间,作为下次请求的起始时间 + last_timestamp = batch[-1][0] + + # 如果最后一条数据时间超过了结束时间,或者返回数据少于请求量,说明已经获取完毕 + if last_timestamp >= end_ms or len(batch) < batch_limit: + break + + # 下次从最后一条的下一个时间点开始 + timeframe_ms = TIMEFRAME_SECONDS.get(timeframe, 86400) * 1000 + current_since = last_timestamp + timeframe_ms + + # logger.info(f"分页获取中: 已获取 {len(all_ohlcv)} 条, 继续从 {datetime.fromtimestamp(current_since/1000)}") + + ohlcv = all_ohlcv + else: + ohlcv = self.exchange.fetch_ohlcv(symbol_pair, ccxt_timeframe, limit=limit) + + # logger.info(f"CCXT 返回 {len(ohlcv) if ohlcv else 0} 条数据") + return ohlcv + + except Exception as e: + logger.warning(f"CCXT fetch_ohlcv failed: {str(e)}; trying fallback") + return self._fetch_ohlcv_fallback(symbol_pair, ccxt_timeframe, limit, before_time, timeframe) + + def _fetch_ohlcv_fallback( + self, + symbol_pair: str, + ccxt_timeframe: str, + limit: int, + before_time: Optional[int], + timeframe: str + ) -> List: + """备用获取方法""" + try: + total_seconds = self.calculate_time_range(timeframe, limit) + + if before_time: + end_time = datetime.fromtimestamp(before_time) + start_time = end_time - timedelta(seconds=total_seconds) + since = int(start_time.timestamp() * 1000) + else: + since = int((datetime.now() - timedelta(seconds=total_seconds)).timestamp() * 1000) + + ohlcv = self.exchange.fetch_ohlcv(symbol_pair, ccxt_timeframe, since=since, limit=limit) + # logger.info(f"CCXT 备用方法返回 {len(ohlcv) if ohlcv else 0} 条数据") + return ohlcv + except Exception as e: + logger.error(f"CCXT fallback method also failed: {str(e)}") + return [] + diff --git a/backend_api_python/app/data_sources/factory.py b/backend_api_python/app/data_sources/factory.py new file mode 100644 index 0000000..4cc7220 --- /dev/null +++ b/backend_api_python/app/data_sources/factory.py @@ -0,0 +1,106 @@ +""" +数据源工厂 +根据市场类型返回对应的数据源 +""" +from typing import Dict, List, Any, Optional + +from app.data_sources.base import BaseDataSource +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +class DataSourceFactory: + """数据源工厂""" + + _sources: Dict[str, BaseDataSource] = {} + + @classmethod + def get_source(cls, market: str) -> BaseDataSource: + """ + 获取指定市场的数据源 + + Args: + market: 市场类型 (Crypto, USStock, AShare, HShare) + + Returns: + 数据源实例 + """ + if market not in cls._sources: + cls._sources[market] = cls._create_source(market) + return cls._sources[market] + + @classmethod + def get_data_source(cls, name: str) -> BaseDataSource: + """ + Backward compatible alias used by older code paths. + + Some modules historically called `get_data_source("binance")` to fetch a crypto data source. + In the localized Python backend we primarily use `get_source("Crypto")`. + """ + key = (name or "").strip().lower() + if key in ("crypto", "binance", "okx", "bybit", "bitget", "kucoin", "gate", "mexc", "kraken", "coinbase"): + return cls.get_source("Crypto") + if key in ("futures",): + return cls.get_source("Futures") + # Default to Crypto for safety (most callers want a ticker for crypto pairs). + return cls.get_source("Crypto") + + @classmethod + def _create_source(cls, market: str) -> BaseDataSource: + """创建数据源实例""" + if market == 'Crypto': + from app.data_sources.crypto import CryptoDataSource + return CryptoDataSource() + elif market == 'USStock': + from app.data_sources.us_stock import USStockDataSource + return USStockDataSource() + elif market == 'AShare': + from app.data_sources.cn_stock import AShareDataSource + return AShareDataSource() + elif market == 'HShare': + from app.data_sources.cn_stock import HShareDataSource + return HShareDataSource() + elif market == 'Forex': + from app.data_sources.forex import ForexDataSource + return ForexDataSource() + elif market == 'Futures': + from app.data_sources.futures import FuturesDataSource + return FuturesDataSource() + else: + raise ValueError(f"不支持的市场类型: {market}") + + @classmethod + def get_kline( + cls, + market: str, + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None + ) -> List[Dict[str, Any]]: + """ + 获取K线数据的便捷方法 + + Args: + market: 市场类型 + symbol: 交易对/股票代码 + timeframe: 时间周期 + limit: 数据条数 + before_time: 获取此时间之前的数据 + + Returns: + K线数据列表 + """ + try: + source = cls.get_source(market) + klines = source.get_kline(symbol, timeframe, limit, before_time) + + # 确保数据按时间排序 + klines.sort(key=lambda x: x['time']) + + return klines + except Exception as e: + logger.error(f"Failed to fetch K-lines {market}:{symbol} - {str(e)}") + return [] + diff --git a/backend_api_python/app/data_sources/forex.py b/backend_api_python/app/data_sources/forex.py new file mode 100644 index 0000000..18e8b89 --- /dev/null +++ b/backend_api_python/app/data_sources/forex.py @@ -0,0 +1,190 @@ +""" +外汇数据源 +使用 Tiingo 获取外汇数据 +""" +from typing import Dict, List, Any, Optional +from datetime import datetime, timedelta +import time +import requests + +from app.data_sources.base import BaseDataSource, TIMEFRAME_SECONDS +from app.utils.logger import get_logger +from app.config import TiingoConfig, APIKeys + +logger = get_logger(__name__) + + +class ForexDataSource(BaseDataSource): + """外汇数据源 (Tiingo)""" + + name = "Forex/Tiingo" + + # Tiingo resampleFreq 映射 + # Tiingo 支持: 1min, 5min, 15min, 30min, 1hour, 4hour, 1day 等 + TIMEFRAME_MAP = { + '1m': '1min', + '5m': '5min', + '15m': '15min', + '30m': '30min', + '1H': '1hour', + '4H': '4hour', + '1D': '1day', + '1W': '1week', + '1M': '1month' + } + + # 外汇对映射 (Tiingo 使用标准 ticker,如 eurusd, audusd) + # 大写也可以,Tiingo 通常不区分大小写,但建议统一 + SYMBOL_MAP = { + # 贵金属 (Tiingo 不一定支持所有 OANDA 格式的贵金属,通常是 XAUUSD) + 'XAUUSD': 'xauusd', + 'XAGUSD': 'xagusd', + # 主要货币对 + 'EURUSD': 'eurusd', + 'GBPUSD': 'gbpusd', + 'USDJPY': 'usdjpy', + 'AUDUSD': 'audusd', + 'USDCAD': 'usdcad', + 'USDCHF': 'usdchf', + 'NZDUSD': 'nzdusd', + } + + def __init__(self): + self.base_url = TiingoConfig.BASE_URL + if not APIKeys.TIINGO_API_KEY: + logger.warning("Tiingo API key is not configured; FX data will be unavailable") + + def _get_timeframe_seconds(self, timeframe: str) -> int: + """获取时间周期对应的秒数""" + return TIMEFRAME_SECONDS.get(timeframe, 86400) + + def get_kline( + self, + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None + ) -> List[Dict[str, Any]]: + """ + 获取外汇K线数据 + + Args: + symbol: 外汇对代码(如 XAUUSD, EURUSD) + timeframe: 时间周期 + limit: 数据条数 + before_time: 结束时间戳 + """ + # 动态获取 API Key + api_key = APIKeys.TIINGO_API_KEY + if not api_key: + logger.error("Tiingo API key is not configured") + return [] + + try: + # 1. 解析 Symbol + tiingo_symbol = self.SYMBOL_MAP.get(symbol) + if not tiingo_symbol: + # 尝试智能转换: EURUSD -> eurusd + tiingo_symbol = symbol.lower() + + # 2. 解析 Resolution (resampleFreq) + resample_freq = self.TIMEFRAME_MAP.get(timeframe) + if not resample_freq: + logger.warning(f"Tiingo does not support timeframe: {timeframe}") + return [] + + # 3. 计算时间范围 + if before_time: + end_dt = datetime.fromtimestamp(before_time) + else: + end_dt = datetime.now() + + # 根据周期和数量计算开始时间 + tf_seconds = self._get_timeframe_seconds(timeframe) + # 多取一些缓冲时间 + start_dt = end_dt - timedelta(seconds=limit * tf_seconds * 2) + + # 格式化日期为 YYYY-MM-DD (Tiingo 支持该格式) + start_date_str = start_dt.strftime('%Y-%m-%d') + end_date_str = end_dt.strftime('%Y-%m-%d') + + # 4. API 请求 + # URL: https://api.tiingo.com/tiingo/fx/{ticker}/prices + url = f"{self.base_url}/fx/{tiingo_symbol}/prices" + + params = { + 'startDate': start_date_str, + 'endDate': end_date_str, + 'resampleFreq': resample_freq, + 'token': api_key, + 'format': 'json' + } + + # logger.info(f"Tiingo Request: {url} params={params}") + + response = requests.get(url, params=params, timeout=TiingoConfig.TIMEOUT) + + if response.status_code == 403: # 具体的权限错误 + logger.error("Tiingo API permission error (403): check whether your API key is valid and has access to this dataset.") + return [] + + response.raise_for_status() + data = response.json() + + # 5. 处理响应 + # Tiingo returns a list of dicts: + # [ + # { + # "date": "2023-01-01T00:00:00.000Z", + # "ticker": "eurusd", + # "open": 1.07, + # "high": 1.08, + # "low": 1.06, + # "close": 1.07 + # "mid": ... (optional, depends on settings, usually OHLC are bid or mid) + # }, ... + # ] + # Note: Tiingo FX prices objects keys: date, open, high, low, close. + + if not isinstance(data, list): + logger.warning(f"Tiingo response is not a list: {data}") + return [] + + klines = [] + for item in data: + # 解析时间: "2023-01-01T00:00:00.000Z" + dt_str = item.get('date') + # 简化处理,Tiingo 返回的是 UTC 时间 ISO 格式 + # datetime.fromisoformat 在 Py3.7+ 支持,但要注意 Z 的处理 + # 这里简单处理一下 Z + if dt_str.endswith('Z'): + dt_str = dt_str[:-1] + + dt = datetime.fromisoformat(dt_str) + ts = int(dt.timestamp()) + + klines.append({ + 'time': ts, + 'open': float(item.get('open')), + 'high': float(item.get('high')), + 'low': float(item.get('low')), + 'close': float(item.get('close')), + 'volume': 0.0 # Tiingo FX 通常没有 volume + }) + + # 按时间排序 + klines.sort(key=lambda x: x['time']) + + # 过滤 + if len(klines) > limit: + klines = klines[-limit:] + + # logger.info(f"获取到 {len(klines)} 条 Tiingo 外汇数据") + return klines + + except requests.exceptions.RequestException as e: + logger.error(f"Tiingo API request failed: {e}") + return [] + except Exception as e: + logger.error(f"Failed to process Tiingo data: {e}") + return [] diff --git a/backend_api_python/app/data_sources/futures.py b/backend_api_python/app/data_sources/futures.py new file mode 100644 index 0000000..15775aa --- /dev/null +++ b/backend_api_python/app/data_sources/futures.py @@ -0,0 +1,243 @@ +""" +期货数据源 +支持: +1. 加密货币期货(Binance Futures via CCXT) +2. 传统期货(Yahoo Finance) +""" +from typing import Dict, List, Any, Optional +from datetime import datetime, timedelta +import ccxt +import yfinance as yf + +from app.data_sources.base import BaseDataSource, TIMEFRAME_SECONDS +from app.utils.logger import get_logger +from app.config import CCXTConfig, APIKeys + +logger = get_logger(__name__) + + +class FuturesDataSource(BaseDataSource): + """期货数据源""" + + name = "Futures" + + # Yahoo Finance时间周期映射 + YF_TIMEFRAME_MAP = { + '1m': '1m', + '5m': '5m', + '15m': '15m', + '30m': '30m', + '1H': '1h', + '4H': '4h', + '1D': '1d', + '1W': '1wk' + } + + # CCXT时间周期映射 + CCXT_TIMEFRAME_MAP = CCXTConfig.TIMEFRAME_MAP + + # 传统期货合约代码(Yahoo Finance) + YF_SYMBOLS = { + 'GC': 'GC=F', # 黄金期货 + 'SI': 'SI=F', # 白银期货 + 'CL': 'CL=F', # 原油期货 + 'NG': 'NG=F', # 天然气期货 + 'ZC': 'ZC=F', # 玉米期货 + 'ZW': 'ZW=F', # 小麦期货 + } + + def __init__(self): + # 初始化CCXT(用于加密货币期货) + config = { + 'timeout': CCXTConfig.TIMEOUT, + 'enableRateLimit': CCXTConfig.ENABLE_RATE_LIMIT, + 'options': { + 'defaultType': 'future' + } + } + + if CCXTConfig.PROXY: + config['proxies'] = { + 'http': CCXTConfig.PROXY, + 'https': CCXTConfig.PROXY + } + + self.exchange = ccxt.binance(config) + + def get_ticker(self, symbol: str) -> Dict[str, Any]: + """ + Get latest ticker for futures symbol. + + - For crypto futures, uses CCXT Binance futures client. + - For traditional futures (Yahoo Finance symbols), returns a minimal ticker shape with `last`. + """ + sym = (symbol or "").strip() + if sym in self.YF_SYMBOLS or sym.endswith("=F"): + try: + yf_symbol = self.YF_SYMBOLS.get(sym, sym) + if not yf_symbol.endswith("=F"): + yf_symbol = yf_symbol + "=F" + t = yf.Ticker(yf_symbol) + # Prefer fast_info if available, fall back to last close + last = None + try: + last = getattr(t, "fast_info", {}).get("last_price") + except Exception: + last = None + if last is None: + hist = t.history(period="2d", interval="1d") + if hist is not None and not hist.empty: + last = float(hist["Close"].iloc[-1]) + return {"symbol": yf_symbol, "last": float(last or 0.0)} + except Exception: + return {"symbol": sym, "last": 0.0} + + if ":" in sym: + sym = sym.split(":", 1)[0] + sym = sym.upper() + if "/" not in sym: + if sym.endswith("USDT") and len(sym) > 4: + sym = f"{sym[:-4]}/USDT" + elif sym.endswith("USD") and len(sym) > 3: + sym = f"{sym[:-3]}/USD" + return self.exchange.fetch_ticker(sym) + + def _get_timeframe_seconds(self, timeframe: str) -> int: + """获取时间周期对应的秒数""" + return TIMEFRAME_SECONDS.get(timeframe, 86400) + + def get_kline( + self, + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None + ) -> List[Dict[str, Any]]: + """ + 获取期货K线数据 + + Args: + symbol: 期货合约代码 + timeframe: 时间周期 + limit: 数据条数 + before_time: 结束时间戳 + """ + # 判断是传统期货还是加密货币期货 + if symbol in self.YF_SYMBOLS or symbol.endswith('=F'): + return self._get_traditional_futures(symbol, timeframe, limit, before_time) + else: + return self._get_crypto_futures(symbol, timeframe, limit, before_time) + + def _get_traditional_futures( + self, + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None + ) -> List[Dict[str, Any]]: + """使用yfinance获取传统期货数据""" + try: + # 转换symbol格式 + yf_symbol = self.YF_SYMBOLS.get(symbol, symbol) + if not yf_symbol.endswith('=F'): + yf_symbol = symbol + '=F' + + # 转换时间周期 + yf_interval = self.YF_TIMEFRAME_MAP.get(timeframe, '1d') + + # logger.info(f"获取传统期货K线: {yf_symbol}, 周期: {yf_interval}, 条数: {limit}") + + # 计算时间范围 + if before_time: + end_time = datetime.fromtimestamp(before_time) + else: + end_time = datetime.now() + + tf_seconds = self._get_timeframe_seconds(timeframe) + start_time = end_time - timedelta(seconds=tf_seconds * limit * 1.5) + + # 获取数据 + ticker = yf.Ticker(yf_symbol) + df = ticker.history( + start=start_time, + end=end_time, + interval=yf_interval + ) + + if df.empty: + logger.warning(f"No data: {yf_symbol}") + return [] + + # 转换格式 + klines = [] + for index, row in df.iterrows(): + klines.append({ + 'time': int(index.timestamp()), + 'open': float(row['Open']), + 'high': float(row['High']), + 'low': float(row['Low']), + 'close': float(row['Close']), + 'volume': float(row['Volume']) + }) + + klines.sort(key=lambda x: x['time']) + if len(klines) > limit: + klines = klines[-limit:] + + # logger.info(f"获取到 {len(klines)} 条传统期货数据") + return klines + + except Exception as e: + logger.error(f"Failed to fetch traditional futures data: {e}") + return [] + + def _get_crypto_futures( + self, + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None + ) -> List[Dict[str, Any]]: + """使用CCXT获取加密货币期货数据""" + try: + # 确保symbol格式正确 + ccxt_symbol = symbol if '/' in symbol else f"{symbol}/USDT" + ccxt_timeframe = self.CCXT_TIMEFRAME_MAP.get(timeframe, '1d') + + # logger.info(f"获取加密货币期货K线: {ccxt_symbol}, 周期: {ccxt_timeframe}, 条数: {limit}") + + # 获取数据 + if before_time: + since_time = before_time - limit * self._get_timeframe_seconds(timeframe) + ohlcv = self.exchange.fetch_ohlcv( + ccxt_symbol, + ccxt_timeframe, + since=since_time * 1000, + limit=limit + ) + else: + ohlcv = self.exchange.fetch_ohlcv( + ccxt_symbol, + ccxt_timeframe, + limit=limit + ) + + # 转换格式 + klines = [] + for candle in ohlcv: + klines.append({ + 'time': int(candle[0] / 1000), + 'open': float(candle[1]), + 'high': float(candle[2]), + 'low': float(candle[3]), + 'close': float(candle[4]), + 'volume': float(candle[5]) + }) + + # logger.info(f"获取到 {len(klines)} 条加密货币期货数据") + return klines + + except Exception as e: + logger.error(f"Failed to fetch crypto futures data: {e}") + return [] + diff --git a/backend_api_python/app/data_sources/us_stock.py b/backend_api_python/app/data_sources/us_stock.py new file mode 100644 index 0000000..d38256a --- /dev/null +++ b/backend_api_python/app/data_sources/us_stock.py @@ -0,0 +1,194 @@ +""" +美股数据源 +使用 yfinance 和 finnhub 获取数据 +""" +from typing import Dict, List, Any, Optional +from datetime import datetime, timedelta + +import yfinance as yf + +from app.data_sources.base import BaseDataSource +from app.utils.logger import get_logger +from app.config import APIKeys, YFinanceConfig + +logger = get_logger(__name__) + + +class USStockDataSource(BaseDataSource): + """美股数据源""" + + name = "USStock/yfinance" + + # yfinance 时间周期映射 + INTERVAL_MAP = { + '1m': '1m', + '5m': '5m', + '15m': '15m', + '30m': '30m', + '1H': '1h', + '4H': '4h', + '1D': '1d', + '1W': '1wk' + } + + # 不同周期获取数据的天数范围 + DAYS_MAP = { + '1m': lambda limit: min(7, max(1, (limit // 390) + 2)), + '5m': lambda limit: min(60, max(1, (limit // 78) + 2)), + '15m': lambda limit: min(60, max(1, (limit // 26) + 2)), + '30m': lambda limit: min(60, max(1, (limit // 13) + 2)), + '1H': lambda limit: min(730, max(1, (limit // 24) + 2)), + '4H': lambda limit: min(730, max(1, (limit // 6) + 2)), + '1D': lambda limit: min(3650, limit + 1), + '1W': lambda limit: min(3650, (limit * 7) + 7) + } + + def __init__(self): + # 初始化 finnhub 作为备选 + self.finnhub_client = None + try: + import finnhub + if APIKeys.is_configured('FINNHUB_API_KEY'): + self.finnhub_client = finnhub.Client(api_key=APIKeys.FINNHUB_API_KEY) + logger.info("Finnhub client initialized") + except Exception as e: + logger.warning(f"Finnhub init failed: {e}") + + def get_kline( + self, + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None + ) -> List[Dict[str, Any]]: + """获取美股K线数据""" + klines = [] + + try: + interval = self.INTERVAL_MAP.get(timeframe, '1d') + days_func = self.DAYS_MAP.get(timeframe, lambda x: x + 1) + days = days_func(limit) + + # 计算日期范围 + if before_time: + end_date = datetime.fromtimestamp(before_time) + start_date = end_date - timedelta(days=days) + else: + end_date = datetime.now() + start_date = end_date - timedelta(days=days) + + # logger.info(f"使用 yfinance 获取 {symbol}, 周期: {interval}, 日期: {start_date.date()} ~ {end_date.date()}") + + # 尝试 yfinance + df = self._fetch_yfinance(symbol, interval, start_date, end_date) + + if df is None or df.empty: + # 尝试 finnhub + if self.finnhub_client and timeframe == '1D': + klines = self._fetch_finnhub(symbol, start_date, end_date, limit) + if klines: + return klines + else: + klines = self._convert_dataframe(df, limit) + + # 过滤和限制 + klines = self.filter_and_limit(klines, limit, before_time) + + # 记录结果 + self.log_result(symbol, klines, timeframe) + + except Exception as e: + logger.error(f"Failed to fetch US stock K-lines {symbol}: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + + return klines + + def _fetch_yfinance(self, symbol: str, interval: str, start_date: datetime, end_date: datetime): + """使用 yfinance 获取数据""" + try: + ticker = yf.Ticker(symbol) + df = ticker.history( + start=start_date.strftime('%Y-%m-%d'), + end=end_date.strftime('%Y-%m-%d'), + interval=interval + ) + # logger.info(f"yfinance 返回 {len(df) if df is not None and not df.empty else 0} 条数据") + return df + except Exception as e: + logger.warning(f"yfinance fetch failed: {e}") + return None + + def _fetch_finnhub( + self, + symbol: str, + start_date: datetime, + end_date: datetime, + limit: int + ) -> List[Dict[str, Any]]: + """使用 finnhub 获取日线数据""" + klines = [] + try: + start_ts = int(start_date.timestamp()) + end_ts = int(end_date.timestamp()) + + # logger.info(f"使用 Finnhub 获取 {symbol} 日线数据") + candles = self.finnhub_client.stock_candles(symbol, 'D', start_ts, end_ts) + + if candles and candles.get('s') == 'ok': + for i in range(len(candles['t'])): + klines.append(self.format_kline( + timestamp=candles['t'][i], + open_price=candles['o'][i], + high=candles['h'][i], + low=candles['l'][i], + close=candles['c'][i], + volume=candles['v'][i] + )) + # logger.info(f"Finnhub 返回 {len(klines)} 条数据") + except Exception as e: + logger.error(f"Finnhub fetch failed: {e}") + + return klines + + def _convert_dataframe(self, df, limit: int) -> List[Dict[str, Any]]: + """转换 DataFrame 为K线列表""" + klines = [] + df = df.tail(limit).reset_index() + + # 确定时间列名(日线是 Date,分钟级是 Datetime) + time_col = None + if 'Datetime' in df.columns: + time_col = 'Datetime' + elif 'Date' in df.columns: + time_col = 'Date' + elif 'index' in df.columns: + time_col = 'index' + + if time_col is None: + logger.warning(f"Unable to determine time column; available columns: {df.columns.tolist()}") + return klines + + for _, row in df.iterrows(): + try: + # 处理时间戳 + time_value = row[time_col] + if hasattr(time_value, 'timestamp'): + ts = int(time_value.timestamp()) + else: + continue + + klines.append(self.format_kline( + timestamp=ts, + open_price=row['Open'], + high=row['High'], + low=row['Low'], + close=row['Close'], + volume=row['Volume'] + )) + except Exception as e: + logger.debug(f"Failed to parse row data: {e}") + continue + + return klines + diff --git a/backend_api_python/app/routes/__init__.py b/backend_api_python/app/routes/__init__.py new file mode 100644 index 0000000..538ebe4 --- /dev/null +++ b/backend_api_python/app/routes/__init__.py @@ -0,0 +1,32 @@ +""" +API 路由模块 +""" +from flask import Flask + + +def register_routes(app: Flask): + """注册所有 API 路由蓝图""" + from app.routes.kline import kline_bp + from app.routes.analysis import analysis_bp + from app.routes.backtest import backtest_bp + from app.routes.health import health_bp + from app.routes.market import market_bp + from app.routes.strategy import strategy_bp + from app.routes.credentials import credentials_bp + from app.routes.auth import auth_bp + from app.routes.ai_chat import ai_chat_bp + from app.routes.indicator import indicator_bp + from app.routes.dashboard import dashboard_bp + + app.register_blueprint(health_bp) + app.register_blueprint(auth_bp, url_prefix='/api/user') # 兼容前端 /api/user/login + app.register_blueprint(kline_bp, url_prefix='/api/indicator') + app.register_blueprint(analysis_bp, url_prefix='/api/analysis') + app.register_blueprint(backtest_bp, url_prefix='/api/indicator') + app.register_blueprint(market_bp, url_prefix='/api/market') + app.register_blueprint(ai_chat_bp, url_prefix='/api/ai') + app.register_blueprint(indicator_bp, url_prefix='/api/indicator') + app.register_blueprint(strategy_bp, url_prefix='/api') + app.register_blueprint(credentials_bp, url_prefix='/api/credentials') + app.register_blueprint(dashboard_bp, url_prefix='/api/dashboard') + diff --git a/backend_api_python/app/routes/ai_chat.py b/backend_api_python/app/routes/ai_chat.py new file mode 100644 index 0000000..81e3702 --- /dev/null +++ b/backend_api_python/app/routes/ai_chat.py @@ -0,0 +1,46 @@ +""" +AI chat API routes (optional). +Currently kept as a minimal compatibility layer for legacy frontend calls. +""" + +from flask import Blueprint, request, jsonify + +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +ai_chat_bp = Blueprint('ai_chat', __name__) + + +@ai_chat_bp.route('/chat/message', methods=['POST']) +def chat_message(): + """ + Minimal placeholder for legacy chat. + Return a friendly message instead of 404, so the UI can evolve gradually. + """ + data = request.get_json() or {} + msg = (data.get('message') or '').strip() + if not msg: + return jsonify({'code': 0, 'msg': 'Missing message', 'data': None}), 400 + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': { + 'reply': 'Chat API is not implemented yet in local-only mode.', + 'echo': msg + } + }) + + +@ai_chat_bp.route('/chat/history', methods=['POST']) +def get_chat_history(): + """Return empty history (compatibility stub).""" + return jsonify({'code': 1, 'msg': 'success', 'data': []}) + + +@ai_chat_bp.route('/chat/history/save', methods=['POST']) +def save_chat_history(): + """No-op save (compatibility stub).""" + return jsonify({'code': 1, 'msg': 'success', 'data': None}) + + diff --git a/backend_api_python/app/routes/analysis.py b/backend_api_python/app/routes/analysis.py new file mode 100644 index 0000000..b97075a --- /dev/null +++ b/backend_api_python/app/routes/analysis.py @@ -0,0 +1,330 @@ +""" +Analysis API routes (local-only). +Implements multi-dimensional analysis plus lightweight task/history APIs for the frontend. +""" +from flask import Blueprint, request, jsonify, Response +import json +import traceback +import time + +from app.services.analysis import AnalysisService, reflect_analysis +from app.utils.logger import get_logger +from app.utils.db import get_db_connection +from app.utils.language import detect_request_language + +logger = get_logger(__name__) + +analysis_bp = Blueprint('analysis', __name__) +DEFAULT_USER_ID = 1 + +def _now_ts() -> int: + return int(time.time()) + +def _normalize_symbol(symbol: str) -> str: + return (symbol or '').strip().upper() + +def _store_task(market: str, symbol: str, model: str, language: str, status: str, result: dict = None, error_message: str = "") -> int: + now = _now_ts() + result_json = json.dumps(result or {}, ensure_ascii=False) + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + INSERT INTO qd_analysis_tasks (user_id, market, symbol, model, language, status, result_json, error_message, created_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (DEFAULT_USER_ID, market, symbol, model or '', language or 'en-US', status, result_json, error_message or '', now, now if status in ['completed', 'failed'] else None) + ) + task_id = cur.lastrowid + db.commit() + cur.close() + return int(task_id) + +def _get_task(task_id: int) -> dict: + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT * FROM qd_analysis_tasks WHERE id = ? AND user_id = ?", (task_id, DEFAULT_USER_ID)) + row = cur.fetchone() + cur.close() + return row or None + +def _parse_result_json(row: dict) -> dict: + if not row: + return {} + raw = row.get('result_json') or '' + try: + return json.loads(raw) if raw else {} + except Exception: + return {} + + +@analysis_bp.route('/multi', methods=['POST']) +@analysis_bp.route('/multiAnalysis', methods=['POST']) # compatibility with legacy naming +def multi_analysis(): + """ + Multi-dimensional analysis. + + Request body: + market: Market (AShare, USStock, HShare, Crypto, Forex, Futures) + symbol: Symbol + language: Optional; if omitted we will detect from request headers (X-App-Lang / Accept-Language) + """ + try: + data = request.get_json() + if not data: + return jsonify({ + 'code': 0, + 'msg': 'Request body is required', + 'data': None + }), 400 + + market = data.get('market', '') + symbol = data.get('symbol', '') + language = detect_request_language(request, body=data, default='en-US') + model = data.get('model', None) + use_multi_agent = data.get('use_multi_agent', None) # None -> use backend default + + if not symbol or not market: + return jsonify({ + 'code': 0, + 'msg': 'Missing required parameters', + 'data': None + }), 400 + + # Normalize/defend input for local-only mode. + market = str(market).strip() + symbol = _normalize_symbol(symbol) + language = str(language or 'en-US') + model = str(model) if model else None + + logger.info(f"Analyze request: {market}:{symbol}, use_multi_agent={use_multi_agent}, model={model}") + + # Create analysis service instance (local-only; no paid credits) + service = AnalysisService(use_multi_agent=use_multi_agent) + result = service.analyze(market, symbol, language, model=model) + + # Persist as "completed" history (no paid credits in local mode). + task_id = _store_task(market, symbol, model or '', language, 'completed', result=result, error_message='') + + # Keep frontend compatible: if it expects task polling, it can still use the id. + result_payload = dict(result or {}) + result_payload['task_id'] = task_id + + return jsonify({'code': 1, 'msg': 'success', 'data': result_payload}) + + except Exception as e: + logger.error(f"Analysis failed: {str(e)}") + logger.error(traceback.format_exc()) + try: + market = (data or {}).get('market', '') if 'data' in locals() else '' + symbol = (data or {}).get('symbol', '') if 'data' in locals() else '' + language = detect_request_language(request, body=(data or {}), default='en-US') + model = (data or {}).get('model', '') if 'data' in locals() else '' + market = str(market).strip() + symbol = _normalize_symbol(symbol) + _store_task(market, symbol, model, language, 'failed', result={}, error_message=str(e)) + except Exception: + pass + return jsonify({ + 'code': 0, + 'msg': f'Analysis failed: {str(e)}', + 'data': None + }), 500 + + +@analysis_bp.route('/getTaskStatus', methods=['POST']) +def get_task_status(): + """Frontend compatibility: return task status + result by task_id.""" + try: + data = request.get_json() or {} + task_id = int(data.get('task_id') or 0) + if not task_id: + return jsonify({'code': 0, 'msg': 'Missing task_id', 'data': None}), 400 + + row = _get_task(task_id) + if not row: + return jsonify({'code': 0, 'msg': 'Task not found', 'data': None}), 404 + + payload = { + 'id': row.get('id'), + 'market': row.get('market'), + 'symbol': row.get('symbol'), + 'status': row.get('status'), + 'error_message': row.get('error_message') or '', + 'result': _parse_result_json(row) + } + return jsonify({'code': 1, 'msg': 'success', 'data': payload}) + except Exception as e: + logger.error(f"get_task_status failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@analysis_bp.route('/getHistoryList', methods=['POST']) +def get_history_list(): + """Frontend compatibility: paginated analysis history for the single user.""" + try: + data = request.get_json() or {} + page = int(data.get('page') or 1) + pagesize = int(data.get('pagesize') or 20) + page = max(page, 1) + pagesize = min(max(pagesize, 1), 100) + offset = (page - 1) * pagesize + + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT COUNT(1) as cnt FROM qd_analysis_tasks WHERE user_id = ?", (DEFAULT_USER_ID,)) + total = int((cur.fetchone() or {}).get('cnt') or 0) + cur.execute( + """ + SELECT id, market, symbol, model, status, error_message, created_at, completed_at, result_json + FROM qd_analysis_tasks + WHERE user_id = ? + ORDER BY id DESC + LIMIT ? OFFSET ? + """, + (DEFAULT_USER_ID, pagesize, offset) + ) + rows = cur.fetchall() or [] + cur.close() + + out = [] + for r in rows: + has_result = bool((r.get('result_json') or '').strip()) + out.append({ + 'id': r.get('id'), + 'market': r.get('market'), + 'symbol': r.get('symbol'), + 'model': r.get('model') or '', + 'status': r.get('status'), + 'has_result': has_result, + 'error_message': r.get('error_message') or '', + 'createtime': int(r.get('created_at') or 0), + 'completetime': int(r.get('completed_at') or 0) if r.get('completed_at') else None + }) + + return jsonify({'code': 1, 'msg': 'success', 'data': {'list': out, 'total': total}}) + except Exception as e: + logger.error(f"get_history_list failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': {'list': [], 'total': 0}}), 500 + + +@analysis_bp.route('/createTask', methods=['POST']) +def create_task(): + """ + Compatibility endpoint for legacy frontend. + In local-only mode we do not run a separate async worker; we create a completed task record immediately. + """ + try: + data = request.get_json() or {} + market = str((data.get('market') or '')).strip() + symbol = _normalize_symbol(data.get('symbol')) + language = detect_request_language(request, body=data, default='en-US') + model = data.get('model') or '' + + if not market or not symbol: + return jsonify({'code': 0, 'msg': 'Missing market or symbol', 'data': None}), 400 + + # Create a placeholder "pending" task so frontend can show task_id if it needs it. + task_id = _store_task(market, symbol, str(model), language, 'pending', result={}, error_message='') + return jsonify({'code': 1, 'msg': 'success', 'data': {'task_id': task_id, 'status': 'pending'}}) + except Exception as e: + logger.error(f"create_task failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@analysis_bp.route('/stream', methods=['POST']) +def stream_analysis(): + """Streaming analysis (SSE).""" + try: + data = request.get_json() + if not data: + return jsonify({'code': 0, 'msg': 'Request body is required'}), 400 + + market = data.get('market', '') + symbol = data.get('symbol', '') + language = detect_request_language(request, body=data, default='en-US') + use_multi_agent = data.get('use_multi_agent', None) + + def generate(): + try: + yield f"data: {json.dumps({'status': 'started', 'message': 'Analysis started'})}\n\n" + + service = AnalysisService(use_multi_agent=use_multi_agent) + result = service.analyze(market, symbol, language) + + yield f"data: {json.dumps({'status': 'completed', 'data': result})}\n\n" + except Exception as e: + yield f"data: {json.dumps({'status': 'error', 'message': str(e)})}\n\n" + + return Response( + generate(), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + } + ) + + except Exception as e: + logger.error(f"Streaming analysis failed: {str(e)}") + return jsonify({'code': 0, 'msg': str(e)}), 500 + + +@analysis_bp.route('/reflect', methods=['POST']) +def reflect(): + """ + Reflection API. + Learn from post-trade outcomes and update agent memory (local-only). + + Body: + market: Market + symbol: Symbol + decision: BUY/SELL/HOLD + returns: Optional return percentage + result: Optional free-text outcome + """ + try: + data = request.get_json() + if not data: + return jsonify({ + 'code': 0, + 'msg': 'Request body is required', + 'data': None + }), 400 + + market = data.get('market', '') + symbol = data.get('symbol', '') + decision = data.get('decision', '') + returns = data.get('returns', None) + result = data.get('result', None) + + if not symbol or not market or not decision: + return jsonify({ + 'code': 0, + 'msg': 'Missing required parameters (market, symbol, decision)', + 'data': None + }), 400 + + logger.info(f"Reflection: {market}:{symbol}, decision={decision}, returns={returns}") + + reflect_analysis(market, symbol, decision, returns, result) + + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': None + }) + + except Exception as e: + logger.error(f"Reflection failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({ + 'code': 0, + 'msg': f'Reflection failed: {str(e)}', + 'data': None + }), 500 + diff --git a/backend_api_python/app/routes/auth.py b/backend_api_python/app/routes/auth.py new file mode 100644 index 0000000..3699323 --- /dev/null +++ b/backend_api_python/app/routes/auth.py @@ -0,0 +1,68 @@ + +from flask import Blueprint, request, jsonify +from app.config.settings import Config +from app.utils.auth import generate_token +from app.utils.logger import get_logger + +auth_bp = Blueprint('auth', __name__) +logger = get_logger(__name__) + +@auth_bp.route('/login', methods=['POST']) +def login(): + """Login (single-user, env-configured credentials).""" + try: + data = request.get_json() + if not data: + return jsonify({'code': 400, 'msg': 'No data provided', 'data': None}), 400 + + username = data.get('username') or data.get('account') + password = data.get('password') + + if not username or not password: + return jsonify({'code': 400, 'msg': 'Missing username or password', 'data': None}), 400 + + # Validate credentials from environment / settings + if username == Config.ADMIN_USER and password == Config.ADMIN_PASSWORD: + token = generate_token(username) + if token: + return jsonify({ + 'code': 1, + 'msg': 'Login successful', + 'data': { + 'token': token, + 'userinfo': { + 'username': username, + 'nickname': 'Admin', + 'avatar': '' + } + } + }) + else: + return jsonify({'code': 500, 'msg': 'Token generation error', 'data': None}), 500 + else: + return jsonify({'code': 0, 'msg': 'Invalid credentials', 'data': None}), 401 + + except Exception as e: + logger.error(f"Login error: {e}") + return jsonify({'code': 500, 'msg': str(e), 'data': None}), 500 + +@auth_bp.route('/logout', methods=['POST']) +def logout(): + """Logout (client removes token; server is stateless).""" + return jsonify({'code': 1, 'msg': 'Logout successful', 'data': None}) + +@auth_bp.route('/info', methods=['GET']) +def get_user_info(): + """Get user info (single-user mock).""" + return jsonify({ + 'code': 1, + 'msg': 'Success', + 'data': { + 'id': 1, + 'username': Config.ADMIN_USER, + 'nickname': 'Admin', + 'avatar': '/avatar2.jpg', + 'role': {'id': 'admin', 'permissions': ['dashboard', 'exception', 'account']} + } + }) + diff --git a/backend_api_python/app/routes/backtest.py b/backend_api_python/app/routes/backtest.py new file mode 100644 index 0000000..5410c3b --- /dev/null +++ b/backend_api_python/app/routes/backtest.py @@ -0,0 +1,801 @@ +""" +Backtest API routes +""" +from flask import Blueprint, request, jsonify +from datetime import datetime +import traceback +import json +import time +import os + +from app.services.backtest import BacktestService +from app.utils.logger import get_logger +from app.utils.db import get_db_connection +import requests + +logger = get_logger(__name__) + +backtest_bp = Blueprint('backtest', __name__) +backtest_service = BacktestService() + + +def _openrouter_base_and_key() -> tuple[str, str]: + key = os.getenv("OPENROUTER_API_KEY", "").strip() + base = os.getenv("OPENROUTER_BASE_URL", "").strip() + if not base: + api_url = os.getenv("OPENROUTER_API_URL", "").strip() + if api_url.endswith("/chat/completions"): + base = api_url[: -len("/chat/completions")] + if not base: + base = "https://openrouter.ai/api/v1" + return base, key + + +def _normalize_lang(lang: str | None) -> str: + """ + Normalize language code for AI output. + + This should align with frontend i18n locales under `quantdinger_vue/src/locales/lang`. + Supported: + - zh-CN, zh-TW, en-US, ko-KR, th-TH, vi-VN, ar-SA, de-DE, fr-FR, ja-JP + Default: zh-CN + """ + supported = { + "zh-CN", + "zh-TW", + "en-US", + "ko-KR", + "th-TH", + "vi-VN", + "ar-SA", + "de-DE", + "fr-FR", + "ja-JP", + } + l = (lang or "").strip() + if not l: + return "zh-CN" + alias = { + "zh": "zh-CN", + "zh-cn": "zh-CN", + "zh-hans": "zh-CN", + "zh-tw": "zh-TW", + "zh-hant": "zh-TW", + "en": "en-US", + "en-us": "en-US", + "ko": "ko-KR", + "ko-kr": "ko-KR", + "ja": "ja-JP", + "ja-jp": "ja-JP", + "fr": "fr-FR", + "fr-fr": "fr-FR", + "de": "de-DE", + "de-de": "de-DE", + "vi": "vi-VN", + "vi-vn": "vi-VN", + "th": "th-TH", + "th-th": "th-TH", + "ar": "ar-SA", + "ar-sa": "ar-SA", + } + l2 = alias.get(l.lower(), l) + return l2 if l2 in supported else "zh-CN" + + +@backtest_bp.route('/backtest', methods=['POST']) +def run_backtest(): + """ + Run indicator backtest + + Params: + indicatorId: Indicator ID (optional) + indicatorCode: Indicator Python code + symbol: Symbol + market: Market type + timeframe: Timeframe + startDate: Start date (YYYY-MM-DD) + endDate: End date (YYYY-MM-DD) + initialCapital: Initial capital (default 10000) + commission: Commission rate (default 0.001) + """ + try: + data = request.get_json() + if not data: + return jsonify({ + 'code': 0, + 'msg': 'Request body is required', + 'data': None + }), 400 + + # Extract params + user_id = int(data.get('userid') or data.get('userId') or 1) + indicator_code = data.get('indicatorCode', '') + indicator_id = data.get('indicatorId') + symbol = data.get('symbol', '') + market = data.get('market', '') + timeframe = data.get('timeframe', '1D') + start_date_str = data.get('startDate', '') + end_date_str = data.get('endDate', '') + initial_capital = float(data.get('initialCapital', 10000)) + commission = float(data.get('commission', 0.001)) + slippage = float(data.get('slippage', 0.0)) + leverage = int(data.get('leverage', 1)) + trade_direction = data.get('tradeDirection', 'long') # long, short, both + strategy_config = data.get('strategyConfig') or {} + + # (Debug) log received params if needed + + # If frontend only provides indicatorId, load code from local DB. + if (not indicator_code or not str(indicator_code).strip()) and indicator_id: + try: + iid = int(indicator_id) + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT code FROM qd_indicator_codes WHERE id = ?", (iid,)) + row = cur.fetchone() + cur.close() + if row and row.get('code'): + indicator_code = row.get('code') + except Exception: + pass + + # 参数验证 + if not all([indicator_code, symbol, market, timeframe, start_date_str, end_date_str]): + return jsonify({ + 'code': 0, + 'msg': 'Missing required parameters', + 'data': None + }), 400 + + # 转换日期 + # 开始日期:当天的 00:00:00 + start_date = datetime.strptime(start_date_str, '%Y-%m-%d') + # 结束日期:当天的 23:59:59,确保包含整天的数据 + end_date = datetime.strptime(end_date_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59) + + # 验证时间范围限制 + days_diff = (end_date - start_date).days + + # 根据周期设置不同的时间限制 + if timeframe == '1m': + max_days = 30 # 1分钟K线最多1个月 + max_range_text = '1个月' + elif timeframe == '5m': + max_days = 180 # 5分钟K线最多6个月 + max_range_text = '6个月' + elif timeframe in ['15m', '30m']: + max_days = 365 # 15分钟和30分钟K线最多1年 + max_range_text = '1年' + else: # 1H, 4H, 1D, 1W + max_days = 1095 # 1小时及以上最多3年 + max_range_text = '3年' + + if days_diff > max_days: + return jsonify({ + 'code': 0, + 'msg': f'回测时间范围超出限制:{timeframe}周期最多可回测{max_range_text}({max_days}天),当前选择了{days_diff}天', + 'data': None + }), 400 + + + # 执行回测 + result = backtest_service.run( + indicator_code=indicator_code, + market=market, + symbol=symbol, + timeframe=timeframe, + start_date=start_date, + end_date=end_date, + initial_capital=initial_capital, + commission=commission, + slippage=slippage, + leverage=leverage, + trade_direction=trade_direction, + strategy_config=strategy_config + ) + + # Persist backtest run for AI optimization / history + run_id = None + try: + now_ts = int(time.time()) + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + INSERT INTO qd_backtest_runs + (user_id, indicator_id, market, symbol, timeframe, start_date, end_date, + initial_capital, commission, slippage, leverage, trade_direction, + strategy_config, status, error_message, result_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + int(indicator_id) if indicator_id is not None else None, + market, + symbol, + timeframe, + start_date_str, + end_date_str, + initial_capital, + commission, + slippage, + leverage, + trade_direction, + json.dumps(strategy_config or {}, ensure_ascii=False), + 'success', + '', + json.dumps(result or {}, ensure_ascii=False), + now_ts + ) + ) + run_id = cur.lastrowid + db.commit() + cur.close() + except Exception: + # Do not break the main backtest response if persistence fails. + logger.warning("Failed to persist backtest run", exc_info=True) + + return jsonify({ + 'code': 1, + 'msg': 'Backtest succeeded', + 'data': { + 'runId': run_id, + 'result': result + } + }) + + except ValueError as e: + logger.warning(f"Invalid backtest parameters: {str(e)}") + return jsonify({ + 'code': 0, + 'msg': str(e), + 'data': None + }), 400 + except Exception as e: + logger.error(f"Backtest failed: {str(e)}") + logger.error(traceback.format_exc()) + # Best-effort persist failed run (if we have enough context) + try: + data = data if isinstance(data, dict) else {} + user_id = int(data.get('userid') or data.get('userId') or 1) + indicator_id = data.get('indicatorId') + now_ts = int(time.time()) + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + INSERT INTO qd_backtest_runs + (user_id, indicator_id, market, symbol, timeframe, start_date, end_date, + initial_capital, commission, slippage, leverage, trade_direction, + strategy_config, status, error_message, result_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + int(indicator_id) if indicator_id is not None else None, + str(data.get('market', '') or ''), + str(data.get('symbol', '') or ''), + str(data.get('timeframe', '') or ''), + str(data.get('startDate', '') or ''), + str(data.get('endDate', '') or ''), + float(data.get('initialCapital', 0) or 0), + float(data.get('commission', 0) or 0), + float(data.get('slippage', 0) or 0), + int(data.get('leverage', 1) or 1), + str(data.get('tradeDirection', 'long') or 'long'), + json.dumps(data.get('strategyConfig') or {}, ensure_ascii=False), + 'failed', + str(e), + '', + now_ts + ) + ) + db.commit() + cur.close() + except Exception: + pass + return jsonify({ + 'code': 0, + 'msg': f'Backtest failed: {str(e)}', + 'data': None + }), 500 + + +@backtest_bp.route('/backtest/history', methods=['POST']) +def get_backtest_history(): + """ + Get backtest run history (saved in SQLite). + + Params: + userid: User ID (default 1) + limit: Page size (default 50, max 200) + offset: Offset (default 0) + indicatorId: Optional indicator id filter + symbol: Optional symbol filter + market: Optional market filter + timeframe: Optional timeframe filter + """ + try: + data = request.get_json() or {} + user_id = int(data.get('userid') or data.get('userId') or 1) + limit = int(data.get('limit') or 50) + offset = int(data.get('offset') or 0) + limit = max(1, min(limit, 200)) + offset = max(0, offset) + + indicator_id = data.get('indicatorId') + symbol = (data.get('symbol') or '').strip() + market = (data.get('market') or '').strip() + timeframe = (data.get('timeframe') or '').strip() + + where = ["user_id = ?"] + params = [user_id] + if indicator_id is not None and str(indicator_id).strip() != "": + try: + where.append("indicator_id = ?") + params.append(int(indicator_id)) + except Exception: + pass + if symbol: + where.append("symbol = ?") + params.append(symbol) + if market: + where.append("market = ?") + params.append(market) + if timeframe: + where.append("timeframe = ?") + params.append(timeframe) + where_sql = " AND ".join(where) + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + f""" + SELECT id, user_id, indicator_id, market, symbol, timeframe, + start_date, end_date, initial_capital, commission, slippage, + leverage, trade_direction, strategy_config, status, error_message, + created_at + FROM qd_backtest_runs + WHERE {where_sql} + ORDER BY id DESC + LIMIT ? OFFSET ? + """, + (*params, limit, offset) + ) + rows = cur.fetchall() or [] + cur.close() + + # Parse strategy_config JSON best-effort + for r in rows: + try: + r['strategy_config'] = json.loads(r.get('strategy_config') or '{}') + except Exception: + pass + + return jsonify({'code': 1, 'msg': 'OK', 'data': rows}) + except Exception as e: + logger.error(f"get_backtest_history failed: {e}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@backtest_bp.route('/backtest/get', methods=['POST']) +def get_backtest_run(): + """ + Get a backtest run detail by run id (includes result_json). + + Params: + userid: User ID (default 1) + runId: Backtest run id (required) + """ + try: + data = request.get_json() or {} + user_id = int(data.get('userid') or data.get('userId') or 1) + run_id = int(data.get('runId') or 0) + if not run_id: + return jsonify({'code': 0, 'msg': 'runId is required', 'data': None}), 400 + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT id, user_id, indicator_id, market, symbol, timeframe, + start_date, end_date, initial_capital, commission, slippage, + leverage, trade_direction, strategy_config, status, error_message, + result_json, created_at + FROM qd_backtest_runs + WHERE id = ? AND user_id = ? + """, + (run_id, user_id), + ) + row = cur.fetchone() + cur.close() + + if not row: + return jsonify({'code': 0, 'msg': 'run not found', 'data': None}), 404 + + try: + row['strategy_config'] = json.loads(row.get('strategy_config') or '{}') + except Exception: + pass + try: + row['result'] = json.loads(row.get('result_json') or '{}') + except Exception: + row['result'] = {} + row.pop('result_json', None) + + return jsonify({'code': 1, 'msg': 'OK', 'data': row}) + except Exception as e: + logger.error(f"get_backtest_run failed: {e}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +def _heuristic_ai_advice(runs: list[dict], lang: str) -> str: + """ + Heuristic fallback when no model key is configured. + Returns Chinese suggestions for parameter tuning. + """ + if not runs: + msg_map = { + "zh-CN": "未找到可分析的回测记录。", + "zh-TW": "未找到可分析的回測記錄。", + "en-US": "No backtest runs selected.", + "ko-KR": "분석할 백테스트 기록을 찾을 수 없습니다.", + "th-TH": "ไม่พบประวัติแบ็กเทสต์สำหรับการวิเคราะห์", + "vi-VN": "Không tìm thấy lịch sử backtest để phân tích.", + "ar-SA": "لم يتم العثور على سجلات اختبار خلفي لتحليلها.", + "de-DE": "Keine Backtest-Läufe zur Analyse ausgewählt.", + "fr-FR": "Aucune exécution de backtest sélectionnée pour analyse.", + "ja-JP": "分析するバックテスト記録が見つかりません。", + } + return msg_map.get(lang, msg_map["en-US"]) + + # Use the last run as primary context, but mention multi-run comparison if provided. + r0 = runs[0] + result = (r0.get("result") or {}) if isinstance(r0, dict) else {} + cfg = (r0.get("strategy_config") or {}) if isinstance(r0, dict) else {} + risk = cfg.get("risk") or {} + pos = cfg.get("position") or {} + scale = cfg.get("scale") or {} + + total_return = float(result.get("totalReturn") or 0.0) + max_dd = float(result.get("maxDrawdown") or 0.0) + sharpe = float(result.get("sharpeRatio") or 0.0) + win_rate = float(result.get("winRate") or 0.0) + profit_factor = float(result.get("profitFactor") or 0.0) + trades = int(result.get("totalTrades") or 0) + + stop_loss = float(risk.get("stopLossPct") or 0.0) + take_profit = float(risk.get("takeProfitPct") or 0.0) + trailing = (risk.get("trailing") or {}) if isinstance(risk.get("trailing"), dict) else {} + trailing_enabled = bool(trailing.get("enabled")) + trailing_pct = float(trailing.get("pct") or 0.0) + trailing_act = float(trailing.get("activationPct") or 0.0) + + entry_pct = float(pos.get("entryPct") or 1.0) + trend_add = scale.get("trendAdd") or {} + dca_add = scale.get("dcaAdd") or {} + trend_reduce = scale.get("trendReduce") or {} + adverse_reduce = scale.get("adverseReduce") or {} + + # Minimal localized headings to keep heuristic readable across locales. + headings = { + "zh-CN": {"overall": "【总体建议】", "params": "【参数建议(可直接改回测配置测试)】", "next": "【下一步建议的回测方法】"}, + "zh-TW": {"overall": "【總體建議】", "params": "【參數建議(可直接改回測配置測試)】", "next": "【下一步回測方法建議】"}, + "en-US": {"overall": "Overall", "params": "Parameter suggestions (edit backtest config and re-run)", "next": "Next steps"}, + "ko-KR": {"overall": "요약", "params": "파라미터 제안(백테스트 설정 변경)", "next": "다음 단계"}, + "th-TH": {"overall": "สรุป", "params": "ข้อเสนอแนะพารามิเตอร์ (ปรับค่าที่ตั้งแบ็กเทสต์)", "next": "ขั้นตอนถัดไป"}, + "vi-VN": {"overall": "Tổng quan", "params": "Gợi ý tham số (sửa cấu hình backtest và chạy lại)", "next": "Bước tiếp theo"}, + "ar-SA": {"overall": "ملخص", "params": "اقتراحات المعلمات (عدّل إعدادات الاختبار وأعد التشغيل)", "next": "الخطوات التالية"}, + "de-DE": {"overall": "Überblick", "params": "Parameter-Vorschläge (Backtest-Konfiguration anpassen)", "next": "Nächste Schritte"}, + "fr-FR": {"overall": "Vue d’ensemble", "params": "Suggestions de paramètres (modifier la config et relancer)", "next": "Étapes suivantes"}, + "ja-JP": {"overall": "概要", "params": "パラメータ提案(設定変更→再バックテスト)", "next": "次のステップ"}, + } + h = headings.get(lang, headings["en-US"]) + + lines = [] + if lang == "en-US": + if len(runs) > 1: + lines.append(f"Received {len(runs)} backtest runs. Suggestions below focus on run #{r0.get('id','')}; validate with A/B tests across runs.") + lines.append(h["overall"]) + elif lang == "zh-TW": + if len(runs) > 1: + lines.append(f"已收到 {len(runs)} 條回測記錄。以下以記錄 #{r0.get('id','')} 為主給出參數調整建議,並建議你用多組記錄做 A/B 驗證。") + lines.append(h["overall"]) + else: + if len(runs) > 1: + if lang == "ko-KR": + lines.append(f"{len(runs)}개의 백테스트 기록을 받았습니다. 아래는 #{r0.get('id','')} 기준으로 제안하며, 여러 기록으로 A/B 검증을 권장합니다.") + elif lang == "th-TH": + lines.append(f"ได้รับประวัติแบ็กเทสต์ {len(runs)} รายการ ข้อเสนอแนะด้านล่างอิงจาก #{r0.get('id','')} และแนะนำให้ทำ A/B test เทียบหลายชุด") + elif lang == "vi-VN": + lines.append(f"Đã nhận {len(runs)} bản ghi backtest. Gợi ý bên dưới tập trung vào #{r0.get('id','')} và khuyến nghị A/B test với nhiều bản ghi.") + elif lang == "ar-SA": + lines.append(f"تم استلام {len(runs)} من سجلات الاختبار الخلفي. تركّز الاقتراحات أدناه على التشغيل #{r0.get('id','')} مع توصية باختبارات A/B.") + elif lang == "de-DE": + lines.append(f"{len(runs)} Backtest-Läufe empfangen. Vorschläge unten fokussieren auf Lauf #{r0.get('id','')}; A/B-Tests über mehrere Läufe empfohlen.") + elif lang == "fr-FR": + lines.append(f"{len(runs)} exécutions de backtest reçues. Suggestions ci-dessous centrées sur #{r0.get('id','')}; A/B tests recommandés.") + elif lang == "ja-JP": + lines.append(f"{len(runs)} 件のバックテスト記録を受け取りました。以下は #{r0.get('id','')} を中心に提案し、複数記録でA/B検証を推奨します。") + else: + lines.append(f"Received {len(runs)} backtest runs. Suggestions below focus on run #{r0.get('id','')}; validate with A/B tests across runs.") + lines.append(h["overall"]) + if sharpe < 0 or total_return < 0: + if lang == "en-US": + lines.append("- Strategy is losing/unstable: reduce risk first (lower entryPct, fewer/smaller scale-ins), then refine signal filters.") + elif lang == "zh-TW": + lines.append("- 目前策略偏虧損/不穩定:先降低風險暴露(降低開倉資金占比 entryPct、減少加倉次數/比例),再調整信號過濾。") + else: + lines.append("- 当前策略整体偏亏损/不稳定:优先降低风险暴露(降低开仓资金占比 entryPct、减少加仓次数/比例),再调信号过滤。") + if max_dd > 30: + if lang == "en-US": + lines.append("- Max drawdown is high: tighten stop-loss or reduce leverage/entry size; consider enabling trailing to protect profits.") + elif lang == "zh-TW": + lines.append("- 最大回撤偏大:建議優先收緊止損或降低槓桿/開倉倉位;同時考慮啟用移動止盈以保護盈利回撤。") + else: + lines.append("- 最大回撤较大:建议优先收紧止损或降低杠杆/开仓仓位;同时考虑启用移动止盈保护盈利回撤。") + if trades < 10: + if lang == "en-US": + lines.append("- Too few trades: rules may be too strict; relax thresholds or remove one filter to get enough samples.") + elif lang == "zh-TW": + lines.append("- 交易次數偏少:可能條件過嚴,建議適度放寬信號門檻或減少過濾條件,確保有足夠樣本驗證。") + else: + lines.append("- 交易次数偏少:可能条件过严,建议适当放宽信号阈值或减少过滤条件,确保有足够样本验证。") + if win_rate < 35 and profit_factor >= 1.2: + if lang == "en-US": + lines.append("- Low win rate but decent PF: consider slightly wider stop-loss and use trailing to lock profits.") + elif lang == "zh-TW": + lines.append("- 勝率偏低但盈虧比不差:可考慮略放寬止損(讓盈利單跑起來),並用移動止盈鎖住利潤。") + else: + lines.append("- 胜率偏低但盈亏比不差:可以考虑放宽止损(让盈利单跑起来)并用移动止盈锁利润。") + if win_rate >= 55 and profit_factor < 1.1: + if lang == "en-US": + lines.append("- Win rate is OK but PF is low: raise take-profit or enable trailing to improve winners; avoid taking profits too early.") + elif lang == "zh-TW": + lines.append("- 勝率不低但盈虧比偏小:考慮提高止盈或啟用移動止盈,讓單筆盈利更充分;避免過早止盈。") + else: + lines.append("- 胜率不低但盈亏比偏小:考虑提高止盈或启用移动止盈,让单笔盈利更充分;避免过早止盈。") + + lines.append("\n" + h["params"]) + if stop_loss <= 0: + if lang == "en-US": + lines.append("- Stop-loss: set stopLossPct (margin PnL basis). For crypto leverage, start with 2%~6% (then consider leverage conversion) and grid test.") + elif lang == "zh-TW": + lines.append("- 止損:建議設定 stopLossPct(按保證金口徑)。在加密+槓桿下,先從 2%~6%(再結合槓桿換算)做網格測試。") + else: + lines.append("- 止损:建议设置 stopLossPct(按保证金口径)。在加密+杠杆下,先从 2%~6%(再结合杠杆换算)做网格测试。") + else: + if lang == "en-US": + lines.append(f"- Stop-loss: current stopLossPct={stop_loss:.4f} (margin basis). Test ±30% around it and monitor drawdown/liquidations.") + elif lang == "zh-TW": + lines.append(f"- 止損:目前 stopLossPct={stop_loss:.4f}(保證金口徑)。建議圍繞它做 ±30% 區間測試,並觀察回撤/爆倉次數變化。") + else: + lines.append(f"- 止损:当前 stopLossPct={stop_loss:.4f}(保证金口径)。建议围绕它做 ±30% 的区间测试,并观察回撤/爆仓次数变化。") + if take_profit > 0 and (not trailing_enabled): + if lang == "en-US": + lines.append(f"- Take-profit: current takeProfitPct={take_profit:.4f}. Also test enabling trailing to reduce profit giveback.") + elif lang == "zh-TW": + lines.append(f"- 止盈:目前 takeProfitPct={take_profit:.4f}。建議同時測試啟用移動止盈(trailing)以降低盈利回撤。") + else: + lines.append(f"- 止盈:当前 takeProfitPct={take_profit:.4f}。建议同时测试开启移动止盈(trailing)以降低盈利回撤。") + if trailing_enabled: + if lang == "en-US": + lines.append(f"- Trailing: enabled, pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}. Set activation near typical winner PnL and test pct at 0.5x~1.5x.") + elif lang == "zh-TW": + lines.append(f"- 移動止盈:已啟用,pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}。建議將 activationPct 設為略低於常見單筆盈利水平,並把 pct 做 0.5x~1.5x 測試。") + else: + lines.append(f"- 移动止盈:已启用,pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}。建议把 activationPct 设为略低于常见单笔盈利水平,并把 pct 做 0.5x~1.5x 测试。") + else: + if lang == "en-US": + lines.append("- Trailing: consider trailing.enabled=true; start with pct=1%~3% (margin basis) and test.") + elif lang == "zh-TW": + lines.append("- 移動止盈:建議開啟 trailing.enabled=true,並從 pct=1%~3%(保證金口徑換算後)開始測試。") + else: + lines.append("- 移动止盈:建议开启 trailing.enabled=true,并从 pct=1%~3%(保证金口径换算后)开始测试。") + if lang == "en-US": + lines.append(f"- Entry sizing: entryPct={entry_pct:.4f}. Test 0.2/0.3/0.5/0.8 to find a better return/drawdown sweet spot.") + elif lang == "zh-TW": + lines.append(f"- 開倉倉位:目前 entryPct={entry_pct:.4f}。建議先用 0.2/0.3/0.5/0.8 分層回測,找收益/回撤更優的甜區。") + else: + lines.append(f"- 开仓仓位:当前 entryPct={entry_pct:.4f}。建议先用 0.2/0.3/0.5/0.8 做分层回测,找收益/回撤更优的甜区。") + + # Scaling (very light guidance) + if isinstance(trend_add, dict) and trend_add.get("enabled"): + if lang == "en-US": + lines.append("- Trend scale-in: reduce sizePct or maxTimes to avoid drawdown expansion; verify same-bar conflict rules match expectations.") + elif lang == "zh-TW": + lines.append("- 順勢加倉:建議優先降低 sizePct 或 maxTimes,避免回撤擴大;並確認同K線主信號禁用加減倉規則符合預期。") + else: + lines.append("- 顺势加仓:建议优先降低 sizePct 或 maxTimes,避免回撤扩大;并确保同K线主信号禁用加减仓的规则与你预期一致。") + if isinstance(dca_add, dict) and dca_add.get("enabled"): + if lang == "en-US": + lines.append("- DCA scale-in: very risky under leverage; keep maxTimes small, sizePct low, and use stricter stop-loss.") + elif lang == "zh-TW": + lines.append("- 逆勢加倉:加密槓桿下風險極高,建議 maxTimes 更小、sizePct 更低,並採用更嚴格止損。") + else: + lines.append("- 逆势加仓:加密杠杆下风险极高,建议 maxTimes 更小、sizePct 更低,并强制更严格止损。") + if isinstance(trend_reduce, dict) and trend_reduce.get("enabled"): + if lang == "en-US": + lines.append("- Trend reduce: can lower volatility but may reduce returns; test together with trailing.") + elif lang == "zh-TW": + lines.append("- 順勢減倉:有助降低波動,但可能降低收益;建議搭配移動止盈一起做對比測試。") + else: + lines.append("- 顺势减仓:适合降低波动,但可能降低收益;建议和移动止盈一起对比测试。") + if isinstance(adverse_reduce, dict) and adverse_reduce.get("enabled"): + if lang == "en-US": + lines.append("- Adverse reduce: can control drawdowns but increases fees/slippage; consider enabling under higher leverage.") + elif lang == "zh-TW": + lines.append("- 逆勢減倉:可用於控回撤,但可能增加手續費/滑點成本;建議優先在高槓桿時開啟。") + else: + lines.append("- 逆势减仓:可用于控回撤,但可能增加手续费/滑点成本;建议优先在高杠杆时开启。") + + lines.append("\n" + h["next"]) + if lang == "zh-CN": + lines.append("- 固定信号逻辑不变,只用参数做网格/分组测试(先粗再细)。每次只改 1~2 个参数,避免结论不可归因。") + lines.append("- 重点同时看:总收益、最大回撤、夏普、交易次数、爆仓/止损触发次数。") + elif lang == "zh-TW": + lines.append("- 固定信號邏輯不變,只用參數做網格/分組測試(先粗後細)。每次只改 1~2 個參數,避免結論不可歸因。") + lines.append("- 重點同時看:總收益、最大回撤、夏普、交易次數、爆倉/止損觸發次數。") + else: + # Keep English for other locales to ensure readability in fallback mode. + lines.append("- Keep signal logic fixed; run parameter grid tests (coarse → fine). Change only 1-2 params per run.") + lines.append("- Track: total return, max drawdown, Sharpe, trade count, liquidation/stop-loss triggers.") + return "\n".join(lines) + + +@backtest_bp.route('/backtest/aiAnalyze', methods=['POST']) +def ai_analyze_backtest_runs(): + """ + AI analyze selected backtest runs and provide strategy_config tuning suggestions. + + Params: + userid: User ID (default 1) + runIds: list[int] (required) + """ + try: + data = request.get_json() or {} + user_id = int(data.get('userid') or data.get('userId') or 1) + lang = _normalize_lang(data.get('lang')) + run_ids = data.get('runIds') or [] + if not isinstance(run_ids, list) or not run_ids: + return jsonify({'code': 0, 'msg': 'runIds is required', 'data': None}), 400 + + # Limit to avoid huge prompts / payload. + run_ids = [int(x) for x in run_ids if str(x).strip().isdigit()] + run_ids = run_ids[:10] + if not run_ids: + return jsonify({'code': 0, 'msg': 'runIds is required', 'data': None}), 400 + + placeholders = ",".join(["?"] * len(run_ids)) + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + f""" + SELECT id, user_id, indicator_id, market, symbol, timeframe, + start_date, end_date, initial_capital, commission, slippage, + leverage, trade_direction, strategy_config, status, error_message, + result_json, created_at + FROM qd_backtest_runs + WHERE user_id = ? AND id IN ({placeholders}) + ORDER BY id DESC + """, + (user_id, *run_ids), + ) + rows = cur.fetchall() or [] + cur.close() + + runs: list[dict] = [] + for r in rows: + try: + r['strategy_config'] = json.loads(r.get('strategy_config') or '{}') + except Exception: + r['strategy_config'] = {} + try: + r['result'] = json.loads(r.get('result_json') or '{}') + except Exception: + r['result'] = {} + r.pop('result_json', None) + runs.append(r) + + if not runs: + return jsonify({'code': 0, 'msg': 'runs not found', 'data': None}), 404 + + # OpenRouter (optional) + base_url, api_key = _openrouter_base_and_key() + if not api_key: + analysis = _heuristic_ai_advice(runs, lang) + return jsonify({'code': 1, 'msg': 'OK', 'data': {'analysis': analysis, 'mode': 'heuristic', 'lang': lang}}) + + model = (os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini") or "").strip() or "openai/gpt-4o-mini" + temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.4") or 0.4) + + output_lang_map = { + "zh-CN": "Simplified Chinese", + "zh-TW": "Traditional Chinese", + "en-US": "English", + "ko-KR": "Korean", + "th-TH": "Thai", + "vi-VN": "Vietnamese", + "ar-SA": "Arabic", + "de-DE": "German", + "fr-FR": "French", + "ja-JP": "Japanese", + } + output_lang = output_lang_map.get(lang, "English") + + system_prompt = ( + "You are an expert quantitative trading researcher specialized in crypto leveraged trading. " + "Your job is to analyze backtest configurations and results, then propose actionable parameter tuning suggestions. " + f"Output in {output_lang}. Be concise and practical. " + "Do NOT change indicator code logic. Focus on strategy_config parameters only: risk (stopLossPct/takeProfitPct/trailing), " + "position (entryPct), scale (trendAdd/dcaAdd/trendReduce/adverseReduce), execution assumptions. " + "Provide: (1) diagnosis, (2) recommended parameter ranges, (3) suggested A/B test plan (few steps). " + "Avoid investment advice language; focus on engineering/experimental recommendations." + ) + + user_payload = { + "selectedRuns": [ + { + "id": r.get("id"), + "market": r.get("market"), + "symbol": r.get("symbol"), + "timeframe": r.get("timeframe"), + "start_date": r.get("start_date"), + "end_date": r.get("end_date"), + "leverage": r.get("leverage"), + "trade_direction": r.get("trade_direction"), + "strategy_config": r.get("strategy_config") or {}, + "result": r.get("result") or {}, + "status": r.get("status"), + } + for r in runs + ] + } + + resp = requests.post( + f"{base_url}/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={ + "model": model, + "temperature": temperature, + "stream": False, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": json.dumps(user_payload, ensure_ascii=False)}, + ], + }, + timeout=120, + ) + try: + resp.raise_for_status() + j = resp.json() + content = (((j.get("choices") or [{}])[0]).get("message") or {}).get("content") or "" + analysis = content.strip() + if not analysis: + analysis = _heuristic_ai_advice(runs, lang) + return jsonify({'code': 1, 'msg': 'OK', 'data': {'analysis': analysis, 'mode': 'heuristic_fallback', 'lang': lang}}) + return jsonify({'code': 1, 'msg': 'OK', 'data': {'analysis': analysis, 'mode': 'llm', 'lang': lang}}) + except requests.exceptions.RequestException as e: + # Do not fail the whole endpoint if LLM provider is misconfigured or rate-limited. + logger.error(f"OpenRouter request failed, falling back to heuristic: {e}") + analysis = _heuristic_ai_advice(runs, lang) + return jsonify( + { + 'code': 1, + 'msg': 'OK', + 'data': { + 'analysis': analysis, + 'mode': 'heuristic_fallback', + 'lang': lang, + 'llmError': str(e), + }, + } + ) + + except Exception as e: + logger.error(f"ai_analyze_backtest_runs failed: {e}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + diff --git a/backend_api_python/app/routes/credentials.py b/backend_api_python/app/routes/credentials.py new file mode 100644 index 0000000..3dd5f2c --- /dev/null +++ b/backend_api_python/app/routes/credentials.py @@ -0,0 +1,181 @@ +""" +Exchange credentials vault (local-only). + +Local deployment notes: +- No encryption/decryption is used. +- Credentials are stored as plaintext JSON in DB (encrypted_config column kept for compatibility). +""" + +import time +import traceback +import json +from flask import Blueprint, request, jsonify + +from app.utils.db import get_db_connection +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +credentials_bp = Blueprint('credentials', __name__) + +DEFAULT_USER_ID = 1 + + +def _api_key_hint(api_key: str) -> str: + if not api_key: + return '' + s = str(api_key) + if len(s) <= 8: + return s[:2] + '***' + return f"{s[:4]}...{s[-4:]}" + + +@credentials_bp.route('/list', methods=['GET']) +def list_credentials(): + try: + user_id = request.args.get('user_id', type=int) or DEFAULT_USER_ID + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT id, user_id, name, exchange_id, api_key_hint, created_at, updated_at + FROM qd_exchange_credentials + WHERE user_id = ? + ORDER BY id DESC + """, + (user_id,) + ) + rows = cur.fetchall() or [] + cur.close() + + return jsonify({'code': 1, 'msg': 'success', 'data': {'items': rows}}) + except Exception as e: + logger.error(f"list_credentials failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': {'items': []}}), 500 + + +@credentials_bp.route('/create', methods=['POST']) +def create_credential(): + try: + data = request.get_json() or {} + user_id = int(data.get('user_id') or DEFAULT_USER_ID) + name = (data.get('name') or '').strip() + exchange_id = (data.get('exchange_id') or '').strip() + api_key = (data.get('api_key') or '').strip() + secret_key = (data.get('secret_key') or '').strip() + passphrase = (data.get('passphrase') or '').strip() + + if not exchange_id: + return jsonify({'code': 0, 'msg': 'Missing exchange_id', 'data': None}), 400 + if not api_key or not secret_key: + return jsonify({'code': 0, 'msg': 'Missing api_key/secret_key', 'data': None}), 400 + + plaintext_config = json.dumps({ + 'exchange_id': exchange_id, + 'api_key': api_key, + 'secret_key': secret_key, + 'passphrase': passphrase + }, ensure_ascii=False) + now = int(time.time()) + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + INSERT INTO qd_exchange_credentials (user_id, name, exchange_id, api_key_hint, encrypted_config, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (user_id, name, exchange_id, _api_key_hint(api_key), plaintext_config, now, now) + ) + new_id = cur.lastrowid + db.commit() + cur.close() + + return jsonify({'code': 1, 'msg': 'success', 'data': {'id': new_id}}) + except Exception as e: + logger.error(f"create_credential failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@credentials_bp.route('/delete', methods=['DELETE']) +def delete_credential(): + try: + cred_id = request.args.get('id', type=int) + user_id = request.args.get('user_id', type=int) or DEFAULT_USER_ID + if not cred_id: + return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400 + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "DELETE FROM qd_exchange_credentials WHERE id = ? AND user_id = ?", + (cred_id, user_id) + ) + db.commit() + cur.close() + + return jsonify({'code': 1, 'msg': 'success', 'data': None}) + except Exception as e: + logger.error(f"delete_credential failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@credentials_bp.route('/get', methods=['GET']) +def get_credential(): + """ + Return decrypted credential for form auto-fill. + NOTE: In a production system, this must be protected by strong authentication/authorization. + """ + try: + cred_id = request.args.get('id', type=int) + user_id = request.args.get('user_id', type=int) or DEFAULT_USER_ID + if not cred_id: + return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400 + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT id, user_id, name, exchange_id, encrypted_config, api_key_hint, created_at, updated_at + FROM qd_exchange_credentials + WHERE id = ? AND user_id = ? + """, + (cred_id, user_id) + ) + row = cur.fetchone() + cur.close() + + if not row: + return jsonify({'code': 0, 'msg': 'Not found', 'data': None}), 404 + + decrypted = {} + raw = row.get('encrypted_config') or '' + if isinstance(raw, str) and raw.strip(): + try: + decrypted = json.loads(raw) + except Exception: + decrypted = {} + # Ensure exchange_id is present + decrypted['exchange_id'] = row.get('exchange_id') or decrypted.get('exchange_id') + + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': { + 'id': row.get('id'), + 'name': row.get('name'), + 'exchange_id': row.get('exchange_id'), + 'api_key_hint': row.get('api_key_hint'), + 'config': decrypted + } + }) + except Exception as e: + logger.error(f"get_credential failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + diff --git a/backend_api_python/app/routes/dashboard.py b/backend_api_python/app/routes/dashboard.py new file mode 100644 index 0000000..2b4677d --- /dev/null +++ b/backend_api_python/app/routes/dashboard.py @@ -0,0 +1,370 @@ +""" +Dashboard APIs (local-first). + +Endpoints: +- GET /api/dashboard/summary +- GET /api/dashboard/pendingOrders?page=1&pageSize=20 + +Notes: +- Paper mode: no real trading execution. Metrics are best-effort based on local DB tables. +""" + +from __future__ import annotations + +import json +import time +from typing import Any, Dict, List, Tuple + +from flask import Blueprint, jsonify, request + +from app.utils.db import get_db_connection +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +dashboard_bp = Blueprint("dashboard", __name__) + + +def _safe_int(v: Any, default: int) -> int: + try: + return int(v) + except Exception: + return default + + +def _safe_json_loads(value: Any, default: Any) -> Any: + if value is None: + return default + if isinstance(value, (dict, list)): + return value + if not isinstance(value, str): + return default + s = value.strip() + if not s: + return default + try: + return json.loads(s) + except Exception: + return default + + +def _as_list(value: Any) -> List[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(x) for x in value if str(x or "").strip()] + if isinstance(value, str): + s = value.strip() + if not s: + return [] + # allow comma-separated + if "," in s: + return [p.strip() for p in s.split(",") if p.strip()] + return [s] + return [] + + +def _calc_unrealized_pnl(side: str, entry_price: float, current_price: float, size: float) -> float: + try: + ep = float(entry_price or 0.0) + cp = float(current_price or 0.0) + sz = float(size or 0.0) + if ep <= 0 or cp <= 0 or sz <= 0: + return 0.0 + s = (side or "").strip().lower() + if s == "short": + return (ep - cp) * sz + return (cp - ep) * sz + except Exception: + return 0.0 + + +def _calc_pnl_percent(entry_price: float, size: float, pnl: float, leverage: float = 1.0, market_type: str = "spot") -> float: + try: + denom = float(entry_price or 0.0) * float(size or 0.0) + if denom <= 0: + return 0.0 + lev = float(leverage or 1.0) + if lev <= 0: + lev = 1.0 + mt = str(market_type or "").strip().lower() + # Margin PnL% (user expectation): pnl / (notional / leverage) + # = pnl / notional * leverage + mult = lev if mt in ("swap", "futures", "future", "perp", "perpetual") else 1.0 + return float(pnl) / denom * 100.0 * float(mult) + except Exception: + return 0.0 + + +@dashboard_bp.route("/summary", methods=["GET"]) +def summary(): + """ + Return dashboard summary used by `quantdinger_vue/src/views/dashboard/index.vue`. + """ + try: + # Strategy counts + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT id, strategy_name, strategy_type, status, initial_capital + FROM qd_strategies_trading + """ + ) + strategies = cur.fetchall() or [] + cur.close() + + running = [s for s in strategies if (s.get("status") or "").strip().lower() == "running"] + indicator_strategy_count = len([s for s in running if (s.get("strategy_type") or "") == "IndicatorStrategy"]) + ai_strategy_count = max(0, len(running) - indicator_strategy_count) + + # Positions (best-effort) + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT p.*, s.strategy_name, s.initial_capital, s.leverage, s.market_type + FROM qd_strategy_positions p + LEFT JOIN qd_strategies_trading s ON s.id = p.strategy_id + ORDER BY p.updated_at DESC + """ + ) + rows = cur.fetchall() or [] + cur.close() + + current_positions: List[Dict[str, Any]] = [] + total_unrealized_pnl = 0.0 + for r in rows: + pnl = _calc_unrealized_pnl( + side=str(r.get("side") or ""), + entry_price=float(r.get("entry_price") or 0.0), + current_price=float(r.get("current_price") or 0.0), + size=float(r.get("size") or 0.0), + ) + pct = _calc_pnl_percent( + float(r.get("entry_price") or 0.0), + float(r.get("size") or 0.0), + pnl, + leverage=float(r.get("leverage") or 1.0), + market_type=str(r.get("market_type") or "spot"), + ) + total_unrealized_pnl += float(pnl) + current_positions.append( + { + **r, + "strategy_name": r.get("strategy_name") or "", + "unrealized_pnl": float(pnl), + "pnl_percent": float(pct), + } + ) + + # Recent trades (best-effort) + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT t.*, s.strategy_name + FROM qd_strategy_trades t + LEFT JOIN qd_strategies_trading s ON s.id = t.strategy_id + ORDER BY t.created_at DESC + LIMIT 200 + """ + ) + recent_trades = cur.fetchall() or [] + cur.close() + + # Total equity/pnl (best-effort) + total_initial_capital = 0.0 + for s in strategies: + try: + total_initial_capital += float(s.get("initial_capital") or 0.0) + except Exception: + pass + total_pnl = float(total_unrealized_pnl) + total_equity = float(total_initial_capital + total_pnl) + + # Daily PnL chart (uses realized profit field if present, otherwise 0) + # Keep output stable even if profit is mostly empty. + day_to_profit: Dict[str, float] = {} + for trow in recent_trades: + ts = _safe_int(trow.get("created_at"), 0) + if ts <= 0: + continue + day = time.strftime("%Y-%m-%d", time.localtime(ts)) + try: + p = float(trow.get("profit") or 0.0) + except Exception: + p = 0.0 + day_to_profit[day] = float(day_to_profit.get(day, 0.0) + p) + daily_pnl_chart = [{"date": d, "profit": float(v)} for d, v in sorted(day_to_profit.items())] + + # Strategy performance pie (use unrealized pnl by strategy as best-effort) + sid_to_unreal: Dict[int, float] = {} + sid_to_name: Dict[int, str] = {} + for p in current_positions: + sid = _safe_int(p.get("strategy_id"), 0) + sid_to_name[sid] = str(p.get("strategy_name") or f"Strategy_{sid}") + sid_to_unreal[sid] = float(sid_to_unreal.get(sid, 0.0) + float(p.get("unrealized_pnl") or 0.0)) + strategy_pnl_chart = [{"name": sid_to_name[sid], "value": float(val)} for sid, val in sid_to_unreal.items()] + + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + "ai_strategy_count": int(ai_strategy_count), + "indicator_strategy_count": int(indicator_strategy_count), + "total_equity": float(total_equity), + "total_pnl": float(total_pnl), + "daily_pnl_chart": daily_pnl_chart, + "strategy_pnl_chart": strategy_pnl_chart, + "recent_trades": recent_trades, + "current_positions": current_positions, + }, + } + ) + except Exception as e: + logger.error(f"dashboard summary failed: {e}", exc_info=True) + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 + + +@dashboard_bp.route("/pendingOrders", methods=["GET"]) +def pending_orders(): + """ + Return pending orders list for dashboard page. + """ + try: + page = max(1, _safe_int(request.args.get("page"), 1)) + page_size = max(1, min(200, _safe_int(request.args.get("pageSize"), 20))) + offset = (page - 1) * page_size + + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT COUNT(1) AS cnt FROM pending_orders") + total = int((cur.fetchone() or {}).get("cnt") or 0) + cur.close() + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT o.*, + s.strategy_name, + s.notification_config AS strategy_notification_config, + s.exchange_config AS strategy_exchange_config, + s.market_type AS strategy_market_type, + s.market_category AS strategy_market_category, + s.execution_mode AS strategy_execution_mode + FROM pending_orders o + LEFT JOIN qd_strategies_trading s ON s.id = o.strategy_id + ORDER BY o.id DESC + LIMIT %s OFFSET %s + """, + (int(page_size), int(offset)), + ) + rows = cur.fetchall() or [] + cur.close() + + out: List[Dict[str, Any]] = [] + for r in rows: + status = (r.get("status") or "").strip().lower() + if status == "sent": + status = "completed" + if status == "deferred": + status = "pending" + + # Frontend expects these keys: + # - filled_amount, filled_price, error_message + filled_amount = float(r.get("filled") or 0.0) + filled_price = float(r.get("avg_price") or 0.0) if float(r.get("avg_price") or 0.0) > 0 else float(r.get("price") or 0.0) + + # Derive exchange_id + notify channels without leaking secrets to frontend. + ex_cfg = _safe_json_loads(r.get("strategy_exchange_config"), {}) or {} + notify_cfg = _safe_json_loads(r.get("strategy_notification_config"), {}) or {} + exchange_id = (r.get("exchange_id") or ex_cfg.get("exchange_id") or ex_cfg.get("exchangeId") or "").strip().lower() + notify_channels = _as_list((notify_cfg or {}).get("channels")) + if not notify_channels: + notify_channels = ["browser"] + market_type = (r.get("market_type") or r.get("strategy_market_type") or ex_cfg.get("market_type") or ex_cfg.get("marketType") or "").strip().lower() + market_category = str(r.get("strategy_market_category") or "").strip().lower() + execution_mode = str(r.get("strategy_execution_mode") or r.get("execution_mode") or "").strip().lower() + + # If non-crypto markets are "signal-only", show SIGNAL instead of blank exchange. + exchange_display = exchange_id + if not exchange_display: + if execution_mode == "signal" or (market_category and market_category != "crypto"): + exchange_display = "signal" + + out.append( + { + **r, + "strategy_name": r.get("strategy_name") or "", + "status": status, + "filled_amount": filled_amount, + "filled_price": filled_price, + "error_message": r.get("last_error") or "", + "exchange_id": exchange_id, + "exchange_display": exchange_display, + "notify_channels": notify_channels, + "market_type": market_type or (r.get("market_type") or ""), + } + ) + + # Never expose these strategy-level config blobs. + for item in out: + try: + item.pop("strategy_exchange_config", None) + item.pop("strategy_notification_config", None) + item.pop("strategy_market_type", None) + item.pop("strategy_market_category", None) + item.pop("strategy_execution_mode", None) + except Exception: + pass + + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + "list": out, + "page": page, + "pageSize": page_size, + "total": total, + }, + } + ) + except Exception as e: + logger.error(f"dashboard pendingOrders failed: {e}", exc_info=True) + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 + + +@dashboard_bp.route("/pendingOrders/", methods=["DELETE"]) +def delete_pending_order(order_id: int): + """ + Delete a pending order record (dashboard operation). + """ + try: + oid = int(order_id or 0) + if oid <= 0: + return jsonify({"code": 0, "msg": "invalid_id", "data": None}), 400 + + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT id, status FROM pending_orders WHERE id = %s", (oid,)) + row = cur.fetchone() or {} + if not row: + cur.close() + return jsonify({"code": 0, "msg": "not_found", "data": None}), 404 + st = (row.get("status") or "").strip().lower() + if st == "processing": + cur.close() + return jsonify({"code": 0, "msg": "cannot_delete_processing", "data": None}), 400 + cur.execute("DELETE FROM pending_orders WHERE id = %s", (oid,)) + db.commit() + cur.close() + + return jsonify({"code": 1, "msg": "success", "data": {"id": oid}}) + except Exception as e: + logger.error(f"dashboard delete pendingOrders failed: {e}", exc_info=True) + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 + diff --git a/backend_api_python/app/routes/health.py b/backend_api_python/app/routes/health.py new file mode 100644 index 0000000..0977b20 --- /dev/null +++ b/backend_api_python/app/routes/health.py @@ -0,0 +1,28 @@ +""" +健康检查路由 +""" +from flask import Blueprint, jsonify +from datetime import datetime + +health_bp = Blueprint('health', __name__) + + +@health_bp.route('/', methods=['GET']) +def index(): + """API 首页""" + return jsonify({ + 'name': 'QuantDinger Python API', + 'version': '2.0.0', + 'status': 'running', + 'timestamp': datetime.now().isoformat() + }) + + +@health_bp.route('/health', methods=['GET']) +def health_check(): + """健康检查""" + return jsonify({ + 'status': 'healthy', + 'timestamp': datetime.now().isoformat() + }) + diff --git a/backend_api_python/app/routes/indicator.py b/backend_api_python/app/routes/indicator.py new file mode 100644 index 0000000..7414ae3 --- /dev/null +++ b/backend_api_python/app/routes/indicator.py @@ -0,0 +1,433 @@ +""" +Indicator APIs (local-first). + +These endpoints are used by the frontend `/indicator-analysis` page. +In the original architecture, the frontend called PHP endpoints like: +`/addons/quantdinger/indicator/getIndicators`. + +For local mode, we expose Python equivalents under `/api/indicator/*`. +""" + +from __future__ import annotations + +import json +import os +import re +import time +from typing import Any, Dict, List + +from flask import Blueprint, Response, jsonify, request + +from app.utils.db import get_db_connection +from app.utils.logger import get_logger +import requests + +logger = get_logger(__name__) + +indicator_bp = Blueprint("indicator", __name__) + + +def _now_ts() -> int: + return int(time.time()) + + +def _extract_indicator_meta_from_code(code: str) -> Dict[str, str]: + """ + Extract indicator name/description from python code. + Expected variables: + my_indicator_name = "..." + my_indicator_description = "..." + """ + if not code or not isinstance(code, str): + return {"name": "", "description": ""} + + # Simple assignment capture for single/double quoted strings. + name_match = re.search(r'^\s*my_indicator_name\s*=\s*([\'"])(.*?)\1\s*$', code, re.MULTILINE) + desc_match = re.search(r'^\s*my_indicator_description\s*=\s*([\'"])(.*?)\1\s*$', code, re.MULTILINE) + + name = (name_match.group(2).strip() if name_match else "")[:100] + description = (desc_match.group(2).strip() if desc_match else "")[:500] + return {"name": name, "description": description} + + +def _row_to_indicator(row: Dict[str, Any], user_id: int) -> Dict[str, Any]: + """ + Map SQLite row -> frontend expected indicator shape. + + Frontend uses: + - id, name, description, code + - is_buy (1 bought, 0 custom) + - user_id / userId + - end_time (optional) + """ + return { + "id": row.get("id"), + "user_id": row.get("user_id") if row.get("user_id") is not None else user_id, + "is_buy": row.get("is_buy") if row.get("is_buy") is not None else 0, + "end_time": row.get("end_time") if row.get("end_time") is not None else 1, + "name": row.get("name") or "", + "code": row.get("code") or "", + "description": row.get("description") or "", + "publish_to_community": row.get("publish_to_community") if row.get("publish_to_community") is not None else 0, + "pricing_type": row.get("pricing_type") or "free", + "price": row.get("price") if row.get("price") is not None else 0, + # Local mode: encryption is not supported; keep field for frontend compatibility (always 0). + "is_encrypted": 0, + "preview_image": row.get("preview_image") or "", + # Prefer MySQL-like time fields; fallback to legacy local columns. + "createtime": row.get("createtime") or row.get("created_at"), + "updatetime": row.get("updatetime") or row.get("updated_at"), + } + + +@indicator_bp.route("/getIndicators", methods=["POST"]) +def get_indicators(): + """ + Get indicator list for a user. + + Request: + { userid: number } + + Response: + { code: 1, data: [ ... ] } + """ + try: + data = request.get_json() or {} + user_id = int(data.get("userid") or 1) + + with get_db_connection() as db: + cur = db.cursor() + # Local mode: "我的指标" should include both purchased and custom indicators. + cur.execute( + """ + SELECT + id, user_id, is_buy, end_time, name, code, description, + publish_to_community, pricing_type, price, is_encrypted, preview_image, + createtime, updatetime, created_at, updated_at + FROM qd_indicator_codes + WHERE user_id = ? + ORDER BY id DESC + """, + (user_id,), + ) + rows = cur.fetchall() or [] + cur.close() + + out = [_row_to_indicator(r, user_id) for r in rows] + return jsonify({"code": 1, "msg": "success", "data": out}) + except Exception as e: + logger.error(f"get_indicators failed: {str(e)}", exc_info=True) + return jsonify({"code": 0, "msg": str(e), "data": []}), 500 + + +@indicator_bp.route("/saveIndicator", methods=["POST"]) +def save_indicator(): + """ + Create or update an indicator. + + Request (frontend sends many extra fields; we store only the essentials): + { + userid: number, + id: number (0 for create), + name: string, + code: string, + description?: string, + ... + } + """ + try: + data = request.get_json() or {} + user_id = int(data.get("userid") or 1) + indicator_id = int(data.get("id") or 0) + code = data.get("code") or "" + name = (data.get("name") or "").strip() + description = (data.get("description") or "").strip() + publish_to_community = 1 if data.get("publishToCommunity") or data.get("publish_to_community") else 0 + pricing_type = (data.get("pricingType") or data.get("pricing_type") or "free").strip() or "free" + try: + price = float(data.get("price") or 0) + except Exception: + price = 0.0 + preview_image = (data.get("previewImage") or data.get("preview_image") or "").strip() + + if not code or not str(code).strip(): + return jsonify({"code": 0, "msg": "code is required", "data": None}), 400 + + # Local dev UX: if name/description not provided, derive from code variables. + if not name or not description: + meta = _extract_indicator_meta_from_code(code) + if not name: + name = meta.get("name") or "" + if not description: + description = meta.get("description") or "" + + if not name: + name = "Custom Indicator" + + now = _now_ts() + + with get_db_connection() as db: + cur = db.cursor() + if indicator_id and indicator_id > 0: + cur.execute( + """ + UPDATE qd_indicator_codes + SET name = ?, code = ?, description = ?, + publish_to_community = ?, pricing_type = ?, price = ?, preview_image = ?, + updatetime = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0) + """, + (name, code, description, publish_to_community, pricing_type, price, preview_image, now, now, indicator_id, user_id), + ) + else: + cur.execute( + """ + INSERT INTO qd_indicator_codes + (user_id, is_buy, end_time, name, code, description, + publish_to_community, pricing_type, price, preview_image, + createtime, updatetime, created_at, updated_at) + VALUES (?, 0, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (user_id, name, code, description, publish_to_community, pricing_type, price, preview_image, now, now, now, now), + ) + indicator_id = int(cur.lastrowid or 0) + db.commit() + cur.close() + + return jsonify({"code": 1, "msg": "success", "data": {"id": indicator_id, "userid": user_id}}) + except Exception as e: + logger.error(f"save_indicator failed: {str(e)}", exc_info=True) + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 + + +@indicator_bp.route("/deleteIndicator", methods=["POST"]) +def delete_indicator(): + """Delete an indicator by id.""" + try: + data = request.get_json() or {} + user_id = int(data.get("userid") or 1) + indicator_id = int(data.get("id") or 0) + if not indicator_id: + return jsonify({"code": 0, "msg": "id is required", "data": None}), 400 + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "DELETE FROM qd_indicator_codes WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0)", + (indicator_id, user_id), + ) + db.commit() + cur.close() + + return jsonify({"code": 1, "msg": "success", "data": None}) + except Exception as e: + logger.error(f"delete_indicator failed: {str(e)}", exc_info=True) + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 + + +@indicator_bp.route("/aiGenerate", methods=["POST"]) +def ai_generate(): + """ + SSE endpoint to generate indicator code. + + Frontend expects 'text/event-stream' with chunks: + data: {"content":"..."}\n\n + then: + data: [DONE]\n\n + + Local-first: if OpenRouter key is not configured, we return a reasonable template. + """ + data = request.get_json() or {} + prompt = (data.get("prompt") or "").strip() + existing = (data.get("existingCode") or "").strip() + + if not prompt: + # Keep SSE contract (match PHP behavior) so frontend doesn't look "stuck". + def _err_stream(): + yield "data: " + json.dumps({"error": "提示词不能为空"}, ensure_ascii=False) + "\n\n" + yield "data: [DONE]\n\n" + + return Response( + _err_stream(), + mimetype="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + # System prompt copied/adapted from the legacy PHP implementation. + SYSTEM_PROMPT = """# Role + +You are an expert Python quantitative trading developer. Your task is to write custom indicator or strategy scripts for a professional K-line chart component running in a browser (Pyodide environment). + +# Context & Environment + +1. **Runtime Environment**: Code runs in a browser sandbox, **network access is prohibited** (cannot use `pip` or `requests`). + +2. **Pre-installed Libraries**: The system has already imported `pandas as pd` and `numpy as np`. **DO NOT** include `import pandas as pd` or `import numpy as np` in your generated code. Use `pd` and `np` directly. + +3. **Input Data**: The system provides a variable `df` (Pandas DataFrame) with index from 0 to N. + - Columns include: `df['time']` (timestamp), `df['open']`, `df['high']`, `df['low']`, `df['close']`, `df['volume']`. + +# Output Requirement (Strict) + +At the end of code execution, you **MUST** define a dictionary variable named `output`. The system only reads this variable to render the chart. + +Additionally, you MUST define: +- my_indicator_name = "..." +- my_indicator_description = "..." + +`output` MUST follow this shape: +output = { + "name": my_indicator_name, + "plots": [ { "name": str, "data": list, "color": "#RRGGBB", "overlay": bool, "type": "line" (optional) } ], + "signals": [ { "type": "buy"|"sell", "text": str, "data": list, "color": "#RRGGBB" } ] (optional), + "calculatedVars": {} (optional) +} +Where `data` lists MUST have the same length as `df` and use `None` for "no value". + +Backtest/execution compatibility (recommended): +- Also set df['buy'] and df['sell'] as boolean columns (same length as df). + +# Signal confirmation / execution timing (IMPORTANT) +- Signals are generally confirmed on bar close. The backtest engine may execute them on the next bar open to better match live trading and avoid look-ahead bias. + +# Robustness requirements (IMPORTANT) +- Always handle NaN/inf and division-by-zero (common in RSI/BB/RSV calculations). +- Avoid overly restrictive entry/exit logic that results in zero buy or zero sell signals. + For multi-indicator strategies, do NOT require a crossover AND extreme RSI on the same bar unless explicitly requested. +- Prefer edge-triggered signals (one-shot) to avoid repeated consecutive signals: + buy = raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False)) + sell = raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False)) +- If your final conditions produce no buys or no sells in the visible range, relax logically (e.g., remove one filter or widen thresholds). + +IMPORTANT: Output Python code directly, without explanations, without descriptions, start directly with code, and do NOT use markdown code blocks like ```python. +""" + + def _template_code() -> str: + # Fallback template that follows the project expectations. + header = ( + f"my_indicator_name = \"Custom Indicator\"\n" + f"my_indicator_description = \"{prompt.replace('\\n', ' ')[:200]}\"\n\n" + ) + body = ( + "df = df.copy()\n\n" + "# Example: robust RSI with edge-triggered buy/sell (no position management, no TP/SL on chart)\n" + "rsi_len = 14\n" + "delta = df['close'].diff()\n" + "gain = delta.clip(lower=0)\n" + "loss = (-delta).clip(lower=0)\n" + "# Wilder-style smoothing (stable and avoids early NaN explosion)\n" + "avg_gain = gain.ewm(alpha=1/rsi_len, adjust=False).mean()\n" + "avg_loss = loss.ewm(alpha=1/rsi_len, adjust=False).mean()\n" + "rs = avg_gain / avg_loss.replace(0, np.nan)\n" + "rsi = 100 - (100 / (1 + rs))\n" + "rsi = rsi.fillna(50)\n\n" + "# Raw conditions (avoid overly strict filters)\n" + "raw_buy = (rsi < 30)\n" + "raw_sell = (rsi > 70)\n" + "# One-shot signals\n" + "buy = raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))\n" + "sell = raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))\n" + "df['buy'] = buy.astype(bool)\n" + "df['sell'] = sell.astype(bool)\n\n" + "buy_marks = [df['low'].iloc[i] * 0.995 if bool(buy.iloc[i]) else None for i in range(len(df))]\n" + "sell_marks = [df['high'].iloc[i] * 1.005 if bool(sell.iloc[i]) else None for i in range(len(df))]\n\n" + "output = {\n" + " 'name': my_indicator_name,\n" + " 'plots': [\n" + " {'name': 'RSI(14)', 'data': rsi.tolist(), 'color': '#faad14', 'overlay': False}\n" + " ],\n" + " 'signals': [\n" + " {'type': 'buy', 'text': 'B', 'data': buy_marks, 'color': '#00E676'},\n" + " {'type': 'sell', 'text': 'S', 'data': sell_marks, 'color': '#FF5252'}\n" + " ]\n" + "}\n" + ) + if existing: + header = "# Existing code was provided as context.\n" + header + return header + body + + def _openrouter_base_and_key() -> tuple[str, str]: + """ + Support both: + - OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 + - OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions + """ + key = os.getenv("OPENROUTER_API_KEY", "").strip() + base = os.getenv("OPENROUTER_BASE_URL", "").strip() + if not base: + api_url = os.getenv("OPENROUTER_API_URL", "").strip() + if api_url.endswith("/chat/completions"): + base = api_url[: -len("/chat/completions")] + if not base: + base = "https://openrouter.ai/api/v1" + return base, key + + def _generate_code_via_openrouter() -> str: + base_url, api_key = _openrouter_base_and_key() + if not api_key: + return _template_code() + + model = os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini").strip() or "openai/gpt-4o-mini" + # Match legacy PHP default more closely + temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.7") or 0.7) + + # Build user prompt (match PHP behavior) + user_prompt = prompt + if existing: + user_prompt = ( + "# Existing Code (modify based on this):\n\n```python\n" + + existing.strip() + + "\n```\n\n# Modification Requirements:\n\n" + + prompt + + "\n\nPlease generate complete new Python code based on the existing code above and my modification requirements. Output the complete Python code directly, without explanations, without segmentation." + ) + + payload = { + "model": model, + "temperature": temperature, + "stream": False, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + } + + resp = requests.post( + f"{base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=payload, + timeout=120, + ) + resp.raise_for_status() + j = resp.json() + content = (((j.get("choices") or [{}])[0]).get("message") or {}).get("content") or "" + return content.strip() or _template_code() + + def stream(): + # 不扣任何 QDT:开源本地版直接生成/返回代码 + try: + code_text = _generate_code_via_openrouter() + except Exception as e: + logger.warning(f"ai_generate openrouter failed, fallback to template: {e}") + code_text = _template_code() + + # Stream in chunks (front-end appends). + chunk_size = 200 + for i in range(0, len(code_text), chunk_size): + chunk = code_text[i : i + chunk_size] + yield "data: " + json.dumps({"content": chunk}, ensure_ascii=False) + "\n\n" + yield "data: [DONE]\n\n" + + return Response( + stream(), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + diff --git a/backend_api_python/app/routes/kline.py b/backend_api_python/app/routes/kline.py new file mode 100644 index 0000000..cf9ba08 --- /dev/null +++ b/backend_api_python/app/routes/kline.py @@ -0,0 +1,121 @@ +""" +K线数据 API 路由 +""" +from flask import Blueprint, request, jsonify +from datetime import datetime +import traceback + +from app.services.kline import KlineService +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +kline_bp = Blueprint('kline', __name__) +kline_service = KlineService() + + +@kline_bp.route('/kline', methods=['GET', 'POST']) +def get_kline(): + """ + 获取K线数据 + + 参数: + market: 市场类型 (Crypto, USStock, AShare, HShare, Forex, Futures) + symbol: 交易对/股票代码 + timeframe: 时间周期 (1m, 5m, 15m, 30m, 1H, 4H, 1D, 1W) + limit: 数据条数 (默认300) + before_time: 获取此时间之前的数据 (可选,Unix时间戳) + """ + try: + # 支持 GET 和 POST + if request.method == 'POST': + data = request.get_json() or {} + else: + data = request.args + + market = data.get('market', 'USStock') + symbol = data.get('symbol', '') + timeframe = data.get('timeframe', '1D') + limit = int(data.get('limit', 300)) + before_time = data.get('before_time') or data.get('beforeTime') + + if before_time: + before_time = int(before_time) + + if not symbol: + return jsonify({ + 'code': 0, + 'msg': '缺少交易标的参数', + 'data': None + }), 400 + + logger.info(f"Requesting K-lines: {market}:{symbol}, timeframe={timeframe}, limit={limit}") + + klines = kline_service.get_kline( + market=market, + symbol=symbol, + timeframe=timeframe, + limit=limit, + before_time=before_time + ) + + if not klines: + return jsonify({ + 'code': 0, + 'msg': '未获取到数据', + 'data': [] + }) + + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': klines + }) + + except Exception as e: + logger.error(f"Failed to fetch K-lines: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({ + 'code': 0, + 'msg': f'获取K线数据失败: {str(e)}', + 'data': None + }), 500 + + +@kline_bp.route('/price', methods=['GET']) +def get_price(): + """获取最新价格""" + try: + market = request.args.get('market', 'USStock') + symbol = request.args.get('symbol', '') + + if not symbol: + return jsonify({ + 'code': 0, + 'msg': '缺少交易标的参数', + 'data': None + }), 400 + + price_data = kline_service.get_latest_price(market, symbol) + + if not price_data: + return jsonify({ + 'code': 0, + 'msg': '未获取到价格数据', + 'data': None + }) + + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': price_data + }) + + except Exception as e: + logger.error(f"Failed to fetch price: {str(e)}") + return jsonify({ + 'code': 0, + 'msg': f'获取价格失败: {str(e)}', + 'data': None + }), 500 + diff --git a/backend_api_python/app/routes/market.py b/backend_api_python/app/routes/market.py new file mode 100644 index 0000000..dbaf815 --- /dev/null +++ b/backend_api_python/app/routes/market.py @@ -0,0 +1,582 @@ +""" +Market API routes (local-only). +Provides watchlist, market metadata, symbol search, and pricing helpers for the frontend. +""" +from flask import Blueprint, request, jsonify +import traceback +import json +import time +from concurrent.futures import ThreadPoolExecutor, as_completed + +from app.services.kline import KlineService +from app.utils.logger import get_logger +from app.utils.cache import CacheManager +from app.utils.db import get_db_connection +from app.utils.config_loader import load_addon_config +from app.data.market_symbols_seed import ( + get_hot_symbols as seed_get_hot_symbols, + search_symbols as seed_search_symbols, + get_symbol_name as seed_get_symbol_name +) +from app.services.symbol_name import resolve_symbol_name + +logger = get_logger(__name__) + +market_bp = Blueprint('market', __name__) +kline_service = KlineService() +cache = CacheManager() + +# 线程池用于并行获取价格 +executor = ThreadPoolExecutor(max_workers=10) + +DEFAULT_USER_ID = 1 + +def _now_ts() -> int: + return int(time.time()) + +def _normalize_symbol(symbol: str) -> str: + return (symbol or '').strip().upper() + +def _ensure_watchlist_table(): + # Table is created by db schema init; this is only a sanity hook. + return True + +@market_bp.route('/config', methods=['GET']) +def get_public_config(): + """ + Public config for frontend (local mode). + Mirrors the old PHP `/addons/quantdinger/index/getConfig` shape. + """ + try: + cfg = load_addon_config() + models = (cfg.get('ai', {}) or {}).get('models') + if not isinstance(models, dict) or not models: + # Fallback defaults (offline friendly) + models = { + # Keep some legacy defaults + 'openai/gpt-4o': 'GPT-4o', + + # Unified frontend model list (OpenRouter-style ids) + 'x-ai/grok-code-fast-1': 'xAI: Grok Code Fast 1', + 'x-ai/grok-4-fast': 'xAI: Grok 4 Fast', + 'x-ai/grok-4.1-fast': 'xAI: Grok 4.1 Fast', + 'google/gemini-2.5-flash': 'Google: Gemini 2.5 Flash', + 'google/gemini-2.0-flash-001': 'Google: Gemini 2.0 Flash', + 'google/gemini-3-pro-preview': 'Google: Gemini 3 Pro Preview', + 'google/gemini-2.5-flash-lite': 'Google: Gemini 2.5 Flash Lite', + 'google/gemini-2.5-pro': 'Google: Gemini 2.5 Pro', + 'openai/gpt-4o-mini': 'OpenAI: GPT-4o-mini', + 'openai/gpt-5-mini': 'OpenAI: GPT-5 Mini', + 'openai/gpt-oss-120b': 'OpenAI: gpt-oss-120b', + 'deepseek/deepseek-v3.2': 'DeepSeek: DeepSeek V3.2', + 'minimax/minimax-m2': 'MiniMax: MiniMax M2', + 'anthropic/claude-sonnet-4': 'Anthropic: Claude Sonnet 4', + 'anthropic/claude-sonnet-4.5': 'Anthropic: Claude Sonnet 4.5', + 'anthropic/claude-opus-4.5': 'Anthropic: Claude Opus 4.5', + 'anthropic/claude-haiku-4.5': 'Anthropic: Claude Haiku 4.5', + 'z-ai/glm-4.6': 'Z.AI: GLM 4.6', + } + return jsonify({'code': 1, 'msg': 'success', 'data': {'models': models, 'qdt_cost': {}}}) + except Exception as e: + logger.error(f"get_public_config failed: {str(e)}") + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + +@market_bp.route('/types', methods=['GET']) +def get_market_types(): + """Return supported market types for the add-watchlist modal.""" + cfg = load_addon_config() + data = (cfg.get('market', {}) or {}).get('types') + if not isinstance(data, list) or not data: + data = [ + {'value': 'AShare', 'i18nKey': 'dashboard.analysis.market.AShare'}, + {'value': 'USStock', 'i18nKey': 'dashboard.analysis.market.USStock'}, + {'value': 'HShare', 'i18nKey': 'dashboard.analysis.market.HShare'}, + {'value': 'Crypto', 'i18nKey': 'dashboard.analysis.market.Crypto'}, + {'value': 'Forex', 'i18nKey': 'dashboard.analysis.market.Forex'}, + {'value': 'Futures', 'i18nKey': 'dashboard.analysis.market.Futures'} + ] + return jsonify({'code': 1, 'msg': 'success', 'data': data}) + + +@market_bp.route('/menuFooterConfig', methods=['POST']) +def get_menu_footer_config(): + """ + Compatibility stub for old PHP `getMenuFooterConfig`. + Frontend can also hardcode this locally; this endpoint remains for completeness. + """ + data = { + 'contact': { + 'support_url': 'https://github.com/', + 'feature_request_url': 'https://github.com/', + 'email': 'support@example.com', + 'live_chat_url': 'https://github.com/' + }, + 'social_accounts': [ + {'name': 'GitHub', 'icon': 'github', 'url': 'https://github.com/'}, + {'name': 'X', 'icon': 'x', 'url': 'https://x.com/'} + ], + 'legal': { + 'user_agreement': '', + 'privacy_policy': '' + }, + 'copyright': '© 2025-2026 QuantDinger' + } + return jsonify({'code': 1, 'msg': 'success', 'data': data}) + +@market_bp.route('/symbols/search', methods=['POST']) +def search_symbols(): + """ + Lightweight symbol search. + In local-only mode we keep this simple; frontend allows manual input when no results. + """ + try: + data = request.get_json() or {} + market = (data.get('market') or '').strip() + keyword = (data.get('keyword') or '').strip().upper() + limit = int(data.get('limit') or 20) + + if not market or not keyword: + return jsonify({'code': 1, 'msg': 'success', 'data': []}) + + out = seed_search_symbols(market=market, keyword=keyword, limit=limit) + return jsonify({'code': 1, 'msg': 'success', 'data': out}) + except Exception as e: + logger.error(f"search_symbols failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500 + +@market_bp.route('/symbols/hot', methods=['POST']) +def get_hot_symbols(): + """Return a small curated hot list per market (local-only).""" + try: + data = request.get_json() or {} + market = (data.get('market') or '').strip() + limit = int(data.get('limit') or 10) + hot = seed_get_hot_symbols(market=market, limit=limit) + return jsonify({'code': 1, 'msg': 'success', 'data': hot}) + except Exception as e: + logger.error(f"get_hot_symbols failed: {str(e)}") + return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500 + +@market_bp.route('/watchlist/get', methods=['POST']) +def get_watchlist(): + """Get local watchlist for the single user.""" + try: + _ensure_watchlist_table() + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "SELECT id, market, symbol, name FROM qd_watchlist WHERE user_id = ? ORDER BY id DESC", + (DEFAULT_USER_ID,) + ) + rows = cur.fetchall() or [] + + # Backfill display names for legacy rows (name empty or equals symbol). + # This keeps UI consistent without requiring users to re-add items. + for row in rows: + try: + market = row.get('market') + symbol = row.get('symbol') + current_name = (row.get('name') or '').strip() + if not market or not symbol: + continue + if current_name and current_name != symbol: + continue + resolved = resolve_symbol_name(market, symbol) or seed_get_symbol_name(market, symbol) + if resolved and resolved != current_name: + row['name'] = resolved + cur.execute( + "UPDATE qd_watchlist SET name = ?, updated_at = ? WHERE user_id = ? AND market = ? AND symbol = ?", + (resolved, _now_ts(), DEFAULT_USER_ID, market, symbol) + ) + except Exception: + continue + db.commit() + cur.close() + return jsonify({'code': 1, 'msg': 'success', 'data': rows}) + except Exception as e: + logger.error(f"get_watchlist failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500 + +@market_bp.route('/watchlist/add', methods=['POST']) +def add_watchlist(): + """Add a symbol to local watchlist (no credits/fees in local mode).""" + try: + data = request.get_json() or {} + market = (data.get('market') or '').strip() + symbol = _normalize_symbol(data.get('symbol')) + name_in = (data.get('name') or '').strip() + if not market or not symbol: + return jsonify({'code': 0, 'msg': 'Missing market or symbol', 'data': None}), 400 + + # Prefer frontend-provided name (search results), otherwise resolve via seed/public sources. + resolved = resolve_symbol_name(market, symbol) or seed_get_symbol_name(market, symbol) + name = name_in or resolved or symbol + + now = _now_ts() + with get_db_connection() as db: + cur = db.cursor() + # Insert or ignore duplicates. + cur.execute( + "INSERT OR IGNORE INTO qd_watchlist (user_id, market, symbol, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + (DEFAULT_USER_ID, market, symbol, name, now, now) + ) + # If already exists, keep it fresh and sync name. + cur.execute( + "UPDATE qd_watchlist SET name = ?, updated_at = ? WHERE user_id = ? AND market = ? AND symbol = ?", + (name, now, DEFAULT_USER_ID, market, symbol) + ) + db.commit() + cur.close() + + return jsonify({'code': 1, 'msg': 'success', 'data': None}) + except Exception as e: + logger.error(f"add_watchlist failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + +@market_bp.route('/watchlist/remove', methods=['POST']) +def remove_watchlist(): + """Remove a symbol from watchlist. Frontend only passes symbol, so we delete across markets.""" + try: + data = request.get_json() or {} + symbol = _normalize_symbol(data.get('symbol')) + if not symbol: + return jsonify({'code': 0, 'msg': 'Missing symbol', 'data': None}), 400 + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "DELETE FROM qd_watchlist WHERE user_id = ? AND symbol = ?", + (DEFAULT_USER_ID, symbol) + ) + db.commit() + cur.close() + return jsonify({'code': 1, 'msg': 'success', 'data': None}) + except Exception as e: + logger.error(f"remove_watchlist failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +def get_single_price(market: str, symbol: str) -> dict: + """获取单个标的的价格数据""" + try: + # 先尝试从缓存获取(60秒缓存) + cache_key = f"watchlist_price:{market}:{symbol}" + cached_data = cache.get(cache_key) + + if cached_data: + logger.debug(f"Cache hit: {market}:{symbol}") + return { + 'market': market, + 'symbol': symbol, + 'price': cached_data.get('price', 0), + 'change': cached_data.get('change', 0), + 'changePercent': cached_data.get('changePercent', 0) + } + + # 获取最新的一根K线 + klines = kline_service.get_kline(market, symbol, '1D', 2) + + if klines and len(klines) > 0: + latest = klines[-1] + prev_close = klines[-2]['close'] if len(klines) > 1 else latest.get('open', 0) + current_price = latest.get('close', 0) + + change = round(current_price - prev_close, 4) if prev_close else 0 + change_percent = round(change / prev_close * 100, 2) if prev_close and prev_close > 0 else 0 + + result = { + 'market': market, + 'symbol': symbol, + 'price': current_price, + 'change': change, + 'changePercent': change_percent + } + + # 缓存60秒 + cache.set(cache_key, result, 60) + + return result + else: + return { + 'market': market, + 'symbol': symbol, + 'price': 0, + 'change': 0, + 'changePercent': 0 + } + except Exception as e: + logger.error(f"Failed to fetch price {market}:{symbol} - {str(e)}") + return { + 'market': market, + 'symbol': symbol, + 'price': 0, + 'change': 0, + 'changePercent': 0 + } + + +@market_bp.route('/watchlist/prices', methods=['POST']) +def get_watchlist_prices(): + """ + 批量获取自选股价格 + + 请求体: + { + "watchlist": [ + {"market": "USStock", "symbol": "AAPL"}, + {"market": "Crypto", "symbol": "BTC"}, + ... + ] + } + + 响应: + { + "code": 1, + "msg": "success", + "data": [ + {"market": "USStock", "symbol": "AAPL", "price": 150.0, "change": 1.5, "changePercent": 1.0}, + {"market": "Crypto", "symbol": "BTC", "price": 95000.0, "change": 1000, "changePercent": 1.05} + ] + } + """ + try: + data = request.get_json() + if not data: + return jsonify({ + 'code': 0, + 'msg': '请求参数不能为空', + 'data': [] + }), 400 + + watchlist = data.get('watchlist', []) + + if not watchlist or not isinstance(watchlist, list): + return jsonify({ + 'code': 0, + 'msg': 'watchlist参数格式错误', + 'data': [] + }), 400 + + # logger.info(f"开始获取 {len(watchlist)} 个自选股价格数据") + + results = [] + + # 使用线程池并行获取价格 + futures = {} + for item in watchlist: + market = item.get('market', '') + symbol = item.get('symbol', '') + + if market and symbol: + future = executor.submit(get_single_price, market, symbol) + futures[future] = (market, symbol) + + # 收集结果(保持顺序) + for future in as_completed(futures, timeout=30): + try: + result = future.result() + results.append(result) + except Exception as e: + market, symbol = futures[future] + logger.error(f"Price fetch timed out or failed: {market}:{symbol} - {str(e)}") + results.append({ + 'market': market, + 'symbol': symbol, + 'price': 0, + 'change': 0, + 'changePercent': 0 + }) + + success_count = sum(1 for r in results if r.get('price', 0) > 0) + # logger.info(f"批量获取完成,成功: {success_count}/{len(results)}") + + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': results + }) + + except Exception as e: + logger.error(f"Batch watchlist price fetch failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({ + 'code': 0, + 'msg': f'获取失败: {str(e)}', + 'data': [] + }), 500 + + +@market_bp.route('/price', methods=['GET']) +def get_price(): + """ + 获取单个标的价格 + + 参数: + market: 市场类型 + symbol: 交易标的 + """ + try: + market = request.args.get('market', '') + symbol = request.args.get('symbol', '') + + if not market or not symbol: + return jsonify({ + 'code': 0, + 'msg': '缺少 market 或 symbol 参数', + 'data': None + }), 400 + + result = get_single_price(market, symbol) + + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': result + }) + + except Exception as e: + logger.error(f"Failed to fetch price: {str(e)}") + return jsonify({ + 'code': 0, + 'msg': f'获取失败: {str(e)}', + 'data': None + }), 500 + + +@market_bp.route('/stock/name', methods=['POST']) +def get_stock_name(): + """ + 获取股票名称 + + 请求体: + { + "market": "USStock", + "symbol": "AAPL" + } + + 响应: + { + "code": 1, + "msg": "success", + "data": { + "name": "Apple Inc." + } + } + """ + try: + data = request.get_json() + if not data: + return jsonify({ + 'code': 0, + 'msg': '请求参数不能为空', + 'data': None + }), 400 + + market = data.get('market', '') + symbol = data.get('symbol', '') + + if not market or not symbol: + return jsonify({ + 'code': 0, + 'msg': '缺少 market 或 symbol 参数', + 'data': None + }), 400 + + # 尝试从缓存获取(1天缓存) + cache_key = f"stock_name:{market}:{symbol}" + cached_name = cache.get(cache_key) + + if cached_name: + logger.debug(f"Stock name cache hit: {market}:{symbol}") + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': {'name': cached_name} + }) + + # 根据不同市场获取股票名称 + stock_name = symbol # 默认使用代码 + + try: + if market in ['USStock', 'AShare', 'HShare']: + # 对于股票,尝试获取基本信息 + import yfinance as yf + + # 转换symbol格式 + if market == 'USStock': + yf_symbol = symbol + elif market == 'AShare': + yf_symbol = symbol + '.SS' if symbol.startswith('6') else symbol + '.SZ' + elif market == 'HShare': + # 港股需要补齐4位数字并添加.HK + hk_code = symbol.zfill(4) + yf_symbol = hk_code + '.HK' + else: + yf_symbol = symbol + + ticker = yf.Ticker(yf_symbol) + info = ticker.info + + # 尝试获取名称 + stock_name = info.get('longName') or info.get('shortName') or symbol + + elif market == 'Crypto': + # 加密货币,使用交易对格式 + if '/' in symbol: + stock_name = symbol + else: + stock_name = f"{symbol}/USDT" + + elif market == 'Forex': + # 外汇 + forex_names = { + 'XAUUSD': '黄金', + 'XAGUSD': '白银', + 'EURUSD': '欧元/美元', + 'GBPUSD': '英镑/美元', + 'USDJPY': '美元/日元', + 'AUDUSD': '澳元/美元', + 'USDCAD': '美元/加元', + 'USDCHF': '美元/瑞郎', + } + stock_name = forex_names.get(symbol, symbol) + + elif market == 'Futures': + # 期货 + futures_names = { + 'GC': '黄金期货', + 'SI': '白银期货', + 'CL': '原油期货', + 'NG': '天然气期货', + 'ZC': '玉米期货', + 'ZW': '小麦期货', + 'BTCUSDT': 'BTC永续合约', + 'ETHUSDT': 'ETH永续合约', + } + stock_name = futures_names.get(symbol, symbol) + + except Exception as e: + logger.warning(f"Failed to fetch stock name; falling back to symbol: {market}:{symbol} - {str(e)}") + stock_name = symbol + + # 缓存1天 + cache.set(cache_key, stock_name, 86400) + + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': {'name': stock_name} + }) + + except Exception as e: + logger.error(f"Failed to fetch stock name: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({ + 'code': 0, + 'msg': f'获取失败: {str(e)}', + 'data': None + }), 500 diff --git a/backend_api_python/app/routes/strategy.py b/backend_api_python/app/routes/strategy.py new file mode 100644 index 0000000..c75212f --- /dev/null +++ b/backend_api_python/app/routes/strategy.py @@ -0,0 +1,590 @@ +""" +交易策略 API 路由 +""" +from flask import Blueprint, request, jsonify +import traceback +import time + +from app.services.strategy import StrategyService +from app.services.strategy_compiler import StrategyCompiler +from app.services.backtest import BacktestService +from app import get_trading_executor +from app.utils.logger import get_logger +from app.utils.db import get_db_connection +from app.data_sources import DataSourceFactory + +logger = get_logger(__name__) + +strategy_bp = Blueprint('strategy', __name__) + +# Local mode: avoid heavy initialization during module import. +# Instantiate services lazily on first use to keep startup clean. +_strategy_service = None + +def get_strategy_service() -> StrategyService: + global _strategy_service + if _strategy_service is None: + _strategy_service = StrategyService() + return _strategy_service + + +@strategy_bp.route('/strategies', methods=['GET']) +def list_strategies(): + """ + 策略列表(本地版:单用户) + """ + try: + user_id = request.args.get('user_id', type=int) or 1 + items = get_strategy_service().list_strategies(user_id=user_id) + return jsonify({'code': 1, 'msg': 'success', 'data': {'strategies': items}}) + except Exception as e: + logger.error(f"list_strategies failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': {'strategies': []}}), 500 + + +@strategy_bp.route('/strategies/detail', methods=['GET']) +def get_strategy_detail(): + try: + strategy_id = request.args.get('id', type=int) + if not strategy_id: + return jsonify({'code': 0, 'msg': '缺少策略ID参数', 'data': None}), 400 + st = get_strategy_service().get_strategy(strategy_id) + if not st: + return jsonify({'code': 0, 'msg': '策略不存在', 'data': None}), 404 + return jsonify({'code': 1, 'msg': 'success', 'data': st}) + except Exception as e: + logger.error(f"get_strategy_detail failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@strategy_bp.route('/strategies/create', methods=['POST']) +def create_strategy(): + try: + payload = request.get_json() or {} + # Local mode default user + payload['user_id'] = int(payload.get('user_id') or 1) + payload['strategy_type'] = payload.get('strategy_type') or 'IndicatorStrategy' + new_id = get_strategy_service().create_strategy(payload) + return jsonify({'code': 1, 'msg': 'success', 'data': {'id': new_id}}) + except Exception as e: + logger.error(f"create_strategy failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@strategy_bp.route('/strategies/update', methods=['PUT']) +def update_strategy(): + try: + strategy_id = request.args.get('id', type=int) + if not strategy_id: + return jsonify({'code': 0, 'msg': '缺少策略ID参数', 'data': None}), 400 + payload = request.get_json() or {} + ok = get_strategy_service().update_strategy(strategy_id, payload) + if not ok: + return jsonify({'code': 0, 'msg': '策略不存在', 'data': None}), 404 + return jsonify({'code': 1, 'msg': 'success', 'data': None}) + except Exception as e: + logger.error(f"update_strategy failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@strategy_bp.route('/strategies/delete', methods=['DELETE']) +def delete_strategy(): + try: + strategy_id = request.args.get('id', type=int) + if not strategy_id: + return jsonify({'code': 0, 'msg': '缺少策略ID参数', 'data': None}), 400 + ok = get_strategy_service().delete_strategy(strategy_id) + return jsonify({'code': 1 if ok else 0, 'msg': 'success' if ok else 'failed', 'data': None}) + except Exception as e: + logger.error(f"delete_strategy failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + + +@strategy_bp.route('/strategies/trades', methods=['GET']) +def get_trades(): + """交易记录(从本地 SQLite 读取)""" + try: + strategy_id = request.args.get('id', type=int) + if not strategy_id: + return jsonify({'code': 0, 'msg': '缺少策略ID参数', 'data': {'trades': [], 'items': []}}), 400 + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT id, strategy_id, symbol, type, price, amount, value, commission, profit, created_at + FROM qd_strategy_trades + WHERE strategy_id = ? + ORDER BY id DESC + """, + (strategy_id,) + ) + rows = cur.fetchall() or [] + cur.close() + # Frontend expects data.trades; keep data.items for compatibility with list-style components. + return jsonify({'code': 1, 'msg': 'success', 'data': {'trades': rows, 'items': rows}}) + except Exception as e: + logger.error(f"get_trades failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': {'trades': [], 'items': []}}), 500 + + +@strategy_bp.route('/strategies/positions', methods=['GET']) +def get_positions(): + """持仓记录(从本地 SQLite 读取)""" + try: + strategy_id = request.args.get('id', type=int) + if not strategy_id: + return jsonify({'code': 0, 'msg': '缺少策略ID参数', 'data': {'positions': [], 'items': []}}), 400 + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT id, strategy_id, symbol, side, size, entry_price, current_price, highest_price, + unrealized_pnl, pnl_percent, equity, updated_at + FROM qd_strategy_positions + WHERE strategy_id = ? + ORDER BY id DESC + """, + (strategy_id,) + ) + rows = cur.fetchall() or [] + cur.close() + + # Sync current price and PnL on read (frontend polls every few seconds). + def _calc_unrealized_pnl(side: str, entry_price: float, current_price: float, size: float) -> float: + ep = float(entry_price or 0.0) + cp = float(current_price or 0.0) + sz = float(size or 0.0) + if ep <= 0 or cp <= 0 or sz <= 0: + return 0.0 + s = (side or "").strip().lower() + if s == "short": + return (ep - cp) * sz + return (cp - ep) * sz + + def _calc_pnl_percent(entry_price: float, size: float, pnl: float) -> float: + ep = float(entry_price or 0.0) + sz = float(size or 0.0) + denom = ep * sz + if denom <= 0: + return 0.0 + return float(pnl) / denom * 100.0 + + now = int(time.time()) + # Fetch prices once per symbol to reduce API calls. + sym_to_price: Dict[str, float] = {} + ds = DataSourceFactory.get_source("Crypto") + for r in rows: + sym = (r.get("symbol") or "").strip() + if not sym: + continue + if sym in sym_to_price: + continue + try: + t = ds.get_ticker(sym) or {} + px = float(t.get("last") or t.get("close") or 0.0) + if px > 0: + sym_to_price[sym] = px + except Exception: + continue + + # Apply to rows and persist best-effort + out = [] + with get_db_connection() as db: + cur = db.cursor() + for r in rows: + sym = (r.get("symbol") or "").strip() + side = (r.get("side") or "").strip().lower() + entry = float(r.get("entry_price") or 0.0) + size = float(r.get("size") or 0.0) + cp = float(sym_to_price.get(sym) or r.get("current_price") or 0.0) + pnl = _calc_unrealized_pnl(side, entry, cp, size) + pct = _calc_pnl_percent(entry, size, pnl) + + rr = dict(r) + rr["current_price"] = float(cp or 0.0) + rr["unrealized_pnl"] = float(pnl) + rr["pnl_percent"] = float(pct) + rr["updated_at"] = now + out.append(rr) + + try: + cur.execute( + """ + UPDATE qd_strategy_positions + SET current_price = ?, unrealized_pnl = ?, pnl_percent = ?, updated_at = ? + WHERE id = ? + """, + (float(cp or 0.0), float(pnl), float(pct), int(now), int(rr.get("id"))), + ) + except Exception: + pass + db.commit() + cur.close() + + return jsonify({'code': 1, 'msg': 'success', 'data': {'positions': out, 'items': out}}) + except Exception as e: + logger.error(f"get_positions failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': {'positions': [], 'items': []}}), 500 + + +@strategy_bp.route('/strategies/equityCurve', methods=['GET']) +def get_equity_curve(): + """净值曲线(本地简单计算:initial_capital + 累计 profit)""" + try: + strategy_id = request.args.get('id', type=int) + if not strategy_id: + return jsonify({'code': 0, 'msg': '缺少策略ID参数', 'data': []}), 400 + + st = get_strategy_service().get_strategy(strategy_id) or {} + initial = float(st.get('initial_capital') or (st.get('trading_config') or {}).get('initial_capital') or 0) + if initial <= 0: + initial = 1000.0 + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT created_at, profit + FROM qd_strategy_trades + WHERE strategy_id = ? + ORDER BY created_at ASC + """, + (strategy_id,) + ) + rows = cur.fetchall() or [] + cur.close() + + equity = initial + curve = [] + for r in rows: + try: + equity += float(r.get('profit') or 0) + except Exception: + pass + ts = int(r.get('created_at') or time.time()) + curve.append({'time': ts, 'equity': equity}) + + return jsonify({'code': 1, 'msg': 'success', 'data': curve}) + except Exception as e: + logger.error(f"get_equity_curve failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500 + + + + + +@strategy_bp.route('/strategies/stop', methods=['POST']) +def stop_strategy(): + """ + 停止策略 + + 参数: + id: 策略ID + """ + try: + strategy_id = request.args.get('id', type=int) + + if not strategy_id: + return jsonify({ + 'code': 0, + 'msg': '缺少策略ID参数', + 'data': None + }), 400 + + # 获取策略类型 + strategy_type = get_strategy_service().get_strategy_type(strategy_id) + + # Local backend: AI strategy executor was removed. Only indicator strategies are supported. + if strategy_type == 'PromptBasedStrategy': + return jsonify({'code': 0, 'msg': 'AI策略已移除,本地版不支持启动/停止 AI 策略', 'data': None}), 400 + + # 指标策略 + get_trading_executor().stop_strategy(strategy_id) + + # 更新策略状态 + get_strategy_service().update_strategy_status(strategy_id, 'stopped') + + return jsonify({ + 'code': 1, + 'msg': '停止成功', + 'data': None + }) + + except Exception as e: + logger.error(f"Failed to stop strategy: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({ + 'code': 0, + 'msg': f'停止策略失败: {str(e)}', + 'data': None + }), 500 + + +@strategy_bp.route('/strategies/start', methods=['POST']) +def start_strategy(): + """ + 启动策略 + + 参数: + id: 策略ID + """ + try: + strategy_id = request.args.get('id', type=int) + + if not strategy_id: + return jsonify({ + 'code': 0, + 'msg': '缺少策略ID参数', + 'data': None + }), 400 + + # 获取策略类型 + strategy_type = get_strategy_service().get_strategy_type(strategy_id) + + # 更新策略状态 + get_strategy_service().update_strategy_status(strategy_id, 'running') + + # Local backend: AI strategy executor was removed. Only indicator strategies are supported. + if strategy_type == 'PromptBasedStrategy': + return jsonify({'code': 0, 'msg': 'AI策略已移除,本地版不支持启动 AI 策略', 'data': None}), 400 + + # 指标策略 + success = get_trading_executor().start_strategy(strategy_id) + + if not success: + # 如果启动失败,恢复状态 + get_strategy_service().update_strategy_status(strategy_id, 'stopped') + return jsonify({ + 'code': 0, + 'msg': '启动策略执行器失败', + 'data': None + }), 500 + + return jsonify({ + 'code': 1, + 'msg': '启动成功', + 'data': None + }) + + except Exception as e: + logger.error(f"Failed to start strategy: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({ + 'code': 0, + 'msg': f'启动策略失败: {str(e)}', + 'data': None + }), 500 + + +@strategy_bp.route('/strategies/test-connection', methods=['POST']) +def test_connection(): + """ + 测试交易所连接 + + 请求体: + exchange_config: 交易所配置 + """ + try: + data = request.get_json() or {} + + # 记录请求数据(用于调试,但不记录敏感信息) + logger.debug(f"Connection test request keys: {list(data.keys())}") + + # 获取交易所配置 + exchange_config = data.get('exchange_config', data) + + # Local deployment: no encryption/decryption; accept dict or JSON string. + if isinstance(exchange_config, str): + try: + import json + exchange_config = json.loads(exchange_config) + except Exception: + pass + + # 验证 exchange_config 是否为字典 + if not isinstance(exchange_config, dict): + logger.error(f"Invalid exchange_config type: {type(exchange_config)}, data: {str(exchange_config)[:200]}") + # Frontend expects HTTP 200 with {code:0} for business failures. + return jsonify({'code': 0, 'msg': '交易所配置格式错误,请检查数据格式', 'data': None}) + + # 验证必要字段 + if not exchange_config.get('exchange_id'): + return jsonify({'code': 0, 'msg': '请选择交易所', 'data': None}) + + api_key = exchange_config.get('api_key', '') + secret_key = exchange_config.get('secret_key', '') + + # 详细日志排查 + logger.info(f"Testing connection: exchange_id={exchange_config.get('exchange_id')}") + logger.info(f"API Key: {api_key[:5]}... (len={len(api_key)})") + logger.info(f"Secret Key: {secret_key[:5]}... (len={len(secret_key)})") + + # 检查是否有特殊字符 + if api_key.strip() != api_key: + logger.warning("API key contains leading/trailing whitespace") + if secret_key.strip() != secret_key: + logger.warning("Secret key contains leading/trailing whitespace") + + if not api_key or not secret_key: + return jsonify({'code': 0, 'msg': '请填写API密钥和Secret密钥', 'data': None}) + + result = get_strategy_service().test_exchange_connection(exchange_config) + + if result['success']: + return jsonify({'code': 1, 'msg': result.get('message') or '连接成功', 'data': result.get('data')}) + # Always return HTTP 200 for business-level failures. + return jsonify({'code': 0, 'msg': result.get('message') or '连接失败', 'data': result.get('data')}) + + except Exception as e: + logger.error(f"Connection test failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({ + 'code': 0, + 'msg': f'测试连接失败: {str(e)}', + 'data': None + }), 500 + + +@strategy_bp.route('/strategies/get-symbols', methods=['POST']) +def get_symbols(): + """ + 获取交易所交易对列表 + + 请求体: + exchange_config: 交易所配置 + """ + try: + data = request.get_json() or {} + exchange_config = data.get('exchange_config', data) + + result = get_strategy_service().get_exchange_symbols(exchange_config) + + if result['success']: + return jsonify({ + 'code': 1, + 'msg': result['message'], + 'data': { + 'symbols': result['symbols'] + } + }) + else: + return jsonify({ + 'code': 0, + 'msg': result['message'], + 'data': { + 'symbols': [] + } + }) + + except Exception as e: + logger.error(f"Failed to fetch symbols: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({ + 'code': 0, + 'msg': f'获取交易对失败: {str(e)}', + 'data': { + 'symbols': [] + } + }), 500 + + +@strategy_bp.route('/strategies/preview-compile', methods=['POST']) +def preview_compile(): + """ + 预览编译后的策略结果 + """ + try: + data = request.get_json() or {} + # strategy_config is passed as 'config' + config = data.get('config') + + if not config: + return jsonify({'code': 0, 'msg': 'Missing config'}), 400 + + # Compile + compiler = StrategyCompiler() + try: + code = compiler.compile(config) + except Exception as e: + return jsonify({'code': 0, 'msg': f'Compilation failed: {str(e)}'}), 400 + + # Execute + symbol = config.get('symbol', 'BTC/USDT') + timeframe = config.get('timeframe', '4h') + + backtest_service = BacktestService() + result = backtest_service.run_code_strategy( + code=code, + symbol=symbol, + timeframe=timeframe, + limit=500 + ) + + if result.get('error'): + return jsonify({'code': 0, 'msg': f"Execution failed: {result['error']}"}), 400 + + return jsonify({ + 'code': 1, + 'msg': 'Success', + 'data': result + }) + + except Exception as e: + logger.error(f"Preview failed: {e}") + return jsonify({'code': 0, 'msg': str(e)}), 500 + + +@strategy_bp.route('/strategies/notifications', methods=['GET']) +def get_strategy_notifications(): + """ + Strategy signal notifications (browser channel persistence). + + Query: + - id: strategy id (optional) + - limit: default 50, max 200 + - since_id: return rows with id > since_id (optional) + """ + try: + strategy_id = request.args.get('id', type=int) + limit = request.args.get('limit', type=int) or 50 + limit = max(1, min(200, int(limit))) + since_id = request.args.get('since_id', type=int) or 0 + + where = [] + args = [] + if strategy_id: + where.append("strategy_id = ?") + args.append(int(strategy_id)) + if since_id: + where.append("id > ?") + args.append(int(since_id)) + where_sql = ("WHERE " + " AND ".join(where)) if where else "" + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + f""" + SELECT * + FROM qd_strategy_notifications + {where_sql} + ORDER BY id DESC + LIMIT ? + """, + tuple(args + [int(limit)]), + ) + rows = cur.fetchall() or [] + cur.close() + + return jsonify({'code': 1, 'msg': 'success', 'data': {'items': rows}}) + except Exception as e: + logger.error(f"get_strategy_notifications failed: {str(e)}") + logger.error(traceback.format_exc()) + return jsonify({'code': 0, 'msg': str(e), 'data': {'items': []}}), 500 \ No newline at end of file diff --git a/backend_api_python/app/routes/strategy_code.py b/backend_api_python/app/routes/strategy_code.py new file mode 100644 index 0000000..84879a0 --- /dev/null +++ b/backend_api_python/app/routes/strategy_code.py @@ -0,0 +1,276 @@ +""" +Indicator-analysis Strategy APIs (local-first). + +These "strategies" are user-authored Python scripts used on `/indicator-analysis`: +- visualize signals on Kline (via output.plots/output.signals) +- optionally support backtest engine expectations (df signal columns) + +They are different from the live trading executor strategies in `app/routes/strategy.py`. +""" + +from __future__ import annotations + +import json +import os +import re +import time +from typing import Any, Dict + +import requests +from flask import Blueprint, Response, jsonify, request + +from app.utils.db import get_db_connection +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +strategy_code_bp = Blueprint("strategy_code", __name__) + + +def _now_ts() -> int: + return int(time.time()) + + +def _extract_meta_from_code(code: str) -> Dict[str, str]: + if not code or not isinstance(code, str): + return {"name": "", "description": ""} + name_match = re.search(r'^\s*my_indicator_name\s*=\s*([\'"])(.*?)\1\s*$', code, re.MULTILINE) + desc_match = re.search(r'^\s*my_indicator_description\s*=\s*([\'"])(.*?)\1\s*$', code, re.MULTILINE) + name = (name_match.group(2).strip() if name_match else "")[:100] + description = (desc_match.group(2).strip() if desc_match else "")[:500] + return {"name": name, "description": description} + + +@strategy_code_bp.route("/strategy/getStrategies", methods=["POST"]) +def get_strategies(): + try: + data = request.get_json() or {} + user_id = int(data.get("userid") or 1) + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "SELECT id, user_id, name, code, description, createtime, updatetime FROM qd_strategy_codes WHERE user_id = ? ORDER BY id DESC", + (user_id,), + ) + rows = cur.fetchall() or [] + cur.close() + return jsonify({"code": 1, "msg": "success", "data": rows}) + except Exception as e: + logger.error(f"get_strategies failed: {e}", exc_info=True) + return jsonify({"code": 0, "msg": str(e), "data": []}), 500 + + +@strategy_code_bp.route("/strategy/saveStrategy", methods=["POST"]) +def save_strategy(): + try: + data = request.get_json() or {} + user_id = int(data.get("userid") or 1) + strategy_id = int(data.get("id") or 0) + code = data.get("code") or "" + if not str(code).strip(): + return jsonify({"code": 0, "msg": "code is required", "data": None}), 400 + + name = (data.get("name") or "").strip() + description = (data.get("description") or "").strip() + if not name or not description: + meta = _extract_meta_from_code(code) + if not name: + name = meta.get("name") or "" + if not description: + description = meta.get("description") or "" + if not name: + name = "Custom Strategy" + + now = _now_ts() + with get_db_connection() as db: + cur = db.cursor() + if strategy_id and strategy_id > 0: + cur.execute( + "UPDATE qd_strategy_codes SET name = ?, code = ?, description = ?, updatetime = ? WHERE id = ? AND user_id = ?", + (name, code, description, now, strategy_id, user_id), + ) + else: + cur.execute( + "INSERT INTO qd_strategy_codes (user_id, name, code, description, createtime, updatetime) VALUES (?, ?, ?, ?, ?, ?)", + (user_id, name, code, description, now, now), + ) + strategy_id = int(cur.lastrowid or 0) + db.commit() + cur.close() + + return jsonify({"code": 1, "msg": "success", "data": {"id": strategy_id, "userid": user_id}}) + except Exception as e: + logger.error(f"save_strategy failed: {e}", exc_info=True) + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 + + +@strategy_code_bp.route("/strategy/deleteStrategy", methods=["POST"]) +def delete_strategy(): + try: + data = request.get_json() or {} + user_id = int(data.get("userid") or 1) + strategy_id = int(data.get("id") or 0) + if not strategy_id: + return jsonify({"code": 0, "msg": "id is required", "data": None}), 400 + with get_db_connection() as db: + cur = db.cursor() + cur.execute("DELETE FROM qd_strategy_codes WHERE id = ? AND user_id = ?", (strategy_id, user_id)) + db.commit() + cur.close() + return jsonify({"code": 1, "msg": "success", "data": None}) + except Exception as e: + logger.error(f"delete_strategy failed: {e}", exc_info=True) + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 + + +@strategy_code_bp.route("/strategy/aiGenerate", methods=["POST"]) +def ai_generate_strategy(): + """ + SSE code generation for strategy scripts (local-first, no QDT deduction). + """ + data = request.get_json() or {} + prompt = (data.get("prompt") or "").strip() + existing = (data.get("existingCode") or "").strip() + + if not prompt: + def _err_stream(): + yield "data: " + json.dumps({"error": "提示词不能为空"}, ensure_ascii=False) + "\n\n" + yield "data: [DONE]\n\n" + + return Response(_err_stream(), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + SYSTEM_PROMPT = """# Role + +You are an expert Python quantitative trading developer. + +# Environment +- Runs in browser (Pyodide): NO network access, no pip, no requests. +- pandas is already imported as pd, numpy as np. DO NOT import them. +- Input: df with columns time/open/high/low/close/volume. + +# Required output (STRICT) +- You MUST define: + - my_indicator_name = "..." + - my_indicator_description = "..." + - output = {"name":..., "plots":[...], "signals":[...]} + +# Chart signal rules (MUST) +- output["signals"] MAY exist, but if present it MUST contain ONLY two types: "buy" and "sell". +- Signals must be aligned with df length: signals[].data length == len(df), use None for "no signal". +- Default signal text MUST be English (recommended "B"/"S" or "Buy"/"Sell"). Do NOT output Chinese text. + +# Execution/backtest compatibility (MUST) +- You MUST set boolean columns: + - df["buy"] and df["sell"] +- Backend will normalize buy/sell into open/close long/short actions based on trade_direction and current position. +- Do NOT emit open_long/close_long/open_short/close_short/add_* in output["signals"]. +- Do NOT implement position sizing, TP/SL, trailing, pyramiding in the script. Those belong to strategy_config / backend. +- Signals are typically confirmed on bar close and executed by backtest on the next bar open (to avoid look-ahead bias). + +# Robustness requirements (IMPORTANT) +- Always handle division-by-zero and NaN/inf when computing indicators (e.g., RSV denominator can be 0). +- Avoid overly restrictive entry conditions that result in zero buys or zero sells. Prefer crossover/event-based signals. +- For multi-indicator strategies, avoid requiring a crossover AND extreme RSI/BB condition on the same bar unless explicitly requested. +- Prefer edge-triggered signals (one-shot) to avoid repeated consecutive buy/sell bars: + buy = raw_buy & ~raw_buy.shift(1).fillna(False) + sell = raw_sell & ~raw_sell.shift(1).fillna(False) + +# Execution rule (IMPORTANT) +- The backtest engine may apply parameterized scaling (scale-in/out) from strategy_config. +- If a candle has a main signal (buy/sell mapped to open/close/reverse), scaling in/out is skipped on the same candle. + +# Output style +- Output Python code only. No markdown code blocks. No extra explanations. +- Keep code comments and default strings in English. +""" + + def _openrouter_base_and_key() -> tuple[str, str]: + key = os.getenv("OPENROUTER_API_KEY", "").strip() + base = os.getenv("OPENROUTER_BASE_URL", "").strip() + if not base: + api_url = os.getenv("OPENROUTER_API_URL", "").strip() + if api_url.endswith("/chat/completions"): + base = api_url[: -len("/chat/completions")] + if not base: + base = "https://openrouter.ai/api/v1" + return base, key + + def _template_code() -> str: + return ( + f'my_indicator_name = "Custom Strategy"\n' + f'my_indicator_description = "{prompt.replace("\\n", " ")[:200]}"\n\n' + "# Buy/Sell only. Execution is normalized in backend.\n" + "df = df.copy()\n" + "sma = df['close'].rolling(14).mean()\n" + "raw_buy = (df['close'] > sma) & (df['close'].shift(1) <= sma.shift(1))\n" + "raw_sell = (df['close'] < sma) & (df['close'].shift(1) >= sma.shift(1))\n" + "# Edge-triggered signals (avoid repeated consecutive signals)\n" + "buy = raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))\n" + "sell = raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))\n" + "df['buy'] = buy.astype(bool)\n" + "df['sell'] = sell.astype(bool)\n" + "\n" + "buy_marks = [df['low'].iloc[i]*0.995 if bool(df['buy'].iloc[i]) else None for i in range(len(df))]\n" + "sell_marks = [df['high'].iloc[i]*1.005 if bool(df['sell'].iloc[i]) else None for i in range(len(df))]\n" + "output = {\n" + " 'name': my_indicator_name,\n" + " 'plots': [ {'name':'SMA 14','data': sma.tolist(),'color':'#1890ff','overlay': True} ],\n" + " 'signals': [\n" + " {'type':'buy','text':'B','data': buy_marks,'color':'#00E676'},\n" + " {'type':'sell','text':'S','data': sell_marks,'color':'#FF5252'}\n" + " ]\n" + "}\n" + ) + + def _generate() -> str: + base_url, api_key = _openrouter_base_and_key() + if not api_key: + return _template_code() + + model = (os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini") or "").strip() or "openai/gpt-4o-mini" + temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.7") or 0.7) + + user_prompt = prompt + if existing: + user_prompt = ( + "# Existing Code (modify based on this):\n\n```python\n" + + existing.strip() + + "\n```\n\n# Modification Requirements:\n\n" + + prompt + + "\n\nPlease generate complete new Python code based on the existing code above and my modification requirements. Output the complete Python code directly, without explanations, without segmentation." + ) + + resp = requests.post( + f"{base_url}/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={ + "model": model, + "temperature": temperature, + "stream": False, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + }, + timeout=120, + ) + resp.raise_for_status() + j = resp.json() + content = (((j.get("choices") or [{}])[0]).get("message") or {}).get("content") or "" + return content.strip() or _template_code() + + def stream(): + try: + code_text = _generate() + except Exception as e: + logger.warning(f"strategy aiGenerate failed, fallback template: {e}") + code_text = _template_code() + + chunk_size = 200 + for i in range(0, len(code_text), chunk_size): + yield "data: " + json.dumps({"content": code_text[i : i + chunk_size]}, ensure_ascii=False) + "\n\n" + yield "data: [DONE]\n\n" + + return Response(stream(), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + diff --git a/backend_api_python/app/services/__init__.py b/backend_api_python/app/services/__init__.py new file mode 100644 index 0000000..2230a70 --- /dev/null +++ b/backend_api_python/app/services/__init__.py @@ -0,0 +1,10 @@ +""" +业务服务层 +""" +from app.services.kline import KlineService +from app.services.analysis import AnalysisService +from app.services.backtest import BacktestService +from app.services.strategy_compiler import StrategyCompiler + +__all__ = ['KlineService', 'AnalysisService', 'BacktestService', 'StrategyCompiler'] + diff --git a/backend_api_python/app/services/agents/README.md b/backend_api_python/app/services/agents/README.md new file mode 100644 index 0000000..8177f97 --- /dev/null +++ b/backend_api_python/app/services/agents/README.md @@ -0,0 +1,148 @@ +# 多智能体分析系统 + +基于 TradingAgents 架构优化的多智能体股票分析系统。 + +## 架构特点 + +### 1. 多智能体协作 +- **分析师团队**:市场分析师、基本面分析师、新闻分析师、情绪分析师、风险分析师 +- **研究团队**:看涨研究员、看跌研究员 +- **交易团队**:交易员、风险分析师(激进/中性/保守) + +### 2. 工作流程 +``` +分析师团队分析 → 研究辩论 → 交易员决策 → 风险辩论 → 最终决策 +``` + +### 3. 记忆系统 +- 使用 SQLite 存储历史决策 +- 基于文本相似度检索历史经验 +- 支持从交易结果中学习 + +### 4. 工具调用 +- 智能体可以主动获取数据 +- 支持多数据源(yfinance, finnhub, ccxt, 腾讯接口) + +## 使用方法 + +### 基本使用 + +```python +from app.services.analysis import AnalysisService + +# 使用多智能体架构(默认) +service = AnalysisService(use_multi_agent=True) +result = service.analyze("USStock", "AAPL", "zh-CN") + +# 使用传统架构(向后兼容) +service = AnalysisService(use_multi_agent=False) +result = service.analyze("USStock", "AAPL", "zh-CN") +``` + +### 环境变量配置 + +在 `.env` 文件中设置: + +```bash +# 是否启用多智能体架构(默认:True) +USE_MULTI_AGENT=True + +# 最大辩论轮数(默认:2) +MAX_DEBATE_ROUNDS=2 +``` + +### 反思学习 + +```python +from app.services.analysis import reflect_analysis + +# 从交易结果中学习 +reflect_analysis( + market="USStock", + symbol="AAPL", + decision="BUY", + returns=1000.0, # 收益 + result="交易成功,收益 10%" +) +``` + +## 智能体说明 + +### 分析师智能体 + +- **MarketAnalyst**: 技术分析,计算技术指标 +- **FundamentalAnalyst**: 基本面分析,财务数据 +- **NewsAnalyst**: 新闻事件分析 +- **SentimentAnalyst**: 市场情绪分析 +- **RiskAnalyst**: 风险评估 + +### 研究员智能体 + +- **BullResearcher**: 构建看涨论据 +- **BearResearcher**: 构建看跌论据 + +### 交易员智能体 + +- **TraderAgent**: 综合所有分析,做出交易决策 + +### 风险分析师智能体 + +- **RiskyAnalyst**: 激进风险分析 +- **NeutralAnalyst**: 中性风险分析 +- **SafeAnalyst**: 保守风险分析 + +## 返回结果格式 + +多智能体模式返回的完整结果包括: + +```json +{ + "overview": { + "overallScore": 75, + "recommendation": "BUY", + "confidence": 82, + "dimensionScores": {...}, + "report": "..." + }, + "fundamental": {...}, + "technical": {...}, + "news": {...}, + "sentiment": {...}, + "risk": {...}, + "debate": { + "bull": {...}, + "bear": {...}, + "research_decision": "..." + }, + "trader_decision": { + "decision": "BUY", + "confidence": 85, + "trading_plan": {...} + }, + "risk_debate": { + "risky": {...}, + "neutral": {...}, + "safe": {...} + }, + "final_decision": { + "decision": "BUY", + "confidence": 85, + "reasoning": "..." + } +} +``` + +## 优势 + +1. **多角度分析**:多个智能体从不同角度分析,减少盲点 +2. **辩论机制**:看涨/看跌辩论,发现潜在问题 +3. **风险控制**:多维度风险分析,提高决策质量 +4. **持续学习**:记忆系统支持从历史经验中学习 +5. **向后兼容**:可以切换到传统模式 + +## 注意事项 + +1. 多智能体模式会产生更多的 API 调用,成本较高 +2. 分析时间会比传统模式长 +3. 记忆系统需要 SQLite 数据库支持 +4. 建议在生产环境中根据需求选择合适的模式 diff --git a/backend_api_python/app/services/agents/__init__.py b/backend_api_python/app/services/agents/__init__.py new file mode 100644 index 0000000..5680cf0 --- /dev/null +++ b/backend_api_python/app/services/agents/__init__.py @@ -0,0 +1,32 @@ +""" +多智能体分析系统 +基于 TradingAgents 架构优化 +""" +from .base_agent import BaseAgent +from .analyst_agents import ( + MarketAnalyst, + FundamentalAnalyst, + NewsAnalyst, + SentimentAnalyst, + RiskAnalyst +) +from .researcher_agents import BullResearcher, BearResearcher +from .trader_agent import TraderAgent +from .risk_agents import RiskyAnalyst, NeutralAnalyst, SafeAnalyst +from .memory import AgentMemory + +__all__ = [ + 'BaseAgent', + 'MarketAnalyst', + 'FundamentalAnalyst', + 'NewsAnalyst', + 'SentimentAnalyst', + 'RiskAnalyst', + 'BullResearcher', + 'BearResearcher', + 'TraderAgent', + 'RiskyAnalyst', + 'NeutralAnalyst', + 'SafeAnalyst', + 'AgentMemory', +] diff --git a/backend_api_python/app/services/agents/analyst_agents.py b/backend_api_python/app/services/agents/analyst_agents.py new file mode 100644 index 0000000..d1c7ff8 --- /dev/null +++ b/backend_api_python/app/services/agents/analyst_agents.py @@ -0,0 +1,434 @@ +""" +Analyst agents. +Includes: market/technical, fundamental, news, sentiment, risk analysts. +""" +import json +from typing import Dict, Any +from .base_agent import BaseAgent +from .tools import AgentTools +from app.services.llm import LLMService + +logger = __import__('app.utils.logger', fromlist=['get_logger']).get_logger(__name__) + + +class MarketAnalyst(BaseAgent): + """Market / technical analyst.""" + + def __init__(self, memory=None): + super().__init__("MarketAnalyst", memory) + self.tools = AgentTools() + self.llm_service = LLMService() + + def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Run technical analysis.""" + market = context.get('market') + symbol = context.get('symbol') + language = context.get('language', 'zh-CN') + model = context.get('model') + base_data = context.get('base_data', {}) + + # Kline + current price + kline_data = base_data.get('kline_data') or self.tools.get_stock_data(market, symbol, days=30) + current_price = base_data.get('current_price') or self.tools.get_current_price(market, symbol) + + # Technical indicators + indicators = {} + if kline_data: + indicators = self.tools.calculate_technical_indicators(kline_data) + + # Prompts + lang_instruction = self._get_language_instruction(language) + system_prompt = f"""You are a professional technical analyst. Please analyze the technical trends of the stock or cryptocurrency, including: +{lang_instruction} +1. Technical Indicator Signals (MACD signals, RSI range, trend strength, important MA directions) +2. Technical Score (0-100). Be objective. Do not default to 75. + - 0-40: Bearish/Weak + - 41-60: Neutral + - 61-100: Bullish/Strong +3. Technical Analysis Report (about 300 words) + +Please strictly return the result in JSON format as follows: +{{ + "score": 75, + "indicators": {{ + "MACD": "Golden Cross/Death Cross or Flat", + "RSI(14)": "75 (Overbought)", + "MA20": "Upward/Downward/Flat", + "Support/Resistance": "Support: 150.00, Resistance: 165.50" + }}, + "report": "Technical analysis report content..." +}}""" + + user_prompt = f"""Please perform technical analysis for {symbol} in {market} market. + +**Current Price:** +{json.dumps(current_price, ensure_ascii=False, indent=2) if current_price else 'No Data'} + +**Kline Data (Last 30 days):** +{json.dumps(kline_data[-10:] if kline_data else [], ensure_ascii=False, indent=2) if kline_data else 'No Data'} + +**Calculated Technical Indicators:** +{json.dumps(indicators, ensure_ascii=False, indent=2) if indicators else 'No Data'} + +Please analyze the short-term and medium-term trends based on the above Kline data and price movements.""" + + # LLM call + result = self.llm_service.safe_call_llm( + system_prompt, + user_prompt, + {"score": 50, "indicators": {}, "report": "Failed to parse technical analysis"}, + model=model + ) + + return { + "type": "technical", + "data": result, + "indicators": indicators + } + + def _get_language_instruction(self, language: str) -> str: + """Return an English language instruction string for the LLM prompt.""" + language_map = { + 'zh-CN': 'Answer in Simplified Chinese.', + 'zh-TW': 'Answer in Traditional Chinese.', + 'en-US': 'Answer in English.', + 'ja-JP': 'Answer in Japanese.', + 'ko-KR': 'Answer in Korean.', + 'vi-VN': 'Answer in Vietnamese.', + 'th-TH': 'Answer in Thai.', + 'ar-SA': 'Answer in Arabic.', + 'fr-FR': 'Answer in French.', + 'de-DE': 'Answer in German.' + } + return language_map.get(language, 'Answer in English.') + + +class FundamentalAnalyst(BaseAgent): + """Fundamental analyst.""" + + def __init__(self, memory=None): + super().__init__("FundamentalAnalyst", memory) + self.tools = AgentTools() + self.llm_service = LLMService() + + def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Run fundamental analysis.""" + market = context.get('market') + symbol = context.get('symbol') + language = context.get('language', 'zh-CN') + model = context.get('model') + base_data = context.get('base_data', {}) + + # Fundamental data + fundamental_data = base_data.get('fundamental_data') or self.tools.get_fundamental_data(market, symbol) + company_data = base_data.get('company_data') or self.tools.get_company_data(market, symbol, language=language) + + # Memory + situation = f"{market}:{symbol} fundamental analysis" + memories = self.get_memories(situation, n_matches=2) + memory_prompt = self.format_memories_for_prompt(memories) + + lang_instruction = self._get_language_instruction(language) + system_prompt = f"""You are a fundamental analyst. Please analyze the financial status and industry position of the stock, including: +{lang_instruction} +1. Financial Indicators (Select key P/E, P/B, ROE, Revenue Growth) +2. Fundamental Score (0-100). Be objective. Do not default to 80. + - 0-40: Poor/Overvalued + - 41-60: Fair/Neutral + - 61-100: Good/Undervalued +3. Fundamental Analysis Report (about 300 words) + +{memory_prompt} + +Please strictly return the result in JSON format as follows: +{{ + "score": 80, + "financials": {{ + "P/E": "25.3", + "P/B": "4.2", + "ROE": "18.5%", + "Market Cap": "1200.5 B" + }}, + "report": "Fundamental analysis report content..." +}}""" + + user_prompt = f"""Please perform fundamental analysis for {symbol} in {market} market. + +**Basic Company Info:** +{json.dumps(company_data, ensure_ascii=False, indent=2) if company_data else 'No Data'} + +**Raw Fundamental Indicators:** +{json.dumps(fundamental_data, ensure_ascii=False, indent=2) if fundamental_data else 'No Data'} + +Please analyze based on the above data.""" + + result = self.llm_service.safe_call_llm( + system_prompt, + user_prompt, + {"score": 50, "financials": {}, "report": "Failed to parse fundamental analysis"}, + model=model + ) + + return { + "type": "fundamental", + "data": result + } + + def _get_language_instruction(self, language: str) -> str: + language_map = { + 'zh-CN': 'Answer in Simplified Chinese.', + 'zh-TW': 'Answer in Traditional Chinese.', + 'en-US': 'Answer in English.', + 'ja-JP': 'Answer in Japanese.', + 'ko-KR': 'Answer in Korean.', + 'vi-VN': 'Answer in Vietnamese.', + 'th-TH': 'Answer in Thai.', + 'ar-SA': 'Answer in Arabic.', + 'fr-FR': 'Answer in French.', + 'de-DE': 'Answer in German.' + } + return language_map.get(language, 'Answer in English.') + + +class NewsAnalyst(BaseAgent): + """News analyst.""" + + def __init__(self, memory=None): + super().__init__("NewsAnalyst", memory) + self.llm_service = LLMService() + + def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Run news analysis.""" + market = context.get('market') + symbol = context.get('symbol') + language = context.get('language', 'zh-CN') + model = context.get('model') + base_data = context.get('base_data', {}) + + company_data = base_data.get('company_data', {}) + news_data = base_data.get('news_data', []) + + lang_instruction = self._get_language_instruction(language) + system_prompt = f"""You are a professional news intelligence analyst. Your task is to extract key intelligence that has a major impact on stock prices from massive market information. + +Analysis Requirements: +{lang_instruction} +1. **Filter Noise**: News data may contain internet results from search engines. Please carefully discriminate and ignore ads, duplicate content, or irrelevant noise. Prioritize authoritative financial media and official announcements. +2. **Timeliness**: Focus on breaking news within the last 48 hours. For old news, lower its weight unless there are new developments. +3. **Deep Interpretation**: Do not just repeat news titles. Analyze the logic behind the event and its specific impact on company fundamentals or market sentiment (e.g., Earnings Beat -> Profit Improvement -> Valuation Increase). +4. **Scoring**: News Score (0-100). + - 0-40: Major Negative (e.g., financial fraud, regulatory crackdown, core business damage) + - 41-59: Neutral or minor impact + - 60-100: Positive (e.g., strong earnings, major partnership, policy support) + - Higher scores indicate more positive news. + +Please strictly return the result in JSON format as follows: +{{ + "score": 70, + "events": [ + {{ + "title": "Event Title", + "impact": "Positive/Negative/Neutral", + "summary": "Event summary and deep impact analysis...", + "date": "2023-10-27" + }} + ], + "report": "Comprehensive news analysis report, including overall judgment on recent market public opinion..." +}}""" + + user_prompt = f"""Please perform in-depth news intelligence analysis for {symbol} in {market} market. + +**Company Background:** +{json.dumps(company_data, ensure_ascii=False, indent=2) if company_data else 'No Data'} + +**Latest Intelligence Sources (Contains professional financial news and web search results, please discriminate):** +{json.dumps(news_data, ensure_ascii=False, indent=2) if news_data else 'No directly related news'} + +Please analyze based on the above intelligence. If the provided "Latest Intelligence Sources" contain no substantive content or only irrelevant noise, please explicitly state "No valid news available" and deduce logically based on the industry trends and macro market environment of the stock.""" + + result = self.llm_service.safe_call_llm( + system_prompt, + user_prompt, + {"score": 50, "events": [], "report": "Failed to parse news analysis"}, + model=model + ) + + return { + "type": "news", + "data": result + } + + def _get_language_instruction(self, language: str) -> str: + language_map = { + 'zh-CN': 'Answer in Simplified Chinese.', + 'zh-TW': 'Answer in Traditional Chinese.', + 'en-US': 'Answer in English.', + 'ja-JP': 'Answer in Japanese.', + 'ko-KR': 'Answer in Korean.', + 'vi-VN': 'Answer in Vietnamese.', + 'th-TH': 'Answer in Thai.', + 'ar-SA': 'Answer in Arabic.', + 'fr-FR': 'Answer in French.', + 'de-DE': 'Answer in German.' + } + return language_map.get(language, 'Answer in English.') + + +class SentimentAnalyst(BaseAgent): + """Sentiment analyst.""" + + def __init__(self, memory=None): + super().__init__("SentimentAnalyst", memory) + self.llm_service = LLMService() + + def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Run sentiment analysis.""" + market = context.get('market') + symbol = context.get('symbol') + language = context.get('language', 'zh-CN') + model = context.get('model') + base_data = context.get('base_data', {}) + + current_price = base_data.get('current_price', {}) + kline_data = base_data.get('kline_data', []) + + lang_instruction = self._get_language_instruction(language) + system_prompt = f"""You are a market sentiment analyst. Please analyze the market sentiment and popularity of the stock, including: +{lang_instruction} +1. Sentiment Heat Indicators (e.g., analyst ratings, social media discussion volume, put/call ratio) +2. Sentiment Score (0-100, higher means more optimistic). Be objective. Do not default to 65. + - 0-40: Bearish/Fearful + - 41-60: Neutral + - 61-100: Bullish/Greedy +3. Sentiment Analysis Report (about 300 words) + +Please strictly return the result in JSON format as follows: +{{ + "score": 65, + "scores": {{ + "Analyst Rating": 90, + "Social Media Heat": 85, + "Market Sentiment Index": 70 + }}, + "report": "Sentiment analysis report content..." +}} +Note: All values in the 'scores' dictionary must be integers between 0-100 (pure numbers), representing optimism or heat, without any text or percentage signs.""" + + user_prompt = f"""Please perform sentiment analysis for {symbol} in {market} market. +Based on the current Kline trends and price fluctuations, combined with your existing knowledge, evaluate whether the market sentiment for this stock is bullish, bearish, or neutral. + +**Current Price:** +{json.dumps(current_price, ensure_ascii=False, indent=2) if current_price else 'No Data'} + +**Kline Data (Recent Trends):** +{json.dumps(kline_data[-5:] if kline_data else [], ensure_ascii=False, indent=2) if kline_data else 'No Data'} + +Please evaluate market sentiment.""" + + result = self.llm_service.safe_call_llm( + system_prompt, + user_prompt, + {"score": 50, "scores": {"Analyst Rating": 50, "Social Media Heat": 50, "Market Sentiment Index": 50}, "report": "Failed to parse sentiment analysis"}, + model=model + ) + + return { + "type": "sentiment", + "data": result + } + + def _get_language_instruction(self, language: str) -> str: + language_map = { + 'zh-CN': 'Answer in Simplified Chinese.', + 'zh-TW': 'Answer in Traditional Chinese.', + 'en-US': 'Answer in English.', + 'ja-JP': 'Answer in Japanese.', + 'ko-KR': 'Answer in Korean.', + 'vi-VN': 'Answer in Vietnamese.', + 'th-TH': 'Answer in Thai.', + 'ar-SA': 'Answer in Arabic.', + 'fr-FR': 'Answer in French.', + 'de-DE': 'Answer in German.' + } + return language_map.get(language, 'Answer in English.') + + +class RiskAnalyst(BaseAgent): + """Risk analyst.""" + + def __init__(self, memory=None): + super().__init__("RiskAnalyst", memory) + self.llm_service = LLMService() + + def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Run risk assessment.""" + market = context.get('market') + symbol = context.get('symbol') + language = context.get('language', 'zh-CN') + model = context.get('model') + base_data = context.get('base_data', {}) + + current_price = base_data.get('current_price', {}) + kline_data = base_data.get('kline_data', []) + fundamental_data = base_data.get('fundamental_data', {}) + + lang_instruction = self._get_language_instruction(language) + system_prompt = f"""You are a risk management analyst. Please evaluate the investment risks of the stock, including: +{lang_instruction} +1. Risk Indicators (Volatility, Liquidity, Concentration Risk, Systemic Risk Exposure) +2. Risk Score (0-100, higher score means lower risk/safer). Be objective. Do not default to 60. + - 0-40: High Risk (Dangerous) + - 41-60: Moderate Risk + - 61-100: Low Risk (Safe) +3. Risk Assessment Report (about 300 words) + +Please strictly return the result in JSON format as follows: +{{ + "score": 60, + "metrics": {{ + "Volatility (Beta)": "1.2 (Higher than market)", + "Liquidity": "Good", + "Concentration Risk": "Low (Diversified business)" + }}, + "report": "Risk assessment report content..." +}}""" + + user_prompt = f"""Please perform risk assessment for {symbol} in {market} market. + +**Current Price:** +{json.dumps(current_price, ensure_ascii=False, indent=2) if current_price else 'No Data'} + +**Kline Data (Volatility Analysis):** +{json.dumps(kline_data, ensure_ascii=False, indent=2) if kline_data else 'No Data'} + +**Fundamental Data (Debt Risk):** +{json.dumps(fundamental_data, ensure_ascii=False, indent=2) if fundamental_data else 'No Data'} + +Please evaluate investment risks based on price volatility, fundamental data, etc.""" + + result = self.llm_service.safe_call_llm( + system_prompt, + user_prompt, + {"score": 50, "metrics": {}, "report": "Failed to parse risk assessment"}, + model=model + ) + + return { + "type": "risk", + "data": result + } + + def _get_language_instruction(self, language: str) -> str: + language_map = { + 'zh-CN': 'Answer in Simplified Chinese.', + 'zh-TW': 'Answer in Traditional Chinese.', + 'en-US': 'Answer in English.', + 'ja-JP': 'Answer in Japanese.', + 'ko-KR': 'Answer in Korean.', + 'vi-VN': 'Answer in Vietnamese.', + 'th-TH': 'Answer in Thai.', + 'ar-SA': 'Answer in Arabic.', + 'fr-FR': 'Answer in French.', + 'de-DE': 'Answer in German.' + } + return language_map.get(language, 'Answer in English.') diff --git a/backend_api_python/app/services/agents/base_agent.py b/backend_api_python/app/services/agents/base_agent.py new file mode 100644 index 0000000..beb8ac8 --- /dev/null +++ b/backend_api_python/app/services/agents/base_agent.py @@ -0,0 +1,71 @@ +""" +智能体基类 +""" +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional, List +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +class BaseAgent(ABC): + """智能体基类,所有分析智能体都继承此类""" + + def __init__(self, name: str, memory: Optional[Any] = None): + """ + 初始化智能体 + + Args: + name: 智能体名称 + memory: 记忆系统实例(可选) + """ + self.name = name + self.memory = memory + self.logger = get_logger(f"{__name__}.{name}") + + @abstractmethod + def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]: + """ + 执行分析任务 + + Args: + context: 分析上下文,包含市场、代码、基础数据等 + + Returns: + 分析结果字典 + """ + pass + + def get_memories(self, situation: str, n_matches: int = 2) -> List[Dict[str, Any]]: + """ + 从记忆中检索相似情况 + + Args: + situation: 当前情况描述 + n_matches: 返回的匹配数量 + + Returns: + 匹配的历史记录列表 + """ + if self.memory: + return self.memory.get_memories(situation, n_matches=n_matches) + return [] + + def format_memories_for_prompt(self, memories: List[Dict[str, Any]]) -> str: + """ + 格式化记忆为提示词 + + Args: + memories: 记忆列表 + + Returns: + 格式化的字符串 + """ + if not memories: + return "无历史经验可参考。" + + formatted = "历史经验参考:\n" + for i, mem in enumerate(memories, 1): + formatted += f"{i}. {mem.get('recommendation', 'N/A')}\n" + + return formatted diff --git a/backend_api_python/app/services/agents/coordinator.py b/backend_api_python/app/services/agents/coordinator.py new file mode 100644 index 0000000..d472121 --- /dev/null +++ b/backend_api_python/app/services/agents/coordinator.py @@ -0,0 +1,500 @@ +""" +Multi-agent coordinator. +Orchestrates agents and the overall analysis workflow. +""" +from typing import Dict, Any, List, Optional +from concurrent.futures import ThreadPoolExecutor, as_completed +from .analyst_agents import ( + MarketAnalyst, FundamentalAnalyst, NewsAnalyst, + SentimentAnalyst, RiskAnalyst +) +from .researcher_agents import BullResearcher, BearResearcher +from .trader_agent import TraderAgent +from .risk_agents import RiskyAnalyst, NeutralAnalyst, SafeAnalyst +from .memory import AgentMemory +from .reflection import ReflectionService +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +class AgentCoordinator: + """Multi-agent coordinator.""" + + @staticmethod + def _is_zh(language: str) -> bool: + return str(language or "").lower().startswith("zh") + + @classmethod + def _t(cls, language: str, en: str, zh: str) -> str: + """Pick a localized string for user-facing fields (not logs).""" + return zh if cls._is_zh(language) else en + + def __init__(self, enable_memory: bool = True, max_debate_rounds: int = 2): + """ + Args: + enable_memory: Enable memory/reflection + max_debate_rounds: Max debate rounds + """ + self.enable_memory = enable_memory + self.max_debate_rounds = max_debate_rounds + + # Reflection service + self.reflection_service = ReflectionService() if enable_memory else None + + # Memory stores + if enable_memory: + self.memories = { + 'market': AgentMemory('market_analyst'), + 'fundamental': AgentMemory('fundamental_analyst'), + 'news': AgentMemory('news_analyst'), + 'sentiment': AgentMemory('sentiment_analyst'), + 'risk': AgentMemory('risk_analyst'), + 'bull': AgentMemory('bull_researcher'), + 'bear': AgentMemory('bear_researcher'), + 'trader': AgentMemory('trader_agent'), + } + else: + self.memories = {} + + # Initialize agents + self._init_agents() + + def _init_agents(self): + """Initialize all agents.""" + # Analyst agents + self.market_analyst = MarketAnalyst( + memory=self.memories.get('market') + ) + self.fundamental_analyst = FundamentalAnalyst( + memory=self.memories.get('fundamental') + ) + self.news_analyst = NewsAnalyst( + memory=self.memories.get('news') + ) + self.sentiment_analyst = SentimentAnalyst( + memory=self.memories.get('sentiment') + ) + self.risk_analyst = RiskAnalyst( + memory=self.memories.get('risk') + ) + + # Researcher agents + self.bull_researcher = BullResearcher( + memory=self.memories.get('bull') + ) + self.bear_researcher = BearResearcher( + memory=self.memories.get('bear') + ) + + # Trader agent + self.trader_agent = TraderAgent( + memory=self.memories.get('trader') + ) + + # Risk debate agents + self.risky_analyst = RiskyAnalyst() + self.neutral_analyst = NeutralAnalyst() + self.safe_analyst = SafeAnalyst() + + def run_analysis(self, market: str, symbol: str, language: str = 'zh-CN', model: str = None) -> Dict[str, Any]: + """ + Run the full multi-agent analysis workflow. + """ + logger.info(f"Multi-agent analysis start: {market}:{symbol}, model={model}, language={language}") + + # Build base context + from .tools import AgentTools + tools = AgentTools() + + # 1) Base data + current_price = tools.get_current_price(market, symbol) + company_data = tools.get_company_data(market, symbol, language=language) + + # 2) Kline + fundamentals + kline_data = tools.get_stock_data(market, symbol, days=30) + fundamental_data = tools.get_fundamental_data(market, symbol) + + # 3) News (Finnhub + web search) + company_name = company_data.get('name', symbol) if company_data else symbol + news_data = tools.get_news(market, symbol, days=7, company_name=company_name) + + base_data = { + "market": market, + "symbol": symbol, + "current_price": current_price, + "kline_data": kline_data, + "fundamental_data": fundamental_data, + "company_data": company_data, + "news_data": news_data, + } + + context = { + "market": market, + "symbol": symbol, + "language": language, + "model": model, + "base_data": base_data + } + + # Phase 1: Analysts (parallel) + logger.info("Phase 1: Analyst team (parallel)") + with ThreadPoolExecutor(max_workers=5) as executor: + future_market = executor.submit(self.market_analyst.analyze, context) + future_fundamental = executor.submit(self.fundamental_analyst.analyze, context) + future_news = executor.submit(self.news_analyst.analyze, context) + future_sentiment = executor.submit(self.sentiment_analyst.analyze, context) + future_risk = executor.submit(self.risk_analyst.analyze, context) + + market_report = future_market.result() + fundamental_report = future_fundamental.result() + news_report = future_news.result() + sentiment_report = future_sentiment.result() + risk_report = future_risk.result() + + # Update context with analyst outputs + context.update({ + "market_report": market_report, + "fundamental_report": fundamental_report, + "news_report": news_report, + "sentiment_report": sentiment_report, + "risk_report": risk_report, + }) + + # Phase 2: Bull/Bear debate (parallel) + logger.info("Phase 2: Research debate (parallel)") + with ThreadPoolExecutor(max_workers=2) as executor: + future_bull = executor.submit(self.bull_researcher.analyze, context) + future_bear = executor.submit(self.bear_researcher.analyze, context) + + bull_argument = future_bull.result() + bear_argument = future_bear.result() + + context["bull_argument"] = bull_argument + context["bear_argument"] = bear_argument + + # Research manager decision (lightweight, based on debate) + research_decision = self._make_research_decision( + bull_argument, bear_argument, context + ) + context["research_decision"] = research_decision + + # Phase 3: Trader decision + logger.info("Phase 3: Trader decision") + trader_result = self.trader_agent.analyze(context) + trader_plan = trader_result.get('data', {}).get('trading_plan', {}) + context["trader_plan"] = trader_plan + + # Phase 4: Risk debate (parallel) + logger.info("Phase 4: Risk debate (parallel)") + with ThreadPoolExecutor(max_workers=3) as executor: + future_risky = executor.submit(self.risky_analyst.analyze, context) + future_neutral = executor.submit(self.neutral_analyst.analyze, context) + future_safe = executor.submit(self.safe_analyst.analyze, context) + + risky_result = future_risky.result() + neutral_result = future_neutral.result() + safe_result = future_safe.result() + + # Risk manager final decision + final_decision = self._make_risk_decision( + risky_result, neutral_result, safe_result, + trader_result, context + ) + + # Record analysis result for later reflection/validation + if self.reflection_service and final_decision.get('decision') in ['BUY', 'SELL', 'HOLD']: + try: + self.reflection_service.record_analysis( + market=market, + symbol=symbol, + price=base_data.get('current_price', {}).get('price'), + decision=final_decision.get('decision'), + confidence=final_decision.get('confidence', 50), + reasoning=final_decision.get('reasoning', ''), + check_days=7 # Validate after 7 days by default + ) + except Exception as e: + logger.warning(f"Record reflection failed: {e}") + + # Build final result (defensive defaults to keep frontend stable) + debate_data = { + "bull": bull_argument.get('data', {}) if bull_argument.get('data') else {}, + "bear": bear_argument.get('data', {}) if bear_argument.get('data') else {}, + "research_decision": research_decision if research_decision else "Analyzing..." + } + + trader_decision_data = trader_result.get('data', {}) if trader_result.get('data') else { + "decision": "HOLD", + "confidence": 50, + "reasoning": "Analyzing...", + "trading_plan": {}, + "report": "Analyzing..." + } + + risk_debate_data = { + "risky": risky_result.get('data', {}) if risky_result.get('data') else {}, + "neutral": neutral_result.get('data', {}) if neutral_result.get('data') else {}, + "safe": safe_result.get('data', {}) if safe_result.get('data') else {} + } + + # Ensure final_decision is present + if not final_decision or (isinstance(final_decision, dict) and len(final_decision) == 0): + final_decision = { + "decision": "HOLD", + "confidence": 50, + "reasoning": "Analyzing...", + "risk_summary": {}, + "recommendation": "Analyzing..." + } + + result = { + "overview": self._generate_overview(context, final_decision), + "fundamental": fundamental_report.get('data', {}), + "technical": market_report.get('data', {}), + "news": news_report.get('data', {}), + "sentiment": sentiment_report.get('data', {}), + "risk": risk_report.get('data', {}), + "debate": debate_data, + "trader_decision": trader_decision_data, + "risk_debate": risk_debate_data, + "final_decision": final_decision, + "error": None + } + + logger.info(f"Multi-agent analysis completed: {market}:{symbol}") + logger.info( + "Result fields - debate=%s, trader_decision=%s, risk_debate=%s, final_decision=%s", + bool(result.get('debate')), + bool(result.get('trader_decision')), + bool(result.get('risk_debate')), + bool(result.get('final_decision')), + ) + return result + + def _make_research_decision(self, bull: Dict, bear: Dict, context: Dict) -> str: + """Research manager decision (rule-based, lightweight).""" + try: + language = context.get("language", "en-US") + bull_confidence = bull.get('data', {}).get('confidence', 50) + bear_confidence = bear.get('data', {}).get('confidence', 50) + + # Tie-breaker when scores are close (<= 10) + score_diff = bull_confidence - bear_confidence + + if abs(score_diff) <= 10: + # Use technical + sentiment as a bias signal + market_score = context.get('market_report', {}).get('data', {}).get('score', 50) + sentiment_score = context.get('sentiment_report', {}).get('data', {}).get('score', 50) + + market_bias = (market_score + sentiment_score) / 2 + + if market_bias > 60: + return self._t( + language, + en=( + f"Research decision: bull and bear cases are close (bull {bull_confidence}% vs bear {bear_confidence}%), " + f"but technical/sentiment are optimistic (avg {market_bias:.1f}), slightly leaning bullish." + ), + zh=( + f"研究经理决策:多空论据势均力敌(看涨 {bull_confidence}% vs 看跌 {bear_confidence}%)," + f"但鉴于技术面和市场情绪偏乐观(平均分 {market_bias:.1f}),稍微倾向于看涨。" + ), + ) + elif market_bias < 40: + return self._t( + language, + en=( + f"Research decision: bull and bear cases are close (bull {bull_confidence}% vs bear {bear_confidence}%), " + f"but technical/sentiment are pessimistic (avg {market_bias:.1f}), slightly leaning bearish." + ), + zh=( + f"研究经理决策:多空论据势均力敌(看涨 {bull_confidence}% vs 看跌 {bear_confidence}%)," + f"但鉴于技术面和市场情绪偏悲观(平均分 {market_bias:.1f}),稍微倾向于看跌。" + ), + ) + else: + return self._t( + language, + en=( + f"Research decision: bull and bear cases are close (bull {bull_confidence}% vs bear {bear_confidence}%), " + "and market bias is unclear. Prefer neutral / wait-and-see." + ), + zh=( + f"研究经理决策:多空论据势均力敌(看涨 {bull_confidence}% vs 看跌 {bear_confidence}%)," + "且市场情绪不明朗,建议保持中立/观望。" + ), + ) + + elif score_diff > 10: + return self._t( + language, + en=( + f"Research decision: bullish case (confidence {bull_confidence}%) is clearly stronger than bearish " + f"(confidence {bear_confidence}%). Lean bullish." + ), + zh=( + f"研究经理决策:基于看涨论据(置信度 {bull_confidence}%)明显强于看跌论据(置信度 {bear_confidence}%),明确倾向于看涨。" + ), + ) + else: # score_diff < -10 + return self._t( + language, + en=( + f"Research decision: bearish case (confidence {bear_confidence}%) is clearly stronger than bullish " + f"(confidence {bull_confidence}%). Lean bearish." + ), + zh=( + f"研究经理决策:基于看跌论据(置信度 {bear_confidence}%)明显强于看涨论据(置信度 {bull_confidence}%),明确倾向于看跌。" + ), + ) + + except Exception as e: + logger.error(f"Research decision failed: {e}") + language = context.get("language", "en-US") if isinstance(context, dict) else "en-US" + return self._t(language, en="Research decision: unable to reach a clear conclusion.", zh="研究经理决策:无法做出明确判断。") + + def _make_risk_decision(self, risky: Dict, neutral: Dict, safe: Dict, + trader: Dict, context: Dict) -> Dict[str, Any]: + """Risk manager final decision (lightweight).""" + try: + language = context.get("language", "en-US") + trader_decision = trader.get('data', {}).get('decision', 'HOLD') + trader_confidence = trader.get('data', {}).get('confidence', 50) + + # Risk debate summary + risk_summary = { + "risky_view": risky.get('data', {}).get('recommendation', ''), + "neutral_view": neutral.get('data', {}).get('recommendation', ''), + "safe_view": safe.get('data', {}).get('recommendation', ''), + } + + # Final decision (use trader decision + risk debate context) + final_decision = { + "decision": trader_decision, + "confidence": trader_confidence, + "reasoning": self._t( + language, + en=f"Final decision is based on trader analysis ({trader_decision}, confidence {trader_confidence}%) and the risk debate.", + zh=f"基于交易员分析({trader_decision},置信度 {trader_confidence}%)和风险辩论,做出最终决策。", + ), + "risk_summary": risk_summary, + "recommendation": trader.get('data', {}).get('report', '') + } + + return final_decision + except Exception as e: + logger.error(f"Risk decision failed: {e}") + language = context.get("language", "en-US") if isinstance(context, dict) else "en-US" + return { + "decision": "HOLD", + "confidence": 50, + "reasoning": self._t(language, en="Risk decision failed", zh="风险决策失败"), + "risk_summary": {}, + "recommendation": "" + } + + def _generate_overview(self, context: Dict, final_decision: Dict) -> Dict[str, Any]: + """Generate overview section (lightweight, deterministic).""" + try: + language = context.get("language", "en-US") + # Extract dimension scores + technical_data = context.get('market_report', {}).get('data', {}) + fundamental_data = context.get('fundamental_report', {}).get('data', {}) + news_data = context.get('news_report', {}).get('data', {}) + sentiment_data = context.get('sentiment_report', {}).get('data', {}) + risk_data = context.get('risk_report', {}).get('data', {}) + + technical_score = technical_data.get('score', 50) + fundamental_score = fundamental_data.get('score', 50) + news_score = news_data.get('score', 50) + sentiment_score = sentiment_data.get('score', 50) + risk_score = risk_data.get('score', 50) + + # Generate an overall score using weighted dimensions + decision/confidence adjustment + decision = final_decision.get('decision', 'HOLD') + confidence = final_decision.get('confidence', 50) + + # 1) Base score: weighted average (tech 30%, fundamental 25%, news 15%, sentiment 15%, risk 15%) + weighted_score = ( + technical_score * 0.3 + + fundamental_score * 0.25 + + news_score * 0.15 + + sentiment_score * 0.15 + + risk_score * 0.15 + ) + + # 2) Decision adjustment: BUY pushes toward 60-100, SELL toward 0-40, HOLD toward 50 + if decision == 'BUY': + target_score = 60 + (confidence / 100 * 40) # Map to 60-100 + overall_score = (weighted_score * 0.4) + (target_score * 0.6) + elif decision == 'SELL': + target_score = 40 - (confidence / 100 * 40) # Map to 0-40 + overall_score = (weighted_score * 0.4) + (target_score * 0.6) + else: + overall_score = (weighted_score * 0.6) + (50 * 0.4) + + # Clamp to 0..100 + overall_score = max(0, min(100, int(overall_score))) + + return { + "overallScore": overall_score, + "recommendation": decision, + "confidence": confidence, + "dimensionScores": { + "fundamental": fundamental_score, + "technical": technical_score, + "news": news_score, + "sentiment": sentiment_score, + "risk": risk_score + }, + "report": final_decision.get( + 'reasoning', + self._t(language, en="Overview generated.", zh="综合分析完成"), + ) + } + except Exception as e: + logger.error(f"Generate overview failed: {e}") + language = context.get("language", "en-US") if isinstance(context, dict) else "en-US" + return { + "overallScore": 50, + "recommendation": "HOLD", + "confidence": 50, + "dimensionScores": { + "fundamental": 50, + "technical": 50, + "news": 50, + "sentiment": 50, + "risk": 50 + }, + "report": self._t(language, en="Failed to generate overview.", zh="综合分析生成失败") + } + + def reflect_and_learn(self, market: str, symbol: str, decision: str, + returns: Optional[float] = None, result: Optional[str] = None): + """ + Reflection hook: store post-trade outcomes into memory (local-only). + + Args: + market: Market + symbol: Symbol + decision: BUY/SELL/HOLD + returns: Return percentage + result: Free-text outcome + """ + if not self.enable_memory: + return + + try: + situation = f"{market}:{symbol} trading decision" + recommendation = f"Decision: {decision}, returns: {returns if returns is not None else 'N/A'}" + + # Update trader memory + if 'trader' in self.memories: + self.memories['trader'].add_memory( + situation, recommendation, result, returns + ) + + logger.info(f"Reflection completed: {market}:{symbol}, decision={decision}, returns={returns}") + except Exception as e: + logger.error(f"Reflection failed: {e}") diff --git a/backend_api_python/app/services/agents/memory.py b/backend_api_python/app/services/agents/memory.py new file mode 100644 index 0000000..f0eec94 --- /dev/null +++ b/backend_api_python/app/services/agents/memory.py @@ -0,0 +1,203 @@ +""" +智能体记忆系统 +使用 SQLite + 简单的文本相似度匹配 +""" +import sqlite3 +import json +import os +from typing import List, Dict, Any, Optional +from datetime import datetime +import difflib + +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +class AgentMemory: + """智能体记忆系统""" + + def __init__(self, agent_name: str, db_path: Optional[str] = None): + """ + 初始化记忆系统 + + Args: + agent_name: 智能体名称 + db_path: 数据库路径(可选) + """ + self.agent_name = agent_name + + if db_path is None: + # 默认数据库路径 + db_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'data', 'memory') + os.makedirs(db_dir, exist_ok=True) + db_path = os.path.join(db_dir, f'{agent_name}_memory.db') + + self.db_path = db_path + self._init_database() + + def _init_database(self): + """初始化数据库表""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + situation TEXT NOT NULL, + recommendation TEXT NOT NULL, + result TEXT, + returns REAL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 创建索引 + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_created_at ON memories(created_at) + ''') + + conn.commit() + conn.close() + except Exception as e: + logger.error(f"初始化记忆数据库失败: {e}") + + def add_memory(self, situation: str, recommendation: str, result: Optional[str] = None, returns: Optional[float] = None): + """ + 添加记忆 + + Args: + situation: 情况描述 + recommendation: 建议/决策 + result: 结果描述(可选) + returns: 收益(可选) + """ + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(''' + INSERT INTO memories (situation, recommendation, result, returns) + VALUES (?, ?, ?, ?) + ''', (situation, recommendation, result, returns)) + + conn.commit() + conn.close() + logger.info(f"{self.agent_name} 添加新记忆") + except Exception as e: + logger.error(f"添加记忆失败: {e}") + + def get_memories(self, current_situation: str, n_matches: int = 2) -> List[Dict[str, Any]]: + """ + 检索相似记忆 + + Args: + current_situation: 当前情况描述 + n_matches: 返回的匹配数量 + + Returns: + 匹配的记忆列表 + """ + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + # 获取所有记忆 + cursor.execute(''' + SELECT id, situation, recommendation, result, returns, created_at + FROM memories + ORDER BY created_at DESC + LIMIT 100 + ''') + + all_memories = cursor.fetchall() + conn.close() + + if not all_memories: + return [] + + # 计算相似度 + scored_memories = [] + for mem in all_memories: + mem_id, situation, recommendation, result, returns, created_at = mem + + # 使用简单的文本相似度 + similarity = difflib.SequenceMatcher( + None, + current_situation.lower(), + situation.lower() + ).ratio() + + scored_memories.append({ + 'id': mem_id, + 'matched_situation': situation, + 'recommendation': recommendation, + 'result': result, + 'returns': returns, + 'similarity_score': similarity, + 'created_at': created_at + }) + + # 按相似度排序 + scored_memories.sort(key=lambda x: x['similarity_score'], reverse=True) + + # 返回前 n_matches 个 + return scored_memories[:n_matches] + + except Exception as e: + logger.error(f"检索记忆失败: {e}") + return [] + + def update_memory_result(self, memory_id: int, result: str, returns: Optional[float] = None): + """ + 更新记忆的结果 + + Args: + memory_id: 记忆ID + result: 结果描述 + returns: 收益 + """ + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(''' + UPDATE memories + SET result = ?, returns = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (result, returns, memory_id)) + + conn.commit() + conn.close() + logger.info(f"{self.agent_name} 更新记忆 {memory_id}") + except Exception as e: + logger.error(f"更新记忆失败: {e}") + + def get_statistics(self) -> Dict[str, Any]: + """获取记忆统计信息""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute('SELECT COUNT(*) FROM memories') + total = cursor.fetchone()[0] + + cursor.execute('SELECT AVG(returns) FROM memories WHERE returns IS NOT NULL') + avg_returns = cursor.fetchone()[0] or 0 + + cursor.execute('SELECT COUNT(*) FROM memories WHERE returns > 0') + positive = cursor.fetchone()[0] + + conn.close() + + return { + 'total_memories': total, + 'average_returns': round(avg_returns, 2), + 'positive_decisions': positive, + 'success_rate': round(positive / total * 100, 2) if total > 0 else 0 + } + except Exception as e: + logger.error(f"获取统计信息失败: {e}") + return {} diff --git a/backend_api_python/app/services/agents/reflection.py b/backend_api_python/app/services/agents/reflection.py new file mode 100644 index 0000000..f8b18f1 --- /dev/null +++ b/backend_api_python/app/services/agents/reflection.py @@ -0,0 +1,205 @@ +""" +自动反思与验证服务 +用于记录分析预测,并在未来自动验证结果,实现闭环学习 +""" +import sqlite3 +import os +import json +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional +from app.utils.logger import get_logger +from .memory import AgentMemory +from .tools import AgentTools + +logger = get_logger(__name__) + +class ReflectionService: + """反思服务:管理分析记录的存储和验证""" + + def __init__(self, db_path: Optional[str] = None): + if db_path is None: + # 默认数据库路径 + db_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'data', 'memory') + os.makedirs(db_dir, exist_ok=True) + db_path = os.path.join(db_dir, 'reflection_records.db') + + self.db_path = db_path + self.tools = AgentTools() + self._init_database() + + def _init_database(self): + """初始化数据库表""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + # 创建分析记录表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS analysis_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + market TEXT NOT NULL, + symbol TEXT NOT NULL, + initial_price REAL, + decision TEXT, + confidence INTEGER, + reasoning TEXT, + analysis_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + target_check_date TIMESTAMP, + status TEXT DEFAULT 'PENDING', -- PENDING, COMPLETED, FAILED + final_price REAL, + actual_return REAL, + check_result TEXT + ) + ''') + + # 创建索引 + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_status_date ON analysis_records(status, target_check_date) + ''') + + conn.commit() + conn.close() + except Exception as e: + logger.error(f"初始化反思数据库失败: {e}") + + def record_analysis(self, market: str, symbol: str, price: float, + decision: str, confidence: int, reasoning: str, + check_days: int = 7): + """ + 记录一次分析,以便未来验证 + + Args: + market: 市场 + symbol: 代码 + price: 当前价格 + decision: 决策 (BUY/SELL/HOLD) + confidence: 置信度 + reasoning: 理由 + check_days: 几天后验证 (默认7天) + """ + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + target_date = datetime.now() + timedelta(days=check_days) + + cursor.execute(''' + INSERT INTO analysis_records + (market, symbol, initial_price, decision, confidence, reasoning, target_check_date) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', (market, symbol, price, decision, confidence, reasoning, target_date)) + + conn.commit() + conn.close() + logger.info(f"已记录分析用于反思: {market}:{symbol}, 将在 {check_days} 天后验证") + except Exception as e: + logger.error(f"记录分析失败: {e}") + + def run_verification_cycle(self): + """ + 执行验证周期:检查到期的记录,验证结果,并写入记忆 + """ + logger.info("开始执行自动反思验证周期...") + + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + # 1. 查找所有已到期且未处理的记录 + cursor.execute(''' + SELECT id, market, symbol, initial_price, decision, confidence, reasoning, analysis_date + FROM analysis_records + WHERE status = 'PENDING' AND target_check_date <= CURRENT_TIMESTAMP + ''') + + records = cursor.fetchall() + + if not records: + logger.info("没有需要验证的记录") + conn.close() + return + + logger.info(f"发现 {len(records)} 条待验证记录") + + # 初始化记忆系统(用于写入验证结果) + trader_memory = AgentMemory('trader_agent') + + for record in records: + record_id, market, symbol, initial_price, decision, confidence, reasoning, analysis_date = record + + try: + # 2. 获取当前最新价格 + current_price_data = self.tools.get_current_price(market, symbol) + current_price = current_price_data.get('price') + + if not current_price: + logger.warning(f"无法获取 {market}:{symbol} 的当前价格,跳过") + continue + + # 3. 计算收益和结果 + if not initial_price or initial_price == 0: + actual_return = 0.0 + else: + actual_return = (current_price - initial_price) / initial_price * 100 + + # 评估结果 + result_desc = "" + is_good_prediction = False + + if decision == "BUY": + if actual_return > 2.0: + result_desc = "准确:买入后价格上涨" + is_good_prediction = True + elif actual_return < -2.0: + result_desc = "错误:买入后价格下跌" + else: + result_desc = "中性:价格波动不大" + elif decision == "SELL": + if actual_return < -2.0: + result_desc = "准确:卖出后价格下跌" + is_good_prediction = True + elif actual_return > 2.0: + result_desc = "错误:卖出后价格上涨" + else: + result_desc = "中性:价格波动不大" + else: # HOLD + if -2.0 <= actual_return <= 2.0: + result_desc = "准确:持有期间波动不大" + is_good_prediction = True + else: + result_desc = f"偏差:持有期间出现了较大波动 ({actual_return:.2f}%)" + + # 4. 写入记忆系统 (Let the agent learn) + memory_situation = f"{market}:{symbol} 自动验证 (预测日期: {analysis_date})" + memory_recommendation = f"当时决策: {decision} (置信度 {confidence}), 理由: {reasoning[:50]}..." + memory_result = f"验证结果: {result_desc}, 实际收益: {actual_return:.2f}% (初始 {initial_price} -> 最新 {current_price})" + + trader_memory.add_memory( + memory_situation, + memory_recommendation, + memory_result, + actual_return + ) + + # 5. 更新记录状态 + cursor.execute(''' + UPDATE analysis_records + SET status = 'COMPLETED', final_price = ?, actual_return = ?, check_result = ? + WHERE id = ? + ''', (current_price, actual_return, result_desc, record_id)) + + conn.commit() + logger.info(f"验证完成 {market}:{symbol}: {result_desc}") + + except Exception as inner_e: + logger.error(f"处理记录 {record_id} 失败: {inner_e}") + # 标记为失败,避免重复处理 + # cursor.execute("UPDATE analysis_records SET status = 'FAILED' WHERE id = ?", (record_id,)) + # conn.commit() + + conn.close() + logger.info("反思验证周期结束") + + except Exception as e: + logger.error(f"执行验证周期失败: {e}") + diff --git a/backend_api_python/app/services/agents/researcher_agents.py b/backend_api_python/app/services/agents/researcher_agents.py new file mode 100644 index 0000000..cd2ed9e --- /dev/null +++ b/backend_api_python/app/services/agents/researcher_agents.py @@ -0,0 +1,192 @@ +""" +Researcher agents. +Includes: bull researcher and bear researcher. +""" +import json +from typing import Dict, Any +from .base_agent import BaseAgent +from app.services.llm import LLMService + +logger = __import__('app.utils.logger', fromlist=['get_logger']).get_logger(__name__) + + +class BullResearcher(BaseAgent): + """Bullish researcher.""" + + def __init__(self, memory=None): + super().__init__("BullResearcher", memory) + self.llm_service = LLMService() + + def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Construct the bull case.""" + market = context.get('market') + symbol = context.get('symbol') + language = context.get('language', 'zh-CN') + model = context.get('model') + + # Inputs + market_report = context.get('market_report', {}) + fundamental_report = context.get('fundamental_report', {}) + news_report = context.get('news_report', {}) + sentiment_report = context.get('sentiment_report', {}) + + # Memory + situation = f"{market}:{symbol} bull case" + memories = self.get_memories(situation, n_matches=2) + memory_prompt = self.format_memories_for_prompt(memories) + + lang_instruction = self._get_language_instruction(language) + system_prompt = f"""You are a Bullish Analyst, constructing a bullish argument for an investment decision. Your tasks are: +{lang_instruction} +1. Highlight growth potential, competitive advantages, and positive market indicators. +2. Use the provided research and data to build a strong argument. +3. Effectively address/counter bearish viewpoints. +4. Learn from historical experience: {memory_prompt} +5. **Confidence Score**: Evaluate your confidence in the bullish case (0-100). Be realistic. If the data is mixed or weak, lower your confidence. Do NOT default to 75. + +Please return in JSON format as follows: +{{ + "argument": "Detailed bullish argument...", + "key_points": ["Point 1", "Point 2", "Point 3"], + "confidence": 75 +}}""" + + user_prompt = f"""Based on the following analysis reports, construct a bullish argument for {symbol} in {market} market: + +**Market Technical Analysis:** +{json.dumps(market_report.get('data', {}), ensure_ascii=False, indent=2) if market_report else 'No Data'} + +**Fundamental Analysis:** +{json.dumps(fundamental_report.get('data', {}), ensure_ascii=False, indent=2) if fundamental_report else 'No Data'} + +**News Analysis:** +{json.dumps(news_report.get('data', {}), ensure_ascii=False, indent=2) if news_report else 'No Data'} + +**Sentiment Analysis:** +{json.dumps(sentiment_report.get('data', {}), ensure_ascii=False, indent=2) if sentiment_report else 'No Data'} + +Please construct a strong bullish argument, emphasizing growth potential, competitive advantages, and positive indicators.""" + + result = self.llm_service.safe_call_llm( + system_prompt, + user_prompt, + {"argument": "", "key_points": [], "confidence": 50}, + model=model + ) + + return { + "type": "bull", + "data": result + } + + def _get_language_instruction(self, language: str) -> str: + language_map = { + 'zh-CN': 'Answer in Simplified Chinese.', + 'zh-TW': 'Answer in Traditional Chinese.', + 'en-US': 'Answer in English.', + 'ja-JP': 'Answer in Japanese.', + 'ko-KR': 'Answer in Korean.', + 'vi-VN': 'Answer in Vietnamese.', + 'th-TH': 'Answer in Thai.', + 'ar-SA': 'Answer in Arabic.', + 'fr-FR': 'Answer in French.', + 'de-DE': 'Answer in German.' + } + return language_map.get(language, 'Answer in English.') + + +class BearResearcher(BaseAgent): + """Bearish researcher.""" + + def __init__(self, memory=None): + super().__init__("BearResearcher", memory) + self.llm_service = LLMService() + + def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Construct the bear case.""" + market = context.get('market') + symbol = context.get('symbol') + language = context.get('language', 'zh-CN') + model = context.get('model') + + # Inputs + market_report = context.get('market_report', {}) + fundamental_report = context.get('fundamental_report', {}) + news_report = context.get('news_report', {}) + sentiment_report = context.get('sentiment_report', {}) + risk_report = context.get('risk_report', {}) + + # Bull argument (if present) + bull_argument = context.get('bull_argument', '') + + # Memory + situation = f"{market}:{symbol} bear case" + memories = self.get_memories(situation, n_matches=2) + memory_prompt = self.format_memories_for_prompt(memories) + + lang_instruction = self._get_language_instruction(language) + system_prompt = f"""You are a Bearish Analyst, constructing a bearish argument for an investment decision. Your tasks are: +{lang_instruction} +1. Identify risks, challenges, and negative indicators. +2. Use the provided research and data to build a strong argument. +3. Effectively address/counter bullish viewpoints. +4. Learn from historical experience: {memory_prompt} +5. **Confidence Score**: Evaluate your confidence in the bearish case (0-100). Be realistic. If the data is mixed or weak, lower your confidence. Do NOT default to 75. + +Please return in JSON format as follows: +{{ + "argument": "Detailed bearish argument...", + "key_points": ["Point 1", "Point 2", "Point 3"], + "confidence": 75 +}}""" + + # Bull argument section (avoid backslashes in f-string expression) + bull_argument_section = f"**Bullish Argument (Needs Rebuttal):**\n{bull_argument}" if bull_argument else "" + + user_prompt = f"""Based on the following analysis reports, construct a bearish argument for {symbol} in {market} market: + +**Market Technical Analysis:** +{json.dumps(market_report.get('data', {}), ensure_ascii=False, indent=2) if market_report else 'No Data'} + +**Fundamental Analysis:** +{json.dumps(fundamental_report.get('data', {}), ensure_ascii=False, indent=2) if fundamental_report else 'No Data'} + +**News Analysis:** +{json.dumps(news_report.get('data', {}), ensure_ascii=False, indent=2) if news_report else 'No Data'} + +**Sentiment Analysis:** +{json.dumps(sentiment_report.get('data', {}), ensure_ascii=False, indent=2) if sentiment_report else 'No Data'} + +**Risk Analysis:** +{json.dumps(risk_report.get('data', {}), ensure_ascii=False, indent=2) if risk_report else 'No Data'} + +{bull_argument_section} + +Please construct a strong bearish argument, emphasizing risks, challenges, and negative indicators.""" + + result = self.llm_service.safe_call_llm( + system_prompt, + user_prompt, + {"argument": "", "key_points": [], "confidence": 50}, + model=model + ) + + return { + "type": "bear", + "data": result + } + + def _get_language_instruction(self, language: str) -> str: + language_map = { + 'zh-CN': 'Answer in Simplified Chinese.', + 'zh-TW': 'Answer in Traditional Chinese.', + 'en-US': 'Answer in English.', + 'ja-JP': 'Answer in Japanese.', + 'ko-KR': 'Answer in Korean.', + 'vi-VN': 'Answer in Vietnamese.', + 'th-TH': 'Answer in Thai.', + 'ar-SA': 'Answer in Arabic.', + 'fr-FR': 'Answer in French.', + 'de-DE': 'Answer in German.' + } + return language_map.get(language, 'Answer in English.') diff --git a/backend_api_python/app/services/agents/risk_agents.py b/backend_api_python/app/services/agents/risk_agents.py new file mode 100644 index 0000000..54e2982 --- /dev/null +++ b/backend_api_python/app/services/agents/risk_agents.py @@ -0,0 +1,206 @@ +""" +Risk debate agents. +Includes: aggressive / neutral / conservative risk analysts. +""" +import json +from typing import Dict, Any +from .base_agent import BaseAgent +from app.services.llm import LLMService + +logger = __import__('app.utils.logger', fromlist=['get_logger']).get_logger(__name__) + + +class RiskyAnalyst(BaseAgent): + """Aggressive risk analyst.""" + + def __init__(self, memory=None): + super().__init__("RiskyAnalyst", memory) + self.llm_service = LLMService() + + def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Analyze risk from an aggressive perspective.""" + market = context.get('market') + symbol = context.get('symbol') + language = context.get('language', 'zh-CN') + model = context.get('model') + trader_plan = context.get('trader_plan', {}) + + lang_instruction = self._get_language_instruction(language) + system_prompt = f"""You are an Aggressive Risk Analyst. You tend to: +{lang_instruction} +1. Emphasize high return potential, even with higher risks. +2. Believe current risks are controllable and worth taking. +3. Support aggressive trading strategies. + +Please return in JSON format as follows: +{{ + "argument": "Aggressive risk analysis argument...", + "risk_assessment": "Risk controllable, high return potential", + "recommendation": "Support trading plan" +}}""" + + user_prompt = f"""Perform aggressive risk analysis for {symbol} in {market} market. + +**Trading Plan:** +{json.dumps(trader_plan, ensure_ascii=False, indent=2) if trader_plan else 'No Data'} + +Please analyze risk from an aggressive perspective, emphasizing return potential.""" + + result = self.llm_service.safe_call_llm( + system_prompt, + user_prompt, + {"argument": "", "risk_assessment": "", "recommendation": ""}, + model=model + ) + + return { + "type": "risky", + "data": result + } + + def _get_language_instruction(self, language: str) -> str: + language_map = { + 'zh-CN': 'Answer in Simplified Chinese.', + 'zh-TW': 'Answer in Traditional Chinese.', + 'en-US': 'Answer in English.', + 'ja-JP': 'Answer in Japanese.', + 'ko-KR': 'Answer in Korean.', + 'vi-VN': 'Answer in Vietnamese.', + 'th-TH': 'Answer in Thai.', + 'ar-SA': 'Answer in Arabic.', + 'fr-FR': 'Answer in French.', + 'de-DE': 'Answer in German.' + } + return language_map.get(language, 'Answer in English.') + + +class NeutralAnalyst(BaseAgent): + """Neutral risk analyst.""" + + def __init__(self, memory=None): + super().__init__("NeutralAnalyst", memory) + self.llm_service = LLMService() + + def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Analyze risk from a neutral perspective.""" + market = context.get('market') + symbol = context.get('symbol') + language = context.get('language', 'zh-CN') + model = context.get('model') + trader_plan = context.get('trader_plan', {}) + + lang_instruction = self._get_language_instruction(language) + system_prompt = f"""You are a Neutral Risk Analyst. You tend to: +{lang_instruction} +1. Balance risk and return. +2. Objectively evaluate various possibilities. +3. Provide neutral risk advice. + +Please return in JSON format as follows: +{{ + "argument": "Neutral risk analysis argument...", + "risk_assessment": "Balance between risk and return", + "recommendation": "Cautiously execute trading plan" +}}""" + + user_prompt = f"""Perform neutral risk analysis for {symbol} in {market} market. + +**Trading Plan:** +{json.dumps(trader_plan, ensure_ascii=False, indent=2) if trader_plan else 'No Data'} + +Please analyze risk from a neutral perspective, balancing risk and return.""" + + result = self.llm_service.safe_call_llm( + system_prompt, + user_prompt, + {"argument": "", "risk_assessment": "", "recommendation": ""}, + model=model + ) + + return { + "type": "neutral", + "data": result + } + + def _get_language_instruction(self, language: str) -> str: + language_map = { + 'zh-CN': 'Answer in Simplified Chinese.', + 'zh-TW': 'Answer in Traditional Chinese.', + 'en-US': 'Answer in English.', + 'ja-JP': 'Answer in Japanese.', + 'ko-KR': 'Answer in Korean.', + 'vi-VN': 'Answer in Vietnamese.', + 'th-TH': 'Answer in Thai.', + 'ar-SA': 'Answer in Arabic.', + 'fr-FR': 'Answer in French.', + 'de-DE': 'Answer in German.' + } + return language_map.get(language, 'Answer in English.') + + +class SafeAnalyst(BaseAgent): + """Conservative risk analyst.""" + + def __init__(self, memory=None): + super().__init__("SafeAnalyst", memory) + self.llm_service = LLMService() + + def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Analyze risk from a conservative perspective.""" + market = context.get('market') + symbol = context.get('symbol') + language = context.get('language', 'zh-CN') + model = context.get('model') + trader_plan = context.get('trader_plan', {}) + risk_report = context.get('risk_report', {}) + + lang_instruction = self._get_language_instruction(language) + system_prompt = f"""You are a Conservative Risk Analyst. You tend to: +{lang_instruction} +1. Emphasize risk control, prioritizing capital protection. +2. Identify potential risk points. +3. Suggest cautious or conservative trading strategies. + +Please return in JSON format as follows: +{{ + "argument": "Conservative risk analysis argument...", + "risk_assessment": "High risk exists, suggest caution", + "recommendation": "Suggest reducing position or suspending trading" +}}""" + + user_prompt = f"""Perform conservative risk analysis for {symbol} in {market} market. + +**Trading Plan:** +{json.dumps(trader_plan, ensure_ascii=False, indent=2) if trader_plan else 'No Data'} + +**Risk Analysis Report:** +{json.dumps(risk_report.get('data', {}), ensure_ascii=False, indent=2) if risk_report else 'No Data'} + +Please analyze risk from a conservative perspective, emphasizing risk control.""" + + result = self.llm_service.safe_call_llm( + system_prompt, + user_prompt, + {"argument": "", "risk_assessment": "", "recommendation": ""}, + model=model + ) + + return { + "type": "safe", + "data": result + } + + def _get_language_instruction(self, language: str) -> str: + language_map = { + 'zh-CN': 'Answer in Simplified Chinese.', + 'zh-TW': 'Answer in Traditional Chinese.', + 'en-US': 'Answer in English.', + 'ja-JP': 'Answer in Japanese.', + 'ko-KR': 'Answer in Korean.', + 'vi-VN': 'Answer in Vietnamese.', + 'th-TH': 'Answer in Thai.', + 'ar-SA': 'Answer in Arabic.', + 'fr-FR': 'Answer in French.', + 'de-DE': 'Answer in German.' + } + return language_map.get(language, 'Answer in English.') diff --git a/backend_api_python/app/services/agents/tools.py b/backend_api_python/app/services/agents/tools.py new file mode 100644 index 0000000..b9a6ed4 --- /dev/null +++ b/backend_api_python/app/services/agents/tools.py @@ -0,0 +1,603 @@ +""" +Agent tools. + +Provides data fetching helpers for the multi-agent analysis pipeline. +All docstrings/log messages in this module are English. Output language of AI reports +is controlled by the `language` value passed through the analysis context. +""" +from typing import Dict, Any, Optional, List +from datetime import datetime, timedelta +import os +import time +import pandas as pd +import yfinance as yf +import finnhub +import ccxt +import requests + +from app.utils.logger import get_logger +from app.config import APIKeys +from app.services.search import SearchService + +logger = get_logger(__name__) + + +class AgentTools: + """A thin wrapper around various public data sources used by agents.""" + + def __init__(self): + self.search_service = SearchService() + self.finnhub_client = None + if APIKeys.is_configured('FINNHUB_API_KEY'): + try: + self.finnhub_client = finnhub.Client(api_key=APIKeys.FINNHUB_API_KEY) + except Exception as e: + # Safe logging to avoid cascading errors during exception handling + try: + logger.warning(f"Finnhub init failed: {e}") + except Exception: + # Fallback to print if logging fails + print(f"Warning: Finnhub init failed: {e}") + + # Optional dependency: akshare (A-share fundamentals/company info) + try: + import akshare as ak # type: ignore + self._ak = ak + self._has_akshare = True + except Exception: + self._ak = None + self._has_akshare = False + + # AShare spot cache (avoid fetching the full market list repeatedly) + self._ashare_spot_cache = None + self._ashare_spot_cache_ts = 0 + self._ashare_spot_cache_ttl = 300 # seconds + + def _get_ashare_spot_df(self): + """Cached AShare spot dataframe via akshare (may be heavy on first load).""" + if not self._akshare_required(): + return None + now = int(time.time()) + if self._ashare_spot_cache is not None and (now - int(self._ashare_spot_cache_ts)) < int(self._ashare_spot_cache_ttl): + return self._ashare_spot_cache + ak = self._ak + if ak is None or not hasattr(ak, "stock_zh_a_spot_em"): + return None + df = ak.stock_zh_a_spot_em() + self._ashare_spot_cache = df + self._ashare_spot_cache_ts = now + return df + + def _ccxt_exchange(self): + """Create a CCXT exchange client (Binance) with optional proxy support.""" + cfg: Dict[str, Any] = {'timeout': 5000, 'enableRateLimit': True} + # Keep proxy behavior consistent with data sources (.env PROXY_* is supported) + from app.config import CCXTConfig + proxy = (CCXTConfig.PROXY or '').strip() + if proxy: + cfg['proxies'] = {'http': proxy, 'https': proxy} + return ccxt.binance(cfg) + + def _akshare_required(self) -> bool: + """Whether akshare is available at runtime.""" + return bool(self._has_akshare and self._ak is not None) + + def get_stock_data(self, market: str, symbol: str, days: int = 30) -> Optional[List[Dict[str, Any]]]: + """ + Get daily Kline data for recent days (best-effort). + + Args: + market: Market + symbol: Symbol + days: Days + + Returns: + List of OHLCV dicts or None + """ + try: + klines = [] + + if market == 'USStock': + end_date = datetime.now().strftime('%Y-%m-%d') + start_date = (datetime.now() - timedelta(days=days + 5)).strftime('%Y-%m-%d') + + ticker = yf.Ticker(symbol) + df = ticker.history(start=start_date, end=end_date, interval="1d") + + if not df.empty: + df = df.tail(days).reset_index() + for _, row in df.iterrows(): + klines.append({ + "time": row['Date'].strftime('%Y-%m-%d'), + "open": round(row['Open'], 4), + "high": round(row['High'], 4), + "low": round(row['Low'], 4), + "close": round(row['Close'], 4), + "volume": int(row['Volume']) + }) + return klines + + elif market == 'Crypto': + exchange = self._ccxt_exchange() + symbol_pair = f'{symbol}/USDT' + start_time = int((datetime.now() - timedelta(days=days)).timestamp()) + ohlcv = exchange.fetch_ohlcv(symbol_pair, '1d', since=start_time * 1000, limit=days) + if ohlcv: + for candle in ohlcv: + klines.append({ + "time": datetime.fromtimestamp(candle[0] / 1000).strftime('%Y-%m-%d'), + "open": candle[1], + "high": candle[2], + "low": candle[3], + "close": candle[4], + "volume": candle[5] + }) + return klines + + # CN/HK stocks + if market in ('AShare', 'HShare'): + # Prefer akshare for AShare (requested), fall back to yfinance. + if market == 'AShare' and self._akshare_required(): + try: + ak = self._ak + start_date = (datetime.now() - timedelta(days=days + 10)).strftime('%Y%m%d') + end_date = datetime.now().strftime('%Y%m%d') + # akshare returns a dataframe with Chinese column names. + df = ak.stock_zh_a_hist(symbol=symbol, period="daily", start_date=start_date, end_date=end_date, adjust="qfq") + if df is not None and not df.empty: + df = df.tail(days) + for _, row in df.iterrows(): + dt = row.get('日期') + # dt can be datetime/date/str + if hasattr(dt, "strftime"): + t = dt.strftime('%Y-%m-%d') + else: + t = str(dt)[:10] + klines.append({ + "time": t, + "open": float(row.get('开盘', 0) or 0), + "high": float(row.get('最高', 0) or 0), + "low": float(row.get('最低', 0) or 0), + "close": float(row.get('收盘', 0) or 0), + "volume": float(row.get('成交量', 0) or 0), + }) + return klines + except Exception as e: + logger.warning(f"akshare AShare kline failed ({symbol}): {e}") + + # yfinance fallback (daily) + if market == 'AShare': + yf_symbol = f"{symbol}.SS" if symbol.startswith('6') else f"{symbol}.SZ" + else: + yf_symbol = f"{symbol.zfill(4)}.HK" + + end_date = datetime.now().strftime('%Y-%m-%d') + start_date = (datetime.now() - timedelta(days=days + 5)).strftime('%Y-%m-%d') + + ticker = yf.Ticker(yf_symbol) + df = ticker.history(start=start_date, end=end_date, interval="1d") + + if not df.empty: + df = df.tail(days).reset_index() + for _, row in df.iterrows(): + klines.append({ + "time": row['Date'].strftime('%Y-%m-%d'), + "open": round(row['Open'], 4), + "high": round(row['High'], 4), + "low": round(row['Low'], 4), + "close": round(row['Close'], 4), + "volume": int(row['Volume']) + }) + return klines + + except Exception as e: + logger.error(f"Failed to fetch kline data {market}:{symbol}: {e}") + + return None + + def get_current_price(self, market: str, symbol: str) -> Optional[Dict[str, Any]]: + """Get current price (best-effort).""" + try: + if market == 'USStock' and self.finnhub_client: + quote = self.finnhub_client.quote(symbol) + if quote and quote.get('c'): + return { + "price": quote.get('c', 0), + "change": quote.get('d', 0), + "changePercent": quote.get('dp', 0), + "high": quote.get('h', 0), + "low": quote.get('l', 0), + "open": quote.get('o', 0), + "previousClose": quote.get('pc', 0) + } + elif market == 'Crypto': + exchange = self._ccxt_exchange() + symbol_pair = f'{symbol}/USDT' + ticker = exchange.fetch_ticker(symbol_pair) + if ticker: + return { + "price": ticker.get('last', 0), + "change": ticker.get('change', 0), + "changePercent": ticker.get('percentage', 0), + "high": ticker.get('high', 0), + "low": ticker.get('low', 0), + "open": ticker.get('open', 0), + "volume": ticker.get('quoteVolume', 0) + } + + # CN/HK stocks: prefer akshare for AShare (requested) + if market in ('AShare', 'HShare'): + if market == 'AShare' and self._akshare_required(): + try: + ak = self._ak + df = self._get_ashare_spot_df() + if df is not None and not df.empty: + row = df[df['代码'] == symbol].iloc[0] + price = float(row.get('最新价', 0) or 0) + change = float(row.get('涨跌额', 0) or 0) + change_pct = float(row.get('涨跌幅', 0) or 0) + high = float(row.get('最高', 0) or 0) + low = float(row.get('最低', 0) or 0) + open_p = float(row.get('今开', 0) or 0) + prev_close = float(row.get('昨收', 0) or 0) + return { + "price": price, + "change": change, + "changePercent": change_pct, + "high": high, + "low": low, + "open": open_p, + "previousClose": prev_close + } + except Exception as e: + logger.warning(f"akshare AShare spot failed ({symbol}): {e}") + + # Do not use Tencent for AShare by default (requested). If akshare is not available, + # return None and let the LLM report degrade gracefully. + if market == 'AShare': + if not self._akshare_required(): + logger.warning("akshare is not installed; AShare spot price is unavailable.") + return None + + # HShare fallback: Tencent quote + symbol_code = f'hk{symbol}' + + url = f"http://qt.gtimg.cn/q={symbol_code}" + resp = requests.get(url, timeout=10) + content = resp.content.decode('gbk', errors='ignore') + if '="' in content: + data_str = content.split('="')[1].strip('";\n') + if data_str: + parts = data_str.split('~') + if len(parts) > 32: + return { + "price": float(parts[3]) if parts[3] else 0, + "change": float(parts[31]) if parts[31] else 0, + "changePercent": float(parts[32]) if parts[32] else 0, + "high": float(parts[33]) if len(parts) > 33 and parts[33] else 0, + "low": float(parts[34]) if len(parts) > 34 and parts[34] else 0, + "open": float(parts[5]) if len(parts) > 5 and parts[5] else 0, + "previousClose": float(parts[4]) if parts[4] else 0 + } + except Exception as e: + logger.error(f"Failed to fetch current price {market}:{symbol}: {e}") + + return None + + def get_fundamental_data(self, market: str, symbol: str) -> Optional[Dict[str, Any]]: + """Get fundamental data (best-effort).""" + try: + if market == 'USStock' and self.finnhub_client: + metrics = self.finnhub_client.company_basic_financials(symbol, 'all') + profile = self.finnhub_client.company_profile2(symbol=symbol) + + return { + "metrics": metrics.get('metric', {}), + "profile_metrics": { + "marketCapitalization": profile.get('marketCapitalization', 0), + "currency": profile.get('currency', 'USD'), + "finnhubIndustry": profile.get('finnhubIndustry', ''), + } + } + + # AShare fundamentals via akshare (requested) + if market == 'AShare' and self._akshare_required(): + ak = self._ak + out: Dict[str, Any] = {"metrics": {}, "profile_metrics": {}} + + # 1) Use spot list (fast) for valuation/market cap + try: + df = self._get_ashare_spot_df() + if df is not None and not df.empty: + row = df[df['代码'] == symbol].iloc[0] + out["metrics"].update({ + "pe_ttm": row.get('市盈率-动态'), + "pb": row.get('市净率'), + "turnoverRate": row.get('换手率'), + }) + out["profile_metrics"].update({ + "marketCapitalization": row.get('总市值'), + "floatMarketCap": row.get('流通市值'), + "currency": "CNY", + }) + except Exception as e: + logger.debug(f"akshare spot metrics unavailable ({symbol}): {e}") + + # 2) Try akshare indicator endpoints (optional, may be slower / may change) + try: + if hasattr(ak, "stock_a_lg_indicator"): + ind_df = ak.stock_a_lg_indicator(symbol=symbol) + if ind_df is not None and not ind_df.empty: + last = ind_df.iloc[-1].to_dict() + out["metrics"].update(last) + except Exception as e: + logger.debug(f"akshare indicator fetch failed ({symbol}): {e}") + + return out + except Exception as e: + logger.error(f"Failed to fetch fundamental data {market}:{symbol}: {e}") + + return None + + def get_company_data(self, market: str, symbol: str, language: str = "en-US") -> Optional[Dict[str, Any]]: + """Get basic company/project info (best-effort).""" + try: + # 1) Finnhub (mainly for US stocks) + if market == 'USStock' and self.finnhub_client: + profile = self.finnhub_client.company_profile2(symbol=symbol) + if profile: + return { + "name": profile.get('name', symbol), + "ticker": profile.get('ticker', symbol), + "exchange": profile.get('exchange', ''), + "industry": profile.get('finnhubIndustry', ''), + "website": profile.get('weburl', ''), + "marketCapitalization": profile.get('marketCapitalization', 0), + "description": f"Sector: {profile.get('finnhubIndustry', '')}, Country: {profile.get('country', '')}" + } + + # 2) Basic info for AShare / HShare / Crypto + elif market in ('AShare', 'HShare', 'Crypto'): + name = symbol + if market == 'AShare': + # Prefer akshare for AShare (requested) + if self._akshare_required(): + try: + ak = self._ak + # 1) Individual info (more structured) + if hasattr(ak, "stock_individual_info_em"): + df = ak.stock_individual_info_em(symbol=symbol) + if df is not None and not df.empty and 'item' in df.columns and 'value' in df.columns: + info = {str(r['item']).strip(): r['value'] for _, r in df.iterrows()} + # common keys: 股票简称, 所属行业, 上市时间, 总市值 ... + name = str(info.get('股票简称') or info.get('证券简称') or symbol).strip() + industry = str(info.get('所属行业') or '').strip() + website = str(info.get('公司网址') or '').strip() + market_cap = info.get('总市值') or info.get('总市值(元)') or 0 + return { + "name": name or symbol, + "ticker": symbol, + "market": market, + "industry": industry, + "website": website, + "marketCapitalization": market_cap, + "description": f"Industry: {industry}" if industry else "" + } + # 2) Spot list for name + df2 = ak.stock_zh_a_spot_em() + if df2 is not None and not df2.empty: + row = df2[df2['代码'] == symbol].iloc[0] + name = str(row.get('名称') or symbol).strip() + except Exception as e: + logger.debug(f"akshare company info failed ({symbol}): {e}") + + # Do not use Tencent for AShare by default (requested). + if not self._akshare_required(): + logger.warning("akshare is not installed; AShare company info is limited.") + elif market == 'Crypto': + name = f"{symbol} Cryptocurrency" + + # Enrich description via web search (best-effort) + # Query language should follow UI language when possible. + if str(language).lower().startswith('zh'): + search_query = f"{name} {symbol} 公司 简介" if market != 'Crypto' else f"{symbol} 加密 项目 介绍" + else: + search_query = f"{name} {symbol} company profile" if market != 'Crypto' else f"{symbol} crypto project info" + search_results = self.search_service.search(search_query, num_results=1) + description = "" + if search_results: + description = search_results[0].get('snippet', '') + + return { + "name": name, + "ticker": symbol, + "market": market, + "description": description + } + + except Exception as e: + logger.error(f"Failed to fetch company data {market}:{symbol}: {e}") + + return None + + def _fetch_page_content(self, url: str) -> str: + """ + Fetch readable page content via Jina Reader. + + Args: + url: Target URL + + Returns: + Extracted content (markdown-ish), truncated + """ + try: + jina_url = f"https://r.jina.ai/{url}" + # Use a slightly longer timeout for content extraction + response = requests.get(jina_url, timeout=15) + if response.status_code == 200: + content = response.text + # Truncate to avoid huge prompts + if len(content) > 3000: + content = content[:3000] + "..." + return content + return "" + except Exception as e: + logger.warning(f"Jina Reader content fetch failed {url}: {e}") + return "" + + def get_news(self, market: str, symbol: str, days: int = 7, company_name: str = None) -> List[Dict[str, Any]]: + """ + Get news items (Finnhub + search engine) and optionally enrich via Jina Reader. + + Args: + market: Market + symbol: Symbol/pair + days: Lookback days + company_name: Optional company/project name to improve search + + Returns: + List of news items + """ + news_list = [] + + # 1) Finnhub news (if available) + try: + if self.finnhub_client: + end_date = datetime.now().strftime('%Y-%m-%d') + start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + + raw_news = [] + + if market == 'USStock': + raw_news = self.finnhub_client.company_news(symbol, _from=start_date, to=end_date) + elif market == 'Crypto': + crypto_symbol = symbol.split('/')[0] if '/' in symbol else symbol + raw_news = self.finnhub_client.crypto_news(crypto_symbol) + else: + raw_news = self.finnhub_client.general_news('general', min_id=0) + + if raw_news: + for item in raw_news: + if not item.get('headline') or not item.get('summary'): + continue + news_list.append({ + "id": str(item.get('id', '')), + "datetime": datetime.fromtimestamp(item.get('datetime', 0)).strftime('%Y-%m-%d %H:%M'), + "headline": item.get('headline', ''), + "summary": item.get('summary', ''), + "source": f"Finnhub ({item.get('source', '')})", + "url": item.get('url', '') + }) + except Exception as e: + logger.warning(f"Finnhub news fetch failed: {e}") + + # 2) Supplement with search engine results (useful for non-US markets or specific events) + try: + # Build search query (use company name to improve relevance) + search_query = "" + search_name = company_name if company_name else symbol + + # Time restriction for Google CSE + date_restrict = f"d{days}" + + if market == 'AShare': + # AShare CN keywords + search_query = f'"{search_name}" {symbol} (利好 OR 利空 OR 财报 OR 公告 OR 业绩) after:{datetime.now().year-1}' + elif market == 'HShare': + search_query = f'"{search_name}" {symbol} (港股 OR 股价 OR 业绩) after:{datetime.now().year-1}' + elif market == 'Crypto': + search_query = f'"{search_name}" {symbol} crypto news analysis' + else: + search_query = f'"{search_name}" {symbol} stock news' + + logger.info(f"Running news search: {search_query}") + # Google CSE uses `dateRestrict` as a separate param; SearchService supports it. + search_results = self.search_service.search(search_query, num_results=10, date_restrict=date_restrict) + + for i, item in enumerate(search_results): + # Default: use snippet as summary + summary = f"{item.get('snippet', '')} (Source: {item.get('source', '')})" + + # Jina Reader: deep-read only first 2 items to avoid slowdowns + if i < 2 and item.get('link'): + logger.info(f"Deep reading: {item.get('title')}") + full_content = self._fetch_page_content(item.get('link')) + if full_content: + summary = f"Deep content:\n{full_content}\n(Source: {item.get('source', '')})" + + news_list.append({ + "id": item.get('link', ''), # Use link as a stable id + "datetime": item.get('published', datetime.now().strftime('%Y-%m-%d')), # Fallback to today if missing + "headline": item.get('title', ''), + "summary": summary, + "source": f"Search ({item.get('source', '')})", + "url": item.get('link', '') + }) + + except Exception as e: + logger.warning(f"Search news failed: {e}") + + # Sort by time desc and keep latest items (best-effort; time formats may vary) + news_list.sort(key=lambda x: x.get('datetime', ''), reverse=True) + return news_list[:20] + + def calculate_technical_indicators(self, kline_data: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Calculate basic technical indicators from kline data. + + Args: + kline_data: List of OHLCV dicts + + Returns: + Indicators dict + """ + if not kline_data or len(kline_data) < 20: + return {} + + try: + df = pd.DataFrame(kline_data) + df['close'] = pd.to_numeric(df['close'], errors='coerce') + df['high'] = pd.to_numeric(df['high'], errors='coerce') + df['low'] = pd.to_numeric(df['low'], errors='coerce') + df['volume'] = pd.to_numeric(df['volume'], errors='coerce') + + indicators = {} + + # Moving averages + if len(df) >= 20: + indicators['MA20'] = round(df['close'].tail(20).mean(), 4) + if len(df) >= 50: + indicators['MA50'] = round(df['close'].tail(50).mean(), 4) + + # RSI + if len(df) >= 14: + delta = df['close'].diff() + gain = (delta.where(delta > 0, 0)).rolling(window=14).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean() + rs = gain / loss + rsi = 100 - (100 / (1 + rs)) + indicators['RSI'] = round(rsi.iloc[-1], 2) if not rsi.empty else None + + # MACD + if len(df) >= 26: + exp1 = df['close'].ewm(span=12, adjust=False).mean() + exp2 = df['close'].ewm(span=26, adjust=False).mean() + macd = exp1 - exp2 + signal = macd.ewm(span=9, adjust=False).mean() + indicators['MACD'] = round(macd.iloc[-1], 4) if not macd.empty else None + indicators['MACD_Signal'] = round(signal.iloc[-1], 4) if not signal.empty else None + indicators['MACD_Histogram'] = round((macd - signal).iloc[-1], 4) if not (macd - signal).empty else None + + # Bollinger bands + if len(df) >= 20: + sma = df['close'].rolling(window=20).mean() + std = df['close'].rolling(window=20).std() + indicators['BB_Upper'] = round((sma + 2 * std).iloc[-1], 4) if not sma.empty else None + indicators['BB_Middle'] = round(sma.iloc[-1], 4) if not sma.empty else None + indicators['BB_Lower'] = round((sma - 2 * std).iloc[-1], 4) if not sma.empty else None + + return indicators + + except Exception as e: + logger.error(f"Failed to calculate technical indicators: {e}") + return {} diff --git a/backend_api_python/app/services/agents/trader_agent.py b/backend_api_python/app/services/agents/trader_agent.py new file mode 100644 index 0000000..01e228f --- /dev/null +++ b/backend_api_python/app/services/agents/trader_agent.py @@ -0,0 +1,129 @@ +""" +Trader agent. +Synthesizes all analysis outputs and produces a final trading decision. +""" +import json +from typing import Dict, Any +from .base_agent import BaseAgent +from app.services.llm import LLMService + +logger = __import__('app.utils.logger', fromlist=['get_logger']).get_logger(__name__) + + +class TraderAgent(BaseAgent): + """Trader agent.""" + + def __init__(self, memory=None): + super().__init__("TraderAgent", memory) + self.llm_service = LLMService() + + def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Make a final trading decision.""" + market = context.get('market') + symbol = context.get('symbol') + language = context.get('language', 'zh-CN') + model = context.get('model') + + # Inputs + market_report = context.get('market_report', {}) + fundamental_report = context.get('fundamental_report', {}) + news_report = context.get('news_report', {}) + sentiment_report = context.get('sentiment_report', {}) + risk_report = context.get('risk_report', {}) + + # Debate outputs + bull_argument = context.get('bull_argument', {}) + bear_argument = context.get('bear_argument', {}) + research_decision = context.get('research_decision', '') + + # Memory + situation = f"{market}:{symbol} trading decision" + memories = self.get_memories(situation, n_matches=2) + memory_prompt = self.format_memories_for_prompt(memories) + + lang_instruction = self._get_language_instruction(language) + system_prompt = f"""You are a Trader, needing to make a final trading decision based on all analysis results. +{lang_instruction} + +Your tasks: +1. Synthesize analysis results from all dimensions. +2. Consider both bullish and bearish arguments. +3. Make a clear trading decision: BUY, SELL, or HOLD. +4. Provide a detailed trading plan. +5. Learn from historical experience: {memory_prompt} +6. **Confidence Score**: Evaluate your confidence in the decision (0-100). Be realistic. If the signals are mixed, confidence should be lower (e.g., 40-60). Only use high confidence (>80) for very clear strong signals. Do NOT default to 85. + +Please return in JSON format as follows: +{{ + "decision": "BUY/SELL/HOLD", + "confidence": 85, + "reasoning": "Reason for decision...", + "trading_plan": {{ + "entry_price": "Suggested entry price", + "stop_loss": "Stop loss price", + "take_profit": "Take profit price", + "position_size": "Suggested position size" + }}, + "report": "Detailed trading plan report..." +}}""" + + user_prompt = f"""Based on all the following analyses, make a trading decision for {symbol} in {market} market: + +**Market Technical Analysis:** +{json.dumps(market_report.get('data', {}), ensure_ascii=False, indent=2) if market_report else 'No Data'} + +**Fundamental Analysis:** +{json.dumps(fundamental_report.get('data', {}), ensure_ascii=False, indent=2) if fundamental_report else 'No Data'} + +**News Analysis:** +{json.dumps(news_report.get('data', {}), ensure_ascii=False, indent=2) if news_report else 'No Data'} + +**Sentiment Analysis:** +{json.dumps(sentiment_report.get('data', {}), ensure_ascii=False, indent=2) if sentiment_report else 'No Data'} + +**Risk Analysis:** +{json.dumps(risk_report.get('data', {}), ensure_ascii=False, indent=2) if risk_report else 'No Data'} + +**Bullish Argument:** +{json.dumps(bull_argument.get('data', {}), ensure_ascii=False, indent=2) if bull_argument else 'No Data'} + +**Bearish Argument:** +{json.dumps(bear_argument.get('data', {}), ensure_ascii=False, indent=2) if bear_argument else 'No Data'} + +**Research Manager Decision:** +{research_decision if research_decision else 'No Data'} + +Please make a clear trading decision (BUY/SELL/HOLD) and provide a detailed trading plan.""" + + result = self.llm_service.safe_call_llm( + system_prompt, + user_prompt, + { + "decision": "HOLD", + "confidence": 50, + "reasoning": "", + "trading_plan": {}, + "report": "Failed to parse trader decision" + }, + model=model + ) + + return { + "type": "trader", + "data": result + } + + def _get_language_instruction(self, language: str) -> str: + language_map = { + 'zh-CN': 'Answer in Simplified Chinese.', + 'zh-TW': 'Answer in Traditional Chinese.', + 'en-US': 'Answer in English.', + 'ja-JP': 'Answer in Japanese.', + 'ko-KR': 'Answer in Korean.', + 'vi-VN': 'Answer in Vietnamese.', + 'th-TH': 'Answer in Thai.', + 'ar-SA': 'Answer in Arabic.', + 'fr-FR': 'Answer in French.', + 'de-DE': 'Answer in German.' + } + return language_map.get(language, 'Answer in English.') diff --git a/backend_api_python/app/services/analysis.py b/backend_api_python/app/services/analysis.py new file mode 100644 index 0000000..6290d6c --- /dev/null +++ b/backend_api_python/app/services/analysis.py @@ -0,0 +1,168 @@ +""" +Multi-dimensional analysis service. +Uses OpenRouter via the internal LLMService and the multi-agent coordinator. +Local-only: this project does not implement any paid/credit system itself. +""" +import json +import traceback +from typing import Dict, Any, Optional + +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +class AnalysisService: + """Multi-dimensional analyzer powered by agent coordinator.""" + + # Class-level guard to avoid circular-init recursion + _initializing = False + + def __init__(self, use_multi_agent: bool = None): + """ + Args: + use_multi_agent: Deprecated; kept for frontend compatibility + """ + # Avoid circular-init recursion + if AnalysisService._initializing: + logger.warning("AnalysisService is initializing; skipping duplicate initialization") + self.coordinator = None + return + + self.coordinator = None + + try: + # Mark initializing + AnalysisService._initializing = True + + # Lazy import to avoid circular imports + from app.services.agents.coordinator import AgentCoordinator + self.coordinator = AgentCoordinator( + enable_memory=True, + max_debate_rounds=2 + ) + logger.info("Multi-agent coordinator initialized") + + except Exception as e: + logger.error(f"Coordinator init failed: {e}") + logger.error(f"Traceback: {traceback.format_exc()}") + self.coordinator = None + finally: + AnalysisService._initializing = False + + def analyze(self, market: str, symbol: str, language: str = 'en-US', model: str = None) -> Dict[str, Any]: + """ + Args: + market: Market (AShare, USStock, HShare, Crypto, Forex, Futures) + symbol: Symbol + language: Output language tag (e.g. en-US, zh-CN, zh-TW) + model: Optional OpenRouter model id + Returns: + Result dict + """ + logger.info(f"Starting analysis {market}:{symbol}, language={language}, mode=multi-agent") + + # Default result structure (keeps frontend compatible even when coordinator fails). + result = { + "overview": {"report": "Initializing..."}, + "fundamental": {"report": "Initializing..."}, + "technical": {"report": "Initializing..."}, + "news": {"report": "Initializing..."}, + "sentiment": {"report": "Initializing..."}, + "risk": {"report": "Initializing..."}, + "error": None + } + + if not self.coordinator: + result["error"] = "Analysis service is not ready (coordinator init failed)" + return result + + try: + logger.info(f"Run coordinator: {market}:{symbol}") + agent_result = self.coordinator.run_analysis(market, symbol, language, model=model) + + logger.info(f"Coordinator result keys: {list(agent_result.keys())}") + + # Validate expected keys (defensive) + debate = agent_result.get("debate", {}) + trader_decision = agent_result.get("trader_decision", {}) + risk_debate = agent_result.get("risk_debate", {}) + final_decision = agent_result.get("final_decision", {}) + + # Keep frontend-compatible shape and fill defaults if empty + if "debate" in agent_result and "trader_decision" in agent_result and "risk_debate" in agent_result and "final_decision" in agent_result: + if not debate or (isinstance(debate, dict) and len(debate) == 0): + logger.warning("debate is empty; using defaults") + agent_result["debate"] = {"bull": {}, "bear": {}, "research_decision": "Analyzing..."} + if not trader_decision or (isinstance(trader_decision, dict) and len(trader_decision) == 0): + logger.warning("trader_decision is empty; using defaults") + agent_result["trader_decision"] = {"decision": "HOLD", "confidence": 50, "reasoning": "Analyzing..."} + if not risk_debate or (isinstance(risk_debate, dict) and len(risk_debate) == 0): + logger.warning("risk_debate is empty; using defaults") + agent_result["risk_debate"] = {"risky": {}, "neutral": {}, "safe": {}} + if not final_decision or (isinstance(final_decision, dict) and len(final_decision) == 0): + logger.warning("final_decision is empty; using defaults") + agent_result["final_decision"] = {"decision": "HOLD", "confidence": 50, "reasoning": "Analyzing..."} + + return agent_result + else: + logger.warning("Coordinator result format is incomplete; filling defaults") + return { + "overview": agent_result.get("overview", {"report": "Analyzing..."}), + "fundamental": agent_result.get("fundamental", {"report": "Analyzing..."}), + "technical": agent_result.get("technical", {"report": "Analyzing..."}), + "news": agent_result.get("news", {"report": "Analyzing..."}), + "sentiment": agent_result.get("sentiment", {"report": "Analyzing..."}), + "risk": agent_result.get("risk", {"report": "Analyzing..."}), + "debate": agent_result.get("debate", {"bull": {}, "bear": {}, "research_decision": "Analyzing..."}), + "trader_decision": agent_result.get("trader_decision", {"decision": "HOLD", "confidence": 50, "reasoning": "Analyzing..."}), + "risk_debate": agent_result.get("risk_debate", {"risky": {}, "neutral": {}, "safe": {}}), + "final_decision": agent_result.get("final_decision", {"decision": "HOLD", "confidence": 50, "reasoning": "Analyzing..."}), + "error": agent_result.get("error") + } + + except Exception as e: + error_msg = str(e) + logger.error(f"Analysis failed {market}:{symbol} - {error_msg}") + + # If OpenRouter returns 402, it's an upstream billing/credit issue (not a QuantDinger fee). + if "402" in error_msg or "Payment Required" in error_msg: + result["error"] = f"OpenRouter returned 402 (billing/credits). Please check your OpenRouter account. Details: {error_msg}" + else: + result["error"] = f"Analysis failed: {error_msg}" + + return result + + +def multi_analysis(market: str, symbol: str, language: str = 'en-US', use_multi_agent: bool = None) -> Dict[str, Any]: + """ + Convenience entrypoint for multi-dimensional analysis. + + Args: + market: Market (AShare, USStock, HShare, Crypto, Forex, Futures) + symbol: Symbol + language: Output language tag + use_multi_agent: Deprecated; kept for compatibility + """ + analyzer = AnalysisService() + return analyzer.analyze(market, symbol, language) + + +def reflect_analysis(market: str, symbol: str, decision: str, returns: float = None, result: str = None): + """ + Reflection hook: learn from post-trade outcomes (local-only). + + Args: + market: Market + symbol: Symbol + decision: Decision (BUY/SELL/HOLD) + returns: Return percentage + result: Free-text outcome + """ + try: + analyzer = AnalysisService() + if analyzer.coordinator: + analyzer.coordinator.reflect_and_learn(market, symbol, decision, returns, result) + logger.info(f"Reflection completed: {market}:{symbol}") + except Exception as e: + logger.error(f"Reflection failed: {e}") diff --git a/backend_api_python/app/services/backtest.py b/backend_api_python/app/services/backtest.py new file mode 100644 index 0000000..8327da3 --- /dev/null +++ b/backend_api_python/app/services/backtest.py @@ -0,0 +1,2867 @@ +""" +回测服务 +""" +import math +import traceback +from datetime import datetime, timedelta +from typing import Dict, List, Any, Optional + +import pandas as pd +import numpy as np + +from app.data_sources import DataSourceFactory +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +class BacktestService: + """回测服务""" + + # 时间周期秒数 + TIMEFRAME_SECONDS = { + '1m': 60, '5m': 300, '15m': 900, '30m': 1800, + '1H': 3600, '4H': 14400, '1D': 86400, '1W': 604800 + } + + def run_code_strategy( + self, + code: str, + symbol: str, + timeframe: str, + limit: int = 1000 + ) -> Dict[str, Any]: + """ + 运行策略代码并返回代码中定义的 'output' 变量。 + 用于信号机器人的预览功能。 + """ + # 1. 计算时间范围 + end_date = datetime.now() + tf_seconds = self.TIMEFRAME_SECONDS.get(timeframe, 3600) + start_date = end_date - timedelta(seconds=tf_seconds * limit) + + # 2. 获取数据 (假设 market='crypto',后续可优化) + df = self._fetch_kline_data('crypto', symbol, timeframe, start_date, end_date) + + if df.empty: + return {"error": "No data found"} + + # 3. 准备执行环境 + local_vars = { + 'df': df.copy(), + 'np': np, + 'pd': pd, + 'output': {} # 默认空输出 + } + + # 4. 执行代码 + try: + import builtins + def safe_import(name, *args, **kwargs): + allowed = ['numpy', 'pandas', 'math', 'json', 'datetime', 'time'] + if name in allowed or name.split('.')[0] in allowed: + return builtins.__import__(name, *args, **kwargs) + raise ImportError(f"Import not allowed: {name}") + + safe_builtins = {k: getattr(builtins, k) for k in dir(builtins) + if not k.startswith('_') and k not in ['eval', 'exec', 'compile', 'open', 'input', 'exit']} + safe_builtins['__import__'] = safe_import + + exec_env = local_vars.copy() + exec_env['__builtins__'] = safe_builtins + + exec(code, exec_env) + + return exec_env.get('output', {}) + + except Exception as e: + logger.error(f"Strategy execution failed: {e}") + logger.error(traceback.format_exc()) + return {"error": str(e)} + + def run( + self, + indicator_code: str, + market: str, + symbol: str, + timeframe: str, + start_date: datetime, + end_date: datetime, + initial_capital: float = 10000.0, + commission: float = 0.001, + slippage: float = 0.0, # 理想回测环境,不考虑滑点 + leverage: int = 1, + trade_direction: str = 'long', + strategy_config: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + 运行回测 + + Args: + indicator_code: 指标代码 + market: 市场类型 + symbol: 交易标的 + timeframe: 时间周期 + start_date: 开始日期 + end_date: 结束日期 + initial_capital: 初始资金 + commission: 手续费率 + slippage: 滑点 + + Returns: + 回测结果 + """ + + # 1. 获取K线数据 + df = self._fetch_kline_data(market, symbol, timeframe, start_date, end_date) + if df.empty: + raise ValueError("回测日期范围内没有K线数据") + + + # 2. 执行指标代码获取信号(传入回测参数) + backtest_params = { + 'leverage': leverage, + 'initial_capital': initial_capital, + 'commission': commission, + 'trade_direction': trade_direction + } + signals = self._execute_indicator(indicator_code, df, backtest_params) + + # 3. 模拟交易 + equity_curve, trades, total_commission = self._simulate_trading( + df, signals, initial_capital, commission, slippage, leverage, trade_direction, strategy_config + ) + + # 4. 计算指标 + metrics = self._calculate_metrics(equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission) + + # 5. 格式化结果 + return self._format_result(metrics, equity_curve, trades) + + def _fetch_kline_data( + self, + market: str, + symbol: str, + timeframe: str, + start_date: datetime, + end_date: datetime + ) -> pd.DataFrame: + """获取K线数据并转换为DataFrame""" + # 计算需要的K线数量 + total_seconds = (end_date - start_date).total_seconds() + tf_seconds = self.TIMEFRAME_SECONDS.get(timeframe, 86400) + limit = math.ceil(total_seconds / tf_seconds) + 200 + + # 计算before_time(结束日期+1天) + before_time = int((end_date + timedelta(days=1)).timestamp()) + + + # 获取数据 + kline_data = DataSourceFactory.get_kline( + market=market, + symbol=symbol, + timeframe=timeframe, + limit=limit, + before_time=before_time + ) + + if not kline_data: + logger.warning("未获取到K线数据") + return pd.DataFrame() + + if kline_data: + first_time = datetime.fromtimestamp(kline_data[0]['time']) + last_time = datetime.fromtimestamp(kline_data[-1]['time']) + + # 转换为DataFrame + df = pd.DataFrame(kline_data) + df['time'] = pd.to_datetime(df['time'], unit='s') + df = df.set_index('time') + + if len(df) > 0: + pass + + # 过滤日期范围 + df = df[(df.index >= start_date) & (df.index <= end_date)].copy() + + if len(df) > 0: + pass + + return df + + def _execute_indicator(self, code: str, df: pd.DataFrame, backtest_params: dict = None): + """执行指标代码获取信号 + + Args: + code: 指标代码 + df: K线数据 + backtest_params: 回测参数字典(leverage, initial_capital, commission, trade_direction) + """ + # Supported indicator signal formats: + # - Preferred (simple): df['buy'], df['sell'] as boolean + # - Backtest/internal (4-way): df['open_long'], df['close_long'], df['open_short'], df['close_short'] as boolean + signals = pd.Series(0, index=df.index) + + try: + # 准备执行环境 + local_vars = { + 'df': df.copy(), + 'open': df['open'], + 'high': df['high'], + 'low': df['low'], + 'close': df['close'], + 'volume': df['volume'], + 'signals': signals, + 'np': np, + 'pd': pd, + } + + # 添加回测参数到执行环境(如果提供了) + if backtest_params: + local_vars['backtest_params'] = backtest_params + local_vars['leverage'] = backtest_params.get('leverage', 1) + local_vars['initial_capital'] = backtest_params.get('initial_capital', 10000) + local_vars['commission'] = backtest_params.get('commission', 0.0002) + local_vars['trade_direction'] = backtest_params.get('trade_direction', 'both') + + # 添加技术指标函数 + local_vars.update(self._get_indicator_functions()) + + # 添加安全的内置函数(保留完整的 builtins 以支持 lambda 等语法) + # 但移除危险的函数如 eval, exec, open 等 + import builtins + + # 创建受限的 __import__ 函数,只允许导入已经加载的安全模块 + def safe_import(name, *args, **kwargs): + """只允许导入 numpy, pandas, math, json 等安全模块""" + allowed_modules = ['numpy', 'pandas', 'math', 'json', 'datetime', 'time'] + if name in allowed_modules or name.split('.')[0] in allowed_modules: + return builtins.__import__(name, *args, **kwargs) + raise ImportError(f"不允许导入模块: {name}") + + safe_builtins = {k: getattr(builtins, k) for k in dir(builtins) + if not k.startswith('_') and k not in [ + 'eval', 'exec', 'compile', 'open', 'input', + 'help', 'exit', 'quit', + 'copyright', 'credits', 'license' + ]} + + # 添加受限的 __import__ + safe_builtins['__import__'] = safe_import + + # 创建统一的执行环境(globals 和 locals 使用同一个字典) + # 这样函数内部才能访问到 np, pd 等变量 + exec_env = local_vars.copy() + exec_env['__builtins__'] = safe_builtins + + # 预执行 import 语句,确保 np 和 pd 可用 + pre_import_code = """ +import numpy as np +import pandas as pd +""" + exec(pre_import_code, exec_env) + + # 安全检查:验证代码不包含危险操作 + from app.utils.safe_exec import validate_code_safety + is_safe, error_msg = validate_code_safety(code) + if not is_safe: + logger.error(f"回测代码安全检查失败: {error_msg}") + raise ValueError(f"代码包含不安全操作: {error_msg}") + + # 安全执行用户代码(带超时) + from app.utils.safe_exec import safe_exec_code + exec_result = safe_exec_code( + code=code, + exec_globals=exec_env, + exec_locals=exec_env, + timeout=60 # 回测允许更长时间(60秒) + ) + + if not exec_result['success']: + raise RuntimeError(f"代码执行失败: {exec_result['error']}") + + # Get the executed df + executed_df = exec_env.get('df', df) + + # Validation: if chart signals are provided, df['buy']/df['sell'] must exist for backtest normalization. + # This keeps indicator scripts simple and consistent (chart=buy/sell, execution=normalized in backend). + output_obj = exec_env.get('output') + has_output_signals = isinstance(output_obj, dict) and isinstance(output_obj.get('signals'), list) and len(output_obj.get('signals')) > 0 + if has_output_signals and not all(col in executed_df.columns for col in ['buy', 'sell']): + raise ValueError( + "Invalid indicator script: output['signals'] is provided, but df['buy'] and df['sell'] are missing. " + "Please set df['buy'] and df['sell'] as boolean columns (len == len(df))." + ) + + # Extract signals from executed df + if all(col in executed_df.columns for col in ['open_long', 'close_long', 'open_short', 'close_short']): + + signals = { + 'open_long': executed_df['open_long'].fillna(False).astype(bool), + 'close_long': executed_df['close_long'].fillna(False).astype(bool), + 'open_short': executed_df['open_short'].fillna(False).astype(bool), + 'close_short': executed_df['close_short'].fillna(False).astype(bool) + } + + # Convention: backtest uses 4-way signals only. + # Position sizing, TP/SL, trailing, etc must be handled by strategy_config / strategy logic. + elif all(col in executed_df.columns for col in ['buy', 'sell']): + # Simple buy/sell signals (recommended for indicator authors) + signals = { + 'buy': executed_df['buy'].fillna(False).astype(bool), + 'sell': executed_df['sell'].fillna(False).astype(bool) + } + + else: + raise ValueError( + "Indicator must define either 4-way columns " + "(df['open_long'], df['close_long'], df['open_short'], df['close_short']) " + "or simple columns (df['buy'], df['sell'])." + ) + + except Exception as e: + logger.error(f"指标代码执行错误: {e}") + logger.error(traceback.format_exc()) + + return signals + + def _get_indicator_functions(self) -> Dict: + """获取技术指标函数""" + def SMA(series, period): + return series.rolling(window=period).mean() + + def EMA(series, period): + return series.ewm(span=period, adjust=False).mean() + + def RSI(series, period=14): + delta = series.diff() + gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() + rs = gain / loss + return 100 - (100 / (1 + rs)) + + def MACD(series, fast=12, slow=26, signal=9): + exp1 = series.ewm(span=fast, adjust=False).mean() + exp2 = series.ewm(span=slow, adjust=False).mean() + macd = exp1 - exp2 + macd_signal = macd.ewm(span=signal, adjust=False).mean() + macd_hist = macd - macd_signal + return macd, macd_signal, macd_hist + + def BOLL(series, period=20, std_dev=2): + middle = series.rolling(window=period).mean() + std = series.rolling(window=period).std() + upper = middle + std_dev * std + lower = middle - std_dev * std + return upper, middle, lower + + def ATR(high, low, close, period=14): + tr1 = high - low + tr2 = abs(high - close.shift()) + tr3 = abs(low - close.shift()) + tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) + return tr.rolling(window=period).mean() + + def CROSSOVER(series1, series2): + return (series1 > series2) & (series1.shift(1) <= series2.shift(1)) + + def CROSSUNDER(series1, series2): + return (series1 < series2) & (series1.shift(1) >= series2.shift(1)) + + return { + 'SMA': SMA, + 'EMA': EMA, + 'RSI': RSI, + 'MACD': MACD, + 'BOLL': BOLL, + 'ATR': ATR, + 'CROSSOVER': CROSSOVER, + 'CROSSUNDER': CROSSUNDER, + } + + def _simulate_trading( + self, + df: pd.DataFrame, + signals, + initial_capital: float, + commission: float, + slippage: float, + leverage: int = 1, + trade_direction: str = 'long', + strategy_config: Optional[Dict[str, Any]] = None + ) -> tuple: + """ + 模拟交易 + + Args: + signals: 信号,可以是 pd.Series (旧格式) 或 dict (新格式四种信号) + trade_direction: 交易方向 + - 'long': 只做多 (buy->sell) + - 'short': 只做空 (sell->buy, 收益反向) + - 'both': 双向 (buy->sell做多 + sell->buy做空) + """ + # Normalize supported signal formats into 4-way signals. + if not isinstance(signals, dict): + raise ValueError("signals must be a dict (either 4-way or buy/sell).") + + if all(k in signals for k in ['open_long', 'close_long', 'open_short', 'close_short']): + norm = signals + elif all(k in signals for k in ['buy', 'sell']): + buy = signals['buy'].fillna(False).astype(bool) + sell = signals['sell'].fillna(False).astype(bool) + + td = (trade_direction or 'both') + td = str(td).lower() + if td not in ['long', 'short', 'both']: + td = 'both' + + # Mapping rules: + # - long: buy=open_long, sell=close_long + # - short: sell=open_short, buy=close_short + # - both: buy=open_long+close_short, sell=open_short+close_long + if td == 'long': + norm = { + 'open_long': buy, + 'close_long': sell, + 'open_short': pd.Series([False] * len(df), index=df.index), + 'close_short': pd.Series([False] * len(df), index=df.index), + } + elif td == 'short': + norm = { + 'open_long': pd.Series([False] * len(df), index=df.index), + 'close_long': pd.Series([False] * len(df), index=df.index), + 'open_short': sell, + 'close_short': buy, + } + else: + norm = { + 'open_long': buy, + 'close_long': sell, + 'open_short': sell, + 'close_short': buy, + } + else: + raise ValueError("signals dict must contain either 4-way keys or buy/sell keys.") + + return self._simulate_trading_new_format(df, norm, initial_capital, commission, slippage, leverage, trade_direction, strategy_config) + + def _simulate_trading_new_format( + self, + df: pd.DataFrame, + signals: dict, + initial_capital: float, + commission: float, + slippage: float, + leverage: int = 1, + trade_direction: str = 'both', + strategy_config: Optional[Dict[str, Any]] = None + ) -> tuple: + """ + 使用新格式四种信号进行交易模拟(支持仓位管理和加仓) + + Args: + trade_direction: 交易方向 ('long', 'short', 'both') + """ + equity_curve = [] + trades = [] + total_commission_paid = 0 + is_liquidated = False + liquidation_price = 0 + min_capital_to_trade = 1.0 # 余额低于该值则视为赔光,不再开新单 + + capital = initial_capital + position = 0 # 正数=多头持仓,负数=空头持仓 + entry_price = 0 # 平均开仓价格 + position_type = None # 'long' or 'short' + + # 仓位管理相关 + has_position_management = 'add_long' in signals and 'add_short' in signals + position_batches = [] # 存储每批持仓:[{'price': xxx, 'amount': xxx}, ...] + + # --- Strategy config: signals + parameters = strategy (sent from BacktestModal as strategyConfig) --- + cfg = strategy_config or {} + exec_cfg = cfg.get('execution') or {} + # Signal confirmation / execution timing: + # - bar_close: execute on the same bar close (more aggressive) + # - next_bar_open: execute on next bar open after signal is confirmed on bar close (recommended, closer to live) + signal_timing = str(exec_cfg.get('signalTiming') or 'next_bar_open').strip().lower() + risk_cfg = cfg.get('risk') or {} + stop_loss_pct = float(risk_cfg.get('stopLossPct') or 0.0) + take_profit_pct = float(risk_cfg.get('takeProfitPct') or 0.0) + trailing_cfg = risk_cfg.get('trailing') or {} + trailing_enabled = bool(trailing_cfg.get('enabled')) + trailing_pct = float(trailing_cfg.get('pct') or 0.0) + trailing_activation_pct = float(trailing_cfg.get('activationPct') or 0.0) + + # Risk percentages are defined on margin PnL; convert to price move thresholds by leverage. + lev = max(int(leverage or 1), 1) + stop_loss_pct_eff = stop_loss_pct / lev + take_profit_pct_eff = take_profit_pct / lev + trailing_pct_eff = trailing_pct / lev + trailing_activation_pct_eff = trailing_activation_pct / lev + + # Conflict rule (TP vs trailing): + # - If trailing is enabled, it takes precedence. + # - If activationPct is not provided, reuse takeProfitPct as the trailing activation threshold. + # - When trailing is enabled, fixed take-profit exits are disabled to avoid ambiguity. + if trailing_enabled and trailing_pct_eff > 0: + if trailing_activation_pct_eff <= 0 and take_profit_pct_eff > 0: + trailing_activation_pct_eff = take_profit_pct_eff + + # IMPORTANT: risk percentages are defined on margin PnL (user expectation): + # e.g. 10x leverage + 5% SL means ~0.5% adverse price move. + lev = max(int(leverage or 1), 1) + stop_loss_pct_eff = stop_loss_pct / lev + take_profit_pct_eff = take_profit_pct / lev + trailing_pct_eff = trailing_pct / lev + trailing_activation_pct_eff = trailing_activation_pct / lev + + pos_cfg = cfg.get('position') or {} + entry_pct_cfg = float(pos_cfg.get('entryPct') or 1.0) # expected 0~1 + # Accept both 0~1 and 0~100 inputs (some clients may send percent units). + if entry_pct_cfg > 1: + entry_pct_cfg = entry_pct_cfg / 100.0 + entry_pct_cfg = max(0.0, min(entry_pct_cfg, 1.0)) + + scale_cfg = cfg.get('scale') or {} + trend_add_cfg = scale_cfg.get('trendAdd') or {} + dca_add_cfg = scale_cfg.get('dcaAdd') or {} + trend_reduce_cfg = scale_cfg.get('trendReduce') or {} + adverse_reduce_cfg = scale_cfg.get('adverseReduce') or {} + + trend_add_enabled = bool(trend_add_cfg.get('enabled')) + trend_add_step_pct = float(trend_add_cfg.get('stepPct') or 0.0) + trend_add_size_pct = float(trend_add_cfg.get('sizePct') or 0.0) + trend_add_max_times = int(trend_add_cfg.get('maxTimes') or 0) + + dca_add_enabled = bool(dca_add_cfg.get('enabled')) + dca_add_step_pct = float(dca_add_cfg.get('stepPct') or 0.0) + dca_add_size_pct = float(dca_add_cfg.get('sizePct') or 0.0) + dca_add_max_times = int(dca_add_cfg.get('maxTimes') or 0) + + # Prevent logical conflict: trend scale-in and mean-reversion scale-in should not run together. + # Otherwise both may trigger in the same candle (high/low both hit), causing double scaling unexpectedly. + if trend_add_enabled and dca_add_enabled: + dca_add_enabled = False + + trend_reduce_enabled = bool(trend_reduce_cfg.get('enabled')) + trend_reduce_step_pct = float(trend_reduce_cfg.get('stepPct') or 0.0) + trend_reduce_size_pct = float(trend_reduce_cfg.get('sizePct') or 0.0) + trend_reduce_max_times = int(trend_reduce_cfg.get('maxTimes') or 0) + + adverse_reduce_enabled = bool(adverse_reduce_cfg.get('enabled')) + adverse_reduce_step_pct = float(adverse_reduce_cfg.get('stepPct') or 0.0) + adverse_reduce_size_pct = float(adverse_reduce_cfg.get('sizePct') or 0.0) + adverse_reduce_max_times = int(adverse_reduce_cfg.get('maxTimes') or 0) + + # 触发百分比按“杠杆后的保证金阈值”理解:换算为价格触发阈值需要除以杠杆倍数 + # 例如 10x + 5% 触发,意味着约 0.5% 的价格波动触发 + trend_add_step_pct_eff = trend_add_step_pct / lev + dca_add_step_pct_eff = dca_add_step_pct / lev + trend_reduce_step_pct_eff = trend_reduce_step_pct / lev + adverse_reduce_step_pct_eff = adverse_reduce_step_pct / lev + + # State: used for trailing exits and scale-in/scale-out anchor levels + highest_since_entry = None + lowest_since_entry = None + trend_add_times = 0 + dca_add_times = 0 + trend_reduce_times = 0 + adverse_reduce_times = 0 + last_trend_add_anchor = None + last_dca_add_anchor = None + last_trend_reduce_anchor = None + last_adverse_reduce_anchor = None + + # 转换信号为数组 + open_long_arr = signals['open_long'].values + close_long_arr = signals['close_long'].values + open_short_arr = signals['open_short'].values + close_short_arr = signals['close_short'].values + + # Apply execution timing to avoid look-ahead bias: + # If signals are computed using bar close, realistic execution is next bar open. + if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']: + open_long_arr = np.insert(open_long_arr[:-1], 0, False) + close_long_arr = np.insert(close_long_arr[:-1], 0, False) + open_short_arr = np.insert(open_short_arr[:-1], 0, False) + close_short_arr = np.insert(close_short_arr[:-1], 0, False) + + # 根据交易方向过滤信号 + if trade_direction == 'long': + # 只做多:禁用所有做空信号 + open_short_arr = np.zeros(len(df), dtype=bool) + close_short_arr = np.zeros(len(df), dtype=bool) + elif trade_direction == 'short': + # 只做空:禁用所有做多信号 + open_long_arr = np.zeros(len(df), dtype=bool) + close_long_arr = np.zeros(len(df), dtype=bool) + else: + pass + + # 加仓信号 + if has_position_management: + add_long_arr = signals['add_long'].values + add_short_arr = signals['add_short'].values + position_size_arr = signals.get('position_size', pd.Series([0.0] * len(df))).values + + # 根据交易方向过滤加仓信号 + if trade_direction == 'long': + add_short_arr = np.zeros(len(df), dtype=bool) + elif trade_direction == 'short': + add_long_arr = np.zeros(len(df), dtype=bool) + + # 开仓触发价格(如果指标提供了精确开仓价格) + open_long_price_arr = signals.get('open_long_price', pd.Series([0.0] * len(df))).values + open_short_price_arr = signals.get('open_short_price', pd.Series([0.0] * len(df))).values + + # 平仓目标价格(如果指标提供了精确平仓价格) + close_long_price_arr = signals.get('close_long_price', pd.Series([0.0] * len(df))).values + close_short_price_arr = signals.get('close_short_price', pd.Series([0.0] * len(df))).values + + # 加仓目标价格(如果指标提供了精确加仓价格) + add_long_price_arr = signals.get('add_long_price', pd.Series([0.0] * len(df))).values + add_short_price_arr = signals.get('add_short_price', pd.Series([0.0] * len(df))).values + + for i, (timestamp, row) in enumerate(df.iterrows()): + if is_liquidated: + equity_curve.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'value': 0 + }) + continue + + # 若已无持仓且余额过低,视为赔光并停止后续交易 + if position == 0 and capital < min_capital_to_trade: + is_liquidated = True + capital = 0 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(float(row.get('close', 0) or 0), 4), + 'amount': 0, + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) + continue + + # Use OHLC to evaluate triggers. + high = row['high'] + low = row['low'] + close = row['close'] + open_ = row.get('open', close) + + # Default execution price depends on timing mode + # - bar_close: close + # - next_bar_open: open (this bar is the next bar for a prior signal) + exec_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else close + + # --- Risk controls: SL / TP / trailing exit (highest priority) --- + if position != 0 and position_type in ['long', 'short']: + # 更新持仓期间极值(用于移动止盈止损) + if position_type == 'long': + if highest_since_entry is None: + highest_since_entry = entry_price + if lowest_since_entry is None: + lowest_since_entry = entry_price + highest_since_entry = max(highest_since_entry, high) + lowest_since_entry = min(lowest_since_entry, low) + else: # short + if lowest_since_entry is None: + lowest_since_entry = entry_price + if highest_since_entry is None: + highest_since_entry = entry_price + lowest_since_entry = min(lowest_since_entry, low) + highest_since_entry = max(highest_since_entry, high) + + # 收集同一根K线内触发的强制平仓点 + # 回测为K线级别,无法确定同一根K线内的真实触发顺序;这里按“确定性优先级”处理: + # 止损 > 移动止盈(回撤) > 固定止盈 + candidates = [] # [(trade_type, trigger_price)] + if position_type == 'long' and position > 0: + if stop_loss_pct_eff > 0: + sl_price = entry_price * (1 - stop_loss_pct_eff) + if low <= sl_price: + candidates.append(('close_long_stop', sl_price)) + # Fixed take-profit exit is disabled when trailing is enabled (see conflict rule above). + if (not trailing_enabled) and take_profit_pct_eff > 0: + tp_price = entry_price * (1 + take_profit_pct_eff) + if high >= tp_price: + candidates.append(('close_long_profit', tp_price)) + if trailing_enabled and trailing_pct_eff > 0 and highest_since_entry is not None: + trail_active = True + if trailing_activation_pct_eff > 0: + trail_active = highest_since_entry >= entry_price * (1 + trailing_activation_pct_eff) + if trail_active: + tr_price = highest_since_entry * (1 - trailing_pct_eff) + if low <= tr_price: + candidates.append(('close_long_trailing', tr_price)) + + if candidates: + # 按优先级选择触发点:止损 > 移动止盈 > 止盈 + pri = {'close_long_stop': 0, 'close_long_trailing': 1, 'close_long_profit': 2} + trade_type, trigger_price = sorted(candidates, key=lambda x: (pri.get(x[0], 99), x[1]))[0] + exec_price_close = trigger_price * (1 - slippage) + commission_fee_close = position * exec_price_close * commission + # 开仓手续费已在开仓时扣除,这里只扣平仓手续费 + profit = (exec_price_close - entry_price) * position - commission_fee_close + capital += profit + total_commission_paid += commission_fee_close + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': trade_type, + 'price': round(exec_price_close, 4), + 'amount': round(position, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + position = 0 + position_type = None + liquidation_price = 0 + highest_since_entry = None + lowest_since_entry = None + trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + + equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)}) + continue + + if position_type == 'short' and position < 0: + shares = abs(position) + if stop_loss_pct_eff > 0: + sl_price = entry_price * (1 + stop_loss_pct_eff) + if high >= sl_price: + candidates.append(('close_short_stop', sl_price)) + # Fixed take-profit exit is disabled when trailing is enabled (see conflict rule above). + if (not trailing_enabled) and take_profit_pct_eff > 0: + tp_price = entry_price * (1 - take_profit_pct_eff) + if low <= tp_price: + candidates.append(('close_short_profit', tp_price)) + if trailing_enabled and trailing_pct_eff > 0 and lowest_since_entry is not None: + trail_active = True + if trailing_activation_pct_eff > 0: + trail_active = lowest_since_entry <= entry_price * (1 - trailing_activation_pct_eff) + if trail_active: + tr_price = lowest_since_entry * (1 + trailing_pct_eff) + if high >= tr_price: + candidates.append(('close_short_trailing', tr_price)) + + if candidates: + # 按优先级选择触发点:止损 > 移动止盈 > 止盈 + pri = {'close_short_stop': 0, 'close_short_trailing': 1, 'close_short_profit': 2} + trade_type, trigger_price = sorted(candidates, key=lambda x: (pri.get(x[0], 99), -x[1]))[0] + exec_price_close = trigger_price * (1 + slippage) + commission_fee_close = shares * exec_price_close * commission + # 开仓手续费已在开仓时扣除,这里只扣平仓手续费 + profit = (entry_price - exec_price_close) * shares - commission_fee_close + + if capital + profit <= 0: + capital = 0 + is_liquidated = True + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(exec_price_close, 4), + 'amount': round(shares, 4), + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + position = 0 + position_type = None + liquidation_price = 0 + equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) + continue + + capital += profit + total_commission_paid += commission_fee_close + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': trade_type, + 'price': round(exec_price_close, 4), + 'amount': round(shares, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + position = 0 + position_type = None + liquidation_price = 0 + highest_since_entry = None + lowest_since_entry = None + trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + + equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)}) + continue + + # 处理平仓信号(优先处理,包括止损/止盈) + if position > 0 and close_long_arr[i]: + # 平多:使用指标提供的目标价格(如果有),否则使用收盘价 + if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']: + target_price = open_ + else: + target_price = close_long_price_arr[i] if close_long_price_arr[i] > 0 else close + exec_price = target_price * (1 - slippage) + commission_fee = position * exec_price * commission + profit = (exec_price - entry_price) * position - commission_fee + capital += profit + total_commission_paid += commission_fee + + # NOTE: + # This is a "signal close" (not a forced stop-loss/take-profit/trailing exit). + # Do NOT label it as *_stop/*_profit based on PnL sign, otherwise it looks like a stop-loss happened + # even when risk controls are disabled (stopLossPct/takeProfitPct == 0). + trade_type = 'close_long' + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': trade_type, + 'price': round(exec_price, 4), + 'amount': round(position, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + position = 0 + position_type = None + liquidation_price = 0 + highest_since_entry = None + lowest_since_entry = None + trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + + # 平仓后余额过低则停止交易(避免同K线反手开仓) + if capital < min_capital_to_trade: + is_liquidated = True + capital = 0 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(exec_price, 4), + 'amount': 0, + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + + elif position < 0 and close_short_arr[i]: + # 平空:使用指标提供的目标价格(如果有),否则使用收盘价 + if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']: + target_price = open_ + else: + target_price = close_short_price_arr[i] if close_short_price_arr[i] > 0 else close + exec_price = target_price * (1 + slippage) + shares = abs(position) + commission_fee = shares * exec_price * commission + profit = (entry_price - exec_price) * shares - commission_fee + + if capital + profit <= 0: + logger.warning(f"平空时资金不足爆仓") + capital = 0 + is_liquidated = True + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(-capital, 2), + 'balance': 0 + }) + position = 0 + position_type = None + equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) + continue + + capital += profit + total_commission_paid += commission_fee + + # Signal close (not forced TP/SL/trailing). + trade_type = 'close_short' + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': trade_type, + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + position = 0 + position_type = None + liquidation_price = 0 + highest_since_entry = None + lowest_since_entry = None + trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + + if capital < min_capital_to_trade: + is_liquidated = True + capital = 0 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(exec_price, 4), + 'amount': 0, + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + + # If this candle has a main strategy signal (open/close long/short), + # we must NOT apply any scale-in/scale-out actions on the same candle. + main_signal_on_bar = bool(open_long_arr[i] or open_short_arr[i] or close_long_arr[i] or close_short_arr[i]) + + # --- Parameterized scaling rules (no strategy code needed) --- + # Rules: + # - Trend scale-in: long triggers when price rises stepPct from anchor; short triggers when price falls stepPct from anchor + # - Mean-reversion DCA: long triggers when price falls stepPct from anchor; short triggers when price rises stepPct from anchor + # - Trend reduce: long reduces on rise; short reduces on fall + # - Adverse reduce: long reduces on fall; short reduces on rise + if (not main_signal_on_bar) and position != 0 and position_type in ['long', 'short'] and capital >= min_capital_to_trade: + # 做多 + if position_type == 'long' and position > 0: + # Trend scale-in (trigger on higher price) + if trend_add_enabled and trend_add_step_pct_eff > 0 and trend_add_size_pct > 0 and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times): + anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price + trigger = anchor * (1 + trend_add_step_pct_eff) + if high >= trigger: + order_pct = trend_add_size_pct + if order_pct > 0: + exec_price_add = trigger * (1 + slippage) + use_capital = capital * order_pct + # 手续费按成交名义价值扣除;下单数量不再除以(1+commission) + shares_add = (use_capital * leverage) / exec_price_add + commission_fee = shares_add * exec_price_add * commission + + total_cost_before = position * entry_price + total_cost_after = total_cost_before + shares_add * exec_price_add + position += shares_add + entry_price = total_cost_after / position + + capital -= commission_fee + total_commission_paid += commission_fee + liquidation_price = entry_price * (1 - 1.0 / leverage) + + trend_add_times += 1 + last_trend_add_anchor = trigger + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'add_long', + 'price': round(exec_price_add, 4), + 'amount': round(shares_add, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + # Mean-reversion DCA (trigger on lower price) + if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times): + anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price + trigger = anchor * (1 - dca_add_step_pct_eff) + if low <= trigger: + order_pct = dca_add_size_pct + if order_pct > 0: + exec_price_add = trigger * (1 + slippage) + use_capital = capital * order_pct + shares_add = (use_capital * leverage) / exec_price_add + commission_fee = shares_add * exec_price_add * commission + + total_cost_before = position * entry_price + total_cost_after = total_cost_before + shares_add * exec_price_add + position += shares_add + entry_price = total_cost_after / position + + capital -= commission_fee + total_commission_paid += commission_fee + liquidation_price = entry_price * (1 - 1.0 / leverage) + + dca_add_times += 1 + last_dca_add_anchor = trigger + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'add_long', + 'price': round(exec_price_add, 4), + 'amount': round(shares_add, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + # Trend reduce (trigger on higher price) + if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times): + anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price + trigger = anchor * (1 + trend_reduce_step_pct_eff) + if high >= trigger: + reduce_pct = max(trend_reduce_size_pct, 0.0) + reduce_shares = position * reduce_pct + if reduce_shares > 0: + exec_price_reduce = trigger * (1 - slippage) + commission_fee = reduce_shares * exec_price_reduce * commission + profit = (exec_price_reduce - entry_price) * reduce_shares - commission_fee + capital += profit + total_commission_paid += commission_fee + position -= reduce_shares + if position <= 1e-12: + position = 0 + position_type = None + liquidation_price = 0 + else: + liquidation_price = entry_price * (1 - 1.0 / leverage) + + trend_reduce_times += 1 + last_trend_reduce_anchor = trigger + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'reduce_long', + 'price': round(exec_price_reduce, 4), + 'amount': round(reduce_shares, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + # Adverse reduce (trigger on lower price) + if position_type == 'long' and position > 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times): + anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price + trigger = anchor * (1 - adverse_reduce_step_pct_eff) + if low <= trigger: + reduce_pct = max(adverse_reduce_size_pct, 0.0) + reduce_shares = position * reduce_pct + if reduce_shares > 0: + exec_price_reduce = trigger * (1 - slippage) + commission_fee = reduce_shares * exec_price_reduce * commission + profit = (exec_price_reduce - entry_price) * reduce_shares - commission_fee + capital += profit + total_commission_paid += commission_fee + position -= reduce_shares + if position <= 1e-12: + position = 0 + position_type = None + liquidation_price = 0 + else: + liquidation_price = entry_price * (1 - 1.0 / leverage) + + adverse_reduce_times += 1 + last_adverse_reduce_anchor = trigger + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'reduce_long', + 'price': round(exec_price_reduce, 4), + 'amount': round(reduce_shares, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + # 做空 + if position_type == 'short' and position < 0: + shares_total = abs(position) + + # Trend scale-in (trigger on lower price) + if trend_add_enabled and trend_add_step_pct_eff > 0 and trend_add_size_pct > 0 and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times): + anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price + trigger = anchor * (1 - trend_add_step_pct_eff) + if low <= trigger: + order_pct = trend_add_size_pct + if order_pct > 0: + exec_price_add = trigger * (1 - slippage) # 卖出加空,滑点不利 + use_capital = capital * order_pct + shares_add = (use_capital * leverage) / exec_price_add + commission_fee = shares_add * exec_price_add * commission + + total_cost_before = shares_total * entry_price + total_cost_after = total_cost_before + shares_add * exec_price_add + position -= shares_add + shares_total = abs(position) + entry_price = total_cost_after / shares_total + + capital -= commission_fee + total_commission_paid += commission_fee + liquidation_price = entry_price * (1 + 1.0 / leverage) + + trend_add_times += 1 + last_trend_add_anchor = trigger + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'add_short', + 'price': round(exec_price_add, 4), + 'amount': round(shares_add, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + # Mean-reversion DCA (trigger on higher price) + if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times): + anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price + trigger = anchor * (1 + dca_add_step_pct_eff) + if high >= trigger: + order_pct = dca_add_size_pct + if order_pct > 0: + exec_price_add = trigger * (1 - slippage) + use_capital = capital * order_pct + shares_add = (use_capital * leverage) / exec_price_add + commission_fee = shares_add * exec_price_add * commission + + total_cost_before = shares_total * entry_price + total_cost_after = total_cost_before + shares_add * exec_price_add + position -= shares_add + shares_total = abs(position) + entry_price = total_cost_after / shares_total + + capital -= commission_fee + total_commission_paid += commission_fee + liquidation_price = entry_price * (1 + 1.0 / leverage) + + dca_add_times += 1 + last_dca_add_anchor = trigger + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'add_short', + 'price': round(exec_price_add, 4), + 'amount': round(shares_add, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + # Trend reduce (trigger on lower price) + if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times): + anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price + trigger = anchor * (1 - trend_reduce_step_pct_eff) + if low <= trigger: + reduce_pct = max(trend_reduce_size_pct, 0.0) + reduce_shares = shares_total * reduce_pct + if reduce_shares > 0: + exec_price_reduce = trigger * (1 + slippage) # 回补更贵 + commission_fee = reduce_shares * exec_price_reduce * commission + profit = (entry_price - exec_price_reduce) * reduce_shares - commission_fee + capital += profit + total_commission_paid += commission_fee + position += reduce_shares + shares_total = abs(position) + if shares_total <= 1e-12: + position = 0 + position_type = None + liquidation_price = 0 + else: + liquidation_price = entry_price * (1 + 1.0 / leverage) + + trend_reduce_times += 1 + last_trend_reduce_anchor = trigger + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'reduce_short', + 'price': round(exec_price_reduce, 4), + 'amount': round(reduce_shares, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + # Adverse reduce (trigger on higher price) + if position_type == 'short' and position < 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times): + anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price + trigger = anchor * (1 + adverse_reduce_step_pct_eff) + if high >= trigger: + reduce_pct = max(adverse_reduce_size_pct, 0.0) + reduce_shares = shares_total * reduce_pct + if reduce_shares > 0: + exec_price_reduce = trigger * (1 + slippage) + commission_fee = reduce_shares * exec_price_reduce * commission + profit = (entry_price - exec_price_reduce) * reduce_shares - commission_fee + capital += profit + total_commission_paid += commission_fee + position += reduce_shares + shares_total = abs(position) + if shares_total <= 1e-12: + position = 0 + position_type = None + liquidation_price = 0 + else: + liquidation_price = entry_price * (1 + 1.0 / leverage) + + adverse_reduce_times += 1 + last_adverse_reduce_anchor = trigger + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'reduce_short', + 'price': round(exec_price_reduce, 4), + 'amount': round(reduce_shares, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + # 处理加仓信号(仓位管理模式) + if has_position_management and (not main_signal_on_bar): + if position > 0 and add_long_arr[i] and capital >= min_capital_to_trade: + # 加多仓:使用指标提供的目标价格(如果有),否则使用收盘价 + target_price = add_long_price_arr[i] if add_long_price_arr[i] > 0 else close + exec_price = target_price * (1 + slippage) + + # 使用指定比例的资金加仓 + position_pct = position_size_arr[i] if position_size_arr[i] > 0 else 0.1 + use_capital = capital * position_pct + shares = (use_capital * leverage) / exec_price + commission_fee = shares * exec_price * commission + + # 更新平均成本 + total_cost_before = position * entry_price + total_cost_after = total_cost_before + shares * exec_price + position += shares + entry_price = total_cost_after / position + + capital -= commission_fee + total_commission_paid += commission_fee + + # 重新计算爆仓线 + liquidation_price = entry_price * (1 - 1.0 / leverage) + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'add_long', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + elif position < 0 and add_short_arr[i] and capital >= min_capital_to_trade: + # 加空仓:使用指标提供的目标价格(如果有),否则使用收盘价 + target_price = add_short_price_arr[i] if add_short_price_arr[i] > 0 else close + exec_price = target_price * (1 - slippage) + + # 使用指定比例的资金加仓 + position_pct = position_size_arr[i] if position_size_arr[i] > 0 else 0.1 + use_capital = capital * position_pct + shares = (use_capital * leverage) / exec_price + commission_fee = shares * exec_price * commission + + # 更新平均成本 + current_shares = abs(position) + total_cost_before = current_shares * entry_price + total_cost_after = total_cost_before + shares * exec_price + position -= shares # 空头是负数 + current_shares = abs(position) + entry_price = total_cost_after / current_shares + + capital -= commission_fee + total_commission_paid += commission_fee + + # 重新计算爆仓线 + liquidation_price = entry_price * (1 + 1.0 / leverage) + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'add_short', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + # 处理开仓信号 + # 注意:code6.py已经处理了反转(先平后开),所以这里只需要处理position==0的情况 + if open_long_arr[i] and position == 0 and capital >= min_capital_to_trade: + # 使用指标提供的开仓触发价格(如果有),否则使用收盘价 + if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']: + base_price = open_ + else: + base_price = open_long_price_arr[i] if open_long_price_arr[i] > 0 else close + exec_price = base_price * (1 + slippage) + + # 使用指定比例的资金开仓(优先采用回测弹窗的 entryPct;其次采用指标提供的 position_size;否则全仓) + position_pct = None + if entry_pct_cfg and entry_pct_cfg > 0: + position_pct = entry_pct_cfg + elif has_position_management and position_size_arr[i] > 0: + position_pct = position_size_arr[i] + if position_pct is not None and position_pct > 0 and position_pct < 1: + use_capital = capital * position_pct + shares = (use_capital * leverage) / exec_price + else: + shares = (capital * leverage) / exec_price + + commission_fee = shares * exec_price * commission + + position = shares + entry_price = exec_price + position_type = 'long' + capital -= commission_fee + total_commission_paid += commission_fee + liquidation_price = entry_price * (1 - 1.0 / leverage) + highest_since_entry = entry_price + lowest_since_entry = entry_price + last_trend_add_anchor = entry_price + last_dca_add_anchor = entry_price + last_trend_reduce_anchor = entry_price + last_adverse_reduce_anchor = entry_price + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'open_long', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + # Strict intrabar stop-loss / liquidation check right after entry (closer to live trading). + # If this bar touches stop-loss price, close immediately at stop price (with slippage). + # If this bar also touches liquidation price, assume stop-loss triggers first only if it is above liquidation. + if position_type == 'long' and position > 0: + sl_price = entry_price * (1 - stop_loss_pct_eff) if stop_loss_pct_eff > 0 else None + hit_sl = (sl_price is not None) and (low <= sl_price) + hit_liq = liquidation_price > 0 and (low <= liquidation_price) + if hit_sl or hit_liq: + if hit_liq and (not hit_sl or (sl_price is not None and sl_price <= liquidation_price)): + # Liquidation happens before stop-loss (or stop-loss not configured). + is_liquidated = True + capital = 0 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(liquidation_price, 4), + 'amount': round(position, 4), + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + else: + # Stop-loss triggers first. + exec_price_close = sl_price * (1 - slippage) + commission_fee_close = position * exec_price_close * commission + profit = (exec_price_close - entry_price) * position - commission_fee_close + capital += profit + total_commission_paid += commission_fee_close + if capital <= 0: + is_liquidated = True + capital = 0 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_long_stop', + 'price': round(exec_price_close, 4), + 'amount': round(position, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + position = 0 + position_type = None + liquidation_price = 0 + highest_since_entry = None + lowest_since_entry = None + equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)}) + continue + + elif open_short_arr[i] and position == 0 and capital >= min_capital_to_trade: + # 使用指标提供的开仓触发价格(如果有),否则使用收盘价 + if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']: + base_price = open_ + else: + base_price = open_short_price_arr[i] if open_short_price_arr[i] > 0 else close + exec_price = base_price * (1 - slippage) + + # 使用指定比例的资金开仓(优先采用回测弹窗的 entryPct;其次采用指标提供的 position_size;否则全仓) + position_pct = None + if entry_pct_cfg and entry_pct_cfg > 0: + position_pct = entry_pct_cfg + elif has_position_management and position_size_arr[i] > 0: + position_pct = position_size_arr[i] + if position_pct is not None and position_pct > 0 and position_pct < 1: + use_capital = capital * position_pct + shares = (use_capital * leverage) / exec_price + else: + shares = (capital * leverage) / exec_price + + commission_fee = shares * exec_price * commission + + position = -shares + entry_price = exec_price + position_type = 'short' + capital -= commission_fee + total_commission_paid += commission_fee + liquidation_price = entry_price * (1 + 1.0 / leverage) + highest_since_entry = entry_price + lowest_since_entry = entry_price + last_trend_add_anchor = entry_price + last_dca_add_anchor = entry_price + last_trend_reduce_anchor = entry_price + last_adverse_reduce_anchor = entry_price + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'open_short', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + # Strict intrabar stop-loss / liquidation check right after entry (closer to live trading). + if position_type == 'short' and position < 0: + sl_price = entry_price * (1 + stop_loss_pct_eff) if stop_loss_pct_eff > 0 else None + hit_sl = (sl_price is not None) and (high >= sl_price) + hit_liq = liquidation_price > 0 and (high >= liquidation_price) + if hit_sl or hit_liq: + if hit_liq and (not hit_sl or (sl_price is not None and sl_price >= liquidation_price)): + # Liquidation happens before stop-loss (or stop-loss not configured). + is_liquidated = True + capital = 0 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(liquidation_price, 4), + 'amount': round(abs(position), 4), + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + else: + # Stop-loss triggers first. + exec_price_close = sl_price * (1 + slippage) + shares_close = abs(position) + commission_fee_close = shares_close * exec_price_close * commission + profit = (entry_price - exec_price_close) * shares_close - commission_fee_close + capital += profit + total_commission_paid += commission_fee_close + if capital <= 0: + is_liquidated = True + capital = 0 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_short_stop', + 'price': round(exec_price_close, 4), + 'amount': round(shares_close, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + position = 0 + position_type = None + liquidation_price = 0 + highest_since_entry = None + lowest_since_entry = None + equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)}) + continue + + # 检测持仓期间是否触及爆仓线(作为兜底保护) + # 注意:这个检查在所有主动平仓信号处理之后 + # 如果触及爆仓线,检查是否有止损信号,止损优先 + if position != 0 and not is_liquidated: + if position_type == 'long' and low <= liquidation_price: + # 做多触及爆仓线:检查是否有止损信号 + has_stop_loss = close_long_arr[i] and close_long_price_arr[i] > 0 + stop_loss_price = close_long_price_arr[i] if has_stop_loss else 0 + + # 判断先触发止损还是爆仓 + if has_stop_loss and stop_loss_price > liquidation_price: + # 止损在爆仓前触发,使用止损价平仓 + exec_price_close = stop_loss_price * (1 - slippage) + commission_fee_close = position * exec_price_close * commission + profit = (exec_price_close - entry_price) * position - commission_fee_close + capital += profit + total_commission_paid += commission_fee_close + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_long_stop', + 'price': round(exec_price_close, 4), + 'amount': round(position, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + else: + # 止损不够严格或无止损,触发爆仓 + logger.warning(f"做多爆仓!开仓价={entry_price:.2f}, 最低价={low:.2f}, " + f"爆仓线={liquidation_price:.2f}, 止损价={stop_loss_price:.2f}") + is_liquidated = True + capital = 0 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(liquidation_price, 4), + 'amount': round(abs(position), 4), + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + + position = 0 + position_type = None + equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': capital}) + continue + + elif position_type == 'short' and high >= liquidation_price: + # 做空触及爆仓线:检查是否有止损信号 + has_stop_loss = close_short_arr[i] and close_short_price_arr[i] > 0 + stop_loss_price = close_short_price_arr[i] if has_stop_loss else 0 + + logger.warning(f"[K线{i}] 做空触及爆仓线!开仓={entry_price:.2f}, 最高={high:.2f}, 爆仓线={liquidation_price:.2f}, " + f"止损信号={close_short_arr[i]}, 止损价={stop_loss_price:.4f}, 时间={timestamp}") + + # 判断先触发止损还是爆仓 + if has_stop_loss and stop_loss_price < liquidation_price: + # 止损在爆仓前触发,使用止损价平仓 + exec_price_close = stop_loss_price * (1 + slippage) + shares_close = abs(position) + commission_fee_close = shares_close * exec_price_close * commission + profit = (entry_price - exec_price_close) * shares_close - commission_fee_close + capital += profit + total_commission_paid += commission_fee_close + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_short_stop', + 'price': round(exec_price_close, 4), + 'amount': round(shares_close, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + else: + # 止损不够严格或无止损,触发爆仓 + logger.warning(f"做空爆仓!开仓价={entry_price:.2f}, 最高价={high:.2f}, " + f"爆仓线={liquidation_price:.2f}, 止损价={stop_loss_price:.2f}") + is_liquidated = True + capital = 0 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(liquidation_price, 4), + 'amount': round(abs(position), 4), + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + + position = 0 + position_type = None + equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': capital}) + continue + + # 记录权益(使用收盘价计算未实现盈亏) + if position_type == 'long': + unrealized_pnl = (close - entry_price) * position + total_value = capital + unrealized_pnl + elif position_type == 'short': + shares = abs(position) + unrealized_pnl = (entry_price - close) * shares + total_value = capital + unrealized_pnl + else: + total_value = capital + + if total_value < 0: + total_value = 0 + + equity_curve.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'value': round(total_value, 2) + }) + + # 回测结束时强制平仓 + if position != 0: + timestamp = df.index[-1] + final_close = df.iloc[-1]['close'] + + if position > 0: # 平多 + exec_price = final_close * (1 - slippage) + commission_fee = position * exec_price * commission + profit = (exec_price - entry_price) * position - commission_fee + capital += profit + total_commission_paid += commission_fee + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_long', + 'price': round(exec_price, 4), + 'amount': round(position, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + else: # 平空 + exec_price = final_close * (1 + slippage) + shares = abs(position) + commission_fee = shares * exec_price * commission + profit = (entry_price - exec_price) * shares - commission_fee + + if capital + profit <= 0: + logger.warning(f"回测结束爆仓!") + capital = 0 + is_liquidated = True + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(-capital, 2), + 'balance': 0 + }) + else: + capital += profit + total_commission_paid += commission_fee + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_short', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + if equity_curve: + equity_curve[-1]['value'] = round(capital, 2) + + return equity_curve, trades, total_commission_paid + + def _simulate_trading_old_format( + self, + df: pd.DataFrame, + signals: pd.Series, + initial_capital: float, + commission: float, + slippage: float, + leverage: int = 1, + trade_direction: str = 'long', + strategy_config: Optional[Dict[str, Any]] = None + ) -> tuple: + """ + 使用旧格式信号进行交易模拟(保持兼容性) + """ + equity_curve = [] + trades = [] + total_commission_paid = 0 # 累计手续费 + is_liquidated = False # 爆仓标志 + liquidation_price = 0 # 爆仓价格 + min_capital_to_trade = 1.0 # 余额低于该值则视为赔光,不再开新单 + + capital = initial_capital + position = 0 # 正数=多头持仓,负数=空头持仓 + entry_price = 0 + position_type = None # 'long' or 'short' + + # Risk controls (also supported for legacy signals): SL / TP / trailing exit + cfg = strategy_config or {} + exec_cfg = cfg.get('execution') or {} + # Signal confirmation / execution timing (legacy mode): + # - bar_close: execute on the same bar close + # - next_bar_open: execute on next bar open after signal is confirmed on bar close (recommended) + signal_timing = str(exec_cfg.get('signalTiming') or 'next_bar_open').strip().lower() + risk_cfg = cfg.get('risk') or {} + stop_loss_pct = float(risk_cfg.get('stopLossPct') or 0.0) + take_profit_pct = float(risk_cfg.get('takeProfitPct') or 0.0) + trailing_cfg = risk_cfg.get('trailing') or {} + trailing_enabled = bool(trailing_cfg.get('enabled')) + trailing_pct = float(trailing_cfg.get('pct') or 0.0) + trailing_activation_pct = float(trailing_cfg.get('activationPct') or 0.0) + + # Risk percentages are defined on margin PnL; convert to price move thresholds by leverage. + lev = max(int(leverage or 1), 1) + stop_loss_pct_eff = stop_loss_pct / lev + take_profit_pct_eff = take_profit_pct / lev + trailing_pct_eff = trailing_pct / lev + trailing_activation_pct_eff = trailing_activation_pct / lev + highest_since_entry = None + lowest_since_entry = None + + # --- Position / scaling config (make old-format strategies support the same backtest modal features) --- + pos_cfg = cfg.get('position') or {} + entry_pct_cfg = float(pos_cfg.get('entryPct') if pos_cfg.get('entryPct') is not None else 1.0) # expected 0~1 + # Accept both 0~1 and 0~100 inputs (some clients may send percent units). + if entry_pct_cfg > 1: + entry_pct_cfg = entry_pct_cfg / 100.0 + entry_pct_cfg = max(0.0, min(entry_pct_cfg, 1.0)) + + scale_cfg = cfg.get('scale') or {} + trend_add_cfg = scale_cfg.get('trendAdd') or {} + dca_add_cfg = scale_cfg.get('dcaAdd') or {} + trend_reduce_cfg = scale_cfg.get('trendReduce') or {} + adverse_reduce_cfg = scale_cfg.get('adverseReduce') or {} + + trend_add_enabled = bool(trend_add_cfg.get('enabled')) + trend_add_step_pct = float(trend_add_cfg.get('stepPct') or 0.0) + trend_add_size_pct = float(trend_add_cfg.get('sizePct') or 0.0) + trend_add_max_times = int(trend_add_cfg.get('maxTimes') or 0) + + dca_add_enabled = bool(dca_add_cfg.get('enabled')) + dca_add_step_pct = float(dca_add_cfg.get('stepPct') or 0.0) + dca_add_size_pct = float(dca_add_cfg.get('sizePct') or 0.0) + dca_add_max_times = int(dca_add_cfg.get('maxTimes') or 0) + + trend_reduce_enabled = bool(trend_reduce_cfg.get('enabled')) + trend_reduce_step_pct = float(trend_reduce_cfg.get('stepPct') or 0.0) + trend_reduce_size_pct = float(trend_reduce_cfg.get('sizePct') or 0.0) + trend_reduce_max_times = int(trend_reduce_cfg.get('maxTimes') or 0) + + adverse_reduce_enabled = bool(adverse_reduce_cfg.get('enabled')) + adverse_reduce_step_pct = float(adverse_reduce_cfg.get('stepPct') or 0.0) + adverse_reduce_size_pct = float(adverse_reduce_cfg.get('sizePct') or 0.0) + adverse_reduce_max_times = int(adverse_reduce_cfg.get('maxTimes') or 0) + + # 触发百分比按杠杆后换算为价格阈值 + trend_add_step_pct_eff = trend_add_step_pct / lev + dca_add_step_pct_eff = dca_add_step_pct / lev + trend_reduce_step_pct_eff = trend_reduce_step_pct / lev + adverse_reduce_step_pct_eff = adverse_reduce_step_pct / lev + + # State for scaling + trend_add_times = 0 + dca_add_times = 0 + trend_reduce_times = 0 + adverse_reduce_times = 0 + last_trend_add_anchor = None + last_dca_add_anchor = None + last_trend_reduce_anchor = None + last_adverse_reduce_anchor = None + + # Apply execution timing to avoid look-ahead bias in legacy signals (buy/sell series): + # If signal is computed on bar close, realistic execution is next bar open. + signals_exec = signals + if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']: + try: + signals_exec = signals.shift(1).fillna(0) + except Exception: + signals_exec = signals + + for i, (timestamp, row) in enumerate(df.iterrows()): + # 如果已爆仓,停止交易 + if is_liquidated: + # 记录爆仓后的权益(保持为0) + equity_curve.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'value': 0 + }) + continue + + # 若已无持仓且余额过低,视为赔光并停止后续交易 + if position == 0 and capital < min_capital_to_trade: + is_liquidated = True + capital = 0 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(float(row.get('close', 0) or 0), 4), + 'amount': 0, + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) + continue + + signal = signals_exec.iloc[i] if i < len(signals_exec) else 0 + high = row['high'] + low = row['low'] + price = row['close'] + open_ = row.get('open', price) + + # 强制平仓(止盈止损/移动止盈)优先于信号 + if position != 0 and position_type in ['long', 'short']: + if position_type == 'long' and position > 0: + if highest_since_entry is None: + highest_since_entry = entry_price + highest_since_entry = max(highest_since_entry, high) + candidates = [] + if stop_loss_pct_eff > 0: + sl_price = entry_price * (1 - stop_loss_pct_eff) + if low <= sl_price: + candidates.append(('stop', sl_price)) + if take_profit_pct_eff > 0: + tp_price = entry_price * (1 + take_profit_pct_eff) + if high >= tp_price: + candidates.append(('profit', tp_price)) + if trailing_enabled and trailing_pct_eff > 0: + trail_active = True + if trailing_activation_pct_eff > 0: + trail_active = highest_since_entry >= entry_price * (1 + trailing_activation_pct_eff) + if trail_active: + tr_price = highest_since_entry * (1 - trailing_pct_eff) + if low <= tr_price: + candidates.append(('trailing', tr_price)) + if candidates: + # 止损 > 移动止盈(回撤) > 止盈 + pri = {'stop': 0, 'trailing': 1, 'profit': 2} + reason, trigger_price = sorted(candidates, key=lambda x: (pri.get(x[0], 99), x[1]))[0] + exec_price = trigger_price * (1 - slippage) + commission_fee = position * exec_price * commission + # 开仓手续费已在开仓时扣除,这里只扣平仓手续费 + profit = (exec_price - entry_price) * position - commission_fee + capital += profit + total_commission_paid += commission_fee + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': {'stop': 'close_long_stop', 'profit': 'close_long_profit', 'trailing': 'close_long_trailing'}.get(reason, 'close_long'), + 'price': round(exec_price, 4), + 'amount': round(position, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + position = 0 + position_type = None + liquidation_price = 0 + highest_since_entry = None + lowest_since_entry = None + equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)}) + continue + + if position_type == 'short' and position < 0: + shares = abs(position) + if lowest_since_entry is None: + lowest_since_entry = entry_price + lowest_since_entry = min(lowest_since_entry, low) + candidates = [] + if stop_loss_pct_eff > 0: + sl_price = entry_price * (1 + stop_loss_pct_eff) + if high >= sl_price: + candidates.append(('stop', sl_price)) + if take_profit_pct_eff > 0: + tp_price = entry_price * (1 - take_profit_pct_eff) + if low <= tp_price: + candidates.append(('profit', tp_price)) + if trailing_enabled and trailing_pct_eff > 0: + trail_active = True + if trailing_activation_pct_eff > 0: + trail_active = lowest_since_entry <= entry_price * (1 - trailing_activation_pct_eff) + if trail_active: + tr_price = lowest_since_entry * (1 + trailing_pct_eff) + if high >= tr_price: + candidates.append(('trailing', tr_price)) + if candidates: + # 止损 > 移动止盈(回撤) > 止盈 + pri = {'stop': 0, 'trailing': 1, 'profit': 2} + reason, trigger_price = sorted(candidates, key=lambda x: (pri.get(x[0], 99), -x[1]))[0] + exec_price = trigger_price * (1 + slippage) + commission_fee = shares * exec_price * commission + # 开仓手续费已在开仓时扣除,这里只扣平仓手续费 + profit = (entry_price - exec_price) * shares - commission_fee + if capital + profit <= 0: + capital = 0 + is_liquidated = True + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + position = 0 + position_type = None + liquidation_price = 0 + equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) + continue + capital += profit + total_commission_paid += commission_fee + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': {'stop': 'close_short_stop', 'profit': 'close_short_profit', 'trailing': 'close_short_trailing'}.get(reason, 'close_short'), + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + position = 0 + position_type = None + liquidation_price = 0 + highest_since_entry = None + lowest_since_entry = None + equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)}) + continue + + # --- Parameterized scaling rules (also for old-format strategies) --- + # 说明:旧格式只有 buy/sell 信号,但回测弹窗的“顺势/逆势加减仓、最小下单比例”等参数仍应生效。 + # 触发百分比按杠杆后阈值理解(已除以 leverage)。 + # IMPORTANT: if this candle has a main buy/sell signal, do NOT apply any scale-in/scale-out. + if signal == 0 and position != 0 and position_type in ['long', 'short'] and capital >= min_capital_to_trade: + # 做多 + if position_type == 'long' and position > 0: + # Trend add(顺势加仓:上涨触发) + if trend_add_enabled and trend_add_step_pct_eff > 0 and trend_add_size_pct > 0 and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times): + anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price + trigger = anchor * (1 + trend_add_step_pct_eff) + if high >= trigger: + order_pct = trend_add_size_pct + if order_pct > 0: + exec_price_add = trigger * (1 + slippage) + use_capital = capital * order_pct + shares_add = (use_capital * leverage) / exec_price_add + commission_fee = shares_add * exec_price_add * commission + + total_cost_before = position * entry_price + total_cost_after = total_cost_before + shares_add * exec_price_add + position += shares_add + entry_price = total_cost_after / position + + capital -= commission_fee + total_commission_paid += commission_fee + liquidation_price = entry_price * (1 - 1.0 / leverage) + + trend_add_times += 1 + last_trend_add_anchor = trigger + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'add_long', + 'price': round(exec_price_add, 4), + 'amount': round(shares_add, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + # DCA add(逆势加仓:下跌触发) + if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times): + anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price + trigger = anchor * (1 - dca_add_step_pct_eff) + if low <= trigger: + order_pct = dca_add_size_pct + if order_pct > 0: + exec_price_add = trigger * (1 + slippage) + use_capital = capital * order_pct + shares_add = (use_capital * leverage) / exec_price_add + commission_fee = shares_add * exec_price_add * commission + + total_cost_before = position * entry_price + total_cost_after = total_cost_before + shares_add * exec_price_add + position += shares_add + entry_price = total_cost_after / position + + capital -= commission_fee + total_commission_paid += commission_fee + liquidation_price = entry_price * (1 - 1.0 / leverage) + + dca_add_times += 1 + last_dca_add_anchor = trigger + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'add_long', + 'price': round(exec_price_add, 4), + 'amount': round(shares_add, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + # Trend reduce(顺势减仓:上涨触发) + if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times): + anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price + trigger = anchor * (1 + trend_reduce_step_pct_eff) + if high >= trigger: + reduce_pct = max(trend_reduce_size_pct, 0.0) + reduce_shares = position * reduce_pct + if reduce_shares > 0: + exec_price_reduce = trigger * (1 - slippage) + commission_fee = reduce_shares * exec_price_reduce * commission + profit = (exec_price_reduce - entry_price) * reduce_shares - commission_fee + capital += profit + total_commission_paid += commission_fee + position -= reduce_shares + if position <= 1e-12: + position = 0 + position_type = None + liquidation_price = 0 + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 + else: + liquidation_price = entry_price * (1 - 1.0 / leverage) + + trend_reduce_times += 1 + last_trend_reduce_anchor = trigger + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'reduce_long', + 'price': round(exec_price_reduce, 4), + 'amount': round(reduce_shares, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + # Adverse reduce(逆势减仓:下跌触发) + if position_type == 'long' and position > 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times): + anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price + trigger = anchor * (1 - adverse_reduce_step_pct_eff) + if low <= trigger: + reduce_pct = max(adverse_reduce_size_pct, 0.0) + reduce_shares = position * reduce_pct + if reduce_shares > 0: + exec_price_reduce = trigger * (1 - slippage) + commission_fee = reduce_shares * exec_price_reduce * commission + profit = (exec_price_reduce - entry_price) * reduce_shares - commission_fee + capital += profit + total_commission_paid += commission_fee + position -= reduce_shares + if position <= 1e-12: + position = 0 + position_type = None + liquidation_price = 0 + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 + else: + liquidation_price = entry_price * (1 - 1.0 / leverage) + + adverse_reduce_times += 1 + last_adverse_reduce_anchor = trigger + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'reduce_long', + 'price': round(exec_price_reduce, 4), + 'amount': round(reduce_shares, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + # 做空 + if position_type == 'short' and position < 0: + shares_total = abs(position) + + # Trend add(顺势加空:下跌触发) + if trend_add_enabled and trend_add_step_pct_eff > 0 and trend_add_size_pct > 0 and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times): + anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price + trigger = anchor * (1 - trend_add_step_pct_eff) + if low <= trigger: + order_pct = trend_add_size_pct + if order_pct > 0: + exec_price_add = trigger * (1 - slippage) + use_capital = capital * order_pct + shares_add = (use_capital * leverage) / exec_price_add + commission_fee = shares_add * exec_price_add * commission + + total_cost_before = shares_total * entry_price + total_cost_after = total_cost_before + shares_add * exec_price_add + position -= shares_add + shares_total = abs(position) + entry_price = total_cost_after / shares_total + + capital -= commission_fee + total_commission_paid += commission_fee + liquidation_price = entry_price * (1 + 1.0 / leverage) + + trend_add_times += 1 + last_trend_add_anchor = trigger + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'add_short', + 'price': round(exec_price_add, 4), + 'amount': round(shares_add, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + # DCA add(逆势加空:上涨触发) + if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times): + anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price + trigger = anchor * (1 + dca_add_step_pct_eff) + if high >= trigger: + order_pct = dca_add_size_pct + if order_pct > 0: + exec_price_add = trigger * (1 - slippage) + use_capital = capital * order_pct + shares_add = (use_capital * leverage) / exec_price_add + commission_fee = shares_add * exec_price_add * commission + + total_cost_before = shares_total * entry_price + total_cost_after = total_cost_before + shares_add * exec_price_add + position -= shares_add + shares_total = abs(position) + entry_price = total_cost_after / shares_total + + capital -= commission_fee + total_commission_paid += commission_fee + liquidation_price = entry_price * (1 + 1.0 / leverage) + + dca_add_times += 1 + last_dca_add_anchor = trigger + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'add_short', + 'price': round(exec_price_add, 4), + 'amount': round(shares_add, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + # Trend reduce(顺势减空:下跌触发,回补一部分) + if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times): + anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price + trigger = anchor * (1 - trend_reduce_step_pct_eff) + if low <= trigger: + reduce_pct = max(trend_reduce_size_pct, 0.0) + reduce_shares = shares_total * reduce_pct + if reduce_shares > 0: + exec_price_reduce = trigger * (1 + slippage) + commission_fee = reduce_shares * exec_price_reduce * commission + profit = (entry_price - exec_price_reduce) * reduce_shares - commission_fee + capital += profit + total_commission_paid += commission_fee + position += reduce_shares + shares_total = abs(position) + if shares_total <= 1e-12: + position = 0 + position_type = None + liquidation_price = 0 + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 + else: + liquidation_price = entry_price * (1 + 1.0 / leverage) + + trend_reduce_times += 1 + last_trend_reduce_anchor = trigger + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'reduce_short', + 'price': round(exec_price_reduce, 4), + 'amount': round(reduce_shares, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + # Adverse reduce(逆势减空:上涨触发) + if position_type == 'short' and position < 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times): + anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price + trigger = anchor * (1 + adverse_reduce_step_pct_eff) + if high >= trigger: + reduce_pct = max(adverse_reduce_size_pct, 0.0) + reduce_shares = shares_total * reduce_pct + if reduce_shares > 0: + exec_price_reduce = trigger * (1 + slippage) + commission_fee = reduce_shares * exec_price_reduce * commission + profit = (entry_price - exec_price_reduce) * reduce_shares - commission_fee + capital += profit + total_commission_paid += commission_fee + position += reduce_shares + shares_total = abs(position) + if shares_total <= 1e-12: + position = 0 + position_type = None + liquidation_price = 0 + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 + else: + liquidation_price = entry_price * (1 + 1.0 / leverage) + + adverse_reduce_times += 1 + last_adverse_reduce_anchor = trigger + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'reduce_short', + 'price': round(exec_price_reduce, 4), + 'amount': round(reduce_shares, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + # 处理不同的交易方向 + if trade_direction == 'long': + # 只做多模式 + if signal == 1 and position == 0 and capital >= min_capital_to_trade: # 买入开多 + logger.debug(f"[做多模式] 买入开多: 时间={timestamp}, 价格={price}, 杠杆={leverage}x") + base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price + exec_price = base_price * (1 + slippage) + # 使用杠杆:实际持仓 = 本金 × 杠杆 / 价格 + # 使用指定比例的资金开仓(entryPct 优先;否则全仓) + position_pct = None + if entry_pct_cfg is not None and entry_pct_cfg > 0: + position_pct = entry_pct_cfg + if position_pct is not None and 0 < position_pct < 1: + use_capital = capital * position_pct + shares = (use_capital * leverage) / exec_price + else: + shares = (capital * leverage) / exec_price + # 保证金(手续费从本金扣除) + margin = capital + commission_fee = shares * exec_price * commission + + position = shares + entry_price = exec_price + position_type = 'long' + capital -= commission_fee # 只扣手续费,不扣全部成本 + total_commission_paid += commission_fee + + # 计算爆仓线:做多时,价格跌到 entry_price × (1 - 1/leverage) 就爆仓 + liquidation_price = entry_price * (1 - 1.0 / leverage) + logger.debug(f"做多爆仓线: {liquidation_price:.2f}") + + # init scaling anchors + last_trend_add_anchor = entry_price + last_dca_add_anchor = entry_price + last_trend_reduce_anchor = entry_price + last_adverse_reduce_anchor = entry_price + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'open_long', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + elif signal == -1 and position > 0: # 卖出平多 + logger.debug(f"[做多模式] 卖出平多: 时间={timestamp}, 价格={price}") + base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price + exec_price = base_price * (1 - slippage) + # 盈亏 = (平仓价 - 开仓价) × 股数 - 手续费 + commission_fee = position * exec_price * commission + profit = (exec_price - entry_price) * position - commission_fee + capital += profit + total_commission_paid += commission_fee + liquidation_price = 0 # 清除爆仓线 + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_long', + 'price': round(exec_price, 4), + 'amount': round(position, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + position = 0 + position_type = None + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 + if capital < min_capital_to_trade: + is_liquidated = True + capital = 0 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(exec_price, 4), + 'amount': 0, + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + + elif trade_direction == 'short': + # 只做空模式 + if signal == -1 and position == 0 and capital >= min_capital_to_trade: # 卖出开空 + logger.debug(f"[做空模式] 卖出开空: 时间={timestamp}, 价格={price}, 杠杆={leverage}x") + base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price + exec_price = base_price * (1 - slippage) + # 使用杠杆:实际持仓 = 本金 × 杠杆 / 价格 + position_pct = None + if entry_pct_cfg is not None and entry_pct_cfg > 0: + position_pct = entry_pct_cfg + if position_pct is not None and 0 < position_pct < 1: + use_capital = capital * position_pct + shares = (use_capital * leverage) / exec_price + else: + shares = (capital * leverage) / exec_price + commission_fee = shares * exec_price * commission + + position = -shares # 负数表示空头(欠股票) + entry_price = exec_price + position_type = 'short' + capital -= commission_fee # 只扣手续费 + total_commission_paid += commission_fee + + # 计算爆仓线:做空时,价格涨到 entry_price × (1 + 1/leverage) 就爆仓 + liquidation_price = entry_price * (1 + 1.0 / leverage) + logger.debug(f"做空爆仓线: {liquidation_price:.2f}") + + last_trend_add_anchor = entry_price + last_dca_add_anchor = entry_price + last_trend_reduce_anchor = entry_price + last_adverse_reduce_anchor = entry_price + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'open_short', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + elif signal == 1 and position < 0: # 买入平空 + logger.debug(f"[做空模式] 买入平空: 时间={timestamp}, 价格={price}") + base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price + exec_price = base_price * (1 + slippage) + shares = abs(position) # 需要买回的股数 + # 盈亏 = (开仓价 - 平仓价) × 股数 - 手续费 + commission_fee = shares * exec_price * commission + profit = (entry_price - exec_price) * shares - commission_fee + + # 检查是否爆仓 + if capital + profit <= 0: + logger.warning(f"平空时资金不足爆仓: 本金={capital:.2f}, 亏损={-profit:.2f}") + capital = 0 + is_liquidated = True + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(-capital, 2), + 'balance': 0 + }) + else: + capital += profit + total_commission_paid += commission_fee + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_short', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + position = 0 + position_type = None + liquidation_price = 0 # 清除爆仓线 + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 + if capital < min_capital_to_trade and not is_liquidated: + is_liquidated = True + capital = 0 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(exec_price, 4), + 'amount': 0, + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + + elif trade_direction == 'both': + # 双向模式 + if signal == 1 and position == 0 and capital >= min_capital_to_trade: # 买入开多 + logger.debug(f"[双向模式] 买入开多: 时间={timestamp}, 价格={price}, 杠杆={leverage}x") + base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price + exec_price = base_price * (1 + slippage) + # 使用杠杆:实际持仓 = 本金 × 杠杆 / 价格 + position_pct = None + if entry_pct_cfg is not None and entry_pct_cfg > 0: + position_pct = entry_pct_cfg + if position_pct is not None and 0 < position_pct < 1: + use_capital = capital * position_pct + shares = (use_capital * leverage) / exec_price + else: + shares = (capital * leverage) / exec_price + commission_fee = shares * exec_price * commission + + position = shares + entry_price = exec_price + position_type = 'long' + capital -= commission_fee # 只扣手续费 + total_commission_paid += commission_fee + + # 计算爆仓线 + liquidation_price = entry_price * (1 - 1.0 / leverage) + logger.debug(f"做多爆仓线: {liquidation_price:.2f}") + + last_trend_add_anchor = entry_price + last_dca_add_anchor = entry_price + last_trend_reduce_anchor = entry_price + last_adverse_reduce_anchor = entry_price + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'open_long', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + elif signal == -1 and position == 0 and capital >= min_capital_to_trade: # 卖出开空 + logger.debug(f"[双向模式] 卖出开空: 时间={timestamp}, 价格={price}, 杠杆={leverage}x") + base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price + exec_price = base_price * (1 - slippage) + # 使用杠杆:实际持仓 = 本金 × 杠杆 / 价格 + position_pct = None + if entry_pct_cfg is not None and entry_pct_cfg > 0: + position_pct = entry_pct_cfg + if position_pct is not None and 0 < position_pct < 1: + use_capital = capital * position_pct + shares = (use_capital * leverage) / exec_price + else: + shares = (capital * leverage) / exec_price + commission_fee = shares * exec_price * commission + + position = -shares + entry_price = exec_price + position_type = 'short' + capital -= commission_fee + total_commission_paid += commission_fee + + # 计算爆仓线 + liquidation_price = entry_price * (1 + 1.0 / leverage) + logger.debug(f"做空爆仓线: {liquidation_price:.2f}") + + last_trend_add_anchor = entry_price + last_dca_add_anchor = entry_price + last_trend_reduce_anchor = entry_price + last_adverse_reduce_anchor = entry_price + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'open_short', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + elif signal == -1 and position > 0: # 平多开空 + logger.debug(f"[双向模式] 平多开空: 时间={timestamp}, 价格={price}") + # 先平多 + base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price + exec_price = base_price * (1 - slippage) + commission_fee_close = position * exec_price * commission + profit = (exec_price - entry_price) * position - commission_fee_close + capital += profit + total_commission_paid += commission_fee_close + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_long', + 'price': round(exec_price, 4), + 'amount': round(position, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + # 若平仓后余额过低则停止(避免同K线反手开仓) + if capital < min_capital_to_trade or is_liquidated: + is_liquidated = True + capital = 0 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(exec_price, 4), + 'amount': 0, + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + continue + + # Re-open short (respects entryPct; default entryPct=100%) + position_pct = None + if entry_pct_cfg is not None and entry_pct_cfg > 0: + position_pct = entry_pct_cfg + if position_pct is not None and 0 < position_pct < 1: + use_capital = capital * position_pct + shares = (use_capital * leverage) / exec_price + else: + shares = (capital * leverage) / exec_price + commission_fee_open = shares * exec_price * commission + + position = -shares + entry_price = exec_price + position_type = 'short' + capital -= commission_fee_open + total_commission_paid += commission_fee_open + + # 计算爆仓线 + liquidation_price = entry_price * (1 + 1.0 / leverage) + logger.debug(f"做空爆仓线: {liquidation_price:.2f}") + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'open_short', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + elif signal == 1 and position < 0: # 平空开多 + logger.debug(f"[双向模式] 平空开多: 时间={timestamp}, 价格={price}") + # 先平空 + base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price + exec_price = base_price * (1 + slippage) + shares = abs(position) + commission_fee_close = shares * exec_price * commission + profit = (entry_price - exec_price) * shares - commission_fee_close + + # 检查是否爆仓 + if capital + profit <= 0: + logger.warning(f"平空时资金不足爆仓: 本金={capital:.2f}, 亏损={-profit:.2f}") + capital = 0 + is_liquidated = True + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(-capital, 2), + 'balance': 0 + }) + position = 0 + position_type = None + continue # 爆仓后不再开新仓 + + capital += profit + total_commission_paid += commission_fee_close + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_short', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + if capital < min_capital_to_trade or is_liquidated: + is_liquidated = True + capital = 0 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(exec_price, 4), + 'amount': 0, + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + continue + + # Re-open long (respects entryPct; default entryPct=100%) + position_pct = None + if entry_pct_cfg is not None and entry_pct_cfg > 0: + position_pct = entry_pct_cfg + if position_pct is not None and 0 < position_pct < 1: + use_capital = capital * position_pct + shares = (use_capital * leverage) / exec_price + else: + shares = (capital * leverage) / exec_price + commission_fee_open = shares * exec_price * commission + + position = shares + entry_price = exec_price + position_type = 'long' + capital -= commission_fee_open + total_commission_paid += commission_fee_open + + # 计算爆仓线 + liquidation_price = entry_price * (1 - 1.0 / leverage) + logger.debug(f"做多爆仓线: {liquidation_price:.2f}") + + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'open_long', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': 0, + 'balance': round(capital, 2) + }) + + # 检测持仓期间是否触及爆仓线(作为兜底保护,仅在没有主动平仓的情况下检查) + # 注意:这个检查在所有信号处理之后,确保止损/止盈优先执行 + if position != 0 and not is_liquidated: + if position_type == 'long': + # 做多爆仓:价格跌破爆仓线 + if price <= liquidation_price: + logger.warning(f"做多爆仓!开仓价={entry_price:.2f}, 当前价={price:.2f}, 爆仓线={liquidation_price:.2f}") + is_liquidated = True + capital = 0 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(liquidation_price, 4), + 'amount': round(abs(position), 4), + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + position = 0 + position_type = None + equity_curve.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'value': 0 + }) + continue + elif position_type == 'short': + # 做空爆仓:价格涨破爆仓线 + if price >= liquidation_price: + logger.warning(f"做空爆仓!开仓价={entry_price:.2f}, 当前价={price:.2f}, 爆仓线={liquidation_price:.2f}") + is_liquidated = True + capital = 0 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(liquidation_price, 4), + 'amount': round(abs(position), 4), + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + position = 0 + position_type = None + equity_curve.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'value': 0 + }) + continue + + # 记录权益 + if position_type == 'long': + # 多头权益 = 现金 + 未实现盈亏 + # 未实现盈亏 = (当前价 - 开仓价) × 股数 + unrealized_pnl = (price - entry_price) * position + total_value = capital + unrealized_pnl + elif position_type == 'short': + # 空头权益 = 现金 + 未实现盈亏 + # 未实现盈亏 = (开仓价 - 当前价) × 股数 + shares = abs(position) + unrealized_pnl = (entry_price - price) * shares + total_value = capital + unrealized_pnl + else: + total_value = capital + + # 确保权益不为负(如果已经爆仓,在前面已经处理了) + if total_value < 0: + total_value = 0 + + equity_curve.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'value': round(total_value, 2) + }) + + # 回测结束时强制平仓 + if position != 0: + timestamp = df.index[-1] + price = df.iloc[-1]['close'] + + if position > 0: # 平多 + exec_price = price * (1 - slippage) + commission_fee = position * exec_price * commission + profit = (exec_price - entry_price) * position - commission_fee + capital += profit + total_commission_paid += commission_fee + + # 记录平多交易 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_long', + 'price': round(exec_price, 4), + 'amount': round(position, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + else: # 平空 + exec_price = price * (1 + slippage) + shares = abs(position) + commission_fee = shares * exec_price * commission + profit = (entry_price - exec_price) * shares - commission_fee + + # 检查是否爆仓 + if capital + profit <= 0: + logger.warning(f"回测结束爆仓!平空亏损过大: 本金={capital:.2f}, 亏损={-profit:.2f}") + is_liquidated = True + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(-capital, 2), + 'balance': 0 + }) + capital = 0 + else: + capital += profit + total_commission_paid += commission_fee + + # 记录平空交易 + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_short', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(profit, 2), + 'balance': round(capital, 2) + }) + + # 更新权益曲线的最后一个值,包含强制平仓后的资金 + if equity_curve: + equity_curve[-1]['value'] = round(capital, 2) + + return equity_curve, trades, total_commission_paid + + def _calculate_metrics( + self, + equity_curve: List, + trades: List, + initial_capital: float, + timeframe: str, + start_date: datetime, + end_date: datetime, + total_commission: float = 0 + ) -> Dict: + """计算回测指标""" + if not equity_curve: + return {} + + final_value = equity_curve[-1]['value'] + total_return = (final_value - initial_capital) / initial_capital * 100 + + # 计算年化收益:使用简单年化而不是复利年化 + # 对于高收益率策略,复利年化会产生天文数字,不具备参考价值 + actual_days = (end_date - start_date).total_seconds() / 86400 + years = actual_days / 365.0 + + # 简单年化:年化收益率 = 总收益率 / 年数 + if years > 0: + annual_return = total_return / years + else: + annual_return = 0 + + # 计算最大回撤 + values = [e['value'] for e in equity_curve] + max_drawdown = self._calculate_max_drawdown(values) + + # 计算夏普比率 + sharpe = self._calculate_sharpe(values, timeframe) + + # 计算总盈亏:用最终权益减去初始资金(最准确) + total_profit = final_value - initial_capital + + # 计算胜率(包含所有平仓操作) + # 平仓操作:profit != 0 的交易 + closing_trades = [t for t in trades if t.get('profit', 0) != 0] + win_trades = [t for t in closing_trades if t['profit'] > 0] + loss_trades = [t for t in closing_trades if t['profit'] < 0] + total_trades = len(closing_trades) + win_rate = len(win_trades) / total_trades * 100 if total_trades > 0 else 0 + + # 计算盈亏比(Profit Factor = 总盈利 / 总亏损) + total_wins = sum(t['profit'] for t in win_trades) + total_losses = abs(sum(t['profit'] for t in loss_trades)) + profit_factor = total_wins / total_losses if total_losses > 0 else (total_wins if total_wins > 0 else 0) + + return { + 'totalReturn': round(total_return, 2), + 'annualReturn': round(annual_return, 2), + 'maxDrawdown': round(max_drawdown, 2), + 'sharpeRatio': round(sharpe, 2), + 'winRate': round(win_rate, 2), + 'profitFactor': round(profit_factor, 2), + 'totalTrades': total_trades, + 'totalProfit': round(total_profit, 2), + 'totalCommission': round(total_commission, 2) + } + + def _calculate_max_drawdown(self, values: List[float]) -> float: + """计算最大回撤""" + if not values: + return 0 + + peak = values[0] + max_dd = 0 + + for value in values: + if value > peak: + peak = value + dd = (peak - value) / peak * 100 + if dd > max_dd: + max_dd = dd + + return -max_dd + + def _calculate_sharpe(self, values: List[float], timeframe: str = '1D', risk_free_rate: float = 0.02) -> float: + """ + 计算夏普比率 + + Args: + values: 权益曲线数值列表 + timeframe: 时间周期 + risk_free_rate: 无风险收益率(年化) + """ + if len(values) < 2: + return 0 + + # 过滤掉0值(爆仓后的数据),避免除以0 + valid_values = [v for v in values if v > 0] + if len(valid_values) < 2: + return 0 + + # 根据时间周期确定年化系数 + annualization_factor = { + '1m': 252 * 24 * 60, # 分钟K:约362,880 + '5m': 252 * 24 * 12, # 5分钟K:约72,576 + '15m': 252 * 24 * 4, # 15分钟K:约24,192 + '30m': 252 * 24 * 2, # 30分钟K:约12,096 + '1H': 252 * 24, # 小时K:6,048 + '4H': 252 * 6, # 4小时K:1,512 + '1D': 252, # 日K:252 + '1W': 52 # 周K:52 + }.get(timeframe, 252) + + try: + # 计算周期收益率 + returns = np.diff(valid_values) / valid_values[:-1] + + # 过滤无效值 + returns = returns[np.isfinite(returns)] + if len(returns) == 0: + return 0 + + # 年化平均收益率 + avg_return = np.mean(returns) * annualization_factor + + # 年化标准差(波动率) + std_return = np.std(returns) * np.sqrt(annualization_factor) + + if std_return == 0 or not np.isfinite(std_return): + return 0 + + # 夏普比率 = (年化收益 - 无风险利率) / 年化波动率 + sharpe = (avg_return - risk_free_rate) / std_return + return sharpe if np.isfinite(sharpe) else 0 + except Exception as e: + logger.warning(f"夏普比率计算失败: {e}") + return 0 + + def _format_result( + self, + metrics: Dict, + equity_curve: List, + trades: List + ) -> Dict[str, Any]: + """格式化回测结果""" + # 精简权益曲线 + max_points = 500 + if len(equity_curve) > max_points: + step = len(equity_curve) // max_points + equity_curve = equity_curve[::step] + + # 清理数据中的NaN、Inf值,确保可以被JSON序列化 + def clean_value(value): + """清理数值,将NaN/Inf转换为0""" + if isinstance(value, float): + if np.isnan(value) or np.isinf(value): + return 0 + return value + + # 清理metrics + cleaned_metrics = {} + for key, value in metrics.items(): + cleaned_metrics[key] = clean_value(value) + + # 清理equity_curve + cleaned_curve = [] + for item in equity_curve: + cleaned_curve.append({ + 'time': item['time'], + 'value': clean_value(item['value']) + }) + + # 清理trades + cleaned_trades = [] + # 不截断交易记录:有多少条就返回多少条(前端可自行分页展示) + for trade in trades: + cleaned_trade = {} + for key, value in trade.items(): + cleaned_trade[key] = clean_value(value) + cleaned_trades.append(cleaned_trade) + + return { + **cleaned_metrics, + 'equityCurve': cleaned_curve, + 'trades': cleaned_trades + } + diff --git a/backend_api_python/app/services/exchange_execution.py b/backend_api_python/app/services/exchange_execution.py new file mode 100644 index 0000000..3bc6c44 --- /dev/null +++ b/backend_api_python/app/services/exchange_execution.py @@ -0,0 +1,138 @@ +""" +Exchange execution helpers (local deployment). + +This module provides helpers for resolving exchange configs and safe logging. + +Notes: +- In paper mode, the system only enqueues signals into `pending_orders`. +- Real trading execution is intentionally not implemented here. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict + +from app.utils.db import get_db_connection +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +def _safe_json_loads(value: Any, default: Any) -> Any: + if value is None: + return default + if isinstance(value, (dict, list)): + return value + if not isinstance(value, str): + return default + s = value.strip() + if not s: + return default + try: + return json.loads(s) + except Exception: + return default + + +def mask_secret(s: str, keep: int = 4) -> str: + """Return a masked representation of a secret for safe logs.""" + if not s: + return "" + s = str(s) + if len(s) <= keep * 2: + return s[: max(1, keep)] + "***" + return f"{s[:keep]}...{s[-keep:]}" + + +def safe_exchange_config_for_log(cfg: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(cfg, dict): + return {} + out = dict(cfg) + for k in ["api_key", "secret_key", "passphrase", "apiKey", "secret", "password"]: + if k in out and out.get(k): + out[k] = mask_secret(str(out.get(k))) + return out + + +def load_strategy_configs(strategy_id: int) -> Dict[str, Any]: + """Load strategy config fields needed for live execution.""" + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT id, exchange_config, trading_config, market_type, leverage, execution_mode + FROM qd_strategies_trading + WHERE id = %s + """, + (int(strategy_id),), + ) + row = cur.fetchone() or {} + cur.close() + + exchange_config = _safe_json_loads(row.get("exchange_config"), {}) + trading_config = _safe_json_loads(row.get("trading_config"), {}) + + market_type = (row.get("market_type") or exchange_config.get("market_type") or "swap").strip() + leverage = float(row.get("leverage") or trading_config.get("leverage") or exchange_config.get("leverage") or 1.0) + execution_mode = (row.get("execution_mode") or "signal").strip().lower() + + return { + "strategy_id": int(strategy_id), + "exchange_config": exchange_config if isinstance(exchange_config, dict) else {}, + "trading_config": trading_config if isinstance(trading_config, dict) else {}, + "market_type": market_type, + "leverage": leverage, + "execution_mode": execution_mode, + } + + +def _load_credential_config(credential_id: int, user_id: int = 1) -> Dict[str, Any]: + """Load credential JSON from qd_exchange_credentials (plaintext in local mode).""" + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT encrypted_config + FROM qd_exchange_credentials + WHERE id = %s AND user_id = %s + """, + (int(credential_id), int(user_id)), + ) + row = cur.fetchone() or {} + cur.close() + return _safe_json_loads(row.get("encrypted_config"), {}) or {} + + +def resolve_exchange_config(exchange_config: Dict[str, Any], user_id: int = 1) -> Dict[str, Any]: + """ + Resolve exchange config. + + Supports: + - direct inline config: {exchange_id, api_key, secret_key, passphrase?} + - credential reference: {credential_id: 123, ...overrides} + """ + if not isinstance(exchange_config, dict): + return {} + + merged: Dict[str, Any] = {} + credential_id = exchange_config.get("credential_id") or exchange_config.get("credentials_id") + try: + if credential_id: + base = _load_credential_config(int(credential_id), user_id=user_id) + if isinstance(base, dict): + merged.update(base) + except Exception as e: + logger.warning(f"Failed to load credential_id={credential_id}: {e}") + + # Overlay strategy-level settings (non-empty wins) + for k, v in exchange_config.items(): + if v is None: + continue + if isinstance(v, str) and not v.strip(): + continue + merged[k] = v + + return merged + + diff --git a/backend_api_python/app/services/kline.py b/backend_api_python/app/services/kline.py new file mode 100644 index 0000000..50ab567 --- /dev/null +++ b/backend_api_python/app/services/kline.py @@ -0,0 +1,73 @@ +""" +K线数据服务 +""" +from typing import Dict, List, Any, Optional + +from app.data_sources import DataSourceFactory +from app.utils.cache import CacheManager +from app.utils.logger import get_logger +from app.config import CacheConfig + +logger = get_logger(__name__) + + +class KlineService: + """K线数据服务""" + + def __init__(self): + self.cache = CacheManager() + self.cache_ttl = CacheConfig.KLINE_CACHE_TTL + + def get_kline( + self, + market: str, + symbol: str, + timeframe: str, + limit: int = 300, + before_time: Optional[int] = None + ) -> List[Dict[str, Any]]: + """ + 获取K线数据 + + Args: + market: 市场类型 (Crypto, USStock, AShare, HShare, Forex, Futures) + symbol: 交易对/股票代码 + timeframe: 时间周期 + limit: 数据条数 + before_time: 获取此时间之前的数据 + + Returns: + K线数据列表 + """ + # 构建缓存键(历史数据不缓存) + if not before_time: + cache_key = f"kline:{market}:{symbol}:{timeframe}:{limit}" + cached = self.cache.get(cache_key) + if cached: + # logger.info(f"命中缓存: {cache_key}") + return cached + + # 获取数据 + klines = DataSourceFactory.get_kline( + market=market, + symbol=symbol, + timeframe=timeframe, + limit=limit, + before_time=before_time + ) + + # 设置缓存(仅最新数据) + if klines and not before_time: + ttl = self.cache_ttl.get(timeframe, 300) + self.cache.set(cache_key, klines, ttl) + # logger.info(f"缓存设置: {cache_key}, TTL: {ttl}s") + + return klines + + def get_latest_price(self, market: str, symbol: str) -> Optional[Dict[str, Any]]: + """获取最新价格""" + klines = self.get_kline(market, symbol, '1m', 1) + if klines: + return klines[-1] + return None + diff --git a/backend_api_python/app/services/live_trading/__init__.py b/backend_api_python/app/services/live_trading/__init__.py new file mode 100644 index 0000000..7a8ed77 --- /dev/null +++ b/backend_api_python/app/services/live_trading/__init__.py @@ -0,0 +1,7 @@ +""" +Live trading (direct exchange REST) clients. + +This package intentionally does NOT use ccxt. +""" + + diff --git a/backend_api_python/app/services/live_trading/base.py b/backend_api_python/app/services/live_trading/base.py new file mode 100644 index 0000000..decf898 --- /dev/null +++ b/backend_api_python/app/services/live_trading/base.py @@ -0,0 +1,79 @@ +""" +Base REST client helpers for direct exchange connections. + +Notes: +- Keep this minimal and dependency-light (requests only). +- All secrets must be excluded from logs. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import dataclass +from typing import Any, Dict, Optional, Tuple + +import requests + + +@dataclass +class LiveOrderResult: + exchange_id: str + exchange_order_id: str + filled: float + avg_price: float + raw: Dict[str, Any] + + +class LiveTradingError(Exception): + pass + + +class BaseRestClient: + def __init__(self, base_url: str, timeout_sec: float = 15.0): + self.base_url = (base_url or "").rstrip("/") + self.timeout_sec = float(timeout_sec) + + def _url(self, path: str) -> str: + p = str(path or "") + if not p.startswith("/"): + p = "/" + p + return f"{self.base_url}{p}" + + def _request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + data: Optional[Any] = None, + ) -> Tuple[int, Dict[str, Any], str]: + url = self._url(path) + resp = requests.request( + method=str(method or "GET").upper(), + url=url, + params=params or None, + json=json_body if json_body is not None else None, + data=data, + headers=headers or None, + timeout=self.timeout_sec, + ) + text = resp.text or "" + parsed: Dict[str, Any] = {} + try: + parsed = resp.json() if text else {} + except Exception: + parsed = {"raw_text": text[:2000]} + return int(resp.status_code), parsed, text + + @staticmethod + def _now_ms() -> int: + return int(time.time() * 1000) + + @staticmethod + def _json_dumps(obj: Any) -> str: + return json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + + diff --git a/backend_api_python/app/services/live_trading/binance.py b/backend_api_python/app/services/live_trading/binance.py new file mode 100644 index 0000000..a70ca05 --- /dev/null +++ b/backend_api_python/app/services/live_trading/binance.py @@ -0,0 +1,666 @@ +""" +Binance USDT-M Futures (direct REST) client. + +API docs (reference): +- Signed endpoints use HMAC SHA256 over query string. +""" + +from __future__ import annotations + +import hmac +import hashlib +import time +from decimal import Decimal, ROUND_DOWN +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlencode + +from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError +from app.services.live_trading.symbols import to_binance_futures_symbol + + +class BinanceFuturesClient(BaseRestClient): + def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://fapi.binance.com", timeout_sec: float = 15.0): + super().__init__(base_url=base_url, timeout_sec=timeout_sec) + self.api_key = (api_key or "").strip() + self.secret_key = (secret_key or "").strip() + if not self.api_key or not self.secret_key: + raise LiveTradingError("Missing Binance api_key/secret_key") + + # Best-effort cache for public symbol filters used to normalize quantities. + # Key: symbol -> (fetched_at_ts, filters_dict) + self._sym_filter_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {} + self._sym_filter_cache_ttl_sec = 300.0 + + # Best-effort cache for account position mode (Hedge vs One-way). + # Binance endpoint: GET /fapi/v1/positionSide/dual -> {"dualSidePosition": true/false} + self._dual_side_cache: Optional[Tuple[float, bool]] = None + self._dual_side_cache_ttl_sec = 60.0 + + @staticmethod + def _to_dec(x: Any) -> Decimal: + try: + return Decimal(str(x)) + except Exception: + return Decimal("0") + + @staticmethod + def _dec_str(d: Decimal) -> str: + try: + return format(d, "f") + except Exception: + return str(d) + + @staticmethod + def _floor_to_step(value: Decimal, step: Decimal) -> Decimal: + if step is None: + return value + if value <= 0: + return Decimal("0") + try: + st = Decimal(step) + except Exception: + st = Decimal("0") + if st <= 0: + return value + try: + n = (value / st).to_integral_value(rounding=ROUND_DOWN) + return n * st + except Exception: + return Decimal("0") + + def _sign(self, query_string: str) -> str: + sig = hmac.new(self.secret_key.encode("utf-8"), query_string.encode("utf-8"), hashlib.sha256).hexdigest() + return sig + + def _signed_headers(self) -> Dict[str, str]: + return {"X-MBX-APIKEY": self.api_key} + + def _signed_request(self, method: str, path: str, *, params: Dict[str, Any]) -> Dict[str, Any]: + p = dict(params or {}) + # Use server-accepted timestamp in ms. + p["timestamp"] = int(time.time() * 1000) + qs = urlencode(p, doseq=True) + p["signature"] = self._sign(qs) + code, data, text = self._request(method, path, params=p, headers=self._signed_headers()) + if code >= 400: + raise LiveTradingError(f"Binance HTTP {code}: {text[:500]}") + if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0: + raise LiveTradingError(f"Binance error: {data}") + return data if isinstance(data, dict) else {"raw": data} + + def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None) + if code >= 400: + raise LiveTradingError(f"Binance HTTP {code}: {text[:500]}") + if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0: + raise LiveTradingError(f"Binance error: {data}") + return data if isinstance(data, dict) else {"raw": data} + + def get_mark_price(self, *, symbol: str) -> float: + """ + Best-effort mark price for MIN_NOTIONAL validation. + + Endpoint: GET /fapi/v1/premiumIndex?symbol=... + """ + sym = to_binance_futures_symbol(symbol) + if not sym: + return 0.0 + try: + data = self._public_request("GET", "/fapi/v1/premiumIndex", params={"symbol": sym}) + except Exception: + return 0.0 + try: + return float(data.get("markPrice") or 0.0) + except Exception: + return 0.0 + + def get_symbol_filters(self, *, symbol: str) -> Dict[str, Any]: + """ + Get futures symbol filters from exchangeInfo (best-effort). + + Endpoint: GET /fapi/v1/exchangeInfo?symbol=... + """ + sym = to_binance_futures_symbol(symbol) + if not sym: + return {} + now = time.time() + cached = self._sym_filter_cache.get(sym) + if cached: + ts, obj = cached + if obj and (now - float(ts or 0.0)) <= float(self._sym_filter_cache_ttl_sec or 300.0): + return obj + + raw = self._public_request("GET", "/fapi/v1/exchangeInfo", params={"symbol": sym}) + symbols = raw.get("symbols") if isinstance(raw, dict) else None + # Important: Binance may still return the full symbols list even when `symbol=...` is provided. + # Never assume `symbols[0]` matches the requested symbol. + first: Dict[str, Any] = {} + if isinstance(symbols, list) and symbols: + picked = None + try: + picked = next((s for s in symbols if isinstance(s, dict) and str(s.get("symbol") or "") == sym), None) + except Exception: + picked = None + first = picked if isinstance(picked, dict) else (symbols[0] if isinstance(symbols[0], dict) else {}) + filters = first.get("filters") if isinstance(first, dict) else None + fdict: Dict[str, Any] = {} + if isinstance(filters, list): + for f in filters: + if isinstance(f, dict) and f.get("filterType"): + fdict[str(f.get("filterType"))] = f + # Also keep precision metadata when available (used to avoid -1111). + try: + qty_prec = first.get("quantityPrecision") if isinstance(first, dict) else None + price_prec = first.get("pricePrecision") if isinstance(first, dict) else None + meta = { + "symbol": str(first.get("symbol") or "") if isinstance(first, dict) else "", + "contractType": str(first.get("contractType") or "") if isinstance(first, dict) else "", + "quantityPrecision": int(qty_prec) if qty_prec is not None else None, + "pricePrecision": int(price_prec) if price_prec is not None else None, + } + fdict["_meta"] = meta + except Exception: + pass + if fdict: + self._sym_filter_cache[sym] = (now, fdict) + return fdict + + @staticmethod + def _floor_to_precision(value: Decimal, precision: Optional[int]) -> Decimal: + try: + if precision is None: + return value + p = int(precision) + except Exception: + return value + if p < 0 or p > 18: + return value + try: + q = Decimal("1").scaleb(-p) # 1e-precision + return value.quantize(q, rounding=ROUND_DOWN) + except Exception: + return value + + def _normalize_price(self, *, symbol: str, price: float) -> Decimal: + """ + Normalize futures limit price using PRICE_FILTER tickSize (best-effort). + + Binance rejects prices/quantities whose precision exceeds allowed decimals (-1111), + so we must quantize to tickSize and send as string. + """ + px = self._to_dec(price) + if px <= 0: + return Decimal("0") + fdict: Dict[str, Any] = {} + try: + fdict = self.get_symbol_filters(symbol=symbol) or {} + except Exception: + fdict = {} + + filt = fdict.get("PRICE_FILTER") or {} + tick = self._to_dec((filt or {}).get("tickSize") or "0") + min_px = self._to_dec((filt or {}).get("minPrice") or "0") + + if tick > 0: + px = self._floor_to_step(px, tick) + # Enforce price precision cap (some symbols reject more decimals even if tick looks permissive). + try: + meta = fdict.get("_meta") or {} + px = self._floor_to_precision(px, (meta.get("pricePrecision") if isinstance(meta, dict) else None)) + except Exception: + pass + if min_px > 0 and px < min_px: + return Decimal("0") + return px + + def _normalize_quantity(self, *, symbol: str, quantity: float, for_market: bool) -> Decimal: + """ + Normalize futures order quantity using LOT_SIZE / MARKET_LOT_SIZE filters (best-effort). + """ + q = self._to_dec(quantity) + if q <= 0: + return Decimal("0") + fdict: Dict[str, Any] = {} + try: + fdict = self.get_symbol_filters(symbol=symbol) or {} + except Exception: + fdict = {} + + key = "MARKET_LOT_SIZE" if for_market else "LOT_SIZE" + filt = fdict.get(key) or fdict.get("LOT_SIZE") or {} + + step = self._to_dec((filt or {}).get("stepSize") or "0") + min_qty = self._to_dec((filt or {}).get("minQty") or "0") + + if step > 0: + q = self._floor_to_step(q, step) + # Enforce quantity precision cap (Binance may reject quantities with too many decimals: -1111). + try: + meta = fdict.get("_meta") or {} + q = self._floor_to_precision(q, (meta.get("quantityPrecision") if isinstance(meta, dict) else None)) + except Exception: + pass + if min_qty > 0 and q < min_qty: + return Decimal("0") + return q + + def ping(self) -> bool: + code, data, _ = self._request("GET", "/fapi/v1/time") + return code == 200 and isinstance(data, dict) + + def get_account(self) -> Dict[str, Any]: + """ + Private endpoint to validate credentials. + """ + return self._signed_request("GET", "/fapi/v2/account", params={}) + + def get_dual_side_position(self) -> Optional[bool]: + """ + Best-effort read of position mode: + - True => Hedge Mode (dual-side position enabled): orders must specify positionSide=LONG/SHORT + - False => One-way Mode: orders should NOT specify LONG/SHORT + + Endpoint: GET /fapi/v1/positionSide/dual + """ + now = time.time() + cached = self._dual_side_cache + if cached: + ts, val = cached + if (now - float(ts or 0.0)) <= float(self._dual_side_cache_ttl_sec or 60.0): + return bool(val) + try: + data = self._signed_request("GET", "/fapi/v1/positionSide/dual", params={}) + v = data.get("dualSidePosition") if isinstance(data, dict) else None + if v is None: + return None + val = bool(v) + self._dual_side_cache = (now, val) + return val + except Exception: + return None + + @staticmethod + def _is_err_code(err: Exception, code: int) -> bool: + try: + s = str(err or "") + except Exception: + s = "" + return f'\"code\":{int(code)}' in s or f"'code': {int(code)}" in s or f"'code':{int(code)}" in s + + @staticmethod + def _normalize_position_side(pos_side: Optional[str]) -> str: + p = (pos_side or "").strip().lower() + if p in ("long", "l"): + return "LONG" + if p in ("short", "s"): + return "SHORT" + if p in ("both", "net"): + return "BOTH" + return "" + + @staticmethod + def _infer_position_side(*, side: str, reduce_only: bool) -> str: + sd = (side or "").strip().upper() + ro = bool(reduce_only) + # Open: + # - BUY => LONG + # - SELL => SHORT + # Reduce/Close: + # - SELL reduceOnly => close LONG + # - BUY reduceOnly => close SHORT + if ro: + return "LONG" if sd == "SELL" else "SHORT" + return "LONG" if sd == "BUY" else "SHORT" + + def get_order( + self, + *, + symbol: str, + order_id: str = "", + client_order_id: str = "", + ) -> Dict[str, Any]: + """ + Query order status/details. + + Endpoint: GET /fapi/v1/order + """ + sym = to_binance_futures_symbol(symbol) + params: Dict[str, Any] = {"symbol": sym} + if order_id: + params["orderId"] = str(order_id) + elif client_order_id: + params["origClientOrderId"] = str(client_order_id) + else: + raise LiveTradingError("Binance get_order requires order_id or client_order_id") + return self._signed_request("GET", "/fapi/v1/order", params=params) + + def wait_for_fill( + self, + *, + symbol: str, + order_id: str = "", + client_order_id: str = "", + max_wait_sec: float = 3.0, + poll_interval_sec: float = 0.5, + ) -> Dict[str, Any]: + """ + Poll order detail to obtain (best-effort) executed quantity and average price. + + Returns: + { + "filled": float, + "avg_price": float, + "status": str, + "order": {...} + } + """ + end_ts = time.time() + float(max_wait_sec or 0.0) + last: Dict[str, Any] = {} + + while True: + try: + last = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "")) + except Exception: + last = last or {} + + status = str(last.get("status") or "") + try: + filled = float(last.get("executedQty") or 0.0) + except Exception: + filled = 0.0 + + # Futures order endpoint usually provides avgPrice; fall back to price/cumQuote. + avg_price = 0.0 + try: + if last.get("avgPrice") is not None and str(last.get("avgPrice")).strip() != "": + avg_price = float(last.get("avgPrice") or 0.0) + except Exception: + avg_price = 0.0 + if avg_price <= 0 and filled > 0: + try: + cum_quote = float(last.get("cumQuote") or 0.0) + if cum_quote > 0: + avg_price = cum_quote / filled + except Exception: + pass + if avg_price <= 0: + try: + avg_price = float(last.get("price") or 0.0) + except Exception: + avg_price = 0.0 + + if filled > 0 and avg_price > 0: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + + if status in ("FILLED", "CANCELED", "EXPIRED", "REJECTED"): + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + + if time.time() >= end_ts: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + time.sleep(float(poll_interval_sec or 0.5)) + + def place_market_order( + self, + *, + symbol: str, + side: str, + quantity: float, + reduce_only: bool = False, + position_side: Optional[str] = None, + client_order_id: Optional[str] = None, + ) -> LiveOrderResult: + sym = to_binance_futures_symbol(symbol) + sd = (side or "").upper() + if sd not in ("BUY", "SELL"): + raise LiveTradingError(f"Invalid side: {side}") + q_req = float(quantity or 0.0) + q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=True) + if float(q_dec or 0) <= 0: + raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}") + + # Best-effort MIN_NOTIONAL validation (common reason for "open still fails" with small qty). + # Use markPrice as an approximation for MARKET order notional. + min_notional = Decimal("0") + mark_price = 0.0 + notional = Decimal("0") + try: + fdict = self.get_symbol_filters(symbol=symbol) or {} + mn = (fdict.get("MIN_NOTIONAL") or {}).get("notional") + min_notional = self._to_dec(mn or "0") + if min_notional > 0: + mark_price = float(self.get_mark_price(symbol=symbol) or 0.0) + if mark_price > 0: + notional = q_dec * self._to_dec(mark_price) + if notional < min_notional: + raise LiveTradingError( + "Order notional is below MIN_NOTIONAL. " + f"symbol={sym} side={sd} qty={self._dec_str(q_dec)} " + f"markPrice={mark_price} notional={self._dec_str(notional)} " + f"minNotional={self._dec_str(min_notional)}" + ) + except LiveTradingError: + raise + except Exception: + # Never block order placement due to a best-effort validation failure. + pass + + params: Dict[str, Any] = { + "symbol": sym, + "side": sd, + "type": "MARKET", + "quantity": self._dec_str(q_dec), + } + if reduce_only: + params["reduceOnly"] = "true" + if client_order_id: + params["newClientOrderId"] = str(client_order_id) + + # Hedge mode requires explicit positionSide (LONG/SHORT). One-way mode should not use LONG/SHORT. + dual_side = self.get_dual_side_position() + pos_norm = self._normalize_position_side(position_side) + if dual_side is True: + params["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only)) + elif dual_side is False: + # Keep default (BOTH) by omitting positionSide. + params.pop("positionSide", None) + else: + # Unknown mode: try without positionSide first; we may retry on -4061. + params.pop("positionSide", None) + + try: + raw = self._signed_request("POST", "/fapi/v1/order", params=params) + except LiveTradingError as e: + # Retry once if position mode mismatch (-4061). + if self._is_err_code(e, -4061): + params2 = dict(params) + if params2.get("positionSide"): + # Likely one-way mode but we sent LONG/SHORT + params2.pop("positionSide", None) + try: + raw = self._signed_request("POST", "/fapi/v1/order", params=params2) + # Cache for future calls. + self._dual_side_cache = (time.time(), False) + return LiveOrderResult( + exchange_id="binance", + exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""), + filled=float(raw.get("executedQty") or 0.0), + avg_price=float(raw.get("avgPrice") or raw.get("price") or 0.0), + raw=raw, + ) + except Exception: + pass + else: + # Likely hedge mode; retry with inferred positionSide. + params2["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only)) + try: + raw = self._signed_request("POST", "/fapi/v1/order", params=params2) + self._dual_side_cache = (time.time(), True) + return LiveOrderResult( + exchange_id="binance", + exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""), + filled=float(raw.get("executedQty") or 0.0), + avg_price=float(raw.get("avgPrice") or raw.get("price") or 0.0), + raw=raw, + ) + except Exception: + pass + + # Attach normalized params for easier debugging of precision issues (-1111). + # Also attach best-effort public filters and minNotional diagnostics. + step = "n/a" + qty_prec = "n/a" + min_not = "n/a" + filt_symbol = "n/a" + contract_type = "n/a" + dual_mode = "n/a" + pos_side_used = "n/a" + try: + fdict = self.get_symbol_filters(symbol=symbol) or {} + lot = fdict.get("MARKET_LOT_SIZE") or fdict.get("LOT_SIZE") or {} + step = str(lot.get("stepSize") or "n/a") + meta = fdict.get("_meta") or {} + if isinstance(meta, dict) and meta.get("quantityPrecision") is not None: + qty_prec = str(meta.get("quantityPrecision")) + if isinstance(meta, dict) and meta.get("symbol"): + filt_symbol = str(meta.get("symbol")) + if isinstance(meta, dict) and meta.get("contractType"): + contract_type = str(meta.get("contractType")) + mn = fdict.get("MIN_NOTIONAL") or {} + min_not = str(mn.get("notional") or "n/a") + dm = self.get_dual_side_position() + dual_mode = "true" if dm is True else ("false" if dm is False else "unknown") + pos_side_used = str((params or {}).get("positionSide") or "n/a") + except Exception: + pass + raise LiveTradingError( + f"{e} | debug: symbol={sym} side={sd} " + f"qty_req={q_req} qty_norm={self._dec_str(q_dec)} " + f"base_url={self.base_url} filtersSymbol={filt_symbol} contractType={contract_type} " + f"stepSize={step} quantityPrecision={qty_prec} minNotional={min_not} " + f"dualSidePosition={dual_mode} positionSide={pos_side_used} " + f"markPrice={mark_price} notional={self._dec_str(notional)}" + ) + + # Best-effort parse fill info. + exchange_order_id = str(raw.get("orderId") or raw.get("clientOrderId") or "") + filled = float(raw.get("executedQty") or 0.0) + avg_price = float(raw.get("avgPrice") or raw.get("price") or 0.0) + + return LiveOrderResult( + exchange_id="binance", + exchange_order_id=exchange_order_id, + filled=filled, + avg_price=avg_price, + raw=raw, + ) + + def place_limit_order( + self, + *, + symbol: str, + side: str, + quantity: float, + price: float, + reduce_only: bool = False, + position_side: Optional[str] = None, + client_order_id: Optional[str] = None, + ) -> LiveOrderResult: + sym = to_binance_futures_symbol(symbol) + sd = (side or "").upper() + if sd not in ("BUY", "SELL"): + raise LiveTradingError(f"Invalid side: {side}") + q_req = float(quantity or 0.0) + px = float(price or 0.0) + if q_req <= 0 or px <= 0: + raise LiveTradingError("Invalid quantity/price") + q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=False) + if float(q_dec or 0) <= 0: + raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}") + px_dec = self._normalize_price(symbol=symbol, price=px) + if float(px_dec or 0) <= 0: + raise LiveTradingError(f"Invalid price (bad tick/minPrice): requested={px}") + + params: Dict[str, Any] = { + "symbol": sym, + "side": sd, + "type": "LIMIT", + "timeInForce": "GTC", + "quantity": self._dec_str(q_dec), + "price": self._dec_str(px_dec), + } + if reduce_only: + params["reduceOnly"] = "true" + if client_order_id: + params["newClientOrderId"] = str(client_order_id) + + dual_side = self.get_dual_side_position() + pos_norm = self._normalize_position_side(position_side) + if dual_side is True: + params["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only)) + elif dual_side is False: + params.pop("positionSide", None) + else: + params.pop("positionSide", None) + try: + raw = self._signed_request("POST", "/fapi/v1/order", params=params) + except LiveTradingError as e: + if self._is_err_code(e, -4061): + params2 = dict(params) + if params2.get("positionSide"): + params2.pop("positionSide", None) + try: + raw = self._signed_request("POST", "/fapi/v1/order", params=params2) + self._dual_side_cache = (time.time(), False) + return LiveOrderResult( + exchange_id="binance", + exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""), + filled=float(raw.get("executedQty") or 0.0), + avg_price=float(raw.get("avgPrice") or raw.get("price") or 0.0), + raw=raw, + ) + except Exception: + pass + else: + params2["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only)) + try: + raw = self._signed_request("POST", "/fapi/v1/order", params=params2) + self._dual_side_cache = (time.time(), True) + return LiveOrderResult( + exchange_id="binance", + exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""), + filled=float(raw.get("executedQty") or 0.0), + avg_price=float(raw.get("avgPrice") or raw.get("price") or 0.0), + raw=raw, + ) + except Exception: + pass + raise LiveTradingError( + f"{e} | debug: symbol={sym} side={sd} " + f"qty_req={q_req} qty_norm={self._dec_str(q_dec)} " + f"price_req={px} price_norm={self._dec_str(px_dec)}" + ) + exchange_order_id = str(raw.get("orderId") or raw.get("clientOrderId") or "") + filled = float(raw.get("executedQty") or 0.0) + avg_price = float(raw.get("avgPrice") or raw.get("price") or 0.0) + return LiveOrderResult(exchange_id="binance", exchange_order_id=exchange_order_id, filled=filled, avg_price=avg_price, raw=raw) + + def cancel_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]: + sym = to_binance_futures_symbol(symbol) + params: Dict[str, Any] = {"symbol": sym} + if order_id: + params["orderId"] = str(order_id) + elif client_order_id: + params["origClientOrderId"] = str(client_order_id) + else: + raise LiveTradingError("Binance cancel_order requires order_id or client_order_id") + return self._signed_request("DELETE", "/fapi/v1/order", params=params) + + def get_positions(self) -> Any: + """ + Return all futures positions (position risk endpoint). + + Endpoint: GET /fapi/v2/positionRisk + """ + return self._signed_request("GET", "/fapi/v2/positionRisk", params={}) + + diff --git a/backend_api_python/app/services/live_trading/binance_spot.py b/backend_api_python/app/services/live_trading/binance_spot.py new file mode 100644 index 0000000..b132da7 --- /dev/null +++ b/backend_api_python/app/services/live_trading/binance_spot.py @@ -0,0 +1,385 @@ +""" +Binance Spot (direct REST) client. +""" + +from __future__ import annotations + +import hmac +import hashlib +import time +from decimal import Decimal, ROUND_DOWN +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlencode + +from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError +from app.services.live_trading.symbols import to_binance_futures_symbol + + +class BinanceSpotClient(BaseRestClient): + def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.binance.com", timeout_sec: float = 15.0): + super().__init__(base_url=base_url, timeout_sec=timeout_sec) + self.api_key = (api_key or "").strip() + self.secret_key = (secret_key or "").strip() + if not self.api_key or not self.secret_key: + raise LiveTradingError("Missing Binance api_key/secret_key") + + # Best-effort cache for public symbol filters used to normalize quantities. + self._sym_filter_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {} + self._sym_filter_cache_ttl_sec = 300.0 + + @staticmethod + def _to_dec(x: Any) -> Decimal: + try: + return Decimal(str(x)) + except Exception: + return Decimal("0") + + @staticmethod + def _dec_str(d: Decimal) -> str: + try: + return format(d, "f") + except Exception: + return str(d) + + @staticmethod + def _floor_to_step(value: Decimal, step: Decimal) -> Decimal: + if step is None: + return value + if value <= 0: + return Decimal("0") + try: + st = Decimal(step) + except Exception: + st = Decimal("0") + if st <= 0: + return value + try: + n = (value / st).to_integral_value(rounding=ROUND_DOWN) + return n * st + except Exception: + return Decimal("0") + + def _sign(self, query_string: str) -> str: + return hmac.new(self.secret_key.encode("utf-8"), query_string.encode("utf-8"), hashlib.sha256).hexdigest() + + def _signed_headers(self) -> Dict[str, str]: + return {"X-MBX-APIKEY": self.api_key} + + def _signed_request(self, method: str, path: str, *, params: Dict[str, Any]) -> Dict[str, Any]: + p = dict(params or {}) + p["timestamp"] = int(time.time() * 1000) + qs = urlencode(p, doseq=True) + p["signature"] = self._sign(qs) + code, data, text = self._request(method, path, params=p, headers=self._signed_headers()) + if code >= 400: + raise LiveTradingError(f"BinanceSpot HTTP {code}: {text[:500]}") + if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0: + raise LiveTradingError(f"BinanceSpot error: {data}") + return data if isinstance(data, dict) else {"raw": data} + + def ping(self) -> bool: + """ + Public connectivity check. + + Endpoint: GET /api/v3/time + """ + code, data, _ = self._request("GET", "/api/v3/time") + return code == 200 and isinstance(data, dict) + + def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None) + if code >= 400: + raise LiveTradingError(f"BinanceSpot HTTP {code}: {text[:500]}") + if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0: + raise LiveTradingError(f"BinanceSpot error: {data}") + return data if isinstance(data, dict) else {"raw": data} + + def get_symbol_filters(self, *, symbol: str) -> Dict[str, Any]: + """ + Get spot symbol filters from exchangeInfo (best-effort). + + Endpoint: GET /api/v3/exchangeInfo?symbol=... + """ + sym = to_binance_futures_symbol(symbol) + if not sym: + return {} + now = time.time() + cached = self._sym_filter_cache.get(sym) + if cached: + ts, obj = cached + if obj and (now - float(ts or 0.0)) <= float(self._sym_filter_cache_ttl_sec or 300.0): + return obj + + raw = self._public_request("GET", "/api/v3/exchangeInfo", params={"symbol": sym}) + symbols = raw.get("symbols") if isinstance(raw, dict) else None + # Defensive: some gateways/proxies may strip query params; Binance may then return full list. + first: Dict[str, Any] = {} + if isinstance(symbols, list) and symbols: + picked = None + try: + picked = next((s for s in symbols if isinstance(s, dict) and str(s.get("symbol") or "") == sym), None) + except Exception: + picked = None + first = picked if isinstance(picked, dict) else (symbols[0] if isinstance(symbols[0], dict) else {}) + filters = first.get("filters") if isinstance(first, dict) else None + fdict: Dict[str, Any] = {} + if isinstance(filters, list): + for f in filters: + if isinstance(f, dict) and f.get("filterType"): + fdict[str(f.get("filterType"))] = f + # Also keep precision metadata when available (used to avoid -1111). + try: + qty_prec = first.get("baseAssetPrecision") if isinstance(first, dict) else None + # For spot, price precision is typically quotePrecision/quoteAssetPrecision. + price_prec = None + if isinstance(first, dict): + price_prec = first.get("quotePrecision") + if price_prec is None: + price_prec = first.get("quoteAssetPrecision") + meta = { + "symbol": str(first.get("symbol") or "") if isinstance(first, dict) else "", + "quantityPrecision": int(qty_prec) if qty_prec is not None else None, + "pricePrecision": int(price_prec) if price_prec is not None else None, + } + fdict["_meta"] = meta + except Exception: + pass + if fdict: + self._sym_filter_cache[sym] = (now, fdict) + return fdict + + @staticmethod + def _floor_to_precision(value: Decimal, precision: Optional[int]) -> Decimal: + try: + if precision is None: + return value + p = int(precision) + except Exception: + return value + if p < 0 or p > 18: + return value + try: + q = Decimal("1").scaleb(-p) + return value.quantize(q, rounding=ROUND_DOWN) + except Exception: + return value + + def _normalize_price(self, *, symbol: str, price: float) -> Decimal: + """ + Normalize spot limit price using PRICE_FILTER tickSize (best-effort). + """ + px = self._to_dec(price) + if px <= 0: + return Decimal("0") + fdict: Dict[str, Any] = {} + try: + fdict = self.get_symbol_filters(symbol=symbol) or {} + except Exception: + fdict = {} + + filt = fdict.get("PRICE_FILTER") or {} + tick = self._to_dec((filt or {}).get("tickSize") or "0") + min_px = self._to_dec((filt or {}).get("minPrice") or "0") + + if tick > 0: + px = self._floor_to_step(px, tick) + # Enforce price precision cap (some symbols reject more decimals even if tick looks permissive). + try: + meta = fdict.get("_meta") or {} + px = self._floor_to_precision(px, (meta.get("pricePrecision") if isinstance(meta, dict) else None)) + except Exception: + pass + if min_px > 0 and px < min_px: + return Decimal("0") + return px + + def _normalize_quantity(self, *, symbol: str, quantity: float, for_market: bool) -> Decimal: + """ + Normalize spot order quantity using LOT_SIZE / MARKET_LOT_SIZE filters (best-effort). + """ + q = self._to_dec(quantity) + if q <= 0: + return Decimal("0") + fdict: Dict[str, Any] = {} + try: + fdict = self.get_symbol_filters(symbol=symbol) or {} + except Exception: + fdict = {} + + key = "MARKET_LOT_SIZE" if for_market else "LOT_SIZE" + filt = fdict.get(key) or fdict.get("LOT_SIZE") or {} + + step = self._to_dec((filt or {}).get("stepSize") or "0") + min_qty = self._to_dec((filt or {}).get("minQty") or "0") + + if step > 0: + q = self._floor_to_step(q, step) + # Enforce quantity precision cap (Binance may reject quantities with too many decimals: -1111). + try: + meta = fdict.get("_meta") or {} + q = self._floor_to_precision(q, (meta.get("quantityPrecision") if isinstance(meta, dict) else None)) + except Exception: + pass + if min_qty > 0 and q < min_qty: + return Decimal("0") + return q + + def place_limit_order( + self, + *, + symbol: str, + side: str, + quantity: float, + price: float, + client_order_id: Optional[str] = None, + ) -> LiveOrderResult: + sym = to_binance_futures_symbol(symbol) + sd = (side or "").upper() + if sd not in ("BUY", "SELL"): + raise LiveTradingError(f"Invalid side: {side}") + q_req = float(quantity or 0.0) + px = float(price or 0.0) + if q_req <= 0 or px <= 0: + raise LiveTradingError("Invalid quantity/price") + q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=False) + if float(q_dec or 0) <= 0: + raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}") + px_dec = self._normalize_price(symbol=symbol, price=px) + if float(px_dec or 0) <= 0: + raise LiveTradingError(f"Invalid price (bad tick/minPrice): requested={px}") + + params: Dict[str, Any] = { + "symbol": sym, + "side": sd, + "type": "LIMIT", + "timeInForce": "GTC", + "quantity": self._dec_str(q_dec), + "price": self._dec_str(px_dec), + } + if client_order_id: + params["newClientOrderId"] = str(client_order_id) + try: + raw = self._signed_request("POST", "/api/v3/order", params=params) + except LiveTradingError as e: + raise LiveTradingError( + f"{e} | debug: symbol={sym} side={sd} " + f"qty_req={q_req} qty_norm={self._dec_str(q_dec)} " + f"price_req={px} price_norm={self._dec_str(px_dec)}" + ) + return LiveOrderResult( + exchange_id="binance", + exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""), + filled=float(raw.get("executedQty") or 0.0), + avg_price=float(raw.get("cummulativeQuoteQty") or 0.0) / float(raw.get("executedQty") or 1.0) if float(raw.get("executedQty") or 0.0) > 0 else 0.0, + raw=raw, + ) + + def place_market_order( + self, + *, + symbol: str, + side: str, + quantity: float, + client_order_id: Optional[str] = None, + ) -> LiveOrderResult: + sym = to_binance_futures_symbol(symbol) + sd = (side or "").upper() + if sd not in ("BUY", "SELL"): + raise LiveTradingError(f"Invalid side: {side}") + q_req = float(quantity or 0.0) + q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=True) + if float(q_dec or 0) <= 0: + raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}") + + params: Dict[str, Any] = { + "symbol": sym, + "side": sd, + "type": "MARKET", + "quantity": self._dec_str(q_dec), + } + if client_order_id: + params["newClientOrderId"] = str(client_order_id) + try: + raw = self._signed_request("POST", "/api/v3/order", params=params) + except LiveTradingError as e: + raise LiveTradingError( + f"{e} | debug: symbol={sym} side={sd} " + f"qty_req={q_req} qty_norm={self._dec_str(q_dec)}" + ) + return LiveOrderResult( + exchange_id="binance", + exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""), + filled=float(raw.get("executedQty") or 0.0), + avg_price=float(raw.get("cummulativeQuoteQty") or 0.0) / float(raw.get("executedQty") or 1.0) if float(raw.get("executedQty") or 0.0) > 0 else 0.0, + raw=raw, + ) + + def get_account(self) -> Dict[str, Any]: + """ + Get spot account balances. + + Endpoint: GET /api/v3/account + """ + return self._signed_request("GET", "/api/v3/account", params={}) + + def cancel_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]: + sym = to_binance_futures_symbol(symbol) + params: Dict[str, Any] = {"symbol": sym} + if order_id: + params["orderId"] = str(order_id) + elif client_order_id: + params["origClientOrderId"] = str(client_order_id) + else: + raise LiveTradingError("BinanceSpot cancel_order requires order_id or client_order_id") + return self._signed_request("DELETE", "/api/v3/order", params=params) + + def get_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]: + sym = to_binance_futures_symbol(symbol) + params: Dict[str, Any] = {"symbol": sym} + if order_id: + params["orderId"] = str(order_id) + elif client_order_id: + params["origClientOrderId"] = str(client_order_id) + else: + raise LiveTradingError("BinanceSpot get_order requires order_id or client_order_id") + return self._signed_request("GET", "/api/v3/order", params=params) + + def wait_for_fill( + self, + *, + symbol: str, + order_id: str = "", + client_order_id: str = "", + max_wait_sec: float = 10.0, + poll_interval_sec: float = 0.5, + ) -> Dict[str, Any]: + end_ts = time.time() + float(max_wait_sec or 0.0) + last: Dict[str, Any] = {} + while True: + try: + last = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "")) + except Exception: + last = last or {} + + status = str(last.get("status") or "") + try: + filled = float(last.get("executedQty") or 0.0) + except Exception: + filled = 0.0 + avg_price = 0.0 + try: + cum_quote = float(last.get("cummulativeQuoteQty") or 0.0) + if filled > 0 and cum_quote > 0: + avg_price = cum_quote / filled + except Exception: + pass + + if filled > 0 and avg_price > 0: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + if status in ("FILLED", "CANCELED", "EXPIRED", "REJECTED"): + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + if time.time() >= end_ts: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + time.sleep(float(poll_interval_sec or 0.5)) + + diff --git a/backend_api_python/app/services/live_trading/bitget.py b/backend_api_python/app/services/live_trading/bitget.py new file mode 100644 index 0000000..aa59fb7 --- /dev/null +++ b/backend_api_python/app/services/live_trading/bitget.py @@ -0,0 +1,573 @@ +""" +Bitget (direct REST) client for USDT-margined perpetual orders. + +Signing (Bitget): +- ACCESS-SIGN = base64(hmac_sha256(secret, timestamp + method + request_path + body)) +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import time +from decimal import Decimal, ROUND_DOWN +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlencode + +from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError +from app.services.live_trading.symbols import to_bitget_um_symbol + + +class BitgetMixClient(BaseRestClient): + def __init__( + self, + *, + api_key: str, + secret_key: str, + passphrase: str, + base_url: str = "https://api.bitget.com", + timeout_sec: float = 15.0, + ): + super().__init__(base_url=base_url, timeout_sec=timeout_sec) + self.api_key = (api_key or "").strip() + self.secret_key = (secret_key or "").strip() + self.passphrase = (passphrase or "").strip() + if not self.api_key or not self.secret_key or not self.passphrase: + raise LiveTradingError("Missing Bitget api_key/secret_key/passphrase") + + # Best-effort cache for public contract metadata used to normalize order sizes. + # Key: f"{product_type}:{symbol}" -> (fetched_at_ts, contract_dict) + self._contract_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {} + self._contract_cache_ttl_sec = 300.0 + + # Best-effort cache for leverage settings to avoid spamming set-leverage on every tick. + # Key: f"{product_type}:{symbol}:{margin_coin}:{margin_mode}:{hold_side}:{lever}" -> (fetched_at_ts, True) + self._lev_cache: Dict[str, Tuple[float, bool]] = {} + self._lev_cache_ttl_sec = 60.0 + + @staticmethod + def _to_dec(x: Any) -> Decimal: + try: + return Decimal(str(x)) + except Exception: + return Decimal("0") + + @staticmethod + def _dec_str(d: Decimal) -> str: + try: + return format(d, "f") + except Exception: + return str(d) + + @staticmethod + def _floor_to_step(value: Decimal, step: Decimal) -> Decimal: + if step is None: + return value + if value <= 0: + return Decimal("0") + try: + st = Decimal(step) + except Exception: + st = Decimal("0") + if st <= 0: + return value + try: + n = (value / st).to_integral_value(rounding=ROUND_DOWN) + return n * st + except Exception: + return Decimal("0") + + @staticmethod + def _normalize_margin_mode(margin_mode: str) -> str: + """ + Normalize margin mode for Bitget mix orders. + + Bitget expects: + - crossed + - isolated + + Our system often uses: + - cross + - isolated + """ + m = str(margin_mode or "").strip().lower() + if not m: + return "crossed" + if m in ("cross", "crossed"): + return "crossed" + if m in ("isolated", "iso"): + return "isolated" + return "crossed" + + def _sign(self, ts_ms: str, method: str, path: str, body: str) -> str: + prehash = f"{ts_ms}{method.upper()}{path}{body}" + mac = hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).digest() + return base64.b64encode(mac).decode("utf-8") + + def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]: + return { + "ACCESS-KEY": self.api_key, + "ACCESS-SIGN": sign, + "ACCESS-TIMESTAMP": ts_ms, + "ACCESS-PASSPHRASE": self.passphrase, + "Content-Type": "application/json", + } + + def _signed_request( + self, + method: str, + path: str, + *, + json_body: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Bitget signature is computed over (timestamp + method + request_path + body). + + - Use `data=` to ensure the signed body matches the sent body. + - For GET params, include query string into the signed request path. + """ + ts_ms = str(int(time.time() * 1000)) + body_str = self._json_dumps(json_body) if json_body is not None else "" + + qs = "" + if params: + norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()} + qs = urlencode(sorted(norm.items()), doseq=True) + signed_path = f"{path}?{qs}" if qs else path + + sign = self._sign(ts_ms, method, signed_path, body_str) + code, data, text = self._request( + method, + path, + params=params, + data=body_str if body_str else None, + headers=self._headers(ts_ms, sign), + ) + if code >= 400: + raise LiveTradingError(f"Bitget HTTP {code}: {text[:500]}") + if isinstance(data, dict): + # Bitget uses code == "00000" for success in many endpoints. + c = str(data.get("code") or "") + if c and c not in ("00000", "0"): + raise LiveTradingError(f"Bitget error: {data}") + return data if isinstance(data, dict) else {"raw": data} + + def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None) + if code >= 400: + raise LiveTradingError(f"Bitget HTTP {code}: {text[:500]}") + if isinstance(data, dict): + c = str(data.get("code") or "") + if c and c not in ("00000", "0"): + raise LiveTradingError(f"Bitget error: {data}") + return data if isinstance(data, dict) else {"raw": data} + + def get_contract(self, *, symbol: str, product_type: str = "USDT-FUTURES") -> Dict[str, Any]: + """ + Fetch contract metadata (best-effort) from public endpoint. + + Endpoint (Bitget v2 mix): GET /api/v2/mix/market/contracts + Params: productType, symbol(optional) + """ + sym = to_bitget_um_symbol(symbol) + pt = str(product_type or "USDT-FUTURES") + if not sym: + return {} + key = f"{pt}:{sym}" + now = time.time() + cached = self._contract_cache.get(key) + if cached: + ts, obj = cached + if obj and (now - float(ts or 0.0)) <= float(self._contract_cache_ttl_sec or 300.0): + return obj + + raw = self._public_request("GET", "/api/v2/mix/market/contracts", params={"productType": pt, "symbol": sym}) + data = raw.get("data") if isinstance(raw, dict) else None + items = data if isinstance(data, list) else ([data] if isinstance(data, dict) else []) + first: Dict[str, Any] = items[0] if isinstance(items, list) and items else {} + if isinstance(first, dict) and first: + self._contract_cache[key] = (now, first) + return first if isinstance(first, dict) else {} + + def _normalize_size(self, *, symbol: str, product_type: str, base_size: float) -> Decimal: + """ + Normalize Bitget mix order size. + + This system computes `amount` as base-asset quantity (e.g. BTC amount). + Bitget mix `size` is typically in contracts; convert using contractSize if available, + then align to size step / min trade number (best-effort). + """ + req_base = self._to_dec(base_size) + if req_base <= 0: + return Decimal("0") + + contract: Dict[str, Any] = {} + try: + contract = self.get_contract(symbol=symbol, product_type=product_type) or {} + except Exception: + contract = {} + + # Convert base qty -> contracts if contractSize is provided. + ct = self._to_dec(contract.get("contractSize") or contract.get("contractSz") or contract.get("ctVal") or "0") + qty = req_base + if ct > 0: + qty = req_base / ct + + # Determine step size. + step = self._to_dec(contract.get("sizeMultiplier") or contract.get("sizeStep") or contract.get("lotSize") or "0") + if step <= 0: + sp = contract.get("sizePlace") + try: + places = int(sp) if sp is not None else 0 + except Exception: + places = 0 + if places >= 0 and places <= 18: + step = Decimal("1") / (Decimal("10") ** Decimal(str(places))) + + if step > 0: + qty = self._floor_to_step(qty, step) + + # Enforce min trade number if present. + mn = self._to_dec(contract.get("minTradeNum") or contract.get("minSize") or contract.get("minQty") or "0") + if mn > 0 and qty < mn: + return Decimal("0") + return qty + + def ping(self) -> bool: + code, data, _ = self._request("GET", "/api/v2/public/time") + return code == 200 and isinstance(data, dict) + + def get_accounts(self, *, product_type: str = "USDT-FUTURES") -> Dict[str, Any]: + """ + Private endpoint to validate credentials (best-effort). + """ + return self._signed_request("GET", "/api/v2/mix/account/accounts", params={"productType": str(product_type or "USDT-FUTURES")}) + + def get_positions(self, *, product_type: str = "USDT-FUTURES") -> Dict[str, Any]: + """ + Get all positions (best-effort). + + Endpoint: GET /api/v2/mix/position/all-position + """ + return self._signed_request("GET", "/api/v2/mix/position/all-position", params={"productType": str(product_type or "USDT-FUTURES")}) + + def set_leverage( + self, + *, + symbol: str, + leverage: float, + margin_coin: str = "USDT", + product_type: str = "USDT-FUTURES", + margin_mode: str = "crossed", + hold_side: str = "", + ) -> bool: + """ + Best-effort set leverage for Bitget mix. + + NOTE: Bitget requires leverage configured via a private endpoint; order placement may otherwise use defaults. + Endpoint (v2 mix): POST /api/v2/mix/account/set-leverage (best-effort). + """ + sym = to_bitget_um_symbol(symbol) + pt = str(product_type or "USDT-FUTURES") + mc = str(margin_coin or "USDT") + mm = self._normalize_margin_mode(margin_mode) + hs = str(hold_side or "").strip().lower() + try: + lv = int(float(leverage or 0)) + except Exception: + lv = 0 + if not sym or lv <= 0: + return False + + cache_key = f"{pt}:{sym}:{mc}:{mm}:{hs}:{lv}" + now = time.time() + cached = self._lev_cache.get(cache_key) + if cached: + ts, ok = cached + if ok and (now - float(ts or 0.0)) <= float(self._lev_cache_ttl_sec or 60.0): + return True + + body: Dict[str, Any] = { + "symbol": sym, + "productType": pt, + "marginCoin": mc, + "marginMode": mm, + "leverage": str(lv), + } + # Some Bitget accounts require holdSide for hedge mode; keep best-effort. + if hs in ("long", "short"): + body["holdSide"] = hs + + try: + resp = self._signed_request("POST", "/api/v2/mix/account/set-leverage", json_body=body) + ok = isinstance(resp, dict) and str(resp.get("code") or "") in ("00000", "0", "") + if ok: + self._lev_cache[cache_key] = (now, True) + return bool(ok) + except Exception: + return False + + def place_market_order( + self, + *, + symbol: str, + side: str, + size: float, + margin_coin: str = "USDT", + product_type: str = "USDT-FUTURES", + margin_mode: str = "crossed", + reduce_only: bool = False, + client_order_id: Optional[str] = None, + ) -> LiveOrderResult: + sym = to_bitget_um_symbol(symbol) + sd = (side or "").lower() + if sd not in ("buy", "sell"): + raise LiveTradingError(f"Invalid side: {side}") + req = float(size or 0.0) + sz_dec = self._normalize_size(symbol=symbol, product_type=product_type, base_size=req) + if float(sz_dec or 0) <= 0: + raise LiveTradingError(f"Invalid size (below step/min): requested={req}") + + body: Dict[str, Any] = { + "symbol": sym, + "productType": str(product_type or "USDT-FUTURES"), + "marginCoin": str(margin_coin or "USDT"), + "marginMode": self._normalize_margin_mode(margin_mode), + "side": sd, + "orderType": "market", + "size": self._dec_str(sz_dec), + } + if reduce_only: + body["reduceOnly"] = "YES" + if client_order_id: + body["clientOid"] = str(client_order_id) + + raw = self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body) + data = raw.get("data") if isinstance(raw, dict) else None + exchange_order_id = "" + if isinstance(data, dict): + exchange_order_id = str(data.get("orderId") or data.get("clientOid") or "") + + return LiveOrderResult( + exchange_id="bitget", + exchange_order_id=exchange_order_id, + filled=0.0, + avg_price=0.0, + raw=raw, + ) + + def place_limit_order( + self, + *, + symbol: str, + side: str, + size: float, + price: float, + margin_coin: str = "USDT", + product_type: str = "USDT-FUTURES", + margin_mode: str = "crossed", + reduce_only: bool = False, + post_only: bool = False, + client_order_id: Optional[str] = None, + ) -> LiveOrderResult: + sym = to_bitget_um_symbol(symbol) + sd = (side or "").lower() + if sd not in ("buy", "sell"): + raise LiveTradingError(f"Invalid side: {side}") + req = float(size or 0.0) + px = float(price or 0.0) + if req <= 0 or px <= 0: + raise LiveTradingError("Invalid size/price") + sz_dec = self._normalize_size(symbol=symbol, product_type=product_type, base_size=req) + if float(sz_dec or 0) <= 0: + raise LiveTradingError(f"Invalid size (below step/min): requested={req}") + + body: Dict[str, Any] = { + "symbol": sym, + "productType": str(product_type or "USDT-FUTURES"), + "marginCoin": str(margin_coin or "USDT"), + "marginMode": self._normalize_margin_mode(margin_mode), + "side": sd, + "orderType": "limit", + "price": str(px), + "size": self._dec_str(sz_dec), + } + # Force maker behavior when requested (avoid taker fills). + if post_only: + body["force"] = "post_only" + else: + body["force"] = "gtc" + if reduce_only: + body["reduceOnly"] = "YES" + if client_order_id: + body["clientOid"] = str(client_order_id) + raw = self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body) + data = raw.get("data") if isinstance(raw, dict) else None + exchange_order_id = str(data.get("orderId") or data.get("clientOid") or "") if isinstance(data, dict) else "" + return LiveOrderResult(exchange_id="bitget", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw) + + def cancel_order(self, *, symbol: str, product_type: str, margin_coin: str = "USDT", order_id: str = "", client_oid: str = "") -> Dict[str, Any]: + body: Dict[str, Any] = { + "symbol": to_bitget_um_symbol(symbol), + "productType": str(product_type or "USDT-FUTURES"), + "marginCoin": str(margin_coin or "USDT"), + } + if order_id: + body["orderId"] = str(order_id) + elif client_oid: + body["clientOid"] = str(client_oid) + else: + raise LiveTradingError("Bitget cancel_order requires order_id or client_oid") + return self._signed_request("POST", "/api/v2/mix/order/cancel-order", json_body=body) + + def get_order_detail( + self, + *, + symbol: str, + product_type: str, + order_id: str = "", + client_oid: str = "", + ) -> Dict[str, Any]: + params: Dict[str, Any] = { + "symbol": to_bitget_um_symbol(symbol), + "productType": str(product_type or "USDT-FUTURES"), + } + if order_id: + params["orderId"] = str(order_id) + elif client_oid: + params["clientOid"] = str(client_oid) + else: + raise LiveTradingError("Bitget get_order_detail requires order_id or client_oid") + return self._signed_request("GET", "/api/v2/mix/order/detail", params=params) + + def get_order_fills( + self, + *, + symbol: str, + product_type: str, + order_id: str, + ) -> Dict[str, Any]: + params: Dict[str, Any] = { + "orderId": str(order_id), + "productType": str(product_type or "USDT-FUTURES"), + "symbol": to_bitget_um_symbol(symbol), + } + return self._signed_request("GET", "/api/v2/mix/order/fills", params=params) + + def wait_for_fill( + self, + *, + symbol: str, + product_type: str = "USDT-FUTURES", + order_id: str, + client_oid: str = "", + max_wait_sec: float = 3.0, + poll_interval_sec: float = 0.5, + ) -> Dict[str, Any]: + """ + Poll order fills/detail to obtain (best-effort) executed size and average price. + + Returns: + { + "filled": float, + "avg_price": float, + "fee": float, + "fee_ccy": str, + "state": str, + "detail": {...}, + "fills": {...} + } + """ + end_ts = time.time() + float(max_wait_sec or 0.0) + last_detail: Dict[str, Any] = {} + last_fills: Dict[str, Any] = {} + state = "" + + # For robust parsing: contractSize helps converting contracts->base if needed. + ct = Decimal("0") + try: + contract = self.get_contract(symbol=symbol, product_type=product_type) or {} + ct = self._to_dec(contract.get("contractSize") or contract.get("contractSz") or contract.get("ctVal") or "0") + except Exception: + ct = Decimal("0") + + while True: + # Prefer fills endpoint to calculate accurate weighted average. + try: + last_fills = self.get_order_fills(symbol=symbol, product_type=product_type, order_id=str(order_id)) + data = last_fills.get("data") if isinstance(last_fills, dict) else None + fill_list = [] + if isinstance(data, dict): + fill_list = data.get("fillList") or [] + total_base = Decimal("0") + total_quote = Decimal("0") + total_fee = Decimal("0") + fee_ccy = "" + if isinstance(fill_list, list): + for f in fill_list: + try: + # Bitget fills may provide either baseVolume or size. + # Our system standardizes on base-asset quantity. + sz_base = self._to_dec(f.get("baseVolume") or "0") + if sz_base <= 0: + sz_contracts = self._to_dec(f.get("size") or f.get("fillSize") or "0") + if sz_contracts > 0 and ct > 0: + sz_base = sz_contracts * ct + px = self._to_dec(f.get("fillPrice") or f.get("price") or "0") + + fee_v = f.get("fee") + if fee_v is None: + fee_v = f.get("fillFee") + fee = self._to_dec(fee_v or "0") + ccy = str(f.get("feeCoin") or f.get("feeCcy") or f.get("fillFeeCoin") or "").strip() + + if sz_base > 0 and px > 0: + total_base += sz_base + total_quote += sz_base * px + if fee != 0: + # Fees may be negative; store absolute cost. + total_fee += abs(fee) + if (not fee_ccy) and ccy: + fee_ccy = ccy + except Exception: + continue + if total_base > 0 and total_quote > 0: + return { + "filled": float(total_base), + "avg_price": float(total_quote / total_base), + "fee": float(total_fee), + "fee_ccy": str(fee_ccy or ""), + "state": state, + "detail": last_detail, + "fills": last_fills, + } + except Exception: + pass + + # Fall back to order detail (state + sometimes avg/filled fields). + try: + last_detail = self.get_order_detail( + symbol=symbol, + product_type=product_type, + order_id=str(order_id or ""), + client_oid=str(client_oid or ""), + ) + d = last_detail.get("data") if isinstance(last_detail, dict) else None + if isinstance(d, dict): + state = str(d.get("state") or d.get("status") or "") + avg = float(d.get("priceAvg") or d.get("fillPrice") or 0.0) if (d.get("priceAvg") or d.get("fillPrice")) else 0.0 + filled = float(d.get("baseVolume") or d.get("filledQty") or 0.0) if (d.get("baseVolume") or d.get("filledQty")) else 0.0 + if filled > 0 and avg > 0: + return {"filled": filled, "avg_price": avg, "fee": 0.0, "fee_ccy": "", "state": state, "detail": last_detail, "fills": last_fills} + if state in ("filled", "canceled", "cancelled"): + return {"filled": filled, "avg_price": avg, "fee": 0.0, "fee_ccy": "", "state": state, "detail": last_detail, "fills": last_fills} + except Exception: + pass + + if time.time() >= end_ts: + return {"filled": 0.0, "avg_price": 0.0, "fee": 0.0, "fee_ccy": "", "state": state, "detail": last_detail, "fills": last_fills} + time.sleep(float(poll_interval_sec or 0.5)) + + diff --git a/backend_api_python/app/services/live_trading/bitget_spot.py b/backend_api_python/app/services/live_trading/bitget_spot.py new file mode 100644 index 0000000..c83b9da --- /dev/null +++ b/backend_api_python/app/services/live_trading/bitget_spot.py @@ -0,0 +1,354 @@ +""" +Bitget Spot (direct REST) client. + +Endpoints are aligned with hummingbot constants: +- POST /api/v2/spot/trade/place-order +- POST /api/v2/spot/trade/cancel-order +- GET /api/v2/spot/trade/orderInfo +- GET /api/v2/spot/trade/fills +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import time +from decimal import Decimal, ROUND_DOWN +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlencode + +from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError +from app.services.live_trading.symbols import to_bitget_um_symbol + + +class BitgetSpotClient(BaseRestClient): + def __init__( + self, + *, + api_key: str, + secret_key: str, + passphrase: str, + base_url: str = "https://api.bitget.com", + timeout_sec: float = 15.0, + channel_api_code: str = "bntva", + ): + super().__init__(base_url=base_url, timeout_sec=timeout_sec) + self.api_key = (api_key or "").strip() + self.secret_key = (secret_key or "").strip() + self.passphrase = (passphrase or "").strip() + self.channel_api_code = (channel_api_code or "").strip() + if not self.api_key or not self.secret_key or not self.passphrase: + raise LiveTradingError("Missing Bitget api_key/secret_key/passphrase") + + # Best-effort cache for public symbol metadata used to normalize order sizes. + # Key: symbol -> (fetched_at_ts, meta_dict) + self._sym_meta_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {} + self._sym_meta_cache_ttl_sec = 300.0 + + @staticmethod + def _to_dec(x: Any) -> Decimal: + try: + return Decimal(str(x)) + except Exception: + return Decimal("0") + + @staticmethod + def _dec_str(d: Decimal) -> str: + try: + return format(d, "f") + except Exception: + return str(d) + + @staticmethod + def _floor_to_step(value: Decimal, step: Decimal) -> Decimal: + if step is None: + return value + if value <= 0: + return Decimal("0") + try: + st = Decimal(step) + except Exception: + st = Decimal("0") + if st <= 0: + return value + try: + n = (value / st).to_integral_value(rounding=ROUND_DOWN) + return n * st + except Exception: + return Decimal("0") + + def _sign(self, ts_ms: str, method: str, path: str, body: str) -> str: + prehash = f"{ts_ms}{method.upper()}{path}{body}" + mac = hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).digest() + return base64.b64encode(mac).decode("utf-8") + + def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]: + h = { + "ACCESS-KEY": self.api_key, + "ACCESS-SIGN": sign, + "ACCESS-TIMESTAMP": ts_ms, + "ACCESS-PASSPHRASE": self.passphrase, + "Content-Type": "application/json", + } + if self.channel_api_code: + h["X-CHANNEL-API-CODE"] = self.channel_api_code + return h + + def _signed_request( + self, + method: str, + path: str, + *, + json_body: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Bitget signature must match the exact body string sent over the wire. + """ + ts_ms = str(int(time.time() * 1000)) + body_str = self._json_dumps(json_body) if json_body is not None else "" + + qs = "" + if params: + norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()} + qs = urlencode(sorted(norm.items()), doseq=True) + signed_path = f"{path}?{qs}" if qs else path + + sign = self._sign(ts_ms, method, signed_path, body_str) + code, data, text = self._request( + method, + path, + params=params, + data=body_str if body_str else None, + headers=self._headers(ts_ms, sign), + ) + if code >= 400: + raise LiveTradingError(f"BitgetSpot HTTP {code}: {text[:500]}") + if isinstance(data, dict): + c = str(data.get("code") or "") + if c and c not in ("00000", "0"): + raise LiveTradingError(f"BitgetSpot error: {data}") + return data if isinstance(data, dict) else {"raw": data} + + def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None) + if code >= 400: + raise LiveTradingError(f"BitgetSpot HTTP {code}: {text[:500]}") + if isinstance(data, dict): + c = str(data.get("code") or "") + if c and c not in ("00000", "0"): + raise LiveTradingError(f"BitgetSpot error: {data}") + return data if isinstance(data, dict) else {"raw": data} + + def get_symbol_meta(self, *, symbol: str) -> Dict[str, Any]: + """ + Fetch spot symbol metadata (best-effort). + + Endpoint (Bitget v2 spot): GET /api/v2/spot/public/symbols + """ + sym = to_bitget_um_symbol(symbol) + if not sym: + return {} + now = time.time() + cached = self._sym_meta_cache.get(sym) + if cached: + ts, obj = cached + if obj and (now - float(ts or 0.0)) <= float(self._sym_meta_cache_ttl_sec or 300.0): + return obj + + raw = self._public_request("GET", "/api/v2/spot/public/symbols") + data = raw.get("data") if isinstance(raw, dict) else None + items = data if isinstance(data, list) else [] + found: Dict[str, Any] = {} + for it in items: + if not isinstance(it, dict): + continue + s = str(it.get("symbol") or it.get("symbolName") or "") + if s and s.upper() == sym.upper(): + found = it + break + if found: + self._sym_meta_cache[sym] = (now, found) + return found + + def _normalize_base_size(self, *, symbol: str, base_size: float) -> Decimal: + """ + Normalize spot base size to lot/step constraints (best-effort). + """ + req = self._to_dec(base_size) + if req <= 0: + return Decimal("0") + + meta: Dict[str, Any] = {} + try: + meta = self.get_symbol_meta(symbol=symbol) or {} + except Exception: + meta = {} + + # Try common fields. If unavailable, keep as-is. + step = self._to_dec(meta.get("quantityScale") or meta.get("quantityStep") or meta.get("sizeStep") or meta.get("minTradeIncrement") or "0") + if step <= 0: + # Some endpoints expose decimals instead of step. + qd = meta.get("quantityPrecision") or meta.get("quantityPlace") or meta.get("sizePlace") + try: + places = int(qd) if qd is not None else 0 + except Exception: + places = 0 + if places >= 0 and places <= 18: + step = Decimal("1") / (Decimal("10") ** Decimal(str(places))) + + if step > 0: + req = self._floor_to_step(req, step) + + mn = self._to_dec(meta.get("minTradeAmount") or meta.get("minTradeNum") or meta.get("minQty") or meta.get("minSize") or "0") + if mn > 0 and req < mn: + return Decimal("0") + return req + + def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult: + sym = to_bitget_um_symbol(symbol) + sd = (side or "").lower() + if sd not in ("buy", "sell"): + raise LiveTradingError(f"Invalid side: {side}") + req = float(size or 0.0) + px = float(price or 0.0) + if req <= 0 or px <= 0: + raise LiveTradingError("Invalid size/price") + sz_dec = self._normalize_base_size(symbol=symbol, base_size=req) + if float(sz_dec or 0) <= 0: + raise LiveTradingError(f"Invalid size (below step/min): requested={req}") + + body: Dict[str, Any] = { + "side": sd, + "symbol": sym, + "size": self._dec_str(sz_dec), + "orderType": "limit", + "force": "gtc", + "price": str(px), + } + if client_order_id: + body["clientOid"] = str(client_order_id) + raw = self._signed_request("POST", "/api/v2/spot/trade/place-order", json_body=body) + data = raw.get("data") if isinstance(raw, dict) else None + order_id = str(data.get("orderId") or "") if isinstance(data, dict) else "" + return LiveOrderResult(exchange_id="bitget", exchange_order_id=order_id, filled=0.0, avg_price=0.0, raw=raw) + + def place_market_order(self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None) -> LiveOrderResult: + """ + NOTE: Bitget spot market BUY may expect quote amount. We accept `size` as base size, + but the caller can also pass a quote-sized value if desired. + """ + sym = to_bitget_um_symbol(symbol) + sd = (side or "").lower() + if sd not in ("buy", "sell"): + raise LiveTradingError(f"Invalid side: {side}") + req = float(size or 0.0) + if req <= 0: + raise LiveTradingError("Invalid size") + + # For Bitget spot market BUY, many APIs interpret size as quote amount. + # Our worker may pass quote-sized value for BUY; do not quantize it as base size. + if sd == "sell": + sz_dec = self._normalize_base_size(symbol=symbol, base_size=req) + if float(sz_dec or 0) <= 0: + raise LiveTradingError(f"Invalid size (below step/min): requested={req}") + sz_str = self._dec_str(sz_dec) + else: + sz_str = str(req) + + body: Dict[str, Any] = { + "side": sd, + "symbol": sym, + "size": sz_str, + "orderType": "market", + "force": "gtc", + } + if client_order_id: + body["clientOid"] = str(client_order_id) + raw = self._signed_request("POST", "/api/v2/spot/trade/place-order", json_body=body) + data = raw.get("data") if isinstance(raw, dict) else None + order_id = str(data.get("orderId") or "") if isinstance(data, dict) else "" + return LiveOrderResult(exchange_id="bitget", exchange_order_id=order_id, filled=0.0, avg_price=0.0, raw=raw) + + def cancel_order(self, *, symbol: str, client_order_id: str) -> Dict[str, Any]: + sym = to_bitget_um_symbol(symbol) + if not client_order_id: + raise LiveTradingError("BitgetSpot cancel_order requires client_order_id") + body = {"symbol": sym, "clientOid": str(client_order_id)} + return self._signed_request("POST", "/api/v2/spot/trade/cancel-order", json_body=body) + + def get_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]: + sym = to_bitget_um_symbol(symbol) + params: Dict[str, Any] = {"symbol": sym} + if order_id: + params["orderId"] = str(order_id) + elif client_order_id: + params["clientOid"] = str(client_order_id) + else: + raise LiveTradingError("BitgetSpot get_order requires order_id or client_order_id") + return self._signed_request("GET", "/api/v2/spot/trade/orderInfo", params=params) + + def get_fills(self, *, symbol: str, order_id: str) -> Dict[str, Any]: + sym = to_bitget_um_symbol(symbol) + params: Dict[str, Any] = {"symbol": sym, "orderId": str(order_id)} + return self._signed_request("GET", "/api/v2/spot/trade/fills", params=params) + + def wait_for_fill( + self, + *, + symbol: str, + order_id: str, + client_order_id: str = "", + max_wait_sec: float = 10.0, + poll_interval_sec: float = 0.5, + ) -> Dict[str, Any]: + end_ts = time.time() + float(max_wait_sec or 0.0) + last_order: Dict[str, Any] = {} + last_fills: Dict[str, Any] = {} + state = "" + + while True: + # Prefer fills to compute weighted average if available. + try: + last_fills = self.get_fills(symbol=symbol, order_id=str(order_id)) + data = last_fills.get("data") if isinstance(last_fills, dict) else None + fills = data if isinstance(data, list) else [] + total_base = 0.0 + total_quote = 0.0 + if isinstance(fills, list): + for f in fills: + try: + sz = float(f.get("size") or 0.0) + px = float(f.get("priceAvg") or f.get("price") or 0.0) + if sz > 0 and px > 0: + total_base += sz + total_quote += sz * px + except Exception: + continue + if total_base > 0 and total_quote > 0: + return {"filled": total_base, "avg_price": total_quote / total_base, "state": state, "order": last_order, "fills": last_fills} + except Exception: + pass + + try: + last_order = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "")) + od = last_order.get("data") if isinstance(last_order, dict) else None + if isinstance(od, dict): + state = str(od.get("status") or od.get("state") or "") + except Exception: + pass + + if time.time() >= end_ts: + return {"filled": 0.0, "avg_price": 0.0, "state": state, "order": last_order, "fills": last_fills} + time.sleep(float(poll_interval_sec or 0.5)) + + def get_assets(self) -> Dict[str, Any]: + """ + Spot assets/balances. + + Endpoint: GET /api/v2/spot/account/assets + """ + return self._signed_request("GET", "/api/v2/spot/account/assets") + + diff --git a/backend_api_python/app/services/live_trading/execution.py b/backend_api_python/app/services/live_trading/execution.py new file mode 100644 index 0000000..2cac2c1 --- /dev/null +++ b/backend_api_python/app/services/live_trading/execution.py @@ -0,0 +1,114 @@ +""" +Translate a strategy signal into a direct-exchange order call. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, Tuple + +from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError +from app.services.live_trading.binance import BinanceFuturesClient +from app.services.live_trading.binance_spot import BinanceSpotClient +from app.services.live_trading.okx import OkxClient +from app.services.live_trading.bitget import BitgetMixClient +from app.services.live_trading.bitget_spot import BitgetSpotClient + + +def _signal_to_sides(signal_type: str) -> Tuple[str, str, bool]: + """ + Returns (side, pos_side, reduce_only) + - side: buy/sell + - pos_side: long/short (for OKX) + """ + sig = (signal_type or "").strip().lower() + if sig in ("open_long", "add_long"): + return "buy", "long", False + if sig in ("open_short", "add_short"): + return "sell", "short", False + if sig in ("close_long", "reduce_long"): + return "sell", "long", True + if sig in ("close_short", "reduce_short"): + return "buy", "short", True + raise LiveTradingError(f"Unsupported signal_type: {signal_type}") + + +def place_order_from_signal( + client: BaseRestClient, + *, + signal_type: str, + symbol: str, + amount: float, + market_type: str = "swap", + exchange_config: Optional[Dict[str, Any]] = None, + client_order_id: Optional[str] = None, +) -> LiveOrderResult: + if amount is None: + amount = 0.0 + qty = float(amount or 0.0) + if qty <= 0: + raise LiveTradingError("Invalid amount") + + side, pos_side, reduce_only = _signal_to_sides(signal_type) + + cfg = exchange_config if isinstance(exchange_config, dict) else {} + mt = (market_type or cfg.get("market_type") or "swap").strip().lower() + if mt in ("futures", "future", "perp", "perpetual"): + mt = "swap" + + # Spot does not support short signals in this system. + if mt == "spot" and ("short" in (signal_type or "").lower()): + raise LiveTradingError("spot market does not support short signals") + + if isinstance(client, BinanceFuturesClient): + return client.place_market_order( + symbol=symbol, + side="BUY" if side == "buy" else "SELL", + quantity=qty, + reduce_only=reduce_only, + position_side=pos_side, + client_order_id=client_order_id, + ) + if isinstance(client, OkxClient): + td_mode = (cfg.get("margin_mode") or cfg.get("td_mode") or "cross") + return client.place_market_order( + symbol=symbol, + side=side, + pos_side=pos_side, + size=qty, + td_mode=str(td_mode), + reduce_only=reduce_only, + client_order_id=client_order_id, + ) + if isinstance(client, BitgetMixClient): + margin_coin = str(cfg.get("margin_coin") or cfg.get("marginCoin") or "USDT") + product_type = str(cfg.get("product_type") or cfg.get("productType") or "USDT-FUTURES") + margin_mode = str(cfg.get("margin_mode") or cfg.get("marginMode") or cfg.get("td_mode") or "cross") + return client.place_market_order( + symbol=symbol, + side=side, + size=qty, + margin_coin=margin_coin, + product_type=product_type, + margin_mode=margin_mode, + reduce_only=reduce_only, + client_order_id=client_order_id, + ) + if isinstance(client, BinanceSpotClient): + return client.place_market_order( + symbol=symbol, + side="BUY" if side == "buy" else "SELL", + quantity=qty, + client_order_id=client_order_id, + ) + if isinstance(client, BitgetSpotClient): + # For spot market BUY, Bitget may expect quote size; we pass base size here and let caller override if needed. + return client.place_market_order( + symbol=symbol, + side=side, + size=qty, + client_order_id=client_order_id, + ) + + raise LiveTradingError(f"Unsupported client type: {type(client)}") + + diff --git a/backend_api_python/app/services/live_trading/factory.py b/backend_api_python/app/services/live_trading/factory.py new file mode 100644 index 0000000..3e7cd21 --- /dev/null +++ b/backend_api_python/app/services/live_trading/factory.py @@ -0,0 +1,59 @@ +""" +Factory for direct exchange clients. +""" + +from __future__ import annotations + +from typing import Any, Dict + +from app.services.live_trading.base import BaseRestClient, LiveTradingError +from app.services.live_trading.binance import BinanceFuturesClient +from app.services.live_trading.binance_spot import BinanceSpotClient +from app.services.live_trading.okx import OkxClient +from app.services.live_trading.bitget import BitgetMixClient +from app.services.live_trading.bitget_spot import BitgetSpotClient + + +def _get(cfg: Dict[str, Any], *keys: str) -> str: + for k in keys: + v = cfg.get(k) + if v is None: + continue + s = str(v).strip() + if s: + return s + return "" + + +def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap") -> BaseRestClient: + if not isinstance(exchange_config, dict): + raise LiveTradingError("Invalid exchange_config") + exchange_id = _get(exchange_config, "exchange_id", "exchangeId").lower() + api_key = _get(exchange_config, "api_key", "apiKey") + secret_key = _get(exchange_config, "secret_key", "secret") + passphrase = _get(exchange_config, "passphrase", "password") + + mt = (market_type or exchange_config.get("market_type") or exchange_config.get("defaultType") or "swap").strip().lower() + if mt in ("futures", "future", "perp", "perpetual"): + mt = "swap" + + if exchange_id == "binance": + if mt == "spot": + base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.binance.com" + return BinanceSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url) + # Default to USDT-M futures + base_url = _get(exchange_config, "base_url", "baseUrl") or "https://fapi.binance.com" + return BinanceFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url) + if exchange_id == "okx": + base_url = _get(exchange_config, "base_url", "baseUrl") or "https://www.okx.com" + return OkxClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url) + if exchange_id == "bitget": + base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bitget.com" + if mt == "spot": + channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "bntva" + return BitgetSpotClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, channel_api_code=channel_api_code) + return BitgetMixClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url) + + raise LiveTradingError(f"Unsupported exchange_id: {exchange_id}") + + diff --git a/backend_api_python/app/services/live_trading/okx.py b/backend_api_python/app/services/live_trading/okx.py new file mode 100644 index 0000000..84d16e3 --- /dev/null +++ b/backend_api_python/app/services/live_trading/okx.py @@ -0,0 +1,657 @@ +""" +OKX (direct REST) client for perpetual swap orders. + +Signing: +- OK-ACCESS-SIGN = base64(hmac_sha256(secret, timestamp + method + request_path + body)) +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import time +from decimal import Decimal, ROUND_DOWN +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlencode + +from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError +from app.services.live_trading.symbols import to_okx_swap_inst_id, to_okx_spot_inst_id + + +class OkxClient(BaseRestClient): + def __init__( + self, + *, + api_key: str, + secret_key: str, + passphrase: str, + base_url: str = "https://www.okx.com", + timeout_sec: float = 15.0, + ): + super().__init__(base_url=base_url, timeout_sec=timeout_sec) + self.api_key = (api_key or "").strip() + self.secret_key = (secret_key or "").strip() + self.passphrase = (passphrase or "").strip() + if not self.api_key or not self.secret_key or not self.passphrase: + raise LiveTradingError("Missing OKX api_key/secret_key/passphrase") + + # Best-effort cache for public instrument metadata used to normalize order sizes. + # Key: f"{inst_type}:{inst_id}" -> (fetched_at_ts, instrument_dict) + self._inst_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {} + self._inst_cache_ttl_sec = 300.0 + + # Best-effort cache for account config (position mode). + # Key: "account_config" -> (fetched_at_ts, config_dict) + self._acct_cfg_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {} + self._acct_cfg_cache_ttl_sec = 30.0 + + # Best-effort cache for leverage settings to avoid spamming set-leverage on every tick. + # Key: f"{inst_id}:{mgn_mode}:{pos_side}:{lever}" -> (fetched_at_ts, True) + self._lev_cache: Dict[str, Tuple[float, bool]] = {} + self._lev_cache_ttl_sec = 60.0 + + @staticmethod + def _dec_str(d: Decimal) -> str: + """ + Convert Decimal to a non-scientific string (OKX expects plain decimal strings). + """ + try: + return format(d, "f") + except Exception: + return str(d) + + @staticmethod + def _to_dec(x: Any) -> Decimal: + try: + return Decimal(str(x)) + except Exception: + return Decimal("0") + + @staticmethod + def _floor_to_step(value: Decimal, step: Decimal) -> Decimal: + if step is None: + return value + try: + st = Decimal(step) + except Exception: + st = Decimal("0") + if st <= 0: + return value + if value <= 0: + return Decimal("0") + try: + n = (value / st).to_integral_value(rounding=ROUND_DOWN) + return n * st + except Exception: + return Decimal("0") + + def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + code, data, text = self._request(method, path, params=params, json_body=None, headers=None, data=None) + if code >= 400: + raise LiveTradingError(f"OKX HTTP {code}: {text[:500]}") + if isinstance(data, dict) and str(data.get("code") or "") not in ("0", ""): + raise LiveTradingError(f"OKX error: {data}") + return data if isinstance(data, dict) else {"raw": data} + + def get_instrument(self, *, inst_type: str, inst_id: str) -> Dict[str, Any]: + """ + Fetch OKX instrument metadata from public endpoint: + GET /api/v5/public/instruments?instType=...&instId=... + """ + it = str(inst_type or "").strip().upper() + iid = str(inst_id or "").strip() + if not it or not iid: + return {} + + key = f"{it}:{iid}" + now = time.time() + cached = self._inst_cache.get(key) + if cached: + ts, obj = cached + if obj and (now - float(ts or 0.0)) <= float(self._inst_cache_ttl_sec or 300.0): + return obj + + raw = self._public_request("GET", "/api/v5/public/instruments", params={"instType": it, "instId": iid}) + data = (raw.get("data") or []) if isinstance(raw, dict) else [] + first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {} + if isinstance(first, dict) and first: + self._inst_cache[key] = (now, first) + return first if isinstance(first, dict) else {} + + def _normalize_order_size(self, *, inst_id: str, market_type: str, size: float) -> Decimal: + """ + Normalize requested size to OKX constraints: + - Spot: size is base currency quantity; align to lotSz/minSz. + - Swap: OKX sz is in contracts; convert base qty -> contracts using ctVal, then align to lotSz/minSz. + + Note: this system passes `amount` around as base-asset quantity across exchanges. + """ + mt = (market_type or "swap").strip().lower() + iid = str(inst_id or "").strip() + req = self._to_dec(size) + if req <= 0: + return Decimal("0") + + inst_type = "SPOT" if mt == "spot" else "SWAP" + inst: Dict[str, Any] = {} + if iid: + try: + inst = self.get_instrument(inst_type=inst_type, inst_id=iid) or {} + except Exception: + inst = {} + + lot_sz = self._to_dec((inst or {}).get("lotSz") or "0") + min_sz = self._to_dec((inst or {}).get("minSz") or "0") + + # Convert base qty -> contracts for swaps if ctVal is provided. + if mt != "spot": + ct_val = self._to_dec((inst or {}).get("ctVal") or "0") + if ct_val > 0: + req = req / ct_val + + # Align to lot size step. + if lot_sz > 0: + req = self._floor_to_step(req, lot_sz) + + # Enforce min size best-effort. + if min_sz > 0 and req < min_sz: + return Decimal("0") + return req + + def _iso_ts(self) -> str: + # OKX requires RFC3339 timestamp with milliseconds, e.g. 2020-12-08T09:08:57.715Z + t = time.time() + sec = int(t) + ms = int((t - sec) * 1000) + return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(sec)) + f".{ms:03d}Z" + + def _sign(self, ts: str, method: str, path: str, body: str) -> str: + prehash = f"{ts}{method.upper()}{path}{body}" + mac = hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).digest() + return base64.b64encode(mac).decode("utf-8") + + def _headers(self, ts: str, sign: str) -> Dict[str, str]: + return { + "OK-ACCESS-KEY": self.api_key, + "OK-ACCESS-SIGN": sign, + "OK-ACCESS-TIMESTAMP": ts, + "OK-ACCESS-PASSPHRASE": self.passphrase, + "Content-Type": "application/json", + } + + def _signed_request( + self, + method: str, + path: str, + *, + json_body: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Important: the signature must be computed over the exact request body string that is sent. + Therefore we use `data=` instead of `json=` to avoid re-serialization differences. + + For GET requests with params, the query string must be part of request_path in the prehash. + """ + ts = self._iso_ts() + body_str = self._json_dumps(json_body) if json_body is not None else "" + + qs = "" + if params: + # OKX expects the query string in the signed request path. Keep key order stable. + # Convert all values to string to avoid "True"/"False" surprises. + norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()} + qs = urlencode(sorted(norm.items()), doseq=True) + + signed_path = f"{path}?{qs}" if qs else path + sign = self._sign(ts, method, signed_path, body_str) + code, data, text = self._request( + method, + path, + params=params, + data=body_str if body_str else None, + headers=self._headers(ts, sign), + ) + if code >= 400: + raise LiveTradingError(f"OKX HTTP {code}: {text[:500]}") + if isinstance(data, dict) and str(data.get("code") or "") not in ("0", ""): + raise LiveTradingError(f"OKX error: {data}") + return data if isinstance(data, dict) else {"raw": data} + + def ping(self) -> bool: + code, data, _ = self._request("GET", "/api/v5/public/time") + return code == 200 and isinstance(data, dict) + + def get_balance(self) -> Dict[str, Any]: + """ + Private endpoint to validate credentials (best-effort). + """ + return self._signed_request("GET", "/api/v5/account/balance") + + def get_positions(self, *, inst_id: str = "") -> Dict[str, Any]: + """ + Get swap positions (best-effort). + + Endpoint: GET /api/v5/account/positions + """ + params: Dict[str, Any] = {"instType": "SWAP"} + if inst_id: + params["instId"] = str(inst_id) + return self._signed_request("GET", "/api/v5/account/positions", params=params) + + def set_leverage(self, *, inst_id: str, lever: float, mgn_mode: str = "cross", pos_side: str = "") -> bool: + """ + Set leverage for an instrument (best-effort). + + Endpoint: POST /api/v5/account/set-leverage + Body: + - instId + - lever + - mgnMode: cross / isolated + - posSide: net / long / short (required depending on posMode) + """ + iid = str(inst_id or "").strip() + if not iid: + return False + try: + lv = int(float(lever or 0)) + except Exception: + lv = 0 + if lv <= 0: + lv = 1 + + mm = str(mgn_mode or "cross").strip().lower() + if mm not in ("cross", "isolated"): + mm = "cross" + + ps = str(pos_side or "").strip().lower() + # In net_mode, OKX requires posSide=net. In long_short_mode, requires long/short. + # Caller should pass already resolved posSide; but keep a safe fallback. + if ps not in ("net", "long", "short"): + try: + cfg = self.get_account_config() or {} + pm = str(cfg.get("posMode") or "").strip().lower() + ps = "net" if pm in ("net_mode", "net") else "" + except Exception: + ps = "" + + cache_key = f"{iid}:{mm}:{ps}:{lv}" + now = time.time() + cached = self._lev_cache.get(cache_key) + if cached: + ts, ok = cached + if ok and (now - float(ts or 0.0)) <= float(self._lev_cache_ttl_sec or 60.0): + return True + + body: Dict[str, Any] = {"instId": iid, "lever": str(lv), "mgnMode": mm} + if ps: + body["posSide"] = ps + _ = self._signed_request("POST", "/api/v5/account/set-leverage", json_body=body) + self._lev_cache[cache_key] = (now, True) + return True + + def get_account_config(self) -> Dict[str, Any]: + """ + Get account configuration (best-effort). + + Endpoint: GET /api/v5/account/config + Important field: + - posMode: "net_mode" or "long_short_mode" + """ + key = "account_config" + now = time.time() + cached = self._acct_cfg_cache.get(key) + if cached: + ts, obj = cached + if obj and (now - float(ts or 0.0)) <= float(self._acct_cfg_cache_ttl_sec or 30.0): + return obj + + raw = self._signed_request("GET", "/api/v5/account/config") + data = (raw.get("data") or []) if isinstance(raw, dict) else [] + first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {} + if isinstance(first, dict) and first: + self._acct_cfg_cache[key] = (now, first) + return first if isinstance(first, dict) else {} + + def _resolve_pos_side(self, *, requested_pos_side: str, market_type: str) -> str: + """ + OKX swap position mode compatibility: + - long_short_mode: posSide must be "long" or "short" + - net_mode: posSide must be "net" (and close orders should use reduceOnly=true) + """ + mt = (market_type or "swap").strip().lower() + if mt == "spot": + return "" + + ps = (requested_pos_side or "").strip().lower() + # Default to long/short requested. + pos_mode = "" + try: + cfg = self.get_account_config() or {} + pos_mode = str(cfg.get("posMode") or "").strip().lower() + except Exception: + pos_mode = "" + + if pos_mode in ("net_mode", "net"): + return "net" + if pos_mode in ("long_short_mode", "longshort_mode", "long_short", "longshort"): + if ps not in ("long", "short"): + raise LiveTradingError(f"Invalid posSide for long_short_mode: {requested_pos_side}") + return ps + + # Unknown mode: be permissive but keep existing validation. + if ps not in ("long", "short"): + raise LiveTradingError(f"Invalid posSide: {requested_pos_side}") + return ps + + def place_market_order( + self, + *, + symbol: str, + side: str, + size: float, + market_type: str = "swap", + pos_side: str = "", + td_mode: str = "cross", + reduce_only: bool = False, + client_order_id: Optional[str] = None, + ) -> LiveOrderResult: + mt = (market_type or "swap").strip().lower() + inst_id = to_okx_spot_inst_id(symbol) if mt == "spot" else to_okx_swap_inst_id(symbol) + sd = (side or "").lower() + if sd not in ("buy", "sell"): + raise LiveTradingError(f"Invalid side: {side}") + sz_raw = float(size or 0.0) + sz_dec = self._normalize_order_size(inst_id=inst_id, market_type=mt, size=sz_raw) + if float(sz_dec or 0) <= 0: + raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}") + + if mt == "spot": + body: Dict[str, Any] = { + "instId": inst_id, + "tdMode": "cash", + "side": sd, + "ordType": "market", + "sz": self._dec_str(sz_dec), + # Follow hummingbot approach so "sz" is in base currency. + "tgtCcy": "base_ccy", + } + else: + ps = self._resolve_pos_side(requested_pos_side=pos_side, market_type=mt) + td = (td_mode or "cross").lower() + if td not in ("cross", "isolated"): + td = "cross" + body = { + "instId": inst_id, + "tdMode": td, + "side": sd, + "posSide": ps, + "ordType": "market", + "sz": self._dec_str(sz_dec), + } + if reduce_only: + body["reduceOnly"] = "true" + if client_order_id: + body["clOrdId"] = str(client_order_id) + + raw = self._signed_request("POST", "/api/v5/trade/order", json_body=body) + data = (raw.get("data") or []) if isinstance(raw, dict) else [] + first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {} + exchange_order_id = str(first.get("ordId") or first.get("clOrdId") or "") + + # OKX place-order does not guarantee fill fields. Keep them best-effort. + filled = 0.0 + avg_price = 0.0 + return LiveOrderResult( + exchange_id="okx", + exchange_order_id=exchange_order_id, + filled=filled, + avg_price=avg_price, + raw=raw, + ) + + def place_limit_order( + self, + *, + market_type: str, + symbol: str, + side: str, + size: float, + price: float, + pos_side: str = "", + td_mode: str = "cross", + reduce_only: bool = False, + client_order_id: Optional[str] = None, + ) -> LiveOrderResult: + mt = (market_type or "swap").strip().lower() + sd = (side or "").lower() + if sd not in ("buy", "sell"): + raise LiveTradingError(f"Invalid side: {side}") + sz_raw = float(size or 0.0) + px = float(price or 0.0) + if sz_raw <= 0 or px <= 0: + raise LiveTradingError("Invalid size/price") + + if mt == "spot": + inst_id = to_okx_spot_inst_id(symbol) + sz_dec = self._normalize_order_size(inst_id=inst_id, market_type=mt, size=sz_raw) + if float(sz_dec or 0) <= 0: + raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}") + body: Dict[str, Any] = { + "instId": inst_id, + "tdMode": "cash", + "side": sd, + "ordType": "limit", + "sz": self._dec_str(sz_dec), + "px": str(px), + } + else: + inst_id = to_okx_swap_inst_id(symbol) + ps = self._resolve_pos_side(requested_pos_side=pos_side, market_type=mt) + sz_dec = self._normalize_order_size(inst_id=inst_id, market_type=mt, size=sz_raw) + if float(sz_dec or 0) <= 0: + raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}") + td = (td_mode or "cross").lower() + if td not in ("cross", "isolated"): + td = "cross" + body = { + "instId": inst_id, + "tdMode": td, + "side": sd, + "posSide": ps, + "ordType": "limit", + "sz": self._dec_str(sz_dec), + "px": str(px), + } + if reduce_only: + body["reduceOnly"] = "true" + + if client_order_id: + body["clOrdId"] = str(client_order_id) + + raw = self._signed_request("POST", "/api/v5/trade/order", json_body=body) + data = (raw.get("data") or []) if isinstance(raw, dict) else [] + first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {} + exchange_order_id = str(first.get("ordId") or first.get("clOrdId") or "") + return LiveOrderResult(exchange_id="okx", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw) + + def cancel_order(self, *, market_type: str, symbol: str, ord_id: str = "", cl_ord_id: str = "") -> Dict[str, Any]: + mt = (market_type or "swap").strip().lower() + if mt == "spot": + inst_id = to_okx_spot_inst_id(symbol) + else: + inst_id = to_okx_swap_inst_id(symbol) + body: Dict[str, Any] = {"instId": inst_id} + if ord_id: + body["ordId"] = str(ord_id) + elif cl_ord_id: + body["clOrdId"] = str(cl_ord_id) + else: + raise LiveTradingError("OKX cancel_order requires ord_id or cl_ord_id") + return self._signed_request("POST", "/api/v5/trade/cancel-order", json_body=body) + + def get_order(self, *, inst_id: str, ord_id: str = "", cl_ord_id: str = "") -> Dict[str, Any]: + params: Dict[str, Any] = {"instId": str(inst_id)} + if ord_id: + params["ordId"] = str(ord_id) + elif cl_ord_id: + params["clOrdId"] = str(cl_ord_id) + else: + raise LiveTradingError("OKX get_order requires ord_id or cl_ord_id") + resp = self._signed_request("GET", "/api/v5/trade/order", params=params) + data = (resp.get("data") or []) if isinstance(resp, dict) else [] + first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {} + return first + + def get_order_fills(self, *, inst_id: str, ord_id: str, inst_type: str = "SWAP") -> Dict[str, Any]: + params: Dict[str, Any] = {"instId": str(inst_id), "ordId": str(ord_id), "instType": str(inst_type)} + return self._signed_request("GET", "/api/v5/trade/fills", params=params) + + def wait_for_fill( + self, + *, + symbol: str, + ord_id: str, + cl_ord_id: str = "", + market_type: str = "swap", + max_wait_sec: float = 3.0, + poll_interval_sec: float = 0.5, + ) -> Dict[str, Any]: + """ + Poll order detail / fills to obtain (best-effort) executed size and average price. + + Returns: + { + "filled": float, + "avg_price": float, + "fee": float, + "fee_ccy": str, + "state": str, + "order": {...}, + "fills": {...} + } + """ + mt = (market_type or "swap").strip().lower() + inst_id = to_okx_spot_inst_id(symbol) if mt == "spot" else to_okx_swap_inst_id(symbol) + # IMPORTANT: For OKX SWAP, fillSz/accFillSz are in "contracts" (张数), not base-asset quantity. + # Our system standardizes on base-asset quantity everywhere ("币数"), so we convert using ctVal. + ct_val = Decimal("0") + if mt != "spot": + try: + inst = self.get_instrument(inst_type="SWAP", inst_id=inst_id) or {} + ct_val = self._to_dec(inst.get("ctVal") or "0") + except Exception: + ct_val = Decimal("0") + if ct_val <= 0: + # Fallback: keep quantities unchanged if ctVal is unavailable (best-effort). + ct_val = Decimal("1") + end_ts = time.time() + float(max_wait_sec or 0.0) + last_order: Dict[str, Any] = {} + last_fills: Dict[str, Any] = {} + + while True: + try: + last_order = self.get_order(inst_id=inst_id, ord_id=str(ord_id or ""), cl_ord_id=str(cl_ord_id or "")) + except Exception: + last_order = last_order or {} + + state = str(last_order.get("state") or "") + filled_str = str(last_order.get("accFillSz") or last_order.get("fillSz") or "0") + avg_str = str(last_order.get("avgPx") or last_order.get("fillPx") or "0") + try: + filled_contracts = self._to_dec(filled_str or "0") + except Exception: + filled_contracts = Decimal("0") + try: + avg_price = float(avg_str or 0.0) + except Exception: + avg_price = 0.0 + + filled_base_dec = filled_contracts + if mt != "spot": + filled_base_dec = filled_contracts * ct_val + try: + filled = float(filled_base_dec or 0) + except Exception: + filled = 0.0 + + # Prefer fills endpoint for fee (and more reliable avg/filled aggregation). + try: + inst_type = "SPOT" if mt == "spot" else "SWAP" + last_fills = self.get_order_fills(inst_id=inst_id, ord_id=str(ord_id), inst_type=inst_type) + fills = (last_fills.get("data") or []) if isinstance(last_fills, dict) else [] + total_base = Decimal("0") + total_quote = Decimal("0") + total_fee = 0.0 + fee_ccy = "" + got_any_fill = False + if isinstance(fills, list): + for f in fills: + try: + sz_contracts = self._to_dec(f.get("fillSz") or "0") + px = self._to_dec(f.get("fillPx") or "0") + fee_v = f.get("fee") + if fee_v is None: + fee_v = f.get("fillFee") + try: + fee = float(fee_v or 0.0) + except Exception: + fee = 0.0 + ccy = str(f.get("feeCcy") or f.get("fillFeeCcy") or "").strip() + sz_base = sz_contracts + if mt != "spot": + sz_base = sz_contracts * ct_val + if sz_base > 0 and px > 0: + total_base += sz_base + total_quote += sz_base * px + got_any_fill = True + if fee != 0.0: + # OKX fees are often negative for costs; store absolute cost. + total_fee += abs(float(fee)) + if (not fee_ccy) and ccy: + fee_ccy = ccy + except Exception: + continue + # If fills are present, they are the best source of fee/avg aggregation. + # However, OKX may lag in exposing fills right after an order is filled. + # To avoid losing commission, do not fall back early when we haven't seen any fills yet. + if got_any_fill and total_base > 0 and total_quote > 0: + return { + "filled": float(total_base), + "avg_price": float(total_quote / total_base), + "fee": float(total_fee), + "fee_ccy": str(fee_ccy or ""), + "state": state, + "order": last_order, + "fills": last_fills, + "filled_unit": "base", + } + except Exception: + pass + + # Fallback: order detail may include avg/filled but fee is not available. + # IMPORTANT: If the order is already filled but fills endpoint hasn't returned data yet, + # keep polling until timeout to give fills a chance to show up (so we can record fees). + if filled > 0 and avg_price > 0 and time.time() >= end_ts: + return { + "filled": filled, + "avg_price": avg_price, + "fee": 0.0, + "fee_ccy": "", + "state": state, + "order": last_order, + "fills": last_fills, + "filled_unit": "base", + } + + # Terminal states: return whatever we have. + if state in ("filled", "canceled", "cancelled"): + if time.time() >= end_ts: + return {"filled": filled, "avg_price": avg_price, "fee": 0.0, "fee_ccy": "", "state": state, "order": last_order, "fills": last_fills} + + if time.time() >= end_ts: + return {"filled": filled, "avg_price": avg_price, "fee": 0.0, "fee_ccy": "", "state": state, "order": last_order, "fills": last_fills} + time.sleep(float(poll_interval_sec or 0.5)) + + diff --git a/backend_api_python/app/services/live_trading/records.py b/backend_api_python/app/services/live_trading/records.py new file mode 100644 index 0000000..d5b7736 --- /dev/null +++ b/backend_api_python/app/services/live_trading/records.py @@ -0,0 +1,204 @@ +""" +DB helpers for recording live trades and maintaining local position snapshots. + +Important: +- This is a local DB snapshot, not the source of truth (exchange is). +- We keep it best-effort to support UI display and strategy state. +""" + +from __future__ import annotations + +import time +from typing import Any, Dict, Optional, Tuple + +from app.utils.db import get_db_connection + + +def record_trade( + *, + strategy_id: int, + symbol: str, + trade_type: str, + price: float, + amount: float, + commission: float = 0.0, + commission_ccy: str = "", + profit: Optional[float] = None, +) -> None: + now = int(time.time()) + value = float(amount or 0.0) * float(price or 0.0) + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + INSERT INTO qd_strategy_trades + (strategy_id, symbol, type, price, amount, value, commission, commission_ccy, profit, created_at) + VALUES + (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + int(strategy_id), + str(symbol), + str(trade_type), + float(price or 0.0), + float(amount or 0.0), + float(value), + float(commission or 0.0), + str(commission_ccy or ""), + profit, + now, + ), + ) + db.commit() + cur.close() + + +def _fetch_position(strategy_id: int, symbol: str, side: str) -> Dict[str, Any]: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "SELECT * FROM qd_strategy_positions WHERE strategy_id = %s AND symbol = %s AND side = %s", + (int(strategy_id), str(symbol), str(side)), + ) + row = cur.fetchone() or {} + cur.close() + return row if isinstance(row, dict) else {} + + +def _delete_position(strategy_id: int, symbol: str, side: str) -> None: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "DELETE FROM qd_strategy_positions WHERE strategy_id = %s AND symbol = %s AND side = %s", + (int(strategy_id), str(symbol), str(side)), + ) + db.commit() + cur.close() + + +def upsert_position( + *, + strategy_id: int, + symbol: str, + side: str, + size: float, + entry_price: float, + current_price: float, + highest_price: float = 0.0, + lowest_price: float = 0.0, +) -> None: + now = int(time.time()) + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + INSERT INTO qd_strategy_positions + (strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at) + VALUES + (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT(strategy_id, symbol, side) DO UPDATE SET + size = excluded.size, + entry_price = excluded.entry_price, + current_price = excluded.current_price, + highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE highest_price END, + lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE lowest_price END, + updated_at = excluded.updated_at + """, + (int(strategy_id), str(symbol), str(side), float(size or 0.0), float(entry_price or 0.0), float(current_price or 0.0), float(highest_price or 0.0), float(lowest_price or 0.0), now), + ) + db.commit() + cur.close() + + +def apply_fill_to_local_position( + *, + strategy_id: int, + symbol: str, + signal_type: str, + filled: float, + avg_price: float, +) -> Tuple[Optional[float], Optional[Dict[str, Any]]]: + """ + Apply a fill to the local position snapshot. + + Returns (profit, updated_position_row_or_none) + - profit is only calculated on close/reduce fills (best-effort, based on local entry_price). + """ + sig = (signal_type or "").strip().lower() + filled_qty = float(filled or 0.0) + px = float(avg_price or 0.0) + if filled_qty <= 0 or px <= 0: + return None, None + + if "long" in sig: + side = "long" + elif "short" in sig: + side = "short" + else: + return None, None + + is_open = sig.startswith("open_") or sig.startswith("add_") + is_close = sig.startswith("close_") or sig.startswith("reduce_") + + current = _fetch_position(strategy_id, symbol, side) + cur_size = float(current.get("size") or 0.0) + cur_entry = float(current.get("entry_price") or 0.0) + cur_high = float(current.get("highest_price") or 0.0) + cur_low = float(current.get("lowest_price") or 0.0) + + profit: Optional[float] = None + + if is_open: + new_size = cur_size + filled_qty + if new_size <= 0: + return None, None + # Weighted average entry. + if cur_size > 0 and cur_entry > 0: + new_entry = (cur_size * cur_entry + filled_qty * px) / new_size + else: + new_entry = px + new_high = max(cur_high or px, px) + new_low = min(cur_low or px, px) + upsert_position( + strategy_id=strategy_id, + symbol=symbol, + side=side, + size=new_size, + entry_price=new_entry, + current_price=px, + highest_price=new_high, + lowest_price=new_low, + ) + return None, _fetch_position(strategy_id, symbol, side) + + if is_close: + # Calculate PnL using local entry price. + if cur_size > 0 and cur_entry > 0: + close_qty = min(cur_size, filled_qty) + if side == "long": + profit = (px - cur_entry) * close_qty + else: + profit = (cur_entry - px) * close_qty + + new_size = cur_size - filled_qty + if new_size <= 0: + _delete_position(strategy_id, symbol, side) + return profit, None + # Keep entry price for remaining position. + new_high = max(cur_high or px, px) + new_low = min(cur_low or px, px) + upsert_position( + strategy_id=strategy_id, + symbol=symbol, + side=side, + size=new_size, + entry_price=cur_entry if cur_entry > 0 else px, + current_price=px, + highest_price=new_high, + lowest_price=new_low, + ) + return profit, _fetch_position(strategy_id, symbol, side) + + return None, None + + diff --git a/backend_api_python/app/services/live_trading/symbols.py b/backend_api_python/app/services/live_trading/symbols.py new file mode 100644 index 0000000..3011e81 --- /dev/null +++ b/backend_api_python/app/services/live_trading/symbols.py @@ -0,0 +1,55 @@ +""" +Symbol normalization helpers. + +Input symbols may come from UI/strategy config in ccxt-like shape: +- "SOL/USDT:USDT" +- "SOL/USDT" + +We convert them into exchange-specific identifiers. +""" + +from __future__ import annotations + +from typing import Tuple + + +def _split_base_quote(symbol: str) -> Tuple[str, str]: + s = (symbol or "").strip() + if ":" in s: + s = s.split(":", 1)[0] + if "/" not in s: + # Already exchange-specific (best-effort) + return s, "" + base, quote = s.split("/", 1) + return base.strip().upper(), quote.strip().upper() + + +def to_binance_futures_symbol(symbol: str) -> str: + base, quote = _split_base_quote(symbol) + if not quote: + return (symbol or "").replace("/", "").replace(":", "").upper() + return f"{base}{quote}" + + +def to_okx_swap_inst_id(symbol: str) -> str: + base, quote = _split_base_quote(symbol) + if not base or not quote: + return symbol + # OKX perpetual swap instrument id: BASE-QUOTE-SWAP + return f"{base}-{quote}-SWAP" + + +def to_okx_spot_inst_id(symbol: str) -> str: + base, quote = _split_base_quote(symbol) + if not base or not quote: + return symbol + return f"{base}-{quote}" + + +def to_bitget_um_symbol(symbol: str) -> str: + base, quote = _split_base_quote(symbol) + if not quote: + return (symbol or "").replace("/", "").replace(":", "").upper() + return f"{base}{quote}" + + diff --git a/backend_api_python/app/services/llm.py b/backend_api_python/app/services/llm.py new file mode 100644 index 0000000..119c874 --- /dev/null +++ b/backend_api_python/app/services/llm.py @@ -0,0 +1,161 @@ +""" +LLM service. +Wraps OpenRouter API calls and robust JSON parsing. +Kept separate from AnalysisService to avoid circular imports. +""" +import json +import requests +from typing import Dict, Any, Optional, List + +from app.utils.logger import get_logger +from app.config import APIKeys +from app.utils.config_loader import load_addon_config + +logger = get_logger(__name__) + + +class LLMService: + """LLM provider wrapper.""" + + def __init__(self): + # Config may not be loaded yet during import time; we resolve lazily via properties. + pass + + @property + def api_key(self): + return APIKeys.OPENROUTER_API_KEY + + @property + def base_url(self): + config = load_addon_config() + # Keep compatible with old/new config keys. + import os + return config.get('openrouter', {}).get('base_url') or os.getenv('OPENROUTER_BASE_URL', "https://openrouter.ai/api/v1") + + def call_openrouter_api(self, messages: list, model: str = None, temperature: float = 0.7, use_fallback: bool = True) -> str: + """Call OpenRouter API, with optional fallback models.""" + config = load_addon_config() + openrouter_config = config.get('openrouter', {}) + + default_model = openrouter_config.get('model', 'google/gemini-3-pro-preview') + + if model is None: + model = default_model + + url = f"{self.base_url}/chat/completions" + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "HTTP-Referer": "https://quantdinger.com", + "X-Title": "QuantDinger Analysis" + } + + # Build model candidates (primary + optional fallbacks). + models_to_try = [model] + + # Fallback models are currently hard-coded for local mode. + fallback_models = ["openai/gpt-4o-mini"] + + if use_fallback and model == default_model: + models_to_try.extend(fallback_models) + + last_error = None + + timeout = int(openrouter_config.get('timeout', 120)) + + for current_model in models_to_try: + try: + data = { + "model": current_model, + "messages": messages, + "temperature": temperature, + "response_format": {"type": "json_object"} + } + # logger.debug(f"Trying model: {current_model}") + + response = requests.post(url, headers=headers, json=data, timeout=timeout) + + if response.status_code == 402: + logger.warning(f"OpenRouter returned 402 for model {current_model}; trying fallback model...") + last_error = f"402 Payment Required for model {current_model}" + continue + + response.raise_for_status() + + result = response.json() + if "choices" in result and len(result["choices"]) > 0: + content = result["choices"][0]["message"]["content"] + if not content: + raise ValueError(f"Model {current_model} returned empty content") + + if current_model != model: + logger.info(f"Fallback model succeeded: {current_model}") + return content + else: + logger.error(f"OpenRouter API returned unexpected structure ({current_model}): {json.dumps(result)}") + raise ValueError("OpenRouter API response is missing 'choices'") + + except requests.exceptions.HTTPError as e: + logger.error(f"OpenRouter API HTTP error ({current_model}): {e.response.text if e.response else str(e)}") + last_error = str(e) + if not use_fallback or current_model == models_to_try[-1]: + raise + except requests.exceptions.RequestException as e: + logger.error(f"OpenRouter API request error ({current_model}): {str(e)}") + last_error = str(e) + if not use_fallback or current_model == models_to_try[-1]: + raise + except ValueError as e: + logger.warning(f"Model {current_model} returned invalid data: {str(e)}") + last_error = str(e) + # If this is not the last candidate model, try the next one + if current_model == models_to_try[-1]: + raise + + error_msg = f"All model calls failed. Last error: {last_error}" + logger.error(error_msg) + raise Exception(error_msg) + + def safe_call_llm(self, system_prompt: str, user_prompt: str, default_structure: Dict[str, Any], model: str = None) -> Dict[str, Any]: + """Safe LLM call with robust JSON parsing and fallback structure.""" + response_text = "" + try: + response_text = self.call_openrouter_api([ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], model=model) + + # Strip markdown fences if present + clean_text = response_text.strip() + if clean_text.startswith("```"): + first_newline = clean_text.find("\n") + if first_newline != -1: + clean_text = clean_text[first_newline+1:] + if clean_text.endswith("```"): + clean_text = clean_text[:-3] + clean_text = clean_text.strip() + + # Parse JSON + result = json.loads(clean_text) + return result + except json.JSONDecodeError: + logger.error(f"JSON parse failed. Raw text: {response_text[:200] if response_text else 'N/A'}") + + # Try extracting JSON substring + try: + if response_text: + start = response_text.find('{') + end = response_text.rfind('}') + 1 + if start >= 0 and end > start: + result = json.loads(response_text[start:end]) + return result + except: + pass + + default_structure['report'] = f"Failed to parse analysis result JSON. Raw output (partial): {response_text[:500] if response_text else 'N/A'}" + return default_structure + except Exception as e: + logger.error(f"LLM call failed: {str(e)}") + default_structure['report'] = f"Analysis failed: {str(e)}" + return default_structure diff --git a/backend_api_python/app/services/pending_order_worker.py b/backend_api_python/app/services/pending_order_worker.py new file mode 100644 index 0000000..372c0ce --- /dev/null +++ b/backend_api_python/app/services/pending_order_worker.py @@ -0,0 +1,1133 @@ +""" +Pending order worker. + +This worker polls `pending_orders` periodically and dispatches orders based on `execution_mode`: +- signal: send notifications (no real trading). +- live: not implemented (paper mode only). +""" + +from __future__ import annotations + +import json +import os +import threading +import time +from typing import Any, Dict, List, Optional + +from app.services.signal_notifier import SignalNotifier +from app.services.exchange_execution import load_strategy_configs, resolve_exchange_config, safe_exchange_config_for_log +from app.services.live_trading.execution import place_order_from_signal +from app.services.live_trading.factory import create_client +from app.services.live_trading.records import apply_fill_to_local_position, record_trade +from app.services.live_trading.base import LiveTradingError +from app.services.live_trading.binance import BinanceFuturesClient +from app.services.live_trading.binance_spot import BinanceSpotClient +from app.services.live_trading.okx import OkxClient +from app.services.live_trading.bitget import BitgetMixClient +from app.services.live_trading.bitget_spot import BitgetSpotClient +from app.services.live_trading.symbols import to_okx_swap_inst_id +from app.utils.db import get_db_connection +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +class PendingOrderWorker: + def __init__(self, poll_interval_sec: float = 1.0, batch_size: int = 50): + self.poll_interval_sec = float(poll_interval_sec) + self.batch_size = int(batch_size) + self._stop_event = threading.Event() + self._thread: Optional[threading.Thread] = None + self._lock = threading.Lock() + self._notifier = SignalNotifier() + + # Reclaim stuck orders (e.g. if the worker crashed after claiming an order). + try: + self._stale_processing_sec = int(os.getenv("PENDING_ORDER_STALE_SEC", "90")) + except Exception: + self._stale_processing_sec = 90 + + # Position sync self-check (best-effort): keep local positions aligned with exchange. + self._position_sync_enabled = os.getenv("POSITION_SYNC_ENABLED", "true").lower() == "true" + self._position_sync_interval_sec = float(os.getenv("POSITION_SYNC_INTERVAL_SEC", "10")) + self._last_position_sync_ts = 0.0 + + def start(self) -> bool: + with self._lock: + if self._thread and self._thread.is_alive(): + return True + self._stop_event.clear() + self._thread = threading.Thread(target=self._run_loop, name="PendingOrderWorker", daemon=True) + self._thread.start() + logger.info("PendingOrderWorker started") + return True + + def stop(self, timeout_sec: float = 5.0) -> None: + with self._lock: + self._stop_event.set() + th = self._thread + if th and th.is_alive(): + th.join(timeout=timeout_sec) + logger.info("PendingOrderWorker stopped") + + def _run_loop(self) -> None: + while not self._stop_event.is_set(): + try: + self._tick() + except Exception as e: + logger.warning(f"PendingOrderWorker tick error: {e}") + time.sleep(self.poll_interval_sec) + + def _tick(self) -> None: + orders = self._fetch_pending_orders(limit=self.batch_size) + if not orders: + self._maybe_sync_positions() + return + + for o in orders: + oid = o.get("id") + if not oid: + continue + + # Mark processing (best-effort) + if not self._mark_processing(order_id=int(oid)): + continue + + try: + self._dispatch_one(o) + except Exception as e: + self._mark_failed(order_id=int(oid), error=str(e)) + + self._maybe_sync_positions() + + def _maybe_sync_positions(self) -> None: + if not self._position_sync_enabled: + return + now = time.time() + if self._position_sync_interval_sec <= 0: + return + if now - float(self._last_position_sync_ts or 0.0) < float(self._position_sync_interval_sec): + return + self._last_position_sync_ts = now + try: + self._sync_positions_best_effort() + except Exception as e: + logger.info(f"position sync skipped/failed: {e}") + + def _sync_positions_best_effort(self) -> None: + """ + Best-effort reconciliation: + - If exchange position is flat, delete local row from qd_strategy_positions. + - If exchange position size differs, update local size (optional best-effort). + + This prevents "ghost positions" when positions are closed externally on the exchange. + """ + # 1) Load local positions + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT id, strategy_id, symbol, side, size, entry_price FROM qd_strategy_positions ORDER BY updated_at DESC") + rows = cur.fetchall() or [] + cur.close() + + if not rows: + return + + # Group by strategy_id for efficient exchange queries. + sid_to_rows: Dict[int, List[Dict[str, Any]]] = {} + for r in rows: + sid = int(r.get("strategy_id") or 0) + if sid <= 0: + continue + sid_to_rows.setdefault(sid, []).append(r) + + # 2) Reconcile per strategy + for sid, plist in sid_to_rows.items(): + try: + sc = load_strategy_configs(int(sid)) + if (sc.get("execution_mode") or "").strip().lower() != "live": + continue + exchange_config = resolve_exchange_config(sc.get("exchange_config") or {}) + safe_cfg = safe_exchange_config_for_log(exchange_config) + market_type = (sc.get("market_type") or exchange_config.get("market_type") or "swap") + market_type = str(market_type or "swap").strip().lower() + if market_type in ("futures", "future", "perp", "perpetual"): + market_type = "swap" + + client = create_client(exchange_config, market_type=market_type) + + # Build an "exchange snapshot" per symbol+side + exch_size: Dict[str, Dict[str, float]] = {} # {symbol: {long: size, short: size}} + + if isinstance(client, BinanceFuturesClient) and market_type == "swap": + all_pos = client.get_positions() or [] + if isinstance(all_pos, list): + for p in all_pos: + sym = str(p.get("symbol") or "").strip().upper() + try: + amt = float(p.get("positionAmt") or 0.0) + except Exception: + amt = 0.0 + if not sym or abs(amt) <= 0: + continue + # Map to our symbol format: BTCUSDT -> BTC/USDT (best-effort) + hb_sym = sym + if hb_sym.endswith("USDT") and len(hb_sym) > 4 and "/" not in hb_sym: + hb_sym = f"{hb_sym[:-4]}/USDT" + side = "long" if amt > 0 else "short" + exch_size.setdefault(hb_sym, {"long": 0.0, "short": 0.0})[side] = abs(float(amt)) + + elif isinstance(client, OkxClient) and market_type == "swap": + resp = client.get_positions() + data = (resp.get("data") or []) if isinstance(resp, dict) else [] + if isinstance(data, list): + for p in data: + inst_id = str(p.get("instId") or "") + pos_side = str(p.get("posSide") or "").lower() + try: + pos = float(p.get("pos") or 0.0) + except Exception: + pos = 0.0 + if not inst_id or abs(pos) <= 0: + continue + # instId: BTC-USDT-SWAP -> BTC/USDT + hb_sym = inst_id.replace("-SWAP", "").replace("-", "/") + side = "long" if pos_side == "long" else ("short" if pos_side == "short" else ("long" if pos > 0 else "short")) + # IMPORTANT: OKX swap positions `pos` is in contracts (张数), but our system uses base-asset quantity. + # Convert contracts -> base using ctVal when available. + qty_base = abs(float(pos)) + try: + inst = client.get_instrument(inst_type="SWAP", inst_id=inst_id) or {} + ct_val = float(inst.get("ctVal") or 0.0) + if ct_val > 0: + qty_base = qty_base * ct_val + except Exception: + pass + exch_size.setdefault(hb_sym, {"long": 0.0, "short": 0.0})[side] = float(qty_base) + + elif isinstance(client, BitgetMixClient) and market_type == "swap": + product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES") + resp = client.get_positions(product_type=product_type) + data = resp.get("data") if isinstance(resp, dict) else None + if isinstance(data, list): + for p in data: + sym = str(p.get("symbol") or "") + hold_side = str(p.get("holdSide") or "").lower() + try: + total = float(p.get("total") or 0.0) + except Exception: + total = 0.0 + if not sym or abs(total) <= 0: + continue + # Symbol is like BTCUSDT -> BTC/USDT best-effort + hb_sym = sym.upper() + if hb_sym.endswith("USDT") and len(hb_sym) > 4 and "/" not in hb_sym: + hb_sym = f"{hb_sym[:-4]}/USDT" + side = "long" if hold_side == "long" else "short" + exch_size.setdefault(hb_sym, {"long": 0.0, "short": 0.0})[side] = abs(float(total)) + + else: + # Spot reconciliation is optional; skip for now (keeps self-check low-risk). + logger.debug(f"position sync: skip unsupported market/client: sid={sid}, cfg={safe_cfg}, market_type={market_type}, client={type(client)}") + continue + + # 3) Apply reconciliation to local rows. + to_delete_ids: List[int] = [] + to_update: List[Dict[str, Any]] = [] + eps = 1e-12 + + for r in plist: + rid = int(r.get("id") or 0) + sym = str(r.get("symbol") or "").strip() + side = str(r.get("side") or "").strip().lower() + if not rid or not sym or side not in ("long", "short"): + continue + try: + local_size = float(r.get("size") or 0.0) + except Exception: + local_size = 0.0 + + exch = exch_size.get(sym) or {} + exch_qty = float(exch.get(side) or 0.0) + + if exch_qty <= eps: + # Exchange is flat -> delete local position (self-heal). + to_delete_ids.append(rid) + else: + # Update local size if it diverged materially (best-effort). + if local_size <= 0 or abs(exch_qty - local_size) / max(1.0, local_size) > 0.01: + to_update.append({"id": rid, "size": exch_qty}) + + if not to_delete_ids and not to_update: + continue + + with get_db_connection() as db: + cur = db.cursor() + for rid in to_delete_ids: + cur.execute("DELETE FROM qd_strategy_positions WHERE id = %s", (int(rid),)) + now_ts = int(time.time()) + for u in to_update: + cur.execute("UPDATE qd_strategy_positions SET size = %s, updated_at = %s WHERE id = %s", (float(u["size"]), now_ts, int(u["id"]))) + db.commit() + cur.close() + + if to_delete_ids: + logger.info(f"position sync: removed {len(to_delete_ids)} ghost positions for strategy_id={sid}") + except Exception as e: + logger.info(f"position sync: strategy_id={sid} failed: {e}") + + def _fetch_pending_orders(self, limit: int = 50) -> List[Dict[str, Any]]: + try: + # Best-effort: requeue stale "processing" rows to avoid deadlocks after crashes. + try: + stale_sec = int(self._stale_processing_sec or 0) + except Exception: + stale_sec = 0 + if stale_sec > 0: + now = int(time.time()) + cutoff = now - stale_sec + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + UPDATE pending_orders + SET status = 'pending', + updated_at = %s, + dispatch_note = CASE + WHEN dispatch_note IS NULL OR dispatch_note = '' THEN 'requeued_stale_processing' + ELSE dispatch_note + END + WHERE status = 'processing' + AND (updated_at IS NULL OR updated_at < %s) + AND (attempts < max_attempts) + """, + (now, cutoff), + ) + db.commit() + cur.close() + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT * + FROM pending_orders + WHERE status = 'pending' + AND (attempts < max_attempts) + ORDER BY priority DESC, id ASC + LIMIT %s + """, + (int(limit),), + ) + rows = cur.fetchall() or [] + cur.close() + return rows + except Exception as e: + logger.warning(f"fetch_pending_orders failed: {e}") + return [] + + def _mark_processing(self, order_id: int) -> bool: + try: + now = int(time.time()) + with get_db_connection() as db: + cur = db.cursor() + # Only claim if still pending to avoid double-processing. + cur.execute( + """ + UPDATE pending_orders + SET status = 'processing', + attempts = COALESCE(attempts, 0) + 1, + processed_at = %s, + updated_at = %s + WHERE id = %s AND status = 'pending' + """, + (now, now, int(order_id)), + ) + claimed = getattr(cur, "rowcount", None) + db.commit() + cur.close() + # Only treat as success if we actually changed a row. + if claimed is None: + return True + return int(claimed) > 0 + except Exception as e: + logger.warning(f"mark_processing failed: id={order_id}, err={e}") + return False + + def _dispatch_one(self, order_row: Dict[str, Any]) -> None: + order_id = int(order_row["id"]) + mode = (order_row.get("execution_mode") or "signal").strip().lower() + payload_json = order_row.get("payload_json") or "" + + payload: Dict[str, Any] = {} + if payload_json and isinstance(payload_json, str): + try: + payload = json.loads(payload_json) or {} + except Exception: + payload = {} + + signal_type = payload.get("signal_type") or order_row.get("signal_type") + symbol = payload.get("symbol") or order_row.get("symbol") + strategy_id = payload.get("strategy_id") or order_row.get("strategy_id") + price = float(payload.get("price") or order_row.get("price") or 0.0) + amount = float(payload.get("amount") or order_row.get("amount") or 0.0) + direction = "short" if "short" in str(signal_type) else "long" + notification_config = payload.get("notification_config") or {} + strategy_name = str(payload.get("strategy_name") or "").strip() + if not strategy_name: + # Best-effort: load from DB for nicer notifications. + strategy_name = self._load_strategy_name(int(strategy_id or 0)) if strategy_id else "" + if not strategy_name: + strategy_name = f"Strategy_{strategy_id}" + + # If the queued record is legacy ("signal") but the strategy is configured as live, + # automatically upgrade it to live execution to keep the system moving. + try: + if mode != "live" and strategy_id: + sc = load_strategy_configs(int(strategy_id)) + if (sc.get("execution_mode") or "").strip().lower() == "live": + mode = "live" + except Exception: + pass + + if mode == "signal": + # Signal-only mode: dispatch notifications (no real trading). + # Note: notification_config is stored in payload_json at enqueue time; fallback to DB if missing. + if (not notification_config) and strategy_id: + notification_config = self._load_notification_config(int(strategy_id)) + + results = self._notifier.notify_signal( + strategy_id=int(strategy_id or 0), + strategy_name=str(strategy_name or ""), + symbol=str(symbol or ""), + signal_type=str(signal_type or ""), + price=float(price or 0.0), + stake_amount=float(amount or 0.0), + direction=str(direction or "long"), + notification_config=notification_config if isinstance(notification_config, dict) else {}, + extra={"pending_order_id": order_id, "mode": mode}, + ) + + attempted = list(results.keys()) + ok_channels = [c for c, r in results.items() if (r or {}).get("ok")] + fail_channels = [c for c, r in results.items() if not (r or {}).get("ok")] + + if ok_channels: + note = f"notified_ok={','.join(ok_channels)}" + if fail_channels: + note += f";fail={','.join(fail_channels)}" + self._mark_sent(order_id=order_id, note=note[:200]) + else: + # Nothing succeeded -> mark failed with a compact error summary. + first_err = "" + for c in attempted: + err = (results.get(c) or {}).get("error") or "" + if err: + first_err = f"{c}:{err}" + break + self._mark_failed(order_id=order_id, error=first_err or "notify_failed") + return + + if mode == "live": + self._execute_live_order(order_id=order_id, order_row=order_row, payload=payload) + return + + self._mark_failed(order_id=order_id, error=f"unsupported_execution_mode:{mode}") + + def _load_notification_config(self, strategy_id: int) -> Dict[str, Any]: + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "SELECT notification_config FROM qd_strategies_trading WHERE id = ?", + (int(strategy_id),), + ) + row = cur.fetchone() or {} + cur.close() + s = row.get("notification_config") or "" + if isinstance(s, dict): + return s + if isinstance(s, str) and s.strip(): + try: + obj = json.loads(s) + return obj if isinstance(obj, dict) else {} + except Exception: + return {} + return {} + except Exception: + return {} + + def _load_strategy_name(self, strategy_id: int) -> str: + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT strategy_name FROM qd_strategies_trading WHERE id = ?", (int(strategy_id),)) + row = cur.fetchone() or {} + cur.close() + return str(row.get("strategy_name") or "").strip() + except Exception: + return "" + + def _execute_live_order(self, *, order_id: int, order_row: Dict[str, Any], payload: Dict[str, Any]) -> None: + """ + Execute a pending order using direct exchange REST clients (no ccxt). + """ + strategy_id = int(payload.get("strategy_id") or order_row.get("strategy_id") or 0) + if strategy_id <= 0: + self._mark_failed(order_id=order_id, error="missing_strategy_id") + return + + def _notify_live_best_effort( + *, + status: str, + error: str = "", + exchange_id: str = "", + exchange_order_id: str = "", + price_hint: Optional[float] = None, + amount_hint: Optional[float] = None, + ) -> None: + """ + Best-effort notifications for live execution. + + Historically this worker only notified in execution_mode='signal'. For real trading ('live'), + users still want Telegram/browser alerts. This hook never blocks or changes order status. + """ + try: + notification_config = payload.get("notification_config") or {} + if (not notification_config) and strategy_id: + notification_config = self._load_notification_config(int(strategy_id)) + if not notification_config: + return + + strategy_name = str(payload.get("strategy_name") or "").strip() + if not strategy_name: + strategy_name = self._load_strategy_name(int(strategy_id)) or f"Strategy_{strategy_id}" + + sym0 = payload.get("symbol") or order_row.get("symbol") or "" + sig0 = payload.get("signal_type") or order_row.get("signal_type") or "" + ref0 = float(payload.get("ref_price") or payload.get("price") or order_row.get("price") or 0.0) + amt0 = float(payload.get("amount") or order_row.get("amount") or 0.0) + + px = float(price_hint) if (price_hint is not None and float(price_hint or 0.0) > 0) else ref0 + amt = float(amount_hint) if (amount_hint is not None and float(amount_hint or 0.0) > 0) else amt0 + + results = self._notifier.notify_signal( + strategy_id=int(strategy_id), + strategy_name=str(strategy_name or ""), + symbol=str(sym0 or ""), + signal_type=str(sig0 or ""), + price=float(px or 0.0), + stake_amount=float(amt or 0.0), + direction=("short" if "short" in str(sig0 or "").lower() else "long"), + notification_config=notification_config if isinstance(notification_config, dict) else {}, + extra={ + "pending_order_id": int(order_id), + "mode": "live", + "status": str(status or ""), + "error": str(error or ""), + "exchange_id": str(exchange_id or ""), + "exchange_order_id": str(exchange_order_id or ""), + }, + ) + ok_channels = [c for c, r in (results or {}).items() if (r or {}).get("ok")] + fail_channels = [c for c, r in (results or {}).items() if not (r or {}).get("ok")] + if ok_channels or fail_channels: + logger.info( + f"live notify: pending_id={order_id}, strategy_id={strategy_id}, " + f"ok={','.join(ok_channels) if ok_channels else '-'} " + f"fail={','.join(fail_channels) if fail_channels else '-'}" + ) + except Exception as e: + logger.info(f"live notify skipped/failed: pending_id={order_id}, strategy_id={strategy_id}, err={e}") + + def _console_print(msg: str) -> None: + try: + print(str(msg or ""), flush=True) + except Exception: + pass + + signal_type = payload.get("signal_type") or order_row.get("signal_type") + symbol = payload.get("symbol") or order_row.get("symbol") + amount = float(payload.get("amount") or order_row.get("amount") or 0.0) + if not symbol or not signal_type: + self._mark_failed(order_id=order_id, error="missing_symbol_or_signal_type") + _console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} missing symbol/signal_type") + _notify_live_best_effort(status="failed", error="missing_symbol_or_signal_type") + return + + cfg = load_strategy_configs(strategy_id) + exchange_config = resolve_exchange_config(cfg.get("exchange_config") or {}) + safe_cfg = safe_exchange_config_for_log(exchange_config) + exchange_id = str(exchange_config.get("exchange_id") or "").strip().lower() + + market_type = (payload.get("market_type") or order_row.get("market_type") or cfg.get("market_type") or exchange_config.get("market_type") or "swap") + market_type = str(market_type or "swap").strip().lower() + if market_type in ("futures", "future", "perp", "perpetual"): + market_type = "swap" + + client = None + try: + client = create_client(exchange_config, market_type=market_type) + except Exception as e: + self._mark_failed(order_id=order_id, error=f"create_client_failed:{e}") + _console_print(f"[worker] create_client_failed: strategy_id={strategy_id} pending_id={order_id} err={e}") + _notify_live_best_effort(status="failed", error=f"create_client_failed:{e}") + return + + def _make_client_oid(phase: str = "") -> str: + """ + Build a client order id. + + OKX has strict clOrdId rules (length <= 32, alphanumeric only in practice). + We generate a compact, deterministic id per (strategy_id, pending_order_id, phase). + """ + ph = str(phase or "").strip().lower() + # Keep ids stable and short. + if exchange_id == "okx": + base = f"qd{int(strategy_id)}{int(order_id)}{ph}" + # Keep only alphanumeric. + base = "".join([c for c in base if c.isalnum()]) + if not base: + base = f"qd{int(strategy_id)}{int(order_id)}" + # OKX max length is 32. + return base[:32] + # Other exchanges are more permissive. + return f"qd_{int(strategy_id)}_{int(order_id)}{('_' + ph) if ph else ''}" + + client_oid = _make_client_oid("") + sig = str(signal_type or "").strip().lower() + # Spot does not support short signals in this system. + if market_type == "spot" and "short" in sig: + self._mark_failed(order_id=order_id, error="spot_market_does_not_support_short_signals") + _console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} spot short not supported") + _notify_live_best_effort(status="failed", error="spot_market_does_not_support_short_signals") + return + + # Unified maker->market fallback settings (defaults: 10 seconds) + order_mode = str(payload.get("order_mode") or payload.get("orderMode") or "maker").strip().lower() + maker_wait_sec = float(payload.get("maker_wait_sec") or payload.get("makerWaitSec") or 10.0) + maker_offset_bps = float(payload.get("maker_offset_bps") or payload.get("makerOffsetBps") or 2.0) + if maker_wait_sec <= 0: + maker_wait_sec = 10.0 + if maker_offset_bps < 0: + maker_offset_bps = 0.0 + maker_offset = maker_offset_bps / 10000.0 + + ref_price = float(payload.get("ref_price") or payload.get("price") or order_row.get("price") or 0.0) + + # Helper: map signal -> side/posSide/reduceOnly + def _signal_to_side_pos_reduce(sig_type: str): + st = (sig_type or "").strip().lower() + if st in ("open_long", "add_long"): + return "buy", "long", False + if st in ("open_short", "add_short"): + return "sell", "short", False + if st in ("close_long", "reduce_long"): + return "sell", "long", True + if st in ("close_short", "reduce_short"): + return "buy", "short", True + raise LiveTradingError(f"Unsupported signal_type: {sig_type}") + + side, pos_side, reduce_only = _signal_to_side_pos_reduce(signal_type) + + # Leverage handling (best-effort): + # - For OKX swap, leverage must be set via private endpoint; otherwise exchange defaults apply. + # - For other exchanges, leverage setting is not implemented yet in this local client. + leverage = payload.get("leverage") + if leverage is None: + leverage = cfg.get("leverage") + try: + leverage = float(leverage or 1.0) + except Exception: + leverage = 1.0 + if leverage <= 0: + leverage = 1.0 + + # Accumulate fills across phases + total_base = 0.0 + total_quote = 0.0 + total_fee = 0.0 + fee_ccy = "" + phases: Dict[str, Any] = {} + + def _apply_fill(filled_qty: float, avg_px: float) -> None: + nonlocal total_base, total_quote + fq = float(filled_qty or 0.0) + px = float(avg_px or 0.0) + if fq > 0 and px > 0: + total_base += fq + total_quote += fq * px + + def _apply_fee(fee: float, ccy: str = "") -> None: + nonlocal total_fee, fee_ccy + try: + fv = float(fee or 0.0) + except Exception: + fv = 0.0 + if fv > 0: + total_fee += fv + if (not fee_ccy) and ccy: + fee_ccy = str(ccy or "") + + def _current_avg() -> float: + return float(total_quote / total_base) if total_base > 0 else 0.0 + + # Decide if we should use limit-first flow. + use_limit_first = order_mode in ("maker", "limit", "limit_first", "maker_then_market") + + remaining = float(amount or 0.0) + if remaining <= 0: + self._mark_failed(order_id=order_id, error="invalid_amount") + _notify_live_best_effort(status="failed", error="invalid_amount", amount_hint=amount) + return + + # Phase 1: limit (hang order) + limit_order_id = "" + if use_limit_first: + try: + # price adjustment to reduce immediate taker fills (best-effort) + limit_price = float(ref_price or 0.0) + if limit_price <= 0: + raise LiveTradingError("missing_ref_price_for_limit_order") + if side == "buy": + limit_price = limit_price * (1.0 - maker_offset) + else: + limit_price = limit_price * (1.0 + maker_offset) + + limit_client_oid = _make_client_oid("lmt") + if isinstance(client, BinanceFuturesClient): + res1 = client.place_limit_order( + symbol=str(symbol), + side="BUY" if side == "buy" else "SELL", + quantity=remaining, + price=limit_price, + reduce_only=reduce_only, + position_side=pos_side, + client_order_id=limit_client_oid, + ) + elif isinstance(client, BinanceSpotClient): + res1 = client.place_limit_order( + symbol=str(symbol), + side="BUY" if side == "buy" else "SELL", + quantity=remaining, + price=limit_price, + client_order_id=limit_client_oid, + ) + elif isinstance(client, OkxClient): + td_mode = str(payload.get("margin_mode") or payload.get("td_mode") or "cross") + # Ensure leverage is configured for this instrument before placing order. + if market_type == "swap": + try: + inst_id = to_okx_swap_inst_id(str(symbol)) + client.set_leverage(inst_id=inst_id, lever=leverage, mgn_mode=td_mode, pos_side=pos_side) + except Exception: + # If leverage set fails, let place_order raise and mark failed. + pass + res1 = client.place_limit_order( + market_type=market_type, + symbol=str(symbol), + side=side, + size=remaining, + price=limit_price, + pos_side=pos_side, + td_mode=td_mode, + reduce_only=reduce_only, + client_order_id=limit_client_oid, + ) + elif isinstance(client, BitgetMixClient): + product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES") + margin_coin = str(exchange_config.get("margin_coin") or exchange_config.get("marginCoin") or "USDT") + margin_mode = str(payload.get("margin_mode") or payload.get("marginMode") or exchange_config.get("margin_mode") or exchange_config.get("marginMode") or "cross") + # Best-effort set leverage for Bitget mix before placing orders (otherwise exchange defaults apply). + try: + if market_type == "swap": + client.set_leverage( + symbol=str(symbol), + leverage=leverage, + margin_coin=margin_coin, + product_type=product_type, + margin_mode=margin_mode, + hold_side=pos_side, + ) + except Exception: + pass + res1 = client.place_limit_order( + symbol=str(symbol), + side=side, + size=remaining, + price=limit_price, + margin_coin=margin_coin, + product_type=product_type, + margin_mode=margin_mode, + reduce_only=reduce_only, + post_only=(order_mode in ("maker", "maker_then_market", "limit_first", "limit")), + client_order_id=limit_client_oid, + ) + elif isinstance(client, BitgetSpotClient): + res1 = client.place_limit_order( + symbol=str(symbol), + side=side, + size=remaining, + price=limit_price, + client_order_id=limit_client_oid, + ) + else: + raise LiveTradingError(f"Unsupported client type: {type(client)}") + + limit_order_id = str(res1.exchange_order_id or "") + phases["limit_place"] = res1.raw + + # Wait for fills + if isinstance(client, BinanceFuturesClient): + q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) + phases["limit_query"] = q + _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + elif isinstance(client, BinanceSpotClient): + q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) + phases["limit_query"] = q + _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + elif isinstance(client, OkxClient): + q = client.wait_for_fill(symbol=str(symbol), ord_id=limit_order_id, cl_ord_id=limit_client_oid, market_type=market_type, max_wait_sec=maker_wait_sec) + phases["limit_query"] = q + _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + _apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or "")) + elif isinstance(client, BitgetMixClient): + product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES") + q = client.wait_for_fill(symbol=str(symbol), product_type=product_type, order_id=limit_order_id, client_oid=limit_client_oid, max_wait_sec=maker_wait_sec) + phases["limit_query"] = q + _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + _apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or "")) + elif isinstance(client, BitgetSpotClient): + q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) + phases["limit_query"] = q + _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + + remaining = max(0.0, float(amount or 0.0) - total_base) + + # Tail guard: if remaining is below the exchange min tradable amount, do NOT chase it with a market order. + # This avoids the common case: limit partially fills, remainder is too small => market phase fails, yet + # the exchange already opened a position (user sees "failed" incorrectly). + if remaining > 0 and isinstance(client, OkxClient) and market_type == "swap": + try: + inst_id = to_okx_swap_inst_id(str(symbol)) + inst = client.get_instrument(inst_type="SWAP", inst_id=inst_id) or {} + lot_sz = float(inst.get("lotSz") or 0.0) # contracts step + min_sz = float(inst.get("minSz") or 0.0) # min contracts + ct_val = float(inst.get("ctVal") or 0.0) # base per contract + # Convert contract min to base min (best-effort) + min_contract = min_sz if min_sz > 0 else (lot_sz if lot_sz > 0 else 0.0) + min_base = (min_contract * ct_val) if (min_contract > 0 and ct_val > 0) else 0.0 + if min_base > 0 and remaining < (min_base * 0.999999): + phases["tail_guard"] = { + "exchange": "okx", + "inst_id": inst_id, + "remaining": remaining, + "min_base": min_base, + } + remaining = 0.0 + except Exception: + pass + + # Cancel if not fully filled + if remaining > max(0.0, float(amount or 0.0) * 0.001): + try: + if isinstance(client, BinanceFuturesClient): + phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid) + elif isinstance(client, BinanceSpotClient): + phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid) + elif isinstance(client, OkxClient): + phases["limit_cancel"] = client.cancel_order(market_type=market_type, symbol=str(symbol), ord_id=limit_order_id, cl_ord_id=limit_client_oid) + elif isinstance(client, BitgetMixClient): + product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES") + margin_coin = str(exchange_config.get("margin_coin") or exchange_config.get("marginCoin") or "USDT") + phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), product_type=product_type, margin_coin=margin_coin, order_id=limit_order_id, client_oid=limit_client_oid) + elif isinstance(client, BitgetSpotClient): + phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), client_order_id=limit_client_oid) + except Exception: + pass + except LiveTradingError as e: + logger.warning(f"live limit phase failed: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}") + # Fall back to market for full amount + remaining = float(amount or 0.0) + phases["limit_error"] = str(e) + except Exception as e: + logger.warning(f"live limit phase unexpected error: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}") + remaining = float(amount or 0.0) + phases["limit_error"] = str(e) + + # Phase 2: market for remaining + market_order_id = "" + market_client_oid = _make_client_oid("mkt") + if remaining > 0: + try: + if isinstance(client, BinanceFuturesClient): + res2 = client.place_market_order( + symbol=str(symbol), + side="BUY" if side == "buy" else "SELL", + quantity=remaining, + reduce_only=reduce_only, + position_side=pos_side, + client_order_id=market_client_oid, + ) + elif isinstance(client, BinanceSpotClient): + res2 = client.place_market_order( + symbol=str(symbol), + side="BUY" if side == "buy" else "SELL", + quantity=remaining, + client_order_id=market_client_oid, + ) + elif isinstance(client, OkxClient): + td_mode = str(payload.get("margin_mode") or payload.get("td_mode") or "cross") + if market_type == "swap": + try: + inst_id = to_okx_swap_inst_id(str(symbol)) + client.set_leverage(inst_id=inst_id, lever=leverage, mgn_mode=td_mode, pos_side=pos_side) + except Exception: + pass + res2 = client.place_market_order( + symbol=str(symbol), + side=side, + size=remaining, + market_type=market_type, + pos_side=pos_side, + td_mode=td_mode, + reduce_only=reduce_only, + client_order_id=market_client_oid, + ) + elif isinstance(client, BitgetMixClient): + product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES") + margin_coin = str(exchange_config.get("margin_coin") or exchange_config.get("marginCoin") or "USDT") + margin_mode = str(payload.get("margin_mode") or payload.get("marginMode") or exchange_config.get("margin_mode") or exchange_config.get("marginMode") or "cross") + try: + if market_type == "swap": + client.set_leverage( + symbol=str(symbol), + leverage=leverage, + margin_coin=margin_coin, + product_type=product_type, + margin_mode=margin_mode, + hold_side=pos_side, + ) + except Exception: + pass + res2 = client.place_market_order( + symbol=str(symbol), + side=side, + size=remaining, + margin_coin=margin_coin, + product_type=product_type, + margin_mode=margin_mode, + reduce_only=reduce_only, + client_order_id=market_client_oid, + ) + elif isinstance(client, BitgetSpotClient): + # For Bitget spot market BUY, convert base->quote using ref_price (hummingbot style). + mkt_size = remaining + if side == "buy" and ref_price > 0: + mkt_size = remaining * ref_price + res2 = client.place_market_order( + symbol=str(symbol), + side=side, + size=mkt_size, + client_order_id=market_client_oid, + ) + else: + raise LiveTradingError(f"Unsupported client type: {type(client)}") + + market_order_id = str(res2.exchange_order_id or "") + phases["market_place"] = res2.raw + + # Query fills (short wait) + if isinstance(client, BinanceFuturesClient): + q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) + phases["market_query"] = q2 + _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + elif isinstance(client, BinanceSpotClient): + q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) + phases["market_query"] = q2 + _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + elif isinstance(client, OkxClient): + # OKX fills endpoint may lag shortly after execution; wait a bit longer to capture fee. + q2 = client.wait_for_fill(symbol=str(symbol), ord_id=market_order_id, cl_ord_id=market_client_oid, market_type=market_type, max_wait_sec=12.0) + phases["market_query"] = q2 + _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + _apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or "")) + elif isinstance(client, BitgetMixClient): + product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES") + q2 = client.wait_for_fill(symbol=str(symbol), product_type=product_type, order_id=market_order_id, client_oid=market_client_oid, max_wait_sec=3.0) + phases["market_query"] = q2 + _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + _apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or "")) + elif isinstance(client, BitgetSpotClient): + q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) + phases["market_query"] = q2 + _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + except LiveTradingError as e: + logger.warning(f"live market phase failed: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}") + phases["market_error"] = str(e) + # If we already got any fills in the limit phase, treat as partial success instead of failing the whole order. + if float(total_base or 0.0) > 0: + _console_print( + f"[worker] market tail failed but partial filled: strategy_id={strategy_id} pending_id={order_id} filled={total_base} err={e}" + ) + remaining = 0.0 + else: + self._mark_failed(order_id=order_id, error=str(e)) + _console_print(f"[worker] order failed: strategy_id={strategy_id} pending_id={order_id} err={e}") + _notify_live_best_effort(status="failed", error=str(e), amount_hint=amount, price_hint=ref_price) + return + except Exception as e: + logger.warning(f"live market phase unexpected error: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}") + self._mark_failed(order_id=order_id, error=str(e)) + _console_print(f"[worker] order unexpected error: strategy_id={strategy_id} pending_id={order_id} err={e}") + _notify_live_best_effort(status="failed", error=str(e), amount_hint=amount, price_hint=ref_price) + return + + # Build final result (best-effort) + filled_final = float(total_base or 0.0) + avg_final = float(_current_avg() or 0.0) + if filled_final <= 0 and ref_price > 0: + filled_final = float(amount or 0.0) + avg_final = float(ref_price or 0.0) + + res = type("Tmp", (), {"exchange_id": str(exchange_config.get("exchange_id") or ""), "exchange_order_id": str(market_order_id or limit_order_id), "raw": phases, "filled": filled_final, "avg_price": avg_final})() + + executed_at = int(time.time()) + filled = filled_final + avg_price = avg_final + post_query: Dict[str, Any] = phases + + # Persist queue result first (idempotency / observability). + try: + self._mark_sent( + order_id=order_id, + note="live_order_sent", + exchange_id=res.exchange_id, + exchange_order_id=res.exchange_order_id, + exchange_response_json=json.dumps({"phases": (post_query or {})}, ensure_ascii=False), + filled=filled, + avg_price=avg_price, + executed_at=executed_at, + ) + _console_print(f"[worker] order sent: strategy_id={strategy_id} pending_id={order_id} exchange={res.exchange_id} order_id={res.exchange_order_id} filled={filled} avg={avg_price}") + except Exception as e: + logger.warning(f"mark_sent failed: pending_id={order_id}, err={e}") + + # Record trade + update local position snapshot (best-effort). + try: + if filled > 0 and avg_price > 0: + profit, _pos = apply_fill_to_local_position( + strategy_id=strategy_id, + symbol=str(symbol), + signal_type=str(signal_type), + filled=filled, + avg_price=avg_price, + ) + # Best-effort: subtract commission from profit if fee is in USDT/USDC/USD. + if profit is not None and total_fee > 0 and str(fee_ccy or "").upper() in ("USDT", "USDC", "USD"): + profit = float(profit) - float(total_fee) + record_trade( + strategy_id=strategy_id, + symbol=str(symbol), + trade_type=str(signal_type), + price=avg_price, + amount=filled, + # Always persist fee (even if fee_ccy is not stablecoin), and store fee currency separately. + # Profit adjustment is only applied when fee currency is stable (see above). + commission=float(total_fee or 0.0), + commission_ccy=str(fee_ccy or "").strip().upper(), + profit=profit, + ) + except Exception as e: + logger.warning(f"record_trade/update_position failed: pending_id={order_id}, err={e}") + + # Notify live results (best-effort; does not affect execution). + _notify_live_best_effort( + status="sent", + exchange_id=res.exchange_id, + exchange_order_id=res.exchange_order_id, + price_hint=avg_price if avg_price > 0 else ref_price, + amount_hint=filled if filled > 0 else amount, + ) + + def _mark_sent( + self, + order_id: int, + note: str = "", + exchange_id: str = "", + exchange_order_id: str = "", + exchange_response_json: str = "", + filled: float = 0.0, + avg_price: float = 0.0, + executed_at: Optional[int] = None, + ) -> None: + now = int(time.time()) + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + UPDATE pending_orders + SET status = 'sent', + last_error = %s, + dispatch_note = %s, + sent_at = %s, + executed_at = %s, + exchange_id = %s, + exchange_order_id = %s, + exchange_response_json = %s, + filled = %s, + avg_price = %s, + updated_at = %s + WHERE id = %s + """, + ( + "", + str(note or ""), + now, + int(executed_at) if executed_at is not None else None, + str(exchange_id or ""), + str(exchange_order_id or ""), + str(exchange_response_json or ""), + float(filled or 0.0), + float(avg_price or 0.0), + now, + int(order_id), + ), + ) + db.commit() + cur.close() + + def _mark_failed(self, order_id: int, error: str) -> None: + now = int(time.time()) + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + UPDATE pending_orders + SET status = 'failed', + last_error = %s, + updated_at = %s + WHERE id = %s + """, + (str(error or "failed"), now, int(order_id)), + ) + db.commit() + cur.close() + + def _mark_deferred(self, order_id: int, reason: str) -> None: + now = int(time.time()) + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + UPDATE pending_orders + SET status = 'deferred', + last_error = %s, + updated_at = %s + WHERE id = %s + """, + (str(reason or "deferred"), now, int(order_id)), + ) + db.commit() + cur.close() + + diff --git a/backend_api_python/app/services/search.py b/backend_api_python/app/services/search.py new file mode 100644 index 0000000..8d2f3e8 --- /dev/null +++ b/backend_api_python/app/services/search.py @@ -0,0 +1,142 @@ +""" +Search service. +Integrates Google Custom Search (CSE) and Bing Search API. +Configuration is provided via environment variables (see env.example) through config_loader. +""" +import requests +import json +from typing import List, Dict, Any, Optional +from app.utils.logger import get_logger +from app.utils.config_loader import load_addon_config + +logger = get_logger(__name__) + + +class SearchService: + """Search service.""" + + def __init__(self): + self._config = {} + self._load_config() + + def _load_config(self): + """Load config (re-read env-config on each call for local hot-reload).""" + config = load_addon_config() + self._config = config.get('search', {}) + self.provider = self._config.get('provider', 'google') + self.max_results = int(self._config.get('max_results', 10)) + + def search(self, query: str, num_results: int = None, date_restrict: str = None) -> List[Dict[str, Any]]: + """ + Execute a web search. + + Args: + query: Search query + num_results: Override default max results + date_restrict: Time restriction like 'd7' (past 7 days), Google only + + Returns: + List of search results + """ + # 重新加载配置以支持热更新 + self._load_config() + + limit = num_results if num_results else self.max_results + + if self.provider == 'bing': + return self._search_bing(query, limit) + else: + return self._search_google(query, limit, date_restrict) + + def _search_google(self, query: str, num_results: int, date_restrict: str = None) -> List[Dict[str, Any]]: + """Google Custom Search (CSE).""" + api_key = self._config.get('google', {}).get('api_key') + cx = self._config.get('google', {}).get('cx') + + if not api_key or not cx: + logger.warning("Google Search is not configured (missing api_key or cx).") + return [] + + url = "https://www.googleapis.com/customsearch/v1" + params = { + 'key': api_key, + 'cx': cx, + 'q': query, + 'num': min(num_results, 10), # Google API 限制每次最多10条 + 'gl': 'cn' if any(c in query for c in ['A股', '利好', '利空', '财报']) else None # 针对中文内容优化地区 + } + + # 添加时间限制参数 + if date_restrict: + params['dateRestrict'] = date_restrict + + try: + # logger.info(f"正在调用 Google Search API: q={query}, params={params}") + response = requests.get(url, params=params, timeout=10) + response.raise_for_status() + data = response.json() + + # logger.info(f"Google Search 原始响应: {json.dumps(data, ensure_ascii=False)}") # 打印全部字符 + # logger.info(f"Google Search 原始响应: {json.dumps(data, ensure_ascii=False)[:500]}...") # 打印前500字符避免日志过大 + + results = [] + if 'items' in data: + # logger.info(f"Google Search 返回了 {len(data['items'])} 条结果") + for item in data['items']: + logger.debug(f"Search Item: {item.get('title')} - {item.get('link')}") + results.append({ + 'title': item.get('title'), + 'link': item.get('link'), + 'snippet': item.get('snippet'), + 'source': 'Google', + 'published': item.get('pagemap', {}).get('metatags', [{}])[0].get('article:published_time', '') + }) + else: + logger.warning(f"Google Search returned no 'items'. Full response: {json.dumps(data, ensure_ascii=False)}") + + return results + + except Exception as e: + logger.error(f"Google search failed: {e}") + if 'response' in locals(): + logger.error(f"Response: {response.text}") + return [] + + def _search_bing(self, query: str, num_results: int) -> List[Dict[str, Any]]: + """Bing search.""" + api_key = self._config.get('bing', {}).get('api_key') + + if not api_key: + logger.warning("Bing Search is not configured (missing api_key).") + return [] + + url = "https://api.bing.microsoft.com/v7.0/search" + headers = {"Ocp-Apim-Subscription-Key": api_key} + params = { + "q": query, + "count": num_results, + "textDecorations": True, + "textFormat": "HTML" + } + + try: + response = requests.get(url, headers=headers, params=params, timeout=10) + response.raise_for_status() + data = response.json() + + results = [] + if 'webPages' in data and 'value' in data['webPages']: + for item in data['webPages']['value']: + results.append({ + 'title': item.get('name'), + 'link': item.get('url'), + 'snippet': item.get('snippet'), + 'source': 'Bing', + 'published': item.get('datePublished', '') + }) + return results + + except Exception as e: + logger.error(f"Bing search failed: {e}") + return [] + diff --git a/backend_api_python/app/services/signal_notifier.py b/backend_api_python/app/services/signal_notifier.py new file mode 100644 index 0000000..4740c43 --- /dev/null +++ b/backend_api_python/app/services/signal_notifier.py @@ -0,0 +1,613 @@ +""" +Strategy signal notification service. + +This module implements per-strategy notification channels based on the frontend schema: + +notification_config = { + "channels": ["browser", "email", "phone", "telegram", "discord", "webhook"], + "targets": { + "email": "foo@example.com", + "phone": "+15551234567", + "telegram": "12345678 or @username", + "discord": "https://discord.com/api/webhooks/...", + "webhook": "https://example.com/webhook" + } +} +""" + +from __future__ import annotations + +import html +import json +import os +import smtplib +import time +from datetime import datetime, timezone +from email.message import EmailMessage +from typing import Any, Dict, List, Optional, Tuple + +import requests + +from app.utils.db import get_db_connection +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +def _as_list(value: Any) -> List[str]: + if value is None: + return [] + if isinstance(value, (list, tuple)): + return [str(x).strip() for x in value if str(x).strip()] + s = str(value).strip() + if not s: + return [] + # Allow comma-separated inputs. + if "," in s: + return [x.strip() for x in s.split(",") if x.strip()] + return [s] + + +def _safe_json(value: Any) -> Dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str) and value.strip(): + try: + obj = json.loads(value) + return obj if isinstance(obj, dict) else {} + except Exception: + return {} + return {} + + +def _signal_meta(signal_type: str) -> Dict[str, str]: + st = (signal_type or "").strip().lower() + action = "signal" + if st.startswith("open_"): + action = "open" + elif st.startswith("add_"): + action = "add" + elif st.startswith("close_"): + action = "close" + elif st.startswith("reduce_"): + action = "reduce" + + side = "long" if "long" in st else ("short" if "short" in st else "") + return {"action": action, "side": side, "type": st} + + +def _fmt_float(value: Any, *, max_decimals: int = 10) -> str: + try: + v = float(value or 0.0) + except Exception: + v = 0.0 + s = f"{v:.{int(max_decimals)}f}" + s = s.rstrip("0").rstrip(".") + return s or "0" + + +class SignalNotifier: + """ + Notify signal events across channels. + + Provider environment variables: + - SIGNAL_NOTIFY_TIMEOUT_SEC: HTTP timeout (default: 6) + + - SIGNAL_WEBHOOK_TOKEN: optional bearer token for generic webhook channel + + - TELEGRAM_BOT_TOKEN: Telegram bot token (required for telegram channel) + + - SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM, SMTP_USE_TLS + (required for email channel) + + - TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER + (optional for phone/SMS channel) + """ + + def __init__(self) -> None: + try: + self.timeout_sec = float(os.getenv("SIGNAL_NOTIFY_TIMEOUT_SEC") or "6") + except Exception: + self.timeout_sec = 6.0 + + self.webhook_token = (os.getenv("SIGNAL_WEBHOOK_TOKEN") or "").strip() + + self.telegram_token = (os.getenv("TELEGRAM_BOT_TOKEN") or "").strip() + + self.smtp_host = (os.getenv("SMTP_HOST") or "").strip() + try: + self.smtp_port = int(os.getenv("SMTP_PORT") or "587") + except Exception: + self.smtp_port = 587 + self.smtp_user = (os.getenv("SMTP_USER") or "").strip() + self.smtp_password = (os.getenv("SMTP_PASSWORD") or "").strip() + self.smtp_from = (os.getenv("SMTP_FROM") or self.smtp_user or "").strip() + self.smtp_use_tls = (os.getenv("SMTP_USE_TLS") or "true").strip().lower() == "true" + # Some providers require implicit SSL (port 465). Support it via SMTP_USE_SSL. + self.smtp_use_ssl = (os.getenv("SMTP_USE_SSL") or "").strip().lower() == "true" + + self.twilio_sid = (os.getenv("TWILIO_ACCOUNT_SID") or "").strip() + self.twilio_token = (os.getenv("TWILIO_AUTH_TOKEN") or "").strip() + self.twilio_from = (os.getenv("TWILIO_FROM_NUMBER") or "").strip() + + def notify_signal( + self, + *, + strategy_id: int, + strategy_name: str, + symbol: str, + signal_type: str, + price: float = 0.0, + stake_amount: float = 0.0, + direction: str = "long", + notification_config: Optional[Dict[str, Any]] = None, + extra: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Dict[str, Any]]: + cfg = _safe_json(notification_config or {}) + channels = _as_list(cfg.get("channels")) + if not channels: + channels = ["browser"] + + targets = _safe_json(cfg.get("targets") or {}) + extra = extra if isinstance(extra, dict) else {} + + payload = self._build_payload( + strategy_id=strategy_id, + strategy_name=strategy_name, + symbol=symbol, + signal_type=signal_type, + price=price, + stake_amount=stake_amount, + direction=direction, + extra=extra, + ) + rendered = self._render_messages(payload) + title = rendered.get("title") or "" + message_plain = rendered.get("plain") or "" + + results: Dict[str, Dict[str, Any]] = {} + for ch in channels: + c = (ch or "").strip().lower() + if not c: + continue + try: + if c == "browser": + ok, err = self._notify_browser( + strategy_id=strategy_id, + symbol=symbol, + signal_type=signal_type, + channels=channels, + title=title, + message=message_plain, + payload=payload, + ) + elif c == "webhook": + url = (targets.get("webhook") or os.getenv("SIGNAL_WEBHOOK_URL") or "").strip() + ok, err = self._notify_webhook( + url=url, + payload=payload, + ) + elif c == "discord": + url = (targets.get("discord") or "").strip() + ok, err = self._notify_discord(url=url, payload=payload, fallback_text=message_plain) + elif c == "telegram": + chat_id = (targets.get("telegram") or "").strip() + # Support per-strategy token override (local mode). Falls back to env TELEGRAM_BOT_TOKEN. + token_override = "" + if not self.telegram_token: + try: + token_override = str( + targets.get("telegram_bot_token") + or targets.get("telegram_token") + or cfg.get("telegram_bot_token") + or cfg.get("telegram_token") + or "" + ).strip() + except Exception: + token_override = "" + ok, err = self._notify_telegram( + chat_id=chat_id, + text=rendered.get("telegram_html") or message_plain, + token_override=token_override, + parse_mode="HTML", + ) + elif c == "email": + to_email = (targets.get("email") or "").strip() + ok, err = self._notify_email( + to_email=to_email, + subject=title, + body_text=message_plain, + body_html=rendered.get("email_html") or "", + ) + elif c == "phone": + to_phone = (targets.get("phone") or "").strip() + ok, err = self._notify_phone(to_phone=to_phone, body=message_plain) + else: + ok, err = False, f"unsupported_channel:{c}" + except Exception as e: + ok, err = False, str(e) + + results[c] = {"ok": bool(ok), "error": (err or "")} + + return results + + def _build_payload( + self, + *, + strategy_id: int, + strategy_name: str, + symbol: str, + signal_type: str, + price: float, + stake_amount: float, + direction: str, + extra: Dict[str, Any], + ) -> Dict[str, Any]: + now = int(time.time()) + iso = datetime.fromtimestamp(now, tz=timezone.utc).isoformat() + meta = _signal_meta(signal_type) + + pending_id = None + try: + pending_id = int((extra or {}).get("pending_order_id") or 0) or None + except Exception: + pending_id = None + + return { + "event": "qd.signal", + "version": 1, + "timestamp": now, + "timestamp_iso": iso, + "strategy": { + "id": int(strategy_id), + "name": str(strategy_name or ""), + }, + "instrument": { + "symbol": str(symbol or ""), + }, + "signal": { + "type": meta.get("type") or str(signal_type or ""), + "action": meta.get("action") or "signal", + "side": meta.get("side") or "", + "direction": str(direction or ""), + }, + "order": { + "ref_price": float(price or 0.0), + "stake_amount": float(stake_amount or 0.0), + }, + "trace": { + "pending_order_id": pending_id, + "mode": str((extra or {}).get("mode") or ""), + }, + "extra": extra or {}, + } + + def _render_messages(self, payload: Dict[str, Any]) -> Dict[str, str]: + strategy = (payload or {}).get("strategy") or {} + instrument = (payload or {}).get("instrument") or {} + sig = (payload or {}).get("signal") or {} + order = (payload or {}).get("order") or {} + trace = (payload or {}).get("trace") or {} + + symbol = str(instrument.get("symbol") or "") + stype = str(sig.get("type") or "") + action = str(sig.get("action") or "").upper() + side = str(sig.get("side") or "").upper() + title = f"QD Signal | {symbol} | {action} {side}".strip() + + price_s = _fmt_float(order.get("ref_price") or 0.0, max_decimals=10) + stake_s = _fmt_float(order.get("stake_amount") or 0.0, max_decimals=12) + pending_id = int(trace.get("pending_order_id") or 0) if trace.get("pending_order_id") else 0 + mode = str(trace.get("mode") or "") + ts_iso = str(payload.get("timestamp_iso") or "") + + plain_lines = [ + "QuantDinger Signal", + f"Strategy: {strategy.get('name') or ''} (#{int(strategy.get('id') or 0)})", + f"Symbol: {symbol}", + f"Signal: {stype}", + f"Price: {price_s}", + f"Stake: {stake_s}", + ] + if pending_id: + plain_lines.append(f"PendingOrder: {pending_id}") + if mode: + plain_lines.append(f"Mode: {mode}") + if ts_iso: + plain_lines.append(f"Time(UTC): {ts_iso}") + + # Telegram (HTML) message. Escape all dynamic values. + t_strategy = f"{strategy.get('name') or ''} (#{int(strategy.get('id') or 0)})" + telegram_lines = [ + "QuantDinger Signal", + "", + f"Strategy: {html.escape(str(t_strategy))}", + f"Symbol: {html.escape(symbol)}", + f"Signal: {html.escape(stype)}", + f"Price: {html.escape(price_s)}", + f"Stake: {html.escape(stake_s)}", + ] + if pending_id: + telegram_lines.append(f"PendingOrder: {pending_id}") + if mode: + telegram_lines.append(f"Mode: {html.escape(mode)}") + if ts_iso: + telegram_lines.append(f"Time (UTC): {html.escape(ts_iso)}") + telegram_html = "\n".join([x for x in telegram_lines if x is not None]) + + # Email (HTML) message. Keep inline CSS for maximum compatibility. + email_html = self._build_email_html( + title_text="QuantDinger Signal", + strategy_text=t_strategy, + symbol=symbol, + signal_type=stype, + price_text=price_s, + stake_text=stake_s, + pending_id=pending_id or None, + mode_text=mode or "", + timestamp_iso=ts_iso or "", + ) + + return { + "title": title, + "plain": "\n".join(plain_lines), + "telegram_html": telegram_html, + "email_html": email_html, + } + + def _build_email_html( + self, + *, + title_text: str, + strategy_text: str, + symbol: str, + signal_type: str, + price_text: str, + stake_text: str, + pending_id: Optional[int], + mode_text: str, + timestamp_iso: str, + ) -> str: + def esc(s: Any) -> str: + return html.escape(str(s or "")) + + rows: List[Tuple[str, str]] = [ + ("Strategy", strategy_text), + ("Symbol", symbol), + ("Signal", signal_type), + ("Price", price_text), + ("Stake", stake_text), + ] + if pending_id: + rows.append(("PendingOrder", str(int(pending_id)))) + if mode_text: + rows.append(("Mode", mode_text)) + if timestamp_iso: + rows.append(("Time (UTC)", timestamp_iso)) + + tr_html = "\n".join( + [ + ( + "" + "" + f"{esc(k)}" + "" + "" + f"{esc(v)}" + "" + "" + ) + for (k, v) in rows + ] + ) + + return f"""\ + + + +
+
+
{esc(title_text)}
+
{esc(timestamp_iso) if timestamp_iso else ""}
+
+
+ + {tr_html} +
+
+ Generated by QuantDinger +
+
+
+ + +""" + + def _notify_browser( + self, + *, + strategy_id: int, + symbol: str, + signal_type: str, + channels: List[str], + title: str, + message: str, + payload: Dict[str, Any], + ) -> Tuple[bool, str]: + try: + now = int(time.time()) + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + INSERT INTO qd_strategy_notifications + (strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + int(strategy_id), + str(symbol or ""), + str(signal_type or ""), + ",".join([str(c) for c in (channels or [])]), + str(title or ""), + str(message or ""), + json.dumps(payload or {}, ensure_ascii=False), + int(now), + ), + ) + db.commit() + cur.close() + return True, "" + except Exception as e: + logger.warning(f"browser notify persist failed: {e}") + return False, str(e) + + def _notify_webhook(self, *, url: str, payload: Dict[str, Any]) -> Tuple[bool, str]: + if not url: + return False, "missing_webhook_url" + headers = {"Content-Type": "application/json"} + if self.webhook_token: + headers["Authorization"] = f"Bearer {self.webhook_token}" + try: + resp = requests.post(url, json=payload, headers=headers, timeout=self.timeout_sec) + if 200 <= resp.status_code < 300: + return True, "" + return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}" + except Exception as e: + return False, str(e) + + def _notify_discord(self, *, url: str, payload: Dict[str, Any], fallback_text: str) -> Tuple[bool, str]: + if not url: + return False, "missing_discord_webhook_url" + + strategy = (payload or {}).get("strategy") or {} + instrument = (payload or {}).get("instrument") or {} + sig = (payload or {}).get("signal") or {} + order = (payload or {}).get("order") or {} + trace = (payload or {}).get("trace") or {} + + action = str(sig.get("action") or "").lower() + color = 0x3498DB + if action in ("open", "add"): + color = 0x2ECC71 + if action in ("close", "reduce"): + color = 0xE74C3C + + embed: Dict[str, Any] = { + "title": "QuantDinger Signal", + "color": int(color), + "fields": [ + {"name": "Strategy", "value": f"{strategy.get('name') or ''} (#{int(strategy.get('id') or 0)})", "inline": True}, + {"name": "Symbol", "value": str(instrument.get("symbol") or ""), "inline": True}, + {"name": "Signal", "value": str(sig.get("type") or ""), "inline": False}, + {"name": "Price", "value": str(float(order.get('ref_price') or 0.0)), "inline": True}, + {"name": "Stake", "value": str(float(order.get('stake_amount') or 0.0)), "inline": True}, + ], + } + if payload.get("timestamp_iso"): + embed["timestamp"] = str(payload.get("timestamp_iso") or "") + if trace.get("pending_order_id"): + embed["footer"] = {"text": f"pending_order_id={int(trace.get('pending_order_id'))}"} + try: + resp = requests.post(url, json={"content": "", "embeds": [embed]}, timeout=self.timeout_sec) + if 200 <= resp.status_code < 300: + return True, "" + # Fallback: try plain text. + try: + resp2 = requests.post(url, json={"content": str(fallback_text or "")[:1900]}, timeout=self.timeout_sec) + if 200 <= resp2.status_code < 300: + return True, "" + except Exception: + pass + return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}" + except Exception as e: + return False, str(e) + + def _notify_telegram( + self, + *, + chat_id: str, + text: str, + token_override: str = "", + parse_mode: str = "", + ) -> Tuple[bool, str]: + token = (token_override or "").strip() or (self.telegram_token or "").strip() + if not token: + return False, "missing_TELEGRAM_BOT_TOKEN" + if not chat_id: + return False, "missing_telegram_chat_id" + url = f"https://api.telegram.org/bot{token}/sendMessage" + try: + data: Dict[str, Any] = { + "chat_id": chat_id, + "text": str(text or "")[:3900], + "disable_web_page_preview": True, + } + if (parse_mode or "").strip(): + data["parse_mode"] = str(parse_mode).strip() + resp = requests.post( + url, + data=data, + timeout=self.timeout_sec, + ) + if 200 <= resp.status_code < 300: + return True, "" + return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}" + except Exception as e: + return False, str(e) + + def _notify_email(self, *, to_email: str, subject: str, body_text: str, body_html: str = "") -> Tuple[bool, str]: + if not to_email: + return False, "missing_email_target" + if not self.smtp_host: + return False, "missing_SMTP_HOST" + if not self.smtp_from: + return False, "missing_SMTP_FROM" + + msg = EmailMessage() + msg["From"] = self.smtp_from + msg["To"] = to_email + msg["Subject"] = str(subject or "Signal") + msg.set_content(str(body_text or "")) + if (body_html or "").strip(): + msg.add_alternative(str(body_html or ""), subtype="html") + + try: + # Heuristic: if port is 465 and SMTP_USE_SSL is not explicitly set, assume SSL. + use_ssl = bool(self.smtp_use_ssl) or int(self.smtp_port or 0) == 465 + if use_ssl: + with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, timeout=self.timeout_sec) as server: + server.ehlo() + if self.smtp_user and self.smtp_password: + server.login(self.smtp_user, self.smtp_password) + server.send_message(msg) + else: + with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=self.timeout_sec) as server: + server.ehlo() + if self.smtp_use_tls: + server.starttls() + server.ehlo() + if self.smtp_user and self.smtp_password: + server.login(self.smtp_user, self.smtp_password) + server.send_message(msg) + return True, "" + except Exception as e: + return False, str(e) + + def _notify_phone(self, *, to_phone: str, body: str) -> Tuple[bool, str]: + # Optional provider: Twilio via REST (no extra dependency). + if not to_phone: + return False, "missing_phone_target" + if not (self.twilio_sid and self.twilio_token and self.twilio_from): + return False, "missing_TWILIO_config" + url = f"https://api.twilio.com/2010-04-01/Accounts/{self.twilio_sid}/Messages.json" + data = {"To": to_phone, "From": self.twilio_from, "Body": str(body or "")[:1500]} + try: + resp = requests.post(url, data=data, auth=(self.twilio_sid, self.twilio_token), timeout=self.timeout_sec) + if 200 <= resp.status_code < 300: + return True, "" + return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}" + except Exception as e: + return False, str(e) + + diff --git a/backend_api_python/app/services/strategy.py b/backend_api_python/app/services/strategy.py new file mode 100644 index 0000000..42ff578 --- /dev/null +++ b/backend_api_python/app/services/strategy.py @@ -0,0 +1,465 @@ +import os +import time +import json +import threading +from typing import List, Dict, Any, Optional +from datetime import datetime + +from app.utils.logger import get_logger +from app.utils.db import get_db_connection + +logger = get_logger(__name__) + +class StrategyService: + """Strategy service.""" + + # 类变量:限制连接测试并发数 + _connection_test_semaphore = threading.Semaphore(5) + + def __init__(self): + # Local deployment: do not use encryption/decryption. + pass + + def get_running_strategies(self) -> List[Dict[str, Any]]: + """获取所有运行中的策略(仅ID)""" + try: + with get_db_connection() as db: + cursor = db.cursor() + query = "SELECT id FROM qd_strategies_trading WHERE status = 'running'" + cursor.execute(query) + results = cursor.fetchall() + cursor.close() + return [row['id'] for row in results] + except Exception as e: + logger.error(f"Failed to fetch running strategies: {str(e)}") + return [] + + def get_running_strategies_with_type(self) -> List[Dict[str, Any]]: + """获取所有运行中的策略(包含类型信息)""" + try: + with get_db_connection() as db: + cursor = db.cursor() + # 假设 qd_strategies_trading 表中有 strategy_type 字段 + # 如果没有,可能需要关联查询或者根据其他字段判断 + # 这里假设表结构已更新 + query = "SELECT id, strategy_type FROM qd_strategies_trading WHERE status = 'running'" + cursor.execute(query) + results = cursor.fetchall() + cursor.close() + + strategies = [{'id': row['id'], 'strategy_type': row.get('strategy_type', '')} for row in results] + logger.info(f"Found {len(strategies)} running strategies: {strategies}") + return strategies + + except Exception as e: + logger.error(f"Failed to fetch running strategies: {str(e)}") + return [] + + def get_exchange_symbols(self, exchange_config: Dict[str, Any]) -> Dict[str, Any]: + """ + 获取交易所交易对列表 (无需API Key) + """ + try: + import ccxt + + exchange_id = exchange_config.get('exchange_id', '') + proxies = exchange_config.get('proxies') + + if not exchange_id: + return {'success': False, 'message': '请选择交易所', 'symbols': []} + + # 创建交易所实例 (public only) + exchange_class = getattr(ccxt, exchange_id, None) + if not exchange_class: + return {'success': False, 'message': f'不支持的交易所: {exchange_id}', 'symbols': []} + + exchange_config_dict = { + 'enableRateLimit': True, + 'options': {'defaultType': 'swap'} # 默认为 swap + } + if proxies: + exchange_config_dict['proxies'] = proxies + + exchange = exchange_class(exchange_config_dict) + markets = exchange.load_markets() + + symbols = [] + for symbol, market in markets.items(): + if market.get('active', False) and market.get('quote') == 'USDT': + symbols.append(symbol) + + symbols.sort() + return {'success': True, 'message': f'获取成功,共 {len(symbols)} 个交易对', 'symbols': symbols} + + except Exception as e: + logger.error(f"Failed to fetch symbols: {str(e)}") + return {'success': False, 'message': f'获取交易对失败: {str(e)}', 'symbols': []} + + def test_exchange_connection(self, exchange_config: Dict[str, Any]) -> Dict[str, Any]: + """ + Test exchange connection via direct REST clients (no ccxt). + + Notes: + - This is local-only; failures are returned as user-friendly messages. + - We do not log secrets. + """ + # Limit concurrency to protect CPU / rate limits + with StrategyService._connection_test_semaphore: + try: + from app.services.exchange_execution import resolve_exchange_config, safe_exchange_config_for_log + from app.services.live_trading.factory import create_client + from app.services.live_trading.binance import BinanceFuturesClient + from app.services.live_trading.binance_spot import BinanceSpotClient + from app.services.live_trading.okx import OkxClient + from app.services.live_trading.bitget import BitgetMixClient + + resolved = resolve_exchange_config(exchange_config or {}) + safe_cfg = safe_exchange_config_for_log(resolved) + + exchange_id = (resolved.get("exchange_id") or "").strip().lower() + if not exchange_id: + return {'success': False, 'message': 'Missing exchange_id', 'data': None} + + # IMPORTANT: + # Test connection should respect configured market_type (spot vs swap). + # Otherwise Binance will default to futures endpoints (fapi) and spot-only keys will fail with -2015. + market_type = str(resolved.get("market_type") or resolved.get("defaultType") or "swap").strip().lower() + client = create_client(resolved, market_type=market_type) + client_kind = type(client).__name__ + + # Best-effort detect current egress IP (for Binance IP whitelist debugging). + egress_ip = "" + try: + import requests as _rq + egress_ip = str(_rq.get("https://ifconfig.me/ip", timeout=5).text or "").strip() + except Exception: + egress_ip = "" + + # 1) Public connectivity + ok_public = False + try: + ok_public = bool(getattr(client, "ping")()) + except Exception: + ok_public = False + if not ok_public: + return { + 'success': False, + 'message': f'Public ping failed: {exchange_id}', + 'data': {'exchange': safe_cfg, 'client': client_kind, 'market_type': market_type, 'egress_ip': egress_ip}, + } + + # 2) Private credential validation (best-effort) + priv_data = None + try: + if isinstance(client, BinanceFuturesClient): + priv_data = client.get_account() + elif isinstance(client, BinanceSpotClient): + priv_data = client.get_account() + elif isinstance(client, OkxClient): + priv_data = client.get_balance() + elif isinstance(client, BitgetMixClient): + product_type = str(resolved.get("product_type") or resolved.get("productType") or "USDT-FUTURES") + priv_data = client.get_accounts(product_type=product_type) + except Exception as e: + msg = str(e) + # Add actionable hints for the most common Binance auth error. + if exchange_id == "binance" and ("-2015" in msg or "Invalid API-key, IP, or permissions" in msg): + # Auto A/B test: try the other market_type once to pinpoint permission mismatch. + alt_market_type = "spot" if market_type != "spot" else "swap" + alt_client_kind = "" + alt_base_url = "" + alt_ok = False + try: + alt_client = create_client(resolved, market_type=alt_market_type) + alt_client_kind = type(alt_client).__name__ + alt_base_url = getattr(alt_client, "base_url", "") or "" + if isinstance(alt_client, BinanceFuturesClient) or isinstance(alt_client, BinanceSpotClient): + _ = alt_client.get_account() + alt_ok = True + except Exception: + alt_ok = False + + base_url = getattr(client, "base_url", "") or "" + hint = ( + f"Binance auth failed (-2015). Verify: " + f"(1) IP whitelist includes this server egress IP={egress_ip or 'unknown'}, " + f"(2) API key permissions match market_type={market_type} " + f"(spot requires Spot permissions; swap requires Futures permissions), " + f"(3) you're using binance.com keys for base_url={base_url or 'unknown'}." + ) + if alt_ok: + hint += ( + f" Auto-check: your key works for market_type={alt_market_type} " + f"(client={alt_client_kind}, base_url={alt_base_url or 'unknown'}) " + f"but fails for market_type={market_type}. This is almost always a permissions/product mismatch." + ) + msg = f"{msg} | {hint}" + return { + 'success': False, + 'message': f'Auth failed: {msg}', + 'data': { + 'exchange': safe_cfg, + 'client': client_kind, + 'market_type': market_type, + 'egress_ip': egress_ip, + 'base_url': getattr(client, "base_url", "") or "", + }, + } + + return { + 'success': True, + 'message': 'Connection OK', + 'data': { + 'exchange': safe_cfg, + 'client': client_kind, + 'market_type': market_type, + 'egress_ip': egress_ip, + 'base_url': getattr(client, "base_url", "") or "", + 'private': priv_data, + }, + } + except Exception as e: + logger.error(f"test_exchange_connection failed: {str(e)}") + return {'success': False, 'message': f'Connection failed: {str(e)}', 'data': None} + + def get_strategy_type(self, strategy_id: int) -> str: + """Get strategy type from DB.""" + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "SELECT strategy_type FROM qd_strategies_trading WHERE id = ?", + (strategy_id,) + ) + row = cur.fetchone() + cur.close() + return (row or {}).get('strategy_type') or 'IndicatorStrategy' + except Exception: + return 'IndicatorStrategy' + + def update_strategy_status(self, strategy_id: int, status: str) -> bool: + """Update strategy status.""" + try: + now = int(time.time()) + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "UPDATE qd_strategies_trading SET status = ?, updated_at = ? WHERE id = ?", + (status, now, strategy_id) + ) + db.commit() + cur.close() + return True + except Exception as e: + logger.error(f"update_strategy_status failed: {e}") + return False + + def _safe_json_loads(self, value: Any, default: Any): + """Load JSON string into Python object (local deployment: plaintext only).""" + if value is None: + return default + if isinstance(value, (dict, list)): + return value + if not isinstance(value, str): + return default + s = value.strip() + if not s: + return default + try: + return json.loads(s) + except Exception: + return default + + def _dump_json_or_encrypt(self, obj: Any, encrypt: bool = False) -> str: + if obj is None: + return '' + # Local deployment: always store plaintext JSON. + return json.dumps(obj, ensure_ascii=False) + + def list_strategies(self, user_id: int = 1) -> List[Dict[str, Any]]: + """List strategies for local single-user.""" + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT * + FROM qd_strategies_trading + ORDER BY id DESC + """ + ) + rows = cur.fetchall() or [] + cur.close() + + out = [] + for r in rows: + ex = self._safe_json_loads(r.get('exchange_config'), {}) + ind = self._safe_json_loads(r.get('indicator_config'), {}) + tr = self._safe_json_loads(r.get('trading_config'), {}) + ai = self._safe_json_loads(r.get('ai_model_config'), {}) + notify = self._safe_json_loads(r.get('notification_config'), {}) + out.append({ + **r, + 'exchange_config': ex, + 'indicator_config': ind, + 'trading_config': tr, + 'ai_model_config': ai, + 'notification_config': notify + }) + return out + except Exception as e: + logger.error(f"list_strategies failed: {e}") + return [] + + def get_strategy(self, strategy_id: int) -> Optional[Dict[str, Any]]: + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT * FROM qd_strategies_trading WHERE id = ?", (strategy_id,)) + r = cur.fetchone() + cur.close() + if not r: + return None + r['exchange_config'] = self._safe_json_loads(r.get('exchange_config'), {}) + r['indicator_config'] = self._safe_json_loads(r.get('indicator_config'), {}) + r['trading_config'] = self._safe_json_loads(r.get('trading_config'), {}) + r['ai_model_config'] = self._safe_json_loads(r.get('ai_model_config'), {}) + r['notification_config'] = self._safe_json_loads(r.get('notification_config'), {}) + return r + except Exception as e: + logger.error(f"get_strategy failed: {e}") + return None + + def create_strategy(self, payload: Dict[str, Any]) -> int: + now = int(time.time()) + name = (payload.get('strategy_name') or '').strip() + if not name: + raise ValueError("strategy_name is required") + + strategy_type = payload.get('strategy_type') or 'IndicatorStrategy' + market_category = payload.get('market_category') or 'Crypto' + execution_mode = payload.get('execution_mode') or 'signal' + notification_config = payload.get('notification_config') or {} + + indicator_config = payload.get('indicator_config') or {} + trading_config = payload.get('trading_config') or {} + exchange_config = payload.get('exchange_config') or {} + + # Denormalized fields for quick list rendering + symbol = (trading_config or {}).get('symbol') + timeframe = (trading_config or {}).get('timeframe') + initial_capital = (trading_config or {}).get('initial_capital') or payload.get('initial_capital') or 1000 + leverage = (trading_config or {}).get('leverage') or 1 + market_type = (trading_config or {}).get('market_type') or 'swap' + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + INSERT INTO qd_strategies_trading + (strategy_name, strategy_type, market_category, execution_mode, notification_config, + status, symbol, timeframe, initial_capital, leverage, market_type, + exchange_config, indicator_config, trading_config, ai_model_config, decide_interval, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + name, + strategy_type, + market_category, + execution_mode, + self._dump_json_or_encrypt(notification_config, encrypt=False), + payload.get('status') or 'stopped', + symbol, + timeframe, + float(initial_capital or 1000), + int(leverage or 1), + market_type, + self._dump_json_or_encrypt(exchange_config, encrypt=False) if exchange_config else '', + self._dump_json_or_encrypt(indicator_config, encrypt=False), + self._dump_json_or_encrypt(trading_config, encrypt=False), + self._dump_json_or_encrypt(payload.get('ai_model_config') or {}, encrypt=False), + int(payload.get('decide_interval') or 300), + now, + now + ) + ) + new_id = cur.lastrowid + db.commit() + cur.close() + return int(new_id) + + def update_strategy(self, strategy_id: int, payload: Dict[str, Any]) -> bool: + now = int(time.time()) + existing = self.get_strategy(strategy_id) + if not existing: + return False + + # Merge: allow partial updates + name = (payload.get('strategy_name') or existing.get('strategy_name') or '').strip() + market_category = payload.get('market_category') or existing.get('market_category') or 'Crypto' + execution_mode = payload.get('execution_mode') or existing.get('execution_mode') or 'signal' + notification_config = payload.get('notification_config') if payload.get('notification_config') is not None else (existing.get('notification_config') or {}) + + indicator_config = payload.get('indicator_config') if payload.get('indicator_config') is not None else (existing.get('indicator_config') or {}) + trading_config = payload.get('trading_config') if payload.get('trading_config') is not None else (existing.get('trading_config') or {}) + exchange_config = payload.get('exchange_config') if payload.get('exchange_config') is not None else (existing.get('exchange_config') or {}) + + symbol = (trading_config or {}).get('symbol') + timeframe = (trading_config or {}).get('timeframe') + initial_capital = (trading_config or {}).get('initial_capital') or existing.get('initial_capital') or 1000 + leverage = (trading_config or {}).get('leverage') or existing.get('leverage') or 1 + market_type = (trading_config or {}).get('market_type') or existing.get('market_type') or 'swap' + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + UPDATE qd_strategies_trading + SET strategy_name = ?, + market_category = ?, + execution_mode = ?, + notification_config = ?, + symbol = ?, + timeframe = ?, + initial_capital = ?, + leverage = ?, + market_type = ?, + exchange_config = ?, + indicator_config = ?, + trading_config = ?, + updated_at = ? + WHERE id = ? + """, + ( + name, + market_category, + execution_mode, + self._dump_json_or_encrypt(notification_config, encrypt=False), + symbol, + timeframe, + float(initial_capital or 1000), + int(leverage or 1), + market_type, + self._dump_json_or_encrypt(exchange_config, encrypt=False) if exchange_config else '', + self._dump_json_or_encrypt(indicator_config, encrypt=False), + self._dump_json_or_encrypt(trading_config, encrypt=False), + now, + strategy_id + ) + ) + db.commit() + cur.close() + return True + + def delete_strategy(self, strategy_id: int) -> bool: + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute("DELETE FROM qd_strategies_trading WHERE id = ?", (strategy_id,)) + db.commit() + cur.close() + return True + except Exception as e: + logger.error(f"delete_strategy failed: {e}") + return False diff --git a/backend_api_python/app/services/strategy_compiler.py b/backend_api_python/app/services/strategy_compiler.py new file mode 100644 index 0000000..61d6bd9 --- /dev/null +++ b/backend_api_python/app/services/strategy_compiler.py @@ -0,0 +1,688 @@ +import json +from typing import Dict, Any, List + +class StrategyCompiler: + def compile(self, config: Dict[str, Any]) -> str: + """ + Compiles the strategy configuration JSON into executable Python code. + """ + + # Extract configurations + name = config.get('name', 'Generated Strategy') + entry_rules = config.get('entry_rules', []) + position_config = config.get('position_config', {}) + pyramiding_rules = config.get('pyramiding_rules', {}) + risk_management = config.get('risk_management', {}) + + # 1. Imports and Setup + code = self._get_header(name) + + # 2. Parameters (Variables) + code += self._get_parameters(position_config, pyramiding_rules, risk_management) + + # 3. Indicators Calculation + code += self._get_indicators_calculation(entry_rules) + + # 4. Signal Logic (Entry Conditions) + code += self._get_entry_logic(entry_rules) + + # 5. Core Loop (Position Management) - Based on code2.py + code += self._get_core_loop(position_config, pyramiding_rules, risk_management) + + # 6. Output Formatting + code += self._get_output_section(name, entry_rules) + + return code + + def _get_header(self, name): + return f''' +# Generated Strategy: {name} +import pandas as pd +import numpy as np + +# Helper function for checking signals safely +def get_val(arr, i, default=0): + if i < 0 or i >= len(arr): return default + return arr[i] +''' + + def _get_parameters(self, pos_config, pyr_rules, risk_mgmt): + # Default values + initial_size = pos_config.get('initial_size_pct', 10) / 100.0 + leverage = pos_config.get('leverage', 1) + max_pyramiding = pos_config.get('max_pyramiding', 0) + + pyr_enabled = pyr_rules.get('enabled', False) + add_size = pyr_rules.get('size_pct', 0) / 100.0 if pyr_enabled else 0 + add_threshold = pyr_rules.get('value', 0) / 100.0 + + stop_loss = risk_mgmt.get('stop_loss', {}) + sl_enabled = stop_loss.get('enabled', False) + sl_pct = stop_loss.get('value', 0) / 100.0 if sl_enabled else 0.0 + + trailing = risk_mgmt.get('trailing_stop', {}) + ts_enabled = trailing.get('enabled', False) + ts_activation = trailing.get('activation_profit', 0) / 100.0 + ts_callback = trailing.get('callback_pct', 0) / 100.0 + + return f''' +# =========================== +# 1. Parameters +# =========================== +initial_position_pct = {initial_size} +leverage = {leverage} +max_pyramiding = {max_pyramiding} + +# Pyramiding +add_position_pct = {add_size} +add_threshold_pct = {add_threshold} + +# Risk Management +stop_loss_pct = {sl_pct} +take_profit_activation = {ts_activation} +trailing_callback = {ts_callback} +''' + + def _get_indicators_calculation(self, rules): + code = """ +# =========================== +# 2. Indicators Calculation +# =========================== +""" + calculated = set() + + for rule in rules: + ind = rule.get('indicator') + params = rule.get('params', {}) + + if ind == 'supertrend': + key = f"st_{params.get('period')}_{params.get('multiplier')}" + if key not in calculated: + code += f""" +# SuperTrend ({params.get('period')}, {params.get('multiplier')}) +period = {params.get('period', 14)} +multiplier = {params.get('multiplier', 3.0)} +df['hl2'] = (df['high'] + df['low']) / 2 +df['tr'] = np.maximum(df['high'] - df['low'], np.maximum(abs(df['high'] - df['close'].shift(1)), abs(df['low'] - df['close'].shift(1)))) +df['atr'] = df['tr'].ewm(alpha=1/period, adjust=False).mean() +df['basic_upper'] = df['hl2'] + (multiplier * df['atr']) +df['basic_lower'] = df['hl2'] - (multiplier * df['atr']) + +final_upper = [0.0] * len(df) +final_lower = [0.0] * len(df) +trend = [1] * len(df) +close_arr = df['close'].values +basic_upper = np.nan_to_num(df['basic_upper'].values) +basic_lower = np.nan_to_num(df['basic_lower'].values) + +for i in range(1, len(df)): + if basic_upper[i] < final_upper[i-1] or close_arr[i-1] > final_upper[i-1]: + final_upper[i] = basic_upper[i] + else: + final_upper[i] = final_upper[i-1] + + if basic_lower[i] > final_lower[i-1] or close_arr[i-1] < final_lower[i-1]: + final_lower[i] = basic_lower[i] + else: + final_lower[i] = final_lower[i-1] + + prev_trend = trend[i-1] + if prev_trend == -1 and close_arr[i] > final_upper[i-1]: + trend[i] = 1 + elif prev_trend == 1 and close_arr[i] < final_lower[i-1]: + trend[i] = -1 + else: + trend[i] = prev_trend + +df['st_trend'] = trend +df['st_upper'] = final_upper +df['st_lower'] = final_lower +""" + calculated.add(key) + + elif ind == 'ema': + period = params.get('period', 20) + key = f"ema_{period}" + if key not in calculated: + code += f"\ndf['ema_{period}'] = df['close'].ewm(span={period}, adjust=False).mean()\n" + calculated.add(key) + + elif ind == 'rsi': + period = params.get('period', 14) + key = f"rsi_{period}" + if key not in calculated: + code += f""" +# RSI ({period}) +delta = df['close'].diff() +gain = (delta.where(delta > 0, 0)).rolling(window={period}).mean() +loss = (-delta.where(delta < 0, 0)).rolling(window={period}).mean() +rs = gain / loss +df['rsi_{period}'] = 100 - (100 / (1 + rs)) +""" + calculated.add(key) + + elif ind == 'macd': + fast = params.get('fast_period', 12) + slow = params.get('slow_period', 26) + signal = params.get('signal_period', 9) + key = f"macd_{fast}_{slow}_{signal}" + if key not in calculated: + code += f""" +# MACD ({fast}, {slow}, {signal}) +exp1 = df['close'].ewm(span={fast}, adjust=False).mean() +exp2 = df['close'].ewm(span={slow}, adjust=False).mean() +df['macd_{fast}_{slow}_{signal}_line'] = exp1 - exp2 +df['macd_{fast}_{slow}_{signal}_signal'] = df['macd_{fast}_{slow}_{signal}_line'].ewm(span={signal}, adjust=False).mean() +df['macd_{fast}_{slow}_{signal}_hist'] = df['macd_{fast}_{slow}_{signal}_line'] - df['macd_{fast}_{slow}_{signal}_signal'] +""" + calculated.add(key) + + elif ind == 'bollinger': + period = params.get('period', 20) + std_dev = params.get('std_dev', 2.0) + key = f"bb_{period}_{std_dev}" + if key not in calculated: + code += f""" +# Bollinger Bands ({period}, {std_dev}) +sma = df['close'].rolling(window={period}).mean() +std = df['close'].rolling(window={period}).std() +df['bb_{period}_{std_dev}_upper'] = sma + ({std_dev} * std) +df['bb_{period}_{std_dev}_lower'] = sma - ({std_dev} * std) +df['bb_{period}_{std_dev}_mid'] = sma +""" + calculated.add(key) + + elif ind == 'kdj': + period = params.get('period', 9) + signal_period = params.get('signal_period', 3) + key = f"kdj_{period}_{signal_period}" + if key not in calculated: + code += f""" +# KDJ ({period}, {signal_period}) +low_min = df['low'].rolling(window={period}).min() +high_max = df['high'].rolling(window={period}).max() +rsv = (df['close'] - low_min) / (high_max - low_min) * 100 +df['kdj_{period}_{signal_period}_k'] = rsv.ewm(alpha=1/{signal_period}, adjust=False).mean() +df['kdj_{period}_{signal_period}_d'] = df['kdj_{period}_{signal_period}_k'].ewm(alpha=1/{signal_period}, adjust=False).mean() +df['kdj_{period}_{signal_period}_j'] = 3 * df['kdj_{period}_{signal_period}_k'] - 2 * df['kdj_{period}_{signal_period}_d'] +""" + calculated.add(key) + + elif ind == 'ma': + period = params.get('period', 20) + ma_type = params.get('ma_type', 'sma') + key = f"ma_{ma_type}_{period}" + if key not in calculated: + if ma_type == 'ema': + code += f"\ndf['ma_{ma_type}_{period}'] = df['close'].ewm(span={period}, adjust=False).mean()\n" + else: + code += f"\ndf['ma_{ma_type}_{period}'] = df['close'].rolling(window={period}).mean()\n" + calculated.add(key) + + return code + + def _get_entry_logic(self, rules): + code = """ +# =========================== +# 3. Entry Signal Logic +# =========================== +# Default False +df['raw_buy'] = False +df['raw_sell'] = False +""" + conditions_buy = [] + conditions_sell = [] + + for rule in rules: + ind = rule.get('indicator') + params = rule.get('params', {}) + + if ind == 'supertrend': + signal = rule.get('signal', 'trend_bullish') + if signal == 'trend_bullish': + conditions_buy.append("(df['st_trend'] == 1) & (df['st_trend'].shift(1) == -1)") + conditions_sell.append("(df['st_trend'] == -1) & (df['st_trend'].shift(1) == 1)") + elif signal == 'is_uptrend': + conditions_buy.append("(df['st_trend'] == 1)") + conditions_sell.append("(df['st_trend'] == -1)") + + elif ind == 'ema': + period = params.get('period', 20) + operator = rule.get('operator', 'price_above') + col = f"df['ema_{period}']" + if operator == 'price_above': + conditions_buy.append(f"(df['close'] > {col})") + conditions_sell.append(f"(df['close'] < {col})") + elif operator == 'price_below': + conditions_buy.append(f"(df['close'] < {col})") + conditions_sell.append(f"(df['close'] > {col})") + elif operator == 'cross_up': + conditions_buy.append(f"(df['close'] > {col}) & (df['close'].shift(1) <= {col}.shift(1))") + conditions_sell.append(f"(df['close'] < {col}) & (df['close'].shift(1) >= {col}.shift(1))") + elif operator == 'cross_down': + conditions_buy.append(f"(df['close'] < {col}) & (df['close'].shift(1) >= {col}.shift(1))") + conditions_sell.append(f"(df['close'] > {col}) & (df['close'].shift(1) <= {col}.shift(1))") + + elif ind == 'rsi': + period = params.get('period', 14) + operator = rule.get('operator', '<') + thresh = params.get('threshold', 30) + col = f"df['rsi_{period}']" + if operator == '<': + conditions_buy.append(f"({col} < {thresh})") + conditions_sell.append(f"({col} > {100-thresh})") + elif operator == '>': + conditions_buy.append(f"({col} > {thresh})") + conditions_sell.append(f"({col} < {100-thresh})") + elif operator == 'cross_up': + conditions_buy.append(f"({col} > {thresh}) & ({col}.shift(1) <= {thresh})") + conditions_sell.append(f"({col} < {100-thresh}) & ({col}.shift(1) >= {100-thresh})") + elif operator == 'cross_down': + conditions_buy.append(f"({col} < {thresh}) & ({col}.shift(1) >= {thresh})") + conditions_sell.append(f"({col} > {100-thresh}) & ({col}.shift(1) <= {100-thresh})") + + elif ind == 'macd': + fast = params.get('fast_period', 12) + slow = params.get('slow_period', 26) + signal = params.get('signal_period', 9) + operator = rule.get('operator', 'diff_gt_dea') + line_col = f"df['macd_{fast}_{slow}_{signal}_line']" + sig_col = f"df['macd_{fast}_{slow}_{signal}_signal']" + + if operator == 'diff_gt_dea': + conditions_buy.append(f"({line_col} > {sig_col})") + conditions_sell.append(f"({line_col} < {sig_col})") + elif operator == 'diff_lt_dea': + conditions_buy.append(f"({line_col} < {sig_col})") + conditions_sell.append(f"({line_col} > {sig_col})") + elif operator == 'cross_up': + conditions_buy.append(f"({line_col} > {sig_col}) & ({line_col}.shift(1) <= {sig_col}.shift(1))") + conditions_sell.append(f"({line_col} < {sig_col}) & ({line_col}.shift(1) >= {sig_col}.shift(1))") + elif operator == 'cross_down': + conditions_buy.append(f"({line_col} < {sig_col}) & ({line_col}.shift(1) >= {sig_col}.shift(1))") + conditions_sell.append(f"({line_col} > {sig_col}) & ({line_col}.shift(1) <= {sig_col}.shift(1))") + + elif ind == 'bollinger': + period = params.get('period', 20) + std_dev = params.get('std_dev', 2.0) + operator = rule.get('operator', 'price_above_upper') + upper = f"df['bb_{period}_{std_dev}_upper']" + lower = f"df['bb_{period}_{std_dev}_lower']" + mid = f"df['bb_{period}_{std_dev}_mid']" + + if operator == 'price_above_upper': + conditions_buy.append(f"(df['close'] > {upper})") + conditions_sell.append(f"(df['close'] < {lower})") + elif operator == 'price_below_lower': + conditions_buy.append(f"(df['close'] < {lower})") + conditions_sell.append(f"(df['close'] > {upper})") + elif operator == 'price_above_mid': + conditions_buy.append(f"(df['close'] > {mid})") + conditions_sell.append(f"(df['close'] < {mid})") + elif operator == 'price_below_mid': + conditions_buy.append(f"(df['close'] < {mid})") + conditions_sell.append(f"(df['close'] > {mid})") + elif operator == 'cross_up_lower': + conditions_buy.append(f"(df['close'] > {lower}) & (df['close'].shift(1) <= {lower}.shift(1))") + conditions_sell.append(f"(df['close'] < {upper}) & (df['close'].shift(1) >= {upper}.shift(1))") + elif operator == 'cross_down_upper': + conditions_buy.append(f"(df['close'] < {upper}) & (df['close'].shift(1) >= {upper}.shift(1))") + conditions_sell.append(f"(df['close'] > {lower}) & (df['close'].shift(1) <= {lower}.shift(1))") + + elif ind == 'kdj': + period = params.get('period', 9) + signal_period = params.get('signal_period', 3) + operator = rule.get('operator', 'k_gt_d') + k_col = f"df['kdj_{period}_{signal_period}_k']" + d_col = f"df['kdj_{period}_{signal_period}_d']" + + if operator == 'k_gt_d': + conditions_buy.append(f"({k_col} > {d_col})") + conditions_sell.append(f"({k_col} < {d_col})") + elif operator == 'k_lt_d': + conditions_buy.append(f"({k_col} < {d_col})") + conditions_sell.append(f"({k_col} > {d_col})") + elif operator == 'gold_cross': + conditions_buy.append(f"({k_col} > {d_col}) & ({k_col}.shift(1) <= {d_col}.shift(1))") + conditions_sell.append(f"({k_col} < {d_col}) & ({k_col}.shift(1) >= {d_col}.shift(1))") + elif operator == 'death_cross': + conditions_buy.append(f"({k_col} < {d_col}) & ({k_col}.shift(1) >= {d_col}.shift(1))") + conditions_sell.append(f"({k_col} > {d_col}) & ({k_col}.shift(1) <= {d_col}.shift(1))") + + elif ind == 'ma': + period = params.get('period', 20) + ma_type = params.get('ma_type', 'sma') + operator = rule.get('operator', 'price_above') + col = f"df['ma_{ma_type}_{period}']" + + if operator == 'price_above': + conditions_buy.append(f"(df['close'] > {col})") + conditions_sell.append(f"(df['close'] < {col})") + elif operator == 'price_below': + conditions_buy.append(f"(df['close'] < {col})") + conditions_sell.append(f"(df['close'] > {col})") + elif operator == 'cross_up': + conditions_buy.append(f"(df['close'] > {col}) & (df['close'].shift(1) <= {col}.shift(1))") + conditions_sell.append(f"(df['close'] < {col}) & (df['close'].shift(1) >= {col}.shift(1))") + elif operator == 'cross_down': + conditions_buy.append(f"(df['close'] < {col}) & (df['close'].shift(1) >= {col}.shift(1))") + conditions_sell.append(f"(df['close'] > {col}) & (df['close'].shift(1) <= {col}.shift(1))") + + if conditions_buy: + code += f"\ndf['raw_buy'] = {' & '.join(conditions_buy)}\n" + if conditions_sell: + code += f"\ndf['raw_sell'] = {' & '.join(conditions_sell)}\n" + + return code + + def _get_core_loop(self, pos_config, pyr_rules, risk_mgmt): + # This mirrors the logic in code2.py loop + return """ +# =========================== +# 4. Core Loop (Backtest) +# =========================== +open_long_signals = [False] * len(df) +add_long_signals = [False] * len(df) +close_long_signals = [False] * len(df) + +open_long_text = [None] * len(df) +add_long_text = [None] * len(df) + +open_long_price = [0.0] * len(df) +add_long_price = [0.0] * len(df) +close_long_price = [0.0] * len(df) +close_long_text = [None] * len(df) + +open_short_signals = [False] * len(df) +add_short_signals = [False] * len(df) +close_short_signals = [False] * len(df) + +open_short_price = [0.0] * len(df) +add_short_price = [0.0] * len(df) +close_short_price = [0.0] * len(df) +close_short_text = [None] * len(df) + +position = 0 # 0, 1 (Long), -1 (Short) +position_count = 0 +avg_entry_price = 0.0 +last_add_price = 0.0 +highest_price = 0.0 # For Long: Highest High; For Short: Lowest Low + +close_arr = df['close'].values +high_arr = df['high'].values +low_arr = df['low'].values +raw_buy_arr = df['raw_buy'].values +raw_sell_arr = df['raw_sell'].values + +for i in range(len(df)): + current_close = close_arr[i] + current_high = high_arr[i] + current_low = low_arr[i] + + if position == 1: + # Long Position + if current_high > highest_price: + highest_price = current_high + + profit_pct = (highest_price - avg_entry_price) / avg_entry_price + current_profit_pct = (current_close - avg_entry_price) / avg_entry_price + + # 1. Trailing Stop + if take_profit_activation > 0 and profit_pct >= take_profit_activation: + drawdown = (highest_price - current_close) / avg_entry_price + if drawdown >= trailing_callback: + close_long_signals[i] = True + close_long_price[i] = current_close + close_long_text[i] = "Trailing Stop" + position = 0 + position_count = 0 + continue + + # 2. Stop Loss + if stop_loss_pct > 0: + loss_pct = (avg_entry_price - current_low) / avg_entry_price + if loss_pct >= stop_loss_pct: + close_long_signals[i] = True + close_long_price[i] = avg_entry_price * (1 - stop_loss_pct) + close_long_text[i] = "Stop Loss" + position = 0 + position_count = 0 + continue + + # 3. Signal Exit (if enabled) + # Note: Code2 uses raw_sell_arr for exit + if raw_sell_arr[i]: + close_long_signals[i] = True + close_long_price[i] = current_close + close_long_text[i] = "Signal Exit" + position = 0 + position_count = 0 + + # Reverse to Short if trade_direction allows (simplified here) + # For now we just close. + continue + + # 4. Pyramiding (Add Long) + if max_pyramiding > 0 and position_count < max_pyramiding + 1 and current_profit_pct > 0: + # Condition: Price rise by threshold + if add_threshold_pct > 0: + target_price = last_add_price * (1 + add_threshold_pct) + if current_high >= target_price: + add_long_signals[i] = True + add_long_price[i] = target_price + add_long_text[i] = "Add Long" + position_count += 1 + last_add_price = target_price + + elif position == -1: + # Short Position + # For Short, highest_price tracks the LOWEST price (best profit scenario) + if highest_price == 0: highest_price = avg_entry_price + if current_low < highest_price: + highest_price = current_low + + # Profit: (Entry - Lowest) / Entry + profit_pct = (avg_entry_price - highest_price) / avg_entry_price + current_profit_pct = (avg_entry_price - current_close) / avg_entry_price + + # 1. Trailing Stop + if take_profit_activation > 0 and profit_pct >= take_profit_activation: + # Drawdown: (Current - Lowest) / Entry + drawdown = (current_close - highest_price) / avg_entry_price + if drawdown >= trailing_callback: + close_short_signals[i] = True + close_short_price[i] = current_close + close_short_text[i] = "Trailing Stop" + position = 0 + position_count = 0 + continue + + # 2. Stop Loss + if stop_loss_pct > 0: + # Loss: Price went up. (High - Entry) / Entry + loss_pct = (current_high - avg_entry_price) / avg_entry_price + if loss_pct >= stop_loss_pct: + close_short_signals[i] = True + close_short_price[i] = avg_entry_price * (1 + stop_loss_pct) + close_short_text[i] = "Stop Loss" + position = 0 + position_count = 0 + continue + + # 3. Signal Exit + if raw_buy_arr[i]: + close_short_signals[i] = True + close_short_price[i] = current_close + close_short_text[i] = "Signal Exit" + position = 0 + position_count = 0 + continue + + # 4. Pyramiding (Add Short) + if max_pyramiding > 0 and position_count < max_pyramiding + 1 and current_profit_pct > 0: + # Condition: Price drop by threshold + if add_threshold_pct > 0: + target_price = last_add_price * (1 - add_threshold_pct) + if current_low <= target_price: + add_short_signals[i] = True + add_short_price[i] = target_price + add_short_text[i] = "Add Short" + position_count += 1 + last_add_price = target_price + + else: + # No Position + if raw_buy_arr[i]: + open_long_signals[i] = True + open_long_price[i] = current_close + open_long_text[i] = "Open Long" + position = 1 + position_count = 1 + avg_entry_price = current_close + last_add_price = current_close + highest_price = current_close + + elif raw_sell_arr[i]: + open_short_signals[i] = True + open_short_price[i] = current_close + open_short_text[i] = "Open Short" + position = -1 + position_count = 1 + avg_entry_price = current_close + last_add_price = current_close + highest_price = current_close # Init with Entry + +# Append columns +df['open_long'] = open_long_signals +df['add_long'] = add_long_signals +df['close_long'] = close_long_signals +df['open_long_price'] = [p if s else None for p, s in zip(open_long_price, open_long_signals)] +df['add_long_price'] = [p if s else None for p, s in zip(add_long_price, add_long_signals)] +df['close_long_price'] = [p if s else None for p, s in zip(close_long_price, close_long_signals)] +df['open_long_text'] = open_long_text +df['add_long_text'] = add_long_text +df['close_long_text'] = close_long_text +""" + + def _get_output_section(self, name, rules): + # Generate plot configs based on indicators + plots = [] + for rule in rules: + ind = rule.get('indicator') + params = rule.get('params', {}) + if ind == 'supertrend': + plots.append({ + "name": "SuperTrend Up", "type": "line", "data": "df['st_lower'].tolist()", "color": "#00FF00", "overlay": True + }) + plots.append({ + "name": "SuperTrend Down", "type": "line", "data": "df['st_upper'].tolist()", "color": "#FF0000", "overlay": True + }) + elif ind == 'ema': + p = params.get('period', 20) + plots.append({ + "name": f"EMA {p}", "type": "line", "data": f"df['ema_{p}'].tolist()", "color": "#FFA500", "overlay": True + }) + elif ind == 'ma': + p = params.get('period', 20) + t = params.get('ma_type', 'sma') + plots.append({ + "name": f"{t.upper()} {p}", "type": "line", "data": f"df['ma_{t}_{p}'].tolist()", "color": "#FFA500", "overlay": True + }) + elif ind == 'bollinger': + p = params.get('period', 20) + d = params.get('std_dev', 2.0) + plots.append({ + "name": "BB Upper", "type": "line", "data": f"df['bb_{p}_{d}_upper'].tolist()", "color": "#0088FE", "overlay": True + }) + plots.append({ + "name": "BB Lower", "type": "line", "data": f"df['bb_{p}_{d}_lower'].tolist()", "color": "#0088FE", "overlay": True + }) + # MACD, RSI, KDJ are typically separate panes, not overlay. The `overlay` param controls this. + elif ind == 'macd': + f = params.get('fast_period', 12) + s = params.get('slow_period', 26) + si = params.get('signal_period', 9) + plots.append({ + "name": "MACD", "type": "line", "data": f"df['macd_{f}_{s}_{si}_line'].tolist()", "color": "#0088FE", "overlay": False + }) + plots.append({ + "name": "Signal", "type": "line", "data": f"df['macd_{f}_{s}_{si}_signal'].tolist()", "color": "#FF8042", "overlay": False + }) + elif ind == 'rsi': + p = params.get('period', 14) + plots.append({ + "name": f"RSI {p}", "type": "line", "data": f"df['rsi_{p}'].tolist()", "color": "#8884d8", "overlay": False + }) + elif ind == 'kdj': + p = params.get('period', 9) + si = params.get('signal_period', 3) + plots.append({ + "name": "K", "type": "line", "data": f"df['kdj_{p}_{si}_k'].tolist()", "color": "#8884d8", "overlay": False + }) + plots.append({ + "name": "D", "type": "line", "data": f"df['kdj_{p}_{si}_d'].tolist()", "color": "#82ca9d", "overlay": False + }) + plots.append({ + "name": "J", "type": "line", "data": f"df['kdj_{p}_{si}_j'].tolist()", "color": "#ffc658", "overlay": False + }) + + # Convert plots to string representation valid in Python + plots_py = "[\n" + for p in plots: + plots_py += f" {{'name': '{p['name']}', 'type': '{p['type']}', 'data': {p['data']}, 'color': '{p['color']}', 'overlay': {p['overlay']}}},\n" + plots_py += "]" + + return f""" +# =========================== +# 5. Output +# =========================== +output = {{ + "name": "{name}", + "plots": {plots_py}, + "signals": [ + {{ + "name": "Open Long", + "type": "buy", + "data": df['open_long_price'].tolist(), + "color": "#00FF00", + "text": "Open Long" + }}, + {{ + "name": "Add Long", + "type": "buy", + "data": df['add_long_price'].tolist(), + "color": "#00DD00", + "text": "Add Long" + }}, + {{ + "name": "Close Long", + "type": "sell", + "data": df['close_long_price'].tolist(), + "color": "#FF6600", + "text": "Close Long" + }}, + {{ + "name": "Open Short", + "type": "sell", + "data": df['open_short_price'].tolist(), + "color": "#FF0000", + "text": "Open Short" + }}, + {{ + "name": "Add Short", + "type": "sell", + "data": df['add_short_price'].tolist(), + "color": "#DD0000", + "text": "Add Short" + }}, + {{ + "name": "Close Short", + "type": "buy", + "data": df['close_short_price'].tolist(), + "color": "#00CCFF", + "text": "Close Short" + }} + ] +}} +""" + diff --git a/backend_api_python/app/services/symbol_name.py b/backend_api_python/app/services/symbol_name.py new file mode 100644 index 0000000..25131de --- /dev/null +++ b/backend_api_python/app/services/symbol_name.py @@ -0,0 +1,243 @@ +""" +Symbol/company name resolver for local-only mode. + +Goal: +- When a symbol is not present in our seed list, try to resolve a human-readable name + from public data sources, then persist it into watchlist records. + +Notes: +- For A shares we prefer akshare when available (requested). +- For H shares we use Tencent quote API (no key required). +- For US stocks we use Finnhub (if configured) or yfinance. +- For Crypto/Forex/Futures we provide best-effort fallbacks. +""" + +from __future__ import annotations + +from typing import Optional + +import re +import os + +import requests + +from app.utils.logger import get_logger +from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name + +logger = get_logger(__name__) + +try: + import akshare as ak # type: ignore + HAS_AKSHARE = True +except Exception: + ak = None + HAS_AKSHARE = False + + +def _normalize_symbol_for_market(market: str, symbol: str) -> str: + m = (market or '').strip() + s = (symbol or '').strip().upper() + if not m or not s: + return s + + if m == 'AShare' and s.isdigit(): + return s.zfill(6) + if m == 'HShare' and s.isdigit(): + return s.zfill(5) + + return s + + +def _tencent_quote_code(market: str, symbol: str) -> Optional[str]: + """ + Convert symbol to Tencent quote code, e.g. + - AShare: sh600000 / sz000001 / bj430047 + - HShare: hk00700 + """ + m = (market or '').strip() + s = _normalize_symbol_for_market(m, symbol) + if not s: + return None + + if m == 'AShare': + if s.startswith('6'): + return f"sh{s}" + if s.startswith('0') or s.startswith('3'): + return f"sz{s}" + if s.startswith('4') or s.startswith('8'): + return f"bj{s}" + return None + + if m == 'HShare': + if s.isdigit(): + return f"hk{s}" + # allow already prefixed + if s.startswith('HK') and s[2:].isdigit(): + return f"hk{s[2:]}" + if s.startswith('HK') and len(s) > 2: + return f"hk{s[2:]}" + return f"hk{s}" + + return None + + +def _resolve_name_from_tencent(market: str, symbol: str) -> Optional[str]: + """ + Tencent quote endpoint: http://qt.gtimg.cn/q=sz000858 + Returns: + v_sz000858="51~五 粮 液~000858~..."; -> name is the 2nd field split by '~' + """ + code = _tencent_quote_code(market, symbol) + if not code: + return None + + try: + url = f"http://qt.gtimg.cn/q={code}" + resp = requests.get(url, timeout=5) + # Tencent often responds in GBK for Chinese names + resp.encoding = 'gbk' + text = resp.text or '' + + # Extract quoted payload + m = re.search(r'="([^"]*)"', text) + payload = m.group(1) if m else '' + if not payload: + return None + + parts = payload.split('~') + if len(parts) < 2: + return None + + name = (parts[1] or '').strip().replace(' ', '') + return name if name else None + except Exception as e: + logger.debug(f"Tencent name resolve failed: {market} {symbol}: {e}") + return None + + +def _resolve_name_from_akshare_ashare(symbol: str) -> Optional[str]: + """ + Resolve A-share name via akshare (no API key required). + """ + if not HAS_AKSHARE or ak is None: + return None + try: + # Prefer per-symbol endpoint (avoids fetching the whole market list). + if hasattr(ak, "stock_individual_info_em"): + df = ak.stock_individual_info_em(symbol=symbol) + if df is not None and not df.empty and 'item' in df.columns and 'value' in df.columns: + info = {str(r['item']).strip(): r['value'] for _, r in df.iterrows()} + name = str(info.get('股票简称') or info.get('证券简称') or '').strip() + return name if name else None + + # Fallback: spot list (may be slow / large) + if hasattr(ak, "stock_zh_a_spot_em"): + df2 = ak.stock_zh_a_spot_em() + if df2 is not None and not df2.empty: + row = df2[df2['代码'] == symbol].iloc[0] + name = str(row.get('名称') or '').strip() + return name if name else None + except Exception as e: + logger.debug(f"akshare name resolve failed (AShare {symbol}): {e}") + return None + return None + +def _resolve_name_from_yfinance(symbol: str) -> Optional[str]: + """ + Best-effort company name via yfinance. + """ + def _try_one(sym: str) -> Optional[str]: + import yfinance as yf + t = yf.Ticker(sym) + info = getattr(t, "info", None) + if not isinstance(info, dict) or not info: + return None + name = (info.get('longName') or info.get('shortName') or '').strip() + return name if name else None + try: + # yfinance uses '-' for some tickers (e.g. BRK-B) while users may input 'BRK.B' + out = _try_one(symbol) + if out: + return out + if '.' in symbol: + out = _try_one(symbol.replace('.', '-')) + if out: + return out + return None + except Exception as e: + logger.debug(f"yfinance name resolve failed: {symbol}: {e}") + return None + + +def _resolve_name_from_finnhub(symbol: str) -> Optional[str]: + """ + Finnhub company profile (requires FINNHUB_API_KEY). + https://finnhub.io/docs/api/company-profile2 + """ + try: + api_key = (os.getenv('FINNHUB_API_KEY') or '').strip() + if not api_key: + return None + url = "https://finnhub.io/api/v1/stock/profile2" + resp = requests.get(url, params={"symbol": symbol, "token": api_key}, timeout=8) + if resp.status_code != 200: + return None + data = resp.json() if resp.text else {} + if not isinstance(data, dict) or not data: + return None + name = (data.get("name") or data.get("ticker") or '').strip() + return name if name else None + except Exception as e: + logger.debug(f"Finnhub name resolve failed: {symbol}: {e}") + return None + + +def resolve_symbol_name(market: str, symbol: str) -> Optional[str]: + """ + Resolve a display name for a symbol. + Priority: + 1) Seed mapping (fast, offline) + 2) Market-specific public sources + 3) Reasonable fallback (None) + """ + m = (market or '').strip() + s = _normalize_symbol_for_market(m, symbol) + if not m or not s: + return None + + # 1) Seed + seed = seed_get_symbol_name(m, s) + if seed: + return seed + + # 2) Market-specific + if m == 'AShare': + # Requested: use akshare for A shares, do not depend on Tencent by default. + return _resolve_name_from_akshare_ashare(s) + + if m == 'HShare': + return _resolve_name_from_tencent(m, s) + + if m == 'USStock': + # Prefer Finnhub if configured (more stable for company name), + # otherwise fall back to yfinance. + return _resolve_name_from_finnhub(s) or _resolve_name_from_yfinance(s) + + # Crypto: at least return base ticker-like display (not a "company", but better than empty) + if m == 'Crypto': + if '/' in s: + base = s.split('/')[0].strip() + return base if base else None + return s + + # Forex: keep as-is (e.g. EURUSD) – you can later replace with a nicer mapping if needed. + if m == 'Forex': + return s + + # Futures: keep as-is + if m == 'Futures': + return s + + return None + + diff --git a/backend_api_python/app/services/trading_executor.py b/backend_api_python/app/services/trading_executor.py new file mode 100644 index 0000000..0f51ed8 --- /dev/null +++ b/backend_api_python/app/services/trading_executor.py @@ -0,0 +1,2341 @@ +""" +实时交易执行服务 +""" +import time +import threading +import traceback +import os +try: + import resource # Linux/Unix only +except Exception: + resource = None +from typing import Dict, List, Any, Optional +from datetime import datetime +import json +from decimal import Decimal, ROUND_DOWN, ROUND_UP +import pandas as pd +import numpy as np +import ccxt + +from app.utils.logger import get_logger +from app.utils.db import get_db_connection +from app.data_sources import DataSourceFactory +from app.services.kline import KlineService + +logger = get_logger(__name__) + + +class TradingExecutor: + """实时交易执行器 (Signal Provider Mode)""" + + def __init__(self): + # 不再使用全局连接,改为每次使用时从连接池获取 + self.running_strategies = {} # {strategy_id: thread} + self.lock = threading.Lock() + # Local-only lightweight in-memory price cache (symbol -> (price, expiry_ts)). + # This replaces the old Redis-based PriceCache for local deployments. + self._price_cache = {} + self._price_cache_lock = threading.Lock() + # Default to 10s to match the unified tick cadence. + self._price_cache_ttl_sec = int(os.getenv("PRICE_CACHE_TTL_SEC", "10")) + + # In-memory signal de-dup cache to prevent repeated orders on the same candle signal. + # Keyed by (strategy_id, symbol, signal_type, signal_timestamp). + self._signal_dedup = {} # type: Dict[int, Dict[str, float]] + self._signal_dedup_lock = threading.Lock() + self.kline_service = KlineService() # K线服务(带缓存) + + # 单实例线程上限,避免无限制创建线程导致 can't start new thread/OOM + self.max_threads = int(os.getenv('STRATEGY_MAX_THREADS', '64')) + + # 确保数据库字段存在 + self._ensure_db_columns() + + def _ensure_db_columns(self): + """确保必要的数据库字段存在""" + try: + with get_db_connection() as db: + cursor = db.cursor() + + # SQLite 兼容:使用 PRAGMA table_info 检查列是否存在 + try: + cursor.execute("PRAGMA table_info(qd_strategy_positions)") + cols = cursor.fetchall() or [] + col_names = {c.get('name') for c in cols if isinstance(c, dict)} + except Exception: + col_names = set() + + if 'highest_price' not in col_names: + logger.info("Adding highest_price column to qd_strategy_positions (SQLite)...") + # SQLite 不支持 AFTER 子句,类型用 REAL 即可 + cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN highest_price REAL DEFAULT 0") + db.commit() + logger.info("highest_price column added") + + if 'lowest_price' not in col_names: + logger.info("Adding lowest_price column to qd_strategy_positions (SQLite)...") + cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN lowest_price REAL DEFAULT 0") + db.commit() + logger.info("lowest_price column added") + + cursor.close() + except Exception as e: + logger.error(f"Failed to check/ensure DB columns: {str(e)}") + + def _normalize_trade_symbol(self, exchange: Any, symbol: str, market_type: str, exchange_id: str) -> str: + """ + 将数据库/配置里的 symbol 规范化为交易所合约可用的 CCXT symbol。 + + 典型场景:OKX 永续统一符号通常是 `BNB/USDT:USDT`,但前端/数据库可能传 `BNB/USDT`。 + """ + try: + # 新系统:仅支持 swap(合约永续) / spot(现货) + if market_type != 'swap': + return symbol + if not symbol or ':' in symbol: + return symbol + if not getattr(exchange, 'markets', None): + return symbol + + # 如果 symbol 本身就是合约市场,直接返回 + try: + m = exchange.market(symbol) + if m and (m.get('swap') or m.get('future') or m.get('contract')): + return symbol + except Exception: + pass + + # OKX/部分交易所:永续常见为 BASE/QUOTE:QUOTE 或 BASE/QUOTE:USDT + if '/' not in symbol: + return symbol + base, quote = symbol.split('/', 1) + candidates = [] + if quote: + candidates.append(f"{base}/{quote}:{quote}") + if quote.upper() != 'USDT': + candidates.append(f"{base}/{quote}:USDT") + + for cand in candidates: + if cand in exchange.markets: + cm = exchange.markets[cand] + if cm and (cm.get('swap') or cm.get('future') or cm.get('contract')): + logger.info(f"symbol normalized: {symbol} -> {cand} (exchange={exchange_id}, market_type={market_type})") + return cand + + return symbol + except Exception: + return symbol + + def _log_resource_status(self, prefix: str = ""): + """调试:记录线程/内存使用,便于定位 can't start new thread 根因""" + try: + import psutil # 如果有安装则使用更精确的指标 + p = psutil.Process() + mem = p.memory_info().rss / 1024 / 1024 + th = p.num_threads() + logger.warning(f"{prefix}resource status: memory={mem:.1f}MB, threads={th}, " + f"running_strategies={len(self.running_strategies)}") + except Exception: + try: + th = threading.active_count() + # 从 /proc/self/status 读取 VmRSS(适用于 Linux 容器) + vmrss = None + try: + with open('/proc/self/status') as f: + for line in f: + if line.startswith('VmRSS:'): + vmrss = line.split()[1:3] # e.g. ['123456', 'kB'] + break + except Exception: + pass + vmrss_str = f"{vmrss[0]}{vmrss[1]}" if vmrss else "N/A" + logger.warning(f"{prefix}resource status: VmRSS={vmrss_str}, active_threads={th}, " + f"running_strategies={len(self.running_strategies)}") + except Exception: + pass + + def _console_print(self, msg: str) -> None: + """ + Local-only observability: print to stdout so user can see strategy status in console. + """ + try: + print(str(msg or ""), flush=True) + except Exception: + pass + + def _position_state(self, positions: List[Dict[str, Any]]) -> str: + """ + Return current position state for a strategy+symbol in local single-position mode. + + Returns: 'flat' | 'long' | 'short' + """ + try: + if not positions: + return "flat" + # Local mode assumes single-direction position per symbol. + side = (positions[0].get("side") or "").strip().lower() + if side in ("long", "short"): + return side + except Exception: + pass + return "flat" + + def _is_signal_allowed(self, state: str, signal_type: str) -> bool: + """ + Enforce strict state machine: + - flat: only open_long/open_short + - long: only add_long/close_long + - short: only add_short/close_short + """ + st = (state or "flat").strip().lower() + sig = (signal_type or "").strip().lower() + if st == "flat": + return sig in ("open_long", "open_short") + if st == "long": + return sig in ("add_long", "reduce_long", "close_long") + if st == "short": + return sig in ("add_short", "reduce_short", "close_short") + return False + + def _signal_priority(self, signal_type: str) -> int: + """ + Lower value = higher priority. We always close before (re)opening/adding. + """ + sig = (signal_type or "").strip().lower() + if sig.startswith("close_"): + return 0 + if sig.startswith("reduce_"): + return 1 + if sig.startswith("open_"): + return 2 + if sig.startswith("add_"): + return 3 + return 99 + + def _dedup_key(self, strategy_id: int, symbol: str, signal_type: str, signal_ts: int) -> str: + sym = (symbol or "").strip().upper() + if ":" in sym: + sym = sym.split(":", 1)[0] + return f"{int(strategy_id)}|{sym}|{(signal_type or '').strip().lower()}|{int(signal_ts or 0)}" + + def _should_skip_signal_once_per_candle( + self, + strategy_id: int, + symbol: str, + signal_type: str, + signal_ts: int, + timeframe_seconds: int, + now_ts: Optional[int] = None, + ) -> bool: + """ + Prevent repeated orders for the same candle signal across ticks. + + This is especially important for 'confirmed' signals that point to the previous closed candle: + the signal timestamp stays constant for the entire next candle, so without de-dup the system + would re-enqueue the same order every tick. + """ + try: + now = int(now_ts or time.time()) + tf = int(timeframe_seconds or 0) + if tf <= 0: + tf = 60 + # Keep keys long enough to cover at least the next candle. + ttl_sec = max(tf * 2, 120) + expiry = float(now + ttl_sec) + key = self._dedup_key(strategy_id, symbol, signal_type, int(signal_ts or 0)) + + with self._signal_dedup_lock: + bucket = self._signal_dedup.get(int(strategy_id)) + if bucket is None: + bucket = {} + self._signal_dedup[int(strategy_id)] = bucket + + # Opportunistic cleanup + stale = [k for k, exp in bucket.items() if float(exp) <= now] + for k in stale[:512]: + try: + del bucket[k] + except Exception: + pass + + exp = bucket.get(key) + if exp is not None and float(exp) > now: + return True + + # Reserve the key (best-effort). Caller may still fail to enqueue; that's acceptable + # because repeated failures should not flood the queue. + bucket[key] = expiry + return False + except Exception: + return False + + def _to_ratio(self, v: Any, default: float = 0.0) -> float: + """ + Convert a percent-like value into ratio in [0, 1]. + Accepts both 0~1 and 0~100 inputs. + """ + try: + x = float(v if v is not None else default) + except Exception: + x = float(default or 0.0) + if x > 1.0: + x = x / 100.0 + if x < 0: + x = 0.0 + if x > 1.0: + x = 1.0 + return float(x) + + def _build_cfg_from_trading_config(self, trading_config: Dict[str, Any]) -> Dict[str, Any]: + """ + Build a backtest-modal compatible config dict for indicator scripts. + + Frontend (trading assistant) stores most params as flat keys under `trading_config`. + Backtest service expects nested structure: cfg.risk/cfg.scale/cfg.position (camelCase). + + We provide BOTH: + - `trading_config`: the original flat dict (so existing scripts keep working) + - `cfg`: a normalized nested dict (so scripts can reuse backtest-style helpers) + """ + tc = trading_config or {} + + # Risk / trailing + stop_loss_pct = self._to_ratio(tc.get("stop_loss_pct")) + take_profit_pct = self._to_ratio(tc.get("take_profit_pct")) + trailing_enabled = bool(tc.get("trailing_enabled")) + trailing_stop_pct = self._to_ratio(tc.get("trailing_stop_pct")) + trailing_activation_pct = self._to_ratio(tc.get("trailing_activation_pct")) + + # Position sizing + entry_pct = self._to_ratio(tc.get("entry_pct")) + + # Scale-in + trend_add_enabled = bool(tc.get("trend_add_enabled")) + trend_add_step_pct = self._to_ratio(tc.get("trend_add_step_pct")) + trend_add_size_pct = self._to_ratio(tc.get("trend_add_size_pct")) + trend_add_max_times = int(tc.get("trend_add_max_times") or 0) + + dca_add_enabled = bool(tc.get("dca_add_enabled")) + dca_add_step_pct = self._to_ratio(tc.get("dca_add_step_pct")) + dca_add_size_pct = self._to_ratio(tc.get("dca_add_size_pct")) + dca_add_max_times = int(tc.get("dca_add_max_times") or 0) + + # Scale-out / reduce + trend_reduce_enabled = bool(tc.get("trend_reduce_enabled")) + trend_reduce_step_pct = self._to_ratio(tc.get("trend_reduce_step_pct")) + trend_reduce_size_pct = self._to_ratio(tc.get("trend_reduce_size_pct")) + trend_reduce_max_times = int(tc.get("trend_reduce_max_times") or 0) + + adverse_reduce_enabled = bool(tc.get("adverse_reduce_enabled")) + adverse_reduce_step_pct = self._to_ratio(tc.get("adverse_reduce_step_pct")) + adverse_reduce_size_pct = self._to_ratio(tc.get("adverse_reduce_size_pct")) + adverse_reduce_max_times = int(tc.get("adverse_reduce_max_times") or 0) + + return { + "risk": { + "stopLossPct": stop_loss_pct, + "takeProfitPct": take_profit_pct, + "trailing": { + "enabled": trailing_enabled, + "pct": trailing_stop_pct, + "activationPct": trailing_activation_pct, + }, + }, + "position": { + "entryPct": entry_pct, + }, + "scale": { + "trendAdd": { + "enabled": trend_add_enabled, + "stepPct": trend_add_step_pct, + "sizePct": trend_add_size_pct, + "maxTimes": trend_add_max_times, + }, + "dcaAdd": { + "enabled": dca_add_enabled, + "stepPct": dca_add_step_pct, + "sizePct": dca_add_size_pct, + "maxTimes": dca_add_max_times, + }, + "trendReduce": { + "enabled": trend_reduce_enabled, + "stepPct": trend_reduce_step_pct, + "sizePct": trend_reduce_size_pct, + "maxTimes": trend_reduce_max_times, + }, + "adverseReduce": { + "enabled": adverse_reduce_enabled, + "stepPct": adverse_reduce_step_pct, + "sizePct": adverse_reduce_size_pct, + "maxTimes": adverse_reduce_max_times, + }, + }, + } + + def start_strategy(self, strategy_id: int) -> bool: + """ + 启动策略 + + Args: + strategy_id: 策略ID + + Returns: + 是否成功 + """ + try: + with self.lock: + # 清理已退出的线程,防止计数膨胀 + stale_ids = [sid for sid, th in self.running_strategies.items() if not th.is_alive()] + for sid in stale_ids: + del self.running_strategies[sid] + + if len(self.running_strategies) >= self.max_threads: + logger.error( + f"Thread limit reached ({self.max_threads}); refuse to start strategy {strategy_id}. " + f"Reduce running strategies or increase STRATEGY_MAX_THREADS." + ) + self._log_resource_status(prefix="start_denied: ") + return False + + if strategy_id in self.running_strategies: + logger.warning(f"Strategy {strategy_id} is already running") + return False + + # 创建并启动线程 + thread = threading.Thread( + target=self._run_strategy_loop, + args=(strategy_id,), + daemon=True + ) + try: + thread.start() + except Exception as e: + # 捕获 can't start new thread 等异常,记录资源状态 + self._log_resource_status(prefix="启动异常") + raise e + self.running_strategies[strategy_id] = thread + + logger.info(f"Strategy {strategy_id} started") + self._console_print(f"[strategy:{strategy_id}] started") + return True + + except Exception as e: + logger.error(f"Failed to start strategy {strategy_id}: {str(e)}") + logger.error(traceback.format_exc()) + return False + + def stop_strategy(self, strategy_id: int) -> bool: + """ + 停止策略 + + Args: + strategy_id: 策略ID + + Returns: + 是否成功 + """ + try: + with self.lock: + if strategy_id not in self.running_strategies: + logger.warning(f"Strategy {strategy_id} is not running") + return False + + # 标记策略为停止状态 + with get_db_connection() as db: + cursor = db.cursor() + cursor.execute( + "UPDATE qd_strategies_trading SET status = 'stopped' WHERE id = %s", + (strategy_id,) + ) + db.commit() + cursor.close() + + # 从运行列表中移除(线程会在下次循环检查状态时退出) + del self.running_strategies[strategy_id] + + logger.info(f"Strategy {strategy_id} stopped") + self._console_print(f"[strategy:{strategy_id}] stopped (requested)") + return True + + except Exception as e: + logger.error(f"Failed to stop strategy {strategy_id}: {str(e)}") + logger.error(traceback.format_exc()) + return False + + def _run_strategy_loop(self, strategy_id: int): + """ + 策略运行循环 + + Args: + strategy_id: 策略ID + """ + logger.info(f"Strategy {strategy_id} loop starting") + self._console_print(f"[strategy:{strategy_id}] loop initializing") + + try: + # 加载策略配置 + strategy = self._load_strategy(strategy_id) + if not strategy: + logger.error(f"Strategy {strategy_id} not found") + return + + if strategy['strategy_type'] != 'IndicatorStrategy': + logger.error(f"Strategy {strategy_id} has unsupported strategy_type for realtime execution: {strategy['strategy_type']}") + return + + # 初始化策略状态 + trading_config = strategy['trading_config'] + indicator_config = strategy['indicator_config'] + execution_mode = (strategy.get('execution_mode') or 'signal').strip().lower() + if execution_mode not in ['signal', 'live']: + execution_mode = 'signal' + notification_config = strategy.get('notification_config') or {} + symbol = trading_config.get('symbol', '') + timeframe = trading_config.get('timeframe', '1H') + + # 安全获取 leverage 和 trade_direction + try: + leverage_val = trading_config.get('leverage', 1) + if isinstance(leverage_val, (list, tuple)): + leverage_val = leverage_val[0] if leverage_val else 1 + leverage = float(leverage_val) + except: + logger.warning(f"Strategy {strategy_id} invalid leverage format, reset to 1: {trading_config.get('leverage')}") + leverage = 1.0 + + # 获取市场类型,默认为合约 + # 根据杠杆自动判断:杠杆=1为现货,杠杆>1为合约 + market_type = trading_config.get('market_type', 'swap') + if market_type not in ['swap', 'spot']: + logger.error(f"Strategy {strategy_id} invalid market_type={market_type} (only swap/spot supported); refusing to start") + return + + # 根据杠杆自动调整市场类型 + if leverage == 1.0: + market_type = 'spot' # 现货固定1倍杠杆 + logger.info(f"Strategy {strategy_id} leverage=1; auto-switch market_type to spot") + else: + # 合约市场:统一使用 swap(永续),避免 futures/delivery 混淆导致持仓/下单查错市场 + market_type = 'swap' + logger.info(f"Strategy {strategy_id} derivatives trading; normalize market_type to: {market_type}") + + # 根据市场类型限制杠杆 + if market_type == 'spot': + leverage = 1.0 # 现货固定1倍杠杆 + elif leverage < 1: + leverage = 1.0 + elif leverage > 125: + leverage = 125.0 + logger.warning(f"Strategy {strategy_id} leverage > 125; capped to 125") + + # 获取交易方向,现货只能做多 + trade_direction = trading_config.get('trade_direction', 'long') + if market_type == 'spot': + trade_direction = 'long' # 现货只能做多 + logger.info(f"Strategy {strategy_id} spot trading; force trade_direction=long") + + # 初始化交易所连接(信号模式下无需真实连接) + exchange = None + + # 安全获取 initial_capital + try: + initial_capital_val = strategy.get('initial_capital', 1000) + if isinstance(initial_capital_val, (list, tuple)): + initial_capital_val = initial_capital_val[0] if initial_capital_val else 1000 + initial_capital = float(initial_capital_val) + except: + logger.warning(f"Strategy {strategy_id} invalid initial_capital format, reset to 1000: {strategy.get('initial_capital')}") + initial_capital = 1000.0 + + # 净值会在首次更新持仓时自动计算和更新 + + # 获取指标代码 + indicator_id = indicator_config.get('indicator_id') + indicator_code = indicator_config.get('indicator_code', '') + + # 如果代码为空,尝试从数据库获取 + if not indicator_code and indicator_id: + indicator_code = self._get_indicator_code_from_db(indicator_id) + + if not indicator_code: + logger.error(f"Strategy {strategy_id} indicator_code is empty") + return + + # 确保 indicator_code 是字符串(处理 JSON 转义问题) + if not isinstance(indicator_code, str): + indicator_code = str(indicator_code) + + # 处理可能的 JSON 转义问题 + if '\\n' in indicator_code and '\n' not in indicator_code: + try: + import json + decoded = json.loads(f'"{indicator_code}"') + if isinstance(decoded, str): + indicator_code = decoded + logger.info(f"Strategy {strategy_id} decoded escaped indicator_code") + except Exception as e: + logger.warning(f"Strategy {strategy_id} JSON decode failed; falling back to manual unescape: {str(e)}") + indicator_code = ( + indicator_code + .replace('\\n', '\n') + .replace('\\t', '\t') + .replace('\\r', '\r') + .replace('\\"', '"') + .replace("\\'", "'") + .replace('\\\\', '\\') + ) + + # ============================================ + # 初始化阶段:获取历史K线并计算指标 + # ============================================ + # logger.info(f"策略 {strategy_id} 初始化:获取历史K线数据...") + klines = self._fetch_latest_kline(symbol, timeframe, limit=500) + if not klines or len(klines) < 2: + logger.error(f"Strategy {strategy_id} failed to fetch K-lines") + return + + # 转换为DataFrame + df = self._klines_to_dataframe(klines) + if len(df) == 0: + logger.error(f"Strategy {strategy_id} K-lines are empty after normalization") + return + + # ============================================ + # 启动时:完全依赖本地数据库的持仓状态(虚拟持仓) + # ============================================ + # 信号模式下,不再同步交易所持仓 + pass + + # 获取当前持仓最高价(从本地数据库读取) + current_pos_list = self._get_current_positions(strategy_id, symbol) + initial_highest = 0.0 + initial_position = 0 # 0=无持仓, 1=多头, -1=空头 + initial_avg_entry_price = 0.0 + initial_position_count = 0 + initial_last_add_price = 0.0 + + if current_pos_list: + pos = current_pos_list[0] # 取第一个持仓(单向持仓模式) + initial_highest = float(pos.get('highest_price', 0) or 0) + pos_side = pos.get('side', 'long') + initial_position = 1 if pos_side == 'long' else -1 + initial_avg_entry_price = float(pos.get('entry_price', 0) or 0) + initial_position_count = 1 # 简化处理,假设是单笔持仓 + initial_last_add_price = initial_avg_entry_price + + # 关键诊断日志:确认指标是否拿到了持仓状态 + logger.info( + f"策略 {strategy_id} 指标注入持仓状态: count={len(current_pos_list)}, " + f"position={initial_position}, entry_price={initial_avg_entry_price}, highest={initial_highest}" + ) + + # 执行指标代码,获取信号和触发价格 + indicator_result = self._execute_indicator_with_prices( + indicator_code, df, trading_config, + initial_highest_price=initial_highest, + initial_position=initial_position, + initial_avg_entry_price=initial_avg_entry_price, + initial_position_count=initial_position_count, + initial_last_add_price=initial_last_add_price + ) + if indicator_result is None: + logger.error(f"Strategy {strategy_id} indicator execution failed") + return + + # 提取信号和触发价格 + pending_signals = indicator_result.get('pending_signals', []) # 待触发的信号列表 + last_kline_time = indicator_result.get('last_kline_time', 0) # 最后一根K线的时间 + + logger.info(f"Strategy {strategy_id} initialized; pending_signals={len(pending_signals)}") + if pending_signals: + logger.info(f"Initial signals: {pending_signals}") + + # ============================================ + # Main loop: unified tick cadence (default: 10s) + # ============================================ + # One tick = fetch current price once + evaluate triggers once + (if needed) refresh K-lines / recalc indicator. + # Note: `pending_orders` scanning stays at 1s (see PendingOrderWorker) to reduce live dispatch latency. + try: + # Global-only (no per-strategy override) + tick_interval_sec = int(os.getenv('STRATEGY_TICK_INTERVAL_SEC', '10')) + except Exception: + tick_interval_sec = 10 + if tick_interval_sec < 1: + tick_interval_sec = 1 + + last_tick_time = 0.0 + last_kline_update_time = time.time() + + # 计算K线周期(秒) + from app.data_sources.base import TIMEFRAME_SECONDS + timeframe_seconds = TIMEFRAME_SECONDS.get(timeframe, 3600) + kline_update_interval = timeframe_seconds # 每个K线周期更新一次 + + while True: + try: + # 检查策略状态 + if not self._is_strategy_running(strategy_id): + logger.info(f"Strategy {strategy_id} stopped") + break + + current_time = time.time() + + # Sleep until next tick to avoid CPU spin. + if last_tick_time > 0: + sleep_sec = (last_tick_time + tick_interval_sec) - current_time + if sleep_sec > 0: + time.sleep(min(sleep_sec, 1.0)) + continue + last_tick_time = current_time + + # ============================================ + # 0. 虚拟持仓模式,无需同步交易所 + # ============================================ + # pass + + # ============================================ + # 1. Fetch current price once per tick + # ============================================ + current_price = self._fetch_current_price(exchange, symbol, market_type=market_type) + if current_price is None: + logger.warning(f"Strategy {strategy_id} failed to fetch current price") + continue + + # ============================================ + # 2. 检查是否需要更新K线(每个K线周期更新一次,从API拉取) + # ============================================ + if current_time - last_kline_update_time >= kline_update_interval: + klines = self._fetch_latest_kline(symbol, timeframe, limit=500) + if klines and len(klines) >= 2: + df = self._klines_to_dataframe(klines) + if len(df) > 0: + current_pos_list = self._get_current_positions(strategy_id, symbol) + initial_highest = 0.0 + initial_position = 0 + initial_avg_entry_price = 0.0 + initial_position_count = 0 + initial_last_add_price = 0.0 + + if current_pos_list: + pos = current_pos_list[0] + initial_highest = float(pos.get('highest_price', 0) or 0) + pos_side = pos.get('side', 'long') + initial_position = 1 if pos_side == 'long' else -1 + initial_avg_entry_price = float(pos.get('entry_price', 0) or 0) + initial_position_count = 1 + initial_last_add_price = initial_avg_entry_price + + indicator_result = self._execute_indicator_with_prices( + indicator_code, df, trading_config, + initial_highest_price=initial_highest, + initial_position=initial_position, + initial_avg_entry_price=initial_avg_entry_price, + initial_position_count=initial_position_count, + initial_last_add_price=initial_last_add_price + ) + if indicator_result: + pending_signals = indicator_result.get('pending_signals', []) + last_kline_time = indicator_result.get('last_kline_time', 0) + new_hp = indicator_result.get('new_highest_price', 0) + + last_kline_update_time = current_time + + # 更新 highest_price(使用最新 close 作为 current_price 的近似) + if new_hp > 0 and current_pos_list: + current_close = float(df['close'].iloc[-1]) + for p in current_pos_list: + self._update_position( + strategy_id, p['symbol'], p['side'], + float(p['size']), float(p['entry_price']), + current_close, + highest_price=new_hp + ) + else: + # ============================================ + # 3. 非K线更新tick:用当前价更新最后一根K线并重算指标(统一tick节奏) + # ============================================ + if 'df' in locals() and df is not None and len(df) > 0: + try: + realtime_df = df.copy() + realtime_df = self._update_dataframe_with_current_price(realtime_df, current_price, timeframe) + + current_pos_list = self._get_current_positions(strategy_id, symbol) + initial_highest = 0.0 + initial_position = 0 + initial_avg_entry_price = 0.0 + initial_position_count = 0 + initial_last_add_price = 0.0 + + if current_pos_list: + pos = current_pos_list[0] + initial_highest = float(pos.get('highest_price', 0) or 0) + pos_side = pos.get('side', 'long') + initial_position = 1 if pos_side == 'long' else -1 + initial_avg_entry_price = float(pos.get('entry_price', 0) or 0) + initial_position_count = 1 + initial_last_add_price = initial_avg_entry_price + + indicator_result = self._execute_indicator_with_prices( + indicator_code, realtime_df, trading_config, + initial_highest_price=initial_highest, + initial_position=initial_position, + initial_avg_entry_price=initial_avg_entry_price, + initial_position_count=initial_position_count, + initial_last_add_price=initial_last_add_price + ) + if indicator_result: + pending_signals = indicator_result.get('pending_signals', []) + new_hp = indicator_result.get('new_highest_price', 0) + + if new_hp > 0 and current_pos_list: + for p in current_pos_list: + self._update_position( + strategy_id, p['symbol'], p['side'], + float(p['size']), float(p['entry_price']), + current_price, + highest_price=new_hp + ) + except Exception as e: + logger.warning(f"Strategy {strategy_id} realtime indicator recompute failed: {str(e)}") + + # ============================================ + # 4. Evaluate triggers once per tick + # ============================================ + # 优化点4: 信号有效期清理 (Signal Expiration) + current_ts = int(time.time()) + if pending_signals: + expiration_threshold = timeframe_seconds * 2 + valid_signals = [] + for s in pending_signals: + signal_time = s.get('timestamp', 0) + if signal_time == 0 or (current_ts - signal_time) < expiration_threshold: + valid_signals.append(s) + else: + logger.warning(f"Signal expired and removed: {s}") + if len(valid_signals) != len(pending_signals): + pending_signals = valid_signals + + # Unified cadence log: at most once per tick. + if pending_signals: + logger.info(f"[monitoring] strategy={strategy_id} price={current_price}, pending_signals={len(pending_signals)}") + + # 检查是否有待触发的信号 + triggered_signals = [] + signals_to_remove = [] + + for signal_info in pending_signals: + signal_type = signal_info.get('type') # 'open_long', 'close_long', 'open_short', 'close_short' + trigger_price = signal_info.get('trigger_price', 0) + + # 检查价格是否触发 + triggered = False + + # 【关键修复】平仓/止损止盈信号默认“立即触发” + exit_trigger_mode = trading_config.get('exit_trigger_mode', 'immediate') # 'immediate' or 'price' + if signal_type in ['close_long', 'close_short'] and exit_trigger_mode == 'immediate': + triggered = True + + # 【可选】开仓/加仓信号是否“立即触发” + entry_trigger_mode = trading_config.get('entry_trigger_mode', 'price') # 'price' or 'immediate' + if signal_type in ['open_long', 'open_short', 'add_long', 'add_short'] and entry_trigger_mode == 'immediate': + triggered = True + + if trigger_price > 0: + if signal_type in ['open_long', 'close_short', 'add_long']: + if current_price >= trigger_price: + triggered = True + elif signal_type in ['open_short', 'close_long', 'add_short']: + if current_price <= trigger_price: + triggered = True + else: + triggered = True + + if triggered: + triggered_signals.append(signal_info) + signals_to_remove.append(signal_info) + + # ============================================ + # 4.1 Server-side exits (config-driven): SL / TP / trailing + # ============================================ + # Note: stop-loss is only applied when stop_loss_pct > 0. No default fallback. + risk_tp = self._server_side_take_profit_or_trailing_signal( + strategy_id=strategy_id, + symbol=symbol, + current_price=float(current_price), + market_type=market_type, + leverage=float(leverage), + trading_config=trading_config, + timeframe_seconds=int(timeframe_seconds or 60), + ) + if risk_tp: + triggered_signals.append(risk_tp) + + risk_sl = self._server_side_stop_loss_signal( + strategy_id=strategy_id, + symbol=symbol, + current_price=float(current_price), + market_type=market_type, + leverage=float(leverage), + trading_config=trading_config, + timeframe_seconds=int(timeframe_seconds or 60), + ) + if risk_sl: + triggered_signals.append(risk_sl) + + # 从待触发列表中移除已触发的信号 + for signal_info in signals_to_remove: + if signal_info in pending_signals: + pending_signals.remove(signal_info) + + # 执行触发的信号 + if triggered_signals: + logger.info(f"Strategy {strategy_id} triggered signals: {triggered_signals}") + + current_positions = self._get_current_positions(strategy_id, symbol) + state = self._position_state(current_positions) + + # Strict state machine + priority: + # - Only allow signals matching current state (flat/long/short). + # - Always prefer close_* over open_*/add_*. + # - Execute at most ONE signal per tick to avoid duplicated/re-entrant orders. + candidates = [s for s in triggered_signals if self._is_signal_allowed(state, s.get('type'))] + + # If both directions are present while flat, choose by trade_direction (deterministic). + if state == "flat" and candidates: + td = (trade_direction or "both").strip().lower() + if td == "long": + candidates = [s for s in candidates if s.get("type") == "open_long"] + elif td == "short": + candidates = [s for s in candidates if s.get("type") == "open_short"] + + candidates = sorted( + candidates, + key=lambda s: ( + self._signal_priority(s.get("type")), + int(s.get("timestamp") or 0), + str(s.get("type") or ""), + ), + ) + + selected = None + now_i = int(time.time()) + for s in candidates: + stype = s.get("type") + sts = int(s.get("timestamp") or 0) + if self._should_skip_signal_once_per_candle( + strategy_id=strategy_id, + symbol=symbol, + signal_type=str(stype or ""), + signal_ts=sts, + timeframe_seconds=int(timeframe_seconds or 60), + now_ts=now_i, + ): + continue + selected = s + break + + if selected: + signal_type = selected.get('type') + position_size = selected.get('position_size', 0) + trigger_price = selected.get('trigger_price', current_price) + execute_price = trigger_price if trigger_price > 0 else current_price + signal_ts = int(selected.get("timestamp") or 0) + + ok = self._execute_signal( + strategy_id=strategy_id, + exchange=exchange, + symbol=symbol, + current_price=execute_price, + signal_type=signal_type, + position_size=position_size, + signal_ts=signal_ts, + current_positions=current_positions, + trade_direction=trade_direction, + leverage=leverage, + initial_capital=initial_capital, + market_type=market_type, + execution_mode=execution_mode, + notification_config=notification_config, + trading_config=trading_config, + ) + if ok: + logger.info(f"Strategy {strategy_id} signal executed: {signal_type} @ {execute_price}") + else: + logger.warning(f"Strategy {strategy_id} signal rejected/failed: {signal_type}") + + # Update positions once per tick. + self._update_positions(strategy_id, symbol, current_price) + + # Heartbeat for UI observability (once per tick). + self._console_print( + f"[strategy:{strategy_id}] tick price={float(current_price or 0.0):.8f} pending_signals={len(pending_signals or [])}" + ) + + except Exception as e: + logger.error(f"Strategy {strategy_id} loop error: {str(e)}") + logger.error(traceback.format_exc()) + self._console_print(f"[strategy:{strategy_id}] loop error: {e}") + time.sleep(5) + + except Exception as e: + logger.error(f"Strategy {strategy_id} crashed: {str(e)}") + logger.error(traceback.format_exc()) + self._console_print(f"[strategy:{strategy_id}] fatal error: {e}") + finally: + # 清理 + with self.lock: + if strategy_id in self.running_strategies: + del self.running_strategies[strategy_id] + self._console_print(f"[strategy:{strategy_id}] loop exited") + logger.info(f"Strategy {strategy_id} loop exited") + + def _sync_positions_with_exchange(self, strategy_id: int, exchange: Any, symbol: str, market_type: str): + """ + [Depracated] 信号模式下无需同步交易所持仓 + """ + pass + + def _load_strategy(self, strategy_id: int) -> Optional[Dict[str, Any]]: + """Load strategy config (local deployment: no encryption/decryption).""" + try: + with get_db_connection() as db: + cursor = db.cursor() + query = """ + SELECT + id, strategy_name, strategy_type, status, + initial_capital, leverage, decide_interval, + execution_mode, notification_config, + indicator_config, exchange_config, trading_config + FROM qd_strategies_trading + WHERE id = %s + """ + cursor.execute(query, (strategy_id,)) + strategy = cursor.fetchone() + cursor.close() + + if strategy: + # 解析JSON字段 + for field in ['indicator_config', 'trading_config', 'notification_config']: + if isinstance(strategy.get(field), str): + try: + strategy[field] = json.loads(strategy[field]) + except: + strategy[field] = {} + + # exchange_config: local deployment stores plaintext JSON + exchange_config_str = strategy.get('exchange_config', '{}') + if isinstance(exchange_config_str, str) and exchange_config_str: + try: + strategy['exchange_config'] = json.loads(exchange_config_str) + except Exception as e: + logger.error(f"Strategy {strategy_id} failed to parse exchange_config: {str(e)}") + # 尝试直接解析 JSON(向后兼容) + try: + strategy['exchange_config'] = json.loads(exchange_config_str) + except: + strategy['exchange_config'] = {} + else: + strategy['exchange_config'] = {} + + return strategy + + except Exception as e: + logger.error(f"Failed to load strategy config: {str(e)}") + return None + + def _is_strategy_running(self, strategy_id: int) -> bool: + """检查策略是否在运行""" + try: + with get_db_connection() as db: + cursor = db.cursor() + cursor.execute( + "SELECT status FROM qd_strategies_trading WHERE id = %s", + (strategy_id,) + ) + result = cursor.fetchone() + cursor.close() + return result and result.get('status') == 'running' + except: + return False + + def _init_exchange( + self, + exchange_config: Dict[str, Any], + market_type: str = None, + leverage: float = None, + strategy_id: int = None + ) -> Optional[ccxt.Exchange]: + """(Mock) 信号模式不需要真实交易所连接""" + return None + + def _fetch_latest_kline(self, symbol: str, timeframe: str, limit: int = 500) -> List[Dict[str, Any]]: + """获取最新K线数据(优先从缓存获取)""" + try: + # 使用 KlineService 获取K线数据(自动处理缓存) + return self.kline_service.get_kline( + market='Crypto', + symbol=symbol, + timeframe=timeframe, + limit=limit + ) + except Exception as e: + logger.error(f"Failed to fetch K-lines: {str(e)}") + return [] + + def _fetch_current_price(self, exchange: Any, symbol: str, market_type: str = None) -> Optional[float]: + """获取当前价格 (改用 DataSource)""" + # Local in-memory cache first + cache_key = (symbol or "").strip().upper() + if cache_key and self._price_cache_ttl_sec > 0: + now = time.time() + try: + with self._price_cache_lock: + item = self._price_cache.get(cache_key) + if item: + price, expiry = item + if expiry > now: + return float(price) + # expired + del self._price_cache[cache_key] + except Exception: + pass + + try: + # 默认使用 binance 获取价格 (或者根据配置) + # 简单起见,这里硬编码或使用 generic source + ds = DataSourceFactory.get_data_source('binance') + # normalized symbol handling is tricky without exchange object. + # But usually DataSource expects standard 'BTC/USDT' + ticker = ds.get_ticker(symbol) + if ticker: + price = float(ticker.get('last') or ticker.get('close') or 0) + if price > 0: + if cache_key and self._price_cache_ttl_sec > 0: + try: + with self._price_cache_lock: + self._price_cache[cache_key] = (float(price), time.time() + self._price_cache_ttl_sec) + except Exception: + pass + return price + except Exception as e: + logger.warning(f"Failed to fetch price: {e}") + + return None + + def _server_side_stop_loss_signal( + self, + strategy_id: int, + symbol: str, + current_price: float, + market_type: str, + leverage: float, + trading_config: Dict[str, Any], + timeframe_seconds: int, + ) -> Optional[Dict[str, Any]]: + """ + 服务端兜底止损:当价格穿透止损线时,直接生成 close_long/close_short 信号。 + + 目的:防止“指标回放逻辑导致最后一根K线没有 close_* 信号”或“插针反弹导致二次触发条件不满足”时不止损。 + """ + try: + if trading_config is None: + return None + + enabled = trading_config.get('enable_server_side_stop_loss', True) + if str(enabled).lower() in ['0', 'false', 'no', 'off']: + return None + + # 获取当前持仓(使用本地数据库记录作为风控依据) + current_positions = self._get_current_positions(strategy_id, symbol) + if not current_positions: + return None + + pos = current_positions[0] + side = pos.get('side') + if side not in ['long', 'short']: + return None + + entry_price = float(pos.get('entry_price', 0) or 0) + if entry_price <= 0 or current_price <= 0: + return None + + # Stop-loss is config-driven: if stop_loss_pct is not set or <= 0, do NOT stop-loss. + sl_cfg = trading_config.get('stop_loss_pct', 0) + sl = 0.0 + try: + sl_cfg = float(sl_cfg or 0) + if sl_cfg > 1: + sl = sl_cfg / 100.0 + else: + sl = sl_cfg + except Exception: + sl = 0.0 + + if sl <= 0: + return None + + # Align with backtest semantics: risk percentages are defined on margin PnL, + # so we convert to price move threshold by dividing by leverage. + lev = max(1.0, float(leverage or 1.0)) + sl = sl / lev + + # Use candle start timestamp to deduplicate exit attempts within a candle. + now_ts = int(time.time()) + tf = int(timeframe_seconds or 60) + candle_ts = int(now_ts // tf) * tf + + # 多头:跌破止损线 + if side == 'long': + stop_line = entry_price * (1 - sl) + if current_price <= stop_line: + return { + 'type': 'close_long', + 'trigger_price': 0, # 立即触发(由 exit_trigger_mode 控制) + 'position_size': 0, + 'timestamp': candle_ts, + 'reason': 'server_stop_loss', + 'stop_loss_price': stop_line, + } + # 空头:突破止损线 + elif side == 'short': + stop_line = entry_price * (1 + sl) + if current_price >= stop_line: + return { + 'type': 'close_short', + 'trigger_price': 0, + 'position_size': 0, + 'timestamp': candle_ts, + 'reason': 'server_stop_loss', + 'stop_loss_price': stop_line, + } + + return None + except Exception as e: + logger.warning(f"Strategy {strategy_id} server-side stop-loss check failed: {str(e)}") + return None + + def _server_side_take_profit_or_trailing_signal( + self, + strategy_id: int, + symbol: str, + current_price: float, + market_type: str, + leverage: float, + trading_config: Dict[str, Any], + timeframe_seconds: int, + ) -> Optional[Dict[str, Any]]: + """ + Server-side exits driven by trading_config (no indicator script required): + - Fixed take-profit: take_profit_pct + - Trailing stop: trailing_enabled + trailing_stop_pct + trailing_activation_pct + + Semantics align with BacktestService: + - Percentages are defined on margin PnL; effective price threshold = pct / leverage. + - When trailing is enabled, fixed take-profit is disabled to avoid ambiguity. + """ + try: + if not trading_config: + return None + + current_positions = self._get_current_positions(strategy_id, symbol) + if not current_positions: + return None + + pos = current_positions[0] + side = (pos.get('side') or '').strip().lower() + if side not in ['long', 'short']: + return None + + entry_price = float(pos.get('entry_price', 0) or 0) + if entry_price <= 0 or current_price <= 0: + return None + + lev = max(1.0, float(leverage or 1.0)) + + tp = self._to_ratio(trading_config.get('take_profit_pct')) + trailing_enabled = bool(trading_config.get('trailing_enabled')) + trailing_pct = self._to_ratio(trading_config.get('trailing_stop_pct')) + trailing_act = self._to_ratio(trading_config.get('trailing_activation_pct')) + + tp_eff = (tp / lev) if tp > 0 else 0.0 + trailing_pct_eff = (trailing_pct / lev) if trailing_pct > 0 else 0.0 + trailing_act_eff = (trailing_act / lev) if trailing_act > 0 else 0.0 + + # Conflict rule: when trailing is enabled, fixed TP is disabled. + if trailing_enabled and trailing_pct_eff > 0: + tp_eff = 0.0 + # If activationPct is missing, reuse take_profit_pct as activation threshold. + if trailing_act_eff <= 0 and tp > 0: + trailing_act_eff = tp / lev + + now_ts = int(time.time()) + tf = int(timeframe_seconds or 60) + candle_ts = int(now_ts // tf) * tf + + # Highest/lowest tracking (persisted in DB so restart continues trailing correctly) + try: + hp = float(pos.get('highest_price') or 0.0) + except Exception: + hp = 0.0 + try: + lp = float(pos.get('lowest_price') or 0.0) + except Exception: + lp = 0.0 + + if hp <= 0: + hp = entry_price + hp = max(hp, float(current_price)) + + if lp <= 0: + lp = entry_price + lp = min(lp, float(current_price)) + + # Persist best-effort + try: + self._update_position( + strategy_id=strategy_id, + symbol=pos.get('symbol') or symbol, + side=side, + size=float(pos.get('size') or 0.0), + entry_price=entry_price, + current_price=float(current_price), + highest_price=hp, + lowest_price=lp, + ) + except Exception: + pass + + # 1) Trailing stop + if trailing_enabled and trailing_pct_eff > 0: + if side == 'long': + active = True + if trailing_act_eff > 0: + active = hp >= entry_price * (1 + trailing_act_eff) + if active: + stop_line = hp * (1 - trailing_pct_eff) + if current_price <= stop_line: + return { + 'type': 'close_long', + 'trigger_price': 0, + 'position_size': 0, + 'timestamp': candle_ts, + 'reason': 'server_trailing_stop', + 'trailing_stop_price': stop_line, + 'highest_price': hp, + } + else: + active = True + if trailing_act_eff > 0: + active = lp <= entry_price * (1 - trailing_act_eff) + if active: + stop_line = lp * (1 + trailing_pct_eff) + if current_price >= stop_line: + return { + 'type': 'close_short', + 'trigger_price': 0, + 'position_size': 0, + 'timestamp': candle_ts, + 'reason': 'server_trailing_stop', + 'trailing_stop_price': stop_line, + 'lowest_price': lp, + } + + # 2) Fixed take-profit (only when trailing is disabled) + if tp_eff > 0: + if side == 'long': + tp_line = entry_price * (1 + tp_eff) + if current_price >= tp_line: + return { + 'type': 'close_long', + 'trigger_price': 0, + 'position_size': 0, + 'timestamp': candle_ts, + 'reason': 'server_take_profit', + 'take_profit_price': tp_line, + } + else: + tp_line = entry_price * (1 - tp_eff) + if current_price <= tp_line: + return { + 'type': 'close_short', + 'trigger_price': 0, + 'position_size': 0, + 'timestamp': candle_ts, + 'reason': 'server_take_profit', + 'take_profit_price': tp_line, + } + + return None + except Exception: + return None + + def _klines_to_dataframe(self, klines: List[Dict[str, Any]]) -> pd.DataFrame: + """将K线数据转换为DataFrame""" + if not klines: + # 返回空的 DataFrame,包含正确的列 + return pd.DataFrame(columns=['open', 'high', 'low', 'close', 'volume']) + + # 创建 DataFrame + df = pd.DataFrame(klines) + + # Convert time column. + # IMPORTANT: use UTC tz-aware index to avoid timezone skew when computing candle boundaries. + if 'time' in df.columns: + df['time'] = pd.to_datetime(df['time'], unit='s', utc=True) + df = df.set_index('time') + elif 'timestamp' in df.columns: + df['timestamp'] = pd.to_datetime(df['timestamp'], unit='s', utc=True) + df = df.set_index('timestamp') + + # 确保只包含需要的列 + required_columns = ['open', 'high', 'low', 'close', 'volume'] + available_columns = [col for col in required_columns if col in df.columns] + if not available_columns: + logger.warning("K-lines are missing required columns") + return pd.DataFrame(columns=required_columns) + + df = df[available_columns] + + # 强制转换所有数值列为 float64 类型 + for col in ['open', 'high', 'low', 'close', 'volume']: + if col in df.columns: + # 先转换为数值类型,然后强制转换为 float64 + df[col] = pd.to_numeric(df[col], errors='coerce').astype('float64') + + # 删除包含 NaN 的行 + df = df.dropna() + + return df + + def _update_dataframe_with_current_price(self, df: pd.DataFrame, current_price: float, timeframe: str) -> pd.DataFrame: + """ + 使用当前价格更新DataFrame的最后一根K线(用于实时计算) + """ + if df is None or len(df) == 0: + return df + + try: + # 获取最后一根K线的时间 + last_time = df.index[-1] + + # 计算当前时间对应的K线起始时间 + from app.data_sources.base import TIMEFRAME_SECONDS + timeframe_key = timeframe + if timeframe_key not in TIMEFRAME_SECONDS: + timeframe_key = str(timeframe_key).upper() + if timeframe_key not in TIMEFRAME_SECONDS: + timeframe_key = str(timeframe_key).lower() + tf_seconds = TIMEFRAME_SECONDS.get(timeframe_key, 60) + + # Use epoch seconds directly to avoid naive datetime timezone conversion issues. + last_ts = float(last_time.timestamp()) + now_ts = float(time.time()) + + # 计算当前价格所属的 K 线开始时间 + current_period_start = int(now_ts // tf_seconds) * tf_seconds + + # 检查最后一根K线是否就是当前周期的 + if abs(last_ts - current_period_start) < 2: + # 更新最后一根 + df.iloc[-1, df.columns.get_loc('close')] = current_price + df.iloc[-1, df.columns.get_loc('high')] = max(df.iloc[-1]['high'], current_price) + df.iloc[-1, df.columns.get_loc('low')] = min(df.iloc[-1]['low'], current_price) + elif current_period_start > last_ts: + # 追加新行 + new_row = pd.DataFrame({ + 'open': [current_price], + 'high': [current_price], + 'low': [current_price], + 'close': [current_price], + 'volume': [0.0] + }, index=[pd.to_datetime(current_period_start, unit='s', utc=True)]) + + df = pd.concat([df, new_row]) + + return df + + except Exception as e: + logger.error(f"Failed to update realtime candle: {str(e)}") + return df + + def _execute_indicator_with_prices( + self, indicator_code: str, df: pd.DataFrame, trading_config: Dict[str, Any], + initial_highest_price: float = 0.0, + initial_position: int = 0, + initial_avg_entry_price: float = 0.0, + initial_position_count: int = 0, + initial_last_add_price: float = 0.0 + ) -> Optional[Dict[str, Any]]: + """ + 执行指标代码并提取待触发的信号和价格 + """ + try: + # 执行指标代码 + executed_df, exec_env = self._execute_indicator_df( + indicator_code, df, trading_config, + initial_highest_price=initial_highest_price, + initial_position=initial_position, + initial_avg_entry_price=initial_avg_entry_price, + initial_position_count=initial_position_count, + initial_last_add_price=initial_last_add_price + ) + if executed_df is None: + return None + + # 提取最新的 highest_price + new_highest_price = exec_env.get('highest_price', 0.0) + + # 提取最后一根K线的时间 + last_kline_time = int(df.index[-1].timestamp()) if hasattr(df.index[-1], 'timestamp') else int(time.time()) + + # 提取待触发的信号 + pending_signals = [] + + # Supported indicator signal formats: + # - Preferred (simple): df['buy'], df['sell'] as boolean + # - Internal (4-way): df['open_long'], df['close_long'], df['open_short'], df['close_short'] as boolean + if all(col in executed_df.columns for col in ['buy', 'sell']) and not all(col in executed_df.columns for col in ['open_long', 'close_long', 'open_short', 'close_short']): + # Normalize buy/sell into 4-way columns for execution. + td = trading_config.get('trade_direction', trading_config.get('tradeDirection', 'both')) + td = str(td or 'both').lower() + if td not in ['long', 'short', 'both']: + td = 'both' + + buy = executed_df['buy'].fillna(False).astype(bool) + sell = executed_df['sell'].fillna(False).astype(bool) + + executed_df = executed_df.copy() + if td == 'long': + executed_df['open_long'] = buy + executed_df['close_long'] = sell + executed_df['open_short'] = False + executed_df['close_short'] = False + elif td == 'short': + executed_df['open_long'] = False + executed_df['close_long'] = False + executed_df['open_short'] = sell + executed_df['close_short'] = buy + else: + executed_df['open_long'] = buy + executed_df['close_short'] = buy + executed_df['open_short'] = sell + executed_df['close_long'] = sell + + # Check for 4-way columns after normalization + if all(col in executed_df.columns for col in ['open_long', 'close_long', 'open_short', 'close_short']): + # 优化点3: 防“信号闪烁” (Repainting) + signal_mode = trading_config.get('signal_mode', 'confirmed') # 'confirmed' or 'aggressive' + exit_signal_mode = trading_config.get('exit_signal_mode', 'aggressive') # 'confirmed' or 'aggressive' + + entry_check_set = set() + exit_check_set = set() + + if len(executed_df) > 1: + # 始终检查上一根已完成K线 + entry_check_set.add(len(executed_df) - 2) + exit_check_set.add(len(executed_df) - 2) + + if signal_mode == 'aggressive' and len(executed_df) > 0: + entry_check_set.add(len(executed_df) - 1) + + if exit_signal_mode == 'aggressive' and len(executed_df) > 0: + exit_check_set.add(len(executed_df) - 1) + + # 统一遍历索引(保持确定性排序) + check_indices = sorted(entry_check_set.union(exit_check_set), reverse=True) + + for idx in check_indices: + # 获取该K线的收盘价(作为默认触发价) + close_price = float(executed_df['close'].iloc[idx]) + # 该信号的时间戳 + signal_timestamp = int(executed_df.index[idx].timestamp()) if hasattr(executed_df.index[idx], 'timestamp') else last_kline_time + + # 开多信号(仅在 entry_check_set 中检查) + if idx in entry_check_set and executed_df['open_long'].iloc[idx]: + trigger_price = close_price + position_size = 0.08 + if 'position_size' in executed_df.columns: + pos_size = executed_df['position_size'].iloc[idx] + if pos_size > 0: + position_size = float(pos_size) + + if not any(s['type'] == 'open_long' and s.get('timestamp') == signal_timestamp for s in pending_signals): + pending_signals.append({ + 'type': 'open_long', + 'trigger_price': trigger_price, + 'position_size': position_size, + 'timestamp': signal_timestamp + }) + + # 平多信号 + if idx in exit_check_set and executed_df['close_long'].iloc[idx]: + trigger_price = close_price + if not any(s['type'] == 'close_long' and s.get('timestamp') == signal_timestamp for s in pending_signals): + pending_signals.append({ + 'type': 'close_long', + 'trigger_price': trigger_price, + 'position_size': 0, + 'timestamp': signal_timestamp + }) + + # 开空信号 + if idx in entry_check_set and executed_df['open_short'].iloc[idx]: + trigger_price = close_price + position_size = 0.08 + if 'position_size' in executed_df.columns: + pos_size = executed_df['position_size'].iloc[idx] + if pos_size > 0: + position_size = float(pos_size) + + if not any(s['type'] == 'open_short' and s.get('timestamp') == signal_timestamp for s in pending_signals): + pending_signals.append({ + 'type': 'open_short', + 'trigger_price': trigger_price, + 'position_size': position_size, + 'timestamp': signal_timestamp + }) + + # 平空信号 + if idx in exit_check_set and executed_df['close_short'].iloc[idx]: + trigger_price = close_price + if not any(s['type'] == 'close_short' and s.get('timestamp') == signal_timestamp for s in pending_signals): + pending_signals.append({ + 'type': 'close_short', + 'trigger_price': trigger_price, + 'position_size': 0, + 'timestamp': signal_timestamp + }) + + # 加多信号 + if idx in entry_check_set and 'add_long' in executed_df.columns and executed_df['add_long'].iloc[idx]: + trigger_price = close_price + position_size = 0.06 + if 'position_size' in executed_df.columns: + pos_size = executed_df['position_size'].iloc[idx] + if pos_size > 0: + position_size = float(pos_size) + + if not any(s['type'] == 'add_long' and s.get('timestamp') == signal_timestamp for s in pending_signals): + pending_signals.append({ + 'type': 'add_long', + 'trigger_price': trigger_price, + 'position_size': position_size, + 'timestamp': signal_timestamp + }) + + # 加空信号 + if idx in entry_check_set and 'add_short' in executed_df.columns and executed_df['add_short'].iloc[idx]: + trigger_price = close_price + position_size = 0.06 + if 'position_size' in executed_df.columns: + pos_size = executed_df['position_size'].iloc[idx] + if pos_size > 0: + position_size = float(pos_size) + + if not any(s['type'] == 'add_short' and s.get('timestamp') == signal_timestamp for s in pending_signals): + pending_signals.append({ + 'type': 'add_short', + 'trigger_price': trigger_price, + 'position_size': position_size, + 'timestamp': signal_timestamp + }) + + # Reduce / scale-out signals (optional) + # These are used by position management rules (trend/adverse reduce) and should be treated as exits. + if idx in exit_check_set and 'reduce_long' in executed_df.columns and executed_df['reduce_long'].iloc[idx]: + trigger_price = close_price + reduce_pct = 0.1 + if 'reduce_size' in executed_df.columns: + try: + reduce_pct = float(executed_df['reduce_size'].iloc[idx] or 0) + except Exception: + reduce_pct = 0.1 + elif 'position_size' in executed_df.columns: + try: + reduce_pct = float(executed_df['position_size'].iloc[idx] or 0) + except Exception: + reduce_pct = 0.1 + if reduce_pct <= 0: + reduce_pct = 0.1 + if not any(s['type'] == 'reduce_long' and s.get('timestamp') == signal_timestamp for s in pending_signals): + pending_signals.append({ + 'type': 'reduce_long', + 'trigger_price': trigger_price, + 'position_size': reduce_pct, + 'timestamp': signal_timestamp + }) + + if idx in exit_check_set and 'reduce_short' in executed_df.columns and executed_df['reduce_short'].iloc[idx]: + trigger_price = close_price + reduce_pct = 0.1 + if 'reduce_size' in executed_df.columns: + try: + reduce_pct = float(executed_df['reduce_size'].iloc[idx] or 0) + except Exception: + reduce_pct = 0.1 + elif 'position_size' in executed_df.columns: + try: + reduce_pct = float(executed_df['position_size'].iloc[idx] or 0) + except Exception: + reduce_pct = 0.1 + if reduce_pct <= 0: + reduce_pct = 0.1 + if not any(s['type'] == 'reduce_short' and s.get('timestamp') == signal_timestamp for s in pending_signals): + pending_signals.append({ + 'type': 'reduce_short', + 'trigger_price': trigger_price, + 'position_size': reduce_pct, + 'timestamp': signal_timestamp + }) + + return { + 'pending_signals': pending_signals, + 'last_kline_time': last_kline_time, + 'new_highest_price': new_highest_price + } + + except Exception as e: + logger.error(f"Failed to execute indicator and extract prices: {str(e)}") + logger.error(traceback.format_exc()) + return None + + def _execute_indicator_df( + self, indicator_code: str, df: pd.DataFrame, trading_config: Dict[str, Any], + initial_highest_price: float = 0.0, + initial_position: int = 0, + initial_avg_entry_price: float = 0.0, + initial_position_count: int = 0, + initial_last_add_price: float = 0.0 + ) -> tuple[Optional[pd.DataFrame], dict]: + """执行指标代码,返回执行后的DataFrame和执行环境""" + try: + # 确保 DataFrame 的所有数值列都是 float64 类型 + df = df.copy() + for col in ['open', 'high', 'low', 'close', 'volume']: + if col in df.columns: + if not pd.api.types.is_numeric_dtype(df[col]): + df[col] = pd.to_numeric(df[col], errors='coerce').astype('float64') + else: + df[col] = df[col].astype('float64') + + # 删除包含 NaN 的行 + df = df.dropna() + + if len(df) == 0: + logger.warning("DataFrame is empty; cannot execute indicator script") + return None, {} + + # 初始化信号Series + signals = pd.Series(0, index=df.index, dtype='float64') + + # 准备执行环境 + # Expose the full trading config to indicator scripts so frontend parameters + # (scale-in/out, position sizing, risk params) can be used directly. + # Also provide a backtest-modal compatible nested config object: cfg.risk/cfg.scale/cfg.position. + tc = dict(trading_config or {}) + cfg = self._build_cfg_from_trading_config(tc) + local_vars = { + 'df': df, + 'open': df['open'].astype('float64'), + 'high': df['high'].astype('float64'), + 'low': df['low'].astype('float64'), + 'close': df['close'].astype('float64'), + 'volume': df['volume'].astype('float64'), + 'signals': signals, + 'np': np, + 'pd': pd, + 'trading_config': tc, + 'config': tc, # alias + 'cfg': cfg, # normalized nested config + 'leverage': float(trading_config.get('leverage', 1)), + 'initial_capital': float(trading_config.get('initial_capital', 1000)), + 'commission': 0.001, + 'trade_direction': str(trading_config.get('trade_direction', 'long')), + 'initial_highest_price': float(initial_highest_price), + 'initial_position': int(initial_position), + 'initial_avg_entry_price': float(initial_avg_entry_price), + 'initial_position_count': int(initial_position_count), + 'initial_last_add_price': float(initial_last_add_price) + } + + import builtins + def safe_import(name, *args, **kwargs): + allowed_modules = ['numpy', 'pandas', 'math', 'json', 'time'] + if name in allowed_modules or name.split('.')[0] in allowed_modules: + return builtins.__import__(name, *args, **kwargs) + raise ImportError(f"不允许导入模块: {name}") + + safe_builtins = {k: getattr(builtins, k) for k in dir(builtins) + if not k.startswith('_') and k not in [ + 'eval', 'exec', 'compile', 'open', 'input', + 'help', 'exit', 'quit', '__import__', + 'copyright', 'credits', 'license' + ]} + safe_builtins['__import__'] = safe_import + + exec_env = local_vars.copy() + exec_env['__builtins__'] = safe_builtins + + pre_import_code = "import numpy as np\nimport pandas as pd\n" + exec(pre_import_code, exec_env) + + # 这里的 safe_exec_code 假设已存在 + exec(indicator_code, exec_env) + + executed_df = exec_env.get('df', df) + + # Validation: if chart signals are provided, df['buy']/df['sell'] must exist for execution normalization. + output_obj = exec_env.get('output') + has_output_signals = isinstance(output_obj, dict) and isinstance(output_obj.get('signals'), list) and len(output_obj.get('signals')) > 0 + if has_output_signals and not all(col in executed_df.columns for col in ['buy', 'sell']): + raise ValueError( + "Invalid indicator script: output['signals'] is provided, but df['buy'] and df['sell'] are missing. " + "Please set df['buy'] and df['sell'] as boolean columns (len == len(df))." + ) + + return executed_df, exec_env + + except Exception as e: + logger.error(f"Failed to execute indicator script: {str(e)}") + logger.error(traceback.format_exc()) + return None, {} + + def _execute_indicator(self, indicator_code: str, df: pd.DataFrame, trading_config: Dict[str, Any]) -> Optional[Any]: + """兼容旧版本""" + executed_df, _ = self._execute_indicator_df(indicator_code, df, trading_config) + if executed_df is None: + return None + return 0 + + def _get_current_positions(self, strategy_id: int, symbol: str) -> List[Dict[str, Any]]: + """获取当前持仓(支持symbol规范化匹配)""" + try: + with get_db_connection() as db: + cursor = db.cursor() + query = """ + SELECT id, symbol, side, size, entry_price, highest_price, lowest_price + FROM qd_strategy_positions + WHERE strategy_id = %s + """ + cursor.execute(query, (strategy_id,)) + all_positions = cursor.fetchall() + + matched_positions = [] + for pos in all_positions: + # 简化匹配逻辑:只匹配前缀 + if pos['symbol'].split(':')[0] == symbol.split(':')[0]: + matched_positions.append(pos) + + cursor.close() + return matched_positions + except Exception as e: + logger.error(f"Failed to fetch positions: {str(e)}") + return [] + + def _execute_trading_logic(self, *args, **kwargs): + """已废弃""" + pass + + def _execute_signal( + self, + strategy_id: int, + exchange: Any, + symbol: str, + current_price: float, + signal_type: str, + position_size: float, + current_positions: List[Dict[str, Any]], + trade_direction: str, + leverage: int, + initial_capital: float, + market_type: str = 'swap', + margin_mode: str = 'cross', + stop_loss_price: float = None, + take_profit_price: float = None, + execution_mode: str = 'signal', + notification_config: Optional[Dict[str, Any]] = None, + trading_config: Optional[Dict[str, Any]] = None, + signal_ts: int = 0, + ): + """执行具体的交易信号""" + try: + # Hard state-machine guard (double safety in addition to loop-level filtering). + state = self._position_state(current_positions) + if not self._is_signal_allowed(state, signal_type): + return False + + # 1. 检查交易方向限制 + if market_type == 'spot' and 'short' in signal_type: + return False + + # 2. 计算下单数量 + available_capital = self._get_available_capital(strategy_id, initial_capital) + + amount = 0.0 + sig = (signal_type or "").strip().lower() + + # Frontend position sizing alignment: + # - open_* uses entry_pct from trading_config if provided (0~1 or 0~100 are both accepted) + if sig in ("open_long", "open_short") and isinstance(trading_config, dict): + ep = trading_config.get("entry_pct") + if ep is not None: + position_size = self._to_ratio(ep, default=position_size if position_size is not None else 0.0) + + # Open / add sizing: position_size is treated as capital ratio in [0,1]. + if ('open' in sig or 'add' in sig): + if position_size is None or float(position_size) <= 0: + position_size = 0.05 + position_ratio = self._to_ratio(position_size, default=0.05) + if market_type == 'spot': + amount = available_capital * position_ratio / current_price + else: + amount = (initial_capital * position_ratio * leverage) / current_price + + # Reduce sizing: position_size is treated as a reduce ratio (close X% of current position). + if sig in ("reduce_long", "reduce_short"): + pos_side = "long" if "long" in sig else "short" + pos = next((p for p in current_positions if (p.get('side') or '').strip().lower() == pos_side), None) + if not pos: + return False + cur_size = float(pos.get("size") or 0.0) + if cur_size <= 0: + return False + reduce_ratio = self._to_ratio(position_size, default=0.1) + reduce_amount = cur_size * reduce_ratio + # If reduce is effectively full, treat as close_*. + if reduce_amount >= cur_size * 0.999: + sig = "close_long" if pos_side == "long" else "close_short" + signal_type = sig + amount = cur_size + else: + amount = reduce_amount + + # 3. 检查反向持仓(单向持仓逻辑) + # ... (简化处理,假设无反向或由用户处理) ... + + # 4. Execute order enqueue (PendingOrderWorker will dispatch notifications in signal mode) + if 'close' in sig: + # 平仓逻辑:找到对应持仓大小 + pos = next((p for p in current_positions if p.get('side') and p['side'] in signal_type), None) + if not pos: + return False + amount = float(pos['size'] or 0.0) + if amount <= 0: + return False + + if amount <= 0 and ('open' in signal_type or 'add' in signal_type): + return False + + order_result = self._execute_exchange_order( + exchange=exchange, + strategy_id=strategy_id, + symbol=symbol, + signal_type=signal_type, + amount=amount, + ref_price=float(current_price or 0.0), + market_type=market_type, + leverage=leverage, + execution_mode=execution_mode, + notification_config=notification_config, + signal_ts=int(signal_ts or 0), + ) + + if order_result and order_result.get('success'): + # For live execution, the order is only enqueued here. + # The actual fill/trade/position updates are performed by PendingOrderWorker. + if str(execution_mode or "").strip().lower() == "live": + return True + + # 更新数据库状态 (signal mode / local simulation) + if 'open' in sig or 'add' in sig: + self._record_trade( + strategy_id=strategy_id, symbol=symbol, type=signal_type, + price=current_price, amount=amount, value=amount*current_price + ) + side = 'short' if 'short' in signal_type else 'long' + + # 查找现有持仓以计算均价 + old_pos = next((p for p in current_positions if p['side'] == side), None) + new_size = amount + new_entry = current_price + if old_pos: + old_size = float(old_pos['size']) + old_entry = float(old_pos['entry_price']) + new_size += old_size + new_entry = ((old_size * old_entry) + (amount * current_price)) / new_size + + self._update_position( + strategy_id=strategy_id, symbol=symbol, side=side, + size=new_size, entry_price=new_entry, current_price=current_price + ) + elif sig.startswith("reduce_"): + # Partial scale-out: reduce position size, keep entry price unchanged. + self._record_trade( + strategy_id=strategy_id, symbol=symbol, type=signal_type, + price=current_price, amount=amount, value=amount*current_price + ) + side = 'short' if 'short' in signal_type else 'long' + old_pos = next((p for p in current_positions if p.get('side') == side), None) + if not old_pos: + return True + old_size = float(old_pos.get('size') or 0.0) + old_entry = float(old_pos.get('entry_price') or 0.0) + new_size = max(0.0, old_size - float(amount or 0.0)) + if new_size <= old_size * 0.001: + self._close_position(strategy_id, symbol, side) + else: + self._update_position( + strategy_id=strategy_id, symbol=symbol, side=side, + size=new_size, entry_price=old_entry, current_price=current_price + ) + elif 'close' in sig: + self._record_trade( + strategy_id=strategy_id, symbol=symbol, type=signal_type, + price=current_price, amount=amount, value=amount*current_price + ) + side = 'short' if 'short' in signal_type else 'long' + self._close_position(strategy_id, symbol, side) + + return True + + return False + + except Exception as e: + logger.error(f"Failed to execute signal: {e}") + return False + + def _execute_exchange_order( + self, + exchange: Any, + strategy_id: int, + symbol: str, + signal_type: str, + amount: float, + ref_price: Optional[float] = None, + market_type: str = 'swap', + leverage: float = 1.0, + margin_mode: str = 'cross', + stop_loss_price: float = None, + take_profit_price: float = None, + order_mode: str = 'maker', + maker_wait_sec: float = 8.0, + maker_retries: int = 3, + close_fallback_to_market: bool = True, + open_fallback_to_market: bool = True, + execution_mode: str = 'signal', + notification_config: Optional[Dict[str, Any]] = None, + signal_ts: int = 0, + ) -> Optional[Dict[str, Any]]: + """ + Convert a signal into a concrete pending order and enqueue it into DB. + + A separate worker will poll `pending_orders` and dispatch: + - execution_mode='signal': dispatch notifications (no real trading). + - execution_mode='live': reserved for future live trading execution (not implemented). + """ + try: + # Reference price at enqueue time: use current tick price if provided to avoid extra fetch. + if ref_price is None: + ref_price = self._fetch_current_price(None, symbol) or 0.0 + ref_price = float(ref_price or 0.0) + + extra_payload = { + "ref_price": float(ref_price or 0.0), + "signal_ts": int(signal_ts or 0), + "stop_loss_price": float(stop_loss_price or 0.0) if stop_loss_price is not None else 0.0, + "take_profit_price": float(take_profit_price or 0.0) if take_profit_price is not None else 0.0, + "margin_mode": str(margin_mode or "cross"), + "order_mode": str(order_mode or "maker"), + "maker_wait_sec": float(maker_wait_sec or 0.0), + "maker_retries": int(maker_retries or 0), + "close_fallback_to_market": bool(close_fallback_to_market), + "open_fallback_to_market": bool(open_fallback_to_market), + } + pending_id = self._enqueue_pending_order( + strategy_id=strategy_id, + symbol=symbol, + signal_type=signal_type, + amount=float(amount or 0.0), + price=float(ref_price or 0.0), + signal_ts=int(signal_ts or 0), + market_type=market_type, + leverage=float(leverage or 1.0), + execution_mode=execution_mode, + notification_config=notification_config, + extra_payload=extra_payload, + ) + + pending_flag = str(execution_mode or "").strip().lower() == "live" + + # Local "signal provider mode": we keep the local state machine moving forward. + return { + 'success': True, + 'pending': bool(pending_flag), + 'order_id': f"pending_{pending_id or int(time.time()*1000)}", + 'filled_amount': 0 if pending_flag else amount, + 'filled_base_amount': 0 if pending_flag else amount, + 'filled_price': 0 if pending_flag else ref_price, + 'total_cost': 0 if pending_flag else (float(amount or 0.0) * float(ref_price or 0.0) if ref_price else 0), + 'fee': 0, + 'message': 'Order enqueued to pending_orders' + } + except Exception as e: + logger.error(f"Signal execution failed: {e}") + return {'success': False, 'error': str(e)} + + def _enqueue_pending_order( + self, + strategy_id: int, + symbol: str, + signal_type: str, + amount: float, + price: float, + signal_ts: int, + market_type: str, + leverage: float, + execution_mode: str, + notification_config: Optional[Dict[str, Any]] = None, + extra_payload: Optional[Dict[str, Any]] = None, + ) -> Optional[int]: + """Insert a pending order record and return its id.""" + try: + now = int(time.time()) + # Local deployment supports both "signal" and "live" (live is executed by PendingOrderWorker). + mode = (execution_mode or "signal").strip().lower() + if mode not in ("signal", "live"): + mode = "signal" + + payload: Dict[str, Any] = { + "strategy_id": int(strategy_id), + "symbol": symbol, + "signal_type": signal_type, + "market_type": market_type, + "amount": float(amount or 0.0), + "price": float(price or 0.0), + "leverage": float(leverage or 1.0), + "execution_mode": mode, + "notification_config": notification_config or {}, + "signal_ts": int(signal_ts or 0), + } + if extra_payload and isinstance(extra_payload, dict): + payload.update(extra_payload) + + with get_db_connection() as db: + cur = db.cursor() + + # Extra dedup/cooldown guard (DB-based, more rigorous than local position state): + # The indicator recompute runs on a fixed tick cadence, and some strategies may keep emitting the same + # entry/exit signal across multiple ticks/candles (especially when orders fail). + # We prevent spamming the queue by skipping if a very recent identical order already exists. + # + # Rules: + # - If signal_ts is provided (>0), treat (strategy_id, symbol, signal_type, signal_ts) as the canonical + # "same candle" key: if any record already exists, do NOT enqueue again. + # - Otherwise, fall back to the older (strategy_id, symbol, signal_type) cooldown guard. + cooldown_sec = 30 # keep small; worker already retries the claimed order via attempts/max_attempts + try: + stsig = int(signal_ts or 0) + # Strict "same candle" de-dup should ONLY apply to open signals. + # Rationale: on higher timeframes (e.g. 1D), scale-in signals (add_*) may legitimately trigger + # multiple times within the same candle/day as price evolves; we must not block them by candle key. + sig_norm = str(signal_type or "").strip().lower() + strict_candle_dedup = stsig > 0 and sig_norm in ("open_long", "open_short") + + if strict_candle_dedup: + cur.execute( + """ + SELECT id, status, created_at + FROM pending_orders + WHERE strategy_id = %s + AND symbol = %s + AND signal_type = %s + AND signal_ts = %s + ORDER BY id DESC + LIMIT 1 + """, + (int(strategy_id), str(symbol), str(signal_type), int(stsig)), + ) + else: + cur.execute( + """ + SELECT id, status, created_at + FROM pending_orders + WHERE strategy_id = %s + AND symbol = %s + AND signal_type = %s + ORDER BY id DESC + LIMIT 1 + """, + (int(strategy_id), str(symbol), str(signal_type)), + ) + last = cur.fetchone() or {} + last_id = int(last.get("id") or 0) + last_status = str(last.get("status") or "").strip().lower() + last_created = int(last.get("created_at") or 0) + if last_id > 0: + if strict_candle_dedup: + logger.info( + f"enqueue_pending_order skipped (same candle): existing id={last_id} " + f"strategy_id={strategy_id} symbol={symbol} signal={signal_type} signal_ts={stsig} status={last_status}" + ) + cur.close() + return None + if last_status in ("pending", "processing"): + logger.info( + f"enqueue_pending_order skipped: existing_inflight id={last_id} " + f"strategy_id={strategy_id} symbol={symbol} signal={signal_type} status={last_status}" + ) + cur.close() + return None + if last_created > 0 and (now - last_created) < cooldown_sec: + logger.info( + f"enqueue_pending_order cooldown: last_id={last_id} last_status={last_status} " + f"age_sec={now - last_created} (<{cooldown_sec}) " + f"strategy_id={strategy_id} symbol={symbol} signal={signal_type}" + ) + cur.close() + return None + except Exception: + # Best-effort only; do not block enqueue on dedup query errors. + pass + + cur.execute( + """ + INSERT INTO pending_orders + (strategy_id, symbol, signal_type, signal_ts, market_type, order_type, amount, price, + execution_mode, status, priority, attempts, max_attempts, last_error, payload_json, + created_at, updated_at, processed_at, sent_at) + VALUES + (%s, %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s) + """, + ( + int(strategy_id), + symbol, + signal_type, + int(signal_ts or 0), + market_type or 'swap', + 'market', + float(amount or 0.0), + float(price or 0.0), + mode, + 'pending', + 0, + 0, + 10, + '', + json.dumps(payload, ensure_ascii=False), + now, + now, + None, + None, + ), + ) + pending_id = cur.lastrowid + db.commit() + cur.close() + return int(pending_id) if pending_id is not None else None + except Exception as e: + logger.error(f"enqueue_pending_order failed: {e}") + return None + + def _place_stop_loss_order(self, *args, **kwargs): + pass + + def _get_available_capital(self, strategy_id: int, initial_capital: float) -> float: + """获取可用资金""" + return initial_capital + + def _calculate_current_equity(self, strategy_id: int, initial_capital: float) -> float: + return initial_capital + + def _record_trade(self, strategy_id: int, symbol: str, type: str, price: float, amount: float, value: float, profit: float = None, commission: float = None): + """记录交易到数据库""" + try: + with get_db_connection() as db: + cursor = db.cursor() + query = """ + INSERT INTO qd_strategy_trades ( + strategy_id, symbol, type, price, amount, value, commission, profit, created_at + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, %s + ) + """ + cursor.execute(query, (strategy_id, symbol, type, price, amount, value, commission or 0, profit, int(time.time()))) + db.commit() + cursor.close() + except Exception as e: + logger.error(f"Failed to record trade: {e}") + + def _update_position( + self, + strategy_id: int, + symbol: str, + side: str, + size: float, + entry_price: float, + current_price: float, + highest_price: float = 0.0, + lowest_price: float = 0.0, + ): + """更新持仓状态""" + try: + with get_db_connection() as db: + cursor = db.cursor() + # 简化:直接 Update 或 Insert + upsert_query = """ + INSERT INTO qd_strategy_positions ( + strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, %s + ) ON CONFLICT(strategy_id, symbol, side) DO UPDATE SET + size = excluded.size, + entry_price = excluded.entry_price, + current_price = excluded.current_price, + highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE highest_price END, + lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE lowest_price END, + updated_at = excluded.updated_at + """ + cursor.execute(upsert_query, ( + strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, int(time.time()) + )) + db.commit() + cursor.close() + except Exception as e: + logger.error(f"Failed to update position: {e}") + + def _close_position(self, strategy_id: int, symbol: str, side: str): + """平仓:删除持仓记录""" + try: + with get_db_connection() as db: + cursor = db.cursor() + cursor.execute("DELETE FROM qd_strategy_positions WHERE strategy_id = %s AND symbol = %s AND side = %s", (strategy_id, symbol, side)) + db.commit() + cursor.close() + except Exception as e: + logger.error(f"Failed to close position: {e}") + + def _delete_position_by_id(self, position_id: int): + pass + + def _update_positions(self, strategy_id: int, symbol: str, current_price: float): + """更新所有持仓的当前价格""" + try: + with get_db_connection() as db: + cursor = db.cursor() + cursor.execute("UPDATE qd_strategy_positions SET current_price = %s WHERE strategy_id = %s AND symbol = %s", (current_price, strategy_id, symbol)) + db.commit() + cursor.close() + except Exception: + pass + + def _get_indicator_code_from_db(self, indicator_id: int) -> Optional[str]: + try: + with get_db_connection() as db: + cursor = db.cursor() + cursor.execute("SELECT code FROM qd_indicator_codes WHERE id = %s", (indicator_id,)) + result = cursor.fetchone() + return result['code'] if result else None + except: + return None diff --git a/backend_api_python/app/utils/__init__.py b/backend_api_python/app/utils/__init__.py new file mode 100644 index 0000000..2fc2761 --- /dev/null +++ b/backend_api_python/app/utils/__init__.py @@ -0,0 +1,9 @@ +""" +工具模块 +""" +from app.utils.logger import get_logger +from app.utils.cache import CacheManager +from app.utils.http import get_retry_session + +__all__ = ['get_logger', 'CacheManager', 'get_retry_session'] + diff --git a/backend_api_python/app/utils/auth.py b/backend_api_python/app/utils/auth.py new file mode 100644 index 0000000..b5fa689 --- /dev/null +++ b/backend_api_python/app/utils/auth.py @@ -0,0 +1,63 @@ + +import jwt +import datetime +from functools import wraps +from flask import request, jsonify, g +from app.config.settings import Config +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +def generate_token(username): + """Generate JWT token.""" + try: + payload = { + 'exp': datetime.datetime.utcnow() + datetime.timedelta(days=7), + 'iat': datetime.datetime.utcnow(), + 'sub': username + } + return jwt.encode( + payload, + Config.SECRET_KEY, + algorithm='HS256' + ) + except Exception as e: + logger.error(f"Token generation failed: {e}") + return None + +def verify_token(token): + """Verify JWT token.""" + try: + payload = jwt.decode(token, Config.SECRET_KEY, algorithms=['HS256']) + return payload['sub'] + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None + +def login_required(f): + """Decorator that enforces Bearer token auth.""" + @wraps(f) + def decorated(*args, **kwargs): + token = None + + # Read token from Authorization: Bearer + auth_header = request.headers.get('Authorization') + if auth_header: + parts = auth_header.split() + if len(parts) == 2 and parts[0].lower() == 'bearer': + token = parts[1] + + if not token: + return jsonify({'code': 401, 'msg': 'Token missing', 'data': None}), 401 + + username = verify_token(token) + if not username: + return jsonify({'code': 401, 'msg': 'Token invalid or expired', 'data': None}), 401 + + # Store user in flask.g + g.user = username + return f(*args, **kwargs) + + return decorated + diff --git a/backend_api_python/app/utils/cache.py b/backend_api_python/app/utils/cache.py new file mode 100644 index 0000000..2e03380 --- /dev/null +++ b/backend_api_python/app/utils/cache.py @@ -0,0 +1,128 @@ +""" +Cache utilities. +Local-first behavior: use in-memory cache by default. +Redis is only used when explicitly enabled via environment variables. +""" +import time +import threading +from typing import Optional, Any +import json + +from app.utils.logger import get_logger +from app.config import CacheConfig + +logger = get_logger(__name__) + + +class MemoryCache: + """内存缓存(Redis 不可用时的备选方案)""" + + def __init__(self): + self._cache = {} + self._lock = threading.Lock() + + def get(self, key: str) -> Optional[str]: + with self._lock: + if key in self._cache: + data, expiry = self._cache[key] + if expiry > time.time(): + return data + else: + del self._cache[key] + return None + + def setex(self, key: str, ttl: int, value: str): + with self._lock: + expiry = time.time() + ttl + self._cache[key] = (value, expiry) + + def delete(self, key: str): + with self._lock: + if key in self._cache: + del self._cache[key] + + def clear(self): + with self._lock: + self._cache.clear() + + +class CacheManager: + """缓存管理器""" + + _instance = None + _lock = threading.Lock() + + def __new__(cls): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + + self._initialized = True + self._client = None + self._use_redis = False + + # Local-first: do NOT touch Redis unless explicitly enabled. + if not CacheConfig.ENABLED: + self._client = MemoryCache() + self._use_redis = False + return + + # Try Redis only when enabled. + try: + import redis + from app.config import RedisConfig + + self._client = redis.Redis( + host=RedisConfig.HOST, + port=RedisConfig.PORT, + db=RedisConfig.DB, + password=RedisConfig.PASSWORD, + decode_responses=True, + socket_connect_timeout=RedisConfig.CONNECT_TIMEOUT, + socket_timeout=RedisConfig.SOCKET_TIMEOUT + ) + self._client.ping() + self._use_redis = True + logger.info("Redis cache connected") + except Exception as e: + # Fall back silently (keep startup logs clean in local mode). + logger.info(f"Redis is enabled but unavailable; using in-memory cache instead: {e}") + self._client = MemoryCache() + self._use_redis = False + + def get(self, key: str) -> Optional[Any]: + """获取缓存""" + try: + data = self._client.get(key) + if data: + return json.loads(data) + return None + except Exception as e: + logger.error(f"Cache read failed: {e}") + return None + + def set(self, key: str, value: Any, ttl: int = 300): + """设置缓存""" + try: + self._client.setex(key, ttl, json.dumps(value)) + except Exception as e: + logger.error(f"Cache write failed: {e}") + + def delete(self, key: str): + """删除缓存""" + try: + self._client.delete(key) + except Exception as e: + logger.error(f"Cache delete failed: {e}") + + @property + def is_redis(self) -> bool: + return self._use_redis + diff --git a/backend_api_python/app/utils/config_loader.py b/backend_api_python/app/utils/config_loader.py new file mode 100644 index 0000000..b567186 --- /dev/null +++ b/backend_api_python/app/utils/config_loader.py @@ -0,0 +1,215 @@ +""" +Config loader (local-only). + +This project is fully localized: all sensitive configuration should come from +`backend_api_python/.env` (or OS environment variables). + +We keep the return shape compatible with the old PHP `loadConfig`: +flat keys like `openrouter.api_key` become nested dicts like: +{ + "openrouter": {"api_key": "..."} +} +""" +from typing import Dict, Any, Optional, List, Tuple +import os + +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +# 配置缓存 +_config_cache: Optional[Dict[str, Any]] = None + + +def load_addon_config() -> Dict[str, Any]: + """ + Build config from environment variables (.env / OS env). + + NOTE: We intentionally do NOT load secrets from the database. + + Returns: + Nested config dict (PHP-compatible shape) + """ + global _config_cache + + # 如果缓存存在,直接返回 + if _config_cache is not None: + return _config_cache + + config: Dict[str, Any] = {} + + def set_nested(cfg: Dict[str, Any], dotted_key: str, value: Any) -> None: + keys = dotted_key.split('.') + ref = cfg + for i, k in enumerate(keys): + if i == len(keys) - 1: + ref[k] = value + else: + if k not in ref or not isinstance(ref[k], dict): + ref[k] = {} + ref = ref[k] + + def env_get(name: str) -> Optional[str]: + val = os.getenv(name) + if val is None: + return None + val = str(val).strip() + return val if val != '' else None + + # Map env vars to PHP-style dotted keys. + mappings: List[Tuple[str, str, str]] = [ + # internal + ('INTERNAL_API_KEY', 'internal_api.key', 'string'), + + # OpenRouter / LLM + ('OPENROUTER_API_KEY', 'openrouter.api_key', 'string'), + ('OPENROUTER_API_URL', 'openrouter.api_url', 'string'), + ('OPENROUTER_MODEL', 'openrouter.model', 'string'), + ('OPENROUTER_TEMPERATURE', 'openrouter.temperature', 'float'), + ('OPENROUTER_MAX_TOKENS', 'openrouter.max_tokens', 'int'), + ('OPENROUTER_TIMEOUT', 'openrouter.timeout', 'int'), + ('OPENROUTER_CONNECT_TIMEOUT', 'openrouter.connect_timeout', 'int'), + ('AI_MODELS_JSON', 'ai.models', 'json'), + + # Market + ('MARKET_TYPES_JSON', 'market.types', 'json'), + ('TRADING_SUPPORTED_SYMBOLS_JSON', 'trading.supported_symbols', 'json'), + + # App + ('CORS_ORIGINS', 'app.cors_origins', 'string'), + ('RATE_LIMIT', 'app.rate_limit', 'int'), + ('ENABLE_CACHE', 'app.enable_cache', 'bool'), + ('ENABLE_REQUEST_LOG', 'app.enable_request_log', 'bool'), + ('ENABLE_AI_ANALYSIS', 'app.enable_ai_analysis', 'bool'), + + # Data source common + ('DATA_SOURCE_TIMEOUT', 'data_source.timeout', 'int'), + ('DATA_SOURCE_RETRY', 'data_source.retry_count', 'int'), + ('DATA_SOURCE_RETRY_BACKOFF', 'data_source.retry_backoff', 'float'), + + # Finnhub + ('FINNHUB_API_KEY', 'finnhub.api_key', 'string'), + ('FINNHUB_TIMEOUT', 'finnhub.timeout', 'int'), + ('FINNHUB_RATE_LIMIT', 'finnhub.rate_limit', 'int'), + + # CCXT + ('CCXT_DEFAULT_EXCHANGE', 'ccxt.default_exchange', 'string'), + ('CCXT_TIMEOUT', 'ccxt.timeout', 'int'), + ('CCXT_PROXY', 'ccxt.proxy', 'string'), + + # Other sources + ('YFINANCE_TIMEOUT', 'yfinance.timeout', 'int'), + ('AKSHARE_TIMEOUT', 'akshare.timeout', 'int'), + ('TIINGO_API_KEY', 'tiingo.api_key', 'string'), + ('TIINGO_TIMEOUT', 'tiingo.timeout', 'int'), + + # Search (Google CSE / Bing) + ('SEARCH_PROVIDER', 'search.provider', 'string'), + ('SEARCH_MAX_RESULTS', 'search.max_results', 'int'), + ('SEARCH_GOOGLE_API_KEY', 'search.google.api_key', 'string'), + ('SEARCH_GOOGLE_CX', 'search.google.cx', 'string'), + ('SEARCH_BING_API_KEY', 'search.bing.api_key', 'string'), + ] + + for env_name, dotted_key, value_type in mappings: + raw = env_get(env_name) + if raw is None: + continue + try: + value = _convert_config_value(raw, value_type) + set_nested(config, dotted_key, value) + except Exception as e: + logger.warning(f"Config env parse failed: {env_name} -> {dotted_key}: {e}") + + _config_cache = config + return config + + +def _convert_config_value(value: str, value_type: str) -> Any: + """ + 根据类型转换配置值(与PHP端convertConfigValue方法保持一致) + + Args: + value: 配置值字符串(可能为None) + value_type: 配置类型 + + Returns: + 转换后的配置值 + """ + # 处理 None 或空值 + if value is None or value == '': + if value_type == 'int': + return 0 + elif value_type == 'float': + return 0.0 + elif value_type == 'bool': + return False + elif value_type == 'json': + return {} + else: + return '' + + try: + if value_type == 'int': + return int(value) + elif value_type == 'float': + return float(value) + elif value_type == 'bool': + return bool(value) or value == '1' or value == 'true' or value == 'True' + elif value_type == 'json': + import json + try: + return json.loads(value) if value else {} + except (json.JSONDecodeError, TypeError): + return {} + else: + return str(value) if value is not None else '' + except (ValueError, TypeError) as e: + logger.warning(f"Config value type conversion failed: value={value}, type={value_type}, error={str(e)}") + # 转换失败时返回默认值 + if value_type == 'int': + return 0 + elif value_type == 'float': + return 0.0 + elif value_type == 'bool': + return False + elif value_type == 'json': + return {} + else: + return str(value) if value is not None else '' + + +def get_internal_api_key() -> Optional[str]: + """ + 获取内部API密钥(优先从环境变量读取) + + Returns: + 内部API密钥,如果未配置则返回None + """ + try: + env_val = os.getenv('INTERNAL_API_KEY', '').strip() + if env_val: + return env_val + + config = load_addon_config() + api_key = config.get('internal_api', {}).get('key') + + if api_key: + logger.debug(f"Loaded INTERNAL_API_KEY from env-config shape, length: {len(api_key)}") + else: + logger.warning("Missing INTERNAL_API_KEY (env).") + + return api_key + except Exception as e: + logger.error(f"Failed to load internal API key: {str(e)}") + return None + + +def clear_config_cache(): + """ + 清除配置缓存(配置更新后调用) + """ + global _config_cache + _config_cache = None + logger.debug("Addon config cache cleared") + diff --git a/backend_api_python/app/utils/db.py b/backend_api_python/app/utils/db.py new file mode 100644 index 0000000..4ba3ea1 --- /dev/null +++ b/backend_api_python/app/utils/db.py @@ -0,0 +1,501 @@ + +""" +SQLite 数据库连接工具 (本地化适配版) +""" +import sqlite3 +import os +import threading +from typing import Optional, Any, List, Dict +from contextlib import contextmanager +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +# 数据库文件路径 +DB_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'quantdinger.db') + +# 线程锁,用于简单的并发控制(SQLite 对写操作有限制) +_db_lock = threading.Lock() + +def _init_db_schema(conn): + """初始化数据库表结构""" + cursor = conn.cursor() + + def ensure_columns(table: str, columns: Dict[str, str]) -> None: + """ + Ensure columns exist for an existing SQLite table (simple migration). + columns: {column_name: "TYPE DEFAULT ..."} + """ + try: + cursor.execute(f"PRAGMA table_info({table})") + existing = {row[1] for row in cursor.fetchall() or []} # row[1] is column name + for col, ddl in columns.items(): + if col in existing: + continue + cursor.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}") + except Exception as e: + logger.warning(f"ensure_columns failed for table={table}: {e}") + + # 1. 策略表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS qd_strategies_trading ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + strategy_name TEXT NOT NULL, + strategy_type TEXT DEFAULT 'IndicatorStrategy', + market_category TEXT DEFAULT 'Crypto', + execution_mode TEXT DEFAULT 'signal', + notification_config TEXT DEFAULT '', -- JSON string + status TEXT DEFAULT 'stopped', + symbol TEXT, + timeframe TEXT, + initial_capital REAL DEFAULT 1000, + leverage INTEGER DEFAULT 1, + market_type TEXT DEFAULT 'swap', + exchange_config TEXT, -- JSON string + indicator_config TEXT, -- JSON string + trading_config TEXT, -- JSON string + ai_model_config TEXT, -- JSON string + decide_interval INTEGER DEFAULT 300, + created_at INTEGER, + updated_at INTEGER + ) + """) + + ensure_columns("qd_strategies_trading", { + "market_category": "TEXT DEFAULT 'Crypto'", + "execution_mode": "TEXT DEFAULT 'signal'", + "notification_config": "TEXT DEFAULT ''" + }) + + # 2. 持仓表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS qd_strategy_positions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + strategy_id INTEGER, + symbol TEXT, + side TEXT, -- long/short + size REAL, + entry_price REAL, + current_price REAL, + highest_price REAL DEFAULT 0, + lowest_price REAL DEFAULT 0, + unrealized_pnl REAL DEFAULT 0, + pnl_percent REAL DEFAULT 0, + equity REAL DEFAULT 0, + updated_at INTEGER, + UNIQUE(strategy_id, symbol, side) + ) + """) + + ensure_columns("qd_strategy_positions", { + "highest_price": "REAL DEFAULT 0", + "lowest_price": "REAL DEFAULT 0", + }) + + # 3. 交易记录表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS qd_strategy_trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + strategy_id INTEGER, + symbol TEXT, + type TEXT, -- open_long, close_short, etc. + price REAL, + amount REAL, + value REAL, + commission REAL DEFAULT 0, + commission_ccy TEXT DEFAULT '', + profit REAL DEFAULT 0, + created_at INTEGER + ) + """) + + ensure_columns("qd_strategy_trades", { + "commission_ccy": "TEXT DEFAULT ''", + }) + + # NOTE: + # We intentionally do not persist runtime logs in DB for local deployments. + # Use console logs / stdout prints instead. + + # 3.1 Pending orders queue (signal dispatch / live execution) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS pending_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + strategy_id INTEGER, + symbol TEXT NOT NULL, + signal_type TEXT NOT NULL, -- open_long/close_long/open_short/close_short/add_long/add_short + signal_ts INTEGER, -- candle timestamp (seconds). used for strict de-dup per candle + market_type TEXT DEFAULT 'swap', + order_type TEXT DEFAULT 'market', + amount REAL DEFAULT 0, -- base amount (or stake amount depending on execution) + price REAL DEFAULT 0, -- reference price at enqueue time + execution_mode TEXT DEFAULT 'signal', -- signal/live + status TEXT DEFAULT 'pending', -- pending/processing/sent/failed/deferred + priority INTEGER DEFAULT 0, + attempts INTEGER DEFAULT 0, + max_attempts INTEGER DEFAULT 10, + last_error TEXT DEFAULT '', + payload_json TEXT DEFAULT '', -- JSON string for dispatcher + -- Live execution result fields (best-effort) + dispatch_note TEXT DEFAULT '', + exchange_id TEXT DEFAULT '', + exchange_order_id TEXT DEFAULT '', + exchange_response_json TEXT DEFAULT '', + filled REAL DEFAULT 0, + avg_price REAL DEFAULT 0, + executed_at INTEGER, + created_at INTEGER, + updated_at INTEGER, + processed_at INTEGER, + sent_at INTEGER + ) + """) + + ensure_columns("pending_orders", { + "signal_ts": "INTEGER", + "market_type": "TEXT DEFAULT 'swap'", + "order_type": "TEXT DEFAULT 'market'", + "price": "REAL DEFAULT 0", + "execution_mode": "TEXT DEFAULT 'signal'", + "status": "TEXT DEFAULT 'pending'", + "priority": "INTEGER DEFAULT 0", + "attempts": "INTEGER DEFAULT 0", + "max_attempts": "INTEGER DEFAULT 10", + "last_error": "TEXT DEFAULT ''", + "payload_json": "TEXT DEFAULT ''", + "dispatch_note": "TEXT DEFAULT ''", + "exchange_id": "TEXT DEFAULT ''", + "exchange_order_id": "TEXT DEFAULT ''", + "exchange_response_json": "TEXT DEFAULT ''", + "filled": "REAL DEFAULT 0", + "avg_price": "REAL DEFAULT 0", + "executed_at": "INTEGER", + "created_at": "INTEGER", + "updated_at": "INTEGER", + "processed_at": "INTEGER", + "sent_at": "INTEGER", + }) + + # 3.2 Strategy notifications (browser polling / audit trail) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS qd_strategy_notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + strategy_id INTEGER, + symbol TEXT DEFAULT '', + signal_type TEXT DEFAULT '', + channels TEXT DEFAULT '', + title TEXT DEFAULT '', + message TEXT DEFAULT '', + payload_json TEXT DEFAULT '', + created_at INTEGER + ) + """) + + ensure_columns("qd_strategy_notifications", { + "strategy_id": "INTEGER", + "symbol": "TEXT DEFAULT ''", + "signal_type": "TEXT DEFAULT ''", + "channels": "TEXT DEFAULT ''", + "title": "TEXT DEFAULT ''", + "message": "TEXT DEFAULT ''", + "payload_json": "TEXT DEFAULT ''", + "created_at": "INTEGER", + }) + + # 4. 指标代码表(参考 MySQL: qd_indicator_codes) + # 说明: + # - 本地化后统一使用 SQLite,但字段保持与 MySQL 结构接近,便于前端/业务复用。 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS qd_indicator_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 1, + is_buy INTEGER NOT NULL DEFAULT 0, + end_time INTEGER NOT NULL DEFAULT 1, + name TEXT NOT NULL DEFAULT '', + code TEXT, + description TEXT DEFAULT '', + publish_to_community INTEGER NOT NULL DEFAULT 0, + pricing_type TEXT NOT NULL DEFAULT 'free', + price REAL NOT NULL DEFAULT 0, + is_encrypted INTEGER NOT NULL DEFAULT 0, + preview_image TEXT DEFAULT '', + createtime INTEGER, + updatetime INTEGER, + -- legacy local columns (kept for backward compatibility) + created_at INTEGER, + updated_at INTEGER + ) + """) + + # Migrate older local DBs (missing columns) to the new schema shape. + ensure_columns("qd_indicator_codes", { + "user_id": "INTEGER NOT NULL DEFAULT 1", + "is_buy": "INTEGER NOT NULL DEFAULT 0", + "end_time": "INTEGER NOT NULL DEFAULT 1", + "publish_to_community": "INTEGER NOT NULL DEFAULT 0", + "pricing_type": "TEXT NOT NULL DEFAULT 'free'", + "price": "REAL NOT NULL DEFAULT 0", + "is_encrypted": "INTEGER NOT NULL DEFAULT 0", + "preview_image": "TEXT DEFAULT ''", + "createtime": "INTEGER", + "updatetime": "INTEGER" + }) + + # 4.1 策略代码表(indicator-analysis 本地策略;与交易执行器的 qd_strategies_trading 区分) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS qd_strategy_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 1, + name TEXT NOT NULL DEFAULT '', + code TEXT, + description TEXT DEFAULT '', + createtime INTEGER, + updatetime INTEGER + ) + """) + + ensure_columns("qd_strategy_codes", { + "user_id": "INTEGER NOT NULL DEFAULT 1", + "name": "TEXT NOT NULL DEFAULT ''", + "code": "TEXT", + "description": "TEXT DEFAULT ''", + "createtime": "INTEGER", + "updatetime": "INTEGER" + }) + + # 5. AI决策记录表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS qd_ai_decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + strategy_id INTEGER, + decision_data TEXT, -- JSON + context_data TEXT, -- JSON + created_at INTEGER + ) + """) + + # 6. 插件/系统配置表(原来由 MySQL 提供,这里用 SQLite 本地化) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS qd_addon_config ( + config_key TEXT PRIMARY KEY, + config_value TEXT, + type TEXT DEFAULT 'string' + ) + """) + + # 7. Watchlist (local-only, single-user by default) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS qd_watchlist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER DEFAULT 1, + market TEXT NOT NULL, + symbol TEXT NOT NULL, + name TEXT DEFAULT '', + created_at INTEGER, + updated_at INTEGER, + UNIQUE(user_id, market, symbol) + ) + """) + + # 8. Analysis tasks / history (local-only) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS qd_analysis_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER DEFAULT 1, + market TEXT NOT NULL, + symbol TEXT NOT NULL, + model TEXT DEFAULT '', + language TEXT DEFAULT 'en-US', + status TEXT DEFAULT 'completed', -- completed/failed/processing/pending + result_json TEXT DEFAULT '', + error_message TEXT DEFAULT '', + created_at INTEGER, + completed_at INTEGER + ) + """) + + # 9. Backtest runs (for AI optimization / history) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS qd_backtest_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 1, + indicator_id INTEGER, + market TEXT NOT NULL, + symbol TEXT NOT NULL, + timeframe TEXT NOT NULL, + start_date TEXT NOT NULL, -- YYYY-MM-DD + end_date TEXT NOT NULL, -- YYYY-MM-DD + initial_capital REAL DEFAULT 10000, + commission REAL DEFAULT 0.001, + slippage REAL DEFAULT 0, + leverage INTEGER DEFAULT 1, + trade_direction TEXT DEFAULT 'long', + strategy_config TEXT DEFAULT '', -- JSON string + status TEXT DEFAULT 'success', -- success/failed + error_message TEXT DEFAULT '', + result_json TEXT DEFAULT '', -- JSON string + created_at INTEGER + ) + """) + + ensure_columns("qd_backtest_runs", { + "user_id": "INTEGER NOT NULL DEFAULT 1", + "indicator_id": "INTEGER", + "market": "TEXT NOT NULL DEFAULT ''", + "symbol": "TEXT NOT NULL DEFAULT ''", + "timeframe": "TEXT NOT NULL DEFAULT ''", + "start_date": "TEXT NOT NULL DEFAULT ''", + "end_date": "TEXT NOT NULL DEFAULT ''", + "initial_capital": "REAL DEFAULT 10000", + "commission": "REAL DEFAULT 0.001", + "slippage": "REAL DEFAULT 0", + "leverage": "INTEGER DEFAULT 1", + "trade_direction": "TEXT DEFAULT 'long'", + "strategy_config": "TEXT DEFAULT ''", + "status": "TEXT DEFAULT 'success'", + "error_message": "TEXT DEFAULT ''", + "result_json": "TEXT DEFAULT ''", + "created_at": "INTEGER" + }) + + # 10. Exchange credentials vault (local-only) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS qd_exchange_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 1, + name TEXT DEFAULT '', + exchange_id TEXT NOT NULL, + api_key_hint TEXT DEFAULT '', + encrypted_config TEXT NOT NULL, -- encrypted JSON string + created_at INTEGER, + updated_at INTEGER + ) + """) + + ensure_columns("qd_exchange_credentials", { + "user_id": "INTEGER NOT NULL DEFAULT 1", + "name": "TEXT DEFAULT ''", + "exchange_id": "TEXT NOT NULL DEFAULT ''", + "api_key_hint": "TEXT DEFAULT ''", + "encrypted_config": "TEXT NOT NULL DEFAULT ''", + "created_at": "INTEGER", + "updated_at": "INTEGER" + }) + + conn.commit() + logger.info("Database schema initialized (SQLite)") + +# 初始化一次 +_has_initialized = False + +class SQLiteCursor: + """模拟 pymysql DictCursor""" + def __init__(self, cursor): + self._cursor = cursor + + def execute(self, query: str, args: Any = None): + # 适配 MySQL -> SQLite 语法 + # 1. 替换占位符: %s -> ? + query = query.replace('%s', '?') + # 2. 替换 INSERT IGNORE -> INSERT OR IGNORE + query = query.replace('INSERT IGNORE', 'INSERT OR IGNORE') + # 3. 替换 ON DUPLICATE KEY UPDATE -> 简化为 UPSERT (SQLite 3.24+) + # 注意:复杂的 ON DUPLICATE KEY UPDATE 很难自动转换,建议业务代码改写 + # 这里做一个简单的替换尝试,如果失败则需要人工介入代码 + if 'ON DUPLICATE KEY UPDATE' in query: + # 简单的正则替换很难完美,这里记录日志提醒 + logger.warning(f"Complex SQL may require manual SQLite adaptation: {query}") + # 尝试转换为 SQLite 的 ON CONFLICT (id) DO UPDATE SET ... + # 但由于不知道主键冲突列,很难自动转换。 + # 临时方案:如果遇到这种 SQL,可能报错。我们假设主要业务逻辑已经重构。 + pass + + if args: + return self._cursor.execute(query, args) + return self._cursor.execute(query) + + def fetchone(self): + row = self._cursor.fetchone() + if row is None: + return None + # Convert sqlite3.Row to dict + return dict(row) + + def fetchall(self): + rows = self._cursor.fetchall() + return [dict(row) for row in rows] + + def close(self): + self._cursor.close() + + @property + def lastrowid(self): + return self._cursor.lastrowid + +class SQLiteConnection: + """数据库连接包装类""" + def __init__(self, db_path): + self._conn = sqlite3.connect(db_path, check_same_thread=False, timeout=30.0) + # 设置 Row factory 以支持字段名访问 + self._conn.row_factory = sqlite3.Row + + def cursor(self): + return SQLiteCursor(self._conn.cursor()) + + def commit(self): + self._conn.commit() + + def rollback(self): + self._conn.rollback() + + def close(self): + self._conn.close() + +@contextmanager +def get_db_connection(): + """ + 获取数据库连接 (Context Manager) + """ + global _has_initialized + + # 简单的连接创建,不使用连接池(SQLite 文件锁机制决定了连接池意义不大) + # 使用线程锁防止写冲突(虽然 SQLite 有 WAL 模式,但稳妥起见) + # 注意:这里加锁粒度较大,如果是高并发场景可能会慢,但对于个人量化系统足够。 + + # 初始化表结构 + if not _has_initialized: + try: + conn_init = sqlite3.connect(DB_FILE) + _init_db_schema(conn_init) + conn_init.close() + _has_initialized = True + except Exception as e: + logger.error(f"Failed to initialize database: {e}") + + conn = SQLiteConnection(DB_FILE) + try: + # with _db_lock: # SQLite 内部有锁,这里如果不跨线程共享连接其实不用强加锁 + yield conn + except Exception as e: + logger.error(f"Database operation error: {e}") + conn.rollback() + raise e + finally: + conn.close() + +def get_db_connection_sync(): + """兼容旧接口""" + global _has_initialized + if not _has_initialized: + try: + conn_init = sqlite3.connect(DB_FILE) + _init_db_schema(conn_init) + conn_init.close() + _has_initialized = True + except Exception as e: + logger.error(f"Failed to initialize database: {e}") + + return SQLiteConnection(DB_FILE) + +def close_db_connection(): + pass diff --git a/backend_api_python/app/utils/http.py b/backend_api_python/app/utils/http.py new file mode 100644 index 0000000..df60f53 --- /dev/null +++ b/backend_api_python/app/utils/http.py @@ -0,0 +1,41 @@ +""" +HTTP 工具模块 +""" +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + + +def get_retry_session( + retries: int = 3, + backoff_factor: float = 0.5, + status_forcelist: tuple = (500, 502, 503, 504) +) -> requests.Session: + """ + 获取带重试机制的 HTTP Session + + Args: + retries: 重试次数 + backoff_factor: 重试间隔因子 + status_forcelist: 需要重试的 HTTP 状态码 + + Returns: + 配置好的 Session 实例 + """ + session = requests.Session() + retry = Retry( + total=retries, + read=retries, + connect=retries, + backoff_factor=backoff_factor, + status_forcelist=status_forcelist, + ) + adapter = HTTPAdapter(max_retries=retry) + session.mount('http://', adapter) + session.mount('https://', adapter) + return session + + +# 全局共享 Session +global_session = get_retry_session() + diff --git a/backend_api_python/app/utils/language.py b/backend_api_python/app/utils/language.py new file mode 100644 index 0000000..b449cec --- /dev/null +++ b/backend_api_python/app/utils/language.py @@ -0,0 +1,86 @@ +""" +Language helpers (local-only). + +We want AI analysis output language to follow the frontend UI language. +Frontend sends `X-App-Lang` (and also `Accept-Language`) on each request. +""" + +from __future__ import annotations + +from typing import Optional + + +SUPPORTED_LANGS = { + "en-US", + "zh-CN", + "zh-TW", + "ja-JP", + "ko-KR", + "vi-VN", + "th-TH", + "ar-SA", + "fr-FR", + "de-DE", +} + + +def _normalize_lang(raw: Optional[str]) -> Optional[str]: + if not raw: + return None + s = str(raw).strip() + if not s: + return None + + # Accept-Language can be like: "en-US,en;q=0.9" + if "," in s: + s = s.split(",", 1)[0].strip() + if ";" in s: + s = s.split(";", 1)[0].strip() + + # Normalize short tags + lower = s.lower() + if lower in ("en", "en-us"): + return "en-US" + if lower in ("zh", "zh-cn", "zh-hans"): + return "zh-CN" + if lower in ("zh-tw", "zh-hant"): + return "zh-TW" + + # Keep canonical casing if already supported + for lang in SUPPORTED_LANGS: + if lang.lower() == lower: + return lang + return None + + +def detect_request_language(flask_request, body: Optional[dict] = None, default: str = "en-US") -> str: + """ + Detect language for the current request. + + Priority: + 1) Header X-App-Lang (frontend UI language) + 2) body["language"] or query ?language= + 3) Header Accept-Language + """ + # 1) Custom header + lang = _normalize_lang(flask_request.headers.get("X-App-Lang")) + if lang: + return lang + + # 2) Explicit parameter + if body and isinstance(body, dict): + lang = _normalize_lang(body.get("language")) + if lang: + return lang + lang = _normalize_lang(flask_request.args.get("language")) + if lang: + return lang + + # 3) Browser default + lang = _normalize_lang(flask_request.headers.get("Accept-Language")) + if lang: + return lang + + return default + + diff --git a/backend_api_python/app/utils/logger.py b/backend_api_python/app/utils/logger.py new file mode 100644 index 0000000..d933e15 --- /dev/null +++ b/backend_api_python/app/utils/logger.py @@ -0,0 +1,46 @@ +""" +Logging utilities (local-only friendly). +""" +import logging +import os +from logging.handlers import RotatingFileHandler + + +def setup_logger(): + """配置全局日志""" + log_level = os.getenv('LOG_LEVEL', 'INFO') + log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format=log_format + ) + + # 创建日志目录 + log_dir = 'logs' + if not os.path.exists(log_dir): + os.makedirs(log_dir) + + # 添加文件处理器 + file_handler = RotatingFileHandler( + os.path.join(log_dir, 'app.log'), + maxBytes=10*1024*1024, # 10MB + backupCount=5, + encoding='utf-8' + ) + file_handler.setFormatter(logging.Formatter(log_format)) + logging.getLogger().addHandler(file_handler) + + +def get_logger(name: str) -> logging.Logger: + """ + 获取指定名称的日志记录器 + + Args: + name: 日志记录器名称 + + Returns: + Logger 实例 + """ + return logging.getLogger(name) + diff --git a/backend_api_python/app/utils/safe_exec.py b/backend_api_python/app/utils/safe_exec.py new file mode 100644 index 0000000..83d4112 --- /dev/null +++ b/backend_api_python/app/utils/safe_exec.py @@ -0,0 +1,312 @@ +""" +安全的代码执行工具 +提供超时、资源限制和沙箱环境 +""" +import signal +import sys +import os +import threading +import traceback +from typing import Dict, Any, Optional, Tuple +from contextlib import contextmanager + +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +class TimeoutError(Exception): + """代码执行超时异常""" + pass + + +@contextmanager +def timeout_context(seconds: int): + """ + 代码执行超时上下文管理器 + + 注意: + - 仅在Unix/Linux系统上有效 + - 仅在主线程中有效,非主线程会降级为不限制超时 + - Windows上会降级为不限制超时 + + Args: + seconds: 超时时间(秒) + """ + # 检查是否在主线程中 + is_main_thread = threading.current_thread() is threading.main_thread() + + if sys.platform == 'win32': + # Windows不支持signal.alarm,只能记录警告 + logger.warning("Windows does not support signal-based timeouts; execution time limits may not work") + yield + return + + if not is_main_thread: + # 非主线程不能使用signal,记录警告但不限制超时 + # logger.warning(f"当前在非主线程中运行(线程: {threading.current_thread().name})," + # f"signal超时不可用,代码执行可能无法限制时间") + yield + return + + def timeout_handler(signum, frame): + raise TimeoutError(f"代码执行超时(超过{seconds}秒)") + + try: + # 设置信号处理器 + old_handler = signal.signal(signal.SIGALRM, timeout_handler) + signal.alarm(seconds) + + try: + yield + finally: + # 恢复原来的信号处理器 + signal.alarm(0) + signal.signal(signal.SIGALRM, old_handler) + except ValueError as e: + # 如果signal设置失败(比如在某些环境中),记录警告但不中断执行 + logger.warning(f"Failed to set signal timeout: {str(e)}; execution will continue without timeout enforcement") + yield + + +def safe_exec_code( + code: str, + exec_globals: Dict[str, Any], + exec_locals: Optional[Dict[str, Any]] = None, + timeout: int = 30, + max_memory_mb: Optional[int] = None +) -> Dict[str, Any]: + """ + 安全执行Python代码 + + Args: + code: 要执行的Python代码 + exec_globals: 全局变量字典 + exec_locals: 局部变量字典(如果为None,则使用exec_globals) + timeout: 超时时间(秒),默认30秒 + max_memory_mb: 最大内存限制(MB),默认500MB + + Returns: + 执行结果字典,包含: + - success: bool,是否执行成功 + - error: str,错误信息(如果失败) + - result: Any,执行结果(如果有) + + Raises: + TimeoutError: 如果代码执行超时 + """ + if exec_locals is None: + exec_locals = exec_globals + + # 设置内存限制(如果支持) + if max_memory_mb is None: + max_memory_mb = 500 # 默认500MB + + try: + # 注意:resource.setrlimit 是进程级别,会影响整个 API 进程。 + # 之前全局限制为 500MB 可能导致并行策略/线程无法分配内存。 + # 仅当显式开启 SAFE_EXEC_ENABLE_RLIMIT 时才设置。 + if sys.platform != 'win32' and os.getenv('SAFE_EXEC_ENABLE_RLIMIT', 'false').lower() == 'true': + try: + import resource + max_memory_bytes = max_memory_mb * 1024 * 1024 + resource.setrlimit(resource.RLIMIT_AS, (max_memory_bytes, max_memory_bytes)) + logger.debug(f"Memory limit set: {max_memory_mb}MB (SAFE_EXEC_ENABLE_RLIMIT enabled)") + except (ImportError, ValueError, OSError) as e: + logger.warning(f"Failed to set memory limit: {str(e)}") + else: + logger.debug("No resource memory limit (SAFE_EXEC_ENABLE_RLIMIT disabled or unsupported platform)") + + # 在Windows上,timeout_context不会真正限制时间 + # 但会记录警告 + with timeout_context(timeout): + exec(code, exec_globals, exec_locals) + + return { + 'success': True, + 'error': None, + 'result': None + } + + except MemoryError as e: + error_msg = f"代码执行内存不足(超过{max_memory_mb}MB限制)" + logger.error(f"Code execution out of memory (limit={max_memory_mb}MB)") + return { + 'success': False, + 'error': error_msg, + 'result': None + } + except TimeoutError as e: + error_msg = str(e) + logger.error(f"Code execution timed out (timeout={timeout}s)") + return { + 'success': False, + 'error': error_msg, + 'result': None + } + except Exception as e: + error_msg = f"代码执行错误: {str(e)}\n{traceback.format_exc()}" + logger.error(f"Code execution error: {str(e)}") + logger.error(traceback.format_exc()) + return { + 'success': False, + 'error': error_msg, + 'result': None + } + + +def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]: + """ + 验证代码安全性(基本检查) + + 检查代码中是否包含危险的函数调用或导入 + + Args: + code: 要检查的Python代码 + + Returns: + (is_safe: bool, error_message: Optional[str]) + """ + import ast + import re + + # 危险的关键字和函数名 + dangerous_patterns = [ + # 系统命令执行 + r'\bos\.system\b', + r'\bos\.popen\b', + r'\bos\.spawn\b', + r'\bos\.exec\b', + r'\bos\.fork\b', + r'\bsubprocess\b', + r'\bcommands\b', + # 代码执行 + r'\b__import__\s*\(', + r'\beval\s*\(', + r'\bexec\s*\(', + r'\bcompile\s*\(', + # 文件操作 + r'\bopen\s*\(', + r'\bfile\s*\(', + r'\b__builtins__\b', + # 模块导入 + r'\bimport\s+os\b', + r'\bimport\s+sys\b', + r'\bimport\s+subprocess\b', + r'\bimport\s+pymysql\b', + r'\bimport\s+sqlite3\b', + r'\bimport\s+requests\b', + r'\bimport\s+urllib\b', + r'\bimport\s+http\b', + r'\bimport\s+socket\b', + r'\bimport\s+ftplib\b', + r'\bimport\s+telnetlib\b', + r'\bimport\s+pickle\b', + r'\bimport\s+cpickle\b', + r'\bimport\s+marshal\b', + r'\bimport\s+ctypes\b', + r'\bimport\s+multiprocessing\b', + r'\bimport\s+threading\b', + r'\bimport\s+concurrent\b', + # 反射和元编程(可能用于绕过限制) + r'\bgetattr\s*\(.*__import__', + r'\bgetattr\s*\(.*eval', + r'\bgetattr\s*\(.*exec', + r'\bsetattr\s*\(', + r'\b__getattribute__\b', + r'\b__setattr__\b', + r'\b__dict__\b', + r'\bglobals\s*\(', + r'\blocals\s*\(', + r'\bdir\s*\(', + r'\btype\s*\(.*\)\s*\(', # type() 可能用于创建新类型 + r'\b__class__\b', + r'\b__bases__\b', + r'\b__subclasses__\b', + r'\b__mro__\b', + r'\b__init__\b.*__import__', + r'\b__new__\b.*__import__', + # 其他危险操作 + r'\b__builtins__\s*\[', + r'\b__builtins__\s*\.', + r'\b__import__\s*\(', + r'\bimportlib\b', + r'\bimp\b', + ] + + # 检查代码中是否包含危险模式 + for pattern in dangerous_patterns: + if re.search(pattern, code): + return False, f"检测到危险代码模式: {pattern}" + + # 尝试解析AST,检查是否有危险的节点 + try: + tree = ast.parse(code) + + # 危险模块列表(扩展) + dangerous_modules = [ + 'os', 'sys', 'subprocess', 'pymysql', 'sqlite3', + 'requests', 'urllib', 'http', 'socket', 'ftplib', 'telnetlib', + 'pickle', 'cpickle', 'marshal', 'ctypes', + 'multiprocessing', 'threading', 'concurrent', + 'importlib', 'imp', 'builtins' + ] + + # 危险函数列表(扩展) + # 注意:hasattr 是安全的,只用于检查属性,不用于访问 + dangerous_functions = [ + 'eval', 'exec', 'compile', '__import__', + 'getattr', 'setattr', 'delattr', # hasattr 已移除,它是安全的 + 'globals', 'locals', 'vars', 'dir', 'type' + ] + + # 检查是否有危险的函数调用 + for node in ast.walk(tree): + # 检查是否有对危险函数的调用 + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Name): + func_name = node.func.id + if func_name in dangerous_functions: + return False, f"检测到危险函数调用: {func_name}()" + + # 检查是否有os.system等调用 + if isinstance(node.func, ast.Attribute): + if isinstance(node.func.value, ast.Name): + if node.func.value.id in dangerous_modules: + return False, f"检测到危险模块调用: {node.func.value.id}.{node.func.attr}" + + # 检查是否有 getattr(builtins, '__import__') 等绕过方式 + if isinstance(node.func, ast.Name) and node.func.id == 'getattr': + # 检查 getattr 的参数 + if len(node.args) >= 2: + if isinstance(node.args[0], ast.Name) and node.args[0].id in ['builtins', '__builtins__']: + if isinstance(node.args[1], ast.Constant) and node.args[1].value in dangerous_functions: + return False, f"检测到通过 getattr 绕过限制: getattr({node.args[0].id}, '{node.args[1].value}')" + + # 检查导入语句 + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name in dangerous_modules: + return False, f"检测到危险模块导入: {alias.name}" + + if isinstance(node, ast.ImportFrom): + if node.module and node.module.split('.')[0] in dangerous_modules: + return False, f"检测到危险模块导入: {node.module}" + + # 检查是否有访问 __builtins__ 的尝试 + for node in ast.walk(tree): + if isinstance(node, ast.Attribute): + if isinstance(node.attr, str) and node.attr.startswith('__') and node.attr.endswith('__'): + if node.attr in ['__builtins__', '__import__', '__class__', '__bases__', '__subclasses__', '__mro__']: + # 检查是否在危险上下文中使用 + if isinstance(node.value, ast.Name) and node.value.id in ['builtins', '__builtins__']: + return False, f"检测到访问危险属性: {node.value.id}.{node.attr}" + + except SyntaxError as e: + return False, f"代码语法错误: {str(e)}" + except Exception as e: + # 如果AST解析失败,记录警告但允许继续(可能是代码不完整) + logger.warning(f"AST parse failed; skipping safety checks: {str(e)}") + + return True, None diff --git a/backend_api_python/env.example b/backend_api_python/env.example new file mode 100644 index 0000000..ff7ee5c --- /dev/null +++ b/backend_api_python/env.example @@ -0,0 +1,158 @@ +# QuantDinger local configuration (copy to `.env` and edit) +# `run.py` will load `backend_api_python/.env` automatically if present. + +# ========================= +# Auth (local login) +# ========================= +SECRET_KEY=quantdinger-secret-key-change-me +ADMIN_USER=quantdinger +ADMIN_PASSWORD=123456 + +# ========================= +# Network / App +# ========================= +PYTHON_API_HOST=0.0.0.0 +PYTHON_API_PORT=5000 +PYTHON_API_DEBUG=False + +# ========================= +# Pending orders worker (optional) +# ========================= +# Paper mode default: disabled. If enabled, it will consume `pending_orders` and dispatch signals via webhook. +# Poll and dispatch orders from `pending_orders` (live/signal). +# Local mode default is enabled in code, but you can override here. +ENABLE_PENDING_ORDER_WORKER=true + +# Reclaim orders stuck in status=processing after worker crashes (seconds). +PENDING_ORDER_STALE_SEC=90 + +# ========================= +# Strategy signal notifications (optional) +# ========================= +# The frontend stores per-strategy notification_config.targets.*, but you can also set +# a global fallback webhook URL used when a strategy enables "webhook" channel but +# doesn't provide targets.webhook. +SIGNAL_WEBHOOK_URL= +SIGNAL_WEBHOOK_TOKEN= + +# HTTP timeout for outbound notification requests (seconds). +SIGNAL_NOTIFY_TIMEOUT_SEC=6 + +# Telegram (required if you enable telegram channel) +TELEGRAM_BOT_TOKEN= + +# Email / SMTP (required if you enable email channel) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= +SMTP_USE_TLS=true +SMTP_USE_SSL=false + +# Phone / SMS (optional; Twilio REST, required if you enable phone channel) +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= +TWILIO_FROM_NUMBER= + +# Restore strategies with status='running' on backend startup. +# Set to true to disable auto-restore. +DISABLE_RESTORE_RUNNING_STRATEGIES=false + +# ========================= +# Strategy execution loop (tick interval) +# ========================= +# Default tick interval for strategy monitoring loop (seconds). +# The strategy thread will fetch current price and evaluate triggers once per tick. +STRATEGY_TICK_INTERVAL_SEC=10 + +# In-memory price cache TTL (seconds). Normally doesn't matter when tick interval is >= TTL. +PRICE_CACHE_TTL_SEC=10 + +# ========================= +# Outbound Proxy (optional, recommended if your network blocks data providers) +# ========================= +# If you use a local proxy (common ports: 7890/7891/10808), set PROXY_PORT only. +# Default scheme is socks5h and host is 127.0.0.1. +PROXY_PORT= +PROXY_HOST=127.0.0.1 +PROXY_SCHEME=socks5h +PROXY_URL= + +# You can also set standard variables directly (advanced): +# ALL_PROXY=socks5h://127.0.0.1:10808 +# HTTP_PROXY=socks5h://127.0.0.1:10808 +# HTTPS_PROXY=socks5h://127.0.0.1:10808 + +# Allow frontend dev server +CORS_ORIGINS=* + +# Request rate limit (per minute) +RATE_LIMIT=100 + +ENABLE_CACHE=False +ENABLE_REQUEST_LOG=True +ENABLE_AI_ANALYSIS=True + +# ========================= +# OpenRouter / LLM +# ========================= +OPENROUTER_API_KEY= +OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions +OPENROUTER_MODEL=openai/gpt-4o +OPENROUTER_TEMPERATURE=0.7 +OPENROUTER_MAX_TOKENS=4000 +OPENROUTER_TIMEOUT=300 +OPENROUTER_CONNECT_TIMEOUT=30 + +# Optional: override model list shown in UI (JSON object: {"model_id":"Display Name", ...}) +AI_MODELS_JSON={} + +# ========================= +# Market presets (optional) +# ========================= +# Optional: override market types shown in UI (JSON array) +MARKET_TYPES_JSON=[] + +# Optional: supported crypto symbols list (JSON array) +TRADING_SUPPORTED_SYMBOLS_JSON=[] + +# ========================= +# Data sources (Kline / pricing) +# ========================= +DATA_SOURCE_TIMEOUT=30 +DATA_SOURCE_RETRY=3 +DATA_SOURCE_RETRY_BACKOFF=0.5 + +# Finnhub (US stocks / forex helpers depending on implementation) +FINNHUB_API_KEY= +FINNHUB_TIMEOUT=10 +FINNHUB_RATE_LIMIT=60 + +# CCXT (crypto via Binance by default) +CCXT_DEFAULT_EXCHANGE=binance +CCXT_TIMEOUT=10000 +CCXT_PROXY= + +# Akshare (CN/HK stocks if enabled) +AKSHARE_TIMEOUT=30 + +# YFinance +YFINANCE_TIMEOUT=30 + +# Tiingo (optional) +TIINGO_API_KEY= +TIINGO_TIMEOUT=10 + +# ========================= +# Web Search (optional) +# ========================= +SEARCH_PROVIDER=google +SEARCH_MAX_RESULTS=10 +SEARCH_GOOGLE_API_KEY= +SEARCH_GOOGLE_CX= +SEARCH_BING_API_KEY= + +# Internal API key (optional, if you add internal-service auth later) +INTERNAL_API_KEY= + diff --git a/backend_api_python/gunicorn_config.py b/backend_api_python/gunicorn_config.py new file mode 100644 index 0000000..edd9cf8 --- /dev/null +++ b/backend_api_python/gunicorn_config.py @@ -0,0 +1,37 @@ +""" +Gunicorn 配置文件(生产环境) +""" +import multiprocessing + +# 服务器 socket +bind = "0.0.0.0:5000" +backlog = 2048 + +# Worker 进程 +workers = multiprocessing.cpu_count() * 2 + 1 +worker_class = "sync" +worker_connections = 1000 +timeout = 120 +keepalive = 5 + +# 日志 +accesslog = "logs/access.log" +errorlog = "logs/error.log" +loglevel = "info" +access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s' + +# 进程命名 +proc_name = "quantdinger_python_api" + +# 服务器机制 +daemon = False +pidfile = "logs/gunicorn.pid" +umask = 0 +user = None +group = None +tmp_upload_dir = None + +# SSL(如果需要) +# keyfile = None +# certfile = None + diff --git a/backend_api_python/requirements.txt b/backend_api_python/requirements.txt new file mode 100644 index 0000000..b244035 --- /dev/null +++ b/backend_api_python/requirements.txt @@ -0,0 +1,13 @@ +Flask==2.3.3 +flask-cors==4.0.0 +finnhub-python>=2.4.18 +yfinance>=0.2.18 +ccxt>=4.0.0 +pandas>=1.5.0 +requests>=2.28.0 +PySocks>=1.7.1 +akshare>=1.12.0 +pymysql>=1.0.2 +SQLAlchemy>=2.0.0 +PyJWT==2.8.0 +python-dotenv>=1.0.1 \ No newline at end of file diff --git a/backend_api_python/run.py b/backend_api_python/run.py new file mode 100644 index 0000000..6a53bb7 --- /dev/null +++ b/backend_api_python/run.py @@ -0,0 +1,98 @@ +""" +QuantDinger Python API entrypoint. +""" +import os +import sys + +# Ensure UTF-8 console output on Windows to avoid UnicodeEncodeError in logs. +# (PowerShell default encoding may be GBK/CP936.) +try: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +# Load local .env early so config classes can read from os.environ. +# This keeps local deployment simple: edit one file and run. +try: + from dotenv import load_dotenv + this_dir = os.path.dirname(os.path.abspath(__file__)) + # Primary: backend_api_python/.env (same dir as run.py) + load_dotenv(os.path.join(this_dir, ".env"), override=False) + # Fallback: repo-root/.env (one level up) for users who place .env at workspace root. + parent_dir = os.path.dirname(this_dir) + load_dotenv(os.path.join(parent_dir, ".env"), override=False) +except Exception: + # python-dotenv is optional; environment variables can still be provided by the OS. + pass + +# Optional: disable tqdm progress bars (some data providers like akshare may emit them), +# keeping console logs clean in local mode. +os.environ.setdefault("TQDM_DISABLE", "1") + +# Optional: normalize outbound proxy settings for the whole process. +# This makes requests/yfinance/finnhub/tiingo/GoogleSearch etc work behind a local proxy. +def _apply_proxy_env(): + def _set_if_blank(key: str, value: str) -> None: + """ + Set env var if it is missing OR present but empty. + (`os.environ.setdefault` does not override empty strings.) + """ + cur = os.getenv(key) + if cur is None or str(cur).strip() == "": + os.environ[key] = value + + # If user provided explicit proxy URL, honor it. + proxy_url = (os.getenv('PROXY_URL') or '').strip() + + # If user only provided port, build a URL (common local proxy setups). + if not proxy_url: + port = (os.getenv('PROXY_PORT') or '').strip() + if port: + host = (os.getenv('PROXY_HOST') or '127.0.0.1').strip() + scheme = (os.getenv('PROXY_SCHEME') or 'socks5h').strip() + proxy_url = f"{scheme}://{host}:{port}" + + if not proxy_url: + return + + # Standard env vars used by requests and many libraries. + _set_if_blank('ALL_PROXY', proxy_url) + _set_if_blank('HTTP_PROXY', proxy_url) + _set_if_blank('HTTPS_PROXY', proxy_url) + + # CCXT config uses CCXT_PROXY in our codebase. + _set_if_blank('CCXT_PROXY', proxy_url) + +_apply_proxy_env() + +# Add project root to Python path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app import create_app +from app.config.settings import Config + +# Create app instance (for gunicorn use) +# gunicorn -c gunicorn_config.py "run:app" +app = create_app() + + +def main(): + """启动应用""" + # Keep startup messages ASCII-only and short. + print("QuantDinger Python API v2.0.0") + print(f"Service starting at: http://{Config.HOST}:{Config.PORT}") + + # Flask dev server is for local development only. + app.run( + host=Config.HOST, + port=Config.PORT, + debug=Config.DEBUG, + threaded=False + ) + + +if __name__ == '__main__': + main() diff --git a/backend_api_python/scripts/backfill_zero_trades.py b/backend_api_python/scripts/backfill_zero_trades.py new file mode 100644 index 0000000..0c3c5c6 --- /dev/null +++ b/backend_api_python/scripts/backfill_zero_trades.py @@ -0,0 +1,177 @@ +""" +回填历史 qd_strategy_trades 中 price/amount/value 为 0 的记录。 + +背景: +- 某些交易所/订单类型下,执行器回报里 filled_price/filled_amount 可能为 0, + 但交易所实际已成交,导致交易纪律/交易记录显示为 0。 +- 我们现在在 OrderProcessor 中增加了“fetch_order/fetch_my_trades 回补”逻辑,避免新数据再出现该问题。 +- 对历史脏数据,可用 qd_pending_orders 中的 executed_at/filled_price/filled_amount/fee 做近似匹配回填。 + +使用: + python backend_api_python/scripts/backfill_zero_trades.py --strategy-id 43 --since 2025-12-24 --until 2025-12-25 + python backend_api_python/scripts/backfill_zero_trades.py --strategy-id 43 --since 2025-12-24 --until 2025-12-25 --apply + +注意: +- 该脚本按 (strategy_id, symbol, type) + 时间窗口(默认 ±600s) 匹配 qd_pending_orders。 +- 若同一条 trade 匹配到多个候选订单,将选择 executed_at 最接近的那条;若仍不唯一会跳过。 +""" + +from __future__ import annotations + +import argparse +import time +from datetime import datetime, timezone +from typing import Any, Dict, Optional, Tuple, List + +from app.utils.db import get_db_connection + + +def _parse_date_to_ts(s: str) -> int: + s = (s or "").strip() + # 支持 YYYY-MM-DD 或 YYYY/MM/DD + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S"): + try: + dt = datetime.strptime(s, fmt) + # 服务器通常用本地时间写入 int(time.time());这里按本地时间解析 + return int(dt.replace(tzinfo=None).timestamp()) + except Exception: + pass + raise ValueError(f"无法解析日期: {s}") + + +def _fetch_bad_trades(strategy_id: int, since_ts: int, until_ts: int, limit: int) -> List[Dict[str, Any]]: + with get_db_connection() as db: + cursor = db.cursor() + cursor.execute( + """ + SELECT id, strategy_id, symbol, type, price, amount, value, commission, profit, created_at + FROM qd_strategy_trades + WHERE strategy_id = %s + AND created_at BETWEEN %s AND %s + AND ( + price = 0 + OR amount = 0 + OR value = 0 + ) + ORDER BY created_at ASC + LIMIT %s + """, + (strategy_id, since_ts, until_ts, limit), + ) + rows = cursor.fetchall() or [] + cursor.close() + return rows + + +def _find_best_order_match( + strategy_id: int, + symbol: str, + signal_type: str, + trade_ts: int, + window_sec: int, +) -> Optional[Dict[str, Any]]: + lo = int(trade_ts) - int(window_sec) + hi = int(trade_ts) + int(window_sec) + with get_db_connection() as db: + cursor = db.cursor() + cursor.execute( + """ + SELECT id, symbol, signal_type, status, order_id, filled_amount, filled_price, fee, executed_at, created_at + FROM qd_pending_orders + WHERE strategy_id = %s + AND symbol = %s + AND signal_type = %s + AND status = 'completed' + AND executed_at IS NOT NULL + AND executed_at BETWEEN %s AND %s + AND filled_amount > 0 + AND filled_price > 0 + ORDER BY ABS(executed_at - %s) ASC + LIMIT 3 + """, + (strategy_id, symbol, signal_type, lo, hi, trade_ts), + ) + cand = cursor.fetchall() or [] + cursor.close() + if not cand: + return None + # 若最接近的有并列(比如 executed_at 相同),认为不唯一,跳过以免误回填 + if len(cand) >= 2 and abs(int(cand[0]["executed_at"]) - trade_ts) == abs(int(cand[1]["executed_at"]) - trade_ts): + return None + return cand[0] + + +def _update_trade( + trade_id: int, + price: float, + amount: float, + value: float, + commission: Optional[float], + apply: bool, +) -> None: + if not apply: + return + with get_db_connection() as db: + cursor = db.cursor() + cursor.execute( + """ + UPDATE qd_strategy_trades + SET price=%s, amount=%s, value=%s, commission=%s + WHERE id=%s + """, + (price, amount, value, commission, trade_id), + ) + db.commit() + cursor.close() + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--strategy-id", type=int, required=True) + ap.add_argument("--since", type=str, required=True, help="YYYY-MM-DD 或 YYYY/MM/DD") + ap.add_argument("--until", type=str, required=True, help="YYYY-MM-DD 或 YYYY/MM/DD") + ap.add_argument("--window-sec", type=int, default=600, help="匹配窗口,默认±600秒") + ap.add_argument("--limit", type=int, default=500, help="最多处理多少条 trade") + ap.add_argument("--apply", action="store_true", help="真正写库;默认 dry-run 仅打印") + args = ap.parse_args() + + since_ts = _parse_date_to_ts(args.since) + until_ts = _parse_date_to_ts(args.until) + 24 * 3600 - 1 if len(args.until.strip()) <= 10 else _parse_date_to_ts(args.until) + + trades = _fetch_bad_trades(args.strategy_id, since_ts, until_ts, args.limit) + print(f"[scan] bad_trades={len(trades)} strategy_id={args.strategy_id} since={since_ts} until={until_ts} apply={args.apply}") + + fixed = 0 + skipped = 0 + for t in trades: + tid = int(t["id"]) + symbol = t["symbol"] + sig = t["type"] + ts = int(t["created_at"] or 0) + m = _find_best_order_match(args.strategy_id, symbol, sig, ts, args.window_sec) + if not m: + skipped += 1 + print(f"[skip] trade_id={tid} {symbol} {sig} ts={ts} reason=no_unique_match") + continue + + price = float(m["filled_price"]) + amount = float(m["filled_amount"]) + value = float(price * amount) + fee = m.get("fee") + commission = float(fee) if fee is not None else None + + print( + f"[fix] trade_id={tid} {symbol} {sig} ts={ts} -> " + f"price={price} amount={amount} value={value} commission={commission} " + f"(matched pending_id={m['id']} ex_order_id={m.get('order_id')}, executed_at={m.get('executed_at')})" + ) + _update_trade(tid, price, amount, value, commission, args.apply) + fixed += 1 + + print(f"[done] fixed={fixed} skipped={skipped} apply={args.apply}") + + +if __name__ == "__main__": + main() + + diff --git a/backend_api_python/scripts/run_reflection_task.py b/backend_api_python/scripts/run_reflection_task.py new file mode 100644 index 0000000..614f0b2 --- /dev/null +++ b/backend_api_python/scripts/run_reflection_task.py @@ -0,0 +1,21 @@ +import sys +import os + +# 添加项目根目录到 Python 路径 +sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..')) + +from app.services.agents.reflection import ReflectionService + +def main(): + """ + 运行自动反思验证任务 + 建议通过 cron 或 定时任务调度器 每天运行一次 + """ + print("Running Automated Reflection Verification Task...") + service = ReflectionService() + service.run_verification_cycle() + print("Task Completed.") + +if __name__ == "__main__": + main() + diff --git a/backend_api_python/scripts/simulate_trading_executor.py b/backend_api_python/scripts/simulate_trading_executor.py new file mode 100644 index 0000000..211fa81 --- /dev/null +++ b/backend_api_python/scripts/simulate_trading_executor.py @@ -0,0 +1,394 @@ +""" +Local simulation for TradingExecutor. + +Goal: +- Create one indicator strategy using `indicator_python_code/code_test.py` +- Inject deterministic K-lines and a deterministic tick-price sequence +- Run TradingExecutor for a short period +- Verify orders are enqueued into SQLite table `pending_orders` + +Notes: +- This is a local-only test helper. It does NOT talk to real exchanges. +- We intentionally shorten tick interval to speed up the simulation. +""" + +from __future__ import annotations + +import json +import os +import sys +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + + +def _ensure_backend_on_syspath() -> None: + """ + Ensure `backend_api_python/` is on sys.path so `import app...` works + no matter where the script is executed from. + """ + backend_root = Path(__file__).resolve().parents[1] + p = str(backend_root) + if p not in sys.path: + sys.path.insert(0, p) + + +_ensure_backend_on_syspath() + +from app.services.trading_executor import TradingExecutor # noqa: E402 +from app.utils.db import get_db_connection # noqa: E402 + + +def _repo_root() -> Path: + # backend_api_python/scripts/ -> backend_api_python/ + return Path(__file__).resolve().parents[1] + + +def _read_indicator_code() -> str: + # Use the user's current indicator script under repo root. + root = _repo_root().parent # project root (quantdinger/) + p = root / "indicator_python_code" / "code_test.py" + return p.read_text(encoding="utf-8") + + +def _make_klines_1m(base: float = 3000.0, n: int = 200) -> List[Dict[str, Any]]: + """ + Generate synthetic 1m klines to allow SuperTrend to produce buy/sell signals. + We use a downtrend then an uptrend to force a trend flip. + """ + now = int(time.time()) + start = now - n * 60 + + klines: List[Dict[str, Any]] = [] + price = float(base) + for i in range(n): + ts = start + i * 60 + + # Down for first part, then up for second part. + if i < int(n * 0.45): + price *= 0.996 # -0.4% per bar + else: + price *= 1.008 # +0.8% per bar + + o = price * 0.999 + c = price + h = max(o, c) * 1.0005 + l = min(o, c) * 0.9995 + klines.append( + { + "time": int(ts), + "open": float(o), + "high": float(h), + "low": float(l), + "close": float(c), + "volume": 1.0, + } + ) + return klines + + +def _count_signals_for_klines( + ex: TradingExecutor, + indicator_code: str, + klines: List[Dict[str, Any]], + trade_direction: str, + leverage: int, + initial_capital: float, +) -> Dict[str, int]: + df = ex._klines_to_dataframe(klines) + tc = { + "trade_direction": trade_direction, + "leverage": leverage, + "initial_capital": initial_capital, + } + executed_df, _env = ex._execute_indicator_df(indicator_code, df, tc) + if executed_df is None: + return {"buy": 0, "sell": 0} + buy = int(executed_df.get("buy", False).fillna(False).astype(bool).sum()) if "buy" in executed_df.columns else 0 + sell = int(executed_df.get("sell", False).fillna(False).astype(bool).sum()) if "sell" in executed_df.columns else 0 + return {"buy": buy, "sell": sell} + + +def _trim_klines_to_last_signal( + ex: TradingExecutor, + indicator_code: str, + klines: List[Dict[str, Any]], + keep_before: int = 220, + keep_after: int = 0, +) -> List[Dict[str, Any]]: + """ + Trim klines so the last buy/sell signal falls within the last 1~2 bars, + which is what TradingExecutor evaluates. + """ + df = ex._klines_to_dataframe(klines) + tc = {"trade_direction": "both", "leverage": 5, "initial_capital": 1000.0} + executed_df, _env = ex._execute_indicator_df(indicator_code, df, tc) + if executed_df is None or "buy" not in executed_df.columns or "sell" not in executed_df.columns: + return klines + + buy = executed_df["buy"].fillna(False).astype(bool).values.tolist() + sell = executed_df["sell"].fillna(False).astype(bool).values.tolist() + last_idx = -1 + for i in range(len(buy) - 1, -1, -1): + if buy[i] or sell[i]: + last_idx = i + break + if last_idx < 0: + return klines + + start = max(0, last_idx - int(keep_before)) + end = min(len(klines), last_idx + 1 + int(keep_after)) + out = klines[start:end] + if len(out) < 30: + return klines + + # Rebase timestamps so the last bar is close to "now". + # Otherwise TradingExecutor will consider signals expired (it compares signal_timestamp vs time.time()). + try: + now = int(time.time()) + last_ts = int(out[-1].get("time") or 0) + if last_ts > 0: + shift = (now - 60) - last_ts # keep last candle near current time + for row in out: + row["time"] = int(row.get("time") or 0) + int(shift) + except Exception: + pass + + return out + + +def _find_klines_with_signal(ex: TradingExecutor, indicator_code: str) -> List[Dict[str, Any]]: + """ + Try a few synthetic patterns until SuperTrend produces at least one buy/sell. + This makes the simulation deterministic. + """ + patterns = [ + # (down_mult, up_mult, split_ratio) + (0.998, 1.004, 0.45), + (0.996, 1.008, 0.45), + (0.994, 1.012, 0.50), + (0.992, 1.015, 0.55), + (0.990, 1.020, 0.60), + ] + for down_mult, up_mult, split in patterns: + kl = _make_klines_1m(base=3000.0, n=260) + # Rewrite using the requested multipliers (keep timestamps). + price = 3000.0 + for i, row in enumerate(kl): + if i < int(len(kl) * split): + price *= float(down_mult) + else: + price *= float(up_mult) + o = price * 0.999 + c = price + h = max(o, c) * 1.0005 + l = min(o, c) * 0.9995 + row["open"] = float(o) + row["high"] = float(h) + row["low"] = float(l) + row["close"] = float(c) + + cnt = _count_signals_for_klines(ex, indicator_code, kl, "both", 5, 1000.0) + if cnt["buy"] > 0 or cnt["sell"] > 0: + kl2 = _trim_klines_to_last_signal(ex, indicator_code, kl, keep_before=220, keep_after=0) + cnt2 = _count_signals_for_klines(ex, indicator_code, kl2, "both", 5, 1000.0) + print(f"[OK] Found signals with pattern down={down_mult}, up={up_mult}, split={split}: {cnt} -> trimmed={cnt2}, bars={len(kl2)}") + return kl2 + print(f"[MISS] Pattern down={down_mult}, up={up_mult}, split={split}: {cnt}") + + print("[WARN] No buy/sell signals found in tested patterns; falling back to default klines.") + return _make_klines_1m(base=3000.0, n=260) + + +def _insert_strategy( + *, + symbol: str, + indicator_code: str, + initial_capital: float, + leverage: int, + trade_direction: str, + timeframe: str, + stop_loss_pct: float, + take_profit_pct: float, + trailing_enabled: bool, + trailing_activation_pct: float, + trailing_stop_pct: float, +) -> int: + """ + Insert one strategy row into qd_strategies_trading and return its id. + """ + now = int(time.time()) + trading_config = { + "symbol": symbol, + "initial_capital": float(initial_capital), + "leverage": int(leverage), + "trade_direction": str(trade_direction), + "timeframe": str(timeframe), + "market_type": "swap", + # Make entries deterministic in this simulation. + "entry_trigger_mode": "immediate", + "exit_trigger_mode": "immediate", + # Aggressive = allow current candle signals. This makes simulation deterministic. + "signal_mode": "aggressive", + "exit_signal_mode": "aggressive", + # Risk params (config-driven exits) + "stop_loss_pct": float(stop_loss_pct), + "take_profit_pct": float(take_profit_pct), + "trailing_enabled": bool(trailing_enabled), + "trailing_activation_pct": float(trailing_activation_pct), + "trailing_stop_pct": float(trailing_stop_pct), + # Position sizing + "entry_pct": 1.0, + } + indicator_config = { + "indicator_id": 1, + "indicator_name": "code_test.py", + "indicator_code": indicator_code, + } + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + INSERT INTO qd_strategies_trading + (strategy_name, strategy_type, market_category, execution_mode, notification_config, + status, symbol, timeframe, initial_capital, leverage, market_type, + exchange_config, indicator_config, trading_config, ai_model_config, decide_interval, + created_at, updated_at) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "SIM_ETH_1m", + "IndicatorStrategy", + "Crypto", + "signal", + json.dumps({"channels": ["webhook"]}, ensure_ascii=False), + "running", + symbol, + timeframe, + float(initial_capital), + int(leverage), + "swap", + json.dumps({}, ensure_ascii=False), + json.dumps(indicator_config, ensure_ascii=False), + json.dumps(trading_config, ensure_ascii=False), + json.dumps({}, ensure_ascii=False), + 300, + now, + now, + ), + ) + sid = int(cur.lastrowid) + db.commit() + cur.close() + return sid + + +class _SimPriceFeed: + def __init__(self, prices: List[float]): + self._prices = list(prices) + self._idx = 0 + + def next(self) -> float: + if not self._prices: + return 0.0 + if self._idx >= len(self._prices): + return float(self._prices[-1]) + p = float(self._prices[self._idx]) + self._idx += 1 + return p + + +def main() -> None: + # Speed up: 1s tick in simulation (logic is identical to 10s tick). + os.environ.setdefault("STRATEGY_TICK_INTERVAL_SEC", "1") + # Disable in-memory price cache so each tick uses next simulated price. + os.environ.setdefault("PRICE_CACHE_TTL_SEC", "0") + + indicator_code = _read_indicator_code() + + ex = TradingExecutor() + klines = _find_klines_with_signal(ex, indicator_code) + + # Your requested config (note: risk percentages are margin-based, executor divides by leverage). + strategy_id = _insert_strategy( + symbol="ETH/USDT", + indicator_code=indicator_code, + initial_capital=1000.0, + leverage=5, + trade_direction="both", + timeframe="1m", + stop_loss_pct=0.02, # 2% + take_profit_pct=0.0, + trailing_enabled=True, + trailing_activation_pct=0.04, # 4% + trailing_stop_pct=0.01, # 1% + ) + + # Build a price path that triggers trailing: + # - start near last close + # - move up enough to activate trailing (activation is divided by leverage in executor) + # - then pull back enough to hit trailing stop + last_close = float(klines[-1]["close"]) + up = last_close * 1.02 # +2% (enough to activate when leverage=5) + high = last_close * 1.03 + pullback = high * (1 - 0.004) # -0.4% from high (enough to hit trailing when leverage=5) + + prices = [ + last_close, + last_close * 1.005, + last_close * 1.01, + up, + high, + high * 0.999, + pullback, + pullback * 0.999, + ] + feed = _SimPriceFeed(prices) + + # Monkeypatch market data methods (no network). + ex._fetch_latest_kline = lambda _symbol, _tf, limit=500: klines # type: ignore[assignment] + ex._fetch_current_price = lambda _exchange, _symbol, market_type=None: feed.next() # type: ignore[assignment] + + ok = ex.start_strategy(strategy_id) + if not ok: + raise SystemExit("Failed to start strategy thread") + + # Let it run a few ticks. + time.sleep(10) + + # Stop strategy by updating DB status. + with get_db_connection() as db: + cur = db.cursor() + cur.execute("UPDATE qd_strategies_trading SET status = 'stopped' WHERE id = ?", (strategy_id,)) + db.commit() + cur.close() + + # Wait for thread to exit. + time.sleep(2) + + # Print pending orders. + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT id, strategy_id, symbol, signal_type, amount, price, status, created_at + FROM pending_orders + WHERE strategy_id = ? + ORDER BY id ASC + """, + (strategy_id,), + ) + rows = cur.fetchall() or [] + cur.close() + + print(f"strategy_id={strategy_id}, pending_orders={len(rows)}") + for r in rows: + print(r) + + +if __name__ == "__main__": + main() + + diff --git a/backend_api_python/start.sh b/backend_api_python/start.sh new file mode 100644 index 0000000..c088464 --- /dev/null +++ b/backend_api_python/start.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# QuantDinger Python API 启动脚本 + +# 激活虚拟环境(如果使用虚拟环境) +# source venv/bin/activate + +# 检查依赖是否安装 +if ! python -c "import flask" 2>/dev/null; then + echo "正在安装依赖..." + pip install -r requirements.txt +fi + +# 启动服务 +echo "启动 QuantDinger Python API 服务..." +echo "服务地址: http://0.0.0.0:5000" + +# 创建日志目录 +mkdir -p logs + +# 开发环境(使用新的入口文件) +python run.py + +# 生产环境(使用 gunicorn) +# gunicorn -w 4 -b 0.0.0.0:5000 --timeout 120 --access-logfile logs/access.log --error-logfile logs/error.log "run:create_app()" + diff --git a/quantdinger_vue/.browserslistrc b/quantdinger_vue/.browserslistrc new file mode 100644 index 0000000..8f96043 --- /dev/null +++ b/quantdinger_vue/.browserslistrc @@ -0,0 +1,3 @@ +> 1% +last 2 versions +not ie <= 10 diff --git a/quantdinger_vue/.editorconfig b/quantdinger_vue/.editorconfig new file mode 100644 index 0000000..6f77dff --- /dev/null +++ b/quantdinger_vue/.editorconfig @@ -0,0 +1,39 @@ +[*] +charset=utf-8 +end_of_line=lf +insert_final_newline=false +indent_style=space +indent_size=2 + +[{*.ng,*.sht,*.html,*.shtm,*.shtml,*.htm}] +indent_style=space +indent_size=2 + +[{*.jhm,*.xslt,*.xul,*.rng,*.xsl,*.xsd,*.ant,*.tld,*.fxml,*.jrxml,*.xml,*.jnlp,*.wsdl}] +indent_style=space +indent_size=2 + +[{.babelrc,.stylelintrc,jest.config,.eslintrc,.prettierrc,*.json,*.jsb3,*.jsb2,*.bowerrc}] +indent_style=space +indent_size=2 + +[*.svg] +indent_style=space +indent_size=2 + +[*.js.map] +indent_style=space +indent_size=2 + +[*.less] +indent_style=space +indent_size=2 + +[*.vue] +indent_style=space +indent_size=2 + +[{.analysis_options,*.yml,*.yaml}] +indent_style=space +indent_size=2 + diff --git a/quantdinger_vue/.env b/quantdinger_vue/.env new file mode 100644 index 0000000..5a5c073 --- /dev/null +++ b/quantdinger_vue/.env @@ -0,0 +1,3 @@ +NODE_ENV=production +VUE_APP_PREVIEW=false +VUE_APP_API_BASE_URL=/api \ No newline at end of file diff --git a/quantdinger_vue/.env.development b/quantdinger_vue/.env.development new file mode 100644 index 0000000..166c0bc --- /dev/null +++ b/quantdinger_vue/.env.development @@ -0,0 +1,3 @@ +NODE_ENV=development +VUE_APP_PREVIEW=true +VUE_APP_API_BASE_URL=/api \ No newline at end of file diff --git a/quantdinger_vue/.env.preview b/quantdinger_vue/.env.preview new file mode 100644 index 0000000..a9e44c8 --- /dev/null +++ b/quantdinger_vue/.env.preview @@ -0,0 +1,3 @@ +NODE_ENV=production +VUE_APP_PREVIEW=true +VUE_APP_API_BASE_URL=/api \ No newline at end of file diff --git a/quantdinger_vue/.eslintrc.js b/quantdinger_vue/.eslintrc.js new file mode 100644 index 0000000..5bece06 --- /dev/null +++ b/quantdinger_vue/.eslintrc.js @@ -0,0 +1,75 @@ +module.exports = { + root: true, + env: { + node: true + }, + 'extends': [ + 'plugin:vue/strongly-recommended', + '@vue/standard' + ], + rules: { + 'no-console': 'off', + 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', + 'generator-star-spacing': 'off', + 'no-mixed-operators': 0, + 'vue/max-attributes-per-line': [ + 2, + { + 'singleline': 5, + 'multiline': { + 'max': 1, + 'allowFirstLine': false + } + } + ], + 'vue/attribute-hyphenation': 0, + 'vue/html-self-closing': 0, + 'vue/component-name-in-template-casing': 0, + 'vue/html-closing-bracket-spacing': 0, + 'vue/singleline-html-element-content-newline': 0, + 'vue/no-unused-components': 0, + 'vue/multiline-html-element-content-newline': 0, + 'vue/no-use-v-if-with-v-for': 0, + 'vue/html-closing-bracket-newline': 0, + 'vue/no-parsing-error': 0, + 'no-tabs': 0, + 'quotes': [ + 2, + 'single', + { + 'avoidEscape': true, + 'allowTemplateLiterals': true + } + ], + 'semi': [ + 2, + 'never', + { + 'beforeStatementContinuationChars': 'never' + } + ], + 'no-delete-var': 2, + 'prefer-const': [ + 2, + { + 'ignoreReadBeforeAssign': false + } + ], + 'template-curly-spacing': 'off', + 'indent': 'off' + }, + parserOptions: { + parser: 'babel-eslint' + }, + overrides: [ + { + files: [ + '**/__tests__/*.{j,t}s?(x)', + '**/tests/unit/**/*.spec.{j,t}s?(x)' + ], + env: { + jest: true + } + } + ] +} diff --git a/quantdinger_vue/.eslintrc.json b/quantdinger_vue/.eslintrc.json new file mode 100644 index 0000000..ed223c7 --- /dev/null +++ b/quantdinger_vue/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "space-before-function-paren": 0 + } +} diff --git a/quantdinger_vue/.gitattributes b/quantdinger_vue/.gitattributes new file mode 100644 index 0000000..a801add --- /dev/null +++ b/quantdinger_vue/.gitattributes @@ -0,0 +1,11 @@ +public/* linguist-vendored + +# Automatically normalize line endings (to LF) for all text-based files. +* text=auto eol=lf + +# Declare files that will always have CRLF line endings on checkout. +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf + +# Denote all files that are truly binary and should not be modified. +*.{ico,png,jpg,jpeg,gif,webp,svg,woff,woff2} binary \ No newline at end of file diff --git a/quantdinger_vue/.gitignore b/quantdinger_vue/.gitignore new file mode 100644 index 0000000..945cf3c --- /dev/null +++ b/quantdinger_vue/.gitignore @@ -0,0 +1,26 @@ +.DS_Store +node_modules +/dist +# local env files +.env.local +.env.*.local + +# Log files +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw* +package-lock.json + +# Pyodide (large offline runtime assets) +# - Download via `scripts/fetch_pyodide.ps1` +# - If you want fully offline distribution, remove this ignore and commit the assets. +/public/assets/pyodide/ diff --git a/quantdinger_vue/.husky/.gitignore b/quantdinger_vue/.husky/.gitignore new file mode 100644 index 0000000..31354ec --- /dev/null +++ b/quantdinger_vue/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/quantdinger_vue/.lintstagedrc.json b/quantdinger_vue/.lintstagedrc.json new file mode 100644 index 0000000..bcffb34 --- /dev/null +++ b/quantdinger_vue/.lintstagedrc.json @@ -0,0 +1,4 @@ +{ + "*.js": "eslint --fix", + "*.{css,less}": "stylelint --fix" +} \ No newline at end of file diff --git a/quantdinger_vue/.prettierrc b/quantdinger_vue/.prettierrc new file mode 100644 index 0000000..b0e80f7 --- /dev/null +++ b/quantdinger_vue/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 120, + "semi": false, + "singleQuote": true, + "prettier.spaceBeforeFunctionParen": true +} diff --git a/quantdinger_vue/.stylelintrc.js b/quantdinger_vue/.stylelintrc.js new file mode 100644 index 0000000..412e214 --- /dev/null +++ b/quantdinger_vue/.stylelintrc.js @@ -0,0 +1,102 @@ +module.exports = { + processors: [], + plugins: ['stylelint-order'], + extends: [ + 'stylelint-config-standard', + 'stylelint-config-css-modules' + ], + rules: { + 'selector-class-pattern': null, + 'string-quotes': 'single', // 单引号 + 'at-rule-empty-line-before': null, + 'at-rule-no-unknown': null, + 'at-rule-name-case': 'lower', // 指定@规则名的大小写 + 'length-zero-no-unit': true, // 禁止零长度的单位(可自动修复) + 'shorthand-property-no-redundant-values': true, // 简写属性 + 'number-leading-zero': 'never', // 小数不带0 + 'declaration-block-no-duplicate-properties': null, // 禁止声明快重复属性 + 'no-descending-specificity': null, // 禁止在具有较高优先级的选择器后出现被其覆盖的较低优先级的选择器。 + 'selector-max-id': 3, // 限制一个选择器中 ID 选择器的数量 + 'max-nesting-depth': 4, + 'indentation': [2, { // 指定缩进 warning 提醒 + 'severity': 'warning' + }], + 'order/properties-order': [ // 规则顺序 + 'position', + 'top', + 'right', + 'bottom', + 'left', + 'z-index', + 'display', + 'float', + 'width', + 'height', + 'max-width', + 'max-height', + 'min-width', + 'min-height', + 'padding', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'margin', + 'margin-top', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'margin-collapse', + 'margin-top-collapse', + 'margin-right-collapse', + 'margin-bottom-collapse', + 'margin-left-collapse', + 'overflow', + 'overflow-x', + 'overflow-y', + 'clip', + 'clear', + 'font', + 'font-family', + 'font-size', + 'font-smoothing', + 'osx-font-smoothing', + 'font-style', + 'font-weight', + 'line-height', + 'letter-spacing', + 'word-spacing', + 'color', + 'text-align', + 'text-decoration', + 'text-indent', + 'text-overflow', + 'text-rendering', + 'text-size-adjust', + 'text-shadow', + 'text-transform', + 'word-break', + 'word-wrap', + 'white-space', + 'vertical-align', + 'list-style', + 'list-style-type', + 'list-style-position', + 'list-style-image', + 'pointer-events', + 'cursor', + 'background', + 'background-color', + 'border', + 'border-radius', + 'content', + 'outline', + 'outline-offset', + 'opacity', + 'filter', + 'visibility', + 'size', + 'transform' + ] + } +} diff --git a/quantdinger_vue/.travis.yml b/quantdinger_vue/.travis.yml new file mode 100644 index 0000000..a08bfcb --- /dev/null +++ b/quantdinger_vue/.travis.yml @@ -0,0 +1,7 @@ +language: node_js +node_js: + - 10.15.0 +cache: yarn +script: + - yarn + - yarn run lint --no-fix && yarn run build diff --git a/quantdinger_vue/Dockerfile b/quantdinger_vue/Dockerfile new file mode 100644 index 0000000..3ebc1f5 --- /dev/null +++ b/quantdinger_vue/Dockerfile @@ -0,0 +1,6 @@ +FROM nginx + +RUN rm /etc/nginx/conf.d/default.conf + +ADD deploy/nginx.conf /etc/nginx/conf.d/default.conf +COPY dist/ /usr/share/nginx/html/ diff --git a/quantdinger_vue/LICENSE b/quantdinger_vue/LICENSE new file mode 100644 index 0000000..66eef0b --- /dev/null +++ b/quantdinger_vue/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Anan Yang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/quantdinger_vue/README.md b/quantdinger_vue/README.md new file mode 100644 index 0000000..eb88edf --- /dev/null +++ b/quantdinger_vue/README.md @@ -0,0 +1,103 @@ +English | [简体中文](./README.zh-CN.md) + +

Ant Design Vue Pro

+
+An out-of-box UI solution for enterprise applications as a Vue boilerplate. based on Ant Design of Vue +
+ +
+ +[![License](https://img.shields.io/npm/l/package.json.svg?style=flat)](https://github.com/vueComponent/ant-design-vue-pro/blob/master/LICENSE) +[![Release](https://img.shields.io/github/release/vueComponent/ant-design-vue-pro.svg?style=flat)](https://github.com/vueComponent/ant-design-vue-pro/releases/latest) +[![Support Vue Version](https://img.shields.io/badge/Support-Vue2-green?style=flat)](https://github.com/vueComponent/ant-design-vue-pro/releases/latest) +[![Travis branch](https://travis-ci.org/vueComponent/ant-design-vue-pro.svg?branch=master)](https://travis-ci.org/vueComponent/ant-design-vue-pro) + +
+ +- Preview: https://preview.pro.antdv.com +- Home Page: https://pro.antdv.com +- Documentation: https://pro.antdv.com/docs/getting-started +- ChangeLog: https://pro.antdv.com/docs/changelog +- FAQ: https://pro.antdv.com/docs/faq +- Vue3 ProLayout: https://github.com/vueComponent/pro-layout + +Overview +---- + +![dashboard](https://static-2.loacg.com/open/static/github/SP1.png) + +### Env and dependencies + +- node +- yarn +- webpack +- eslint +- @vue/cli +- [ant-design-vue@1.x](https://github.com/vueComponent/ant-design-vue) - Ant Design Of Vue +- [vue-cropper](https://github.com/xyxiao001/vue-cropper) - Picture edit +- [@antv/g2](https://antv.alipay.com/zh-cn/index.html) - AntV G2 +- [Viser-vue](https://viserjs.github.io/docs.html#/viser/guide/installation) - Antv/G2 of Vue + +> Note: [Yarn](https://yarnpkg.com/) package management is recommended, the exact same version loaded with the demo site of this project (yarn.lock) . but you can also use npm + + +### Project setup + +- Clone repo +```bash +git clone https://github.com/vueComponent/ant-design-vue-pro.git +cd ant-design-vue-pro +``` + +- Install dependencies +``` +yarn install +``` + +- Compiles and hot-reloads for development +``` +yarn run serve +``` + +- Compiles and minifies for production +``` +yarn run build +``` + +- Lints and fixes files +``` +yarn run lint +``` + + +### Other + +- **IMPORTANT : Issue feedback !! when opening Issue read [Issue / PR Contributing](https://github.com/vueComponent/ant-design-vue-pro/issues/90)** + +- [Vue-cli3](https://cli.vuejs.org/guide/) used by the project. + +- Disable Eslint (not recommended): remove `eslintConfig` field in `package.json` and `vue.config.js` field `lintOnSave: false` + +- Load on Demand `/src/main.js` L14, in `import './core/lazy_use'`, `import './core/use''`. more [load-on-demand.md](./docs/load-on-demand.md) + +- Customize Theme: [Custom Theme Config (@kokoroli)](https://github.com/kokoroli/antd-awesome/blob/master/docs/Ant_Design_%E6%A0%B7%E5%BC%8F%E8%A6%86%E7%9B%96.md) + +- I18n: [locales (@musnow)](./src/locales/index.js) + +- Production env `mock` is disabled. use `src/mock/index.js` + +- pls use `release` version + +## Browsers support + +Modern browsers and IE10. + +| [IE / Edge](http://godban.github.io/browsers-support-badges/)
IE / Edge | [Firefox](http://godban.github.io/browsers-support-badges/)
Firefox | [Chrome](http://godban.github.io/browsers-support-badges/)
Chrome | [Safari](http://godban.github.io/browsers-support-badges/)
Safari | [Opera](http://godban.github.io/browsers-support-badges/)
Opera | +| --- | --- | --- | --- | --- | +| IE10, Edge | last 2 versions | last 2 versions | last 2 versions | last 2 versions | + + +## Contributors + +This project exists thanks to all the people who contribute. + diff --git a/quantdinger_vue/README.zh-CN.md b/quantdinger_vue/README.zh-CN.md new file mode 100644 index 0000000..c21ecbd --- /dev/null +++ b/quantdinger_vue/README.zh-CN.md @@ -0,0 +1,110 @@ +[English](./README.md) | 简体中文 + +

Ant Design Vue Pro

+
+An out-of-box UI solution for enterprise applications as a Vue boilerplate. based on Ant Design of Vue +
+ +
+ +[![License](https://img.shields.io/npm/l/package.json.svg?style=flat)](https://github.com/vueComponent/ant-design-vue-pro/blob/master/LICENSE) +[![Release](https://img.shields.io/github/release/vueComponent/ant-design-vue-pro.svg?style=flat)](https://github.com/vueComponent/ant-design-vue-pro/releases/latest) +[![Support Vue Version](https://img.shields.io/badge/Support-Vue2-green?style=flat)](https://github.com/vueComponent/ant-design-vue-pro/releases/latest) +[![Travis branch](https://travis-ci.org/vueComponent/ant-design-vue-pro.svg?branch=master)](https://travis-ci.org/vueComponent/ant-design-vue-pro) + +
+ +- 预览: https://preview.pro.antdv.com +- 首页: https://pro.antdv.com +- 文档: https://pro.antdv.com/docs/getting-started +- 更新日志: https://pro.antdv.com/docs/changelog +- 常见问题: https://pro.antdv.com/docs/faq +- Vue3 ProLayout: https://github.com/vueComponent/pro-layout + +Overview +---- + +基于 [Ant Design of Vue](https://vuecomponent.github.io/ant-design-vue/docs/vue/introduce-cn/) 实现的 [Ant Design Pro](https://pro.ant.design/) + +![dashboard](https://static-2.loacg.com/open/static/github/SP1.png) + +环境和依赖 +---- + +- node +- yarn +- webpack +- eslint +- @vue/cli +- [ant-design-vue@1.x](https://github.com/vueComponent/ant-design-vue) - Ant Design Of Vue 实现 +- [vue-cropper](https://github.com/xyxiao001/vue-cropper) - 头像裁剪组件 +- [@antv/g2](https://antv.alipay.com/zh-cn/index.html) - Alipay AntV 数据可视化图表 +- [Viser-vue](https://viserjs.github.io/docs.html#/viser/guide/installation) - antv/g2 封装实现 + +> 请注意,我们强烈建议本项目使用 [Yarn](https://yarnpkg.com/) 包管理工具,这样可以与本项目演示站所加载完全相同的依赖版本 (yarn.lock) 。由于我们没有对依赖进行强制的版本控制,采用非 yarn 包管理进行引入时,可能由于 Pro 所依赖的库已经升级版本而引入了新版本所导致的问题。作者可能会由于时间问题无法及时排查而导致您采用本项目作为基项目而出现问题。 + + + +项目下载和运行 +---- + +- 拉取项目代码 +```bash +git clone https://github.com/vueComponent/ant-design-vue-pro.git +cd ant-design-vue-pro +``` + +- 安装依赖 +``` +yarn install +``` + +- 开发模式运行 +``` +yarn run serve +``` + +- 编译项目 +``` +yarn run build +``` + +- Lints and fixes files +``` +yarn run lint +``` + + + +其他说明 +---- + +- **关于 Issue 反馈 (重要!重要!重要!) 请在开 *Issue* 前,先阅读该内容:[Issue / PR 编写建议](https://github.com/vueComponent/ant-design-vue-pro/issues/90)** + +- 项目使用的 [vue-cli3](https://cli.vuejs.org/guide/), 请确保你所使用的 vue-cli 是新版,并且已经学习 cli 官方文档使用教程 + +- 关闭 Eslint (不推荐) 移除 `package.json` 中 `eslintConfig` 整个节点代码, `vue.config.js` 下的 `lintOnSave` 值改为 `false` + +- 组件按需加载 `/src/main.js` L14 相关代码 `import './core/lazy_use'` / `import './core/use'` + +- [修改 Ant Design 配色 (@kokoroli)](https://github.com/kokoroli/antd-awesome/blob/master/docs/Ant_Design_%E6%A0%B7%E5%BC%8F%E8%A6%86%E7%9B%96.md) + +- I18n: [多语言支持 (@musnow)](./src/locales/index.js) + +- 生产环境默认不加载 `mock`,更多详情请看 `src/mock/index.js` + +- **用于生产环境,请使用 `release` 版本代码,使用 master 代码出现的任何问题需要你自行解决** + +## 浏览器兼容 + +Modern browsers and IE10. + +| [IE / Edge](http://godban.github.io/browsers-support-badges/)
IE / Edge | [Firefox](http://godban.github.io/browsers-support-badges/)
Firefox | [Chrome](http://godban.github.io/browsers-support-badges/)
Chrome | [Safari](http://godban.github.io/browsers-support-badges/)
Safari | [Opera](http://godban.github.io/browsers-support-badges/)
Opera | +| --- | --- | --- | --- | --- | +| IE10, Edge | last 2 versions | last 2 versions | last 2 versions | last 2 versions | + + +## Contributors + +This project exists thanks to all the people who contribute. + diff --git a/quantdinger_vue/babel.config.js b/quantdinger_vue/babel.config.js new file mode 100644 index 0000000..4fe6229 --- /dev/null +++ b/quantdinger_vue/babel.config.js @@ -0,0 +1,30 @@ +const IS_PROD = ['production', 'prod'].includes(process.env.NODE_ENV) +const IS_PREVIEW = process.env.VUE_APP_PREVIEW === 'true' + +const plugins = [] +if (IS_PROD && !IS_PREVIEW) { + // 去除日志的插件, + plugins.push('transform-remove-console') +} + +// lazy load ant-design-vue +// if your use import on Demand, Use this code +plugins.push(['import', { + 'libraryName': 'ant-design-vue', + 'libraryDirectory': 'es', + 'style': true // `style: true` 会加载 less 文件 +}]) + +module.exports = { + presets: [ + '@vue/cli-plugin-babel/preset', + [ + '@babel/preset-env', + { + 'useBuiltIns': 'entry', + 'corejs': 3 + } + ] + ], + plugins +} diff --git a/quantdinger_vue/commitlint.config.js b/quantdinger_vue/commitlint.config.js new file mode 100644 index 0000000..7197cc6 --- /dev/null +++ b/quantdinger_vue/commitlint.config.js @@ -0,0 +1,26 @@ +/** + * feat:新增功能 + * fix:bug 修复 + * docs:文档更新 + * style:不影响程序逻辑的代码修改(修改空白字符,格式缩进,补全缺失的分号等,没有改变代码逻辑) + * refactor:重构代码(既没有新增功能,也没有修复 bug) + * perf:性能, 体验优化 + * test:新增测试用例或是更新现有测试 + * build:主要目的是修改项目构建系统(例如 glup,webpack,rollup 的配置等)的提交 + * ci:主要目的是修改项目继续集成流程(例如 Travis,Jenkins,GitLab CI,Circle等)的提交 + * chore:不属于以上类型的其他类型,比如构建流程, 依赖管理 + * revert:回滚某个更早之前的提交 + */ + +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'type-enum': [ + 2, + 'always', + ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore', 'revert'] + ], + 'subject-full-stop': [0, 'never'], + 'subject-case': [0, 'never'] + } +} diff --git a/quantdinger_vue/config/plugin.config.js b/quantdinger_vue/config/plugin.config.js new file mode 100644 index 0000000..bb51b98 --- /dev/null +++ b/quantdinger_vue/config/plugin.config.js @@ -0,0 +1,49 @@ +const ThemeColorReplacer = require('webpack-theme-color-replacer') +const generate = require('@ant-design/colors/lib/generate').default + +const getAntdSerials = (color) => { + // 淡化(即less的tint) + const lightens = new Array(9).fill().map((t, i) => { + return ThemeColorReplacer.varyColor.lighten(color, i / 10) + }) + const colorPalettes = generate(color) + const rgb = ThemeColorReplacer.varyColor.toNum3(color.replace('#', '')).join(',') + return lightens.concat(colorPalettes).concat(rgb) +} + +const themePluginOption = { + fileName: 'css/theme-colors-[contenthash:8].css', + matchColors: getAntdSerials('#1890ff'), // 主色系列 + // 改变样式选择器,解决样式覆盖问题 + changeSelector (selector) { + switch (selector) { + case '.ant-calendar-today .ant-calendar-date': + return ':not(.ant-calendar-selected-date):not(.ant-calendar-selected-day)' + selector + case '.ant-btn:focus,.ant-btn:hover': + return '.ant-btn:focus:not(.ant-btn-primary):not(.ant-btn-danger),.ant-btn:hover:not(.ant-btn-primary):not(.ant-btn-danger)' + case '.ant-btn.active,.ant-btn:active': + return '.ant-btn.active:not(.ant-btn-primary):not(.ant-btn-danger),.ant-btn:active:not(.ant-btn-primary):not(.ant-btn-danger)' + case '.ant-steps-item-process .ant-steps-item-icon > .ant-steps-icon': + case '.ant-steps-item-process .ant-steps-item-icon>.ant-steps-icon': + return ':not(.ant-steps-item-process)' + selector + // fixed https://github.com/vueComponent/ant-design-vue-pro/issues/876 + case '.ant-steps-item-process .ant-steps-item-icon': + return ':not(.ant-steps-item-custom)' + selector + case '.ant-menu-horizontal>.ant-menu-item-active,.ant-menu-horizontal>.ant-menu-item-open,.ant-menu-horizontal>.ant-menu-item-selected,.ant-menu-horizontal>.ant-menu-item:hover,.ant-menu-horizontal>.ant-menu-submenu-active,.ant-menu-horizontal>.ant-menu-submenu-open,.ant-menu-horizontal>.ant-menu-submenu-selected,.ant-menu-horizontal>.ant-menu-submenu:hover': + case '.ant-menu-horizontal > .ant-menu-item-active,.ant-menu-horizontal > .ant-menu-item-open,.ant-menu-horizontal > .ant-menu-item-selected,.ant-menu-horizontal > .ant-menu-item:hover,.ant-menu-horizontal > .ant-menu-submenu-active,.ant-menu-horizontal > .ant-menu-submenu-open,.ant-menu-horizontal > .ant-menu-submenu-selected,.ant-menu-horizontal > .ant-menu-submenu:hover': + return '.ant-menu-horizontal > .ant-menu-item-active,.ant-menu-horizontal > .ant-menu-item-open,.ant-menu-horizontal > .ant-menu-item-selected,.ant-menu-horizontal:not(.ant-menu-dark) > .ant-menu-item:hover,.ant-menu-horizontal > .ant-menu-submenu-active,.ant-menu-horizontal > .ant-menu-submenu-open,.ant-menu-horizontal:not(.ant-menu-dark) > .ant-menu-submenu-selected,.ant-menu-horizontal:not(.ant-menu-dark) > .ant-menu-submenu:hover' + case '.ant-menu-horizontal > .ant-menu-item-selected > a': + case '.ant-menu-horizontal>.ant-menu-item-selected>a': + return '.ant-menu-horizontal:not(ant-menu-light):not(.ant-menu-dark) > .ant-menu-item-selected > a' + case '.ant-menu-horizontal > .ant-menu-item > a:hover': + case '.ant-menu-horizontal>.ant-menu-item>a:hover': + return '.ant-menu-horizontal:not(ant-menu-light):not(.ant-menu-dark) > .ant-menu-item > a:hover' + default : + return selector + } + } +} + +const createThemeColorReplacerPlugin = () => new ThemeColorReplacer(themePluginOption) + +module.exports = createThemeColorReplacerPlugin diff --git a/quantdinger_vue/config/themePluginConfig.js b/quantdinger_vue/config/themePluginConfig.js new file mode 100644 index 0000000..d35ef8b --- /dev/null +++ b/quantdinger_vue/config/themePluginConfig.js @@ -0,0 +1,115 @@ +export default { + theme: [ + { + key: 'dark', + fileName: 'dark.css', + theme: 'dark' + }, + { + key: '#F5222D', + fileName: '#F5222D.css', + modifyVars: { + '@primary-color': '#F5222D' + } + }, + { + key: '#FA541C', + fileName: '#FA541C.css', + modifyVars: { + '@primary-color': '#FA541C' + } + }, + { + key: '#FAAD14', + fileName: '#FAAD14.css', + modifyVars: { + '@primary-color': '#FAAD14' + } + }, + { + key: '#13C2C2', + fileName: '#13C2C2.css', + modifyVars: { + '@primary-color': '#13C2C2' + } + }, + { + key: '#52C41A', + fileName: '#52C41A.css', + modifyVars: { + '@primary-color': '#52C41A' + } + }, + { + key: '#2F54EB', + fileName: '#2F54EB.css', + modifyVars: { + '@primary-color': '#2F54EB' + } + }, + { + key: '#722ED1', + fileName: '#722ED1.css', + modifyVars: { + '@primary-color': '#722ED1' + } + }, + + { + key: '#F5222D', + theme: 'dark', + fileName: 'dark-#F5222D.css', + modifyVars: { + '@primary-color': '#F5222D' + } + }, + { + key: '#FA541C', + theme: 'dark', + fileName: 'dark-#FA541C.css', + modifyVars: { + '@primary-color': '#FA541C' + } + }, + { + key: '#FAAD14', + theme: 'dark', + fileName: 'dark-#FAAD14.css', + modifyVars: { + '@primary-color': '#FAAD14' + } + }, + { + key: '#13C2C2', + theme: 'dark', + fileName: 'dark-#13C2C2.css', + modifyVars: { + '@primary-color': '#13C2C2' + } + }, + { + key: '#52C41A', + theme: 'dark', + fileName: 'dark-#52C41A.css', + modifyVars: { + '@primary-color': '#52C41A' + } + }, + { + key: '#2F54EB', + theme: 'dark', + fileName: 'dark-#2F54EB.css', + modifyVars: { + '@primary-color': '#2F54EB' + } + }, + { + key: '#722ED1', + theme: 'dark', + fileName: 'dark-#722ED1.css', + modifyVars: { + '@primary-color': '#722ED1' + } + } + ] +} diff --git a/quantdinger_vue/deploy/caddy.conf b/quantdinger_vue/deploy/caddy.conf new file mode 100644 index 0000000..acd5c0e --- /dev/null +++ b/quantdinger_vue/deploy/caddy.conf @@ -0,0 +1,9 @@ +0.0.0.0:80 { + gzip + root /usr/share/nginx/html + + rewrite { + r .* + to {path} / + } +} \ No newline at end of file diff --git a/quantdinger_vue/deploy/nginx.conf b/quantdinger_vue/deploy/nginx.conf new file mode 100644 index 0000000..5ddb66d --- /dev/null +++ b/quantdinger_vue/deploy/nginx.conf @@ -0,0 +1,24 @@ +server { + listen 80; + server_name _; + # gzip config + gzip on; + gzip_min_length 1k; + gzip_comp_level 6; + gzip_types text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml; + gzip_vary on; + gzip_disable "MSIE [1-6]\."; + + root /usr/share/nginx/html; + include /etc/nginx/mime.types; + + location / { + try_files $uri $uri/ /index.html; + } + +# location /api { +# proxy_pass https://preview.pro.antdv.com/api; +# proxy_set_header X-Forwarded-Proto $scheme; +# proxy_set_header X-Real-IP $remote_addr; +# } +} diff --git a/quantdinger_vue/jest.config.js b/quantdinger_vue/jest.config.js new file mode 100644 index 0000000..29fee32 --- /dev/null +++ b/quantdinger_vue/jest.config.js @@ -0,0 +1,23 @@ +module.exports = { + moduleFileExtensions: [ + 'js', + 'jsx', + 'json', + 'vue' + ], + transform: { + '^.+\\.vue$': 'vue-jest', + '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub', + '^.+\\.jsx?$': 'babel-jest' + }, + moduleNameMapper: { + '^@/(.*)$': '/src/$1' + }, + snapshotSerializers: [ + 'jest-serializer-vue' + ], + testMatch: [ + '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)' + ], + testURL: 'http://localhost/' +} diff --git a/quantdinger_vue/jsconfig.json b/quantdinger_vue/jsconfig.json new file mode 100644 index 0000000..68fb8a4 --- /dev/null +++ b/quantdinger_vue/jsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es6", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "exclude": ["node_modules", "dist"], + "include": ["src/**/*"] +} \ No newline at end of file diff --git a/quantdinger_vue/package.json b/quantdinger_vue/package.json new file mode 100644 index 0000000..ecba914 --- /dev/null +++ b/quantdinger_vue/package.json @@ -0,0 +1,103 @@ +{ + "name": "vue-antd-pro", + "version": "3.0.4", + "private": true, + "scripts": { + "serve": "vue-cli-service serve --no-lint", + "build": "vue-cli-service build --no-lint", + "test:unit": "vue-cli-service test:unit", + "lint": "vue-cli-service lint", + "build:preview": "vue-cli-service build --no-module --mode preview", + "lint:nofix": "vue-cli-service lint --no-fix", + "lint:js": "eslint src/**/*.js --fix", + "lint:css": "stylelint src/**/*.*ss --fix --custom-syntax postcss-less", + "prepare": "husky install" + }, + "dependencies": { + "@ant-design-vue/pro-layout": "^1.0.12", + "@antv/data-set": "^0.10.2", + "@iconify/vue2": "^2.1.0", + "ant-design-vue": "^1.7.8", + "axios": "^0.26.1", + "babel-loader": "8", + "codemirror": "^5.65.16", + "core-js": "^3.21.1", + "crypto-js": "^4.2.0", + "echarts": "^6.0.0", + "enquire.js": "^2.1.6", + "klinecharts": "^9.8.0", + "lightweight-charts": "^5.0.8", + "lodash.clonedeep": "^4.5.0", + "lodash.get": "^4.4.2", + "lodash.pick": "^4.4.0", + "md5": "^2.3.0", + "mockjs2": "1.0.8", + "moment": "^2.29.2", + "nprogress": "^0.2.0", + "store": "^2.0.12", + "viser-vue": "^2.4.8", + "vue": "^2.6.14", + "vue-clipboard2": "^0.2.1", + "vue-cropper": "0.4.9", + "vue-i18n": "^8.27.1", + "vue-quill-editor": "^3.0.6", + "vue-router": "^3.5.3", + "vue-svg-component-runtime": "^1.0.1", + "vue-template-compiler": "^2.6.14", + "vuex": "^3.6.2", + "wangeditor": "^3.1.1" + }, + "devDependencies": { + "@ant-design/colors": "^3.2.2", + "@commitlint/cli": "^12.1.4", + "@commitlint/config-conventional": "^12.1.4", + "@vue/babel-helper-vue-jsx-merge-props": "^1.2.1", + "@vue/cli-plugin-babel": "~5.0.8", + "@vue/cli-plugin-eslint": "~5.0.8", + "@vue/cli-plugin-router": "~5.0.8", + "@vue/cli-plugin-unit-jest": "~5.0.8", + "@vue/cli-plugin-vuex": "~5.0.8", + "@vue/cli-service": "~5.0.8", + "@vue/eslint-config-standard": "^4.0.0", + "@vue/test-utils": "^1.3.0", + "babel-eslint": "^10.1.0", + "babel-plugin-import": "^1.13.3", + "babel-plugin-transform-remove-console": "^6.9.4", + "commitizen": "^4.2.4", + "cz-conventional-changelog": "^3.3.0", + "eslint": "^7.0.0", + "eslint-plugin-html": "^5.0.5", + "eslint-plugin-vue": "^5.2.3", + "file-loader": "^6.2.0", + "git-revision-webpack-plugin": "^3.0.6", + "husky": "^6.0.0", + "less": "^3.13.1", + "less-loader": "^5.0.0", + "lint-staged": "^12.5.0", + "postcss": "^8.3.5", + "postcss-less": "^6.0.0", + "regenerator-runtime": "^0.13.9", + "stylelint": "^14.8.5", + "stylelint-config-css-modules": "^4.1.0", + "stylelint-config-recess-order": "^3.0.0", + "stylelint-config-recommended": "^7.0.0", + "stylelint-config-standard": "^25.0.0", + "stylelint-order": "^5.0.0", + "vue-svg-icon-loader": "^2.1.1", + "vue-svg-loader": "0.16.0", + "webpack-theme-color-replacer": "^1.3.26" + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } + }, + "husky": { + "hooks": { + "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" + } + }, + "gitHooks": { + "pre-commit": "lint-staged" + } +} diff --git a/quantdinger_vue/pnpm-lock.yaml b/quantdinger_vue/pnpm-lock.yaml new file mode 100644 index 0000000..85a5224 --- /dev/null +++ b/quantdinger_vue/pnpm-lock.yaml @@ -0,0 +1,11874 @@ +lockfileVersion: 5.4 + +specifiers: + '@ant-design-vue/pro-layout': ^1.0.12 + '@ant-design/colors': ^3.2.2 + '@antv/data-set': ^0.10.2 + '@commitlint/cli': ^12.1.4 + '@commitlint/config-conventional': ^12.1.4 + '@vue/babel-helper-vue-jsx-merge-props': ^1.2.1 + '@vue/cli-plugin-babel': ~5.0.8 + '@vue/cli-plugin-eslint': ~5.0.8 + '@vue/cli-plugin-router': ~5.0.8 + '@vue/cli-plugin-unit-jest': ~5.0.8 + '@vue/cli-plugin-vuex': ~5.0.8 + '@vue/cli-service': ~5.0.8 + '@vue/eslint-config-standard': ^4.0.0 + '@vue/test-utils': ^1.3.0 + ant-design-vue: ^1.7.8 + axios: ^0.26.1 + babel-eslint: ^10.1.0 + babel-loader: '8' + babel-plugin-import: ^1.13.3 + babel-plugin-transform-remove-console: ^6.9.4 + commitizen: ^4.2.4 + core-js: ^3.21.1 + cz-conventional-changelog: ^3.3.0 + enquire.js: ^2.1.6 + eslint: ^7.0.0 + eslint-plugin-html: ^5.0.5 + eslint-plugin-vue: ^5.2.3 + file-loader: ^6.2.0 + git-revision-webpack-plugin: ^3.0.6 + husky: ^6.0.0 + less: ^3.13.1 + less-loader: ^5.0.0 + lint-staged: ^12.5.0 + lodash.clonedeep: ^4.5.0 + lodash.get: ^4.4.2 + lodash.pick: ^4.4.0 + md5: ^2.3.0 + mockjs2: 1.0.8 + moment: ^2.29.2 + nprogress: ^0.2.0 + postcss: ^8.3.5 + postcss-less: ^6.0.0 + regenerator-runtime: ^0.13.9 + store: ^2.0.12 + stylelint: ^14.8.5 + stylelint-config-css-modules: ^4.1.0 + stylelint-config-recess-order: ^3.0.0 + stylelint-config-recommended: ^7.0.0 + stylelint-config-standard: ^25.0.0 + stylelint-order: ^5.0.0 + viser-vue: ^2.4.8 + vue: ^2.6.14 + vue-clipboard2: ^0.2.1 + vue-cropper: 0.4.9 + vue-i18n: ^8.27.1 + vue-quill-editor: ^3.0.6 + vue-router: ^3.5.3 + vue-svg-component-runtime: ^1.0.1 + vue-svg-icon-loader: ^2.1.1 + vue-svg-loader: 0.16.0 + vue-template-compiler: ^2.6.14 + vuex: ^3.6.2 + wangeditor: ^3.1.1 + webpack-theme-color-replacer: ^1.3.26 + +dependencies: + '@ant-design-vue/pro-layout': 1.0.13_vs2ghphrx2fd5pa7rzph6go6hy + '@antv/data-set': 0.10.2 + ant-design-vue: 1.7.8_42puyn3dcxirnpdjnosl7pbb6a + axios: 0.26.1 + babel-loader: 8.2.5 + core-js: 3.24.1 + enquire.js: 2.1.6 + lodash.clonedeep: 4.5.0 + lodash.get: 4.4.2 + lodash.pick: 4.4.0 + md5: 2.3.0 + mockjs2: 1.0.8 + moment: 2.29.4 + nprogress: 0.2.0 + store: 2.0.12 + viser-vue: 2.4.8_vue@2.7.10 + vue: 2.7.10 + vue-clipboard2: 0.2.1 + vue-cropper: 0.4.9 + vue-i18n: 8.27.2_vue@2.7.10 + vue-quill-editor: 3.0.6 + vue-router: 3.6.3_vue@2.7.10 + vue-svg-component-runtime: 1.0.1_vue@2.7.10 + vue-template-compiler: 2.7.10 + vuex: 3.6.2_vue@2.7.10 + wangeditor: 3.1.1 + +devDependencies: + '@ant-design/colors': 3.2.2 + '@commitlint/cli': 12.1.4 + '@commitlint/config-conventional': 12.1.4 + '@vue/babel-helper-vue-jsx-merge-props': 1.2.1 + '@vue/cli-plugin-babel': 5.0.8_2pvo7dpnbf4o5og67iqwgx7rn4 + '@vue/cli-plugin-eslint': 5.0.8_yvnkslq6l73c7sesu7e5hzum2a + '@vue/cli-plugin-router': 5.0.8_@vue+cli-service@5.0.8 + '@vue/cli-plugin-unit-jest': 5.0.8_@vue+cli-service@5.0.8 + '@vue/cli-plugin-vuex': 5.0.8_@vue+cli-service@5.0.8 + '@vue/cli-service': 5.0.8_26d7a3va3smzyfrmey4itzvxkm + '@vue/eslint-config-standard': 4.0.0_eslint@7.32.0 + '@vue/test-utils': 1.3.0_42puyn3dcxirnpdjnosl7pbb6a + babel-eslint: 10.1.0_eslint@7.32.0 + babel-plugin-import: 1.13.5 + babel-plugin-transform-remove-console: 6.9.4 + commitizen: 4.2.5 + cz-conventional-changelog: 3.3.0 + eslint: 7.32.0 + eslint-plugin-html: 5.0.5 + eslint-plugin-vue: 5.2.3_eslint@7.32.0 + file-loader: 6.2.0 + git-revision-webpack-plugin: 3.0.6 + husky: 6.0.0 + less: 3.13.1 + less-loader: 5.0.0_less@3.13.1 + lint-staged: 12.5.0 + postcss: 8.4.16 + postcss-less: 6.0.0_postcss@8.4.16 + regenerator-runtime: 0.13.9 + stylelint: 14.11.0 + stylelint-config-css-modules: 4.1.0_stylelint@14.11.0 + stylelint-config-recess-order: 3.0.0_stylelint@14.11.0 + stylelint-config-recommended: 7.0.0_stylelint@14.11.0 + stylelint-config-standard: 25.0.0_stylelint@14.11.0 + stylelint-order: 5.0.0_stylelint@14.11.0 + vue-svg-icon-loader: 2.1.1_7ijf3dzidy2s7qy7g4vlqxnlga + vue-svg-loader: 0.16.0_abjhqgwglfwb46q2jh7n36fu5u + webpack-theme-color-replacer: 1.4.1 + +packages: + + /@achrinza/node-ipc/9.2.5: + resolution: {integrity: sha512-kBX7Ay911iXZ3VZ1pYltj3Rfu7Ow9H7sK4H4RSfWIfWR2JKNB40K808wppoRIEzE2j2hXLU+r6TJgCAliCGhyQ==} + engines: {node: 8 || 9 || 10 || 11 || 12 || 13 || 14 || 15 || 16 || 17 || 18} + dependencies: + '@node-ipc/js-queue': 2.0.3 + event-pubsub: 4.3.0 + js-message: 1.0.7 + dev: true + + /@ampproject/remapping/2.2.0: + resolution: {integrity: sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/gen-mapping': 0.1.1 + '@jridgewell/trace-mapping': 0.3.15 + dev: true + + /@ant-design-vue/pro-layout/1.0.13_vs2ghphrx2fd5pa7rzph6go6hy: + resolution: {integrity: sha512-O0Kixs9C0noECnAVHxnhvkI0MKWqnAJaV9H8O6tr3ad3Bpr4IAPd+f7y9o3y57eDhfZCPaLR3quM4nxfHSgYWA==} + peerDependencies: + ant-design-vue: ^1.6.2 + vue: '>=2.6.0' + vue-template-compiler: '>=2.6.0' + dependencies: + ant-design-vue: 1.7.8_42puyn3dcxirnpdjnosl7pbb6a + classnames: 2.3.1 + insert-css: 2.0.0 + lodash: 4.17.21 + omit.js: 1.0.2 + umi-request: 1.4.0 + vue: 2.7.10 + vue-container-query: 0.1.0 + vue-copy-to-clipboard: 1.0.3_vue@2.7.10 + vue-template-compiler: 2.7.10 + dev: false + + /@ant-design/colors/3.2.2: + resolution: {integrity: sha512-YKgNbG2dlzqMhA9NtI3/pbY16m3Yl/EeWBRa+lB1X1YaYxHrxNexiQYCLTWO/uDvAjLFMEDU+zR901waBtMtjQ==} + dependencies: + tinycolor2: 1.4.2 + + /@ant-design/icons-vue/2.0.0_4oqadidiaueivzvlpdylfsa7xi: + resolution: {integrity: sha512-2c0QQE5hL4N48k5NkPG5sdpMl9YnvyNhf0U7YkdZYDlLnspoRU7vIA0UK9eHBs6OpFLcJB6o8eJrIl2ajBskPg==} + peerDependencies: + '@ant-design/icons': ^2.0.0 + vue: '>=2.5.0' + vue-template-compiler: '>=2.5.0' + dependencies: + '@ant-design/colors': 3.2.2 + '@ant-design/icons': 2.1.1 + babel-runtime: 6.26.0 + vue: 2.7.10 + vue-template-compiler: 2.7.10 + dev: false + + /@ant-design/icons/2.1.1: + resolution: {integrity: sha512-jCH+k2Vjlno4YWl6g535nHR09PwCEmTBKAG6VqF+rhkrSPRLfgpU2maagwbZPLjaHuU5Jd1DFQ2KJpQuI6uG8w==} + dev: false + + /@antv/adjust/0.1.1: + resolution: {integrity: sha512-9FaMOyBlM4AgoRL0b5o0VhEKAYkexBNUrxV8XmpHU/9NBPJONBOB/NZUlQDqxtLItrt91tCfbAuMQmF529UX2Q==} + dependencies: + '@antv/util': 1.3.1 + dev: false + + /@antv/attr/0.1.2: + resolution: {integrity: sha512-QXjP+T2I+pJQcwZx1oCA4tipG43vgeCeKcGGKahlcxb71OBAzjJZm1QbF4frKXcnOqRkxVXtCr70X9TRair3Ew==} + dependencies: + '@antv/util': 1.3.1 + dev: false + + /@antv/component/0.3.10: + resolution: {integrity: sha512-8HLkgdhc0jXrnNrkaACPrWx2JB/51VGscL9t0pH2xoLdxiDQVtTUad2geWxbac5k/ZZHG+bDPWWb83CZIR9A9w==} + dependencies: + '@antv/attr': 0.1.2 + '@antv/g': 3.3.6 + '@antv/util': 1.3.1 + wolfy87-eventemitter: 5.1.0 + dev: false + + /@antv/coord/0.1.0: + resolution: {integrity: sha512-W1R8h3Jfb3AfMBVfCreFPMVetgEYuwHBIGn0+d3EgYXe2ckOF8XWjkpGF1fZhOMHREMr+Gt27NGiQh8yBdLUgg==} + dependencies: + '@antv/util': 1.3.1 + dev: false + + /@antv/data-set/0.10.2: + resolution: {integrity: sha512-FFWG5tiTiFiUrLDRwulraU5XfOdDjkYOlZna+AMT9FJw406D/gfS8eXM9YibscBH28M/+KLAVO8xEwuD1sc3bw==} + dependencies: + '@antv/hierarchy': 0.4.0 + '@antv/util': 1.3.1 + d3-array: 1.2.4 + d3-composite-projections: 1.2.3 + d3-dsv: 1.0.10 + d3-geo: 1.6.4 + d3-geo-projection: 2.1.2 + d3-hexjson: 1.0.1 + d3-hierarchy: 1.1.9 + d3-sankey: 0.7.1 + d3-voronoi: 1.1.4 + dagre: 0.8.5 + point-at-length: 1.0.2 + regression: 2.0.1 + simple-statistics: 6.1.1 + topojson-client: 3.0.1 + wolfy87-eventemitter: 5.1.0 + dev: false + + /@antv/g/3.3.6: + resolution: {integrity: sha512-2GtyTz++s0BbN6s0ZL2/nrqGYCkd52pVoNH92YkrTdTOvpO6Z4DNoo6jGVgZdPX6Nzwli6yduC8MinVAhE8X6g==} + dependencies: + '@antv/gl-matrix': 2.7.1 + '@antv/util': 1.3.1 + d3-ease: 1.0.7 + d3-interpolate: 1.1.6 + d3-timer: 1.0.10 + wolfy87-eventemitter: 5.1.0 + dev: false + + /@antv/g/3.4.10: + resolution: {integrity: sha512-pKy/L1SyRBsXuujdkggqrdBA0/ciAgHiArYBdIJsxHRxCneUP01wGwHdGfDayh2+S0gcSBHynjhoEahsaZaLkw==} + dependencies: + '@antv/gl-matrix': 2.7.1 + '@antv/util': 1.3.1 + d3-ease: 1.0.7 + d3-interpolate: 1.1.6 + d3-timer: 1.0.10 + detect-browser: 5.3.0 + dev: false + + /@antv/g2-brush/0.0.2: + resolution: {integrity: sha512-7O9szwem19nmEgReXhFB8kVLRaz8J5MHvrzDSDY36YaBOaHSWRGHnvYt2KkkPqgWtHtLY1srssk4X/UmP5govA==} + dev: false + + /@antv/g2-plugin-slider/2.1.1_@antv+g2@3.5.19: + resolution: {integrity: sha512-nB678VEGG3FkrvkDDFADAKjLQIeXzITEYqey5oeOpbf0vT5jOa55lQDyJDZ79cK8PmU/Hz6VPeSb3CNQBA+/FQ==} + peerDependencies: + '@antv/g2': '>=3.2.8' + dependencies: + '@antv/g2': 3.5.19 + dev: false + + /@antv/g2/3.5.19: + resolution: {integrity: sha512-OWWDJof1ghfsxDYO20TxVF9TUhDsyOE/yzbSdSu+N9Ft1zQxKJQlgG43/FO+rOsdC/k1dXoYOBRPQ7kk5EBaJA==} + dependencies: + '@antv/adjust': 0.1.1 + '@antv/attr': 0.1.2 + '@antv/component': 0.3.10 + '@antv/coord': 0.1.0 + '@antv/g': 3.4.10 + '@antv/scale': 0.1.5 + '@antv/util': 1.3.1 + core-js: 2.6.12 + venn.js: 0.2.20 + wolfy87-eventemitter: 5.1.0 + dev: false + + /@antv/gl-matrix/2.7.1: + resolution: {integrity: sha512-oOWcVNlpELIKi9x+Mm1Vwbz8pXfkbJKykoCIOJ/dNK79hSIANbpXJ5d3Rra9/wZqK6MC961B7sybFhPlLraT3Q==} + dev: false + + /@antv/hierarchy/0.4.0: + resolution: {integrity: sha512-ols+m+Z8QA4895SWMTOSjVImOX4tEbWQTwJ0NE+WATc0WLSKs6D9y2yaR+ZWt6P60BMGVIKS6lIfabO3CwGgnQ==} + dependencies: + '@antv/util': 1.3.1 + dev: false + + /@antv/scale/0.1.5: + resolution: {integrity: sha512-7RAu4iH5+Hk21h6+aBMiDTfmLf4IibK2SWjx/+E4f4AXRpqucO+8u7IbZdFkakAWxvqhJtN3oePJuTKqOMcmlg==} + dependencies: + '@antv/util': 1.3.1 + fecha: 2.3.3 + dev: false + + /@antv/util/1.3.1: + resolution: {integrity: sha512-cbUta0hIJrKEaW3eKoGarz3Ita+9qUPF2YzTj8A6wds/nNiy20G26ztIWHU+5ThLc13B1n5Ik52LbaCaeg9enA==} + dependencies: + '@antv/gl-matrix': 2.7.1 + dev: false + + /@babel/code-frame/7.12.11: + resolution: {integrity: sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==} + dependencies: + '@babel/highlight': 7.18.6 + dev: true + + /@babel/code-frame/7.18.6: + resolution: {integrity: sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.18.6 + dev: true + + /@babel/compat-data/7.18.13: + resolution: {integrity: sha512-5yUzC5LqyTFp2HLmDoxGQelcdYgSpP9xsnMWBphAscOdFrHSAVbLNzWiy32sVNDqJRDiJK6klfDnAgu6PAGSHw==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/core/7.18.13: + resolution: {integrity: sha512-ZisbOvRRusFktksHSG6pjj1CSvkPkcZq/KHD45LAkVP/oiHJkNBZWfpvlLmX8OtHDG8IuzsFlVRWo08w7Qxn0A==} + engines: {node: '>=6.9.0'} + dependencies: + '@ampproject/remapping': 2.2.0 + '@babel/code-frame': 7.18.6 + '@babel/generator': 7.18.13 + '@babel/helper-compilation-targets': 7.18.9_@babel+core@7.18.13 + '@babel/helper-module-transforms': 7.18.9 + '@babel/helpers': 7.18.9 + '@babel/parser': 7.18.13 + '@babel/template': 7.18.10 + '@babel/traverse': 7.18.13 + '@babel/types': 7.18.13 + convert-source-map: 1.8.0 + debug: 4.3.4 + gensync: 1.0.0-beta.2 + json5: 2.2.1 + semver: 6.3.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/generator/7.18.13: + resolution: {integrity: sha512-CkPg8ySSPuHTYPJYo7IRALdqyjM9HCbt/3uOBEFbzyGVP6Mn8bwFPB0jX6982JVNBlYzM1nnPkfjuXSOPtQeEQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.13 + '@jridgewell/gen-mapping': 0.3.2 + jsesc: 2.5.2 + dev: true + + /@babel/helper-annotate-as-pure/7.18.6: + resolution: {integrity: sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.13 + dev: true + + /@babel/helper-builder-binary-assignment-operator-visitor/7.18.9: + resolution: {integrity: sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-explode-assignable-expression': 7.18.6 + '@babel/types': 7.18.13 + dev: true + + /@babel/helper-compilation-targets/7.18.9: + resolution: {integrity: sha512-tzLCyVmqUiFlcFoAPLA/gL9TeYrF61VLNtb+hvkuVaB5SUjW7jcfrglBIX1vUIoT7CLP3bBlIMeyEsIl2eFQNg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/compat-data': 7.18.13 + '@babel/helper-validator-option': 7.18.6 + browserslist: 4.21.3 + semver: 6.3.0 + dev: true + + /@babel/helper-compilation-targets/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-tzLCyVmqUiFlcFoAPLA/gL9TeYrF61VLNtb+hvkuVaB5SUjW7jcfrglBIX1vUIoT7CLP3bBlIMeyEsIl2eFQNg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/compat-data': 7.18.13 + '@babel/core': 7.18.13 + '@babel/helper-validator-option': 7.18.6 + browserslist: 4.21.3 + semver: 6.3.0 + dev: true + + /@babel/helper-create-class-features-plugin/7.18.13_@babel+core@7.18.13: + resolution: {integrity: sha512-hDvXp+QYxSRL+23mpAlSGxHMDyIGChm0/AwTfTAAK5Ufe40nCsyNdaYCGuK91phn/fVu9kqayImRDkvNAgdrsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-annotate-as-pure': 7.18.6 + '@babel/helper-environment-visitor': 7.18.9 + '@babel/helper-function-name': 7.18.9 + '@babel/helper-member-expression-to-functions': 7.18.9 + '@babel/helper-optimise-call-expression': 7.18.6 + '@babel/helper-replace-supers': 7.18.9 + '@babel/helper-split-export-declaration': 7.18.6 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-create-regexp-features-plugin/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-7LcpH1wnQLGrI+4v+nPp+zUvIkF9x0ddv1Hkdue10tg3gmRnLy97DXh4STiOf1qeIInyD69Qv5kKSZzKD8B/7A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-annotate-as-pure': 7.18.6 + regexpu-core: 5.1.0 + dev: true + + /@babel/helper-define-polyfill-provider/0.3.2_@babel+core@7.18.13: + resolution: {integrity: sha512-r9QJJ+uDWrd+94BSPcP6/de67ygLtvVy6cK4luE6MOuDsZIdoaPBnfSpbO/+LTifjPckbKXRuI9BB/Z2/y3iTg==} + peerDependencies: + '@babel/core': ^7.4.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-compilation-targets': 7.18.9_@babel+core@7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + debug: 4.3.4 + lodash.debounce: 4.0.8 + resolve: 1.22.1 + semver: 6.3.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-environment-visitor/7.18.9: + resolution: {integrity: sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-explode-assignable-expression/7.18.6: + resolution: {integrity: sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.13 + dev: true + + /@babel/helper-function-name/7.18.9: + resolution: {integrity: sha512-fJgWlZt7nxGksJS9a0XdSaI4XvpExnNIgRP+rVefWh5U7BL8pPuir6SJUmFKRfjWQ51OtWSzwOxhaH/EBWWc0A==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.18.10 + '@babel/types': 7.18.13 + dev: true + + /@babel/helper-hoist-variables/7.18.6: + resolution: {integrity: sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.13 + dev: true + + /@babel/helper-member-expression-to-functions/7.18.9: + resolution: {integrity: sha512-RxifAh2ZoVU67PyKIO4AMi1wTenGfMR/O/ae0CCRqwgBAt5v7xjdtRw7UoSbsreKrQn5t7r89eruK/9JjYHuDg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.13 + dev: true + + /@babel/helper-module-imports/7.18.6: + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.13 + dev: true + + /@babel/helper-module-transforms/7.18.9: + resolution: {integrity: sha512-KYNqY0ICwfv19b31XzvmI/mfcylOzbLtowkw+mfvGPAQ3kfCnMLYbED3YecL5tPd8nAYFQFAd6JHp2LxZk/J1g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-environment-visitor': 7.18.9 + '@babel/helper-module-imports': 7.18.6 + '@babel/helper-simple-access': 7.18.6 + '@babel/helper-split-export-declaration': 7.18.6 + '@babel/helper-validator-identifier': 7.18.6 + '@babel/template': 7.18.10 + '@babel/traverse': 7.18.13 + '@babel/types': 7.18.13 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-optimise-call-expression/7.18.6: + resolution: {integrity: sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.13 + dev: true + + /@babel/helper-plugin-utils/7.18.9: + resolution: {integrity: sha512-aBXPT3bmtLryXaoJLyYPXPlSD4p1ld9aYeR+sJNOZjJJGiOpb+fKfh3NkcCu7J54nUJwCERPBExCCpyCOHnu/w==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-remap-async-to-generator/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-annotate-as-pure': 7.18.6 + '@babel/helper-environment-visitor': 7.18.9 + '@babel/helper-wrap-function': 7.18.11 + '@babel/types': 7.18.13 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-replace-supers/7.18.9: + resolution: {integrity: sha512-dNsWibVI4lNT6HiuOIBr1oyxo40HvIVmbwPUm3XZ7wMh4k2WxrxTqZwSqw/eEmXDS9np0ey5M2bz9tBmO9c+YQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-environment-visitor': 7.18.9 + '@babel/helper-member-expression-to-functions': 7.18.9 + '@babel/helper-optimise-call-expression': 7.18.6 + '@babel/traverse': 7.18.13 + '@babel/types': 7.18.13 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-simple-access/7.18.6: + resolution: {integrity: sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.13 + dev: true + + /@babel/helper-skip-transparent-expression-wrappers/7.18.9: + resolution: {integrity: sha512-imytd2gHi3cJPsybLRbmFrF7u5BIEuI2cNheyKi3/iOBC63kNn3q8Crn2xVuESli0aM4KYsyEqKyS7lFL8YVtw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.13 + dev: true + + /@babel/helper-split-export-declaration/7.18.6: + resolution: {integrity: sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.13 + dev: true + + /@babel/helper-string-parser/7.18.10: + resolution: {integrity: sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw==} + engines: {node: '>=6.9.0'} + + /@babel/helper-validator-identifier/7.18.6: + resolution: {integrity: sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==} + engines: {node: '>=6.9.0'} + + /@babel/helper-validator-option/7.18.6: + resolution: {integrity: sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-wrap-function/7.18.11: + resolution: {integrity: sha512-oBUlbv+rjZLh2Ks9SKi4aL7eKaAXBWleHzU89mP0G6BMUlRxSckk9tSIkgDGydhgFxHuGSlBQZfnaD47oBEB7w==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-function-name': 7.18.9 + '@babel/template': 7.18.10 + '@babel/traverse': 7.18.13 + '@babel/types': 7.18.13 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helpers/7.18.9: + resolution: {integrity: sha512-Jf5a+rbrLoR4eNdUmnFu8cN5eNJT6qdTdOg5IHIzq87WwyRw9PwguLFOWYgktN/60IP4fgDUawJvs7PjQIzELQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.18.10 + '@babel/traverse': 7.18.13 + '@babel/types': 7.18.13 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/highlight/7.18.6: + resolution: {integrity: sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.18.6 + chalk: 2.4.2 + js-tokens: 4.0.0 + dev: true + + /@babel/parser/7.18.13: + resolution: {integrity: sha512-dgXcIfMuQ0kgzLB2b9tRZs7TTFFaGM2AbtA4fJgUUYukzGH4jwsS7hzQHEGs67jdehpm22vkgKwvbU+aEflgwg==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.18.13 + + /@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-AHrP9jadvH7qlOj6PINbgSuphjQUAK7AOT7DPjBo9EHoLhQTnnK5u45e1Hd4DbSQEO9nqPWtQ89r+XEOWFScKg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-skip-transparent-expression-wrappers': 7.18.9 + '@babel/plugin-proposal-optional-chaining': 7.18.9_@babel+core@7.18.13 + dev: true + + /@babel/plugin-proposal-async-generator-functions/7.18.10_@babel+core@7.18.13: + resolution: {integrity: sha512-1mFuY2TOsR1hxbjCo4QL+qlIjV07p4H4EUYw2J/WCqsvFV6V9X9z9YhXbWndc/4fw+hYGlDT7egYxliMp5O6Ew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-environment-visitor': 7.18.9 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-remap-async-to-generator': 7.18.9_@babel+core@7.18.13 + '@babel/plugin-syntax-async-generators': 7.8.4_@babel+core@7.18.13 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-proposal-class-properties/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-create-class-features-plugin': 7.18.13_@babel+core@7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-proposal-class-static-block/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-+I3oIiNxrCpup3Gi8n5IGMwj0gOCAjcJUSQEcotNnCCPMEnixawOQ+KeJPlgfjzx+FKQ1QSyZOWe7wmoJp7vhw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-create-class-features-plugin': 7.18.13_@babel+core@7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/plugin-syntax-class-static-block': 7.14.5_@babel+core@7.18.13 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-proposal-decorators/7.18.10_@babel+core@7.18.13: + resolution: {integrity: sha512-wdGTwWF5QtpTY/gbBtQLAiCnoxfD4qMbN87NYZle1dOZ9Os8Y6zXcKrIaOU8W+TIvFUWVGG9tUgNww3CjXRVVw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-create-class-features-plugin': 7.18.13_@babel+core@7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-replace-supers': 7.18.9 + '@babel/helper-split-export-declaration': 7.18.6 + '@babel/plugin-syntax-decorators': 7.18.6_@babel+core@7.18.13 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-proposal-dynamic-import/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.18.13 + dev: true + + /@babel/plugin-proposal-export-namespace-from/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/plugin-syntax-export-namespace-from': 7.8.3_@babel+core@7.18.13 + dev: true + + /@babel/plugin-proposal-json-strings/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/plugin-syntax-json-strings': 7.8.3_@babel+core@7.18.13 + dev: true + + /@babel/plugin-proposal-logical-assignment-operators/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-128YbMpjCrP35IOExw2Fq+x55LMP42DzhOhX2aNNIdI9avSWl2PI0yuBWarr3RYpZBSPtabfadkH2yeRiMD61Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4_@babel+core@7.18.13 + dev: true + + /@babel/plugin-proposal-nullish-coalescing-operator/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3_@babel+core@7.18.13 + dev: true + + /@babel/plugin-proposal-numeric-separator/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/plugin-syntax-numeric-separator': 7.10.4_@babel+core@7.18.13 + dev: true + + /@babel/plugin-proposal-object-rest-spread/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-kDDHQ5rflIeY5xl69CEqGEZ0KY369ehsCIEbTGb4siHG5BE9sga/T0r0OUwyZNLMmZE79E1kbsqAjwFCW4ds6Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/compat-data': 7.18.13 + '@babel/core': 7.18.13 + '@babel/helper-compilation-targets': 7.18.9_@babel+core@7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/plugin-syntax-object-rest-spread': 7.8.3_@babel+core@7.18.13 + '@babel/plugin-transform-parameters': 7.18.8_@babel+core@7.18.13 + dev: true + + /@babel/plugin-proposal-optional-catch-binding/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/plugin-syntax-optional-catch-binding': 7.8.3_@babel+core@7.18.13 + dev: true + + /@babel/plugin-proposal-optional-chaining/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-v5nwt4IqBXihxGsW2QmCWMDS3B3bzGIk/EQVZz2ei7f3NJl8NzAJVvUmpDW5q1CRNY+Beb/k58UAH1Km1N411w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-skip-transparent-expression-wrappers': 7.18.9 + '@babel/plugin-syntax-optional-chaining': 7.8.3_@babel+core@7.18.13 + dev: true + + /@babel/plugin-proposal-private-methods/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-create-class-features-plugin': 7.18.13_@babel+core@7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-proposal-private-property-in-object/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-9Rysx7FOctvT5ouj5JODjAFAkgGoudQuLPamZb0v1TGLpapdNaftzifU8NTWQm0IRjqoYypdrSmyWgkocDQ8Dw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-annotate-as-pure': 7.18.6 + '@babel/helper-create-class-features-plugin': 7.18.13_@babel+core@7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/plugin-syntax-private-property-in-object': 7.14.5_@babel+core@7.18.13 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-proposal-unicode-property-regex/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==} + engines: {node: '>=4'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-create-regexp-features-plugin': 7.18.6_@babel+core@7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-async-generators/7.8.4_@babel+core@7.18.13: + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-bigint/7.8.3_@babel+core@7.18.13: + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-class-properties/7.12.13_@babel+core@7.18.13: + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-class-static-block/7.14.5_@babel+core@7.18.13: + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-decorators/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-fqyLgjcxf/1yhyZ6A+yo1u9gJ7eleFQod2lkaUsF9DQ7sbbY3Ligym3L0+I2c0WmqNKDpoD9UTb1AKP3qRMOAQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-dynamic-import/7.8.3_@babel+core@7.18.13: + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-export-namespace-from/7.8.3_@babel+core@7.18.13: + resolution: {integrity: sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-import-assertions/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-/DU3RXad9+bZwrgWJQKbr39gYbJpLJHezqEzRzi/BHRlJ9zsQb4CK2CA/5apllXNomwA1qHwzvHl+AdEmC5krQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-import-meta/7.10.4_@babel+core@7.18.13: + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-json-strings/7.8.3_@babel+core@7.18.13: + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-jsx/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-logical-assignment-operators/7.10.4_@babel+core@7.18.13: + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-nullish-coalescing-operator/7.8.3_@babel+core@7.18.13: + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-numeric-separator/7.10.4_@babel+core@7.18.13: + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-object-rest-spread/7.8.3_@babel+core@7.18.13: + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-optional-catch-binding/7.8.3_@babel+core@7.18.13: + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-optional-chaining/7.8.3_@babel+core@7.18.13: + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-private-property-in-object/7.14.5_@babel+core@7.18.13: + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-top-level-await/7.14.5_@babel+core@7.18.13: + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-typescript/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-mAWAuq4rvOepWCBid55JuRNvpTNf2UGVgoz4JV0fXEKolsVZDzsa4NqCef758WZJj/GDu0gVGItjKFiClTAmZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-arrow-functions/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-9S9X9RUefzrsHZmKMbDXxweEH+YlE8JJEuat9FdvW9Qh1cw7W64jELCtWNkPBPX5En45uy28KGvA/AySqUh8CQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-async-to-generator/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-ARE5wZLKnTgPW7/1ftQmSi1CmkqqHo2DNmtztFhvgtOWSDfq0Cq9/9L+KnZNYSNrydBekhW3rwShduf59RoXag==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-module-imports': 7.18.6 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-remap-async-to-generator': 7.18.9_@babel+core@7.18.13 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-block-scoped-functions/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-block-scoping/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-5sDIJRV1KtQVEbt/EIBwGy4T01uYIo4KRB3VUqzkhrAIOGx7AoctL9+Ux88btY0zXdDyPJ9mW+bg+v+XEkGmtw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-classes/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-EkRQxsxoytpTlKJmSPYrsOMjCILacAjtSVkd4gChEe2kXjFCun3yohhW5I7plXJhCemM0gKsaGMcO8tinvCA5g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-annotate-as-pure': 7.18.6 + '@babel/helper-environment-visitor': 7.18.9 + '@babel/helper-function-name': 7.18.9 + '@babel/helper-optimise-call-expression': 7.18.6 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-replace-supers': 7.18.9 + '@babel/helper-split-export-declaration': 7.18.6 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-computed-properties/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-+i0ZU1bCDymKakLxn5srGHrsAPRELC2WIbzwjLhHW9SIE1cPYkLCL0NlnXMZaM1vhfgA2+M7hySk42VBvrkBRw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-destructuring/7.18.13_@babel+core@7.18.13: + resolution: {integrity: sha512-TodpQ29XekIsex2A+YJPj5ax2plkGa8YYY6mFjCohk/IG9IY42Rtuj1FuDeemfg2ipxIFLzPeA83SIBnlhSIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-dotall-regex/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-create-regexp-features-plugin': 7.18.6_@babel+core@7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-duplicate-keys/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-exponentiation-operator/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-builder-binary-assignment-operator-visitor': 7.18.9 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-for-of/7.18.8_@babel+core@7.18.13: + resolution: {integrity: sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-function-name/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-compilation-targets': 7.18.9_@babel+core@7.18.13 + '@babel/helper-function-name': 7.18.9 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-literals/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-member-expression-literals/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-modules-amd/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-Pra5aXsmTsOnjM3IajS8rTaLCy++nGM4v3YR4esk5PCsyg9z8NA5oQLwxzMUtDBd8F+UmVza3VxoAaWCbzH1rg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-module-transforms': 7.18.9 + '@babel/helper-plugin-utils': 7.18.9 + babel-plugin-dynamic-import-node: 2.3.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-modules-commonjs/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-Qfv2ZOWikpvmedXQJDSbxNqy7Xr/j2Y8/KfijM0iJyKkBTmWuvCA1yeH1yDM7NJhBW/2aXxeucLj6i80/LAJ/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-module-transforms': 7.18.9 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-simple-access': 7.18.6 + babel-plugin-dynamic-import-node: 2.3.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-modules-systemjs/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-zY/VSIbbqtoRoJKo2cDTewL364jSlZGvn0LKOf9ntbfxOvjfmyrdtEEOAdswOswhZEb8UH3jDkCKHd1sPgsS0A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-hoist-variables': 7.18.6 + '@babel/helper-module-transforms': 7.18.9 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-validator-identifier': 7.18.6 + babel-plugin-dynamic-import-node: 2.3.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-modules-umd/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-module-transforms': 7.18.9 + '@babel/helper-plugin-utils': 7.18.9 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-named-capturing-groups-regex/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-UmEOGF8XgaIqD74bC8g7iV3RYj8lMf0Bw7NJzvnS9qQhM4mg+1WHKotUIdjxgD2RGrgFLZZPCFPFj3P/kVDYhg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-create-regexp-features-plugin': 7.18.6_@babel+core@7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-new-target/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-object-super/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-replace-supers': 7.18.9 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-parameters/7.18.8_@babel+core@7.18.13: + resolution: {integrity: sha512-ivfbE3X2Ss+Fj8nnXvKJS6sjRG4gzwPMsP+taZC+ZzEGjAYlvENixmt1sZ5Ca6tWls+BlKSGKPJ6OOXvXCbkFg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-property-literals/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-regenerator/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-poqRI2+qiSdeldcz4wTSTXBRryoq3Gc70ye7m7UD5Ww0nE29IXqMl6r7Nd15WBgRd74vloEMlShtH6CKxVzfmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + regenerator-transform: 0.15.0 + dev: true + + /@babel/plugin-transform-reserved-words/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-runtime/7.18.10_@babel+core@7.18.13: + resolution: {integrity: sha512-q5mMeYAdfEbpBAgzl7tBre/la3LeCxmDO1+wMXRdPWbcoMjR3GiXlCLk7JBZVVye0bqTGNMbt0yYVXX1B1jEWQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-module-imports': 7.18.6 + '@babel/helper-plugin-utils': 7.18.9 + babel-plugin-polyfill-corejs2: 0.3.2_@babel+core@7.18.13 + babel-plugin-polyfill-corejs3: 0.5.3_@babel+core@7.18.13 + babel-plugin-polyfill-regenerator: 0.4.0_@babel+core@7.18.13 + semver: 6.3.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-shorthand-properties/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-spread/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-39Q814wyoOPtIB/qGopNIL9xDChOE1pNU0ZY5dO0owhiVt/5kFm4li+/bBtwc7QotG0u5EPzqhZdjMtmqBqyQA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-skip-transparent-expression-wrappers': 7.18.9 + dev: true + + /@babel/plugin-transform-sticky-regex/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-template-literals/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-typeof-symbol/7.18.9_@babel+core@7.18.13: + resolution: {integrity: sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-unicode-escapes/7.18.10_@babel+core@7.18.13: + resolution: {integrity: sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-transform-unicode-regex/7.18.6_@babel+core@7.18.13: + resolution: {integrity: sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-create-regexp-features-plugin': 7.18.6_@babel+core@7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/preset-env/7.18.10_@babel+core@7.18.13: + resolution: {integrity: sha512-wVxs1yjFdW3Z/XkNfXKoblxoHgbtUF7/l3PvvP4m02Qz9TZ6uZGxRVYjSQeR87oQmHco9zWitW5J82DJ7sCjvA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/compat-data': 7.18.13 + '@babel/core': 7.18.13 + '@babel/helper-compilation-targets': 7.18.9_@babel+core@7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-validator-option': 7.18.6 + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.18.9_@babel+core@7.18.13 + '@babel/plugin-proposal-async-generator-functions': 7.18.10_@babel+core@7.18.13 + '@babel/plugin-proposal-class-properties': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-proposal-class-static-block': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-proposal-dynamic-import': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-proposal-export-namespace-from': 7.18.9_@babel+core@7.18.13 + '@babel/plugin-proposal-json-strings': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-proposal-logical-assignment-operators': 7.18.9_@babel+core@7.18.13 + '@babel/plugin-proposal-nullish-coalescing-operator': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-proposal-numeric-separator': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-proposal-object-rest-spread': 7.18.9_@babel+core@7.18.13 + '@babel/plugin-proposal-optional-catch-binding': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-proposal-optional-chaining': 7.18.9_@babel+core@7.18.13 + '@babel/plugin-proposal-private-methods': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-proposal-private-property-in-object': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-proposal-unicode-property-regex': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-syntax-async-generators': 7.8.4_@babel+core@7.18.13 + '@babel/plugin-syntax-class-properties': 7.12.13_@babel+core@7.18.13 + '@babel/plugin-syntax-class-static-block': 7.14.5_@babel+core@7.18.13 + '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.18.13 + '@babel/plugin-syntax-export-namespace-from': 7.8.3_@babel+core@7.18.13 + '@babel/plugin-syntax-import-assertions': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-syntax-json-strings': 7.8.3_@babel+core@7.18.13 + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4_@babel+core@7.18.13 + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3_@babel+core@7.18.13 + '@babel/plugin-syntax-numeric-separator': 7.10.4_@babel+core@7.18.13 + '@babel/plugin-syntax-object-rest-spread': 7.8.3_@babel+core@7.18.13 + '@babel/plugin-syntax-optional-catch-binding': 7.8.3_@babel+core@7.18.13 + '@babel/plugin-syntax-optional-chaining': 7.8.3_@babel+core@7.18.13 + '@babel/plugin-syntax-private-property-in-object': 7.14.5_@babel+core@7.18.13 + '@babel/plugin-syntax-top-level-await': 7.14.5_@babel+core@7.18.13 + '@babel/plugin-transform-arrow-functions': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-async-to-generator': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-block-scoped-functions': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-block-scoping': 7.18.9_@babel+core@7.18.13 + '@babel/plugin-transform-classes': 7.18.9_@babel+core@7.18.13 + '@babel/plugin-transform-computed-properties': 7.18.9_@babel+core@7.18.13 + '@babel/plugin-transform-destructuring': 7.18.13_@babel+core@7.18.13 + '@babel/plugin-transform-dotall-regex': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-duplicate-keys': 7.18.9_@babel+core@7.18.13 + '@babel/plugin-transform-exponentiation-operator': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-for-of': 7.18.8_@babel+core@7.18.13 + '@babel/plugin-transform-function-name': 7.18.9_@babel+core@7.18.13 + '@babel/plugin-transform-literals': 7.18.9_@babel+core@7.18.13 + '@babel/plugin-transform-member-expression-literals': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-modules-amd': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-modules-commonjs': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-modules-systemjs': 7.18.9_@babel+core@7.18.13 + '@babel/plugin-transform-modules-umd': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-named-capturing-groups-regex': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-new-target': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-object-super': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-parameters': 7.18.8_@babel+core@7.18.13 + '@babel/plugin-transform-property-literals': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-regenerator': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-reserved-words': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-shorthand-properties': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-spread': 7.18.9_@babel+core@7.18.13 + '@babel/plugin-transform-sticky-regex': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-template-literals': 7.18.9_@babel+core@7.18.13 + '@babel/plugin-transform-typeof-symbol': 7.18.9_@babel+core@7.18.13 + '@babel/plugin-transform-unicode-escapes': 7.18.10_@babel+core@7.18.13 + '@babel/plugin-transform-unicode-regex': 7.18.6_@babel+core@7.18.13 + '@babel/preset-modules': 0.1.5_@babel+core@7.18.13 + '@babel/types': 7.18.13 + babel-plugin-polyfill-corejs2: 0.3.2_@babel+core@7.18.13 + babel-plugin-polyfill-corejs3: 0.5.3_@babel+core@7.18.13 + babel-plugin-polyfill-regenerator: 0.4.0_@babel+core@7.18.13 + core-js-compat: 3.24.1 + semver: 6.3.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/preset-modules/0.1.5_@babel+core@7.18.13: + resolution: {integrity: sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/plugin-proposal-unicode-property-regex': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-dotall-regex': 7.18.6_@babel+core@7.18.13 + '@babel/types': 7.18.13 + esutils: 2.0.3 + dev: true + + /@babel/runtime/7.18.9: + resolution: {integrity: sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.13.9 + dev: true + + /@babel/template/7.18.10: + resolution: {integrity: sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.18.6 + '@babel/parser': 7.18.13 + '@babel/types': 7.18.13 + dev: true + + /@babel/traverse/7.18.13: + resolution: {integrity: sha512-N6kt9X1jRMLPxxxPYWi7tgvJRH/rtoU+dbKAPDM44RFHiMH8igdsaSBgFeskhSl/kLWLDUvIh1RXCrTmg0/zvA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.18.6 + '@babel/generator': 7.18.13 + '@babel/helper-environment-visitor': 7.18.9 + '@babel/helper-function-name': 7.18.9 + '@babel/helper-hoist-variables': 7.18.6 + '@babel/helper-split-export-declaration': 7.18.6 + '@babel/parser': 7.18.13 + '@babel/types': 7.18.13 + debug: 4.3.4 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/types/7.18.13: + resolution: {integrity: sha512-ePqfTihzW0W6XAU+aMw2ykilisStJfDnsejDCXRchCcMJ4O0+8DhPXf2YUbZ6wjBlsEmZwLK/sPweWtu8hcJYQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.18.10 + '@babel/helper-validator-identifier': 7.18.6 + to-fast-properties: 2.0.0 + + /@bcoe/v8-coverage/0.2.3: + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + dev: true + + /@commitlint/cli/12.1.4: + resolution: {integrity: sha512-ZR1WjXLvqEffYyBPT0XdnSxtt3Ty1TMoujEtseW5o3vPnkA1UNashAMjQVg/oELqfaiAMnDw8SERPMN0e/0kLg==} + engines: {node: '>=v10'} + hasBin: true + dependencies: + '@commitlint/format': 12.1.4 + '@commitlint/lint': 12.1.4 + '@commitlint/load': 12.1.4 + '@commitlint/read': 12.1.4 + '@commitlint/types': 12.1.4 + lodash: 4.17.21 + resolve-from: 5.0.0 + resolve-global: 1.0.0 + yargs: 16.2.0 + dev: true + + /@commitlint/config-conventional/12.1.4: + resolution: {integrity: sha512-ZIdzmdy4o4WyqywMEpprRCrehjCSQrHkaRTVZV411GyLigFQHlEBSJITAihLAWe88Qy/8SyoIe5uKvAsV5vRqQ==} + engines: {node: '>=v10'} + dependencies: + conventional-changelog-conventionalcommits: 4.6.3 + dev: true + + /@commitlint/config-validator/17.0.3: + resolution: {integrity: sha512-3tLRPQJKapksGE7Kee9axv+9z5I2GDHitDH4q63q7NmNA0wkB+DAorJ0RHz2/K00Zb1/MVdHzhCga34FJvDihQ==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/types': 17.0.0 + ajv: 8.11.0 + dev: true + optional: true + + /@commitlint/ensure/12.1.4: + resolution: {integrity: sha512-MxHIBuAG9M4xl33qUfIeMSasbv3ktK0W+iygldBxZOL4QSYC2Gn66pZAQMnV9o3V+sVFHoAK2XUKqBAYrgbEqw==} + engines: {node: '>=v10'} + dependencies: + '@commitlint/types': 12.1.4 + lodash: 4.17.21 + dev: true + + /@commitlint/execute-rule/12.1.4: + resolution: {integrity: sha512-h2S1j8SXyNeABb27q2Ok2vD1WfxJiXvOttKuRA9Or7LN6OQoC/KtT3844CIhhWNteNMu/wE0gkTqGxDVAnJiHg==} + engines: {node: '>=v10'} + dev: true + + /@commitlint/execute-rule/17.0.0: + resolution: {integrity: sha512-nVjL/w/zuqjCqSJm8UfpNaw66V9WzuJtQvEnCrK4jDw6qKTmZB+1JQ8m6BQVZbNBcwfYdDNKnhIhqI0Rk7lgpQ==} + engines: {node: '>=v14'} + dev: true + optional: true + + /@commitlint/format/12.1.4: + resolution: {integrity: sha512-h28ucMaoRjVvvgS6Bdf85fa/+ZZ/iu1aeWGCpURnQV7/rrVjkhNSjZwGlCOUd5kDV1EnZ5XdI7L18SUpRjs26g==} + engines: {node: '>=v10'} + dependencies: + '@commitlint/types': 12.1.4 + chalk: 4.1.2 + dev: true + + /@commitlint/is-ignored/12.1.4: + resolution: {integrity: sha512-uTu2jQU2SKvtIRVLOzMQo3KxDtO+iJ1p0olmncwrqy4AfPLgwoyCP2CiULq5M7xpR3+dE3hBlZXbZTQbD7ycIw==} + engines: {node: '>=v10'} + dependencies: + '@commitlint/types': 12.1.4 + semver: 7.3.5 + dev: true + + /@commitlint/lint/12.1.4: + resolution: {integrity: sha512-1kZ8YDp4to47oIPFELUFGLiLumtPNKJigPFDuHt2+f3Q3IKdQ0uk53n3CPl4uoyso/Og/EZvb1mXjFR/Yce4cA==} + engines: {node: '>=v10'} + dependencies: + '@commitlint/is-ignored': 12.1.4 + '@commitlint/parse': 12.1.4 + '@commitlint/rules': 12.1.4 + '@commitlint/types': 12.1.4 + dev: true + + /@commitlint/load/12.1.4: + resolution: {integrity: sha512-Keszi0IOjRzKfxT+qES/n+KZyLrxy79RQz8wWgssCboYjKEp+wC+fLCgbiMCYjI5k31CIzIOq/16J7Ycr0C0EA==} + engines: {node: '>=v10'} + dependencies: + '@commitlint/execute-rule': 12.1.4 + '@commitlint/resolve-extends': 12.1.4 + '@commitlint/types': 12.1.4 + chalk: 4.1.2 + cosmiconfig: 7.0.1 + lodash: 4.17.21 + resolve-from: 5.0.0 + dev: true + + /@commitlint/load/17.0.3: + resolution: {integrity: sha512-3Dhvr7GcKbKa/ey4QJ5MZH3+J7QFlARohUow6hftQyNjzoXXROm+RwpBes4dDFrXG1xDw9QPXA7uzrOShCd4bw==} + engines: {node: '>=v14'} + requiresBuild: true + dependencies: + '@commitlint/config-validator': 17.0.3 + '@commitlint/execute-rule': 17.0.0 + '@commitlint/resolve-extends': 17.0.3 + '@commitlint/types': 17.0.0 + '@types/node': 18.7.13 + chalk: 4.1.2 + cosmiconfig: 7.0.1 + cosmiconfig-typescript-loader: 2.0.2_57uwcby55h6tzvkj3v5sfcgxoe + lodash: 4.17.21 + resolve-from: 5.0.0 + typescript: 4.7.4 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + dev: true + optional: true + + /@commitlint/message/12.1.4: + resolution: {integrity: sha512-6QhalEKsKQ/Y16/cTk5NH4iByz26fqws2ub+AinHPtM7Io0jy4e3rym9iE+TkEqiqWZlUigZnTwbPvRJeSUBaA==} + engines: {node: '>=v10'} + dev: true + + /@commitlint/parse/12.1.4: + resolution: {integrity: sha512-yqKSAsK2V4X/HaLb/yYdrzs6oD/G48Ilt0EJ2Mp6RJeWYxG14w/Out6JrneWnr/cpzemyN5hExOg6+TB19H/Lw==} + engines: {node: '>=v10'} + dependencies: + '@commitlint/types': 12.1.4 + conventional-changelog-angular: 5.0.13 + conventional-commits-parser: 3.2.4 + dev: true + + /@commitlint/read/12.1.4: + resolution: {integrity: sha512-TnPQSJgD8Aod5Xeo9W4SaYKRZmIahukjcCWJ2s5zb3ZYSmj6C85YD9cR5vlRyrZjj78ItLUV/X4FMWWVIS38Jg==} + engines: {node: '>=v10'} + dependencies: + '@commitlint/top-level': 12.1.4 + '@commitlint/types': 12.1.4 + fs-extra: 9.1.0 + git-raw-commits: 2.0.11 + dev: true + + /@commitlint/resolve-extends/12.1.4: + resolution: {integrity: sha512-R9CoUtsXLd6KSCfsZly04grsH6JVnWFmVtWgWs1KdDpdV+G3TSs37tColMFqglpkx3dsWu8dsPD56+D9YnJfqg==} + engines: {node: '>=v10'} + dependencies: + import-fresh: 3.3.0 + lodash: 4.17.21 + resolve-from: 5.0.0 + resolve-global: 1.0.0 + dev: true + + /@commitlint/resolve-extends/17.0.3: + resolution: {integrity: sha512-H/RFMvrcBeJCMdnVC4i8I94108UDccIHrTke2tyQEg9nXQnR5/Hd6MhyNWkREvcrxh9Y+33JLb+PiPiaBxCtBA==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/config-validator': 17.0.3 + '@commitlint/types': 17.0.0 + import-fresh: 3.3.0 + lodash: 4.17.21 + resolve-from: 5.0.0 + resolve-global: 1.0.0 + dev: true + optional: true + + /@commitlint/rules/12.1.4: + resolution: {integrity: sha512-W8m6ZSjg7RuIsIfzQiFHa48X5mcPXeKT9yjBxVmjHvYfS2FDBf1VxCQ7vO0JTVIdV4ohjZ0eKg/wxxUuZHJAZg==} + engines: {node: '>=v10'} + dependencies: + '@commitlint/ensure': 12.1.4 + '@commitlint/message': 12.1.4 + '@commitlint/to-lines': 12.1.4 + '@commitlint/types': 12.1.4 + dev: true + + /@commitlint/to-lines/12.1.4: + resolution: {integrity: sha512-TParumvbi8bdx3EdLXz2MaX+e15ZgoCqNUgqHsRLwyqLUTRbqCVkzrfadG1UcMQk8/d5aMbb327ZKG3Q4BRorw==} + engines: {node: '>=v10'} + dev: true + + /@commitlint/top-level/12.1.4: + resolution: {integrity: sha512-d4lTJrOT/dXlpY+NIt4CUl77ciEzYeNVc0VFgUQ6VA+b1rqYD2/VWFjBlWVOrklxtSDeKyuEhs36RGrppEFAvg==} + engines: {node: '>=v10'} + dependencies: + find-up: 5.0.0 + dev: true + + /@commitlint/types/12.1.4: + resolution: {integrity: sha512-KRIjdnWNUx6ywz+SJvjmNCbQKcKP6KArhjZhY2l+CWKxak0d77SOjggkMwFTiSgLODOwmuLTbarR2ZfWPiPMlw==} + engines: {node: '>=v10'} + dependencies: + chalk: 4.1.2 + dev: true + + /@commitlint/types/17.0.0: + resolution: {integrity: sha512-hBAw6U+SkAT5h47zDMeOu3HSiD0SODw4Aq7rRNh1ceUmL7GyLKYhPbUvlRWqZ65XjBLPHZhFyQlRaPNz8qvUyQ==} + engines: {node: '>=v14'} + dependencies: + chalk: 4.1.2 + dev: true + optional: true + + /@cspotcode/source-map-support/0.8.1: + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + dev: true + optional: true + + /@csstools/selector-specificity/2.0.2_pnx64jze6bptzcedy5bidi3zdi: + resolution: {integrity: sha512-IkpVW/ehM1hWKln4fCA3NzJU8KwD+kIOvPZA4cqxoJHtE21CCzjyp+Kxbu0i5I4tBNOlXPL9mjwnWlL0VEG4Fg==} + engines: {node: ^12 || ^14 || >=16} + peerDependencies: + postcss: ^8.2 + postcss-selector-parser: ^6.0.10 + dependencies: + postcss: 8.4.16 + postcss-selector-parser: 6.0.10 + dev: true + + /@eslint/eslintrc/0.4.3: + resolution: {integrity: sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + ajv: 6.12.6 + debug: 4.3.4 + espree: 7.3.1 + globals: 13.17.0 + ignore: 4.0.6 + import-fresh: 3.3.0 + js-yaml: 3.14.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@hapi/hoek/9.3.0: + resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + dev: true + + /@hapi/topo/5.1.0: + resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + dependencies: + '@hapi/hoek': 9.3.0 + dev: true + + /@humanwhocodes/config-array/0.5.0: + resolution: {integrity: sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==} + engines: {node: '>=10.10.0'} + dependencies: + '@humanwhocodes/object-schema': 1.2.1 + debug: 4.3.4 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@humanwhocodes/object-schema/1.2.1: + resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} + dev: true + + /@istanbuljs/load-nyc-config/1.1.0: + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.1 + resolve-from: 5.0.0 + dev: true + + /@istanbuljs/schema/0.1.3: + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + dev: true + + /@jest/console/27.5.1: + resolution: {integrity: sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/types': 27.5.1 + '@types/node': 18.7.13 + chalk: 4.1.2 + jest-message-util: 27.5.1 + jest-util: 27.5.1 + slash: 3.0.0 + dev: true + + /@jest/console/28.1.3: + resolution: {integrity: sha512-QPAkP5EwKdK/bxIr6C1I4Vs0rm2nHiANzj/Z5X2JQkrZo6IqvC4ldZ9K95tF0HdidhA8Bo6egxSzUFPYKcEXLw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/types': 28.1.3 + '@types/node': 18.7.13 + chalk: 4.1.2 + jest-message-util: 28.1.3 + jest-util: 28.1.3 + slash: 3.0.0 + dev: true + + /@jest/core/27.5.1: + resolution: {integrity: sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/console': 27.5.1 + '@jest/reporters': 27.5.1 + '@jest/test-result': 27.5.1 + '@jest/transform': 27.5.1 + '@jest/types': 27.5.1 + '@types/node': 18.7.13 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.8.1 + exit: 0.1.2 + graceful-fs: 4.2.10 + jest-changed-files: 27.5.1 + jest-config: 27.5.1 + jest-haste-map: 27.5.1 + jest-message-util: 27.5.1 + jest-regex-util: 27.5.1 + jest-resolve: 27.5.1 + jest-resolve-dependencies: 27.5.1 + jest-runner: 27.5.1 + jest-runtime: 27.5.1 + jest-snapshot: 27.5.1 + jest-util: 27.5.1 + jest-validate: 27.5.1 + jest-watcher: 27.5.1 + micromatch: 4.0.5 + rimraf: 3.0.2 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - ts-node + - utf-8-validate + dev: true + + /@jest/environment/27.5.1: + resolution: {integrity: sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/fake-timers': 27.5.1 + '@jest/types': 27.5.1 + '@types/node': 18.7.13 + jest-mock: 27.5.1 + dev: true + + /@jest/fake-timers/27.5.1: + resolution: {integrity: sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/types': 27.5.1 + '@sinonjs/fake-timers': 8.1.0 + '@types/node': 18.7.13 + jest-message-util: 27.5.1 + jest-mock: 27.5.1 + jest-util: 27.5.1 + dev: true + + /@jest/globals/27.5.1: + resolution: {integrity: sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/environment': 27.5.1 + '@jest/types': 27.5.1 + expect: 27.5.1 + dev: true + + /@jest/reporters/27.5.1: + resolution: {integrity: sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 27.5.1 + '@jest/test-result': 27.5.1 + '@jest/transform': 27.5.1 + '@jest/types': 27.5.1 + '@types/node': 18.7.13 + chalk: 4.1.2 + collect-v8-coverage: 1.0.1 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.10 + istanbul-lib-coverage: 3.2.0 + istanbul-lib-instrument: 5.2.0 + istanbul-lib-report: 3.0.0 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.1.5 + jest-haste-map: 27.5.1 + jest-resolve: 27.5.1 + jest-util: 27.5.1 + jest-worker: 27.5.1 + slash: 3.0.0 + source-map: 0.6.1 + string-length: 4.0.2 + terminal-link: 2.1.1 + v8-to-istanbul: 8.1.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@jest/schemas/28.1.3: + resolution: {integrity: sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@sinclair/typebox': 0.24.28 + dev: true + + /@jest/source-map/27.5.1: + resolution: {integrity: sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + callsites: 3.1.0 + graceful-fs: 4.2.10 + source-map: 0.6.1 + dev: true + + /@jest/test-result/27.5.1: + resolution: {integrity: sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/console': 27.5.1 + '@jest/types': 27.5.1 + '@types/istanbul-lib-coverage': 2.0.4 + collect-v8-coverage: 1.0.1 + dev: true + + /@jest/test-result/28.1.3: + resolution: {integrity: sha512-kZAkxnSE+FqE8YjW8gNuoVkkC9I7S1qmenl8sGcDOLropASP+BkcGKwhXoyqQuGOGeYY0y/ixjrd/iERpEXHNg==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/console': 28.1.3 + '@jest/types': 28.1.3 + '@types/istanbul-lib-coverage': 2.0.4 + collect-v8-coverage: 1.0.1 + dev: true + + /@jest/test-sequencer/27.5.1: + resolution: {integrity: sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/test-result': 27.5.1 + graceful-fs: 4.2.10 + jest-haste-map: 27.5.1 + jest-runtime: 27.5.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@jest/transform/27.5.1: + resolution: {integrity: sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@babel/core': 7.18.13 + '@jest/types': 27.5.1 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 1.8.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.10 + jest-haste-map: 27.5.1 + jest-regex-util: 27.5.1 + jest-util: 27.5.1 + micromatch: 4.0.5 + pirates: 4.0.5 + slash: 3.0.0 + source-map: 0.6.1 + write-file-atomic: 3.0.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@jest/types/27.5.1: + resolution: {integrity: sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@types/istanbul-lib-coverage': 2.0.4 + '@types/istanbul-reports': 3.0.1 + '@types/node': 18.7.13 + '@types/yargs': 16.0.4 + chalk: 4.1.2 + dev: true + + /@jest/types/28.1.3: + resolution: {integrity: sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/schemas': 28.1.3 + '@types/istanbul-lib-coverage': 2.0.4 + '@types/istanbul-reports': 3.0.1 + '@types/node': 18.7.13 + '@types/yargs': 17.0.11 + chalk: 4.1.2 + dev: true + + /@jridgewell/gen-mapping/0.1.1: + resolution: {integrity: sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.1.2 + '@jridgewell/sourcemap-codec': 1.4.14 + dev: true + + /@jridgewell/gen-mapping/0.3.2: + resolution: {integrity: sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.1.2 + '@jridgewell/sourcemap-codec': 1.4.14 + '@jridgewell/trace-mapping': 0.3.15 + dev: true + + /@jridgewell/resolve-uri/3.1.0: + resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/set-array/1.1.2: + resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/source-map/0.3.2: + resolution: {integrity: sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==} + dependencies: + '@jridgewell/gen-mapping': 0.3.2 + '@jridgewell/trace-mapping': 0.3.15 + dev: true + + /@jridgewell/sourcemap-codec/1.4.14: + resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} + dev: true + + /@jridgewell/trace-mapping/0.3.15: + resolution: {integrity: sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g==} + dependencies: + '@jridgewell/resolve-uri': 3.1.0 + '@jridgewell/sourcemap-codec': 1.4.14 + dev: true + + /@jridgewell/trace-mapping/0.3.9: + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.0 + '@jridgewell/sourcemap-codec': 1.4.14 + dev: true + optional: true + + /@leichtgewicht/ip-codec/2.0.4: + resolution: {integrity: sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==} + dev: true + + /@node-ipc/js-queue/2.0.3: + resolution: {integrity: sha512-fL1wpr8hhD5gT2dA1qifeVaoDFlQR5es8tFuKqjHX+kdOtdNHnxkVZbtIrR2rxnMFvehkjaZRNV2H/gPXlb0hw==} + engines: {node: '>=1.0.0'} + dependencies: + easy-stack: 1.0.1 + dev: true + + /@nodelib/fs.scandir/2.1.5: + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + dev: true + + /@nodelib/fs.stat/2.0.5: + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + dev: true + + /@nodelib/fs.walk/1.2.8: + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.13.0 + dev: true + + /@polka/url/1.0.0-next.21: + resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==} + dev: true + + /@sideway/address/4.1.4: + resolution: {integrity: sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==} + dependencies: + '@hapi/hoek': 9.3.0 + dev: true + + /@sideway/formula/3.0.0: + resolution: {integrity: sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==} + dev: true + + /@sideway/pinpoint/2.0.0: + resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + dev: true + + /@simonwep/pickr/1.7.4: + resolution: {integrity: sha512-fq7jgKJT21uWGC1mARBHvvd1JYlEf93o7SuVOB4Lr0x/2UPuNC9Oe9n/GzVeg4oVtqMDfh1wIEJpsdOJEZb+3g==} + dependencies: + core-js: 3.24.1 + nanopop: 2.1.0 + dev: false + + /@sinclair/typebox/0.24.28: + resolution: {integrity: sha512-dgJd3HLOkLmz4Bw50eZx/zJwtBq65nms3N9VBYu5LTjJ883oBFkTyXRlCB/ZGGwqYpJJHA5zW2Ibhl5ngITfow==} + dev: true + + /@sinonjs/commons/1.8.3: + resolution: {integrity: sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==} + dependencies: + type-detect: 4.0.8 + dev: true + + /@sinonjs/fake-timers/8.1.0: + resolution: {integrity: sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==} + dependencies: + '@sinonjs/commons': 1.8.3 + dev: true + + /@soda/friendly-errors-webpack-plugin/1.8.1_webpack@5.74.0: + resolution: {integrity: sha512-h2ooWqP8XuFqTXT+NyAFbrArzfQA7R6HTezADrvD9Re8fxMLTPPniLdqVTdDaO0eIoLaAwKT+d6w+5GeTk7Vbg==} + engines: {node: '>=8.0.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + dependencies: + chalk: 3.0.0 + error-stack-parser: 2.1.4 + string-width: 4.2.3 + strip-ansi: 6.0.1 + webpack: 5.74.0 + dev: true + + /@soda/get-current-script/1.0.2: + resolution: {integrity: sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==} + dev: true + + /@tootallnate/once/1.1.2: + resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} + engines: {node: '>= 6'} + dev: true + + /@trysound/sax/0.2.0: + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + dev: true + + /@tsconfig/node10/1.0.9: + resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} + dev: true + optional: true + + /@tsconfig/node12/1.0.11: + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + dev: true + optional: true + + /@tsconfig/node14/1.0.3: + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + dev: true + optional: true + + /@tsconfig/node16/1.0.3: + resolution: {integrity: sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==} + dev: true + optional: true + + /@types/babel__core/7.1.19: + resolution: {integrity: sha512-WEOTgRsbYkvA/KCsDwVEGkd7WAr1e3g31VHQ8zy5gul/V1qKullU/BU5I68X5v7V3GnB9eotmom4v5a5gjxorw==} + dependencies: + '@babel/parser': 7.18.13 + '@babel/types': 7.18.13 + '@types/babel__generator': 7.6.4 + '@types/babel__template': 7.4.1 + '@types/babel__traverse': 7.18.0 + dev: true + + /@types/babel__generator/7.6.4: + resolution: {integrity: sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==} + dependencies: + '@babel/types': 7.18.13 + dev: true + + /@types/babel__template/7.4.1: + resolution: {integrity: sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==} + dependencies: + '@babel/parser': 7.18.13 + '@babel/types': 7.18.13 + dev: true + + /@types/babel__traverse/7.18.0: + resolution: {integrity: sha512-v4Vwdko+pgymgS+A2UIaJru93zQd85vIGWObM5ekZNdXCKtDYqATlEYnWgfo86Q6I1Lh0oXnksDnMU1cwmlPDw==} + dependencies: + '@babel/types': 7.18.13 + dev: true + + /@types/body-parser/1.19.2: + resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==} + dependencies: + '@types/connect': 3.4.35 + '@types/node': 18.7.13 + dev: true + + /@types/bonjour/3.5.10: + resolution: {integrity: sha512-p7ienRMiS41Nu2/igbJxxLDWrSZ0WxM8UQgCeO9KhoVF7cOVFkrKsiDr1EsJIla8vV3oEEjGcz11jc5yimhzZw==} + dependencies: + '@types/node': 18.7.13 + dev: true + + /@types/connect-history-api-fallback/1.3.5: + resolution: {integrity: sha512-h8QJa8xSb1WD4fpKBDcATDNGXghFj6/3GRWG6dhmRcu0RX1Ubasur2Uvx5aeEwlf0MwblEC2bMzzMQntxnw/Cw==} + dependencies: + '@types/express-serve-static-core': 4.17.30 + '@types/node': 18.7.13 + dev: true + + /@types/connect/3.4.35: + resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==} + dependencies: + '@types/node': 18.7.13 + dev: true + + /@types/d3-format/3.0.1: + resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==} + dev: false + + /@types/eslint-scope/3.7.4: + resolution: {integrity: sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==} + dependencies: + '@types/eslint': 8.4.6 + '@types/estree': 0.0.51 + dev: true + + /@types/eslint/8.4.6: + resolution: {integrity: sha512-/fqTbjxyFUaYNO7VcW5g+4npmqVACz1bB7RTHYuLj+PRjw9hrCwrUXVQFpChUS0JsyEFvMZ7U/PfmvWgxJhI9g==} + dependencies: + '@types/estree': 1.0.0 + '@types/json-schema': 7.0.11 + dev: true + + /@types/estree/0.0.51: + resolution: {integrity: sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==} + dev: true + + /@types/estree/1.0.0: + resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==} + dev: true + + /@types/express-serve-static-core/4.17.30: + resolution: {integrity: sha512-gstzbTWro2/nFed1WXtf+TtrpwxH7Ggs4RLYTLbeVgIkUQOI3WG/JKjgeOU1zXDvezllupjrf8OPIdvTbIaVOQ==} + dependencies: + '@types/node': 18.7.13 + '@types/qs': 6.9.7 + '@types/range-parser': 1.2.4 + dev: true + + /@types/express/4.17.13: + resolution: {integrity: sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==} + dependencies: + '@types/body-parser': 1.19.2 + '@types/express-serve-static-core': 4.17.30 + '@types/qs': 6.9.7 + '@types/serve-static': 1.15.0 + dev: true + + /@types/graceful-fs/4.1.5: + resolution: {integrity: sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==} + dependencies: + '@types/node': 18.7.13 + dev: true + + /@types/html-minifier-terser/6.1.0: + resolution: {integrity: sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==} + dev: true + + /@types/http-proxy/1.17.9: + resolution: {integrity: sha512-QsbSjA/fSk7xB+UXlCT3wHBy5ai9wOcNDWwZAtud+jXhwOM3l+EYZh8Lng4+/6n8uar0J7xILzqftJdJ/Wdfkw==} + dependencies: + '@types/node': 18.7.13 + dev: true + + /@types/istanbul-lib-coverage/2.0.4: + resolution: {integrity: sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==} + dev: true + + /@types/istanbul-lib-report/3.0.0: + resolution: {integrity: sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==} + dependencies: + '@types/istanbul-lib-coverage': 2.0.4 + dev: true + + /@types/istanbul-reports/3.0.1: + resolution: {integrity: sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==} + dependencies: + '@types/istanbul-lib-report': 3.0.0 + dev: true + + /@types/jest/27.5.2: + resolution: {integrity: sha512-mpT8LJJ4CMeeahobofYWIjFo0xonRS/HfxnVEPMPFSQdGUt1uHCnoPT7Zhb+sjDU2wz0oKV0OLUR0WzrHNgfeA==} + dependencies: + jest-matcher-utils: 27.5.1 + pretty-format: 27.5.1 + dev: true + + /@types/json-schema/7.0.11: + resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==} + + /@types/json5/0.0.29: + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + dev: true + + /@types/loader-utils/1.1.3: + resolution: {integrity: sha512-euKGFr2oCB3ASBwG39CYJMR3N9T0nanVqXdiH7Zu/Nqddt6SmFRxytq/i2w9LQYNQekEtGBz+pE3qG6fQTNvRg==} + dependencies: + '@types/node': 8.9.5 + '@types/webpack': 5.28.0 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + - webpack-cli + dev: true + + /@types/lodash/4.14.184: + resolution: {integrity: sha512-RoZphVtHbxPZizt4IcILciSWiC6dcn+eZ8oX9IWEYfDMcocdd42f7NPI6fQj+6zI8y4E0L7gu2pcZKLGTRaV9Q==} + dev: false + + /@types/mime/3.0.1: + resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==} + dev: true + + /@types/minimist/1.2.2: + resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==} + dev: true + + /@types/node/18.7.13: + resolution: {integrity: sha512-46yIhxSe5xEaJZXWdIBP7GU4HDTG8/eo0qd9atdiL+lFpA03y8KS+lkTN834TWJj5767GbWv4n/P6efyTFt1Dw==} + + /@types/node/8.10.66: + resolution: {integrity: sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw==} + dev: false + + /@types/node/8.9.5: + resolution: {integrity: sha512-jRHfWsvyMtXdbhnz5CVHxaBgnV6duZnPlQuRSo/dm/GnmikNcmZhxIES4E9OZjUmQ8C+HCl4KJux+cXN/ErGDQ==} + dev: true + + /@types/normalize-package-data/2.4.1: + resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} + dev: true + + /@types/parse-json/4.0.0: + resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} + dev: true + + /@types/prettier/2.7.0: + resolution: {integrity: sha512-RI1L7N4JnW5gQw2spvL7Sllfuf1SaHdrZpCHiBlCXjIlufi1SMNnbu2teze3/QE67Fg2tBlH7W+mi4hVNk4p0A==} + dev: true + + /@types/q/1.5.5: + resolution: {integrity: sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ==} + dev: true + + /@types/qs/6.9.7: + resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==} + dev: true + + /@types/range-parser/1.2.4: + resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==} + dev: true + + /@types/retry/0.12.0: + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + dev: true + + /@types/serve-index/1.9.1: + resolution: {integrity: sha512-d/Hs3nWDxNL2xAczmOVZNj92YZCS6RGxfBPjKzuu/XirCgXdpKEb88dYNbrYGint6IVWLNP+yonwVAuRC0T2Dg==} + dependencies: + '@types/express': 4.17.13 + dev: true + + /@types/serve-static/1.15.0: + resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==} + dependencies: + '@types/mime': 3.0.1 + '@types/node': 18.7.13 + dev: true + + /@types/sockjs/0.3.33: + resolution: {integrity: sha512-f0KEEe05NvUnat+boPTZ0dgaLZ4SfSouXUgv5noUiefG2ajgKjmETo9ZJyuqsl7dfl2aHlLJUiki6B4ZYldiiw==} + dependencies: + '@types/node': 18.7.13 + dev: true + + /@types/stack-utils/2.0.1: + resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} + dev: true + + /@types/webpack/5.28.0: + resolution: {integrity: sha512-8cP0CzcxUiFuA9xGJkfeVpqmWTk9nx6CWwamRGCj95ph1SmlRRk9KlCZ6avhCbZd4L68LvYT6l1kpdEnQXrF8w==} + dependencies: + '@types/node': 8.9.5 + tapable: 2.2.1 + webpack: 5.74.0 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + - webpack-cli + dev: true + + /@types/ws/8.5.3: + resolution: {integrity: sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==} + dependencies: + '@types/node': 18.7.13 + dev: true + + /@types/yargs-parser/21.0.0: + resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} + dev: true + + /@types/yargs/16.0.4: + resolution: {integrity: sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==} + dependencies: + '@types/yargs-parser': 21.0.0 + dev: true + + /@types/yargs/17.0.11: + resolution: {integrity: sha512-aB4y9UDUXTSMxmM4MH+YnuR0g5Cph3FLQBoWoMB21DSvFVAxRVEHEMx3TLh+zUZYMCQtKiqazz0Q4Rre31f/OA==} + dependencies: + '@types/yargs-parser': 21.0.0 + dev: true + + /@vue/babel-helper-vue-jsx-merge-props/1.2.1: + resolution: {integrity: sha512-QOi5OW45e2R20VygMSNhyQHvpdUwQZqGPc748JLGCYEy+yp8fNFNdbNIGAgZmi9e+2JHPd6i6idRuqivyicIkA==} + dev: true + + /@vue/babel-helper-vue-transform-on/1.0.2: + resolution: {integrity: sha512-hz4R8tS5jMn8lDq6iD+yWL6XNB699pGIVLk7WSJnn1dbpjaazsjZQkieJoRX6gW5zpYSCFqQ7jUquPNY65tQYA==} + dev: true + + /@vue/babel-plugin-jsx/1.1.1_@babel+core@7.18.13: + resolution: {integrity: sha512-j2uVfZjnB5+zkcbc/zsOc0fSNGCMMjaEXP52wdwdIfn0qjFfEYpYZBFKFg+HHnQeJCVrjOeO0YxgaL7DMrym9w==} + dependencies: + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.18.13 + '@babel/template': 7.18.10 + '@babel/traverse': 7.18.13 + '@babel/types': 7.18.13 + '@vue/babel-helper-vue-transform-on': 1.0.2 + camelcase: 6.3.0 + html-tags: 3.2.0 + svg-tags: 1.0.0 + transitivePeerDependencies: + - '@babel/core' + - supports-color + dev: true + + /@vue/babel-plugin-transform-vue-jsx/1.2.1_@babel+core@7.18.13: + resolution: {integrity: sha512-HJuqwACYehQwh1fNT8f4kyzqlNMpBuUK4rSiSES5D4QsYncv5fxFsLyrxFPG2ksO7t5WP+Vgix6tt6yKClwPzA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.18.13 + '@vue/babel-helper-vue-jsx-merge-props': 1.2.1 + html-tags: 2.0.0 + lodash.kebabcase: 4.1.1 + svg-tags: 1.0.0 + dev: true + + /@vue/babel-preset-app/5.0.8_core-js@3.24.1+vue@2.7.10: + resolution: {integrity: sha512-yl+5qhpjd8e1G4cMXfORkkBlvtPCIgmRf3IYCWYDKIQ7m+PPa5iTm4feiNmCMD6yGqQWMhhK/7M3oWGL9boKwg==} + peerDependencies: + core-js: ^3 + vue: ^2 || ^3.2.13 + peerDependenciesMeta: + core-js: + optional: true + vue: + optional: true + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-compilation-targets': 7.18.9_@babel+core@7.18.13 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-proposal-class-properties': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-proposal-decorators': 7.18.10_@babel+core@7.18.13 + '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.18.13 + '@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.18.13 + '@babel/plugin-transform-runtime': 7.18.10_@babel+core@7.18.13 + '@babel/preset-env': 7.18.10_@babel+core@7.18.13 + '@babel/runtime': 7.18.9 + '@vue/babel-plugin-jsx': 1.1.1_@babel+core@7.18.13 + '@vue/babel-preset-jsx': 1.3.1_d4nzpive36rkayolpiak62ahei + babel-plugin-dynamic-import-node: 2.3.3 + core-js: 3.24.1 + core-js-compat: 3.24.1 + semver: 7.3.7 + vue: 2.7.10 + transitivePeerDependencies: + - supports-color + dev: true + + /@vue/babel-preset-jsx/1.3.1_d4nzpive36rkayolpiak62ahei: + resolution: {integrity: sha512-ml+nqcSKp8uAqFZLNc7OWLMzR7xDBsUfkomF98DtiIBlLqlq4jCQoLINARhgqRIyKdB+mk/94NWpIb4pL6D3xw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + vue: '*' + peerDependenciesMeta: + vue: + optional: true + dependencies: + '@babel/core': 7.18.13 + '@vue/babel-helper-vue-jsx-merge-props': 1.2.1 + '@vue/babel-plugin-transform-vue-jsx': 1.2.1_@babel+core@7.18.13 + '@vue/babel-sugar-composition-api-inject-h': 1.3.0_@babel+core@7.18.13 + '@vue/babel-sugar-composition-api-render-instance': 1.3.0_@babel+core@7.18.13 + '@vue/babel-sugar-functional-vue': 1.2.2_@babel+core@7.18.13 + '@vue/babel-sugar-inject-h': 1.2.2_@babel+core@7.18.13 + '@vue/babel-sugar-v-model': 1.3.0_@babel+core@7.18.13 + '@vue/babel-sugar-v-on': 1.3.0_@babel+core@7.18.13 + vue: 2.7.10 + dev: true + + /@vue/babel-sugar-composition-api-inject-h/1.3.0_@babel+core@7.18.13: + resolution: {integrity: sha512-pIDOutEpqbURdVw7xhgxmuDW8Tl+lTgzJZC5jdlUu0lY2+izT9kz3Umd/Tbu0U5cpCJ2Yhu87BZFBzWpS0Xemg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.18.13 + dev: true + + /@vue/babel-sugar-composition-api-render-instance/1.3.0_@babel+core@7.18.13: + resolution: {integrity: sha512-NYNnU2r7wkJLMV5p9Zj4pswmCs037O/N2+/Fs6SyX7aRFzXJRP1/2CZh5cIwQxWQajHXuCUd5mTb7DxoBVWyTg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.18.13 + dev: true + + /@vue/babel-sugar-functional-vue/1.2.2_@babel+core@7.18.13: + resolution: {integrity: sha512-JvbgGn1bjCLByIAU1VOoepHQ1vFsroSA/QkzdiSs657V79q6OwEWLCQtQnEXD/rLTA8rRit4rMOhFpbjRFm82w==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.18.13 + dev: true + + /@vue/babel-sugar-inject-h/1.2.2_@babel+core@7.18.13: + resolution: {integrity: sha512-y8vTo00oRkzQTgufeotjCLPAvlhnpSkcHFEp60+LJUwygGcd5Chrpn5480AQp/thrxVm8m2ifAk0LyFel9oCnw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.18.13 + dev: true + + /@vue/babel-sugar-v-model/1.3.0_@babel+core@7.18.13: + resolution: {integrity: sha512-zcsabmdX48JmxTObn3xmrvvdbEy8oo63DphVyA3WRYGp4SEvJRpu/IvZCVPl/dXLuob2xO/QRuncqPgHvZPzpA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.18.13 + '@vue/babel-helper-vue-jsx-merge-props': 1.2.1 + '@vue/babel-plugin-transform-vue-jsx': 1.2.1_@babel+core@7.18.13 + camelcase: 5.3.1 + html-tags: 2.0.0 + svg-tags: 1.0.0 + dev: true + + /@vue/babel-sugar-v-on/1.3.0_@babel+core@7.18.13: + resolution: {integrity: sha512-8VZgrS0G5bh7+Prj7oJkzg9GvhSPnuW5YT6MNaVAEy4uwxRLJ8GqHenaStfllChTao4XZ3EZkNtHB4Xbr/ePdA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.18.13 + '@vue/babel-plugin-transform-vue-jsx': 1.2.1_@babel+core@7.18.13 + camelcase: 5.3.1 + dev: true + + /@vue/cli-overlay/5.0.8: + resolution: {integrity: sha512-KmtievE/B4kcXp6SuM2gzsnSd8WebkQpg3XaB6GmFh1BJGRqa1UiW9up7L/Q67uOdTigHxr5Ar2lZms4RcDjwQ==} + dev: true + + /@vue/cli-plugin-babel/5.0.8_2pvo7dpnbf4o5og67iqwgx7rn4: + resolution: {integrity: sha512-a4qqkml3FAJ3auqB2kN2EMPocb/iu0ykeELwed+9B1c1nQ1HKgslKMHMPavYx3Cd/QAx2mBD4hwKBqZXEI/CsQ==} + peerDependencies: + '@vue/cli-service': ^3.0.0 || ^4.0.0 || ^5.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@vue/babel-preset-app': 5.0.8_core-js@3.24.1+vue@2.7.10 + '@vue/cli-service': 5.0.8_26d7a3va3smzyfrmey4itzvxkm + '@vue/cli-shared-utils': 5.0.8 + babel-loader: 8.2.5_tb6moc662p5idmcg3l5ipbhpta + thread-loader: 3.0.4_webpack@5.74.0 + webpack: 5.74.0 + transitivePeerDependencies: + - '@swc/core' + - core-js + - encoding + - esbuild + - supports-color + - uglify-js + - vue + - webpack-cli + dev: true + + /@vue/cli-plugin-eslint/5.0.8_yvnkslq6l73c7sesu7e5hzum2a: + resolution: {integrity: sha512-d11+I5ONYaAPW1KyZj9GlrV/E6HZePq5L5eAF5GgoVdu6sxr6bDgEoxzhcS1Pk2eh8rn1MxG/FyyR+eCBj/CNg==} + peerDependencies: + '@vue/cli-service': ^3.0.0 || ^4.0.0 || ^5.0.0-0 + eslint: '>=7.5.0' + dependencies: + '@vue/cli-service': 5.0.8_26d7a3va3smzyfrmey4itzvxkm + '@vue/cli-shared-utils': 5.0.8 + eslint: 7.32.0 + eslint-webpack-plugin: 3.2.0_2gji4j2x5okmdz3i2csw4u63de + globby: 11.1.0 + webpack: 5.74.0 + yorkie: 2.0.0 + transitivePeerDependencies: + - '@swc/core' + - encoding + - esbuild + - uglify-js + - webpack-cli + dev: true + + /@vue/cli-plugin-router/5.0.8_@vue+cli-service@5.0.8: + resolution: {integrity: sha512-Gmv4dsGdAsWPqVijz3Ux2OS2HkMrWi1ENj2cYL75nUeL+Xj5HEstSqdtfZ0b1q9NCce+BFB6QnHfTBXc/fCvMg==} + peerDependencies: + '@vue/cli-service': ^3.0.0 || ^4.0.0 || ^5.0.0-0 + dependencies: + '@vue/cli-service': 5.0.8_26d7a3va3smzyfrmey4itzvxkm + '@vue/cli-shared-utils': 5.0.8 + transitivePeerDependencies: + - encoding + dev: true + + /@vue/cli-plugin-unit-jest/5.0.8_@vue+cli-service@5.0.8: + resolution: {integrity: sha512-8aTmXUxEUdhJEjMHHoHI1wgi2SHzVRgCQQWIn5lgCAV2xJnXng09+wv8Ap0dhO4Z5vOOA/7xnubMQ9pDLqiskg==} + peerDependencies: + '@vue/cli-service': ^3.0.0 || ^4.0.0 || ^5.0.0-0 + '@vue/vue2-jest': ^27.0.0-alpha.3 + '@vue/vue3-jest': ^27.0.0-alpha.3 + ts-jest: ^27.0.4 + peerDependenciesMeta: + '@vue/vue2-jest': + optional: true + '@vue/vue3-jest': + optional: true + ts-jest: + optional: true + dependencies: + '@babel/core': 7.18.13 + '@babel/plugin-transform-modules-commonjs': 7.18.6_@babel+core@7.18.13 + '@types/jest': 27.5.2 + '@vue/cli-service': 5.0.8_26d7a3va3smzyfrmey4itzvxkm + '@vue/cli-shared-utils': 5.0.8 + babel-jest: 27.5.1_@babel+core@7.18.13 + deepmerge: 4.2.2 + jest: 27.5.1 + jest-serializer-vue: 2.0.2 + jest-transform-stub: 2.0.0 + jest-watch-typeahead: 1.1.0_jest@27.5.1 + transitivePeerDependencies: + - bufferutil + - canvas + - encoding + - node-notifier + - supports-color + - ts-node + - utf-8-validate + dev: true + + /@vue/cli-plugin-vuex/5.0.8_@vue+cli-service@5.0.8: + resolution: {integrity: sha512-HSYWPqrunRE5ZZs8kVwiY6oWcn95qf/OQabwLfprhdpFWAGtLStShjsGED2aDpSSeGAskQETrtR/5h7VqgIlBA==} + peerDependencies: + '@vue/cli-service': ^3.0.0 || ^4.0.0 || ^5.0.0-0 + dependencies: + '@vue/cli-service': 5.0.8_26d7a3va3smzyfrmey4itzvxkm + dev: true + + /@vue/cli-service/5.0.8_26d7a3va3smzyfrmey4itzvxkm: + resolution: {integrity: sha512-nV7tYQLe7YsTtzFrfOMIHc5N2hp5lHG2rpYr0aNja9rNljdgcPZLyQRb2YRivTHqTv7lI962UXFURcpStHgyFw==} + engines: {node: ^12.0.0 || >= 14.0.0} + hasBin: true + peerDependencies: + cache-loader: '*' + less-loader: '*' + pug-plain-loader: '*' + raw-loader: '*' + sass-loader: '*' + stylus-loader: '*' + vue-template-compiler: ^2.0.0 + webpack-sources: '*' + peerDependenciesMeta: + cache-loader: + optional: true + less-loader: + optional: true + pug-plain-loader: + optional: true + raw-loader: + optional: true + sass-loader: + optional: true + stylus-loader: + optional: true + vue-template-compiler: + optional: true + webpack-sources: + optional: true + dependencies: + '@babel/helper-compilation-targets': 7.18.9 + '@soda/friendly-errors-webpack-plugin': 1.8.1_webpack@5.74.0 + '@soda/get-current-script': 1.0.2 + '@types/minimist': 1.2.2 + '@vue/cli-overlay': 5.0.8 + '@vue/cli-plugin-router': 5.0.8_@vue+cli-service@5.0.8 + '@vue/cli-plugin-vuex': 5.0.8_@vue+cli-service@5.0.8 + '@vue/cli-shared-utils': 5.0.8 + '@vue/component-compiler-utils': 3.3.0 + '@vue/vue-loader-v15': /vue-loader/15.10.0_vsyllalxesco42bbuzcqgel7xq + '@vue/web-component-wrapper': 1.3.0 + acorn: 8.8.0 + acorn-walk: 8.2.0 + address: 1.2.0 + autoprefixer: 10.4.8_postcss@8.4.16 + browserslist: 4.21.3 + case-sensitive-paths-webpack-plugin: 2.4.0 + cli-highlight: 2.1.11 + clipboardy: 2.3.0 + cliui: 7.0.4 + copy-webpack-plugin: 9.1.0_webpack@5.74.0 + css-loader: 6.7.1_webpack@5.74.0 + css-minimizer-webpack-plugin: 3.4.1_webpack@5.74.0 + cssnano: 5.1.13_postcss@8.4.16 + debug: 4.3.4 + default-gateway: 6.0.3 + dotenv: 10.0.0 + dotenv-expand: 5.1.0 + fs-extra: 9.1.0 + globby: 11.1.0 + hash-sum: 2.0.0 + html-webpack-plugin: 5.5.0_webpack@5.74.0 + is-file-esm: 1.0.0 + launch-editor-middleware: 2.6.0 + less-loader: 5.0.0_less@3.13.1 + lodash.defaultsdeep: 4.6.1 + lodash.mapvalues: 4.6.0 + mini-css-extract-plugin: 2.6.1_webpack@5.74.0 + minimist: 1.2.6 + module-alias: 2.2.2 + portfinder: 1.0.32 + postcss: 8.4.16 + postcss-loader: 6.2.1_qjv4cptcpse3y5hrjkrbb7drda + progress-webpack-plugin: 1.0.16_webpack@5.74.0 + ssri: 8.0.1 + terser-webpack-plugin: 5.3.5_webpack@5.74.0 + thread-loader: 3.0.4_webpack@5.74.0 + vue-loader: 17.0.0_webpack@5.74.0 + vue-style-loader: 4.1.3 + vue-template-compiler: 2.7.10 + webpack: 5.74.0 + webpack-bundle-analyzer: 4.6.1 + webpack-chain: 6.5.1 + webpack-dev-server: 4.10.0_debug@4.3.4+webpack@5.74.0 + webpack-merge: 5.8.0 + webpack-virtual-modules: 0.4.4 + whatwg-fetch: 3.6.2 + transitivePeerDependencies: + - '@babel/core' + - '@parcel/css' + - '@swc/core' + - '@vue/compiler-sfc' + - arc-templates + - atpl + - babel-core + - bracket-template + - bufferutil + - clean-css + - coffee-script + - csso + - dot + - dust + - dustjs-helpers + - dustjs-linkedin + - eco + - ect + - ejs + - encoding + - esbuild + - haml-coffee + - hamlet + - hamljs + - handlebars + - hogan.js + - htmling + - jade + - jazz + - jqtpl + - just + - liquid-node + - liquor + - lodash + - marko + - mote + - mustache + - nunjucks + - plates + - pug + - qejs + - ractive + - razor-tmpl + - react + - react-dom + - slm + - squirrelly + - supports-color + - swig + - swig-templates + - teacup + - templayed + - then-jade + - then-pug + - tinyliquid + - toffee + - twig + - twing + - uglify-js + - underscore + - utf-8-validate + - vash + - velocityjs + - walrus + - webpack-cli + - whiskers + dev: true + + /@vue/cli-shared-utils/5.0.8: + resolution: {integrity: sha512-uK2YB7bBVuQhjOJF+O52P9yFMXeJVj7ozqJkwYE9PlMHL1LMHjtCYm4cSdOebuPzyP+/9p0BimM/OqxsevIopQ==} + dependencies: + '@achrinza/node-ipc': 9.2.5 + chalk: 4.1.2 + execa: 1.0.0 + joi: 17.6.0 + launch-editor: 2.6.0 + lru-cache: 6.0.0 + node-fetch: 2.6.7 + open: 8.4.0 + ora: 5.4.1 + read-pkg: 5.2.0 + semver: 7.3.7 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - encoding + dev: true + + /@vue/compiler-sfc/2.7.10: + resolution: {integrity: sha512-55Shns6WPxlYsz4WX7q9ZJBL77sKE1ZAYNYStLs6GbhIOMrNtjMvzcob6gu3cGlfpCR4bT7NXgyJ3tly2+Hx8Q==} + dependencies: + '@babel/parser': 7.18.13 + postcss: 8.4.16 + source-map: 0.6.1 + + /@vue/component-compiler-utils/3.3.0: + resolution: {integrity: sha512-97sfH2mYNU+2PzGrmK2haqffDpVASuib9/w2/noxiFi31Z54hW+q3izKQXXQZSNhtiUpAI36uSuYepeBe4wpHQ==} + dependencies: + consolidate: 0.15.1 + hash-sum: 1.0.2 + lru-cache: 4.1.5 + merge-source-map: 1.1.0 + postcss: 7.0.39 + postcss-selector-parser: 6.0.10 + source-map: 0.6.1 + vue-template-es2015-compiler: 1.9.1 + optionalDependencies: + prettier: 2.7.1 + transitivePeerDependencies: + - arc-templates + - atpl + - babel-core + - bracket-template + - coffee-script + - dot + - dust + - dustjs-helpers + - dustjs-linkedin + - eco + - ect + - ejs + - haml-coffee + - hamlet + - hamljs + - handlebars + - hogan.js + - htmling + - jade + - jazz + - jqtpl + - just + - liquid-node + - liquor + - lodash + - marko + - mote + - mustache + - nunjucks + - plates + - pug + - qejs + - ractive + - razor-tmpl + - react + - react-dom + - slm + - squirrelly + - swig + - swig-templates + - teacup + - templayed + - then-jade + - then-pug + - tinyliquid + - toffee + - twig + - twing + - underscore + - vash + - velocityjs + - walrus + - whiskers + dev: true + + /@vue/eslint-config-standard/4.0.0_eslint@7.32.0: + resolution: {integrity: sha512-bQghq1cw1BuMRHNhr3tRpAJx1tpGy0QtajQX873kLtA9YVuOIoXR7nAWnTN09bBHnSUh2N288vMsqPi2fI4Hzg==} + dependencies: + eslint-config-standard: 12.0.0_txevqkd2r4b3zo3ootu35nzt3u + eslint-plugin-import: 2.26.0_eslint@7.32.0 + eslint-plugin-node: 8.0.1_eslint@7.32.0 + eslint-plugin-promise: 4.3.1 + eslint-plugin-standard: 4.1.0_eslint@7.32.0 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + dev: true + + /@vue/test-utils/1.3.0_42puyn3dcxirnpdjnosl7pbb6a: + resolution: {integrity: sha512-Xk2Xiyj2k5dFb8eYUKkcN9PzqZSppTlx7LaQWBbdA8tqh3jHr/KHX2/YLhNFc/xwDrgeLybqd+4ZCPJSGPIqeA==} + peerDependencies: + vue: 2.x + vue-template-compiler: ^2.x + dependencies: + dom-event-types: 1.1.0 + lodash: 4.17.21 + pretty: 2.0.0 + vue: 2.7.10 + vue-template-compiler: 2.7.10 + dev: true + + /@vue/web-component-wrapper/1.3.0: + resolution: {integrity: sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==} + dev: true + + /@webassemblyjs/ast/1.11.1: + resolution: {integrity: sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==} + dependencies: + '@webassemblyjs/helper-numbers': 1.11.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.1 + dev: true + + /@webassemblyjs/floating-point-hex-parser/1.11.1: + resolution: {integrity: sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==} + dev: true + + /@webassemblyjs/helper-api-error/1.11.1: + resolution: {integrity: sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==} + dev: true + + /@webassemblyjs/helper-buffer/1.11.1: + resolution: {integrity: sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==} + dev: true + + /@webassemblyjs/helper-numbers/1.11.1: + resolution: {integrity: sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==} + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.11.1 + '@webassemblyjs/helper-api-error': 1.11.1 + '@xtuc/long': 4.2.2 + dev: true + + /@webassemblyjs/helper-wasm-bytecode/1.11.1: + resolution: {integrity: sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==} + dev: true + + /@webassemblyjs/helper-wasm-section/1.11.1: + resolution: {integrity: sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==} + dependencies: + '@webassemblyjs/ast': 1.11.1 + '@webassemblyjs/helper-buffer': 1.11.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.1 + '@webassemblyjs/wasm-gen': 1.11.1 + dev: true + + /@webassemblyjs/ieee754/1.11.1: + resolution: {integrity: sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==} + dependencies: + '@xtuc/ieee754': 1.2.0 + dev: true + + /@webassemblyjs/leb128/1.11.1: + resolution: {integrity: sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==} + dependencies: + '@xtuc/long': 4.2.2 + dev: true + + /@webassemblyjs/utf8/1.11.1: + resolution: {integrity: sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==} + dev: true + + /@webassemblyjs/wasm-edit/1.11.1: + resolution: {integrity: sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==} + dependencies: + '@webassemblyjs/ast': 1.11.1 + '@webassemblyjs/helper-buffer': 1.11.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.1 + '@webassemblyjs/helper-wasm-section': 1.11.1 + '@webassemblyjs/wasm-gen': 1.11.1 + '@webassemblyjs/wasm-opt': 1.11.1 + '@webassemblyjs/wasm-parser': 1.11.1 + '@webassemblyjs/wast-printer': 1.11.1 + dev: true + + /@webassemblyjs/wasm-gen/1.11.1: + resolution: {integrity: sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==} + dependencies: + '@webassemblyjs/ast': 1.11.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.1 + '@webassemblyjs/ieee754': 1.11.1 + '@webassemblyjs/leb128': 1.11.1 + '@webassemblyjs/utf8': 1.11.1 + dev: true + + /@webassemblyjs/wasm-opt/1.11.1: + resolution: {integrity: sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==} + dependencies: + '@webassemblyjs/ast': 1.11.1 + '@webassemblyjs/helper-buffer': 1.11.1 + '@webassemblyjs/wasm-gen': 1.11.1 + '@webassemblyjs/wasm-parser': 1.11.1 + dev: true + + /@webassemblyjs/wasm-parser/1.11.1: + resolution: {integrity: sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==} + dependencies: + '@webassemblyjs/ast': 1.11.1 + '@webassemblyjs/helper-api-error': 1.11.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.1 + '@webassemblyjs/ieee754': 1.11.1 + '@webassemblyjs/leb128': 1.11.1 + '@webassemblyjs/utf8': 1.11.1 + dev: true + + /@webassemblyjs/wast-printer/1.11.1: + resolution: {integrity: sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==} + dependencies: + '@webassemblyjs/ast': 1.11.1 + '@xtuc/long': 4.2.2 + dev: true + + /@xtuc/ieee754/1.2.0: + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + dev: true + + /@xtuc/long/4.2.2: + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + dev: true + + /JSONStream/1.3.5: + resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} + hasBin: true + dependencies: + jsonparse: 1.3.1 + through: 2.3.8 + dev: true + + /abab/2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + dev: true + + /abbrev/1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + dev: true + + /abs-svg-path/0.1.1: + resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==} + dev: false + + /accepts/1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + dev: true + + /acorn-globals/6.0.0: + resolution: {integrity: sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==} + dependencies: + acorn: 7.4.1 + acorn-walk: 7.2.0 + dev: true + + /acorn-import-assertions/1.8.0_acorn@8.8.0: + resolution: {integrity: sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==} + peerDependencies: + acorn: ^8 + dependencies: + acorn: 8.8.0 + dev: true + + /acorn-jsx/5.3.2_acorn@6.4.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 6.4.2 + dev: true + + /acorn-jsx/5.3.2_acorn@7.4.1: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 7.4.1 + dev: true + + /acorn-walk/7.2.0: + resolution: {integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==} + engines: {node: '>=0.4.0'} + dev: true + + /acorn-walk/8.2.0: + resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} + engines: {node: '>=0.4.0'} + dev: true + + /acorn/6.4.2: + resolution: {integrity: sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /acorn/7.4.1: + resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /acorn/8.8.0: + resolution: {integrity: sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /add-dom-event-listener/1.1.0: + resolution: {integrity: sha512-WCxx1ixHT0GQU9hb0KI/mhgRQhnU+U3GvwY6ZvVjYq8rsihIGoaIOUbY0yMPBxLH5MDtr0kz3fisWGNcbWW7Jw==} + dependencies: + object-assign: 4.1.1 + dev: false + + /address/1.2.0: + resolution: {integrity: sha512-tNEZYz5G/zYunxFm7sfhAxkXEuLj3K6BKwv6ZURlsF6yiUQ65z0Q2wZW9L5cPUl9ocofGvXOdFYbFHp0+6MOig==} + engines: {node: '>= 10.0.0'} + dev: true + + /agent-base/6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + dependencies: + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + + /aggregate-error/3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + dev: true + + /ajv-formats/2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependenciesMeta: + ajv: + optional: true + dependencies: + ajv: 8.11.0 + dev: true + + /ajv-keywords/3.5.2_ajv@6.12.6: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + dependencies: + ajv: 6.12.6 + + /ajv-keywords/5.1.0_ajv@8.11.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + dependencies: + ajv: 8.11.0 + fast-deep-equal: 3.1.3 + dev: true + + /ajv/6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + /ajv/8.11.0: + resolution: {integrity: sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==} + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + dev: true + + /align-text/0.1.4: + resolution: {integrity: sha512-GrTZLRpmp6wIC2ztrWW9MjjTgSKccffgFagbNDOX95/dcjEcYZibYTeaOntySQLcdw1ztBoFkviiUvTMbb9MYg==} + engines: {node: '>=0.10.0'} + dependencies: + kind-of: 3.2.2 + longest: 1.0.1 + repeat-string: 1.6.1 + dev: false + + /amdefine/1.0.1: + resolution: {integrity: sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==} + engines: {node: '>=0.4.2'} + dev: false + + /ansi-colors/4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + dev: true + + /ansi-escapes/3.2.0: + resolution: {integrity: sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==} + engines: {node: '>=4'} + dev: true + + /ansi-escapes/4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.21.3 + dev: true + + /ansi-html-community/0.0.8: + resolution: {integrity: sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==} + engines: {'0': node >= 0.8.0} + hasBin: true + dev: true + + /ansi-regex/2.1.1: + resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==} + engines: {node: '>=0.10.0'} + dev: false + + /ansi-regex/3.0.1: + resolution: {integrity: sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==} + engines: {node: '>=4'} + dev: true + + /ansi-regex/5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + dev: true + + /ansi-regex/6.0.1: + resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} + engines: {node: '>=12'} + dev: true + + /ansi-styles/2.2.1: + resolution: {integrity: sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==} + engines: {node: '>=0.10.0'} + dev: false + + /ansi-styles/3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + dependencies: + color-convert: 1.9.3 + dev: true + + /ansi-styles/4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + dependencies: + color-convert: 2.0.1 + dev: true + + /ansi-styles/5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + dev: true + + /ansi-styles/6.1.0: + resolution: {integrity: sha512-VbqNsoz55SYGczauuup0MFUyXNQviSpFTj1RQtFzmQLk18qbVSpTFFGMT293rmDaQuKCT6InmbuEyUne4mTuxQ==} + engines: {node: '>=12'} + dev: true + + /ant-design-vue/1.7.8_42puyn3dcxirnpdjnosl7pbb6a: + resolution: {integrity: sha512-F1hmiS9vwbyfuFvlamdW5l9bHKqRlj9wHaGDIE41NZMWXyWy8qL0UFa/+I0Wl8gQWZCqODW5pN6Yfoyn85At3A==} + requiresBuild: true + peerDependencies: + vue: ^2.6.0 + vue-template-compiler: ^2.6.0 + dependencies: + '@ant-design/icons': 2.1.1 + '@ant-design/icons-vue': 2.0.0_4oqadidiaueivzvlpdylfsa7xi + '@simonwep/pickr': 1.7.4 + add-dom-event-listener: 1.1.0 + array-tree-filter: 2.1.0 + async-validator: 3.5.2 + babel-helper-vue-jsx-merge-props: 2.0.3 + babel-runtime: 6.26.0 + classnames: 2.3.1 + component-classes: 1.2.6 + dom-align: 1.12.3 + dom-closest: 0.2.0 + dom-scroll-into-view: 2.0.1 + enquire.js: 2.1.6 + intersperse: 1.0.0 + is-mobile: 2.2.2 + is-negative-zero: 2.0.2 + ismobilejs: 1.1.1 + json2mq: 0.2.0 + lodash: 4.17.21 + moment: 2.29.4 + mutationobserver-shim: 0.3.7 + node-emoji: 1.11.0 + omit.js: 1.0.2 + raf: 3.4.1 + resize-observer-polyfill: 1.5.1 + shallow-equal: 1.2.1 + shallowequal: 1.1.0 + vue: 2.7.10 + vue-ref: 2.0.0 + vue-template-compiler: 2.7.10 + warning: 4.0.3 + dev: false + + /any-promise/1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + dev: true + + /anymatch/3.1.2: + resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: true + + /arch/2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + dev: true + + /arg/4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + dev: true + optional: true + + /argparse/1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + dependencies: + sprintf-js: 1.0.3 + dev: true + + /array-flatten/1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + dev: true + + /array-flatten/2.1.2: + resolution: {integrity: sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==} + dev: true + + /array-ify/1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + dev: true + + /array-includes/3.1.5: + resolution: {integrity: sha512-iSDYZMMyTPkiFasVqfuAQnWAYcvO/SeBSCGKePoEthjp4LEMTe4uLc7b025o4jAZpHhihh8xPo99TNWUWWkGDQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.1.4 + es-abstract: 1.20.1 + get-intrinsic: 1.1.2 + is-string: 1.0.7 + dev: true + + /array-tree-filter/2.1.0: + resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==} + dev: false + + /array-union/2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + dev: true + + /array.prototype.flat/1.3.0: + resolution: {integrity: sha512-12IUEkHsAhA4DY5s0FPgNXIdc8VRSqD9Zp78a5au9abH/SOBrsp082JOWFNTjkMozh8mqcdiKuaLGhPeYztxSw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.1.4 + es-abstract: 1.20.1 + es-shim-unscopables: 1.0.0 + dev: true + + /array.prototype.reduce/1.0.4: + resolution: {integrity: sha512-WnM+AjG/DvLRLo4DDl+r+SvCzYtD2Jd9oeBYMcEaI7t3fFrHY9M53/wdLcTvmZNQ70IU6Htj0emFkZ5TS+lrdw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.1.4 + es-abstract: 1.20.1 + es-array-method-boxes-properly: 1.0.0 + is-string: 1.0.7 + dev: true + + /arrify/1.0.1: + resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} + engines: {node: '>=0.10.0'} + dev: true + + /astral-regex/2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + dev: true + + /async-validator/3.5.2: + resolution: {integrity: sha512-8eLCg00W9pIRZSB781UUX/H6Oskmm8xloZfr09lz5bikRpBVDlJ3hRVuxxP1SxcwsEYfJ4IU8Q19Y8/893r3rQ==} + dev: false + + /async/2.6.4: + resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} + dependencies: + lodash: 4.17.21 + dev: true + + /asynckit/0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + dev: true + + /at-least-node/1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + dev: true + + /autoprefixer/10.4.8_postcss@8.4.16: + resolution: {integrity: sha512-75Jr6Q/XpTqEf6D2ltS5uMewJIx5irCU1oBYJrWjFenq/m12WRRrz6g15L1EIoYvPLXTbEry7rDOwrcYNj77xw==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + dependencies: + browserslist: 4.21.3 + caniuse-lite: 1.0.30001382 + fraction.js: 4.2.0 + normalize-range: 0.1.2 + picocolors: 1.0.0 + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + dev: true + + /axios/0.26.1: + resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==} + dependencies: + follow-redirects: 1.15.1 + transitivePeerDependencies: + - debug + dev: false + + /babel-eslint/10.1.0_eslint@7.32.0: + resolution: {integrity: sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==} + engines: {node: '>=6'} + deprecated: babel-eslint is now @babel/eslint-parser. This package will no longer receive updates. + peerDependencies: + eslint: '>= 4.12.1' + dependencies: + '@babel/code-frame': 7.18.6 + '@babel/parser': 7.18.13 + '@babel/traverse': 7.18.13 + '@babel/types': 7.18.13 + eslint: 7.32.0 + eslint-visitor-keys: 1.3.0 + resolve: 1.22.1 + transitivePeerDependencies: + - supports-color + dev: true + + /babel-helper-vue-jsx-merge-props/2.0.3: + resolution: {integrity: sha512-gsLiKK7Qrb7zYJNgiXKpXblxbV5ffSwR0f5whkPAaBAR4fhi6bwRZxX9wBlIc5M/v8CCkXUbXZL4N/nSE97cqg==} + dev: false + + /babel-jest/27.5.1_@babel+core@7.18.13: + resolution: {integrity: sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + dependencies: + '@babel/core': 7.18.13 + '@jest/transform': 27.5.1 + '@jest/types': 27.5.1 + '@types/babel__core': 7.1.19 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 27.5.1_@babel+core@7.18.13 + chalk: 4.1.2 + graceful-fs: 4.2.10 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /babel-loader/8.2.5: + resolution: {integrity: sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==} + engines: {node: '>= 8.9'} + peerDependencies: + '@babel/core': ^7.0.0 + webpack: '>=2' + dependencies: + find-cache-dir: 3.3.2 + loader-utils: 2.0.2 + make-dir: 3.1.0 + schema-utils: 2.7.1 + dev: false + + /babel-loader/8.2.5_tb6moc662p5idmcg3l5ipbhpta: + resolution: {integrity: sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==} + engines: {node: '>= 8.9'} + peerDependencies: + '@babel/core': ^7.0.0 + webpack: '>=2' + dependencies: + '@babel/core': 7.18.13 + find-cache-dir: 3.3.2 + loader-utils: 2.0.2 + make-dir: 3.1.0 + schema-utils: 2.7.1 + webpack: 5.74.0 + dev: true + + /babel-plugin-dynamic-import-node/2.3.3: + resolution: {integrity: sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==} + dependencies: + object.assign: 4.1.4 + dev: true + + /babel-plugin-import/1.13.5: + resolution: {integrity: sha512-IkqnoV+ov1hdJVofly9pXRJmeDm9EtROfrc5i6eII0Hix2xMs5FEm8FG3ExMvazbnZBbgHIt6qdO8And6lCloQ==} + dependencies: + '@babel/helper-module-imports': 7.18.6 + dev: true + + /babel-plugin-istanbul/6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + dependencies: + '@babel/helper-plugin-utils': 7.18.9 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.0 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /babel-plugin-jest-hoist/27.5.1: + resolution: {integrity: sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@babel/template': 7.18.10 + '@babel/types': 7.18.13 + '@types/babel__core': 7.1.19 + '@types/babel__traverse': 7.18.0 + dev: true + + /babel-plugin-polyfill-corejs2/0.3.2_@babel+core@7.18.13: + resolution: {integrity: sha512-LPnodUl3lS0/4wN3Rb+m+UK8s7lj2jcLRrjho4gLw+OJs+I4bvGXshINesY5xx/apM+biTnQ9reDI8yj+0M5+Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/compat-data': 7.18.13 + '@babel/core': 7.18.13 + '@babel/helper-define-polyfill-provider': 0.3.2_@babel+core@7.18.13 + semver: 6.3.0 + transitivePeerDependencies: + - supports-color + dev: true + + /babel-plugin-polyfill-corejs3/0.5.3_@babel+core@7.18.13: + resolution: {integrity: sha512-zKsXDh0XjnrUEW0mxIHLfjBfnXSMr5Q/goMe/fxpQnLm07mcOZiIZHBNWCMx60HmdvjxfXcalac0tfFg0wqxyw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-define-polyfill-provider': 0.3.2_@babel+core@7.18.13 + core-js-compat: 3.24.1 + transitivePeerDependencies: + - supports-color + dev: true + + /babel-plugin-polyfill-regenerator/0.4.0_@babel+core@7.18.13: + resolution: {integrity: sha512-RW1cnryiADFeHmfLS+WW/G431p1PsW5qdRdz0SDRi7TKcUgc7Oh/uXkT7MZ/+tGsT1BkczEAmD5XjUyJ5SWDTw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.13 + '@babel/helper-define-polyfill-provider': 0.3.2_@babel+core@7.18.13 + transitivePeerDependencies: + - supports-color + dev: true + + /babel-plugin-transform-remove-console/6.9.4: + resolution: {integrity: sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==} + dev: true + + /babel-preset-current-node-syntax/1.0.1_@babel+core@7.18.13: + resolution: {integrity: sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.18.13 + '@babel/plugin-syntax-async-generators': 7.8.4_@babel+core@7.18.13 + '@babel/plugin-syntax-bigint': 7.8.3_@babel+core@7.18.13 + '@babel/plugin-syntax-class-properties': 7.12.13_@babel+core@7.18.13 + '@babel/plugin-syntax-import-meta': 7.10.4_@babel+core@7.18.13 + '@babel/plugin-syntax-json-strings': 7.8.3_@babel+core@7.18.13 + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4_@babel+core@7.18.13 + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3_@babel+core@7.18.13 + '@babel/plugin-syntax-numeric-separator': 7.10.4_@babel+core@7.18.13 + '@babel/plugin-syntax-object-rest-spread': 7.8.3_@babel+core@7.18.13 + '@babel/plugin-syntax-optional-catch-binding': 7.8.3_@babel+core@7.18.13 + '@babel/plugin-syntax-optional-chaining': 7.8.3_@babel+core@7.18.13 + '@babel/plugin-syntax-top-level-await': 7.14.5_@babel+core@7.18.13 + dev: true + + /babel-preset-jest/27.5.1_@babel+core@7.18.13: + resolution: {integrity: sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.18.13 + babel-plugin-jest-hoist: 27.5.1 + babel-preset-current-node-syntax: 1.0.1_@babel+core@7.18.13 + dev: true + + /babel-runtime/6.26.0: + resolution: {integrity: sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==} + dependencies: + core-js: 2.6.12 + regenerator-runtime: 0.11.1 + dev: false + + /balanced-match/1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + /balanced-match/2.0.0: + resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + dev: true + + /base64-js/1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + dev: true + + /batch-processor/1.0.0: + resolution: {integrity: sha512-xoLQD8gmmR32MeuBHgH0Tzd5PuSZx71ZsbhVxOCRbgktZEPe4SQy7s9Z50uPp0F/f7iw2XmkHN2xkgbMfckMDA==} + dev: false + + /batch/0.6.1: + resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} + dev: true + + /big.js/3.2.0: + resolution: {integrity: sha512-+hN/Zh2D08Mx65pZ/4g5bsmNiZUuChDiQfTUQ7qJr4/kuopCr88xZsAXv6mBoZEsUI4OuGHlX59qE94K2mMW8Q==} + dev: true + + /big.js/5.2.2: + resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} + + /binary-extensions/2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + dev: true + + /bl/4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.0 + dev: true + + /bluebird/3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + dev: true + + /body-parser/1.20.0: + resolution: {integrity: sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + dependencies: + bytes: 3.1.2 + content-type: 1.0.4 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.10.3 + raw-body: 2.5.1 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /bonjour-service/1.0.13: + resolution: {integrity: sha512-LWKRU/7EqDUC9CTAQtuZl5HzBALoCYwtLhffW3et7vZMwv3bWLpJf8bRYlMD5OCcDpTfnPgNCV4yo9ZIaJGMiA==} + dependencies: + array-flatten: 2.1.2 + dns-equal: 1.0.0 + fast-deep-equal: 3.1.3 + multicast-dns: 7.2.5 + dev: true + + /boolbase/1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + dev: true + + /brace-expansion/1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + /brace-expansion/2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + dependencies: + balanced-match: 1.0.2 + dev: true + + /braces/3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + dev: true + + /browser-process-hrtime/1.0.0: + resolution: {integrity: sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==} + dev: true + + /browserslist/4.21.3: + resolution: {integrity: sha512-898rgRXLAyRkM1GryrrBHGkqA5hlpkV5MhtZwg9QXeiyLUYs2k00Un05aX5l2/yJIOObYKOpS2JNo8nJDE7fWQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001382 + electron-to-chromium: 1.4.228 + node-releases: 2.0.6 + update-browserslist-db: 1.0.5_browserslist@4.21.3 + dev: true + + /bser/2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + dependencies: + node-int64: 0.4.0 + dev: true + + /buffer-from/1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + dev: true + + /buffer/5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: true + + /bytes/3.0.0: + resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + engines: {node: '>= 0.8'} + dev: true + + /bytes/3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + dev: true + + /cachedir/2.3.0: + resolution: {integrity: sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==} + engines: {node: '>=6'} + dev: true + + /call-bind/1.0.2: + resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} + dependencies: + function-bind: 1.1.1 + get-intrinsic: 1.1.2 + + /callsites/3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + dev: true + + /camel-case/4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + dependencies: + pascal-case: 3.1.2 + tslib: 2.4.0 + dev: true + + /camelcase-keys/6.2.2: + resolution: {integrity: sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==} + engines: {node: '>=8'} + dependencies: + camelcase: 5.3.1 + map-obj: 4.3.0 + quick-lru: 4.0.1 + dev: true + + /camelcase/1.2.1: + resolution: {integrity: sha512-wzLkDa4K/mzI1OSITC+DUyjgIl/ETNHE9QvYgy6J6Jvqyyz4C0Xfd+lQhb19sX2jMpZV4IssUn0VDVmglV+s4g==} + engines: {node: '>=0.10.0'} + dev: false + + /camelcase/5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + dev: true + + /camelcase/6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + dev: true + + /caniuse-api/3.0.0: + resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} + dependencies: + browserslist: 4.21.3 + caniuse-lite: 1.0.30001382 + lodash.memoize: 4.1.2 + lodash.uniq: 4.5.0 + dev: true + + /caniuse-lite/1.0.30001382: + resolution: {integrity: sha512-2rtJwDmSZ716Pxm1wCtbPvHtbDWAreTPxXbkc5RkKglow3Ig/4GNGazDI9/BVnXbG/wnv6r3B5FEbkfg9OcTGg==} + dev: true + + /case-sensitive-paths-webpack-plugin/2.4.0: + resolution: {integrity: sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==} + engines: {node: '>=4'} + dev: true + + /center-align/0.1.3: + resolution: {integrity: sha512-Baz3aNe2gd2LP2qk5U+sDk/m4oSuwSDcBfayTCTBoWpfIGO5XFxPmjILQII4NGiZjD6DoDI6kf7gKaxkf7s3VQ==} + engines: {node: '>=0.10.0'} + dependencies: + align-text: 0.1.4 + lazy-cache: 1.0.4 + dev: false + + /chalk/1.1.3: + resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==} + engines: {node: '>=0.10.0'} + dependencies: + ansi-styles: 2.2.1 + escape-string-regexp: 1.0.5 + has-ansi: 2.0.0 + strip-ansi: 3.0.1 + supports-color: 2.0.0 + dev: false + + /chalk/2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + dev: true + + /chalk/3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + dev: true + + /chalk/4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + dev: true + + /char-regex/1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + dev: true + + /char-regex/2.0.1: + resolution: {integrity: sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw==} + engines: {node: '>=12.20'} + dev: true + + /chardet/0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + dev: true + + /charenc/0.0.2: + resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + dev: false + + /chokidar/3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + requiresBuild: true + dependencies: + anymatch: 3.1.2 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /chrome-trace-event/1.0.3: + resolution: {integrity: sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==} + engines: {node: '>=6.0'} + dev: true + + /ci-info/1.6.0: + resolution: {integrity: sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==} + dev: true + + /ci-info/3.3.2: + resolution: {integrity: sha512-xmDt/QIAdeZ9+nfdPsaBCpMvHNLFiLdjj59qjqn+6iPe6YmHGQ35sBnQ8uslRBXFmXkiZQOJRjvQeoGppoTjjg==} + dev: true + + /cjs-module-lexer/1.2.2: + resolution: {integrity: sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==} + dev: true + + /classnames/2.3.1: + resolution: {integrity: sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==} + dev: false + + /clean-css/5.3.1: + resolution: {integrity: sha512-lCr8OHhiWCTw4v8POJovCoh4T7I9U11yVsPjMWWnnMmp9ZowCxyad1Pathle/9HjaDp+fdQKjO9fQydE6RHTZg==} + engines: {node: '>= 10.0'} + dependencies: + source-map: 0.6.1 + dev: true + + /clean-stack/2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + dev: true + + /cli-cursor/2.1.0: + resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} + engines: {node: '>=4'} + dependencies: + restore-cursor: 2.0.0 + dev: true + + /cli-cursor/3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + dependencies: + restore-cursor: 3.1.0 + dev: true + + /cli-highlight/2.1.11: + resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + dependencies: + chalk: 4.1.2 + highlight.js: 10.7.3 + mz: 2.7.0 + parse5: 5.1.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + yargs: 16.2.0 + dev: true + + /cli-spinners/2.7.0: + resolution: {integrity: sha512-qu3pN8Y3qHNgE2AFweciB1IfMnmZ/fsNTEE+NOFjmGB2F/7rLhnhzppvpCnN4FovtP26k8lHyy9ptEbNwWFLzw==} + engines: {node: '>=6'} + dev: true + + /cli-truncate/2.1.0: + resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} + engines: {node: '>=8'} + dependencies: + slice-ansi: 3.0.0 + string-width: 4.2.3 + dev: true + + /cli-truncate/3.1.0: + resolution: {integrity: sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + slice-ansi: 5.0.0 + string-width: 5.1.2 + dev: true + + /cli-width/3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + dev: true + + /clipboard/2.0.11: + resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==} + dependencies: + good-listener: 1.2.2 + select: 1.1.2 + tiny-emitter: 2.1.0 + dev: false + + /clipboardy/2.3.0: + resolution: {integrity: sha512-mKhiIL2DrQIsuXMgBgnfEHOZOryC7kY7YO//TN6c63wlEm3NG5tz+YgY5rVi29KCmq/QQjKYvM7a19+MDOTHOQ==} + engines: {node: '>=8'} + dependencies: + arch: 2.2.0 + execa: 1.0.0 + is-wsl: 2.2.0 + dev: true + + /cliui/2.1.0: + resolution: {integrity: sha512-GIOYRizG+TGoc7Wgc1LiOTLare95R3mzKgoln+Q/lE4ceiYH19gUpl0l0Ffq4lJDEf3FxujMe6IBfOCs7pfqNA==} + dependencies: + center-align: 0.1.3 + right-align: 0.1.3 + wordwrap: 0.0.2 + dev: false + + /cliui/7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + dev: true + + /clone-deep/4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} + dependencies: + is-plain-object: 2.0.4 + kind-of: 6.0.3 + shallow-clone: 3.0.1 + dev: true + + /clone/1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + dev: true + + /clone/2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + + /co/4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + dev: true + + /coa/2.0.2: + resolution: {integrity: sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA==} + engines: {node: '>= 4.0'} + dependencies: + '@types/q': 1.5.5 + chalk: 2.4.2 + q: 1.5.1 + dev: true + + /collect-v8-coverage/1.0.1: + resolution: {integrity: sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==} + dev: true + + /color-convert/1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + dependencies: + color-name: 1.1.3 + dev: true + + /color-convert/2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + dependencies: + color-name: 1.1.4 + dev: true + + /color-name/1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + dev: true + + /color-name/1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + dev: true + + /colord/2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + dev: true + + /colorette/2.0.19: + resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==} + dev: true + + /combined-stream/1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + dependencies: + delayed-stream: 1.0.0 + dev: true + + /commander/2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + /commander/7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + dev: true + + /commander/8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + dev: true + + /commander/9.4.0: + resolution: {integrity: sha512-sRPT+umqkz90UA8M1yqYfnHlZA7fF6nSphDtxeywPZ49ysjxDQybzk13CL+mXekDRG92skbcqCLVovuCusNmFw==} + engines: {node: ^12.20.0 || >=14} + + /commitizen/4.2.5: + resolution: {integrity: sha512-9sXju8Qrz1B4Tw7kC5KhnvwYQN88qs2zbiB8oyMsnXZyJ24PPGiNM3nHr73d32dnE3i8VJEXddBFIbOgYSEXtQ==} + engines: {node: '>= 12'} + hasBin: true + dependencies: + cachedir: 2.3.0 + cz-conventional-changelog: 3.3.0 + dedent: 0.7.0 + detect-indent: 6.1.0 + find-node-modules: 2.1.3 + find-root: 1.1.0 + fs-extra: 9.1.0 + glob: 7.2.3 + inquirer: 8.2.4 + is-utf8: 0.2.1 + lodash: 4.17.21 + minimist: 1.2.6 + strip-bom: 4.0.0 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + dev: true + + /commondir/1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + + /compare-func/2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + dev: true + + /component-classes/1.2.6: + resolution: {integrity: sha512-hPFGULxdwugu1QWW3SvVOCUHLzO34+a2J6Wqy0c5ASQkfi9/8nZcBB0ZohaEbXOQlCflMAEMmEWk7u7BVs4koA==} + dependencies: + component-indexof: 0.0.3 + dev: false + + /component-indexof/0.0.3: + resolution: {integrity: sha512-puDQKvx/64HZXb4hBwIcvQLaLgux8o1CbWl39s41hrIIZDl1lJiD5jc22gj3RBeGK0ovxALDYpIbyjqDUUl0rw==} + dev: false + + /compressible/2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: true + + /compression/1.7.4: + resolution: {integrity: sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==} + engines: {node: '>= 0.8.0'} + dependencies: + accepts: 1.3.8 + bytes: 3.0.0 + compressible: 2.0.18 + debug: 2.6.9 + on-headers: 1.0.2 + safe-buffer: 5.1.2 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + dev: true + + /concat-map/0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + /condense-newlines/0.2.1: + resolution: {integrity: sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg==} + engines: {node: '>=0.10.0'} + dependencies: + extend-shallow: 2.0.1 + is-whitespace: 0.3.0 + kind-of: 3.2.2 + dev: true + + /config-chain/1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + dev: true + + /connect-history-api-fallback/2.0.0: + resolution: {integrity: sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==} + engines: {node: '>=0.8'} + dev: true + + /consolidate/0.15.1: + resolution: {integrity: sha512-DW46nrsMJgy9kqAbPt5rKaCr7uFtpo4mSUvLHIUbJEjm0vo+aY5QLwBUq3FK4tRnJr/X0Psc0C4jf/h+HtXSMw==} + engines: {node: '>= 0.10.0'} + peerDependencies: + arc-templates: ^0.5.3 + atpl: '>=0.7.6' + babel-core: ^6.26.3 + bracket-template: ^1.1.5 + coffee-script: ^1.12.7 + dot: ^1.1.3 + dust: ^0.3.0 + dustjs-helpers: ^1.7.4 + dustjs-linkedin: ^2.7.5 + eco: ^1.1.0-rc-3 + ect: ^0.5.9 + ejs: ^3.1.5 + haml-coffee: ^1.14.1 + hamlet: ^0.3.3 + hamljs: ^0.6.2 + handlebars: ^4.7.6 + hogan.js: ^3.0.2 + htmling: ^0.0.8 + jade: ^1.11.0 + jazz: ^0.0.18 + jqtpl: ~1.1.0 + just: ^0.1.8 + liquid-node: ^3.0.1 + liquor: ^0.0.5 + lodash: ^4.17.20 + marko: ^3.14.4 + mote: ^0.2.0 + mustache: ^3.0.0 + nunjucks: ^3.2.2 + plates: ~0.4.11 + pug: ^3.0.0 + qejs: ^3.0.5 + ractive: ^1.3.12 + razor-tmpl: ^1.3.1 + react: ^16.13.1 + react-dom: ^16.13.1 + slm: ^2.0.0 + squirrelly: ^5.1.0 + swig: ^1.4.2 + swig-templates: ^2.0.3 + teacup: ^2.0.0 + templayed: '>=0.2.3' + then-jade: '*' + then-pug: '*' + tinyliquid: ^0.2.34 + toffee: ^0.3.6 + twig: ^1.15.2 + twing: ^5.0.2 + underscore: ^1.11.0 + vash: ^0.13.0 + velocityjs: ^2.0.1 + walrus: ^0.10.1 + whiskers: ^0.4.0 + peerDependenciesMeta: + arc-templates: + optional: true + atpl: + optional: true + babel-core: + optional: true + bracket-template: + optional: true + coffee-script: + optional: true + dot: + optional: true + dust: + optional: true + dustjs-helpers: + optional: true + dustjs-linkedin: + optional: true + eco: + optional: true + ect: + optional: true + ejs: + optional: true + haml-coffee: + optional: true + hamlet: + optional: true + hamljs: + optional: true + handlebars: + optional: true + hogan.js: + optional: true + htmling: + optional: true + jade: + optional: true + jazz: + optional: true + jqtpl: + optional: true + just: + optional: true + liquid-node: + optional: true + liquor: + optional: true + lodash: + optional: true + marko: + optional: true + mote: + optional: true + mustache: + optional: true + nunjucks: + optional: true + plates: + optional: true + pug: + optional: true + qejs: + optional: true + ractive: + optional: true + razor-tmpl: + optional: true + react: + optional: true + react-dom: + optional: true + slm: + optional: true + squirrelly: + optional: true + swig: + optional: true + swig-templates: + optional: true + teacup: + optional: true + templayed: + optional: true + then-jade: + optional: true + then-pug: + optional: true + tinyliquid: + optional: true + toffee: + optional: true + twig: + optional: true + twing: + optional: true + underscore: + optional: true + vash: + optional: true + velocityjs: + optional: true + walrus: + optional: true + whiskers: + optional: true + dependencies: + bluebird: 3.7.2 + dev: true + + /container-query-toolkit/0.1.3: + resolution: {integrity: sha512-B1EvYaLzFKz81vgWDm+zL0X7fzFUjlN6lF/RivDeNT4xW9mFsTh1oiC9rtvFFiwG52e3JUmYLXwPpqNBf2AXHA==} + dev: false + + /content-disposition/0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + dependencies: + safe-buffer: 5.2.1 + dev: true + + /content-type/1.0.4: + resolution: {integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==} + engines: {node: '>= 0.6'} + dev: true + + /contour_plot/0.0.1: + resolution: {integrity: sha512-Nil2HI76Xux6sVGORvhSS8v66m+/h5CwFkBJDO+U5vWaMdNC0yXNCsGDPbzPhvqOEU5koebhdEvD372LI+IyLw==} + dev: false + + /conventional-changelog-angular/5.0.13: + resolution: {integrity: sha512-i/gipMxs7s8L/QeuavPF2hLnJgH6pEZAttySB6aiQLWcX3puWDL3ACVmvBhJGxnAy52Qc15ua26BufY6KpmrVA==} + engines: {node: '>=10'} + dependencies: + compare-func: 2.0.0 + q: 1.5.1 + dev: true + + /conventional-changelog-conventionalcommits/4.6.3: + resolution: {integrity: sha512-LTTQV4fwOM4oLPad317V/QNQ1FY4Hju5qeBIM1uTHbrnCE+Eg4CdRZ3gO2pUeR+tzWdp80M2j3qFFEDWVqOV4g==} + engines: {node: '>=10'} + dependencies: + compare-func: 2.0.0 + lodash: 4.17.21 + q: 1.5.1 + dev: true + + /conventional-commit-types/3.0.0: + resolution: {integrity: sha512-SmmCYnOniSsAa9GqWOeLqc179lfr5TRu5b4QFDkbsrJ5TZjPJx85wtOr3zn+1dbeNiXDKGPbZ72IKbPhLXh/Lg==} + dev: true + + /conventional-commits-parser/3.2.4: + resolution: {integrity: sha512-nK7sAtfi+QXbxHCYfhpZsfRtaitZLIA6889kFIouLvz6repszQDgxBu7wf2WbU+Dco7sAnNCJYERCwt54WPC2Q==} + engines: {node: '>=10'} + hasBin: true + dependencies: + JSONStream: 1.3.5 + is-text-path: 1.0.1 + lodash: 4.17.21 + meow: 8.1.2 + split2: 3.2.2 + through2: 4.0.2 + dev: true + + /convert-source-map/1.8.0: + resolution: {integrity: sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==} + dependencies: + safe-buffer: 5.1.2 + dev: true + + /cookie-signature/1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + dev: true + + /cookie/0.5.0: + resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} + engines: {node: '>= 0.6'} + dev: true + + /copy-anything/2.0.6: + resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} + dependencies: + is-what: 3.14.1 + dev: true + + /copy-to-clipboard/3.3.2: + resolution: {integrity: sha512-Vme1Z6RUDzrb6xAI7EZlVZ5uvOk2F//GaxKUxajDqm9LhOVM1inxNAD2vy+UZDYsd0uyA9s7b3/FVZPSxqrCfg==} + dependencies: + toggle-selection: 1.0.6 + dev: false + + /copy-webpack-plugin/9.1.0_webpack@5.74.0: + resolution: {integrity: sha512-rxnR7PaGigJzhqETHGmAcxKnLZSR5u1Y3/bcIv/1FnqXedcL/E2ewK7ZCNrArJKCiSv8yVXhTqetJh8inDvfsA==} + engines: {node: '>= 12.13.0'} + peerDependencies: + webpack: ^5.1.0 + dependencies: + fast-glob: 3.2.11 + glob-parent: 6.0.2 + globby: 11.1.0 + normalize-path: 3.0.0 + schema-utils: 3.1.1 + serialize-javascript: 6.0.0 + webpack: 5.74.0 + dev: true + + /core-js-compat/3.24.1: + resolution: {integrity: sha512-XhdNAGeRnTpp8xbD+sR/HFDK9CbeeeqXT6TuofXh3urqEevzkWmLRgrVoykodsw8okqo2pu1BOmuCKrHx63zdw==} + dependencies: + browserslist: 4.21.3 + semver: 7.0.0 + dev: true + + /core-js/2.6.12: + resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + requiresBuild: true + dev: false + + /core-js/3.24.1: + resolution: {integrity: sha512-0QTBSYSUZ6Gq21utGzkfITDylE8jWC9Ne1D2MrhvlsZBI1x39OdDIVbzSqtgMndIy6BlHxBXpMGqzZmnztg2rg==} + requiresBuild: true + + /core-util-is/1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + dev: true + + /cosmiconfig-typescript-loader/2.0.2_57uwcby55h6tzvkj3v5sfcgxoe: + resolution: {integrity: sha512-KmE+bMjWMXJbkWCeY4FJX/npHuZPNr9XF9q9CIQ/bpFwi1qHfCmSiKarrCcRa0LO4fWjk93pVoeRtJAkTGcYNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@types/node': '*' + typescript: '>=3' + dependencies: + '@types/node': 18.7.13 + cosmiconfig: 7.0.1 + ts-node: 10.9.1_57uwcby55h6tzvkj3v5sfcgxoe + typescript: 4.7.4 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + dev: true + optional: true + + /cosmiconfig/7.0.1: + resolution: {integrity: sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==} + engines: {node: '>=10'} + dependencies: + '@types/parse-json': 4.0.0 + import-fresh: 3.3.0 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + dev: true + + /create-require/1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + dev: true + optional: true + + /cross-spawn/5.1.0: + resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} + dependencies: + lru-cache: 4.1.5 + shebang-command: 1.2.0 + which: 1.3.1 + dev: true + + /cross-spawn/6.0.5: + resolution: {integrity: sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==} + engines: {node: '>=4.8'} + dependencies: + nice-try: 1.0.5 + path-key: 2.0.1 + semver: 5.7.1 + shebang-command: 1.2.0 + which: 1.3.1 + dev: true + + /cross-spawn/7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: true + + /crypt/0.0.2: + resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} + dev: false + + /css-declaration-sorter/6.3.0_postcss@8.4.16: + resolution: {integrity: sha512-OGT677UGHJTAVMRhPO+HJ4oKln3wkBTwtDFH0ojbqm+MJm6xuDMHp2nkhh/ThaBqq20IbraBQSWKfSLNHQO9Og==} + engines: {node: ^10 || ^12 || >=14} + peerDependencies: + postcss: ^8.0.9 + dependencies: + postcss: 8.4.16 + dev: true + + /css-functions-list/3.1.0: + resolution: {integrity: sha512-/9lCvYZaUbBGvYUgYGFJ4dcYiyqdhSjG7IPVluoV8A1ILjkF7ilmhp1OGUz8n+nmBcu0RNrQAzgD8B6FJbrt2w==} + engines: {node: '>=12.22'} + dev: true + + /css-loader/6.7.1_webpack@5.74.0: + resolution: {integrity: sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==} + engines: {node: '>= 12.13.0'} + peerDependencies: + webpack: ^5.0.0 + dependencies: + icss-utils: 5.1.0_postcss@8.4.16 + postcss: 8.4.16 + postcss-modules-extract-imports: 3.0.0_postcss@8.4.16 + postcss-modules-local-by-default: 4.0.0_postcss@8.4.16 + postcss-modules-scope: 3.0.0_postcss@8.4.16 + postcss-modules-values: 4.0.0_postcss@8.4.16 + postcss-value-parser: 4.2.0 + semver: 7.3.7 + webpack: 5.74.0 + dev: true + + /css-minimizer-webpack-plugin/3.4.1_webpack@5.74.0: + resolution: {integrity: sha512-1u6D71zeIfgngN2XNRJefc/hY7Ybsxd74Jm4qngIXyUEk7fss3VUzuHxLAq/R8NAba4QU9OUSaMZlbpRc7bM4Q==} + engines: {node: '>= 12.13.0'} + peerDependencies: + '@parcel/css': '*' + clean-css: '*' + csso: '*' + esbuild: '*' + webpack: ^5.0.0 + peerDependenciesMeta: + '@parcel/css': + optional: true + clean-css: + optional: true + csso: + optional: true + esbuild: + optional: true + dependencies: + cssnano: 5.1.13_postcss@8.4.16 + jest-worker: 27.5.1 + postcss: 8.4.16 + schema-utils: 4.0.0 + serialize-javascript: 6.0.0 + source-map: 0.6.1 + webpack: 5.74.0 + dev: true + + /css-select-base-adapter/0.1.1: + resolution: {integrity: sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==} + dev: true + + /css-select/2.1.0: + resolution: {integrity: sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ==} + dependencies: + boolbase: 1.0.0 + css-what: 3.4.2 + domutils: 1.7.0 + nth-check: 1.0.2 + dev: true + + /css-select/4.3.0: + resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 4.3.1 + domutils: 2.8.0 + nth-check: 2.1.1 + dev: true + + /css-tree/1.0.0-alpha.37: + resolution: {integrity: sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==} + engines: {node: '>=8.0.0'} + dependencies: + mdn-data: 2.0.4 + source-map: 0.6.1 + dev: true + + /css-tree/1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + dev: true + + /css-what/3.4.2: + resolution: {integrity: sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==} + engines: {node: '>= 6'} + dev: true + + /css-what/6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + dev: true + + /cssesc/3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /cssnano-preset-default/5.2.12_postcss@8.4.16: + resolution: {integrity: sha512-OyCBTZi+PXgylz9HAA5kHyoYhfGcYdwFmyaJzWnzxuGRtnMw/kR6ilW9XzlzlRAtB6PLT/r+prYgkef7hngFew==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + css-declaration-sorter: 6.3.0_postcss@8.4.16 + cssnano-utils: 3.1.0_postcss@8.4.16 + postcss: 8.4.16 + postcss-calc: 8.2.4_postcss@8.4.16 + postcss-colormin: 5.3.0_postcss@8.4.16 + postcss-convert-values: 5.1.2_postcss@8.4.16 + postcss-discard-comments: 5.1.2_postcss@8.4.16 + postcss-discard-duplicates: 5.1.0_postcss@8.4.16 + postcss-discard-empty: 5.1.1_postcss@8.4.16 + postcss-discard-overridden: 5.1.0_postcss@8.4.16 + postcss-merge-longhand: 5.1.6_postcss@8.4.16 + postcss-merge-rules: 5.1.2_postcss@8.4.16 + postcss-minify-font-values: 5.1.0_postcss@8.4.16 + postcss-minify-gradients: 5.1.1_postcss@8.4.16 + postcss-minify-params: 5.1.3_postcss@8.4.16 + postcss-minify-selectors: 5.2.1_postcss@8.4.16 + postcss-normalize-charset: 5.1.0_postcss@8.4.16 + postcss-normalize-display-values: 5.1.0_postcss@8.4.16 + postcss-normalize-positions: 5.1.1_postcss@8.4.16 + postcss-normalize-repeat-style: 5.1.1_postcss@8.4.16 + postcss-normalize-string: 5.1.0_postcss@8.4.16 + postcss-normalize-timing-functions: 5.1.0_postcss@8.4.16 + postcss-normalize-unicode: 5.1.0_postcss@8.4.16 + postcss-normalize-url: 5.1.0_postcss@8.4.16 + postcss-normalize-whitespace: 5.1.1_postcss@8.4.16 + postcss-ordered-values: 5.1.3_postcss@8.4.16 + postcss-reduce-initial: 5.1.0_postcss@8.4.16 + postcss-reduce-transforms: 5.1.0_postcss@8.4.16 + postcss-svgo: 5.1.0_postcss@8.4.16 + postcss-unique-selectors: 5.1.1_postcss@8.4.16 + dev: true + + /cssnano-utils/3.1.0_postcss@8.4.16: + resolution: {integrity: sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + dev: true + + /cssnano/5.1.13_postcss@8.4.16: + resolution: {integrity: sha512-S2SL2ekdEz6w6a2epXn4CmMKU4K3KpcyXLKfAYc9UQQqJRkD/2eLUG0vJ3Db/9OvO5GuAdgXw3pFbR6abqghDQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + cssnano-preset-default: 5.2.12_postcss@8.4.16 + lilconfig: 2.0.6 + postcss: 8.4.16 + yaml: 1.10.2 + dev: true + + /csso/4.2.0: + resolution: {integrity: sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==} + engines: {node: '>=8.0.0'} + dependencies: + css-tree: 1.1.3 + dev: true + + /cssom/0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + dev: true + + /cssom/0.4.4: + resolution: {integrity: sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==} + dev: true + + /cssstyle/2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + dependencies: + cssom: 0.3.8 + dev: true + + /csstype/3.1.0: + resolution: {integrity: sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==} + + /cz-conventional-changelog/3.3.0: + resolution: {integrity: sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw==} + engines: {node: '>= 10'} + dependencies: + chalk: 2.4.2 + commitizen: 4.2.5 + conventional-commit-types: 3.0.0 + lodash.map: 4.6.0 + longest: 2.0.1 + word-wrap: 1.2.3 + optionalDependencies: + '@commitlint/load': 17.0.3 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + dev: true + + /d3-array/1.2.4: + resolution: {integrity: sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==} + dev: false + + /d3-collection/1.0.7: + resolution: {integrity: sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==} + dev: false + + /d3-color/1.4.1: + resolution: {integrity: sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==} + dev: false + + /d3-composite-projections/1.2.3: + resolution: {integrity: sha512-RxNBoRGf3epTnQBUKeEpaXpD8BA/Ud0xRuLwWxyI7dWfuuYgJZMKw6ZsZOwfDNC0ZbMWaU0eBFlL05A2jlcsWg==} + dependencies: + d3-geo: 1.12.1 + d3-path: 1.0.9 + dev: false + + /d3-dispatch/1.0.6: + resolution: {integrity: sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==} + dev: false + + /d3-dsv/1.0.10: + resolution: {integrity: sha512-vqklfpxmtO2ZER3fq/B33R/BIz3A1PV0FaZRuFM8w6jLo7sUX1BZDh73fPlr0s327rzq4H6EN1q9U+eCBCSN8g==} + hasBin: true + dependencies: + commander: 2.20.3 + iconv-lite: 0.4.24 + rw: 1.3.3 + dev: false + + /d3-ease/1.0.7: + resolution: {integrity: sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ==} + dev: false + + /d3-format/1.4.5: + resolution: {integrity: sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==} + dev: false + + /d3-geo-projection/2.1.2: + resolution: {integrity: sha512-zft6RRvPaB1qplTodBVcSH5Ftvmvvg0qoDiqpt+fyNthGr/qr+DD30cizNDluXjW7jmo7EKUTjvFCAHofv08Ow==} + hasBin: true + dependencies: + commander: 2.20.3 + d3-array: 1.2.4 + d3-geo: 1.6.4 + dev: false + + /d3-geo/1.12.1: + resolution: {integrity: sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==} + dependencies: + d3-array: 1.2.4 + dev: false + + /d3-geo/1.6.4: + resolution: {integrity: sha512-O5Q3iftLc6/EdU1MHUm+O29NoKKN/cyQtySnD9/yEEcinN+q4ng+H56e2Yn1YWdfZBoiaRVtR2NoJ3ivKX5ptQ==} + dependencies: + d3-array: 1.2.4 + dev: false + + /d3-hexjson/1.0.1: + resolution: {integrity: sha512-TeH4T0PSbDazMm3gHgc4ulO0PfrZpz0Uk3y5tCGz+NgC7HnX7KBdem7uAN+j9x3ZshTh7raN3V/bFhaLB2C8DA==} + dependencies: + d3-array: 1.2.4 + dev: false + + /d3-hierarchy/1.1.9: + resolution: {integrity: sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==} + dev: false + + /d3-interpolate/1.1.6: + resolution: {integrity: sha512-mOnv5a+pZzkNIHtw/V6I+w9Lqm9L5bG3OTXPM5A+QO0yyVMQ4W1uZhR+VOJmazaOZXri2ppbiZ5BUNWT0pFM9A==} + dependencies: + d3-color: 1.4.1 + dev: false + + /d3-interpolate/1.4.0: + resolution: {integrity: sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==} + dependencies: + d3-color: 1.4.1 + dev: false + + /d3-path/1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + dev: false + + /d3-sankey/0.7.1: + resolution: {integrity: sha512-KAyowBWtTLQxyXq1UhXcdCXKbuCQvL51FgqOS+fKlNTQ/4FfSWabRlWs2DezzwKyredAsOhBSQZN/i0XdeE2tQ==} + dependencies: + d3-array: 1.2.4 + d3-collection: 1.0.7 + d3-shape: 1.3.7 + dev: false + + /d3-selection/1.4.2: + resolution: {integrity: sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==} + dev: false + + /d3-shape/1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + dependencies: + d3-path: 1.0.9 + dev: false + + /d3-timer/1.0.10: + resolution: {integrity: sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==} + dev: false + + /d3-transition/1.3.2: + resolution: {integrity: sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==} + dependencies: + d3-color: 1.4.1 + d3-dispatch: 1.0.6 + d3-ease: 1.0.7 + d3-interpolate: 1.4.0 + d3-selection: 1.4.2 + d3-timer: 1.0.10 + dev: false + + /d3-voronoi/1.1.4: + resolution: {integrity: sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==} + dev: false + + /dagre/0.8.5: + resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==} + dependencies: + graphlib: 2.1.8 + lodash: 4.17.21 + dev: false + + /dargs/7.0.0: + resolution: {integrity: sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==} + engines: {node: '>=8'} + dev: true + + /data-urls/2.0.0: + resolution: {integrity: sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==} + engines: {node: '>=10'} + dependencies: + abab: 2.0.6 + whatwg-mimetype: 2.3.0 + whatwg-url: 8.7.0 + dev: true + + /de-indent/1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + /debug/2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.0.0 + dev: true + + /debug/3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + dev: true + + /debug/4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + dev: true + + /debug/4.3.4_supports-color@9.2.2: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + supports-color: 9.2.2 + dev: true + + /decamelize-keys/1.1.0: + resolution: {integrity: sha512-ocLWuYzRPoS9bfiSdDd3cxvrzovVMZnRDVEzAs+hWIVXGDbHxWMECij2OBuyB/An0FFW/nLuq6Kv1i/YC5Qfzg==} + engines: {node: '>=0.10.0'} + dependencies: + decamelize: 1.2.0 + map-obj: 1.0.1 + dev: true + + /decamelize/1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + /decimal.js/10.4.0: + resolution: {integrity: sha512-Nv6ENEzyPQ6AItkGwLE2PGKinZZ9g59vSh2BeH6NqPu0OTKZ5ruJsVqh/orbAnqXc9pBbgXAIrc2EyaCj8NpGg==} + dev: true + + /dedent/0.7.0: + resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} + dev: true + + /deep-equal/1.1.1: + resolution: {integrity: sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==} + dependencies: + is-arguments: 1.1.1 + is-date-object: 1.0.5 + is-regex: 1.1.4 + object-is: 1.1.5 + object-keys: 1.1.1 + regexp.prototype.flags: 1.4.3 + dev: false + + /deep-is/0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dev: true + + /deepmerge/1.5.2: + resolution: {integrity: sha512-95k0GDqvBjZavkuvzx/YqVLv/6YYa17fz6ILMSf7neqQITCPbnfEnQvEgMPNjH4kgobe7+WIL0yJEHku+H3qtQ==} + engines: {node: '>=0.10.0'} + dev: true + + /deepmerge/4.2.2: + resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==} + engines: {node: '>=0.10.0'} + dev: true + + /default-gateway/6.0.3: + resolution: {integrity: sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==} + engines: {node: '>= 10'} + dependencies: + execa: 5.1.1 + dev: true + + /defaults/1.0.3: + resolution: {integrity: sha512-s82itHOnYrN0Ib8r+z7laQz3sdE+4FP3d9Q7VLO7U+KRT+CR0GsWuyHxzdAY82I7cXv0G/twrqomTJLOssO5HA==} + dependencies: + clone: 1.0.4 + dev: true + + /define-lazy-prop/2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + dev: true + + /define-properties/1.1.4: + resolution: {integrity: sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==} + engines: {node: '>= 0.4'} + dependencies: + has-property-descriptors: 1.0.0 + object-keys: 1.1.1 + + /defined/1.0.0: + resolution: {integrity: sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==} + dev: false + + /delayed-stream/1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dev: true + + /delegate/3.2.0: + resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==} + dev: false + + /depd/1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + dev: true + + /depd/2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dev: true + + /destroy/1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + dev: true + + /detect-browser/5.3.0: + resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==} + dev: false + + /detect-file/1.0.0: + resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==} + engines: {node: '>=0.10.0'} + dev: true + + /detect-indent/6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + dev: true + + /detect-newline/3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + dev: true + + /detect-node/2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + dev: true + + /diff-sequences/27.5.1: + resolution: {integrity: sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dev: true + + /diff/4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + dev: true + optional: true + + /dir-glob/3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dependencies: + path-type: 4.0.0 + dev: true + + /dns-equal/1.0.0: + resolution: {integrity: sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==} + dev: true + + /dns-packet/5.4.0: + resolution: {integrity: sha512-EgqGeaBB8hLiHLZtp/IbaDQTL8pZ0+IvwzSHA6d7VyMDM+B9hgddEMa9xjK5oYnw0ci0JQ6g2XCD7/f6cafU6g==} + engines: {node: '>=6'} + dependencies: + '@leichtgewicht/ip-codec': 2.0.4 + dev: true + + /doctrine/2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + dependencies: + esutils: 2.0.3 + dev: true + + /doctrine/3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + dependencies: + esutils: 2.0.3 + dev: true + + /dom-align/1.12.3: + resolution: {integrity: sha512-Gj9hZN3a07cbR6zviMUBOMPdWxYhbMI+x+WS0NAIu2zFZmbK8ys9R79g+iG9qLnlCwpFoaB+fKy8Pdv470GsPA==} + dev: false + + /dom-closest/0.2.0: + resolution: {integrity: sha512-6neTn1BtJlTSt+XSISXpnOsF1uni1CHsP/tmzZMGWxasYFHsBOqrHPnzmneqEgKhpagnfnfSfbvRRW0xFsBHAA==} + dependencies: + dom-matches: 2.0.0 + dev: false + + /dom-converter/0.2.0: + resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==} + dependencies: + utila: 0.4.0 + dev: true + + /dom-event-types/1.1.0: + resolution: {integrity: sha512-jNCX+uNJ3v38BKvPbpki6j5ItVlnSqVV6vDWGS6rExzCMjsc39frLjm1n91o6YaKK6AZl0wLloItW6C6mr61BQ==} + dev: true + + /dom-matches/2.0.0: + resolution: {integrity: sha512-2VI856xEDCLXi19W+4BechR5/oIS6bKCKqcf16GR8Pg7dGLJ/eBOWVbCmQx2ISvYH6wTNx5Ef7JTOw1dRGRx6A==} + dev: false + + /dom-scroll-into-view/2.0.1: + resolution: {integrity: sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==} + dev: false + + /dom-serializer/0.2.2: + resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==} + dependencies: + domelementtype: 2.3.0 + entities: 2.2.0 + dev: true + + /dom-serializer/1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + dev: true + + /domelementtype/1.3.1: + resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==} + dev: true + + /domelementtype/2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + dev: true + + /domexception/2.0.1: + resolution: {integrity: sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==} + engines: {node: '>=8'} + dependencies: + webidl-conversions: 5.0.0 + dev: true + + /domhandler/2.4.2: + resolution: {integrity: sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==} + dependencies: + domelementtype: 1.3.1 + dev: true + + /domhandler/4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + dev: true + + /domutils/1.7.0: + resolution: {integrity: sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==} + dependencies: + dom-serializer: 0.2.2 + domelementtype: 1.3.1 + dev: true + + /domutils/2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + dev: true + + /dot-case/3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dependencies: + no-case: 3.0.4 + tslib: 2.4.0 + dev: true + + /dot-prop/5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + dependencies: + is-obj: 2.0.0 + dev: true + + /dotenv-expand/5.1.0: + resolution: {integrity: sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==} + dev: true + + /dotenv/10.0.0: + resolution: {integrity: sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==} + engines: {node: '>=10'} + dev: true + + /dotignore/0.1.2: + resolution: {integrity: sha512-UGGGWfSauusaVJC+8fgV+NVvBXkCTmVv7sk6nojDZZvuOUNGUy0Zk4UpHQD6EDjS0jpBwcACvH4eofvyzBcRDw==} + hasBin: true + dependencies: + minimatch: 3.1.2 + dev: false + + /duplexer/0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + dev: true + + /eastasianwidth/0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + dev: true + + /easy-stack/1.0.1: + resolution: {integrity: sha512-wK2sCs4feiiJeFXn3zvY0p41mdU5VUgbgs1rNsc/y5ngFUijdWd+iIN8eoyuZHKB8xN6BL4PdWmzqFmxNg6V2w==} + engines: {node: '>=6.0.0'} + dev: true + + /editorconfig/0.15.3: + resolution: {integrity: sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==} + hasBin: true + dependencies: + commander: 2.20.3 + lru-cache: 4.1.5 + semver: 5.7.1 + sigmund: 1.0.1 + dev: true + + /ee-first/1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + dev: true + + /electron-to-chromium/1.4.228: + resolution: {integrity: sha512-XfDHCvou7CsDMlFwb0WZ1tWmW48e7Sn7VBRyPfZsZZila9esRsJl1trO+OqDNV97GggFSt0ISbWslKXfQkG//g==} + dev: true + + /element-resize-detector/1.1.13: + resolution: {integrity: sha512-QzMTvOM+hSXzPGxO4XeHq8OJAJZ/0kZQRbIBVGlR4GRVWHdfv/I/udYzIcQCZtzN1LdwkrGsNPWTIDbC8Tj7PA==} + dependencies: + batch-processor: 1.0.0 + dev: false + + /emittery/0.10.2: + resolution: {integrity: sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==} + engines: {node: '>=12'} + dev: true + + /emittery/0.8.1: + resolution: {integrity: sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==} + engines: {node: '>=10'} + dev: true + + /emoji-regex/8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + dev: true + + /emoji-regex/9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + dev: true + + /emojis-list/2.1.0: + resolution: {integrity: sha512-knHEZMgs8BB+MInokmNTg/OyPlAddghe1YBgNwJBc5zsJi/uyIcXoSDsL/W9ymOsBoBGdPIHXYJ9+qKFwRwDng==} + engines: {node: '>= 0.10'} + dev: true + + /emojis-list/3.0.0: + resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} + engines: {node: '>= 4'} + + /encodeurl/1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + dev: true + + /encoding/0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + dependencies: + iconv-lite: 0.6.3 + dev: false + + /end-of-stream/1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + dependencies: + once: 1.4.0 + dev: true + + /enhanced-resolve/5.10.0: + resolution: {integrity: sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ==} + engines: {node: '>=10.13.0'} + dependencies: + graceful-fs: 4.2.10 + tapable: 2.2.1 + dev: true + + /enquire.js/2.1.6: + resolution: {integrity: sha512-/KujNpO+PT63F7Hlpu4h3pE3TokKRHN26JYmQpPyjkRD/N57R7bPDNojMXdi7uveAKjYB7yQnartCxZnFWr0Xw==} + dev: false + + /enquirer/2.3.6: + resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} + engines: {node: '>=8.6'} + dependencies: + ansi-colors: 4.1.3 + dev: true + + /entities/1.1.2: + resolution: {integrity: sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==} + dev: true + + /entities/2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + dev: true + + /errno/0.1.8: + resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} + hasBin: true + dependencies: + prr: 1.0.1 + dev: true + optional: true + + /error-ex/1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + dependencies: + is-arrayish: 0.2.1 + dev: true + + /error-stack-parser/2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + dependencies: + stackframe: 1.3.4 + dev: true + + /es-abstract/1.20.1: + resolution: {integrity: sha512-WEm2oBhfoI2sImeM4OF2zE2V3BYdSF+KnSi9Sidz51fQHd7+JuF8Xgcj9/0o+OWeIeIS/MiuNnlruQrJf16GQA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + es-to-primitive: 1.2.1 + function-bind: 1.1.1 + function.prototype.name: 1.1.5 + get-intrinsic: 1.1.2 + get-symbol-description: 1.0.0 + has: 1.0.3 + has-property-descriptors: 1.0.0 + has-symbols: 1.0.3 + internal-slot: 1.0.3 + is-callable: 1.2.4 + is-negative-zero: 2.0.2 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.2 + is-string: 1.0.7 + is-weakref: 1.0.2 + object-inspect: 1.12.2 + object-keys: 1.1.1 + object.assign: 4.1.4 + regexp.prototype.flags: 1.4.3 + string.prototype.trimend: 1.0.5 + string.prototype.trimstart: 1.0.5 + unbox-primitive: 1.0.2 + + /es-array-method-boxes-properly/1.0.0: + resolution: {integrity: sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==} + dev: true + + /es-module-lexer/0.9.3: + resolution: {integrity: sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==} + dev: true + + /es-shim-unscopables/1.0.0: + resolution: {integrity: sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==} + dependencies: + has: 1.0.3 + dev: true + + /es-to-primitive/1.2.1: + resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} + engines: {node: '>= 0.4'} + dependencies: + is-callable: 1.2.4 + is-date-object: 1.0.5 + is-symbol: 1.0.4 + + /escalade/3.1.1: + resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + engines: {node: '>=6'} + dev: true + + /escape-html/1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + dev: true + + /escape-string-regexp/1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + /escape-string-regexp/2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + dev: true + + /escape-string-regexp/4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + dev: true + + /escodegen/2.0.0: + resolution: {integrity: sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==} + engines: {node: '>=6.0'} + hasBin: true + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionator: 0.8.3 + optionalDependencies: + source-map: 0.6.1 + dev: true + + /eslint-config-standard/12.0.0_txevqkd2r4b3zo3ootu35nzt3u: + resolution: {integrity: sha512-COUz8FnXhqFitYj4DTqHzidjIL/t4mumGZto5c7DrBpvWoie+Sn3P4sLEzUGeYhRElWuFEf8K1S1EfvD1vixCQ==} + peerDependencies: + eslint: '>=5.0.0' + eslint-plugin-import: '>=2.13.0' + eslint-plugin-node: '>=7.0.0' + eslint-plugin-promise: '>=4.0.0' + eslint-plugin-standard: '>=4.0.0' + dependencies: + eslint: 7.32.0 + eslint-plugin-import: 2.26.0_eslint@7.32.0 + eslint-plugin-node: 8.0.1_eslint@7.32.0 + eslint-plugin-promise: 4.3.1 + eslint-plugin-standard: 4.1.0_eslint@7.32.0 + dev: true + + /eslint-import-resolver-node/0.3.6: + resolution: {integrity: sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==} + dependencies: + debug: 3.2.7 + resolve: 1.22.1 + transitivePeerDependencies: + - supports-color + dev: true + + /eslint-module-utils/2.7.4_gw5q3jyuku4zrugqrckhwmprvm: + resolution: {integrity: sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + dependencies: + debug: 3.2.7 + eslint: 7.32.0 + eslint-import-resolver-node: 0.3.6 + transitivePeerDependencies: + - supports-color + dev: true + + /eslint-plugin-es/1.4.1_eslint@7.32.0: + resolution: {integrity: sha512-5fa/gR2yR3NxQf+UXkeLeP8FBBl6tSgdrAz1+cF84v1FMM4twGwQoqTnn+QxFLcPOrF4pdKEJKDB/q9GoyJrCA==} + engines: {node: '>=6.5.0'} + peerDependencies: + eslint: '>=4.19.1' + dependencies: + eslint: 7.32.0 + eslint-utils: 1.4.3 + regexpp: 2.0.1 + dev: true + + /eslint-plugin-html/5.0.5: + resolution: {integrity: sha512-v/33i3OD0fuXcRXexVyXXBOe4mLBLBQoF1UO1Uy9D+XLq4MC8K45GcQKfqjC/FnHAHp3pYUjpHHktYNCtShGmg==} + dependencies: + htmlparser2: 3.10.1 + dev: true + + /eslint-plugin-import/2.26.0_eslint@7.32.0: + resolution: {integrity: sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + dependencies: + array-includes: 3.1.5 + array.prototype.flat: 1.3.0 + debug: 2.6.9 + doctrine: 2.1.0 + eslint: 7.32.0 + eslint-import-resolver-node: 0.3.6 + eslint-module-utils: 2.7.4_gw5q3jyuku4zrugqrckhwmprvm + has: 1.0.3 + is-core-module: 2.10.0 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.values: 1.1.5 + resolve: 1.22.1 + tsconfig-paths: 3.14.1 + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + dev: true + + /eslint-plugin-node/8.0.1_eslint@7.32.0: + resolution: {integrity: sha512-ZjOjbjEi6jd82rIpFSgagv4CHWzG9xsQAVp1ZPlhRnnYxcTgENUVBvhYmkQ7GvT1QFijUSo69RaiOJKhMu6i8w==} + engines: {node: '>=6'} + peerDependencies: + eslint: '>=4.19.1' + dependencies: + eslint: 7.32.0 + eslint-plugin-es: 1.4.1_eslint@7.32.0 + eslint-utils: 1.4.3 + ignore: 5.2.0 + minimatch: 3.1.2 + resolve: 1.22.1 + semver: 5.7.1 + dev: true + + /eslint-plugin-promise/4.3.1: + resolution: {integrity: sha512-bY2sGqyptzFBDLh/GMbAxfdJC+b0f23ME63FOE4+Jao0oZ3E1LEwFtWJX/1pGMJLiTtrSSern2CRM/g+dfc0eQ==} + engines: {node: '>=6'} + dev: true + + /eslint-plugin-standard/4.1.0_eslint@7.32.0: + resolution: {integrity: sha512-ZL7+QRixjTR6/528YNGyDotyffm5OQst/sGxKDwGb9Uqs4In5Egi4+jbobhqJoyoCM6/7v/1A5fhQ7ScMtDjaQ==} + peerDependencies: + eslint: '>=5.0.0' + dependencies: + eslint: 7.32.0 + dev: true + + /eslint-plugin-vue/5.2.3_eslint@7.32.0: + resolution: {integrity: sha512-mGwMqbbJf0+VvpGR5Lllq0PMxvTdrZ/ZPjmhkacrCHbubJeJOt+T6E3HUzAifa2Mxi7RSdJfC9HFpOeSYVMMIw==} + engines: {node: '>=6.5'} + peerDependencies: + eslint: ^5.0.0 + dependencies: + eslint: 7.32.0 + vue-eslint-parser: 5.0.0_eslint@7.32.0 + transitivePeerDependencies: + - supports-color + dev: true + + /eslint-scope/4.0.3: + resolution: {integrity: sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==} + engines: {node: '>=4.0.0'} + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + dev: true + + /eslint-scope/5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + dev: true + + /eslint-utils/1.4.3: + resolution: {integrity: sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==} + engines: {node: '>=6'} + dependencies: + eslint-visitor-keys: 1.3.0 + dev: true + + /eslint-utils/2.1.0: + resolution: {integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==} + engines: {node: '>=6'} + dependencies: + eslint-visitor-keys: 1.3.0 + dev: true + + /eslint-visitor-keys/1.3.0: + resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==} + engines: {node: '>=4'} + dev: true + + /eslint-visitor-keys/2.1.0: + resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==} + engines: {node: '>=10'} + dev: true + + /eslint-webpack-plugin/3.2.0_2gji4j2x5okmdz3i2csw4u63de: + resolution: {integrity: sha512-avrKcGncpPbPSUHX6B3stNGzkKFto3eL+DKM4+VyMrVnhPc3vRczVlCq3uhuFOdRvDHTVXuzwk1ZKUrqDQHQ9w==} + engines: {node: '>= 12.13.0'} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + webpack: ^5.0.0 + dependencies: + '@types/eslint': 8.4.6 + eslint: 7.32.0 + jest-worker: 28.1.3 + micromatch: 4.0.5 + normalize-path: 3.0.0 + schema-utils: 4.0.0 + webpack: 5.74.0 + dev: true + + /eslint/7.32.0: + resolution: {integrity: sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==} + engines: {node: ^10.12.0 || >=12.0.0} + hasBin: true + dependencies: + '@babel/code-frame': 7.12.11 + '@eslint/eslintrc': 0.4.3 + '@humanwhocodes/config-array': 0.5.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.4 + doctrine: 3.0.0 + enquirer: 2.3.6 + escape-string-regexp: 4.0.0 + eslint-scope: 5.1.1 + eslint-utils: 2.1.0 + eslint-visitor-keys: 2.1.0 + espree: 7.3.1 + esquery: 1.4.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + functional-red-black-tree: 1.0.1 + glob-parent: 5.1.2 + globals: 13.17.0 + ignore: 4.0.6 + import-fresh: 3.3.0 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + js-yaml: 3.14.1 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.1 + progress: 2.0.3 + regexpp: 3.2.0 + semver: 7.3.7 + strip-ansi: 6.0.1 + strip-json-comments: 3.1.1 + table: 6.8.0 + text-table: 0.2.0 + v8-compile-cache: 2.3.0 + transitivePeerDependencies: + - supports-color + dev: true + + /espree/4.1.0: + resolution: {integrity: sha512-I5BycZW6FCVIub93TeVY1s7vjhP9CY6cXCznIRfiig7nRviKZYdRnj/sHEWC6A7WE9RDWOFq9+7OsWSYz8qv2w==} + engines: {node: '>=6.0.0'} + dependencies: + acorn: 6.4.2 + acorn-jsx: 5.3.2_acorn@6.4.2 + eslint-visitor-keys: 1.3.0 + dev: true + + /espree/7.3.1: + resolution: {integrity: sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + acorn: 7.4.1 + acorn-jsx: 5.3.2_acorn@7.4.1 + eslint-visitor-keys: 1.3.0 + dev: true + + /esprima/4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /esquery/1.4.0: + resolution: {integrity: sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==} + engines: {node: '>=0.10'} + dependencies: + estraverse: 5.3.0 + dev: true + + /esrecurse/4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + dependencies: + estraverse: 5.3.0 + dev: true + + /estraverse/4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + dev: true + + /estraverse/5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + dev: true + + /esutils/2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + dev: true + + /etag/1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + dev: true + + /event-pubsub/4.3.0: + resolution: {integrity: sha512-z7IyloorXvKbFx9Bpie2+vMJKKx1fH1EN5yiTfp8CiLOTptSYy1g8H4yDpGlEdshL1PBiFtBHepF2cNsqeEeFQ==} + engines: {node: '>=4.0.0'} + dev: true + + /eventemitter3/2.0.3: + resolution: {integrity: sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==} + dev: false + + /eventemitter3/4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + dev: true + + /events/3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + dev: true + + /execa/0.8.0: + resolution: {integrity: sha512-zDWS+Rb1E8BlqqhALSt9kUhss8Qq4nN3iof3gsOdyINksElaPyNBtKUMTR62qhvgVWR0CqCX7sdnKe4MnUbFEA==} + engines: {node: '>=4'} + dependencies: + cross-spawn: 5.1.0 + get-stream: 3.0.0 + is-stream: 1.1.0 + npm-run-path: 2.0.2 + p-finally: 1.0.0 + signal-exit: 3.0.7 + strip-eof: 1.0.0 + dev: true + + /execa/1.0.0: + resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} + engines: {node: '>=6'} + dependencies: + cross-spawn: 6.0.5 + get-stream: 4.1.0 + is-stream: 1.1.0 + npm-run-path: 2.0.2 + p-finally: 1.0.0 + signal-exit: 3.0.7 + strip-eof: 1.0.0 + dev: true + + /execa/5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + dev: true + + /exit/0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + dev: true + + /expand-tilde/2.0.2: + resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} + engines: {node: '>=0.10.0'} + dependencies: + homedir-polyfill: 1.0.3 + dev: true + + /expect/27.5.1: + resolution: {integrity: sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/types': 27.5.1 + jest-get-type: 27.5.1 + jest-matcher-utils: 27.5.1 + jest-message-util: 27.5.1 + dev: true + + /express/4.18.1: + resolution: {integrity: sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==} + engines: {node: '>= 0.10.0'} + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.0 + content-disposition: 0.5.4 + content-type: 1.0.4 + cookie: 0.5.0 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.2.0 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.1 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.7 + proxy-addr: 2.0.7 + qs: 6.10.3 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.18.0 + serve-static: 1.15.0 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + dev: true + + /extend-shallow/2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + dependencies: + is-extendable: 0.1.1 + dev: true + + /extend/3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + dev: false + + /external-editor/3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + dev: true + + /fast-deep-equal/3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + /fast-diff/1.1.2: + resolution: {integrity: sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==} + dev: false + + /fast-glob/3.2.11: + resolution: {integrity: sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + dev: true + + /fast-json-stable-stringify/2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + /fast-levenshtein/2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + dev: true + + /fastest-levenshtein/1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + dev: true + + /fastq/1.13.0: + resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==} + dependencies: + reusify: 1.0.4 + dev: true + + /faye-websocket/0.11.4: + resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} + engines: {node: '>=0.8.0'} + dependencies: + websocket-driver: 0.7.4 + dev: true + + /fb-watchman/2.0.1: + resolution: {integrity: sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg==} + dependencies: + bser: 2.1.1 + dev: true + + /fecha/2.3.3: + resolution: {integrity: sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==} + dev: false + + /figures/2.0.0: + resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} + engines: {node: '>=4'} + dependencies: + escape-string-regexp: 1.0.5 + dev: true + + /figures/3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + dependencies: + escape-string-regexp: 1.0.5 + dev: true + + /file-entry-cache/6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flat-cache: 3.0.4 + dev: true + + /file-loader/6.2.0: + resolution: {integrity: sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==} + engines: {node: '>= 10.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + dependencies: + loader-utils: 2.0.2 + schema-utils: 3.1.1 + dev: true + + /fill-range/7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + + /finalhandler/1.2.0: + resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==} + engines: {node: '>= 0.8'} + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /find-cache-dir/3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + + /find-node-modules/2.1.3: + resolution: {integrity: sha512-UC2I2+nx1ZuOBclWVNdcnbDR5dlrOdVb7xNjmT/lHE+LsgztWks3dG7boJ37yTS/venXw84B/mAW9uHVoC5QRg==} + dependencies: + findup-sync: 4.0.0 + merge: 2.1.1 + dev: true + + /find-root/1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + dev: true + + /find-up/4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + /find-up/5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + dev: true + + /findup-sync/4.0.0: + resolution: {integrity: sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==} + engines: {node: '>= 8'} + dependencies: + detect-file: 1.0.0 + is-glob: 4.0.3 + micromatch: 4.0.5 + resolve-dir: 1.0.1 + dev: true + + /flat-cache/3.0.4: + resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flatted: 3.2.7 + rimraf: 3.0.2 + dev: true + + /flatted/3.2.7: + resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} + dev: true + + /fmin/0.0.2: + resolution: {integrity: sha512-sSi6DzInhl9d8yqssDfGZejChO8d2bAGIpysPsvYsxFe898z89XhCZg6CPNV3nhUhFefeC/AXZK2bAJxlBjN6A==} + dependencies: + contour_plot: 0.0.1 + json2module: 0.0.3 + rollup: 0.25.8 + tape: 4.16.0 + uglify-js: 2.8.29 + dev: false + + /follow-redirects/1.15.1: + resolution: {integrity: sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + /for-each/0.3.3: + resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + dependencies: + is-callable: 1.2.4 + dev: false + + /form-data/3.0.1: + resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: true + + /forwarded/0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + dev: true + + /fraction.js/4.2.0: + resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==} + dev: true + + /fresh/0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + dev: true + + /fs-extra/9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.10 + jsonfile: 6.1.0 + universalify: 2.0.0 + dev: true + + /fs-monkey/1.0.3: + resolution: {integrity: sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==} + dev: true + + /fs.realpath/1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + /fsevents/2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /function-bind/1.1.1: + resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + + /function.prototype.name/1.1.5: + resolution: {integrity: sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.1.4 + es-abstract: 1.20.1 + functions-have-names: 1.2.3 + + /functional-red-black-tree/1.0.1: + resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + dev: true + + /functions-have-names/1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + /gensync/1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + dev: true + + /get-caller-file/2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + dev: true + + /get-intrinsic/1.1.2: + resolution: {integrity: sha512-Jfm3OyCxHh9DJyc28qGk+JmfkpO41A4XkneDSujN9MDXrm4oDKdHvndhZ2dN94+ERNfkYJWDclW6k2L/ZGHjXA==} + dependencies: + function-bind: 1.1.1 + has: 1.0.3 + has-symbols: 1.0.3 + + /get-package-type/0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + dev: true + + /get-stream/3.0.0: + resolution: {integrity: sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==} + engines: {node: '>=4'} + dev: true + + /get-stream/4.1.0: + resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} + engines: {node: '>=6'} + dependencies: + pump: 3.0.0 + dev: true + + /get-stream/6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + dev: true + + /get-symbol-description/1.0.0: + resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.1.2 + + /git-raw-commits/2.0.11: + resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==} + engines: {node: '>=10'} + hasBin: true + dependencies: + dargs: 7.0.0 + lodash: 4.17.21 + meow: 8.1.2 + split2: 3.2.2 + through2: 4.0.2 + dev: true + + /git-revision-webpack-plugin/3.0.6: + resolution: {integrity: sha512-vW/9dBahGbpKPcccy3xKkHgdWoH/cAPPc3lQw+3edl7b4j29JfNGVrja0a1d8ZoRe4nTN8GCPrF9aBErDnzx5Q==} + engines: {node: '>=6.11.5'} + dev: true + + /glob-parent/5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob-parent/6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob-to-regexp/0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + dev: true + + /glob/7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + /glob/8.0.3: + resolution: {integrity: sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==} + engines: {node: '>=12'} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.0 + once: 1.4.0 + dev: true + + /global-dirs/0.1.1: + resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==} + engines: {node: '>=4'} + dependencies: + ini: 1.3.8 + dev: true + + /global-modules/1.0.0: + resolution: {integrity: sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==} + engines: {node: '>=0.10.0'} + dependencies: + global-prefix: 1.0.2 + is-windows: 1.0.2 + resolve-dir: 1.0.1 + dev: true + + /global-modules/2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + dependencies: + global-prefix: 3.0.0 + dev: true + + /global-prefix/1.0.2: + resolution: {integrity: sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==} + engines: {node: '>=0.10.0'} + dependencies: + expand-tilde: 2.0.2 + homedir-polyfill: 1.0.3 + ini: 1.3.8 + is-windows: 1.0.2 + which: 1.3.1 + dev: true + + /global-prefix/3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + dev: true + + /globals/11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + dev: true + + /globals/13.17.0: + resolution: {integrity: sha512-1C+6nQRb1GwGMKm2dH/E7enFAMxGTmGI7/dEdhy/DNelv85w9B72t3uc5frtMNXIbzrarJJ/lTCjcaZwbLJmyw==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.20.2 + dev: true + + /globby/11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.2.11 + ignore: 5.2.0 + merge2: 1.4.1 + slash: 3.0.0 + dev: true + + /globjoin/0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + dev: true + + /good-listener/1.2.2: + resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==} + dependencies: + delegate: 3.2.0 + dev: false + + /graceful-fs/4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + dev: true + + /graphlib/2.1.8: + resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==} + dependencies: + lodash: 4.17.21 + dev: false + + /gzip-size/6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + dependencies: + duplexer: 0.1.2 + dev: true + + /handle-thing/2.0.1: + resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} + dev: true + + /hard-rejection/2.1.0: + resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} + engines: {node: '>=6'} + dev: true + + /has-ansi/2.0.0: + resolution: {integrity: sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==} + engines: {node: '>=0.10.0'} + dependencies: + ansi-regex: 2.1.1 + dev: false + + /has-bigints/1.0.2: + resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} + + /has-flag/3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + dev: true + + /has-flag/4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + dev: true + + /has-property-descriptors/1.0.0: + resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==} + dependencies: + get-intrinsic: 1.1.2 + + /has-symbols/1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + + /has-tostringtag/1.0.0: + resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} + engines: {node: '>= 0.4'} + dependencies: + has-symbols: 1.0.3 + + /has/1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} + dependencies: + function-bind: 1.1.1 + + /hash-sum/1.0.2: + resolution: {integrity: sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==} + dev: true + + /hash-sum/2.0.0: + resolution: {integrity: sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==} + dev: true + + /he/1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + /highlight.js/10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + dev: true + + /homedir-polyfill/1.0.3: + resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} + engines: {node: '>=0.10.0'} + dependencies: + parse-passwd: 1.0.0 + dev: true + + /hosted-git-info/2.8.9: + resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + dev: true + + /hosted-git-info/4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + dependencies: + lru-cache: 6.0.0 + dev: true + + /hpack.js/2.1.6: + resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} + dependencies: + inherits: 2.0.4 + obuf: 1.1.2 + readable-stream: 2.3.7 + wbuf: 1.7.3 + dev: true + + /html-encoding-sniffer/2.0.1: + resolution: {integrity: sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==} + engines: {node: '>=10'} + dependencies: + whatwg-encoding: 1.0.5 + dev: true + + /html-entities/2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + dev: true + + /html-escaper/2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + dev: true + + /html-minifier-terser/6.1.0: + resolution: {integrity: sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==} + engines: {node: '>=12'} + hasBin: true + dependencies: + camel-case: 4.1.2 + clean-css: 5.3.1 + commander: 8.3.0 + he: 1.2.0 + param-case: 3.0.4 + relateurl: 0.2.7 + terser: 5.15.0 + dev: true + + /html-tags/2.0.0: + resolution: {integrity: sha512-+Il6N8cCo2wB/Vd3gqy/8TZhTD3QvcVeQLCnZiGkGCH3JP28IgGAY41giccp2W4R3jfyJPAP318FQTa1yU7K7g==} + engines: {node: '>=4'} + dev: true + + /html-tags/3.2.0: + resolution: {integrity: sha512-vy7ClnArOZwCnqZgvv+ddgHgJiAFXe3Ge9ML5/mBctVJoUoYPCdxVucOywjDARn6CVoh3dRSFdPHy2sX80L0Wg==} + engines: {node: '>=8'} + dev: true + + /html-webpack-plugin/5.5.0_webpack@5.74.0: + resolution: {integrity: sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw==} + engines: {node: '>=10.13.0'} + peerDependencies: + webpack: ^5.20.0 + dependencies: + '@types/html-minifier-terser': 6.1.0 + html-minifier-terser: 6.1.0 + lodash: 4.17.21 + pretty-error: 4.0.0 + tapable: 2.2.1 + webpack: 5.74.0 + dev: true + + /htmlparser2/3.10.1: + resolution: {integrity: sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==} + dependencies: + domelementtype: 1.3.1 + domhandler: 2.4.2 + domutils: 1.7.0 + entities: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.0 + dev: true + + /htmlparser2/6.1.0: + resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + domutils: 2.8.0 + entities: 2.2.0 + dev: true + + /http-deceiver/1.2.7: + resolution: {integrity: sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==} + dev: true + + /http-errors/1.6.3: + resolution: {integrity: sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==} + engines: {node: '>= 0.6'} + dependencies: + depd: 1.1.2 + inherits: 2.0.3 + setprototypeof: 1.1.0 + statuses: 1.5.0 + dev: true + + /http-errors/2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + dev: true + + /http-parser-js/0.5.8: + resolution: {integrity: sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==} + dev: true + + /http-proxy-agent/4.0.1: + resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} + engines: {node: '>= 6'} + dependencies: + '@tootallnate/once': 1.1.2 + agent-base: 6.0.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + + /http-proxy-middleware/2.0.6_vw7eq5saxorls4jwsr6ncij7dm: + resolution: {integrity: sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/express': ^4.17.13 + peerDependenciesMeta: + '@types/express': + optional: true + dependencies: + '@types/express': 4.17.13 + '@types/http-proxy': 1.17.9 + http-proxy: 1.18.1_debug@4.3.4 + is-glob: 4.0.3 + is-plain-obj: 3.0.0 + micromatch: 4.0.5 + transitivePeerDependencies: + - debug + dev: true + + /http-proxy/1.18.1_debug@4.3.4: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.1 + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + dev: true + + /https-proxy-agent/5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + dependencies: + agent-base: 6.0.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + + /human-signals/2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + dev: true + + /husky/6.0.0: + resolution: {integrity: sha512-SQS2gDTB7tBN486QSoKPKQItZw97BMOd+Kdb6ghfpBc0yXyzrddI0oDV5MkDAbuB4X2mO3/nj60TRMcYxwzZeQ==} + hasBin: true + dev: true + + /iconv-lite/0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + + /iconv-lite/0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: false + + /icss-utils/5.1.0_postcss@8.4.16: + resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + postcss: 8.4.16 + dev: true + + /ieee754/1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + dev: true + + /ignore/4.0.6: + resolution: {integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==} + engines: {node: '>= 4'} + dev: true + + /ignore/5.2.0: + resolution: {integrity: sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==} + engines: {node: '>= 4'} + dev: true + + /image-size/0.5.5: + resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} + engines: {node: '>=0.10.0'} + hasBin: true + requiresBuild: true + dev: true + optional: true + + /import-fresh/3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + dev: true + + /import-lazy/4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + dev: true + + /import-local/3.1.0: + resolution: {integrity: sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==} + engines: {node: '>=8'} + hasBin: true + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + dev: true + + /imurmurhash/0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + dev: true + + /indent-string/4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + dev: true + + /inflight/1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + /inherits/2.0.3: + resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==} + dev: true + + /inherits/2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + /ini/1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + dev: true + + /inquirer/8.2.4: + resolution: {integrity: sha512-nn4F01dxU8VeKfq192IjLsxu0/OmMZ4Lg3xKAns148rCaXP6ntAoEkVYZThWjwON8AlzdZZi6oqnhNbxUG9hVg==} + engines: {node: '>=12.0.0'} + dependencies: + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + external-editor: 3.1.0 + figures: 3.2.0 + lodash: 4.17.21 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.5.6 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 7.0.0 + dev: true + + /insert-css/2.0.0: + resolution: {integrity: sha512-xGq5ISgcUP5cvGkS2MMFLtPDBtrtQPSFfC6gA6U8wHKqfjTIMZLZNxOItQnoSjdOzlXOLU/yD32RKC4SvjNbtA==} + dev: false + + /internal-slot/1.0.3: + resolution: {integrity: sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.1.2 + has: 1.0.3 + side-channel: 1.0.4 + + /intersperse/1.0.0: + resolution: {integrity: sha512-LGcfug7OTeWkaQ8PEq8XbTy9Jl6uCNg8DrPnQUmwxSY8UETj1Y+LLmpdD0qHdEj6KVchuH3BE3ZzIXQ1t3oFUw==} + dev: false + + /ipaddr.js/1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + dev: true + + /ipaddr.js/2.0.1: + resolution: {integrity: sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==} + engines: {node: '>= 10'} + dev: true + + /is-arguments/1.1.1: + resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + has-tostringtag: 1.0.0 + dev: false + + /is-arrayish/0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + dev: true + + /is-bigint/1.0.4: + resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} + dependencies: + has-bigints: 1.0.2 + + /is-binary-path/2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.2.0 + dev: true + + /is-boolean-object/1.1.2: + resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + has-tostringtag: 1.0.0 + + /is-buffer/1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + + /is-callable/1.2.4: + resolution: {integrity: sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==} + engines: {node: '>= 0.4'} + + /is-ci/1.2.1: + resolution: {integrity: sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==} + hasBin: true + dependencies: + ci-info: 1.6.0 + dev: true + + /is-core-module/2.10.0: + resolution: {integrity: sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==} + dependencies: + has: 1.0.3 + + /is-date-object/1.0.5: + resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.0 + + /is-docker/2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + dev: true + + /is-extendable/0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + dev: true + + /is-extglob/2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-file-esm/1.0.0: + resolution: {integrity: sha512-rZlaNKb4Mr8WlRu2A9XdeoKgnO5aA53XdPHgCKVyCrQ/rWi89RET1+bq37Ru46obaQXeiX4vmFIm1vks41hoSA==} + dependencies: + read-pkg-up: 7.0.1 + dev: true + + /is-fullwidth-code-point/2.0.0: + resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==} + engines: {node: '>=4'} + dev: true + + /is-fullwidth-code-point/3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + dev: true + + /is-fullwidth-code-point/4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + dev: true + + /is-generator-fn/2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + dev: true + + /is-glob/4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-interactive/1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + dev: true + + /is-mobile/2.2.2: + resolution: {integrity: sha512-wW/SXnYJkTjs++tVK5b6kVITZpAZPtUrt9SF80vvxGiF/Oywal+COk1jlRkiVq15RFNEQKQY31TkV24/1T5cVg==} + dev: false + + /is-negative-zero/2.0.2: + resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==} + engines: {node: '>= 0.4'} + + /is-number-object/1.0.7: + resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.0 + + /is-number/7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /is-obj/2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + dev: true + + /is-plain-obj/1.1.0: + resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} + engines: {node: '>=0.10.0'} + dev: true + + /is-plain-obj/3.0.0: + resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} + engines: {node: '>=10'} + dev: true + + /is-plain-object/2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + dependencies: + isobject: 3.0.1 + dev: true + + /is-plain-object/5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + dev: true + + /is-potential-custom-element-name/1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + dev: true + + /is-regex/1.1.4: + resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + has-tostringtag: 1.0.0 + + /is-shared-array-buffer/1.0.2: + resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==} + dependencies: + call-bind: 1.0.2 + + /is-stream/1.1.0: + resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} + engines: {node: '>=0.10.0'} + + /is-stream/2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + dev: true + + /is-string/1.0.7: + resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.0 + + /is-symbol/1.0.4: + resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} + engines: {node: '>= 0.4'} + dependencies: + has-symbols: 1.0.3 + + /is-text-path/1.0.1: + resolution: {integrity: sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w==} + engines: {node: '>=0.10.0'} + dependencies: + text-extensions: 1.9.0 + dev: true + + /is-typedarray/1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + dev: true + + /is-unicode-supported/0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + dev: true + + /is-utf8/0.2.1: + resolution: {integrity: sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==} + dev: true + + /is-weakref/1.0.2: + resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} + dependencies: + call-bind: 1.0.2 + + /is-what/3.14.1: + resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==} + dev: true + + /is-whitespace/0.3.0: + resolution: {integrity: sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg==} + engines: {node: '>=0.10.0'} + dev: true + + /is-windows/1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + dev: true + + /is-wsl/2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + dependencies: + is-docker: 2.2.1 + dev: true + + /isarray/0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + dev: false + + /isarray/1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + dev: true + + /isexe/2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + dev: true + + /ismobilejs/1.1.1: + resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==} + dev: false + + /isobject/3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + dev: true + + /isomorphic-fetch/2.2.1: + resolution: {integrity: sha512-9c4TNAKYXM5PRyVcwUZrF3W09nQ+sO7+jydgs4ZGW9dhsLG2VOlISJABombdQqQRXCwuYG3sYV/puGf5rp0qmA==} + dependencies: + node-fetch: 1.7.3 + whatwg-fetch: 3.6.2 + dev: false + + /istanbul-lib-coverage/3.2.0: + resolution: {integrity: sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==} + engines: {node: '>=8'} + dev: true + + /istanbul-lib-instrument/5.2.0: + resolution: {integrity: sha512-6Lthe1hqXHBNsqvgDzGO6l03XNeu3CrG4RqQ1KM9+l5+jNGpEJfIELx1NS3SEHmJQA8np/u+E4EPRKRiu6m19A==} + engines: {node: '>=8'} + dependencies: + '@babel/core': 7.18.13 + '@babel/parser': 7.18.13 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.0 + semver: 6.3.0 + transitivePeerDependencies: + - supports-color + dev: true + + /istanbul-lib-report/3.0.0: + resolution: {integrity: sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==} + engines: {node: '>=8'} + dependencies: + istanbul-lib-coverage: 3.2.0 + make-dir: 3.1.0 + supports-color: 7.2.0 + dev: true + + /istanbul-lib-source-maps/4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + dependencies: + debug: 4.3.4 + istanbul-lib-coverage: 3.2.0 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + dev: true + + /istanbul-reports/3.1.5: + resolution: {integrity: sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==} + engines: {node: '>=8'} + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.0 + dev: true + + /javascript-stringify/2.1.0: + resolution: {integrity: sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==} + dev: true + + /jest-changed-files/27.5.1: + resolution: {integrity: sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/types': 27.5.1 + execa: 5.1.1 + throat: 6.0.1 + dev: true + + /jest-circus/27.5.1: + resolution: {integrity: sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/environment': 27.5.1 + '@jest/test-result': 27.5.1 + '@jest/types': 27.5.1 + '@types/node': 18.7.13 + chalk: 4.1.2 + co: 4.6.0 + dedent: 0.7.0 + expect: 27.5.1 + is-generator-fn: 2.1.0 + jest-each: 27.5.1 + jest-matcher-utils: 27.5.1 + jest-message-util: 27.5.1 + jest-runtime: 27.5.1 + jest-snapshot: 27.5.1 + jest-util: 27.5.1 + pretty-format: 27.5.1 + slash: 3.0.0 + stack-utils: 2.0.5 + throat: 6.0.1 + transitivePeerDependencies: + - supports-color + dev: true + + /jest-cli/27.5.1: + resolution: {integrity: sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 27.5.1 + '@jest/test-result': 27.5.1 + '@jest/types': 27.5.1 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.10 + import-local: 3.1.0 + jest-config: 27.5.1 + jest-util: 27.5.1 + jest-validate: 27.5.1 + prompts: 2.4.2 + yargs: 16.2.0 + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - ts-node + - utf-8-validate + dev: true + + /jest-config/27.5.1: + resolution: {integrity: sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + peerDependencies: + ts-node: '>=9.0.0' + peerDependenciesMeta: + ts-node: + optional: true + dependencies: + '@babel/core': 7.18.13 + '@jest/test-sequencer': 27.5.1 + '@jest/types': 27.5.1 + babel-jest: 27.5.1_@babel+core@7.18.13 + chalk: 4.1.2 + ci-info: 3.3.2 + deepmerge: 4.2.2 + glob: 7.2.3 + graceful-fs: 4.2.10 + jest-circus: 27.5.1 + jest-environment-jsdom: 27.5.1 + jest-environment-node: 27.5.1 + jest-get-type: 27.5.1 + jest-jasmine2: 27.5.1 + jest-regex-util: 27.5.1 + jest-resolve: 27.5.1 + jest-runner: 27.5.1 + jest-util: 27.5.1 + jest-validate: 27.5.1 + micromatch: 4.0.5 + parse-json: 5.2.0 + pretty-format: 27.5.1 + slash: 3.0.0 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - utf-8-validate + dev: true + + /jest-diff/27.5.1: + resolution: {integrity: sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + chalk: 4.1.2 + diff-sequences: 27.5.1 + jest-get-type: 27.5.1 + pretty-format: 27.5.1 + dev: true + + /jest-docblock/27.5.1: + resolution: {integrity: sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + detect-newline: 3.1.0 + dev: true + + /jest-each/27.5.1: + resolution: {integrity: sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/types': 27.5.1 + chalk: 4.1.2 + jest-get-type: 27.5.1 + jest-util: 27.5.1 + pretty-format: 27.5.1 + dev: true + + /jest-environment-jsdom/27.5.1: + resolution: {integrity: sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/environment': 27.5.1 + '@jest/fake-timers': 27.5.1 + '@jest/types': 27.5.1 + '@types/node': 18.7.13 + jest-mock: 27.5.1 + jest-util: 27.5.1 + jsdom: 16.7.0 + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - utf-8-validate + dev: true + + /jest-environment-node/27.5.1: + resolution: {integrity: sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/environment': 27.5.1 + '@jest/fake-timers': 27.5.1 + '@jest/types': 27.5.1 + '@types/node': 18.7.13 + jest-mock: 27.5.1 + jest-util: 27.5.1 + dev: true + + /jest-get-type/27.5.1: + resolution: {integrity: sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dev: true + + /jest-haste-map/27.5.1: + resolution: {integrity: sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/types': 27.5.1 + '@types/graceful-fs': 4.1.5 + '@types/node': 18.7.13 + anymatch: 3.1.2 + fb-watchman: 2.0.1 + graceful-fs: 4.2.10 + jest-regex-util: 27.5.1 + jest-serializer: 27.5.1 + jest-util: 27.5.1 + jest-worker: 27.5.1 + micromatch: 4.0.5 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /jest-jasmine2/27.5.1: + resolution: {integrity: sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/environment': 27.5.1 + '@jest/source-map': 27.5.1 + '@jest/test-result': 27.5.1 + '@jest/types': 27.5.1 + '@types/node': 18.7.13 + chalk: 4.1.2 + co: 4.6.0 + expect: 27.5.1 + is-generator-fn: 2.1.0 + jest-each: 27.5.1 + jest-matcher-utils: 27.5.1 + jest-message-util: 27.5.1 + jest-runtime: 27.5.1 + jest-snapshot: 27.5.1 + jest-util: 27.5.1 + pretty-format: 27.5.1 + throat: 6.0.1 + transitivePeerDependencies: + - supports-color + dev: true + + /jest-leak-detector/27.5.1: + resolution: {integrity: sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + jest-get-type: 27.5.1 + pretty-format: 27.5.1 + dev: true + + /jest-matcher-utils/27.5.1: + resolution: {integrity: sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + chalk: 4.1.2 + jest-diff: 27.5.1 + jest-get-type: 27.5.1 + pretty-format: 27.5.1 + dev: true + + /jest-message-util/27.5.1: + resolution: {integrity: sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@babel/code-frame': 7.18.6 + '@jest/types': 27.5.1 + '@types/stack-utils': 2.0.1 + chalk: 4.1.2 + graceful-fs: 4.2.10 + micromatch: 4.0.5 + pretty-format: 27.5.1 + slash: 3.0.0 + stack-utils: 2.0.5 + dev: true + + /jest-message-util/28.1.3: + resolution: {integrity: sha512-PFdn9Iewbt575zKPf1286Ht9EPoJmYT7P0kY+RibeYZ2XtOr53pDLEFoTWXbd1h4JiGiWpTBC84fc8xMXQMb7g==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@babel/code-frame': 7.18.6 + '@jest/types': 28.1.3 + '@types/stack-utils': 2.0.1 + chalk: 4.1.2 + graceful-fs: 4.2.10 + micromatch: 4.0.5 + pretty-format: 28.1.3 + slash: 3.0.0 + stack-utils: 2.0.5 + dev: true + + /jest-mock/27.5.1: + resolution: {integrity: sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/types': 27.5.1 + '@types/node': 18.7.13 + dev: true + + /jest-pnp-resolver/1.2.2_jest-resolve@27.5.1: + resolution: {integrity: sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + dependencies: + jest-resolve: 27.5.1 + dev: true + + /jest-regex-util/27.5.1: + resolution: {integrity: sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dev: true + + /jest-regex-util/28.0.2: + resolution: {integrity: sha512-4s0IgyNIy0y9FK+cjoVYoxamT7Zeo7MhzqRGx7YDYmaQn1wucY9rotiGkBzzcMXTtjrCAP/f7f+E0F7+fxPNdw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dev: true + + /jest-resolve-dependencies/27.5.1: + resolution: {integrity: sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/types': 27.5.1 + jest-regex-util: 27.5.1 + jest-snapshot: 27.5.1 + transitivePeerDependencies: + - supports-color + dev: true + + /jest-resolve/27.5.1: + resolution: {integrity: sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/types': 27.5.1 + chalk: 4.1.2 + graceful-fs: 4.2.10 + jest-haste-map: 27.5.1 + jest-pnp-resolver: 1.2.2_jest-resolve@27.5.1 + jest-util: 27.5.1 + jest-validate: 27.5.1 + resolve: 1.22.1 + resolve.exports: 1.1.0 + slash: 3.0.0 + dev: true + + /jest-runner/27.5.1: + resolution: {integrity: sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/console': 27.5.1 + '@jest/environment': 27.5.1 + '@jest/test-result': 27.5.1 + '@jest/transform': 27.5.1 + '@jest/types': 27.5.1 + '@types/node': 18.7.13 + chalk: 4.1.2 + emittery: 0.8.1 + graceful-fs: 4.2.10 + jest-docblock: 27.5.1 + jest-environment-jsdom: 27.5.1 + jest-environment-node: 27.5.1 + jest-haste-map: 27.5.1 + jest-leak-detector: 27.5.1 + jest-message-util: 27.5.1 + jest-resolve: 27.5.1 + jest-runtime: 27.5.1 + jest-util: 27.5.1 + jest-worker: 27.5.1 + source-map-support: 0.5.21 + throat: 6.0.1 + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - utf-8-validate + dev: true + + /jest-runtime/27.5.1: + resolution: {integrity: sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/environment': 27.5.1 + '@jest/fake-timers': 27.5.1 + '@jest/globals': 27.5.1 + '@jest/source-map': 27.5.1 + '@jest/test-result': 27.5.1 + '@jest/transform': 27.5.1 + '@jest/types': 27.5.1 + chalk: 4.1.2 + cjs-module-lexer: 1.2.2 + collect-v8-coverage: 1.0.1 + execa: 5.1.1 + glob: 7.2.3 + graceful-fs: 4.2.10 + jest-haste-map: 27.5.1 + jest-message-util: 27.5.1 + jest-mock: 27.5.1 + jest-regex-util: 27.5.1 + jest-resolve: 27.5.1 + jest-snapshot: 27.5.1 + jest-util: 27.5.1 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /jest-serializer-vue/2.0.2: + resolution: {integrity: sha512-nK/YIFo6qe3i9Ge+hr3h4PpRehuPPGZFt8LDBdTHYldMb7ZWlkanZS8Ls7D8h6qmQP2lBQVDLP0DKn5bJ9QApQ==} + dependencies: + pretty: 2.0.0 + dev: true + + /jest-serializer/27.5.1: + resolution: {integrity: sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@types/node': 18.7.13 + graceful-fs: 4.2.10 + dev: true + + /jest-snapshot/27.5.1: + resolution: {integrity: sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@babel/core': 7.18.13 + '@babel/generator': 7.18.13 + '@babel/plugin-syntax-typescript': 7.18.6_@babel+core@7.18.13 + '@babel/traverse': 7.18.13 + '@babel/types': 7.18.13 + '@jest/transform': 27.5.1 + '@jest/types': 27.5.1 + '@types/babel__traverse': 7.18.0 + '@types/prettier': 2.7.0 + babel-preset-current-node-syntax: 1.0.1_@babel+core@7.18.13 + chalk: 4.1.2 + expect: 27.5.1 + graceful-fs: 4.2.10 + jest-diff: 27.5.1 + jest-get-type: 27.5.1 + jest-haste-map: 27.5.1 + jest-matcher-utils: 27.5.1 + jest-message-util: 27.5.1 + jest-util: 27.5.1 + natural-compare: 1.4.0 + pretty-format: 27.5.1 + semver: 7.3.7 + transitivePeerDependencies: + - supports-color + dev: true + + /jest-transform-stub/2.0.0: + resolution: {integrity: sha512-lspHaCRx/mBbnm3h4uMMS3R5aZzMwyNpNIJLXj4cEsV0mIUtS4IjYJLSoyjRCtnxb6RIGJ4NL2quZzfIeNhbkg==} + dev: true + + /jest-util/27.5.1: + resolution: {integrity: sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/types': 27.5.1 + '@types/node': 18.7.13 + chalk: 4.1.2 + ci-info: 3.3.2 + graceful-fs: 4.2.10 + picomatch: 2.3.1 + dev: true + + /jest-util/28.1.3: + resolution: {integrity: sha512-XdqfpHwpcSRko/C35uLYFM2emRAltIIKZiJ9eAmhjsj0CqZMa0p1ib0R5fWIqGhn1a103DebTbpqIaP1qCQ6tQ==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/types': 28.1.3 + '@types/node': 18.7.13 + chalk: 4.1.2 + ci-info: 3.3.2 + graceful-fs: 4.2.10 + picomatch: 2.3.1 + dev: true + + /jest-validate/27.5.1: + resolution: {integrity: sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/types': 27.5.1 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 27.5.1 + leven: 3.1.0 + pretty-format: 27.5.1 + dev: true + + /jest-watch-typeahead/1.1.0_jest@27.5.1: + resolution: {integrity: sha512-Va5nLSJTN7YFtC2jd+7wsoe1pNe5K4ShLux/E5iHEwlB9AxaxmggY7to9KUqKojhaJw3aXqt5WAb4jGPOolpEw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + jest: ^27.0.0 || ^28.0.0 + dependencies: + ansi-escapes: 4.3.2 + chalk: 4.1.2 + jest: 27.5.1 + jest-regex-util: 28.0.2 + jest-watcher: 28.1.3 + slash: 4.0.0 + string-length: 5.0.1 + strip-ansi: 7.0.1 + dev: true + + /jest-watcher/27.5.1: + resolution: {integrity: sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/test-result': 27.5.1 + '@jest/types': 27.5.1 + '@types/node': 18.7.13 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + jest-util: 27.5.1 + string-length: 4.0.2 + dev: true + + /jest-watcher/28.1.3: + resolution: {integrity: sha512-t4qcqj9hze+jviFPUN3YAtAEeFnr/azITXQEMARf5cMwKY2SMBRnCQTXLixTl20OR6mLh9KLMrgVJgJISym+1g==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/test-result': 28.1.3 + '@jest/types': 28.1.3 + '@types/node': 18.7.13 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.10.2 + jest-util: 28.1.3 + string-length: 4.0.2 + dev: true + + /jest-worker/27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + dependencies: + '@types/node': 18.7.13 + merge-stream: 2.0.0 + supports-color: 8.1.1 + dev: true + + /jest-worker/28.1.3: + resolution: {integrity: sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@types/node': 18.7.13 + merge-stream: 2.0.0 + supports-color: 8.1.1 + dev: true + + /jest/27.5.1: + resolution: {integrity: sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 27.5.1 + import-local: 3.1.0 + jest-cli: 27.5.1 + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - ts-node + - utf-8-validate + dev: true + + /joi/17.6.0: + resolution: {integrity: sha512-OX5dG6DTbcr/kbMFj0KGYxuew69HPcAE3K/sZpEV2nP6e/j/C0HV+HNiBPCASxdx5T7DMoa0s8UeHWMnb6n2zw==} + dependencies: + '@hapi/hoek': 9.3.0 + '@hapi/topo': 5.1.0 + '@sideway/address': 4.1.4 + '@sideway/formula': 3.0.0 + '@sideway/pinpoint': 2.0.0 + dev: true + + /js-beautify/1.14.6: + resolution: {integrity: sha512-GfofQY5zDp+cuHc+gsEXKPpNw2KbPddreEo35O6jT6i0RVK6LhsoYBhq5TvK4/n74wnA0QbK8gGd+jUZwTMKJw==} + engines: {node: '>=10'} + hasBin: true + dependencies: + config-chain: 1.1.13 + editorconfig: 0.15.3 + glob: 8.0.3 + nopt: 6.0.0 + dev: true + + /js-message/1.0.7: + resolution: {integrity: sha512-efJLHhLjIyKRewNS9EGZ4UpI8NguuL6fKkhRxVuMmrGV2xN/0APGdQYwLFky5w9naebSZ0OwAGp0G6/2Cg90rA==} + engines: {node: '>=0.6.0'} + dev: true + + /js-tokens/4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + /js-yaml/3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + dev: true + + /jsdom/16.7.0: + resolution: {integrity: sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==} + engines: {node: '>=10'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + abab: 2.0.6 + acorn: 8.8.0 + acorn-globals: 6.0.0 + cssom: 0.4.4 + cssstyle: 2.3.0 + data-urls: 2.0.0 + decimal.js: 10.4.0 + domexception: 2.0.1 + escodegen: 2.0.0 + form-data: 3.0.1 + html-encoding-sniffer: 2.0.1 + http-proxy-agent: 4.0.1 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.1 + parse5: 6.0.1 + saxes: 5.0.1 + symbol-tree: 3.2.4 + tough-cookie: 4.1.0 + w3c-hr-time: 1.0.2 + w3c-xmlserializer: 2.0.0 + webidl-conversions: 6.1.0 + whatwg-encoding: 1.0.5 + whatwg-mimetype: 2.3.0 + whatwg-url: 8.7.0 + ws: 7.5.9 + xml-name-validator: 3.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + + /jsesc/0.5.0: + resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} + hasBin: true + dev: true + + /jsesc/2.5.2: + resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /json-parse-better-errors/1.0.2: + resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} + dev: true + + /json-parse-even-better-errors/2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + dev: true + + /json-schema-traverse/0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + /json-schema-traverse/1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + dev: true + + /json-stable-stringify-without-jsonify/1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + dev: true + + /json2module/0.0.3: + resolution: {integrity: sha512-qYGxqrRrt4GbB8IEOy1jJGypkNsjWoIMlZt4bAsmUScCA507Hbc2p1JOhBzqn45u3PWafUgH2OnzyNU7udO/GA==} + hasBin: true + dependencies: + rw: 1.3.3 + dev: false + + /json2mq/0.2.0: + resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==} + dependencies: + string-convert: 0.2.1 + dev: false + + /json5/0.5.1: + resolution: {integrity: sha512-4xrs1aW+6N5DalkqSVA8fxh458CXvR99WU8WLKmq4v8eWAL86Xo3BVqyd3SkA9wEVjCMqyvvRRkshAdOnBp5rw==} + hasBin: true + dev: true + + /json5/1.0.1: + resolution: {integrity: sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==} + hasBin: true + dependencies: + minimist: 1.2.6 + dev: true + + /json5/2.2.1: + resolution: {integrity: sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==} + engines: {node: '>=6'} + hasBin: true + + /jsonfile/6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + dependencies: + universalify: 2.0.0 + optionalDependencies: + graceful-fs: 4.2.10 + dev: true + + /jsonparse/1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} + engines: {'0': node >= 0.2.0} + dev: true + + /kind-of/3.2.2: + resolution: {integrity: sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==} + engines: {node: '>=0.10.0'} + dependencies: + is-buffer: 1.1.6 + + /kind-of/6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + dev: true + + /kleur/3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + dev: true + + /klona/2.0.5: + resolution: {integrity: sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==} + engines: {node: '>= 8'} + dev: true + + /known-css-properties/0.25.0: + resolution: {integrity: sha512-b0/9J1O9Jcyik1GC6KC42hJ41jKwdO/Mq8Mdo5sYN+IuRTXs2YFHZC3kZSx6ueusqa95x3wLYe/ytKjbAfGixA==} + dev: true + + /launch-editor-middleware/2.6.0: + resolution: {integrity: sha512-K2yxgljj5TdCeRN1lBtO3/J26+AIDDDw+04y6VAiZbWcTdBwsYN6RrZBnW5DN/QiSIdKNjKdATLUUluWWFYTIA==} + dependencies: + launch-editor: 2.6.0 + dev: true + + /launch-editor/2.6.0: + resolution: {integrity: sha512-JpDCcQnyAAzZZaZ7vEiSqL690w7dAEyLao+KC96zBplnYbJS7TYNjvM3M7y3dGz+v7aIsJk3hllWuc0kWAjyRQ==} + dependencies: + picocolors: 1.0.0 + shell-quote: 1.7.3 + dev: true + + /lazy-cache/1.0.4: + resolution: {integrity: sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==} + engines: {node: '>=0.10.0'} + dev: false + + /less-loader/5.0.0_less@3.13.1: + resolution: {integrity: sha512-bquCU89mO/yWLaUq0Clk7qCsKhsF/TZpJUzETRvJa9KSVEL9SO3ovCvdEHISBhrC81OwC8QSVX7E0bzElZj9cg==} + engines: {node: '>= 4.8.0'} + peerDependencies: + less: ^2.3.1 || ^3.0.0 + webpack: ^2.0.0 || ^3.0.0 || ^4.0.0 + dependencies: + clone: 2.1.2 + less: 3.13.1 + loader-utils: 1.4.0 + pify: 4.0.1 + dev: true + + /less/3.13.1: + resolution: {integrity: sha512-SwA1aQXGUvp+P5XdZslUOhhLnClSLIjWvJhmd+Vgib5BFIr9lMNlQwmwUNOjXThF/A0x+MCYYPeWEfeWiLRnTw==} + engines: {node: '>=6'} + hasBin: true + dependencies: + copy-anything: 2.0.6 + tslib: 1.14.1 + optionalDependencies: + errno: 0.1.8 + graceful-fs: 4.2.10 + image-size: 0.5.5 + make-dir: 2.1.0 + mime: 1.6.0 + native-request: 1.1.0 + source-map: 0.6.1 + dev: true + + /leven/3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + dev: true + + /levn/0.3.0: + resolution: {integrity: sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.1.2 + type-check: 0.3.2 + dev: true + + /levn/0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + dev: true + + /lilconfig/2.0.5: + resolution: {integrity: sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==} + engines: {node: '>=10'} + dev: true + + /lilconfig/2.0.6: + resolution: {integrity: sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==} + engines: {node: '>=10'} + dev: true + + /lines-and-columns/1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + dev: true + + /lint-staged/12.5.0: + resolution: {integrity: sha512-BKLUjWDsKquV/JuIcoQW4MSAI3ggwEImF1+sB4zaKvyVx1wBk3FsG7UK9bpnmBTN1pm7EH2BBcMwINJzCRv12g==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + dependencies: + cli-truncate: 3.1.0 + colorette: 2.0.19 + commander: 9.4.0 + debug: 4.3.4_supports-color@9.2.2 + execa: 5.1.1 + lilconfig: 2.0.5 + listr2: 4.0.5 + micromatch: 4.0.5 + normalize-path: 3.0.0 + object-inspect: 1.12.2 + pidtree: 0.5.0 + string-argv: 0.3.1 + supports-color: 9.2.2 + yaml: 1.10.2 + transitivePeerDependencies: + - enquirer + dev: true + + /listr2/4.0.5: + resolution: {integrity: sha512-juGHV1doQdpNT3GSTs9IUN43QJb7KHdF9uqg7Vufs/tG9VTzpFphqF4pm/ICdAABGQxsyNn9CiYA3StkI6jpwA==} + engines: {node: '>=12'} + peerDependencies: + enquirer: '>= 2.3.0 < 3' + peerDependenciesMeta: + enquirer: + optional: true + dependencies: + cli-truncate: 2.1.0 + colorette: 2.0.19 + log-update: 4.0.0 + p-map: 4.0.0 + rfdc: 1.3.0 + rxjs: 7.5.6 + through: 2.3.8 + wrap-ansi: 7.0.0 + dev: true + + /loader-runner/4.3.0: + resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} + engines: {node: '>=6.11.5'} + dev: true + + /loader-utils/1.1.0: + resolution: {integrity: sha512-gkD9aSEG9UGglyPcDJqY9YBTUtCLKaBK6ihD2VP1d1X60lTfFspNZNulGBBbUZLkPygy4LySYHyxBpq+VhjObQ==} + engines: {node: '>=4.0.0'} + dependencies: + big.js: 3.2.0 + emojis-list: 2.1.0 + json5: 0.5.1 + dev: true + + /loader-utils/1.4.0: + resolution: {integrity: sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==} + engines: {node: '>=4.0.0'} + dependencies: + big.js: 5.2.2 + emojis-list: 3.0.0 + json5: 1.0.1 + dev: true + + /loader-utils/2.0.2: + resolution: {integrity: sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==} + engines: {node: '>=8.9.0'} + dependencies: + big.js: 5.2.2 + emojis-list: 3.0.0 + json5: 2.2.1 + + /locate-path/5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + dependencies: + p-locate: 4.1.0 + + /locate-path/6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + dependencies: + p-locate: 5.0.0 + dev: true + + /lodash.clonedeep/4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + dev: false + + /lodash.debounce/4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + dev: true + + /lodash.defaultsdeep/4.6.1: + resolution: {integrity: sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==} + dev: true + + /lodash.get/4.4.2: + resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + dev: false + + /lodash.kebabcase/4.1.1: + resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} + dev: true + + /lodash.map/4.6.0: + resolution: {integrity: sha512-worNHGKLDetmcEYDvh2stPCrrQRkP20E4l0iIS7F8EvzMqBBi7ltvFN5m1HvTf1P7Jk1txKhvFcmYsCr8O2F1Q==} + dev: true + + /lodash.mapvalues/4.6.0: + resolution: {integrity: sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ==} + dev: true + + /lodash.memoize/4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + dev: true + + /lodash.merge/4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + dev: true + + /lodash.pick/4.4.0: + resolution: {integrity: sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==} + dev: false + + /lodash.truncate/4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + dev: true + + /lodash.uniq/4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + dev: true + + /lodash/4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + /log-symbols/4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + dev: true + + /log-update/2.3.0: + resolution: {integrity: sha512-vlP11XfFGyeNQlmEn9tJ66rEW1coA/79m5z6BCkudjbAGE83uhAcGYrBFwfs3AdLiLzGRusRPAbSPK9xZteCmg==} + engines: {node: '>=4'} + dependencies: + ansi-escapes: 3.2.0 + cli-cursor: 2.1.0 + wrap-ansi: 3.0.1 + dev: true + + /log-update/4.0.0: + resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==} + engines: {node: '>=10'} + dependencies: + ansi-escapes: 4.3.2 + cli-cursor: 3.1.0 + slice-ansi: 4.0.0 + wrap-ansi: 6.2.0 + dev: true + + /longest/1.0.1: + resolution: {integrity: sha512-k+yt5n3l48JU4k8ftnKG6V7u32wyH2NfKzeMto9F/QRE0amxy/LayxwlvjjkZEIzqR+19IrtFO8p5kB9QaYUFg==} + engines: {node: '>=0.10.0'} + dev: false + + /longest/2.0.1: + resolution: {integrity: sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q==} + engines: {node: '>=0.10.0'} + dev: true + + /loose-envify/1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + dependencies: + js-tokens: 4.0.0 + dev: false + + /lower-case/2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + dependencies: + tslib: 2.4.0 + dev: true + + /lru-cache/4.1.5: + resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} + dependencies: + pseudomap: 1.0.2 + yallist: 2.1.2 + dev: true + + /lru-cache/6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + dependencies: + yallist: 4.0.0 + dev: true + + /make-dir/2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + dependencies: + pify: 4.0.1 + semver: 5.7.1 + dev: true + optional: true + + /make-dir/3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + dependencies: + semver: 6.3.0 + + /make-error/1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + dev: true + optional: true + + /makeerror/1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + dependencies: + tmpl: 1.0.5 + dev: true + + /map-obj/1.0.1: + resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} + engines: {node: '>=0.10.0'} + dev: true + + /map-obj/4.3.0: + resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==} + engines: {node: '>=8'} + dev: true + + /mathml-tag-names/2.1.3: + resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} + dev: true + + /md5/2.3.0: + resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} + dependencies: + charenc: 0.0.2 + crypt: 0.0.2 + is-buffer: 1.1.6 + dev: false + + /mdn-data/2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + dev: true + + /mdn-data/2.0.4: + resolution: {integrity: sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==} + dev: true + + /media-typer/0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + dev: true + + /memfs/3.4.7: + resolution: {integrity: sha512-ygaiUSNalBX85388uskeCyhSAoOSgzBbtVCr9jA2RROssFL9Q19/ZXFqS+2Th2sr1ewNIWgFdLzLC3Yl1Zv+lw==} + engines: {node: '>= 4.0.0'} + dependencies: + fs-monkey: 1.0.3 + dev: true + + /meow/8.1.2: + resolution: {integrity: sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==} + engines: {node: '>=10'} + dependencies: + '@types/minimist': 1.2.2 + camelcase-keys: 6.2.2 + decamelize-keys: 1.1.0 + hard-rejection: 2.1.0 + minimist-options: 4.1.0 + normalize-package-data: 3.0.3 + read-pkg-up: 7.0.1 + redent: 3.0.0 + trim-newlines: 3.0.1 + type-fest: 0.18.1 + yargs-parser: 20.2.9 + dev: true + + /meow/9.0.0: + resolution: {integrity: sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==} + engines: {node: '>=10'} + dependencies: + '@types/minimist': 1.2.2 + camelcase-keys: 6.2.2 + decamelize: 1.2.0 + decamelize-keys: 1.1.0 + hard-rejection: 2.1.0 + minimist-options: 4.1.0 + normalize-package-data: 3.0.3 + read-pkg-up: 7.0.1 + redent: 3.0.0 + trim-newlines: 3.0.1 + type-fest: 0.18.1 + yargs-parser: 20.2.9 + dev: true + + /merge-descriptors/1.0.1: + resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} + dev: true + + /merge-source-map/1.1.0: + resolution: {integrity: sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw==} + dependencies: + source-map: 0.6.1 + dev: true + + /merge-stream/2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + dev: true + + /merge/2.1.1: + resolution: {integrity: sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==} + dev: true + + /merge2/1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + dev: true + + /methods/1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + dev: true + + /micromatch/4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + engines: {node: '>=8.6'} + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + dev: true + + /mime-db/1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: true + + /mime-types/2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: true + + /mime/1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /mimic-fn/1.2.0: + resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} + engines: {node: '>=4'} + dev: true + + /mimic-fn/2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + dev: true + + /min-indent/1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true + + /mini-css-extract-plugin/2.6.1_webpack@5.74.0: + resolution: {integrity: sha512-wd+SD57/K6DiV7jIR34P+s3uckTRuQvx0tKPcvjFlrEylk6P4mQ2KSWk1hblj1Kxaqok7LogKOieygXqBczNlg==} + engines: {node: '>= 12.13.0'} + peerDependencies: + webpack: ^5.0.0 + dependencies: + schema-utils: 4.0.0 + webpack: 5.74.0 + dev: true + + /minimalistic-assert/1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + dev: true + + /minimatch/3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + + /minimatch/5.1.0: + resolution: {integrity: sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==} + engines: {node: '>=10'} + dependencies: + brace-expansion: 2.0.1 + dev: true + + /minimist-options/4.1.0: + resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} + engines: {node: '>= 6'} + dependencies: + arrify: 1.0.1 + is-plain-obj: 1.1.0 + kind-of: 6.0.3 + dev: true + + /minimist/1.2.6: + resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==} + + /minipass/3.3.4: + resolution: {integrity: sha512-I9WPbWHCGu8W+6k1ZiGpPu0GkoKBeorkfKNuAFBNS1HNFJvke82sxvI5bzcCNpWPorkOO5QQ+zomzzwRxejXiw==} + engines: {node: '>=8'} + dependencies: + yallist: 4.0.0 + dev: true + + /mkdirp/0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + dependencies: + minimist: 1.2.6 + dev: true + + /mockjs2/1.0.8: + resolution: {integrity: sha512-IXY9wzq3Pr2tybkJnT+dzrTz0GBRTtgXc7Cke/UUQyyWtbjDrck8uZ3NmMF4LaWgAD8vm8EMGcBk4Itc6nzpRg==} + hasBin: true + dependencies: + commander: 9.4.0 + dev: false + + /module-alias/2.2.2: + resolution: {integrity: sha512-A/78XjoX2EmNvppVWEhM2oGk3x4lLxnkEA4jTbaK97QKSDjkIoOsKQlfylt/d3kKKi596Qy3NP5XrXJ6fZIC9Q==} + dev: true + + /moment/2.29.4: + resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==} + dev: false + + /mrmime/1.0.1: + resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} + engines: {node: '>=10'} + dev: true + + /ms/2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + dev: true + + /ms/2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: true + + /ms/2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + dev: true + + /multicast-dns/7.2.5: + resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} + hasBin: true + dependencies: + dns-packet: 5.4.0 + thunky: 1.1.0 + dev: true + + /mutationobserver-shim/0.3.7: + resolution: {integrity: sha512-oRIDTyZQU96nAiz2AQyngwx1e89iApl2hN5AOYwyxLUB47UYsU3Wv9lJWqH5y/QdiYkc5HQLi23ZNB3fELdHcQ==} + dev: false + + /mute-stream/0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + dev: true + + /mz/2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + dev: true + + /nanoid/3.3.4: + resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + /nanopop/2.1.0: + resolution: {integrity: sha512-jGTwpFRexSH+fxappnGQtN9dspgE2ipa1aOjtR24igG0pv6JCxImIAmrLRHX+zUF5+1wtsFVbKyfP51kIGAVNw==} + dev: false + + /native-request/1.1.0: + resolution: {integrity: sha512-uZ5rQaeRn15XmpgE0xoPL8YWqcX90VtCFglYwAgkvKM5e8fog+vePLAhHxuuv/gRkrQxIeh5U3q9sMNUrENqWw==} + requiresBuild: true + dev: true + optional: true + + /natural-compare/1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + dev: true + + /negotiator/0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + dev: true + + /neo-async/2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + dev: true + + /nice-try/1.0.5: + resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + dev: true + + /no-case/3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + dependencies: + lower-case: 2.0.2 + tslib: 2.4.0 + dev: true + + /node-emoji/1.11.0: + resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + dependencies: + lodash: 4.17.21 + dev: false + + /node-fetch/1.7.3: + resolution: {integrity: sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==} + dependencies: + encoding: 0.1.13 + is-stream: 1.1.0 + dev: false + + /node-fetch/2.6.7: + resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + dependencies: + whatwg-url: 5.0.0 + dev: true + + /node-forge/1.3.1: + resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} + engines: {node: '>= 6.13.0'} + dev: true + + /node-int64/0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + dev: true + + /node-releases/2.0.6: + resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==} + dev: true + + /nopt/6.0.0: + resolution: {integrity: sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + hasBin: true + dependencies: + abbrev: 1.1.1 + dev: true + + /normalize-package-data/2.5.0: + resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} + dependencies: + hosted-git-info: 2.8.9 + resolve: 1.22.1 + semver: 5.7.1 + validate-npm-package-license: 3.0.4 + dev: true + + /normalize-package-data/3.0.3: + resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==} + engines: {node: '>=10'} + dependencies: + hosted-git-info: 4.1.0 + is-core-module: 2.10.0 + semver: 7.3.7 + validate-npm-package-license: 3.0.4 + dev: true + + /normalize-path/1.0.0: + resolution: {integrity: sha512-7WyT0w8jhpDStXRq5836AMmihQwq2nrUVQrgjvUo/p/NZf9uy/MeJ246lBJVmWuYXMlJuG9BNZHF0hWjfTbQUA==} + engines: {node: '>=0.10.0'} + dev: true + + /normalize-path/3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: true + + /normalize-range/0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + dev: true + + /normalize-url/6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + dev: true + + /npm-run-path/2.0.2: + resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} + engines: {node: '>=4'} + dependencies: + path-key: 2.0.1 + dev: true + + /npm-run-path/4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + dependencies: + path-key: 3.1.1 + dev: true + + /nprogress/0.2.0: + resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==} + dev: false + + /nth-check/1.0.2: + resolution: {integrity: sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==} + dependencies: + boolbase: 1.0.0 + dev: true + + /nth-check/2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + dependencies: + boolbase: 1.0.0 + dev: true + + /nwsapi/2.2.1: + resolution: {integrity: sha512-JYOWTeFoS0Z93587vRJgASD5Ut11fYl5NyihP3KrYBvMe1FRRs6RN7m20SA/16GM4P6hTnZjT+UmDOt38UeXNg==} + dev: true + + /object-assign/4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + /object-inspect/1.12.2: + resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==} + + /object-is/1.1.5: + resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.1.4 + dev: false + + /object-keys/1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + /object.assign/4.1.4: + resolution: {integrity: sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.1.4 + has-symbols: 1.0.3 + object-keys: 1.1.1 + + /object.getownpropertydescriptors/2.1.4: + resolution: {integrity: sha512-sccv3L/pMModT6dJAYF3fzGMVcb38ysQ0tEE6ixv2yXJDtEIPph268OlAdJj5/qZMZDq2g/jqvwppt36uS/uQQ==} + engines: {node: '>= 0.8'} + dependencies: + array.prototype.reduce: 1.0.4 + call-bind: 1.0.2 + define-properties: 1.1.4 + es-abstract: 1.20.1 + dev: true + + /object.values/1.1.5: + resolution: {integrity: sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.1.4 + es-abstract: 1.20.1 + dev: true + + /obuf/1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + dev: true + + /omit.js/1.0.2: + resolution: {integrity: sha512-/QPc6G2NS+8d4L/cQhbk6Yit1WTB6Us2g84A7A/1+w9d/eRGHyEqC5kkQtHVoHZ5NFWGG7tUGgrhVZwgZanKrQ==} + dependencies: + babel-runtime: 6.26.0 + dev: false + + /on-finished/2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + dependencies: + ee-first: 1.1.1 + dev: true + + /on-headers/1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + engines: {node: '>= 0.8'} + dev: true + + /once/1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + dependencies: + wrappy: 1.0.2 + + /onetime/2.0.1: + resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} + engines: {node: '>=4'} + dependencies: + mimic-fn: 1.2.0 + dev: true + + /onetime/5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + dependencies: + mimic-fn: 2.1.0 + dev: true + + /open/8.4.0: + resolution: {integrity: sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==} + engines: {node: '>=12'} + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + dev: true + + /opener/1.5.2: + resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} + hasBin: true + dev: true + + /optionator/0.8.3: + resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} + engines: {node: '>= 0.8.0'} + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.3.0 + prelude-ls: 1.1.2 + type-check: 0.3.2 + word-wrap: 1.2.3 + dev: true + + /optionator/0.9.1: + resolution: {integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==} + engines: {node: '>= 0.8.0'} + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.3 + dev: true + + /ora/5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.7.0 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + dev: true + + /os-tmpdir/1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + dev: true + + /p-finally/1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + dev: true + + /p-limit/2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + dependencies: + p-try: 2.2.0 + + /p-limit/3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + dependencies: + yocto-queue: 0.1.0 + dev: true + + /p-locate/4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + dependencies: + p-limit: 2.3.0 + + /p-locate/5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + dependencies: + p-limit: 3.1.0 + dev: true + + /p-map/4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + dependencies: + aggregate-error: 3.1.0 + dev: true + + /p-retry/4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + dev: true + + /p-try/2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + /param-case/3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + dependencies: + dot-case: 3.0.4 + tslib: 2.4.0 + dev: true + + /parchment/1.1.4: + resolution: {integrity: sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==} + dev: false + + /parent-module/1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + dependencies: + callsites: 3.1.0 + dev: true + + /parse-json/5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + dependencies: + '@babel/code-frame': 7.18.6 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + dev: true + + /parse-passwd/1.0.0: + resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} + engines: {node: '>=0.10.0'} + dev: true + + /parse-svg-path/0.1.2: + resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} + dev: false + + /parse5-htmlparser2-tree-adapter/6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + dependencies: + parse5: 6.0.1 + dev: true + + /parse5/5.1.1: + resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} + dev: true + + /parse5/6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + dev: true + + /parseurl/1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + dev: true + + /pascal-case/3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + dependencies: + no-case: 3.0.4 + tslib: 2.4.0 + dev: true + + /path-exists/4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + /path-is-absolute/1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + /path-key/2.0.1: + resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} + engines: {node: '>=4'} + dev: true + + /path-key/3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + dev: true + + /path-parse/1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + /path-to-regexp/0.1.7: + resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} + dev: true + + /path-type/4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + dev: true + + /performance-now/2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + dev: false + + /picocolors/0.2.1: + resolution: {integrity: sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==} + dev: true + + /picocolors/1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + + /picomatch/2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + dev: true + + /pidtree/0.5.0: + resolution: {integrity: sha512-9nxspIM7OpZuhBxPg73Zvyq7j1QMPMPsGKTqRc2XOaFQauDvoNz9fM1Wdkjmeo7l9GXOZiRs97sPkuayl39wjA==} + engines: {node: '>=0.10'} + hasBin: true + dev: true + + /pify/4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + dev: true + + /pirates/4.0.5: + resolution: {integrity: sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==} + engines: {node: '>= 6'} + dev: true + + /pkg-dir/4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + dependencies: + find-up: 4.1.0 + + /point-at-length/1.0.2: + resolution: {integrity: sha512-DSGca2Q7A/4rGS6324Z+0hCVAPT729RFjsISPc6N11D6+r1TpP6KjktGL7HxN8XRYY0Z7EG8n9dBJ5dbrEP4SQ==} + dependencies: + abs-svg-path: 0.1.1 + isarray: 0.0.1 + parse-svg-path: 0.1.2 + dev: false + + /portfinder/1.0.32: + resolution: {integrity: sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==} + engines: {node: '>= 0.12.0'} + dependencies: + async: 2.6.4 + debug: 3.2.7 + mkdirp: 0.5.6 + transitivePeerDependencies: + - supports-color + dev: true + + /postcss-calc/8.2.4_postcss@8.4.16: + resolution: {integrity: sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==} + peerDependencies: + postcss: ^8.2.2 + dependencies: + postcss: 8.4.16 + postcss-selector-parser: 6.0.10 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-colormin/5.3.0_postcss@8.4.16: + resolution: {integrity: sha512-WdDO4gOFG2Z8n4P8TWBpshnL3JpmNmJwdnfP2gbk2qBA8PWwOYcmjmI/t3CmMeL72a7Hkd+x/Mg9O2/0rD54Pg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + browserslist: 4.21.3 + caniuse-api: 3.0.0 + colord: 2.9.3 + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-convert-values/5.1.2_postcss@8.4.16: + resolution: {integrity: sha512-c6Hzc4GAv95B7suy4udszX9Zy4ETyMCgFPUDtWjdFTKH1SE9eFY/jEpHSwTH1QPuwxHpWslhckUQWbNRM4ho5g==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + browserslist: 4.21.3 + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-discard-comments/5.1.2_postcss@8.4.16: + resolution: {integrity: sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + dev: true + + /postcss-discard-duplicates/5.1.0_postcss@8.4.16: + resolution: {integrity: sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + dev: true + + /postcss-discard-empty/5.1.1_postcss@8.4.16: + resolution: {integrity: sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + dev: true + + /postcss-discard-overridden/5.1.0_postcss@8.4.16: + resolution: {integrity: sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + dev: true + + /postcss-less/6.0.0_postcss@8.4.16: + resolution: {integrity: sha512-FPX16mQLyEjLzEuuJtxA8X3ejDLNGGEG503d2YGZR5Ask1SpDN8KmZUMpzCvyalWRywAn1n1VOA5dcqfCLo5rg==} + engines: {node: '>=12'} + peerDependencies: + postcss: ^8.3.5 + dependencies: + postcss: 8.4.16 + dev: true + + /postcss-loader/6.2.1_qjv4cptcpse3y5hrjkrbb7drda: + resolution: {integrity: sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==} + engines: {node: '>= 12.13.0'} + peerDependencies: + postcss: ^7.0.0 || ^8.0.1 + webpack: ^5.0.0 + dependencies: + cosmiconfig: 7.0.1 + klona: 2.0.5 + postcss: 8.4.16 + semver: 7.3.7 + webpack: 5.74.0 + dev: true + + /postcss-media-query-parser/0.2.3: + resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} + dev: true + + /postcss-merge-longhand/5.1.6_postcss@8.4.16: + resolution: {integrity: sha512-6C/UGF/3T5OE2CEbOuX7iNO63dnvqhGZeUnKkDeifebY0XqkkvrctYSZurpNE902LDf2yKwwPFgotnfSoPhQiw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + stylehacks: 5.1.0_postcss@8.4.16 + dev: true + + /postcss-merge-rules/5.1.2_postcss@8.4.16: + resolution: {integrity: sha512-zKMUlnw+zYCWoPN6yhPjtcEdlJaMUZ0WyVcxTAmw3lkkN/NDMRkOkiuctQEoWAOvH7twaxUUdvBWl0d4+hifRQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + browserslist: 4.21.3 + caniuse-api: 3.0.0 + cssnano-utils: 3.1.0_postcss@8.4.16 + postcss: 8.4.16 + postcss-selector-parser: 6.0.10 + dev: true + + /postcss-minify-font-values/5.1.0_postcss@8.4.16: + resolution: {integrity: sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-minify-gradients/5.1.1_postcss@8.4.16: + resolution: {integrity: sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + colord: 2.9.3 + cssnano-utils: 3.1.0_postcss@8.4.16 + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-minify-params/5.1.3_postcss@8.4.16: + resolution: {integrity: sha512-bkzpWcjykkqIujNL+EVEPOlLYi/eZ050oImVtHU7b4lFS82jPnsCb44gvC6pxaNt38Els3jWYDHTjHKf0koTgg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + browserslist: 4.21.3 + cssnano-utils: 3.1.0_postcss@8.4.16 + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-minify-selectors/5.2.1_postcss@8.4.16: + resolution: {integrity: sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + postcss-selector-parser: 6.0.10 + dev: true + + /postcss-modules-extract-imports/3.0.0_postcss@8.4.16: + resolution: {integrity: sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + postcss: 8.4.16 + dev: true + + /postcss-modules-local-by-default/4.0.0_postcss@8.4.16: + resolution: {integrity: sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + icss-utils: 5.1.0_postcss@8.4.16 + postcss: 8.4.16 + postcss-selector-parser: 6.0.10 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-modules-scope/3.0.0_postcss@8.4.16: + resolution: {integrity: sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + postcss: 8.4.16 + postcss-selector-parser: 6.0.10 + dev: true + + /postcss-modules-values/4.0.0_postcss@8.4.16: + resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + icss-utils: 5.1.0_postcss@8.4.16 + postcss: 8.4.16 + dev: true + + /postcss-normalize-charset/5.1.0_postcss@8.4.16: + resolution: {integrity: sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + dev: true + + /postcss-normalize-display-values/5.1.0_postcss@8.4.16: + resolution: {integrity: sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-normalize-positions/5.1.1_postcss@8.4.16: + resolution: {integrity: sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-normalize-repeat-style/5.1.1_postcss@8.4.16: + resolution: {integrity: sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-normalize-string/5.1.0_postcss@8.4.16: + resolution: {integrity: sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-normalize-timing-functions/5.1.0_postcss@8.4.16: + resolution: {integrity: sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-normalize-unicode/5.1.0_postcss@8.4.16: + resolution: {integrity: sha512-J6M3MizAAZ2dOdSjy2caayJLQT8E8K9XjLce8AUQMwOrCvjCHv24aLC/Lps1R1ylOfol5VIDMaM/Lo9NGlk1SQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + browserslist: 4.21.3 + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-normalize-url/5.1.0_postcss@8.4.16: + resolution: {integrity: sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + normalize-url: 6.1.0 + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-normalize-whitespace/5.1.1_postcss@8.4.16: + resolution: {integrity: sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-ordered-values/5.1.3_postcss@8.4.16: + resolution: {integrity: sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + cssnano-utils: 3.1.0_postcss@8.4.16 + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-reduce-initial/5.1.0_postcss@8.4.16: + resolution: {integrity: sha512-5OgTUviz0aeH6MtBjHfbr57tml13PuedK/Ecg8szzd4XRMbYxH4572JFG067z+FqBIf6Zp/d+0581glkvvWMFw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + browserslist: 4.21.3 + caniuse-api: 3.0.0 + postcss: 8.4.16 + dev: true + + /postcss-reduce-transforms/5.1.0_postcss@8.4.16: + resolution: {integrity: sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-resolve-nested-selector/0.1.1: + resolution: {integrity: sha512-HvExULSwLqHLgUy1rl3ANIqCsvMS0WHss2UOsXhXnQaZ9VCc2oBvIpXrl00IUFT5ZDITME0o6oiXeiHr2SAIfw==} + dev: true + + /postcss-safe-parser/6.0.0_postcss@8.4.16: + resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.3.3 + dependencies: + postcss: 8.4.16 + dev: true + + /postcss-selector-parser/6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + dev: true + + /postcss-sorting/7.0.1_postcss@8.4.16: + resolution: {integrity: sha512-iLBFYz6VRYyLJEJsBJ8M3TCqNcckVzz4wFounSc5Oez35ogE/X+aoC5fFu103Ot7NyvjU3/xqIXn93Gp3kJk4g==} + peerDependencies: + postcss: ^8.3.9 + dependencies: + postcss: 8.4.16 + dev: true + + /postcss-svgo/5.1.0_postcss@8.4.16: + resolution: {integrity: sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + postcss-value-parser: 4.2.0 + svgo: 2.8.0 + dev: true + + /postcss-unique-selectors/5.1.1_postcss@8.4.16: + resolution: {integrity: sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.16 + postcss-selector-parser: 6.0.10 + dev: true + + /postcss-value-parser/4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + dev: true + + /postcss/7.0.39: + resolution: {integrity: sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==} + engines: {node: '>=6.0.0'} + dependencies: + picocolors: 0.2.1 + source-map: 0.6.1 + dev: true + + /postcss/8.4.16: + resolution: {integrity: sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.4 + picocolors: 1.0.0 + source-map-js: 1.0.2 + + /prelude-ls/1.1.2: + resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==} + engines: {node: '>= 0.8.0'} + dev: true + + /prelude-ls/1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + dev: true + + /prettier/2.7.1: + resolution: {integrity: sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==} + engines: {node: '>=10.13.0'} + hasBin: true + requiresBuild: true + dev: true + optional: true + + /pretty-error/4.0.0: + resolution: {integrity: sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==} + dependencies: + lodash: 4.17.21 + renderkid: 3.0.0 + dev: true + + /pretty-format/27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + dev: true + + /pretty-format/28.1.3: + resolution: {integrity: sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/schemas': 28.1.3 + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 18.2.0 + dev: true + + /pretty/2.0.0: + resolution: {integrity: sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w==} + engines: {node: '>=0.10.0'} + dependencies: + condense-newlines: 0.2.1 + extend-shallow: 2.0.1 + js-beautify: 1.14.6 + dev: true + + /process-nextick-args/2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + dev: true + + /progress-webpack-plugin/1.0.16_webpack@5.74.0: + resolution: {integrity: sha512-sdiHuuKOzELcBANHfrupYo+r99iPRyOnw15qX+rNlVUqXGfjXdH4IgxriKwG1kNJwVswKQHMdj1hYZMcb9jFaA==} + engines: {node: '>= 10.13.0'} + peerDependencies: + webpack: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + dependencies: + chalk: 2.4.2 + figures: 2.0.0 + log-update: 2.3.0 + webpack: 5.74.0 + dev: true + + /progress/2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + dev: true + + /prompts/2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + dev: true + + /proto-list/1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + dev: true + + /proxy-addr/2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + dev: true + + /prr/1.0.1: + resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + dev: true + optional: true + + /pseudomap/1.0.2: + resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} + dev: true + + /psl/1.9.0: + resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + dev: true + + /pump/3.0.0: + resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + dev: true + + /punycode/2.1.1: + resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==} + engines: {node: '>=6'} + + /q/1.5.1: + resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} + engines: {node: '>=0.6.0', teleport: '>=0.2.0'} + dev: true + + /qs/6.10.3: + resolution: {integrity: sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==} + engines: {node: '>=0.6'} + dependencies: + side-channel: 1.0.4 + dev: true + + /qs/6.11.0: + resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} + engines: {node: '>=0.6'} + dependencies: + side-channel: 1.0.4 + dev: false + + /querystringify/2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + dev: true + + /queue-microtask/1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + dev: true + + /quick-lru/4.0.1: + resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} + engines: {node: '>=8'} + dev: true + + /quill-delta/3.6.3: + resolution: {integrity: sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==} + engines: {node: '>=0.10'} + dependencies: + deep-equal: 1.1.1 + extend: 3.0.2 + fast-diff: 1.1.2 + dev: false + + /quill/1.3.7: + resolution: {integrity: sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==} + dependencies: + clone: 2.1.2 + deep-equal: 1.1.1 + eventemitter3: 2.0.3 + extend: 3.0.2 + parchment: 1.1.4 + quill-delta: 3.6.3 + dev: false + + /raf/3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + dependencies: + performance-now: 2.1.0 + dev: false + + /randombytes/2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + dependencies: + safe-buffer: 5.2.1 + dev: true + + /range-parser/1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + dev: true + + /raw-body/2.5.1: + resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==} + engines: {node: '>= 0.8'} + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + dev: true + + /react-is/17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + dev: true + + /react-is/18.2.0: + resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} + dev: true + + /read-pkg-up/7.0.1: + resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} + engines: {node: '>=8'} + dependencies: + find-up: 4.1.0 + read-pkg: 5.2.0 + type-fest: 0.8.1 + dev: true + + /read-pkg/5.2.0: + resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} + engines: {node: '>=8'} + dependencies: + '@types/normalize-package-data': 2.4.1 + normalize-package-data: 2.5.0 + parse-json: 5.2.0 + type-fest: 0.6.0 + dev: true + + /readable-stream/2.3.7: + resolution: {integrity: sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==} + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + dev: true + + /readable-stream/3.6.0: + resolution: {integrity: sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==} + engines: {node: '>= 6'} + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + dev: true + + /readdirp/3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: true + + /redent/3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + dev: true + + /regenerate-unicode-properties/10.0.1: + resolution: {integrity: sha512-vn5DU6yg6h8hP/2OkQo3K7uVILvY4iu0oI4t3HFa81UPkhGJwkRwM10JEc3upjdhHjs/k8GJY1sRBhk5sr69Bw==} + engines: {node: '>=4'} + dependencies: + regenerate: 1.4.2 + dev: true + + /regenerate/1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + dev: true + + /regenerator-runtime/0.11.1: + resolution: {integrity: sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==} + dev: false + + /regenerator-runtime/0.13.9: + resolution: {integrity: sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==} + dev: true + + /regenerator-transform/0.15.0: + resolution: {integrity: sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg==} + dependencies: + '@babel/runtime': 7.18.9 + dev: true + + /regexp.prototype.flags/1.4.3: + resolution: {integrity: sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.1.4 + functions-have-names: 1.2.3 + + /regexpp/2.0.1: + resolution: {integrity: sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==} + engines: {node: '>=6.5.0'} + dev: true + + /regexpp/3.2.0: + resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} + engines: {node: '>=8'} + dev: true + + /regexpu-core/5.1.0: + resolution: {integrity: sha512-bb6hk+xWd2PEOkj5It46A16zFMs2mv86Iwpdu94la4S3sJ7C973h2dHpYKwIBGaWSO7cIRJ+UX0IeMaWcO4qwA==} + engines: {node: '>=4'} + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.0.1 + regjsgen: 0.6.0 + regjsparser: 0.8.4 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.0.0 + dev: true + + /regjsgen/0.6.0: + resolution: {integrity: sha512-ozE883Uigtqj3bx7OhL1KNbCzGyW2NQZPl6Hs09WTvCuZD5sTI4JY58bkbQWa/Y9hxIsvJ3M8Nbf7j54IqeZbA==} + dev: true + + /regjsparser/0.8.4: + resolution: {integrity: sha512-J3LABycON/VNEu3abOviqGHuB/LOtOQj8SKmfP9anY5GfAVw/SPjwzSjxGjbZXIxbGfqTHtJw58C2Li/WkStmA==} + hasBin: true + dependencies: + jsesc: 0.5.0 + dev: true + + /regression/2.0.1: + resolution: {integrity: sha512-A4XYsc37dsBaNOgEjkJKzfJlE394IMmUPlI/p3TTI9u3T+2a+eox5Pr/CPUqF0eszeWZJPAc6QkroAhuUpWDJQ==} + dev: false + + /relateurl/0.2.7: + resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} + engines: {node: '>= 0.10'} + dev: true + + /renderkid/3.0.0: + resolution: {integrity: sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==} + dependencies: + css-select: 4.3.0 + dom-converter: 0.2.0 + htmlparser2: 6.1.0 + lodash: 4.17.21 + strip-ansi: 6.0.1 + dev: true + + /repeat-string/1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + dev: false + + /require-directory/2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + dev: true + + /require-from-string/2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + dev: true + + /requires-port/1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + dev: true + + /resize-observer-lite/0.2.3: + resolution: {integrity: sha512-k/p+pjCTQkQ7x94bWsxcVwEJI5SrcO95j7czrCKMpHjXFQ+HmKRGLTdAkZoL3+wG1Pe/4L9Sl652zy9lU54dFg==} + dependencies: + element-resize-detector: 1.1.13 + dev: false + + /resize-observer-polyfill/1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + dev: false + + /resolve-cwd/3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + dependencies: + resolve-from: 5.0.0 + dev: true + + /resolve-dir/1.0.1: + resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==} + engines: {node: '>=0.10.0'} + dependencies: + expand-tilde: 2.0.2 + global-modules: 1.0.0 + dev: true + + /resolve-from/4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + dev: true + + /resolve-from/5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + dev: true + + /resolve-global/1.0.0: + resolution: {integrity: sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==} + engines: {node: '>=8'} + dependencies: + global-dirs: 0.1.1 + dev: true + + /resolve.exports/1.1.0: + resolution: {integrity: sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==} + engines: {node: '>=10'} + dev: true + + /resolve/1.22.1: + resolution: {integrity: sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==} + hasBin: true + dependencies: + is-core-module: 2.10.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + /restore-cursor/2.0.0: + resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} + engines: {node: '>=4'} + dependencies: + onetime: 2.0.1 + signal-exit: 3.0.7 + dev: true + + /restore-cursor/3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + dev: true + + /resumer/0.0.0: + resolution: {integrity: sha512-Fn9X8rX8yYF4m81rZCK/5VmrmsSbqS/i3rDLl6ZZHAXgC2nTAx3dhwG8q8odP/RmdLa2YrybDJaAMg+X1ajY3w==} + dependencies: + through: 2.3.8 + dev: false + + /retry/0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + dev: true + + /reusify/1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + dev: true + + /rfdc/1.3.0: + resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==} + dev: true + + /right-align/0.1.3: + resolution: {integrity: sha512-yqINtL/G7vs2v+dFIZmFUDbnVyFUJFKd6gK22Kgo6R4jfJGFtisKyncWDDULgjfqf4ASQuIQyjJ7XZ+3aWpsAg==} + engines: {node: '>=0.10.0'} + dependencies: + align-text: 0.1.4 + dev: false + + /rimraf/3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + + /rollup/0.25.8: + resolution: {integrity: sha512-a2S4Bh3bgrdO4BhKr2E4nZkjTvrJ2m2bWjMTzVYtoqSCn0HnuxosXnaJUHrMEziOWr3CzL9GjilQQKcyCQpJoA==} + hasBin: true + dependencies: + chalk: 1.1.3 + minimist: 1.2.6 + source-map-support: 0.3.3 + dev: false + + /run-async/2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + dev: true + + /run-parallel/1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + dependencies: + queue-microtask: 1.2.3 + dev: true + + /rw/1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + dev: false + + /rxjs/7.5.6: + resolution: {integrity: sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw==} + dependencies: + tslib: 2.4.0 + dev: true + + /safe-buffer/5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + dev: true + + /safe-buffer/5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + dev: true + + /safer-buffer/2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + /sax/1.2.4: + resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==} + dev: true + + /saxes/5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} + dependencies: + xmlchars: 2.2.0 + dev: true + + /schema-utils/2.7.1: + resolution: {integrity: sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==} + engines: {node: '>= 8.9.0'} + dependencies: + '@types/json-schema': 7.0.11 + ajv: 6.12.6 + ajv-keywords: 3.5.2_ajv@6.12.6 + + /schema-utils/3.1.1: + resolution: {integrity: sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==} + engines: {node: '>= 10.13.0'} + dependencies: + '@types/json-schema': 7.0.11 + ajv: 6.12.6 + ajv-keywords: 3.5.2_ajv@6.12.6 + dev: true + + /schema-utils/4.0.0: + resolution: {integrity: sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==} + engines: {node: '>= 12.13.0'} + dependencies: + '@types/json-schema': 7.0.11 + ajv: 8.11.0 + ajv-formats: 2.1.1 + ajv-keywords: 5.1.0_ajv@8.11.0 + dev: true + + /select-hose/2.0.0: + resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==} + dev: true + + /select/1.1.2: + resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==} + dev: false + + /selfsigned/2.0.1: + resolution: {integrity: sha512-LmME957M1zOsUhG+67rAjKfiWFox3SBxE/yymatMZsAx+oMrJ0YQ8AToOnyCm7xbeg2ep37IHLxdu0o2MavQOQ==} + engines: {node: '>=10'} + dependencies: + node-forge: 1.3.1 + dev: true + + /semver/5.7.1: + resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==} + hasBin: true + dev: true + + /semver/6.3.0: + resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==} + hasBin: true + + /semver/7.0.0: + resolution: {integrity: sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==} + hasBin: true + dev: true + + /semver/7.3.5: + resolution: {integrity: sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + dev: true + + /semver/7.3.7: + resolution: {integrity: sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + dev: true + + /send/0.18.0: + resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} + engines: {node: '>= 0.8.0'} + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + dev: true + + /serialize-javascript/6.0.0: + resolution: {integrity: sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==} + dependencies: + randombytes: 2.1.0 + dev: true + + /serve-index/1.9.1: + resolution: {integrity: sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==} + engines: {node: '>= 0.8.0'} + dependencies: + accepts: 1.3.8 + batch: 0.6.1 + debug: 2.6.9 + escape-html: 1.0.3 + http-errors: 1.6.3 + mime-types: 2.1.35 + parseurl: 1.3.3 + transitivePeerDependencies: + - supports-color + dev: true + + /serve-static/1.15.0: + resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} + engines: {node: '>= 0.8.0'} + dependencies: + encodeurl: 1.0.2 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.18.0 + transitivePeerDependencies: + - supports-color + dev: true + + /setprototypeof/1.1.0: + resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==} + dev: true + + /setprototypeof/1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + dev: true + + /shallow-clone/3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} + dependencies: + kind-of: 6.0.3 + dev: true + + /shallow-equal/1.2.1: + resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==} + dev: false + + /shallowequal/1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + dev: false + + /shebang-command/1.2.0: + resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} + engines: {node: '>=0.10.0'} + dependencies: + shebang-regex: 1.0.0 + dev: true + + /shebang-command/2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + dev: true + + /shebang-regex/1.0.0: + resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} + engines: {node: '>=0.10.0'} + dev: true + + /shebang-regex/3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + dev: true + + /shell-quote/1.7.3: + resolution: {integrity: sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==} + dev: true + + /side-channel/1.0.4: + resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.1.2 + object-inspect: 1.12.2 + + /sigmund/1.0.1: + resolution: {integrity: sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==} + dev: true + + /signal-exit/3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + dev: true + + /simple-statistics/6.1.1: + resolution: {integrity: sha512-zGwn0DDRa9Zel4H4n2pjTFIyGoAGpnpjrGIctreCxj5XWrcx9v7Xy7270FkC967WMmcvuc8ZU7m0ZG+hGN7gAA==} + dev: false + + /sirv/1.0.19: + resolution: {integrity: sha512-JuLThK3TnZG1TAKDwNIqNq6QA2afLOCcm+iE8D1Kj3GA40pSPsxQjjJl0J8X3tsR7T+CP1GavpzLwYkgVLWrZQ==} + engines: {node: '>= 10'} + dependencies: + '@polka/url': 1.0.0-next.21 + mrmime: 1.0.1 + totalist: 1.1.0 + dev: true + + /sisteransi/1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + dev: true + + /slash/3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + dev: true + + /slash/4.0.0: + resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} + engines: {node: '>=12'} + dev: true + + /slice-ansi/3.0.0: + resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} + engines: {node: '>=8'} + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + dev: true + + /slice-ansi/4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + dev: true + + /slice-ansi/5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + dependencies: + ansi-styles: 6.1.0 + is-fullwidth-code-point: 4.0.0 + dev: true + + /sockjs/0.3.24: + resolution: {integrity: sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==} + dependencies: + faye-websocket: 0.11.4 + uuid: 8.3.2 + websocket-driver: 0.7.4 + dev: true + + /source-map-js/1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + + /source-map-support/0.3.3: + resolution: {integrity: sha512-9O4+y9n64RewmFoKUZ/5Tx9IHIcXM6Q+RTSw6ehnqybUz4a7iwR3Eaw80uLtqqQ5D0C+5H03D4KKGo9PdP33Gg==} + dependencies: + source-map: 0.1.32 + dev: false + + /source-map-support/0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + dev: true + + /source-map/0.1.32: + resolution: {integrity: sha512-htQyLrrRLkQ87Zfrir4/yN+vAUd6DNjVayEjTSHXu29AYQJw57I4/xEL/M6p6E/woPNJwvZt6rVlzc7gFEJccQ==} + engines: {node: '>=0.8.0'} + dependencies: + amdefine: 1.0.1 + dev: false + + /source-map/0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + dev: false + + /source-map/0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + /source-map/0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + dev: true + + /spdx-correct/3.1.1: + resolution: {integrity: sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==} + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.12 + dev: true + + /spdx-exceptions/2.3.0: + resolution: {integrity: sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==} + dev: true + + /spdx-expression-parse/3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + dependencies: + spdx-exceptions: 2.3.0 + spdx-license-ids: 3.0.12 + dev: true + + /spdx-license-ids/3.0.12: + resolution: {integrity: sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==} + dev: true + + /spdy-transport/3.0.0: + resolution: {integrity: sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==} + dependencies: + debug: 4.3.4 + detect-node: 2.1.0 + hpack.js: 2.1.6 + obuf: 1.1.2 + readable-stream: 3.6.0 + wbuf: 1.7.3 + transitivePeerDependencies: + - supports-color + dev: true + + /spdy/4.0.2: + resolution: {integrity: sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==} + engines: {node: '>=6.0.0'} + dependencies: + debug: 4.3.4 + handle-thing: 2.0.1 + http-deceiver: 1.2.7 + select-hose: 2.0.0 + spdy-transport: 3.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /split2/3.2.2: + resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==} + dependencies: + readable-stream: 3.6.0 + dev: true + + /sprintf-js/1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + dev: true + + /ssri/8.0.1: + resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==} + engines: {node: '>= 8'} + dependencies: + minipass: 3.3.4 + dev: true + + /stable/0.1.8: + resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} + deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' + dev: true + + /stack-utils/2.0.5: + resolution: {integrity: sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA==} + engines: {node: '>=10'} + dependencies: + escape-string-regexp: 2.0.0 + dev: true + + /stackframe/1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + dev: true + + /statuses/1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + dev: true + + /statuses/2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + dev: true + + /store/2.0.12: + resolution: {integrity: sha512-eO9xlzDpXLiMr9W1nQ3Nfp9EzZieIQc10zPPMP5jsVV7bLOziSFFBP0XoDXACEIFtdI+rIz0NwWVA/QVJ8zJtw==} + dev: false + + /string-argv/0.3.1: + resolution: {integrity: sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==} + engines: {node: '>=0.6.19'} + dev: true + + /string-convert/0.2.1: + resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==} + dev: false + + /string-length/4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + dev: true + + /string-length/5.0.1: + resolution: {integrity: sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==} + engines: {node: '>=12.20'} + dependencies: + char-regex: 2.0.1 + strip-ansi: 7.0.1 + dev: true + + /string-width/2.1.1: + resolution: {integrity: sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==} + engines: {node: '>=4'} + dependencies: + is-fullwidth-code-point: 2.0.0 + strip-ansi: 4.0.0 + dev: true + + /string-width/4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + dev: true + + /string-width/5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.0.1 + dev: true + + /string.prototype.trim/1.2.6: + resolution: {integrity: sha512-8lMR2m+U0VJTPp6JjvJTtGyc4FIGq9CdRt7O9p6T0e6K4vjU+OP+SQJpbe/SBmRcCUIvNUnjsbmY6lnMp8MhsQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.1.4 + es-abstract: 1.20.1 + dev: false + + /string.prototype.trimend/1.0.5: + resolution: {integrity: sha512-I7RGvmjV4pJ7O3kdf+LXFpVfdNOxtCW/2C8f6jNiW4+PQchwxkCDzlk1/7p+Wl4bqFIZeF47qAHXLuHHWKAxog==} + dependencies: + call-bind: 1.0.2 + define-properties: 1.1.4 + es-abstract: 1.20.1 + + /string.prototype.trimstart/1.0.5: + resolution: {integrity: sha512-THx16TJCGlsN0o6dl2o6ncWUsdgnLRSA23rRE5pyGBw/mLr3Ej/R2LaqCtgP8VNMGZsvMWnf9ooZPyY2bHvUFg==} + dependencies: + call-bind: 1.0.2 + define-properties: 1.1.4 + es-abstract: 1.20.1 + + /string_decoder/1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + dependencies: + safe-buffer: 5.1.2 + dev: true + + /string_decoder/1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + dependencies: + safe-buffer: 5.2.1 + dev: true + + /strip-ansi/3.0.1: + resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==} + engines: {node: '>=0.10.0'} + dependencies: + ansi-regex: 2.1.1 + dev: false + + /strip-ansi/4.0.0: + resolution: {integrity: sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==} + engines: {node: '>=4'} + dependencies: + ansi-regex: 3.0.1 + dev: true + + /strip-ansi/6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + dependencies: + ansi-regex: 5.0.1 + dev: true + + /strip-ansi/7.0.1: + resolution: {integrity: sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==} + engines: {node: '>=12'} + dependencies: + ansi-regex: 6.0.1 + dev: true + + /strip-bom/3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + dev: true + + /strip-bom/4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + dev: true + + /strip-eof/1.0.0: + resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==} + engines: {node: '>=0.10.0'} + dev: true + + /strip-final-newline/2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + dev: true + + /strip-indent/2.0.0: + resolution: {integrity: sha512-RsSNPLpq6YUL7QYy44RnPVTn/lcVZtb48Uof3X5JLbF4zD/Gs7ZFDv2HWol+leoQN2mT86LAzSshGfkTlSOpsA==} + engines: {node: '>=4'} + dev: true + + /strip-indent/3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + + /strip-json-comments/3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + dev: true + + /style-search/0.1.0: + resolution: {integrity: sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==} + dev: true + + /stylehacks/5.1.0_postcss@8.4.16: + resolution: {integrity: sha512-SzLmvHQTrIWfSgljkQCw2++C9+Ne91d/6Sp92I8c5uHTcy/PgeHamwITIbBW9wnFTY/3ZfSXR9HIL6Ikqmcu6Q==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + browserslist: 4.21.3 + postcss: 8.4.16 + postcss-selector-parser: 6.0.10 + dev: true + + /stylelint-config-css-modules/4.1.0_stylelint@14.11.0: + resolution: {integrity: sha512-w6d552NscwvpUEaUcmq8GgWXKRv6lVHLbDj6QIHSM2vCWr83qRqRvXBJCfXDyaG/J3Zojw2inU9VvU99ZlXuUw==} + peerDependencies: + stylelint: ^14.5.1 + dependencies: + stylelint: 14.11.0 + optionalDependencies: + stylelint-scss: 4.3.0_stylelint@14.11.0 + dev: true + + /stylelint-config-recess-order/3.0.0_stylelint@14.11.0: + resolution: {integrity: sha512-uNXrlDz570Q7HJlrq8mNjgfO/xlKIh2hKVKEFMTG1/ih/6tDLcTbuvO1Zoo2dnQay990OAkWLDpTDOorB+hmBw==} + peerDependencies: + stylelint: '>=14' + dependencies: + stylelint: 14.11.0 + stylelint-order: 5.0.0_stylelint@14.11.0 + dev: true + + /stylelint-config-recommended/7.0.0_stylelint@14.11.0: + resolution: {integrity: sha512-yGn84Bf/q41J4luis1AZ95gj0EQwRX8lWmGmBwkwBNSkpGSpl66XcPTulxGa/Z91aPoNGuIGBmFkcM1MejMo9Q==} + peerDependencies: + stylelint: ^14.4.0 + dependencies: + stylelint: 14.11.0 + dev: true + + /stylelint-config-standard/25.0.0_stylelint@14.11.0: + resolution: {integrity: sha512-21HnP3VSpaT1wFjFvv9VjvOGDtAviv47uTp3uFmzcN+3Lt+RYRv6oAplLaV51Kf792JSxJ6svCJh/G18E9VnCA==} + peerDependencies: + stylelint: ^14.4.0 + dependencies: + stylelint: 14.11.0 + stylelint-config-recommended: 7.0.0_stylelint@14.11.0 + dev: true + + /stylelint-order/5.0.0_stylelint@14.11.0: + resolution: {integrity: sha512-OWQ7pmicXufDw5BlRqzdz3fkGKJPgLyDwD1rFY3AIEfIH/LQY38Vu/85v8/up0I+VPiuGRwbc2Hg3zLAsJaiyw==} + peerDependencies: + stylelint: ^14.0.0 + dependencies: + postcss: 8.4.16 + postcss-sorting: 7.0.1_postcss@8.4.16 + stylelint: 14.11.0 + dev: true + + /stylelint-scss/4.3.0_stylelint@14.11.0: + resolution: {integrity: sha512-GvSaKCA3tipzZHoz+nNO7S02ZqOsdBzMiCx9poSmLlb3tdJlGddEX/8QzCOD8O7GQan9bjsvLMsO5xiw6IhhIQ==} + requiresBuild: true + peerDependencies: + stylelint: ^14.5.1 + dependencies: + lodash: 4.17.21 + postcss-media-query-parser: 0.2.3 + postcss-resolve-nested-selector: 0.1.1 + postcss-selector-parser: 6.0.10 + postcss-value-parser: 4.2.0 + stylelint: 14.11.0 + dev: true + optional: true + + /stylelint/14.11.0: + resolution: {integrity: sha512-OTLjLPxpvGtojEfpESWM8Ir64Z01E89xsisaBMUP/ngOx1+4VG2DPRcUyCCiin9Rd3kPXPsh/uwHd9eqnvhsYA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + dependencies: + '@csstools/selector-specificity': 2.0.2_pnx64jze6bptzcedy5bidi3zdi + balanced-match: 2.0.0 + colord: 2.9.3 + cosmiconfig: 7.0.1 + css-functions-list: 3.1.0 + debug: 4.3.4 + fast-glob: 3.2.11 + fastest-levenshtein: 1.0.16 + file-entry-cache: 6.0.1 + global-modules: 2.0.0 + globby: 11.1.0 + globjoin: 0.1.4 + html-tags: 3.2.0 + ignore: 5.2.0 + import-lazy: 4.0.0 + imurmurhash: 0.1.4 + is-plain-object: 5.0.0 + known-css-properties: 0.25.0 + mathml-tag-names: 2.1.3 + meow: 9.0.0 + micromatch: 4.0.5 + normalize-path: 3.0.0 + picocolors: 1.0.0 + postcss: 8.4.16 + postcss-media-query-parser: 0.2.3 + postcss-resolve-nested-selector: 0.1.1 + postcss-safe-parser: 6.0.0_postcss@8.4.16 + postcss-selector-parser: 6.0.10 + postcss-value-parser: 4.2.0 + resolve-from: 5.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + style-search: 0.1.0 + supports-hyperlinks: 2.2.0 + svg-tags: 1.0.0 + table: 6.8.0 + v8-compile-cache: 2.3.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + dev: true + + /supports-color/2.0.0: + resolution: {integrity: sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==} + engines: {node: '>=0.8.0'} + dev: false + + /supports-color/5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + dependencies: + has-flag: 3.0.0 + dev: true + + /supports-color/7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + dependencies: + has-flag: 4.0.0 + dev: true + + /supports-color/8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + dependencies: + has-flag: 4.0.0 + dev: true + + /supports-color/9.2.2: + resolution: {integrity: sha512-XC6g/Kgux+rJXmwokjm9ECpD6k/smUoS5LKlUCcsYr4IY3rW0XyAympon2RmxGrlnZURMpg5T18gWDP9CsHXFA==} + engines: {node: '>=12'} + dev: true + + /supports-hyperlinks/2.2.0: + resolution: {integrity: sha512-6sXEzV5+I5j8Bmq9/vUphGRM/RJNT9SCURJLjwfOg51heRtguGWDzcaBlgAzKhQa0EVNpPEKzQuBwZ8S8WaCeQ==} + engines: {node: '>=8'} + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + dev: true + + /supports-preserve-symlinks-flag/1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + /svg-tags/1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + dev: true + + /svg-to-vue/0.7.0_abjhqgwglfwb46q2jh7n36fu5u: + resolution: {integrity: sha512-Tg2nMmf3BQorYCAjxbtTkYyWPVSeox5AZUFvfy4MoWK/5tuQlnA/h3LAlTjV3sEvOC5FtUNovRSj3p784l4KOA==} + peerDependencies: + vue-template-compiler: ^2.0.0 + dependencies: + svgo: 1.3.2 + vue-template-compiler: 2.7.10 + dev: true + + /svgo/1.3.2: + resolution: {integrity: sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==} + engines: {node: '>=4.0.0'} + deprecated: This SVGO version is no longer supported. Upgrade to v2.x.x. + hasBin: true + dependencies: + chalk: 2.4.2 + coa: 2.0.2 + css-select: 2.1.0 + css-select-base-adapter: 0.1.1 + css-tree: 1.0.0-alpha.37 + csso: 4.2.0 + js-yaml: 3.14.1 + mkdirp: 0.5.6 + object.values: 1.1.5 + sax: 1.2.4 + stable: 0.1.8 + unquote: 1.1.1 + util.promisify: 1.0.1 + dev: true + + /svgo/2.8.0: + resolution: {integrity: sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==} + engines: {node: '>=10.13.0'} + hasBin: true + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 4.3.0 + css-tree: 1.1.3 + csso: 4.2.0 + picocolors: 1.0.0 + stable: 0.1.8 + dev: true + + /symbol-tree/3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + dev: true + + /table/6.8.0: + resolution: {integrity: sha512-s/fitrbVeEyHKFa7mFdkuQMWlH1Wgw/yEXMt5xACT4ZpzWFluehAxRtUUQKPuWhaLAWhFcVx6w3oC8VKaUfPGA==} + engines: {node: '>=10.0.0'} + dependencies: + ajv: 8.11.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: true + + /tapable/2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + dev: true + + /tape/4.16.0: + resolution: {integrity: sha512-mBlqYFr2mHysgCFXAuSarIQ+ffhielpb7a5/IbeOhMaLnQYhkJLUm6CwO1RszWeHRxnIpMessZ3xL2Cfo94BWw==} + hasBin: true + dependencies: + call-bind: 1.0.2 + deep-equal: 1.1.1 + defined: 1.0.0 + dotignore: 0.1.2 + for-each: 0.3.3 + glob: 7.2.3 + has: 1.0.3 + inherits: 2.0.4 + is-regex: 1.1.4 + minimist: 1.2.6 + object-inspect: 1.12.2 + resolve: 1.22.1 + resumer: 0.0.0 + string.prototype.trim: 1.2.6 + through: 2.3.8 + dev: false + + /terminal-link/2.1.1: + resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} + engines: {node: '>=8'} + dependencies: + ansi-escapes: 4.3.2 + supports-hyperlinks: 2.2.0 + dev: true + + /terser-webpack-plugin/5.3.5_webpack@5.74.0: + resolution: {integrity: sha512-AOEDLDxD2zylUGf/wxHxklEkOe2/r+seuyOWujejFrIxHf11brA1/dWQNIgXa1c6/Wkxgu7zvv0JhOWfc2ELEA==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + dependencies: + '@jridgewell/trace-mapping': 0.3.15 + jest-worker: 27.5.1 + schema-utils: 3.1.1 + serialize-javascript: 6.0.0 + terser: 5.15.0 + webpack: 5.74.0 + dev: true + + /terser/5.15.0: + resolution: {integrity: sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA==} + engines: {node: '>=10'} + hasBin: true + dependencies: + '@jridgewell/source-map': 0.3.2 + acorn: 8.8.0 + commander: 2.20.3 + source-map-support: 0.5.21 + dev: true + + /test-exclude/6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + dev: true + + /text-extensions/1.9.0: + resolution: {integrity: sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==} + engines: {node: '>=0.10'} + dev: true + + /text-table/0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + dev: true + + /thenify-all/1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + dependencies: + thenify: 3.3.1 + dev: true + + /thenify/3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + dependencies: + any-promise: 1.3.0 + dev: true + + /thread-loader/3.0.4_webpack@5.74.0: + resolution: {integrity: sha512-ByaL2TPb+m6yArpqQUZvP+5S1mZtXsEP7nWKKlAUTm7fCml8kB5s1uI3+eHRP2bk5mVYfRSBI7FFf+tWEyLZwA==} + engines: {node: '>= 10.13.0'} + peerDependencies: + webpack: ^4.27.0 || ^5.0.0 + dependencies: + json-parse-better-errors: 1.0.2 + loader-runner: 4.3.0 + loader-utils: 2.0.2 + neo-async: 2.6.2 + schema-utils: 3.1.1 + webpack: 5.74.0 + dev: true + + /throat/6.0.1: + resolution: {integrity: sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==} + dev: true + + /through/2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + /through2/4.0.2: + resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} + dependencies: + readable-stream: 3.6.0 + dev: true + + /thunky/1.1.0: + resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} + dev: true + + /tiny-emitter/2.1.0: + resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} + dev: false + + /tinycolor2/1.4.2: + resolution: {integrity: sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==} + + /tmp/0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + dependencies: + os-tmpdir: 1.0.2 + dev: true + + /tmpl/1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + dev: true + + /to-fast-properties/2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + + /to-regex-range/5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + + /toggle-selection/1.0.6: + resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + dev: false + + /toidentifier/1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + dev: true + + /topojson-client/3.0.1: + resolution: {integrity: sha512-rfGGzyqefpxOaxvV9OTF9t+1g+WhjGEbAIuCcmKYrQkxr0nttjMMyzZsK+NhLW4cTl2g1bz2jQczPUtEshpbVQ==} + hasBin: true + dependencies: + commander: 2.20.3 + dev: false + + /totalist/1.1.0: + resolution: {integrity: sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==} + engines: {node: '>=6'} + dev: true + + /tough-cookie/4.1.0: + resolution: {integrity: sha512-IVX6AagLelGwl6F0E+hoRpXzuD192cZhAcmT7/eoLr0PnsB1wv2E5c+A2O+V8xth9FlL2p0OstFsWn0bZpVn4w==} + engines: {node: '>=6'} + dependencies: + psl: 1.9.0 + punycode: 2.1.1 + universalify: 0.2.0 + url-parse: 1.5.10 + dev: true + + /tr46/0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + dev: true + + /tr46/2.1.0: + resolution: {integrity: sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==} + engines: {node: '>=8'} + dependencies: + punycode: 2.1.1 + dev: true + + /trim-newlines/3.0.1: + resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} + engines: {node: '>=8'} + dev: true + + /ts-node/10.9.1_57uwcby55h6tzvkj3v5sfcgxoe: + resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.9 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.3 + '@types/node': 18.7.13 + acorn: 8.8.0 + acorn-walk: 8.2.0 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 4.7.4 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + dev: true + optional: true + + /tsconfig-paths/3.14.1: + resolution: {integrity: sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==} + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.1 + minimist: 1.2.6 + strip-bom: 3.0.0 + dev: true + + /tslib/1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + dev: true + + /tslib/2.4.0: + resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} + dev: true + + /type-check/0.3.2: + resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.1.2 + dev: true + + /type-check/0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + dev: true + + /type-detect/4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + dev: true + + /type-fest/0.18.1: + resolution: {integrity: sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==} + engines: {node: '>=10'} + dev: true + + /type-fest/0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + dev: true + + /type-fest/0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + dev: true + + /type-fest/0.6.0: + resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==} + engines: {node: '>=8'} + dev: true + + /type-fest/0.8.1: + resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} + engines: {node: '>=8'} + dev: true + + /type-is/1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + dev: true + + /typedarray-to-buffer/3.1.5: + resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + dependencies: + is-typedarray: 1.0.0 + dev: true + + /typescript/4.7.4: + resolution: {integrity: sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true + optional: true + + /uglify-js/2.8.29: + resolution: {integrity: sha512-qLq/4y2pjcU3vhlhseXGGJ7VbFO4pBANu0kwl8VCa9KEI0V8VfZIx2Fy3w01iSTA/pGwKZSmu/+I4etLNDdt5w==} + engines: {node: '>=0.8.0'} + hasBin: true + dependencies: + source-map: 0.5.7 + yargs: 3.10.0 + optionalDependencies: + uglify-to-browserify: 1.0.2 + dev: false + + /uglify-to-browserify/1.0.2: + resolution: {integrity: sha512-vb2s1lYx2xBtUgy+ta+b2J/GLVUR+wmpINwHePmPRhOsIVCG2wDzKJ0n14GslH1BifsqVzSOwQhRaCAsZ/nI4Q==} + requiresBuild: true + dev: false + optional: true + + /umi-request/1.4.0: + resolution: {integrity: sha512-OknwtQZddZHi0Ggi+Vr/olJ7HNMx4AzlywyK0W3NZBT7B0stjeZ9lcztA85dBgdAj3KVk8uPJPZSnGaDjELhrA==} + dependencies: + isomorphic-fetch: 2.2.1 + qs: 6.11.0 + dev: false + + /unbox-primitive/1.0.2: + resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} + dependencies: + call-bind: 1.0.2 + has-bigints: 1.0.2 + has-symbols: 1.0.3 + which-boxed-primitive: 1.0.2 + + /unicode-canonical-property-names-ecmascript/2.0.0: + resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} + engines: {node: '>=4'} + dev: true + + /unicode-match-property-ecmascript/2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.0 + unicode-property-aliases-ecmascript: 2.0.0 + dev: true + + /unicode-match-property-value-ecmascript/2.0.0: + resolution: {integrity: sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw==} + engines: {node: '>=4'} + dev: true + + /unicode-property-aliases-ecmascript/2.0.0: + resolution: {integrity: sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ==} + engines: {node: '>=4'} + dev: true + + /universalify/0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + dev: true + + /universalify/2.0.0: + resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} + engines: {node: '>= 10.0.0'} + dev: true + + /unpipe/1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + dev: true + + /unquote/1.1.1: + resolution: {integrity: sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==} + dev: true + + /update-browserslist-db/1.0.5_browserslist@4.21.3: + resolution: {integrity: sha512-dteFFpCyvuDdr9S/ff1ISkKt/9YZxKjI9WlRR99c180GaztJtRa/fn18FdxGVKVsnPY7/a/FDN68mcvUmP4U7Q==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.21.3 + escalade: 3.1.1 + picocolors: 1.0.0 + dev: true + + /uri-js/4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + dependencies: + punycode: 2.1.1 + + /url-parse/1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + dev: true + + /util-deprecate/1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + dev: true + + /util.promisify/1.0.1: + resolution: {integrity: sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==} + dependencies: + define-properties: 1.1.4 + es-abstract: 1.20.1 + has-symbols: 1.0.3 + object.getownpropertydescriptors: 2.1.4 + dev: true + + /utila/0.4.0: + resolution: {integrity: sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==} + dev: true + + /utils-merge/1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + dev: true + + /uuid/8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + dev: true + + /v8-compile-cache-lib/3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + dev: true + optional: true + + /v8-compile-cache/2.3.0: + resolution: {integrity: sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==} + dev: true + + /v8-to-istanbul/8.1.1: + resolution: {integrity: sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==} + engines: {node: '>=10.12.0'} + dependencies: + '@types/istanbul-lib-coverage': 2.0.4 + convert-source-map: 1.8.0 + source-map: 0.7.4 + dev: true + + /validate-npm-package-license/3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + dependencies: + spdx-correct: 3.1.1 + spdx-expression-parse: 3.0.1 + dev: true + + /vary/1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + dev: true + + /venn.js/0.2.20: + resolution: {integrity: sha512-bb5SYq/wamY9fvcuErb9a0FJkgIFHJjkLZWonQ+DoKKuDX3WPH2B4ouI1ce4K2iejBklQy6r1ly8nOGIyOCO6w==} + dependencies: + d3-selection: 1.4.2 + d3-transition: 1.3.2 + fmin: 0.0.2 + dev: false + + /viser-vue/2.4.8_vue@2.7.10: + resolution: {integrity: sha512-ERAREN+6k/ywrwT+swcMo4CDIAq6dBjnB0+lhmsSfaip06BGHSBfNKg6yl7/4GJ9Nk2kioUw3llNhEboJuIKmQ==} + peerDependencies: + vue: '>=1' + dependencies: + '@types/node': 18.7.13 + viser: 2.4.9 + vue: 2.7.10 + dev: false + + /viser/2.4.9: + resolution: {integrity: sha512-DKsqtMa3TZYQHEZ7jp4kpNp1Iqomda7d+3IkkIjIdKQvfL8OeksXfy/ECZUY1hTrGoOe7cq85+6PMS+MPn4mgQ==} + dependencies: + '@antv/g2': 3.5.19 + '@antv/g2-brush': 0.0.2 + '@antv/g2-plugin-slider': 2.1.1_@antv+g2@3.5.19 + '@types/d3-format': 3.0.1 + '@types/lodash': 4.14.184 + '@types/node': 8.10.66 + d3-format: 1.4.5 + lodash: 4.17.21 + dev: false + + /vue-clipboard2/0.2.1: + resolution: {integrity: sha512-n6ie/0g0bKohmLlC/5ja1esq2Q0jQ5hWmhNSZcvCsWfDeDnVARjl6cBB9p72XV1nlVfuqsZcfV8HTjjZAIlLBA==} + dependencies: + clipboard: 2.0.11 + dev: false + + /vue-container-query/0.1.0: + resolution: {integrity: sha512-WPXn/x1xE5NoCJIHDL919ELln6ZUpsJJBgxuR5tJWfvfxy6zT4Tm4iOhaVmSomtdPzdo9edeMrPXl4e/9MUhfA==} + dependencies: + container-query-toolkit: 0.1.3 + resize-observer-lite: 0.2.3 + vue: 2.7.10 + dev: false + + /vue-copy-to-clipboard/1.0.3_vue@2.7.10: + resolution: {integrity: sha512-FSgewqG+2NwNsAnKOZqZ6GZqNvrbauVh/Y5z+xkbu9AmFqiWlQLKaFc+7BcsYCVZ2TaQnhjcHbDycVRVGFJypQ==} + peerDependencies: + vue: '>=2.6.0' + dependencies: + copy-to-clipboard: 3.3.2 + vue: 2.7.10 + dev: false + + /vue-cropper/0.4.9: + resolution: {integrity: sha512-Uf1i/sCh+ZqSM9hb2YTGRENzJFH+mvDuv8N2brGLjK7UBuF7XDP7zbis8g/dcqZiMojAcBDtObFCn4ERFbRMxQ==} + dev: false + + /vue-eslint-parser/5.0.0_eslint@7.32.0: + resolution: {integrity: sha512-JlHVZwBBTNVvzmifwjpZYn0oPWH2SgWv5dojlZBsrhablDu95VFD+hriB1rQGwbD+bms6g+rAFhQHk6+NyiS6g==} + engines: {node: '>=6.5'} + peerDependencies: + eslint: ^5.0.0 + dependencies: + debug: 4.3.4 + eslint: 7.32.0 + eslint-scope: 4.0.3 + eslint-visitor-keys: 1.3.0 + espree: 4.1.0 + esquery: 1.4.0 + lodash: 4.17.21 + transitivePeerDependencies: + - supports-color + dev: true + + /vue-hot-reload-api/2.3.4: + resolution: {integrity: sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==} + dev: true + + /vue-i18n/8.27.2_vue@2.7.10: + resolution: {integrity: sha512-QVzn7u2WVH8F7eSKIM00lujC7x1mnuGPaTnDTmB01Hd709jDtB9kYtBqM+MWmp5AJRx3gnqAdZbee9MelqwFBg==} + peerDependencies: + vue: ^2 + dependencies: + vue: 2.7.10 + dev: false + + /vue-loader/15.10.0_vsyllalxesco42bbuzcqgel7xq: + resolution: {integrity: sha512-VU6tuO8eKajrFeBzMssFUP9SvakEeeSi1BxdTH5o3+1yUyrldp8IERkSdXlMI2t4kxF2sqYUDsQY+WJBxzBmZg==} + peerDependencies: + '@vue/compiler-sfc': ^3.0.8 + cache-loader: '*' + css-loader: '*' + vue-template-compiler: '*' + webpack: ^3.0.0 || ^4.1.0 || ^5.0.0-0 + peerDependenciesMeta: + '@vue/compiler-sfc': + optional: true + cache-loader: + optional: true + vue-template-compiler: + optional: true + dependencies: + '@vue/component-compiler-utils': 3.3.0 + css-loader: 6.7.1_webpack@5.74.0 + hash-sum: 1.0.2 + loader-utils: 1.4.0 + vue-hot-reload-api: 2.3.4 + vue-style-loader: 4.1.3 + vue-template-compiler: 2.7.10 + webpack: 5.74.0 + transitivePeerDependencies: + - arc-templates + - atpl + - babel-core + - bracket-template + - coffee-script + - dot + - dust + - dustjs-helpers + - dustjs-linkedin + - eco + - ect + - ejs + - haml-coffee + - hamlet + - hamljs + - handlebars + - hogan.js + - htmling + - jade + - jazz + - jqtpl + - just + - liquid-node + - liquor + - lodash + - marko + - mote + - mustache + - nunjucks + - plates + - pug + - qejs + - ractive + - razor-tmpl + - react + - react-dom + - slm + - squirrelly + - swig + - swig-templates + - teacup + - templayed + - then-jade + - then-pug + - tinyliquid + - toffee + - twig + - twing + - underscore + - vash + - velocityjs + - walrus + - whiskers + dev: true + + /vue-loader/17.0.0_webpack@5.74.0: + resolution: {integrity: sha512-OWSXjrzIvbF2LtOUmxT3HYgwwubbfFelN8PAP9R9dwpIkj48TVioHhWWSx7W7fk+iF5cgg3CBJRxwTdtLU4Ecg==} + peerDependencies: + webpack: ^4.1.0 || ^5.0.0-0 + dependencies: + chalk: 4.1.2 + hash-sum: 2.0.0 + loader-utils: 2.0.2 + webpack: 5.74.0 + dev: true + + /vue-quill-editor/3.0.6: + resolution: {integrity: sha512-g20oSZNWg8Hbu41Kinjd55e235qVWPLfg4NvsLW6d+DhgBTFbEuMpcWlUdrD6qT3+Noim6DRu18VLM9lVShXOQ==} + engines: {node: '>= 4.0.0', npm: '>= 3.0.0'} + dependencies: + object-assign: 4.1.1 + quill: 1.3.7 + dev: false + + /vue-ref/2.0.0: + resolution: {integrity: sha512-uKNKpFOVeWNqS2mrBZqnpLyXJo5Q+vnkex6JvpENvhXHFNBW/SJTP8vJywLuVT3DpxwXcF9N0dyIiZ4/NpTexQ==} + dev: false + + /vue-router/3.6.3_vue@2.7.10: + resolution: {integrity: sha512-G21CKd8o/Mr3h8Xgi6zwg2ixJ5OxBG9G5w/b5McEFfLBqyQJc/7HDGsibf2FAl2enpZla+OJ3IlYipRusGN/4w==} + peerDependencies: + vue: ^2 + dependencies: + vue: 2.7.10 + dev: false + + /vue-style-loader/4.1.3: + resolution: {integrity: sha512-sFuh0xfbtpRlKfm39ss/ikqs9AbKCoXZBpHeVZ8Tx650o0k0q/YCM7FRvigtxpACezfq6af+a7JeqVTWvncqDg==} + dependencies: + hash-sum: 1.0.2 + loader-utils: 1.4.0 + dev: true + + /vue-svg-component-builder/2.0.3_7ijf3dzidy2s7qy7g4vlqxnlga: + resolution: {integrity: sha512-We9ZLSYPQx9y3v5+HNWyjkGFaxZMlWPTqYBU08y4YT46f453BQ4JxIoS8rV0a8PIxnKap7m/YIzrdIfoHxrpaA==} + peerDependencies: + vue: '>= 2.5.0' + vue-svg-component-runtime: ^1.0.0 + vue-template-compiler: '>= 2.5.0' + dependencies: + vue: 2.7.10 + vue-svg-component-runtime: 1.0.1_vue@2.7.10 + vue-template-compiler: 2.7.10 + dev: true + + /vue-svg-component-runtime/1.0.1_vue@2.7.10: + resolution: {integrity: sha512-TkmZ1qwFeFJSRH6b6KVqDU2f8DCSdoNoo/veKqog7FsyF0UETTI66ALKX1rrLXy/KT6LSaJB5IfZkuuSfaQsEA==} + peerDependencies: + vue: '>= 2.5.0' + dependencies: + vue: 2.7.10 + + /vue-svg-icon-loader/2.1.1_7ijf3dzidy2s7qy7g4vlqxnlga: + resolution: {integrity: sha512-JOL4fyh9rnbcqMLTF5NVG8YVupnLIMHMY+3CLMaEb9xDUmfk6Cp3RqyI/8gBea7d51i4lyNdzZ3tQ/EJLQxQDA==} + engines: {node: '>= 6.0'} + peerDependencies: + vue: '>= 2.0' + vue-svg-component-runtime: ^1.0.0 + dependencies: + '@types/loader-utils': 1.1.3 + '@types/node': 8.9.5 + loader-utils: 1.1.0 + vue: 2.7.10 + vue-svg-component-builder: 2.0.3_7ijf3dzidy2s7qy7g4vlqxnlga + vue-svg-component-runtime: 1.0.1_vue@2.7.10 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + - vue-template-compiler + - webpack-cli + dev: true + + /vue-svg-loader/0.16.0_abjhqgwglfwb46q2jh7n36fu5u: + resolution: {integrity: sha512-2RtFXlTCYWm8YAEO2qAOZ2SuIF2NvLutB5muc3KDYoZq5ZeCHf8ggzSan3ksbbca7CJ/Aw57ZnDF4B7W/AkGtw==} + peerDependencies: + vue-template-compiler: ^2.0.0 + dependencies: + loader-utils: 1.4.0 + svg-to-vue: 0.7.0_abjhqgwglfwb46q2jh7n36fu5u + vue-template-compiler: 2.7.10 + dev: true + + /vue-template-compiler/2.7.10: + resolution: {integrity: sha512-QO+8R9YRq1Gudm8ZMdo/lImZLJVUIAM8c07Vp84ojdDAf8HmPJc7XB556PcXV218k2AkKznsRz6xB5uOjAC4EQ==} + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + /vue-template-es2015-compiler/1.9.1: + resolution: {integrity: sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==} + dev: true + + /vue/2.7.10: + resolution: {integrity: sha512-HmFC70qarSHPXcKtW8U8fgIkF6JGvjEmDiVInTkKZP0gIlEPhlVlcJJLkdGIDiNkIeA2zJPQTWJUI4iWe+AVfg==} + dependencies: + '@vue/compiler-sfc': 2.7.10 + csstype: 3.1.0 + + /vuex/3.6.2_vue@2.7.10: + resolution: {integrity: sha512-ETW44IqCgBpVomy520DT5jf8n0zoCac+sxWnn+hMe/CzaSejb/eVw2YToiXYX+Ex/AuHHia28vWTq4goAexFbw==} + peerDependencies: + vue: ^2.0.0 + dependencies: + vue: 2.7.10 + dev: false + + /w3c-hr-time/1.0.2: + resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==} + dependencies: + browser-process-hrtime: 1.0.0 + dev: true + + /w3c-xmlserializer/2.0.0: + resolution: {integrity: sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==} + engines: {node: '>=10'} + dependencies: + xml-name-validator: 3.0.0 + dev: true + + /walker/1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + dependencies: + makeerror: 1.0.12 + dev: true + + /wangeditor/3.1.1: + resolution: {integrity: sha512-co18zRS96xVKhLyhTIqgqWs5khSbNPlZeoT8/B2dxnVKQMntRGu1D8ks+nbNOvZcIjyrdhhtde6LjqzVECL6DA==} + dev: false + + /warning/4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + dependencies: + loose-envify: 1.4.0 + dev: false + + /watchpack/2.4.0: + resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==} + engines: {node: '>=10.13.0'} + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.10 + dev: true + + /wbuf/1.7.3: + resolution: {integrity: sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==} + dependencies: + minimalistic-assert: 1.0.1 + dev: true + + /wcwidth/1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + dependencies: + defaults: 1.0.3 + dev: true + + /webidl-conversions/3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + dev: true + + /webidl-conversions/5.0.0: + resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==} + engines: {node: '>=8'} + dev: true + + /webidl-conversions/6.1.0: + resolution: {integrity: sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==} + engines: {node: '>=10.4'} + dev: true + + /webpack-bundle-analyzer/4.6.1: + resolution: {integrity: sha512-oKz9Oz9j3rUciLNfpGFjOb49/jEpXNmWdVH8Ls//zNcnLlQdTGXQQMsBbb/gR7Zl8WNLxVCq+0Hqbx3zv6twBw==} + engines: {node: '>= 10.13.0'} + hasBin: true + dependencies: + acorn: 8.8.0 + acorn-walk: 8.2.0 + chalk: 4.1.2 + commander: 7.2.0 + gzip-size: 6.0.0 + lodash: 4.17.21 + opener: 1.5.2 + sirv: 1.0.19 + ws: 7.5.9 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + + /webpack-chain/6.5.1: + resolution: {integrity: sha512-7doO/SRtLu8q5WM0s7vPKPWX580qhi0/yBHkOxNkv50f6qB76Zy9o2wRTrrPULqYTvQlVHuvbA8v+G5ayuUDsA==} + engines: {node: '>=8'} + dependencies: + deepmerge: 1.5.2 + javascript-stringify: 2.1.0 + dev: true + + /webpack-dev-middleware/5.3.3_webpack@5.74.0: + resolution: {integrity: sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==} + engines: {node: '>= 12.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + dependencies: + colorette: 2.0.19 + memfs: 3.4.7 + mime-types: 2.1.35 + range-parser: 1.2.1 + schema-utils: 4.0.0 + webpack: 5.74.0 + dev: true + + /webpack-dev-server/4.10.0_debug@4.3.4+webpack@5.74.0: + resolution: {integrity: sha512-7dezwAs+k6yXVFZ+MaL8VnE+APobiO3zvpp3rBHe/HmWQ+avwh0Q3d0xxacOiBybZZ3syTZw9HXzpa3YNbAZDQ==} + engines: {node: '>= 12.13.0'} + hasBin: true + peerDependencies: + webpack: ^4.37.0 || ^5.0.0 + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + dependencies: + '@types/bonjour': 3.5.10 + '@types/connect-history-api-fallback': 1.3.5 + '@types/express': 4.17.13 + '@types/serve-index': 1.9.1 + '@types/serve-static': 1.15.0 + '@types/sockjs': 0.3.33 + '@types/ws': 8.5.3 + ansi-html-community: 0.0.8 + bonjour-service: 1.0.13 + chokidar: 3.5.3 + colorette: 2.0.19 + compression: 1.7.4 + connect-history-api-fallback: 2.0.0 + default-gateway: 6.0.3 + express: 4.18.1 + graceful-fs: 4.2.10 + html-entities: 2.3.3 + http-proxy-middleware: 2.0.6_vw7eq5saxorls4jwsr6ncij7dm + ipaddr.js: 2.0.1 + open: 8.4.0 + p-retry: 4.6.2 + rimraf: 3.0.2 + schema-utils: 4.0.0 + selfsigned: 2.0.1 + serve-index: 1.9.1 + sockjs: 0.3.24 + spdy: 4.0.2 + webpack: 5.74.0 + webpack-dev-middleware: 5.3.3_webpack@5.74.0 + ws: 8.8.1 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + dev: true + + /webpack-merge/5.8.0: + resolution: {integrity: sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q==} + engines: {node: '>=10.0.0'} + dependencies: + clone-deep: 4.0.1 + wildcard: 2.0.0 + dev: true + + /webpack-sources/3.2.3: + resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} + engines: {node: '>=10.13.0'} + dev: true + + /webpack-theme-color-replacer/1.4.1: + resolution: {integrity: sha512-WLKWieQzp2LPqn4qV3Qu/wJ410nIqjopYVDkrOv66qQfZpYJJZ5WX4PLUXVpFNVPjn3OH49DGT5NGVvsVpneIg==} + dependencies: + webpack-sources: 3.2.3 + dev: true + + /webpack-virtual-modules/0.4.4: + resolution: {integrity: sha512-h9atBP/bsZohWpHnr+2sic8Iecb60GxftXsWNLLLSqewgIsGzByd2gcIID4nXcG+3tNe4GQG3dLcff3kXupdRA==} + dev: true + + /webpack/5.74.0: + resolution: {integrity: sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + dependencies: + '@types/eslint-scope': 3.7.4 + '@types/estree': 0.0.51 + '@webassemblyjs/ast': 1.11.1 + '@webassemblyjs/wasm-edit': 1.11.1 + '@webassemblyjs/wasm-parser': 1.11.1 + acorn: 8.8.0 + acorn-import-assertions: 1.8.0_acorn@8.8.0 + browserslist: 4.21.3 + chrome-trace-event: 1.0.3 + enhanced-resolve: 5.10.0 + es-module-lexer: 0.9.3 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.10 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.1.1 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.5_webpack@5.74.0 + watchpack: 2.4.0 + webpack-sources: 3.2.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + dev: true + + /websocket-driver/0.7.4: + resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} + engines: {node: '>=0.8.0'} + dependencies: + http-parser-js: 0.5.8 + safe-buffer: 5.2.1 + websocket-extensions: 0.1.4 + dev: true + + /websocket-extensions/0.1.4: + resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} + engines: {node: '>=0.8.0'} + dev: true + + /whatwg-encoding/1.0.5: + resolution: {integrity: sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==} + dependencies: + iconv-lite: 0.4.24 + dev: true + + /whatwg-fetch/3.6.2: + resolution: {integrity: sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==} + + /whatwg-mimetype/2.3.0: + resolution: {integrity: sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==} + dev: true + + /whatwg-url/5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + dev: true + + /whatwg-url/8.7.0: + resolution: {integrity: sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==} + engines: {node: '>=10'} + dependencies: + lodash: 4.17.21 + tr46: 2.1.0 + webidl-conversions: 6.1.0 + dev: true + + /which-boxed-primitive/1.0.2: + resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} + dependencies: + is-bigint: 1.0.4 + is-boolean-object: 1.1.2 + is-number-object: 1.0.7 + is-string: 1.0.7 + is-symbol: 1.0.4 + + /which/1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: true + + /which/2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: true + + /wildcard/2.0.0: + resolution: {integrity: sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==} + dev: true + + /window-size/0.1.0: + resolution: {integrity: sha512-1pTPQDKTdd61ozlKGNCjhNRd+KPmgLSGa3mZTHoOliaGcESD8G1PXhh7c1fgiPjVbNVfgy2Faw4BI8/m0cC8Mg==} + engines: {node: '>= 0.8.0'} + dev: false + + /wolfy87-eventemitter/5.1.0: + resolution: {integrity: sha512-VakY4+17DbamV2VW4nZERrSuilclCRcYtfchPWe6jlma8k0AeLJxBR+C5OSFFtICArDFdXk0yw67HUGrTCdrEg==} + dev: false + + /word-wrap/1.2.3: + resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} + engines: {node: '>=0.10.0'} + dev: true + + /wordwrap/0.0.2: + resolution: {integrity: sha512-xSBsCeh+g+dinoBv3GAOWM4LcVVO68wLXRanibtBSdUvkGWQRGeE9P7IwU9EmDDi4jA6L44lz15CGMwdw9N5+Q==} + engines: {node: '>=0.4.0'} + dev: false + + /wrap-ansi/3.0.1: + resolution: {integrity: sha512-iXR3tDXpbnTpzjKSylUJRkLuOrEC7hwEB221cgn6wtF8wpmz28puFXAEfPT5zrjM3wahygB//VuWEr1vTkDcNQ==} + engines: {node: '>=4'} + dependencies: + string-width: 2.1.1 + strip-ansi: 4.0.0 + dev: true + + /wrap-ansi/6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: true + + /wrap-ansi/7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: true + + /wrappy/1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + /write-file-atomic/3.0.3: + resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} + dependencies: + imurmurhash: 0.1.4 + is-typedarray: 1.0.0 + signal-exit: 3.0.7 + typedarray-to-buffer: 3.1.5 + dev: true + + /write-file-atomic/4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + dev: true + + /ws/7.5.9: + resolution: {integrity: sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + + /ws/8.8.1: + resolution: {integrity: sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + + /xml-name-validator/3.0.0: + resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==} + dev: true + + /xmlchars/2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + dev: true + + /y18n/5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + dev: true + + /yallist/2.1.2: + resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==} + dev: true + + /yallist/4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + dev: true + + /yaml/1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + dev: true + + /yargs-parser/20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + dev: true + + /yargs/16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + dependencies: + cliui: 7.0.4 + escalade: 3.1.1 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + dev: true + + /yargs/3.10.0: + resolution: {integrity: sha512-QFzUah88GAGy9lyDKGBqZdkYApt63rCXYBGYnEP4xDJPXNqXXnBDACnbrXnViV6jRSqAePwrATi2i8mfYm4L1A==} + dependencies: + camelcase: 1.2.1 + cliui: 2.1.0 + decamelize: 1.2.0 + window-size: 0.1.0 + dev: false + + /yn/3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + dev: true + optional: true + + /yocto-queue/0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + dev: true + + /yorkie/2.0.0: + resolution: {integrity: sha512-jcKpkthap6x63MB4TxwCyuIGkV0oYP/YRyuQU5UO0Yz/E/ZAu+653/uov+phdmO54n6BcvFRyyt0RRrWdN2mpw==} + engines: {node: '>=4'} + requiresBuild: true + dependencies: + execa: 0.8.0 + is-ci: 1.2.1 + normalize-path: 1.0.0 + strip-indent: 2.0.0 + dev: true diff --git a/quantdinger_vue/postcss.config.js b/quantdinger_vue/postcss.config.js new file mode 100644 index 0000000..961986e --- /dev/null +++ b/quantdinger_vue/postcss.config.js @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + autoprefixer: {} + } +} diff --git a/quantdinger_vue/public/avatar2.jpg b/quantdinger_vue/public/avatar2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9adb2d1b88665631c9dfe7acd8c125ec87eb6fc0 GIT binary patch literal 80189 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Ȇ~_mYlz55^9KP)hUklbsu17%qOvafHCep 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

@kHGHV*M}ud-K*Ym^3Id2q7H(x;jE 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

    9J2$d}*12p3mw+Jf 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
  1. +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 literal 0 HcmV?d00001 diff --git a/quantdinger_vue/public/index.html b/quantdinger_vue/public/index.html new file mode 100644 index 0000000..2b592ee --- /dev/null +++ b/quantdinger_vue/public/index.html @@ -0,0 +1,429 @@ + + + + + + + + QuantDinger + + + <% for (var i in htmlWebpackPlugin.options.cdn && htmlWebpackPlugin.options.cdn.css) { %> + + <% } %> + + + +
    +
    +

    Landing

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    QuantDinger
    +
    +
    + + <% for (var i in htmlWebpackPlugin.options.cdn && htmlWebpackPlugin.options.cdn.js) { %> + + <% } %> + + + diff --git a/quantdinger_vue/public/logo.png b/quantdinger_vue/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..69a77a7788f98b0831c10bca30890680c9187b95 GIT binary patch literal 9218 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�-2>%wW!@;m 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 + +
    + +
    +
    + + + diff --git a/quantdinger_vue/src/api/ai-trading.js b/quantdinger_vue/src/api/ai-trading.js new file mode 100644 index 0000000..0961230 --- /dev/null +++ b/quantdinger_vue/src/api/ai-trading.js @@ -0,0 +1,113 @@ +import request from '@/utils/request' + +const api = { + strategies: '/addons/quantdinger/strategy/strategies', + createAIStrategy: '/addons/quantdinger/strategy/aiCreate', + updateAIStrategy: '/addons/quantdinger/strategy/aiUpdate', + deleteStrategy: '/addons/quantdinger/strategy/delete', + startStrategy: '/addons/quantdinger/strategy/start', + stopStrategy: '/addons/quantdinger/strategy/stop', + testConnection: '/addons/quantdinger/strategy/testConnection', + aiDecisions: '/addons/quantdinger/strategy/aiDecisions', + getCryptoSymbols: '/addons/quantdinger/strategy/getCryptoSymbols' +} + +/** + * 获取AI交易策略列表 + */ +export function getStrategies () { + return request({ + url: api.strategies, + method: 'get' + }) +} + +/** + * 创建AI交易策略 + */ +export function createAIStrategy (data) { + return request({ + url: api.createAIStrategy, + method: 'post', + data + }) +} + +/** + * 更新AI交易策略 + */ +export function updateAIStrategy (data) { + return request({ + url: api.updateAIStrategy, + method: 'post', + data + }) +} + +/** + * 删除策略 + */ +export function deleteStrategy (strategyId) { + return request({ + url: api.deleteStrategy, + method: 'delete', + params: { id: strategyId } + }) +} + +/** + * 启动策略 + */ +export function startStrategy (strategyId) { + return request({ + url: api.startStrategy, + method: 'post', + params: { id: strategyId } + }) +} + +/** + * 停止策略 + */ +export function stopStrategy (strategyId) { + return request({ + url: api.stopStrategy, + method: 'post', + params: { id: strategyId } + }) +} + +/** + * 测试交易所连接 + */ +export function testConnection (data) { + return request({ + url: api.testConnection, + method: 'post', + data + }) +} + +/** + * 获取AI决策记录 + */ +export function getAIDecisions (strategyId, params) { + return request({ + url: api.aiDecisions, + method: 'get', + params: { + strategy_id: strategyId, + ...params + } + }) +} + +/** + * 获取系统支持的交易对列表 + */ +export function getCryptoSymbols () { + return request({ + url: api.getCryptoSymbols, + method: 'get' + }) +} diff --git a/quantdinger_vue/src/api/credentials.js b/quantdinger_vue/src/api/credentials.js new file mode 100644 index 0000000..85c8a21 --- /dev/null +++ b/quantdinger_vue/src/api/credentials.js @@ -0,0 +1,40 @@ +import request from '@/utils/request' + +const api = { + list: '/api/credentials/list', + get: '/api/credentials/get', + create: '/api/credentials/create', + delete: '/api/credentials/delete' +} + +export function listExchangeCredentials (params = {}) { + return request({ + url: api.list, + method: 'get', + params + }) +} + +export function getExchangeCredential (id, params = {}) { + return request({ + url: api.get, + method: 'get', + params: { id, ...params } + }) +} + +export function createExchangeCredential (data) { + return request({ + url: api.create, + method: 'post', + data + }) +} + +export function deleteExchangeCredential (id, params = {}) { + return request({ + url: api.delete, + method: 'delete', + params: { id, ...params } + }) +} diff --git a/quantdinger_vue/src/api/dashboard.js b/quantdinger_vue/src/api/dashboard.js new file mode 100644 index 0000000..0cd1da7 --- /dev/null +++ b/quantdinger_vue/src/api/dashboard.js @@ -0,0 +1,30 @@ + +import request from '@/utils/request' + +// Dashboard API +const api = { + summary: '/api/dashboard/summary', + pendingOrders: '/api/dashboard/pendingOrders' +} + +export function getDashboardSummary () { + return request({ + url: api.summary, + method: 'get' + }) +} + +export function getPendingOrders (params) { + return request({ + url: api.pendingOrders, + method: 'get', + params + }) +} + +export function deletePendingOrder (id) { + return request({ + url: `${api.pendingOrders}/${id}`, + method: 'delete' + }) +} diff --git a/quantdinger_vue/src/api/login.js b/quantdinger_vue/src/api/login.js new file mode 100644 index 0000000..d6db492 --- /dev/null +++ b/quantdinger_vue/src/api/login.js @@ -0,0 +1,59 @@ +import request from '@/utils/request' + +const userApi = { + Login: '/api/user/login', + Logout: '/api/user/logout', + UserInfo: '/api/user/info', + UserMenu: '/user/nav' +} + +/** + * login func + * parameter: { + * username: '', + * password: '', + * remember_me: true, + * captcha: '12345' + * } + * @param parameter + * @returns {*} + */ +export function login (parameter) { + return request({ + url: userApi.Login, + method: 'post', + data: parameter + }) +} + +export function getInfo () { + return request({ + url: userApi.UserInfo, + method: 'get', + headers: { + 'Content-Type': 'application/json;charset=UTF-8' + } + }) +} + +// Backward-compatible alias: some modules still call getUserInfo() +export function getUserInfo () { + return getInfo() +} + +export function logout () { + return request({ + url: userApi.Logout, + method: 'post', + headers: { + 'Content-Type': 'application/json;charset=UTF-8' + } + }) +} + +export function getCurrentUserNav () { + return request({ + url: userApi.UserMenu, + method: 'get' + }) +} diff --git a/quantdinger_vue/src/api/manage.js b/quantdinger_vue/src/api/manage.js new file mode 100644 index 0000000..46a4a41 --- /dev/null +++ b/quantdinger_vue/src/api/manage.js @@ -0,0 +1,70 @@ +import request from '@/utils/request' + +const api = { + user: '/user', + role: '/role', + service: '/service', + permission: '/permission', + permissionNoPager: '/permission/no-pager', + orgTree: '/org/tree' +} + +export default api + +export function getUserList (parameter) { + return request({ + url: api.user, + method: 'get', + params: parameter + }) +} + +export function getRoleList (parameter) { + return request({ + url: api.role, + method: 'get', + params: parameter + }) +} + +export function getServiceList (parameter) { + return request({ + url: api.service, + method: 'get', + params: parameter + }) +} + +export function getPermissions (parameter) { + return request({ + url: api.permissionNoPager, + method: 'get', + params: parameter + }) +} + +export function getOrgTree (parameter) { + return request({ + url: api.orgTree, + method: 'get', + params: parameter + }) +} + +// id == 0 add post +// id != 0 update put +export function saveService (parameter) { + return request({ + url: api.service, + method: parameter.id === 0 ? 'post' : 'put', + data: parameter + }) +} + +export function saveSub (sub) { + return request({ + url: '/sub', + method: sub.id === 0 ? 'post' : 'put', + data: sub + }) +} diff --git a/quantdinger_vue/src/api/market.js b/quantdinger_vue/src/api/market.js new file mode 100644 index 0000000..bc01a8d --- /dev/null +++ b/quantdinger_vue/src/api/market.js @@ -0,0 +1,243 @@ +import request from '@/utils/request' + +const marketApi = { + // Watchlist + GetWatchlist: '/api/market/watchlist/get', + AddWatchlist: '/api/market/watchlist/add', + RemoveWatchlist: '/api/market/watchlist/remove', + GetWatchlistPrices: '/api/market/watchlist/prices', + // Analysis + MultiAnalysis: '/api/analysis/multiAnalysis', + CreateAnalysisTask: '/api/analysis/createTask', + GetAnalysisTaskStatus: '/api/analysis/getTaskStatus', + GetAnalysisHistoryList: '/api/analysis/getHistoryList', + ReflectAnalysis: '/api/analysis/reflect', + // AI chat (optional) + ChatMessage: '/api/ai/chat/message', + GetChatHistory: '/api/ai/chat/history', + SaveChatHistory: '/api/ai/chat/history/save', + // Public config + GetConfig: '/api/market/config', + GetMenuFooterConfig: '/api/market/menuFooterConfig', + // Market metadata + GetMarketTypes: '/api/market/types', + // Symbol search + SearchSymbols: '/api/market/symbols/search', + GetHotSymbols: '/api/market/symbols/hot' +} + +/** + * 获取自选股列表 + * @param parameter { userid: number } + * @returns {*} + */ +export function getWatchlist (parameter) { + return request({ + url: marketApi.GetWatchlist, + method: 'post', + data: parameter + }) +} + +/** + * 添加自选股 + * @param parameter { userid: number, market: string, symbol: string } + * @returns {*} + */ +export function addWatchlist (parameter) { + return request({ + url: marketApi.AddWatchlist, + method: 'post', + data: parameter + }) +} + +/** + * 删除自选股 + * @param parameter { userid: number, symbol: string } + * @returns {*} + */ +export function removeWatchlist (parameter) { + return request({ + url: marketApi.RemoveWatchlist, + method: 'post', + data: parameter + }) +} + +/** + * 获取自选股价格 + * @param parameter { watchlist: array } watchlist格式:[{market: 'USStock', symbol: 'AAPL'}, ...] + * @returns {*} + */ +export function getWatchlistPrices (parameter) { + return request({ + url: marketApi.GetWatchlistPrices, + method: 'post', + data: parameter + }) +} + +/** + * 发送 AI 聊天消息 + * @param parameter { userid: number, message: string, chatId?: string } + * @returns {*} + */ +export function chatMessage (parameter) { + return request({ + url: marketApi.ChatMessage, + method: 'post', + data: parameter + }) +} + +/** + * 获取聊天历史 + * @param parameter { userid: number } + * @returns {*} + */ +export function getChatHistory (parameter) { + return request({ + url: marketApi.GetChatHistory, + method: 'post', + data: parameter + }) +} + +/** + * 保存聊天历史 + * @param parameter { userid: number, chatHistory: array } + * @returns {*} + */ +export function saveChatHistory (parameter) { + return request({ + url: marketApi.SaveChatHistory, + method: 'post', + data: parameter + }) +} + +/** + * 执行多维度分析 + * @param parameter { userid: number, market: string, symbol: string } + * @returns {*} + */ +export function multiAnalysis (parameter) { + return request({ + url: marketApi.MultiAnalysis, + method: 'post', + data: parameter + }) +} + +/** + * 创建分析任务 + * @param parameter { userid: number, market: string, symbol: string } + * @returns {*} + */ +export function createAnalysisTask (parameter) { + return request({ + url: marketApi.CreateAnalysisTask, + method: 'post', + data: parameter + }) +} + +/** + * 获取分析任务状态 + * @param parameter { task_id: number } + * @returns {*} + */ +export function getAnalysisTaskStatus (parameter) { + return request({ + url: marketApi.GetAnalysisTaskStatus, + method: 'post', + data: parameter + }) +} + +/** + * 获取历史分析列表 + * @param parameter { userid: number, page?: number, pagesize?: number } + * @returns {*} + */ +export function getAnalysisHistoryList (parameter) { + return request({ + url: marketApi.GetAnalysisHistoryList, + method: 'post', + data: parameter + }) +} + +/** + * 反思学习 + * @param parameter { market: string, symbol: string, decision: string, returns?: number, result?: string } + * @returns {*} + */ +export function reflectAnalysis (parameter) { + return request({ + url: marketApi.ReflectAnalysis, + method: 'post', + data: parameter + }) +} + +/** + * 获取插件配置 + * @returns {*} + */ +export function getConfig () { + return request({ + url: marketApi.GetConfig, + method: 'get' + }) +} + +/** + * 获取菜单底部配置 + * @returns {*} + */ +export function getMenuFooterConfig () { + return request({ + url: marketApi.GetMenuFooterConfig, + method: 'post', + data: {} + }) +} + +/** + * 获取股票类型列表 + * @returns {*} + */ +export function getMarketTypes () { + return request({ + url: marketApi.GetMarketTypes, + method: 'get' + }) +} + +/** + * 搜索金融产品 + * @param parameter { market: string, keyword: string, limit?: number } + * @returns {*} + */ +export function searchSymbols (parameter) { + return request({ + url: marketApi.SearchSymbols, + method: 'post', + data: parameter + }) +} + +/** + * 获取热门标的 + * @param parameter { market: string, limit?: number } + * @returns {*} + */ +export function getHotSymbols (parameter) { + return request({ + url: marketApi.GetHotSymbols, + method: 'post', + data: parameter + }) +} diff --git a/quantdinger_vue/src/api/strategy.js b/quantdinger_vue/src/api/strategy.js new file mode 100644 index 0000000..4064c45 --- /dev/null +++ b/quantdinger_vue/src/api/strategy.js @@ -0,0 +1,177 @@ +import request from '@/utils/request' + +const api = { + // Local Python backend + strategies: '/api/strategies', + strategyDetail: '/api/strategies/detail', + createStrategy: '/api/strategies/create', + updateStrategy: '/api/strategies/update', + stopStrategy: '/api/strategies/stop', + startStrategy: '/api/strategies/start', + deleteStrategy: '/api/strategies/delete', + testConnection: '/api/strategies/test-connection', + trades: '/api/strategies/trades', + positions: '/api/strategies/positions', + equityCurve: '/api/strategies/equityCurve', + notifications: '/api/strategies/notifications' +} + +/** + * 获取策略列表 + * @param {Object} params - 查询参数 + * @param {number} params.user_id - 用户ID(可选) + */ +export function getStrategyList (params = {}) { + return request({ + url: api.strategies, + method: 'get', + params + }) +} + +/** + * 获取策略详情 + * @param {number} id - 策略ID + */ +export function getStrategyDetail (id) { + return request({ + url: api.strategyDetail, + method: 'get', + params: { id } + }) +} + +/** + * 创建策略 + * @param {Object} data - 策略数据 + * @param {number} data.user_id - 用户ID + * @param {string} data.strategy_name - 策略名称 + * @param {string} data.strategy_type - 策略类型 + * @param {Object} data.llm_model_config - LLM模型配置 + * @param {Object} data.exchange_config - 交易所配置 + * @param {Object} data.trading_config - 交易配置 + */ +export function createStrategy (data) { + return request({ + url: api.createStrategy, + method: 'post', + data + }) +} + +/** + * 更新策略 + * @param {number} id - 策略ID + * @param {Object} data - 策略数据 + * @param {string} data.strategy_name - 策略名称(可选) + * @param {Object} data.indicator_config - 技术指标配置(可选) + * @param {Object} data.exchange_config - 交易所配置(可选) + * @param {Object} data.trading_config - 交易配置(可选) + */ +export function updateStrategy (id, data) { + return request({ + url: api.updateStrategy, + method: 'put', + params: { id }, + data + }) +} + +/** + * 停止策略 + * @param {number} id - 策略ID + */ +export function stopStrategy (id) { + return request({ + url: api.stopStrategy, + method: 'post', + params: { id } + }) +} + +/** + * 启动策略 + * @param {number} id - 策略ID + */ +export function startStrategy (id) { + return request({ + url: api.startStrategy, + method: 'post', + params: { id } + }) +} + +/** + * 删除策略 + * @param {number} id - 策略ID + */ +export function deleteStrategy (id) { + return request({ + url: api.deleteStrategy, + method: 'delete', + params: { id } + }) +} + +/** + * 测试交易所连接 + * @param {Object} exchangeConfig - 交易所配置 + */ +export function testExchangeConnection (exchangeConfig) { + return request({ + url: api.testConnection, + method: 'post', + data: { exchange_config: exchangeConfig } + }) +} + +/** + * 获取策略交易记录 + * @param {number} id - 策略ID + */ +export function getStrategyTrades (id) { + return request({ + url: api.trades, + method: 'get', + params: { id } + }) +} + +/** + * 获取策略持仓记录 + * @param {number} id - 策略ID + */ +export function getStrategyPositions (id) { + return request({ + url: api.positions, + method: 'get', + params: { id } + }) +} + +/** + * 获取策略净值曲线 + * @param {number} id - 策略ID + */ +export function getStrategyEquityCurve (id) { + return request({ + url: api.equityCurve, + method: 'get', + params: { id } + }) +} + +/** + * Strategy signal notifications (browser channel persistence). + * @param {Object} params + * @param {number} params.id - strategy id (optional) + * @param {number} params.limit - max items (optional) + * @param {number} params.since_id - return items with id > since_id (optional) + */ +export function getStrategyNotifications (params = {}) { + return request({ + url: api.notifications, + method: 'get', + params + }) +} diff --git a/quantdinger_vue/src/assets/background.svg b/quantdinger_vue/src/assets/background.svg new file mode 100644 index 0000000..89c2597 --- /dev/null +++ b/quantdinger_vue/src/assets/background.svg @@ -0,0 +1,69 @@ + + + + Group 21 + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/quantdinger_vue/src/assets/icons/bx-analyse.svg b/quantdinger_vue/src/assets/icons/bx-analyse.svg new file mode 100644 index 0000000..b02a8d6 --- /dev/null +++ b/quantdinger_vue/src/assets/icons/bx-analyse.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/quantdinger_vue/src/assets/logo.png b/quantdinger_vue/src/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..536e42f9f61b7a675784c423ce7db1de73c3fbcf GIT binary patch literal 105735 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 GEaMM&$a=W| literal 0 HcmV?d00001 diff --git a/quantdinger_vue/src/assets/logo.svg b/quantdinger_vue/src/assets/logo.svg new file mode 100644 index 0000000..07ac542 --- /dev/null +++ b/quantdinger_vue/src/assets/logo.svg @@ -0,0 +1,29 @@ + + + + Vue + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/quantdinger_vue/src/assets/slogo.png b/quantdinger_vue/src/assets/slogo.png new file mode 100644 index 0000000000000000000000000000000000000000..02a796aedb256cd4e8c99980954b9d0222d89397 GIT binary patch literal 27144 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�-2>%wW!@;m z>TEKhN3xjn#7S49WcOu#6!(pnFj=E$P`}wuHF_*z2%&((7mmb@1(>c)nJepKFpUx` zU6H5gilLA5cif~ncTLcG<06wyzy?oo=Bei-em>_776$7P$5IIvE3-#Qp8xhmDW|#w zL?Hyn-jo$#cp{g)*0tz! zU1zew)O&+Cf_b@R?d$K6EzEU-z7C*@e*W!8tn4-tSv|?A6wL7DxRgE8eiuWEQhLs~ zm^stM?{UP81y4Jva5P>oTP^g?(Ofni7u02iVtX|+FM`xEQ6%Du6hz9xLb1YuAump( z$&{h)RqdWBKcy~xAXlss#QAM&VplOf#K;KToRv*#jGYbDQ4P+H3=DGxX@QQ^yrnwA zC>C$en=CaI?~ofpgq@tSmJ5t63kE~9UlGRS)jV|1Lx5z5MkV-;^{MS&4kvW9PqdFA z%g?wI+!@Hf9rw^mgaOQkqgS(3W^%Wj?GyDt?_i`bxTQDVeQ^bP9~Cd9Nd zdbxmg&a}|tFhzbd+3r08>ecVWXl~j8Yk3{6Khq1kVV8chUpXE-YxM^Fz`HaSG_nF0 zOT%<>tfsLwy_)hRP#G!$?9KvKy`J)SWd*e`SQ4!B8YN2o6r{&!*{pm$2w|sXhgO^m zPGE9A^8_E1Wqq1M_!K$|ZZ?Jl!?)(Esfp7KmFYH-pLwhVS-xcUDVg4E?h{IMD&e4k z6VCxIs|i%&ialQ}ArCs3QnkiUw-XK9^>#d7)RwaWB+q$HV%f|1*)O#ZO8c2UWxR@O~cS%9t_TRy$#) zjn8OUKk@ZMw-j;ds2Z0n2u|B?CLX(C*AqNCPQcpF>>T<4nk!1VM3O$`GvtRC4G>MY z|Iiflen`@QDQ431-(E%>;+qmi6lo__HsfIDH#iqYkXn-(s2Gl!v}nlFD)a=aEvmJ} z1Y*yb1@M11Us=dZ0?szS6yb%pPK$9~fL`qC`RL*RcFqJam{rVIUB2>$Dy>3TB9*D& z?c6UyKA&-c4-q{m6MC#VExIk}&*WAA+D>BqcKwGESC0H);7~_Tb=62zKo`wo($w=r z*Q2N9z#r>FQU^H5cwqEBH~fI#ZD&)m-wi;x-CeIMTOUq}pJsp1KSX>K!ADYniB4&r zo-*p)C$>G2C=dJzA?0G>_ElwwiH{`XzAmLje;7x%R2kWXc@-*4&IQ+3+c! zT1bWq3>oGDK#y^7Uk_6#SE|Vys#FO54toNDp#!4BN96pc!vK2mAfOUnzFq{75OCQO zsz7X^M>KEnfr~MD%9dh4@;OEOkyWE6U9<7i_3?)J=B3U~+kidI8)QPv=?LX}?aRo& zt(v_Yr~mWxyE_?KQPnhnd#zMR?qpWT{GHkj=+~D+LT_k>|3)+~4}{+Fa$Rm}8f(bM zM#NWWkQ;;3+}G$cWJiWlVMn@qz$}q^aTYCRX?@RDZ!Wh`0&poLV_7a_x3Q_YmRWmR#Df63*T1Q5(ql~w4e23$ zRdFPqpkem0ZT#du_A6+a#@Et%TSf9XRhRb!M) zT@}XiV7B}n`v(X9!F>**o4luM=EwPTp+1lDc>DN#NY0{gg*sJaep_i)ndUN;83$gt z>=sCZSCwB(YW?oRy%p^5QpyvoNcs>yH#Ed608*zE@tuDv12O-35K8@an(zfGCDJ+4 z&)&gAjL3F44WuW&^_R>H-l3Wih2dyQ2-M2wg)W1^|4^thU>k^B{PTiZm zd?!l|W___UZ~okL8pr@pQf>ev76RgJuKqUhi`O$Bc93V@VKETLz!Ut=d+l_m2X`Tz z_&{H)ZZ2^d91y!}LtL%}l-qKub(=t7dyBR!C!diJ=w0E(${jJFsZosuLZ2cP=`~BG zIgkhIE0?0jKZm7!eO>fA$YTX)Gsp8VYMpe>obz%3bQZO>5j}Ph5zLE5639bm`Hedctoab^4mbzyT%UDd4-`awz@F@T&fqqrOxFxMK#A6RVzV? z!;z#y$1Ga``QmZo{nhaCZBr?{)Co(4p5pB_CVm?|5^i=qx3sR_l+KpYE&NrNJ*iNp zs{Ra>qO2d#kK8|8dvGTFM)$qHG(ju6iFA2oOu9sS&Ugin{WO;~9vuv&TtBPvs%HrT z_f_~+AI{J1KkOnOdFnslT^IslQ?3;i?m}cI+lu>3>w!hJ0kiSo1bBlse-06gRcojo zUHFpx#hLaA3pK3LwQV~N(W!<3TdX%nv~RzX1nAW<)cBzM2yH(RWAOXMQ>ZJ5VG+{s z=#go)qBC)I{X)LAiM+*TNp3~!40pR_FWpYFSmU-c$(;{#T)f~Dt*eLT*k)<<6@Lxa zI2HueBs-rDohpqIy%#QPF)Yc;=Jew}S65JeKCvUir`*>U4cM&jE|*+rMNRE{pSS@B z+lR?UK8p<+(1;`|cSYcI%7UDi*i+c$rgNa`mEg!e90taNv3Oe74^)Ywy*tGEP+i2_ z0%)w*y$hJKc6w?|8Ph8(F}n6G&>!SEKo%l_6O6Ph{t-kKCgXwukXGzK+854YDE8wfU*FFq5Z&X5itK; z0elpJralKG;5ISU(DDY+(XZ^uo;uTj#WET7hQc@RCd)a_scUZak-V`0#a7L+fO(Tg zPmBHTr<*V!2s1N1CcJmQKCDFmyeb_CGP+LIP#7U78{}Fto`JUms3}^VzC1PU&b*xu z9Ty*7?8{fj#}}nO_56c5G-M8hO_{7&X^W?M!}RK%vL% zyog9{hGHnU@}5)tYq$s{DjNkj&tNVAToaSvyG!xEG;>$xgG+nsu{zl5soY94EJH4}Phm=BEt{>yPu$IGcwQ3B7F^z0LD?sq=VR2ZmC3!3}>{q?ACnN zxN-@6V6;lS!}0;J>Q|UPO7+uMzYps=fFnWxk_E(|@uMG5F{3}}HijNoH_F2YFNNdF zXjBwaqzEGk5S0Gw&up|U3sT*=BJea@_d+xz^eQ{ zH`{xdD=L7>O0zHKv&;_}Pk$Pp{T{-e?73p(P)=VuW9<`|N@F^myQ zqKq%x8B=`st!$%jtDw>#+b0WX42l!K8y?wzPmvyV^)3t*;7ov%WyQ7uS@1e3VKZIV zz9)`rJ@Ef`yx!v$z=K`B+Wr&#gY2-g;YTTZ}H$1?ujbv-B!m#%Oq?n}^3v zD28J9r2jqp2R_2(bGIFKW|{ioniLgr()KqB*AZfxDM858bqe|H33~Ta#MEKOpRnFB zRSym`aoR(VXK$D~TywMJjn264b^@V8(TA2$;gQ?Ej_F@*Amj`-@_eU>tH!1ddp&jUZ*Mm*`%s2% zk7k_Ky$*~|7k}<|=-E%6cUipB@EQk$v-1Rn2MlygkYZ!4tkkH+YoFN<{o0$83 z=kaUlI?zKCq4Cgxew&4R)#E3PVtx?j=x^=|xdKtR*^|3W_ehJGbd_xRmCg&XM;&HH zs?4$!qp_J*G|+?`*hd{^_Py}g7+P)N+NL1k!KRS9KNL*mtw116&?3z?YgDE7H)wXL zOo?0xd=&Y-4I)-5|bGZRu0&x?RTLmWt8 z-wgiY=@WXpLYX}gJt#zS#ZOJkUSFB>aYg?-=$||H<1n=N460b42>)PLv=uH#ePX3M zWF0SAkZnm?P={RdKr)k-5LwX(!bs^9!}61nsayn;QKQsx4N{TN$$-ycDq)<%b#fLo@~i zGxpbtzjsT7GMic1pVOoHEB}g1dJo+@)j}qK-;~<=iPCTI`ta#4yv!f(Q=A=<{x$D^ zx7iIS8Fuw=f)n>NY1=E+<=&=N9gfwxuJhl4dD;3B(0@Evb+@}~G?Q=nn!)EzazTO&_WAVEsjm zOZCO){yP~ORA>UoZ>5YnVw6iu#>zJu=6#etoln79bo5gy6#+6?@!4t9cF<&JPv!?+ zq%qQZN#f#%9g%X8E3y;*9=hPR7)e0aXZ*KRCR4zNv-97$!-vU<7Fc{Yb#7>RvgmNid-%W?j&WbF;|vBy;|jY{MY^{?HxausJ_-~U zWq?ttMtyZ_5AN`i$L60z%nf(-8Zjq3-khb^Qyx|M4RZh$MGn{D(M&2qO(+7X(%Qn? zcm2tfad&Z-6_mg*K)T?aFAl8s%Ul;%H#fW{;|aN^Eaj|~OwTg$KMar%Mw_%ggs6c- z{L(Ol?uSJm;P-02={)uW95Hu37W*3{(vUBQ>sTLq0T0lTOPPKPxZEoRBfE1r4G*?@SJSby$w^VIYYmv=p^uAw& zlA~%IPs!VhElsjr?CiO8rc=HGQj|`@P5*w~$3gG0<6`T$s&=qk=+!zFp95c5X{aJU z1RNDr(2iY69_bt1%vp%%gn0O%hyjvUvj_IJFVX6tlGJA}d7Z4YbcY;|Cu6#Ro*#eh z&j}}n%9L{nN{u^8qT<(9*DCectpRY{gwO+ldKW zxR^wtE7EcD#gt2(S?$S8*-qj%LR#Bs6$HU9TLCPr1wn0iM>Y_R!f4`mIia#DauPIY zwirk-!U%cLqnc>Ha#@EH)5tD6V^f08I4L3Q{{918_!p+Y>gdTbe1te}-vQ;iFPHzE zQRF*30oQs;RO}UYL1P-q5BRY^vxe;bwW+KJeR28FT)Ab^yf0a6S1m>ya#B86*Ppzq zh6!c{R>AiOQMlr>?LC>eJ_$MJo;Ph8Nr(bSWh06eWzRXo+}aVQ><{txgwP;@gEmnM zH9B>uYzBz`@d9vG0S<+o`eru@NafA^PWcM9um5ERae(RgBnFs4EO zu*rZef&X8J^9}cxAU}T=K1RjSy#7yf$8NLF&ti9JonR0AJBHwSN|N05k;-RIHKx>= zG6UFGWzxuR$rWm;Ad{~b*thr25>^!fneGuy8w{Qzb0XQgq!UFdm4#|dz_Y2|)!1wa z?rv^dKqtyxc@AJ5&L6r>f}RvGjxfz=dlI*brJp?;34Gd|qd}ix@`Qhxqf`G7SD2%+ z#EYBdvrpV_*`UUB1!%xkmIoOA$8N|9^(q^zbd_*=a6hu7-5lpB(GF zr~^8CaFy+b6q=oil6Vy!mbt#TXi*4SO?qx1xCaEEPeVQe>?5!M+5Z0v+$AI0B8wXd z>T6q0lKNmU5vMq8=K>Uz73F-}iNbRbX~=4fq(PD!AAfo5uejsR zJ0YYnlbJ~g->50=`{@hJL{>fvicm1ACOw1R!AsgpFf$WXSHRRsN{P*zH{+2}I&>5x_k>H%;T?ZV1!wx$P<#O2~IKhxt34jH*8#4x<{NyLG@4owD z&YU@zGiMI^`)kTH?KoFuweMhKAT4?dwiT=WXbXXeli!PgcIpfU3$7y1oa4&xgLDcE z!NirJ`{|4NPwiRBz?e2I3IR`g062W%+9Ja;GPHk*iKb<0b6?N%;QRIPgp3)Ws(wbg zfbY{bc#6Pm*suYdm%w>Rv){pLD};Ja&r`o~uZ)8ZJ_Hl?+aDi2{zUxf(jQ{Uk|o&E z8{UnJ+RSF@hYCrSt$|T`w?-(E7SooMlyMVmFeV*o!CSe%h%l-nrgq*2XYhqkVgv%h zp~*<iB8>dw{ne|9~AAR&+~ zJ^+Ec_SM&1gUO%!9Cp}2SvqX}g(5Kdc0V5|xLj#73hySL44cq4bf+a28B0^E)gjr} z)`|`UJ7oqdklTupQ7T9iKNJYp-7;_Lm73hDOS}Z*W=R2Rq->RWR>&YpFXx_>bxs;0 z6LnSy5NxDN?$c_>km4=nvF6~O2PpxrMGRka3%>BphP!Tmy0SaPk5JskJKyzAT=wJ3 zaNBLS;Ugb84x>hmLU(t!A$lQ^We5m`O`vU-~r!_zs zx>UIy7}J@um29F3E%bzE=KA=9>9SNAlpscN@J=Q$w_=Pb-H8`$`Xd>GFC-ZAltNPm zEN)hUSK=z9lo&B$1P=eVLoxlzpW^!$ei!?W-`kjl<2ai*#o<7L-iP)e9hk+)B@p?{ zmwC(UEUy!Ul|BP0V-B?l!WjK0!A$yh1fPfwgD^3RMuo_|~pbQ(z5 zqL_`r^nr-;MQH>bM<9lJ;<7E=r9WkGjiM!rKybiBhsIfz(h$mTsdY7+bL_kCzWCt}e~2Id z_%iIX&)z7PJe@aFvG^q|rf`{I0nIN9wXlKpKlFLvGj!N2o0_%9BK4nAQzNpJ)$7Gc>i&gKMpcDmIfnPVLp% z^E`ktjNW!zoPNd`n19nR@bQm-4BKwIjd@?%Azu1B>JnhmQ2+)$6 z0RszH*+YsdvU#V~Bkta!!Ie$vs8*|Z?|a{i^&8irx3?DnRF)>=%FIJ$xNx=hYT^cN z(C&cEYA2VA6HJ7z=dh#~C?ete%KE}MgYc!%>hHJT+d~8*$;_D!5>M23RbC)|aL%#+ zJKlk5)288=V~)YpsZ+6J$rAKet4fc;VZ2SrxytT~R>z86k2o%PsF78Nj&ik*lT91p z7>bG{0>+pMXgdA$)A7kqejL?mRl6Vikbw3hC?$5X``&8u2ymvj50ybnZ9Oct!V-Yd zd+0x`iqeOrzu{pE25Cz;CG!OfT*MhvkeQw=-QC@dHB>B^^GPCyHo0?-TCIjkR~JtD z*vGKX`0Hky!iO1~dx25oly5QkzrZ4Cw&(N3fih_n|O%9m``WRr!V z_PmSxn}eaJs~bH%J&;mrH)mgOKBH_P?mhXqC0Z$i1h({Pi(uJp5=}s2-y`Zl^rr9o zX0--j#fjwQ7NW508!pGwzcExQ-SB-S&K!8)fw<(7OE78DBwTgXRe1Q}N3m(sCX-Q{ z#JhGxTT*?9^Km(!tJaT?Qpp{ndrzbgV%YvgtWPtL0c8QbyCE8Zu%Yzj>t}n`^vPobYm>GFnCSV#2 zC%|+(iIh5VZVe+wj>NIY9gFe%j>ok#ug6_?-G!H4ddYZc0^vP1o0$tXCqh;X8hf2E zw+OKsqC68XYL3chHpvZJ#qL09!G-f<;~h9y`jciM^(1DRHMgVy6;DCkXZs}>8&R4VI~isF>!nbhi1gt>+|r=DgxMZ*O_Y9g!l z5A{4HlGS~Iz4qQ4mt1lQ=FOXj&wlp*V)W?I5W+W{4iNQOu=O|Dbx^IVg46=8tVqNm ztBu>BgCH8yW_CaHok4&&03U#gQVexquzaR=0Aoy91(=6uu=L;~ahO@FzJ%KNh9Chj zj~jVEbX!jD$PfqSo^LbJ%J~mTR#%BY6pn`g$P88B66{5h~*%f zu?-K_3%66NJd8I!vc?OEI%CI<#o1?{jazTM6{nnX3Pz0@1ptPd@VszFsH}CxUYRLp zhJC(M(3ai3hw`QK5VAP`hM|u38^+MXUM$GLYXANB$2HengG(>H6ywK_4{Zx*D;Ztx zEolt-zHgM2#z&(d1R{^gwGH9kgZr|rJkO@;_V#nbC=OcHJv90(@}sfiLb|%TFlEXV z{Oo5x!v{X_0d#eB8IIFdt};YjzFi0*uw~1ZA`es$b zXn^IZYjP)KQo|eL7A!;RG4H1u8V~WE94rAc^BcjyQ*}ute zW8AoLc>nv~Z?+tyy7iuMfl(n>*=*G1w7S3?SYdnRad3ZzdjN7{iFbPU9bBodTjd zE}3Ir5^Eci(7l9V0?`JFz+2hu6% z2m%2FQOvedrJ2MV#EG~XQ*VO(8x z*5|;bHTzz71CwD4BMElorj0cq{E+ywsqrjRvJ5!Z1aoT9rH-DHw3?m-4-%U6Zl=_~< ztww*0x4v}-2itKX2B8%W0zgZyv%`4fAt@j*Gmk(b0ue-o1o%)J9gB|O90(mrAhM!4 z-pMEg<%s2~wZ=y!9#TL6>ouDW$00-fkg_4v+`Wgl>59qCKfn-wInq^~D zpL329_rNk`$(Z1ewj*j58^%B&oJ!4vTSl*kb^)JWg<~MTxTcxEl(P9`+AclXy{*Uh%rzNFhGO9LCG(1frvd3 zkjrBvI`lP9LpLqy)&F>@te zm-D$f2UO#1XE8+-wF4TETffrmuuHnHP3=wNB<=pJ=wUL+2iea$XZ7Jg@ns=9lFl|$ zn#{YFe|#P}&lOLYeXW#cOL!nyIIIOErH;}490pp&Ihcs$)+mauMY!#pbl7!CyN~uI zWzrTLtmj!Qf0C{sONWpbG*&xtA&!U`%|u8J2Er3C9B`FpK1%YbuhnWHF(pc5Waxe* zbZ(N!Vy@v?gDS}_H-1pZJv$D02Xj_Nt+?{-lxty=vGsg?g-8(R+;P^3YQe2jTptsW zpt?pG!LY}q8>eH({Pg$tn^g7Goq3d$RG!U+xMQ>Ei*rXDTkz9;D(l1xc)s{w7f zMgznq5^*b~>uhD$ArgMh(+O?B3Hb?wB%U>oPL4D?fB0Fk^{@zG)}r8YMvO5C$00TH zEbC8qa->cWhht3Y>#RDPzPH9tD5i%6Y`@J_5Wy&wN~qWC!Sl?sHIYbtderx!)6fwF z0*GHVh*)HKzq$8%o`*`MV$RcG2<^Yf!vk1a+(umaHleTyMA86}OtHUG`r)0-%{4SJ zlDVTe4NUL|mU11{)w zqemWj1hrZ%6svE8dx7YoRv;&5p}yYUv(fWBa8D-%(Z!i`Wsw)spME{I+~6`V^qW2T z;)qYuC$&X{kj01?_NToIRGglLaoW>TlMlmsyCPPYg}2StaqPEgU&bPRXuH^RlyyDA z5x;gb#)*Hn(oi=vU2J|ALcrr5mM&eY`j>OmeF4sy8GC865Y@L*slf9*Ri57R(f(d! zl;T$AA`u4`8X%q_5K_Ck2?($R{XkNATefV$tl6`0!nqD1`aSP!BWjSL_J_|nC4&tZYIjc1n(ELNqAA9B*u1otLF(wNVV#RTqJ|` z3v*-Y4M}t&BFMo|tJTok+pFgWOnvi+7@TuMAc9vf&iu+kh=Yiun+w?b~+3ZOaJFiH7oes>9p!|627YT6Sjt(+L4B1R@L1V&oF zr+k_sEU`dUK@;xCL!A1#EWh)+VaHRNC$sGv)J-+k7r_<_ArUR6Y~sL1jvNWk^Fmxi zr-&LIp@WDvCPTemZzP{P|d(6BI-7m90BRUTdGvrz&HEr#38gQV;7EQncsmPfJMDa0TS9Y7$WohYS5tyXKk#5NLyTxCfaomfLXXxh{S ztrQcH?3hJp3c|#;W30Fv?-YwePe%zTwoK$!izZgJS`EpzZx+}xaHk-Te??wT(^j58 zRAb!LKiHo|N5t4(L_!fKE0S%xlMu4RSb98yYavKN0lO#>Srb7(Ds^JRFBQ{(X;%+n zqtVXZK(QC&IB=ktM1hbcKV}i)BtL|95OBs=;NI&v+k{PS!S?UwP;U`=ANOzDs$>(% zA}>Hmt7IN|J*&Ts?SwT(DBH1OXQ#Z6hrLNtr*IC0$QyUU3kV^Mk}|IniR|qGyP0k) zk3%dGK)}An`5D-DZ`;1E?vHK?oAwUna@i`M>NGgSB9_P{5?{+QqCd&!LDfCq`_gy1 z*M5c&faf*#;%=Su!tO&Lo=_yU3!wU~6XsPM3~4JW7ZGtB(j7ob%DnX^^AZsWvcHod z#PxP_9gO1Y0sze8T9Yp$<=9R=w%JPv0p@Y|DPoel3YSWyPz0;zaroM4$F3voO6``D zRDbs!v+9vl24gJrt{e`LV1XMmO|ZOHD0Lr|4I`p4WUuOZh>`6KM$e<)!hSgMaT{4E zm&>7YI|&_N#R!8sq*|@&ztdSwyA!E%OQjM#kD;rp3sNv}Yjq?uzLZjBtpZJUn&jct zn;dKCS;i?za|*P2RI62N+O)|KsjRHp_Q2zuqh7D0QmJ6uZO6d(>p|iiT}N&>8TZ~~ z2OWE|y3ch*oVa@FGdhk%`3HEMfqNcAT}`;|?rzgAtJkbSUw>b&i4_&O6vt30m%$j< z?I$6Pt9Lvn7e^r7vR#5;qf{!PTCEzwo>s@C_o!4ViRBj^C7`(6gAKQH`}_NI4>amr z>fmj+-4+*K_+1=$;DO+*L>GphCG|OxW+Mjx9?!N)NC=?@N4;Lh!+-h{rcRxTwQJXc za~=v5O9MIAR4NsG_OqYG=RZH$OzK*#hH_WMtl05`FG>K?%x#UD;y4cz8c!42dbJK; z2$ahe2qEy=Yp>zri!a8azb|gce}z_e&r>_HpK-<+IQ;O#v2o)@wPTfx60bO2hN}~@ z%csS`{v^ky{{DVkfBp4Xwrp9X?7SRAS65f0E}epo;vj<6L8t(ln9fOJ3GMSeJv}(^ zzyoo_5l1M$V#Z>9{sD_araIWqg3`i=Wn;~Cu%JGSF>F}BK9snW;yTB!)MwU3_zK4AujTg`JP%4$Me*Jpf zd+)tix^!uT@Z=#5j4{JS(vifS6%mnGjG}=tMG%CLD3?750Z1vZwRbC+6~rgCOsZ51 zf&6P*00>=OU4{b@#yMCK3gf85!o(n{m0VY0TM;LvD%;~6TY9&mzgE>7JWEIs9w5+(mPuiS`6X9$HtDS?S=)?i&5DtQ5fBLTaKp3JBx!(F7=*YrsrEy+i+6+MD z2Vs4wNlV;krFFQii8o5c>`Vfode`f9^!4?jTCIaK29J9>v$u6e-1DtP+WkFVP7hN8 zQ%e^4qW`j#kz5gIX0@j=NrHuv$0&{?wE;{W!$h7z2rb$$6-=fJjNHo@14J@u2Lc_9 zt4FdGsxC+3DlgOOZWA4sLmeg%Z9vdTK^Cb%i>wA{tSs#a2X=>eo)=mb+Ksq@9LqT!2-Gw|2N$&q(HS+Gu8+&JC8&X7X@?P zHfg6RAQP&YT>p%AG5x20HTVsAeJ9GVTeN9nja@K zGNu$>EPaONg4@w?mIB6f&4}aCEWfghgv$x;HB(D}Dm{cP!f?)VyC}y^s+@NwoX?tB z?g@c)$t%u8WFVw46}eFK6Kr1Nv1 zr*4QXCbgc{(;y`avFTu>oAFG2@_;z0P?8m!!bWe%QE{6H2TD^CVF9323dBMHP4rt- zFV#sXJb?hHGXkZYltDBn<6NbKlZGkJRc0#$B95I*;v8(NMR+d{HMaAPm#EBY?fj(v zbZ*d}IVN`6VixFFa#b^UydgkAWx^ss9z9GL5%%0Rg18lw^pW%tS~^^lHRn8_5lu{7U8*TNFmr}Q zS(l(ULfC;QVKd&e9Ux|O#ICW%3CD7kR*p~2mDg&rnMObZ%-%eOkav98n)Qgz1I_6l zrO!7IS2wN zC4pd7ZnljFlRyBPNcaMx?uX^(9CIYj{AQmYa@Z4{1>`DZseux)@_3GBKCX@ZW{;_~ zYmzJ!d0;D6!_O^9|7D;fI0!%uICrz0tbU5?T#9QLOafuoh13RlhezDt97<(Zk}rk+ z1C*vgItLMmc09A4lgqV`*&tD%*|sKvPKwn={V0lnQkwRC@Q^-PNe2xrvm0Fj$HZ@jH4kPiX9@|-u)CB<)i{9H7NmR znE(UB2?Jn@6U^lBQ1>>rdfLuNebrt_CLkn5kcxzN$U}kx-GEsz^iZL{YyDM}y^=W} z7u6XD(mT25?B{g?3KY#5u7hO|gK;#{P#gd=?7F#<1)nlpi2lw1W+iWt0xUzyN~8X9 zN~fgj%jL43+)QirQy&|tL)wZoRAAJm1Y5Rt-mew2G7&{v`yYH)b%;}p>RN`*hY{Tr znpxM9=glpJUdAR*# z@Dx%+gzN#%r!&9+(02p0|8=57fUuE+@0QhU^8jd?CjjU`KkDo^!01Zks{-u_Dz+;% zI=0P0e*70DJjvPjy|!Jb)OdyBlgWl;xmBIE8^5%f{|;3ndgjO1cxkimmE3m}1rfxo zGmZ^9i*Fc4jWW3gYQDIu8B2CYQMK=T`1 zQVP^Cj6s5?bpQVS3qODU1U$yxsIX9ZA)l9svsBo92AyD3nig1nD{cB}nk*+Z#&poW zR&aN*FvR5j)^KXCT~^#L7ACO-(5^jQK#31Zuo z$#D^t2Odd8K~#Z) zlLR70f5-o{&0W(gwuN}QzUfHy61RB^mi{OX<0v(%9s``3lh(MVM)qOm(%GBCN4nV> zTtzcY2L9dJ^9AW$wWP@-+DRq}1x~_*|0BDdXU{)(ifxb$Z9K7t4 zUYdZVkp#k2t~JZcOix7t`%c;pjcpSXsjSt;ha(95-%aNl)Ep~rMx+1UmEKy=(?>mb z@hfLKeD(F+K`8;;u}h1=p=+z{1PPy?!lt&SJO22dph$T+RBf+uZplG zYdiGh)0U#j3Hk&@LdrEpLU&=X?sx~w{alAqTJR$qTtpd;Q_#FA+6KY5Giiffuh;4Q z{QEWd%(ivsBPqBB71dXzRR#xE+qRm^@!kaP3nX?*@RTsTf!kW z03RuU_8Tu7zD9BkQ87~0$E1umBhJU>6fFpL)Bc~J1P8~P-q zlhHK!dH(!2yu)oi8KyyH+lioMM^d=mZt&*KoBNmTz7?!~SOCkt><%;t1 z@&Z48{D41y{=j*<;tfyy_5J)BpF=law4$Rv?9*U$;PSyt%g3$LuC%1jxwM4!xL(H# zdOv*l0B_&E)uf1$f%U*BUSGa^f!pl{UDrX|w)3%jqNk(86k@O83xRYUp+p|iA5D+< zD@~u1nyEaO%LU%Odk62|zt;#oRtjtuMtPSMJBQp4RgnM;1QC+J+OU;ltTbP}-C|pa z*qGW5DU+BJqifYF5vlEKntUPfF?9N>GOm0ilHo*>rYiW`zQ*#Tjgz8n5CHZ;HM02P ziRg5t?{f^$SR9cMLg3q$scp{H36t880uu??2r@?O_kU*gMM*)iyBMLE8_hhSU3#ko zNyIdLzXpy@47T$7)-87%tl6eHBoUuH%<0m3knRR>J^~TgCu!Le)R$0>P6V+CRWY{C z-vyIB$tUf+bwe@;w=>vq29TwN(n%u}v7NDoI9lP4*h!Yle;{S2VcT(%N2TC6L`+J1 zL+>+LW7|z4yb%2EMh;@HFXawr)_ zXISv-nP5yOCaXdA5mEeMtqMD0Hd5#_VLLH#VaqCx3nxKTN#fYZ)qhRHjgyriOjyen z(~;x!V!oZL*DRr?1-H?|8iu7YK5r|BDLev4 zunnSaYnFli4r!SKqEw_KN93?v$IP3ii8E5_D_2YkIpMlU#MKzblX%AV->czcRU$hc zcj4hHxgsYueIrLvpcHQl)i^%q;)#W$R1Cs(VDt02#a2JixP$;9gt7H1YLCu@6V_Fx zT!e@=kma2hv3N{JFD{XR_TKsQA|MfNZ~NzML$+XFv`muj8V*<3_0%+8J4% + + + + + + diff --git a/quantdinger_vue/src/components/ArticleListContent/index.js b/quantdinger_vue/src/components/ArticleListContent/index.js new file mode 100644 index 0000000..37d35c7 --- /dev/null +++ b/quantdinger_vue/src/components/ArticleListContent/index.js @@ -0,0 +1,3 @@ +import ArticleListContent from './ArticleListContent' + +export default ArticleListContent diff --git a/quantdinger_vue/src/components/AvatarList/Item.jsx b/quantdinger_vue/src/components/AvatarList/Item.jsx new file mode 100644 index 0000000..8019fac --- /dev/null +++ b/quantdinger_vue/src/components/AvatarList/Item.jsx @@ -0,0 +1,25 @@ +import PropTypes from 'ant-design-vue/es/_util/vue-types' +import { Tooltip, Avatar } from 'ant-design-vue' +import { getSlotOptions } from 'ant-design-vue/lib/_util/props-util' +import { warning } from 'ant-design-vue/lib/vc-util/warning' + +export const AvatarListItemProps = { + tips: PropTypes.string, + src: PropTypes.string.def('') +} + +const Item = { + __ANT_AVATAR_CHILDREN: true, + name: 'AvatarListItem', + props: AvatarListItemProps, + created () { + warning(getSlotOptions(this.$parent).__ANT_AVATAR_LIST, 'AvatarListItem must be a subcomponent of AvatarList') + }, + render () { + const size = this.$parent.size === 'mini' ? 'small' : this.$parent.size + const AvatarDom = + return (this.tips && {AvatarDom}) || + } +} + +export default Item diff --git a/quantdinger_vue/src/components/AvatarList/List.jsx b/quantdinger_vue/src/components/AvatarList/List.jsx new file mode 100644 index 0000000..bff7092 --- /dev/null +++ b/quantdinger_vue/src/components/AvatarList/List.jsx @@ -0,0 +1,72 @@ +import './index.less' + +import PropTypes from 'ant-design-vue/es/_util/vue-types' +import Avatar from 'ant-design-vue/es/avatar' +import Item from './Item.jsx' +import { filterEmpty } from '@/components/_util/util' + +/** + * size: `number`、 `large`、`small`、`default` 默认值: default + * maxLength: number + * excessItemsStyle: CSSProperties + */ +const AvatarListProps = { + prefixCls: PropTypes.string.def('ant-pro-avatar-list'), + size: { + validator: val => { + return typeof val === 'number' || ['small', 'large', 'default'].includes(val) + }, + default: 'default' + }, + maxLength: PropTypes.number.def(0), + excessItemsStyle: PropTypes.object.def({ + color: '#f56a00', + backgroundColor: '#fde3cf' + }) +} + +const AvatarList = { + __ANT_AVATAR_LIST: true, + Item, + name: 'AvatarList', + props: AvatarListProps, + render (h) { + const { prefixCls, size } = this.$props + const className = { + [`${prefixCls}`]: true, + [`${size}`]: true + } + + const items = filterEmpty(this.$slots.default) + const itemsDom = items && items.length ?
      {this.getItems(items)}
    : null + return ( +
    + {itemsDom} +
    + ) + }, + methods: { + getItems (items) { + const className = { + [`${this.prefixCls}-item`]: true, + [`${this.size}`]: true + } + const totalSize = items.length + + if (this.maxLength > 0) { + items = items.slice(0, this.maxLength) + items.push(({`+${totalSize - this.maxLength}`})) + } + return items.map((item) => ( +
    +
    + + {{ description }} + +
    +
    + + {{ owner }} 发布在 {{ href }} + {{ updateAt | moment }} +
    +
  2. {item}
  3. + )) + } + } +} + +AvatarList.install = function (Vue) { + Vue.component(AvatarList.name, AvatarList) + Vue.component(AvatarList.Item.name, AvatarList.Item) +} + +export default AvatarList diff --git a/quantdinger_vue/src/components/AvatarList/index.js b/quantdinger_vue/src/components/AvatarList/index.js new file mode 100644 index 0000000..b047432 --- /dev/null +++ b/quantdinger_vue/src/components/AvatarList/index.js @@ -0,0 +1,9 @@ +import AvatarList from './List' +import Item from './Item' + +export { + AvatarList, + Item as AvatarListItem +} + +export default AvatarList diff --git a/quantdinger_vue/src/components/AvatarList/index.less b/quantdinger_vue/src/components/AvatarList/index.less new file mode 100644 index 0000000..87cc4b5 --- /dev/null +++ b/quantdinger_vue/src/components/AvatarList/index.less @@ -0,0 +1,59 @@ +@import '../index'; + +@avatar-list-prefix-cls: ~"@{ant-pro-prefix}-avatar-list"; +@avatar-list-item-prefix-cls: ~"@{ant-pro-prefix}-avatar-list-item"; + +.@{avatar-list-prefix-cls} { + display: inline-block; + + ul { + display: inline-block; + padding: 0; + margin: 0 0 0 8px; + font-size: 0; + list-style: none; + } +} + +.@{avatar-list-item-prefix-cls} { + display: inline-block; + width: @avatar-size-base; + height: @avatar-size-base; + margin-left: -8px; + font-size: @font-size-base; + + :global { + .ant-avatar { + cursor: pointer; + border: 1px solid #fff; + } + } + + &.large { + width: @avatar-size-lg; + height: @avatar-size-lg; + } + + &.small { + width: @avatar-size-sm; + height: @avatar-size-sm; + } + + &.mini { + width: 20px; + height: 20px; + + :global { + .ant-avatar { + width: 20px; + height: 20px; + line-height: 20px; + + .ant-avatar-string { + font-size: 12px; + line-height: 18px; + } + } + } + } +} diff --git a/quantdinger_vue/src/components/AvatarList/index.md b/quantdinger_vue/src/components/AvatarList/index.md new file mode 100644 index 0000000..75e022c --- /dev/null +++ b/quantdinger_vue/src/components/AvatarList/index.md @@ -0,0 +1,64 @@ +# AvatarList 用户头像列表 + + +一组用户头像,常用在项目/团队成员列表。可通过设置 `size` 属性来指定头像大小。 + + + +引用方式: + +```javascript +import AvatarList from '@/components/AvatarList' +const AvatarListItem = AvatarList.Item + +export default { + components: { + AvatarList, + AvatarListItem + } +} +``` + + + +## 代码演示 [demo](https://pro.loacg.com/test/home) + +```html + + + + + +``` +或 +```html + + + + + + + + + +``` + + + +## API + +### AvatarList + +| 参数 | 说明 | 类型 | 默认值 | +| ---------------- | -------- | ---------------------------------- | --------- | +| size | 头像大小 | `large`、`small` 、`mini`, `default` | `default` | +| maxLength | 要显示的最大项目 | number | - | +| excessItemsStyle | 多余的项目风格 | CSSProperties | - | + +### AvatarList.Item + +| 参数 | 说明 | 类型 | 默认值 | +| ---- | ------ | --------- | --- | +| tips | 头像展示文案 | string | - | +| src | 头像图片连接 | string | - | + diff --git a/quantdinger_vue/src/components/Charts/Bar.vue b/quantdinger_vue/src/components/Charts/Bar.vue new file mode 100644 index 0000000..4482845 --- /dev/null +++ b/quantdinger_vue/src/components/Charts/Bar.vue @@ -0,0 +1,62 @@ + + + diff --git a/quantdinger_vue/src/components/Charts/ChartCard.vue b/quantdinger_vue/src/components/Charts/ChartCard.vue new file mode 100644 index 0000000..fc1f425 --- /dev/null +++ b/quantdinger_vue/src/components/Charts/ChartCard.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/quantdinger_vue/src/components/Charts/Liquid.vue b/quantdinger_vue/src/components/Charts/Liquid.vue new file mode 100644 index 0000000..4019fb1 --- /dev/null +++ b/quantdinger_vue/src/components/Charts/Liquid.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/quantdinger_vue/src/components/Charts/MiniArea.vue b/quantdinger_vue/src/components/Charts/MiniArea.vue new file mode 100644 index 0000000..58fe92c --- /dev/null +++ b/quantdinger_vue/src/components/Charts/MiniArea.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/quantdinger_vue/src/components/Charts/MiniBar.vue b/quantdinger_vue/src/components/Charts/MiniBar.vue new file mode 100644 index 0000000..beac404 --- /dev/null +++ b/quantdinger_vue/src/components/Charts/MiniBar.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/quantdinger_vue/src/components/Charts/MiniProgress.vue b/quantdinger_vue/src/components/Charts/MiniProgress.vue new file mode 100644 index 0000000..e691363 --- /dev/null +++ b/quantdinger_vue/src/components/Charts/MiniProgress.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/quantdinger_vue/src/components/Charts/MiniSmoothArea.vue b/quantdinger_vue/src/components/Charts/MiniSmoothArea.vue new file mode 100644 index 0000000..e5455c2 --- /dev/null +++ b/quantdinger_vue/src/components/Charts/MiniSmoothArea.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/quantdinger_vue/src/components/Charts/Radar.vue b/quantdinger_vue/src/components/Charts/Radar.vue new file mode 100644 index 0000000..5ee88ad --- /dev/null +++ b/quantdinger_vue/src/components/Charts/Radar.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/quantdinger_vue/src/components/Charts/RankList.vue b/quantdinger_vue/src/components/Charts/RankList.vue new file mode 100644 index 0000000..afb56a1 --- /dev/null +++ b/quantdinger_vue/src/components/Charts/RankList.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/quantdinger_vue/src/components/Charts/TagCloud.vue b/quantdinger_vue/src/components/Charts/TagCloud.vue new file mode 100644 index 0000000..74d1b3f --- /dev/null +++ b/quantdinger_vue/src/components/Charts/TagCloud.vue @@ -0,0 +1,113 @@ + + + diff --git a/quantdinger_vue/src/components/Charts/TransferBar.vue b/quantdinger_vue/src/components/Charts/TransferBar.vue new file mode 100644 index 0000000..7f96f0b --- /dev/null +++ b/quantdinger_vue/src/components/Charts/TransferBar.vue @@ -0,0 +1,64 @@ + + + diff --git a/quantdinger_vue/src/components/Charts/Trend.vue b/quantdinger_vue/src/components/Charts/Trend.vue new file mode 100644 index 0000000..2dce37e --- /dev/null +++ b/quantdinger_vue/src/components/Charts/Trend.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/quantdinger_vue/src/components/Charts/chart.less b/quantdinger_vue/src/components/Charts/chart.less new file mode 100644 index 0000000..f79e17d --- /dev/null +++ b/quantdinger_vue/src/components/Charts/chart.less @@ -0,0 +1,13 @@ +.antv-chart-mini { + position: relative; + width: 100%; + + .chart-wrapper { + position: absolute; + bottom: -28px; + width: 100%; + + /* margin: 0 -5px; + overflow: hidden; */ + } +} diff --git a/quantdinger_vue/src/components/Charts/smooth.area.less b/quantdinger_vue/src/components/Charts/smooth.area.less new file mode 100644 index 0000000..d31ff2f --- /dev/null +++ b/quantdinger_vue/src/components/Charts/smooth.area.less @@ -0,0 +1,14 @@ +@import '../index'; + +@smoothArea-prefix-cls: ~"@{ant-pro-prefix}-smooth-area"; + +.@{smoothArea-prefix-cls} { + position: relative; + width: 100%; + + .chart-wrapper { + position: absolute; + bottom: -28px; + width: 100%; + } +} diff --git a/quantdinger_vue/src/components/Dialog.js b/quantdinger_vue/src/components/Dialog.js new file mode 100644 index 0000000..78e95b2 --- /dev/null +++ b/quantdinger_vue/src/components/Dialog.js @@ -0,0 +1,113 @@ +import Modal from 'ant-design-vue/es/modal' +export default (Vue) => { + function dialog (component, componentProps, modalProps) { + const _vm = this + modalProps = modalProps || {} + if (!_vm || !_vm._isVue) { + return + } + let dialogDiv = document.querySelector('body>div[type=dialog]') + if (!dialogDiv) { + dialogDiv = document.createElement('div') + dialogDiv.setAttribute('type', 'dialog') + document.body.appendChild(dialogDiv) + } + + const handle = function (checkFunction, afterHandel) { + if (checkFunction instanceof Function) { + const res = checkFunction() + if (res instanceof Promise) { + res.then(c => { + c && afterHandel() + }) + } else { + res && afterHandel() + } + } else { + // checkFunction && afterHandel() + checkFunction || afterHandel() + } + } + + const dialogInstance = new Vue({ + data () { + return { + visible: true + } + }, + router: _vm.$router, + store: _vm.$store, + mounted () { + this.$on('close', (v) => { + this.handleClose() + }) + }, + methods: { + handleClose () { + handle(this.$refs._component.onCancel, () => { + this.visible = false + this.$refs._component.$emit('close') + this.$refs._component.$emit('cancel') + dialogInstance.$destroy() + }) + }, + handleOk () { + handle(this.$refs._component.onOK || this.$refs._component.onOk, () => { + this.visible = false + this.$refs._component.$emit('close') + this.$refs._component.$emit('ok') + dialogInstance.$destroy() + }) + } + }, + render: function (h) { + const that = this + const modalModel = modalProps && modalProps.model + if (modalModel) { + delete modalProps.model + } + const ModalProps = Object.assign({}, modalModel && { model: modalModel } || {}, { + attrs: Object.assign({}, { + ...(modalProps.attrs || modalProps) + }, { + visible: this.visible + }), + on: Object.assign({}, { + ...(modalProps.on || modalProps) + }, { + ok: () => { + that.handleOk() + }, + cancel: () => { + that.handleClose() + } + }) + }) + + const componentModel = componentProps && componentProps.model + if (componentModel) { + delete componentProps.model + } + const ComponentProps = Object.assign({}, componentModel && { model: componentModel } || {}, { + ref: '_component', + attrs: Object.assign({}, { + ...((componentProps && componentProps.attrs) || componentProps) + }), + on: Object.assign({}, { + ...((componentProps && componentProps.on) || componentProps) + }) + }) + + return h(Modal, ModalProps, [h(component, ComponentProps)]) + } + }).$mount(dialogDiv) + } + + Object.defineProperty(Vue.prototype, '$dialog', { + get: () => { + return function () { + dialog.apply(this, arguments) + } + } + }) +} diff --git a/quantdinger_vue/src/components/Editor/QuillEditor.vue b/quantdinger_vue/src/components/Editor/QuillEditor.vue new file mode 100644 index 0000000..a9610ee --- /dev/null +++ b/quantdinger_vue/src/components/Editor/QuillEditor.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/quantdinger_vue/src/components/Editor/WangEditor.vue b/quantdinger_vue/src/components/Editor/WangEditor.vue new file mode 100644 index 0000000..d9c71c7 --- /dev/null +++ b/quantdinger_vue/src/components/Editor/WangEditor.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/quantdinger_vue/src/components/Ellipsis/Ellipsis.vue b/quantdinger_vue/src/components/Ellipsis/Ellipsis.vue new file mode 100644 index 0000000..5d59200 --- /dev/null +++ b/quantdinger_vue/src/components/Ellipsis/Ellipsis.vue @@ -0,0 +1,64 @@ + diff --git a/quantdinger_vue/src/components/Ellipsis/index.js b/quantdinger_vue/src/components/Ellipsis/index.js new file mode 100644 index 0000000..91e3ff4 --- /dev/null +++ b/quantdinger_vue/src/components/Ellipsis/index.js @@ -0,0 +1,3 @@ +import Ellipsis from './Ellipsis' + +export default Ellipsis diff --git a/quantdinger_vue/src/components/Ellipsis/index.md b/quantdinger_vue/src/components/Ellipsis/index.md new file mode 100644 index 0000000..f528ac7 --- /dev/null +++ b/quantdinger_vue/src/components/Ellipsis/index.md @@ -0,0 +1,38 @@ +# Ellipsis 文本自动省略号 + +文本过长自动处理省略号,支持按照文本长度和最大行数两种方式截取。 + + + +引用方式: + +```javascript +import Ellipsis from '@/components/Ellipsis' + +export default { + components: { + Ellipsis + } +} +``` + + + +## 代码演示 [demo](https://pro.loacg.com/test/home) + +```html + + There were injuries alleged in three cases in 2015, and a + fourth incident in September, according to the safety recall report. After meeting with US regulators in October, the firm decided to issue a voluntary recall. + +``` + + + +## API + + +参数 | 说明 | 类型 | 默认值 +----|------|-----|------ +tooltip | 移动到文本展示完整内容的提示 | boolean | - +length | 在按照长度截取下的文本最大字符数,超过则截取省略 | number | - \ No newline at end of file diff --git a/quantdinger_vue/src/components/FooterToolbar/FooterToolBar.vue b/quantdinger_vue/src/components/FooterToolbar/FooterToolBar.vue new file mode 100644 index 0000000..ea07123 --- /dev/null +++ b/quantdinger_vue/src/components/FooterToolbar/FooterToolBar.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/quantdinger_vue/src/components/FooterToolbar/index.js b/quantdinger_vue/src/components/FooterToolbar/index.js new file mode 100644 index 0000000..a0bf145 --- /dev/null +++ b/quantdinger_vue/src/components/FooterToolbar/index.js @@ -0,0 +1,4 @@ +import FooterToolBar from './FooterToolBar' +import './index.less' + +export default FooterToolBar diff --git a/quantdinger_vue/src/components/FooterToolbar/index.less b/quantdinger_vue/src/components/FooterToolbar/index.less new file mode 100644 index 0000000..3f49436 --- /dev/null +++ b/quantdinger_vue/src/components/FooterToolbar/index.less @@ -0,0 +1,23 @@ +@import '../index'; + +@footer-toolbar-prefix-cls: ~"@{ant-pro-prefix}-footer-toolbar"; + +.@{footer-toolbar-prefix-cls} { + position: fixed; + right: 0; + bottom: 0; + z-index: 9; + width: 100%; + height: 56px; + padding: 0 24px; + line-height: 56px; + background: #fff; + box-shadow: 0 -1px 2px rgb(0 0 0 / 3%); + border-top: 1px solid #e8e8e8; + + &::after { + display: block; + clear: both; + content: ''; + } +} diff --git a/quantdinger_vue/src/components/FooterToolbar/index.md b/quantdinger_vue/src/components/FooterToolbar/index.md new file mode 100644 index 0000000..c1aec2c --- /dev/null +++ b/quantdinger_vue/src/components/FooterToolbar/index.md @@ -0,0 +1,48 @@ +# FooterToolbar 底部工具栏 + +固定在底部的工具栏。 + + + +## 何时使用 + +固定在内容区域的底部,不随滚动条移动,常用于长页面的数据搜集和提交工作。 + + + +引用方式: + +```javascript +import FooterToolBar from '@/components/FooterToolbar' + +export default { + components: { + FooterToolBar + } +} +``` + + + +## 代码演示 + +```html + + 提交 + +``` +或 +```html + + 提交 + +``` + + +## API + +参数 | 说明 | 类型 | 默认值 +----|------|-----|------ +children (slot) | 工具栏内容,向右对齐 | - | - +extra | 额外信息,向左对齐 | String, Object | - + diff --git a/quantdinger_vue/src/components/GlobalFooter/index.vue b/quantdinger_vue/src/components/GlobalFooter/index.vue new file mode 100644 index 0000000..564af54 --- /dev/null +++ b/quantdinger_vue/src/components/GlobalFooter/index.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/quantdinger_vue/src/components/GlobalHeader/AvatarDropdown.vue b/quantdinger_vue/src/components/GlobalHeader/AvatarDropdown.vue new file mode 100644 index 0000000..3f76448 --- /dev/null +++ b/quantdinger_vue/src/components/GlobalHeader/AvatarDropdown.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/quantdinger_vue/src/components/GlobalHeader/RightContent.vue b/quantdinger_vue/src/components/GlobalHeader/RightContent.vue new file mode 100644 index 0000000..4fb795c --- /dev/null +++ b/quantdinger_vue/src/components/GlobalHeader/RightContent.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/quantdinger_vue/src/components/IconSelector/IconSelector.vue b/quantdinger_vue/src/components/IconSelector/IconSelector.vue new file mode 100644 index 0000000..810d297 --- /dev/null +++ b/quantdinger_vue/src/components/IconSelector/IconSelector.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/quantdinger_vue/src/components/IconSelector/README.md b/quantdinger_vue/src/components/IconSelector/README.md new file mode 100644 index 0000000..503095d --- /dev/null +++ b/quantdinger_vue/src/components/IconSelector/README.md @@ -0,0 +1,48 @@ +IconSelector +==== + +> 图标选择组件,常用于为某一个数据设定一个图标时使用 +> eg: 设定菜单列表时,为每个菜单设定一个图标 + +该组件由 [@Saraka](https://github.com/saraka-tsukai) 封装 + + + +### 使用方式 + +```vue + + + +``` + + + +### 事件 + + +| 名称 | 说明 | 类型 | 默认值 | +| ------ | -------------------------- | ------ | ------ | +| change | 当改变了 `icon` 选中项触发 | String | - | diff --git a/quantdinger_vue/src/components/IconSelector/icons.js b/quantdinger_vue/src/components/IconSelector/icons.js new file mode 100644 index 0000000..920f464 --- /dev/null +++ b/quantdinger_vue/src/components/IconSelector/icons.js @@ -0,0 +1,36 @@ +/** + * 增加新的图标时,请遵循以下数据结构 + * Adding new icon please follow the data structure below + */ +export default [ + { + key: 'directional', + title: '方向性图标', + icons: ['step-backward', 'step-forward', 'fast-backward', 'fast-forward', 'shrink', 'arrows-alt', 'down', 'up', 'left', 'right', 'caret-up', 'caret-down', 'caret-left', 'caret-right', 'up-circle', 'down-circle', 'left-circle', 'right-circle', 'double-right', 'double-left', 'vertical-left', 'vertical-right', 'forward', 'backward', 'rollback', 'enter', 'retweet', 'swap', 'swap-left', 'swap-right', 'arrow-up', 'arrow-down', 'arrow-left', 'arrow-right', 'play-circle', 'up-square', 'down-square', 'left-square', 'right-square', 'login', 'logout', 'menu-fold', 'menu-unfold', 'border-bottom', 'border-horizontal', 'border-inner', 'border-left', 'border-right', 'border-top', 'border-verticle', 'pic-center', 'pic-left', 'pic-right', 'radius-bottomleft', 'radius-bottomright', 'radius-upleft', 'fullscreen', 'fullscreen-exit'] + }, + { + key: 'suggested', + title: '提示建议性图标', + icons: ['question', 'question-circle', 'plus', 'plus-circle', 'pause', 'pause-circle', 'minus', 'minus-circle', 'plus-square', 'minus-square', 'info', 'info-circle', 'exclamation', 'exclamation-circle', 'close', 'close-circle', 'close-square', 'check', 'check-circle', 'check-square', 'clock-circle', 'warning', 'issues-close', 'stop'] + }, + { + key: 'editor', + title: '编辑类图标', + icons: ['edit', 'form', 'copy', 'scissor', 'delete', 'snippets', 'diff', 'highlight', 'align-center', 'align-left', 'align-right', 'bg-colors', 'bold', 'italic', 'underline', 'strikethrough', 'redo', 'undo', 'zoom-in', 'zoom-out', 'font-colors', 'font-size', 'line-height', 'colum-height', 'dash', 'small-dash', 'sort-ascending', 'sort-descending', 'drag', 'ordered-list', 'radius-setting'] + }, + { + key: 'data', + title: '数据类图标', + icons: ['area-chart', 'pie-chart', 'bar-chart', 'dot-chart', 'line-chart', 'radar-chart', 'heat-map', 'fall', 'rise', 'stock', 'box-plot', 'fund', 'sliders'] + }, + { + key: 'brand_logo', + title: '网站通用图标', + icons: ['lock', 'unlock', 'bars', 'book', 'calendar', 'cloud', 'cloud-download', 'code', 'copy', 'credit-card', 'delete', 'desktop', 'download', 'ellipsis', 'file', 'file-text', 'file-unknown', 'file-pdf', 'file-word', 'file-excel', 'file-jpg', 'file-ppt', 'file-markdown', 'file-add', 'folder', 'folder-open', 'folder-add', 'hdd', 'frown', 'meh', 'smile', 'inbox', 'laptop', 'appstore', 'link', 'mail', 'mobile', 'notification', 'paper-clip', 'picture', 'poweroff', 'reload', 'search', 'setting', 'share-alt', 'shopping-cart', 'tablet', 'tag', 'tags', 'to-top', 'upload', 'user', 'video-camera', 'home', 'loading', 'loading-3-quarters', 'cloud-upload', 'star', 'heart', 'environment', 'eye', 'camera', 'save', 'team', 'solution', 'phone', 'filter', 'exception', 'export', 'customer-service', 'qrcode', 'scan', 'like', 'dislike', 'message', 'pay-circle', 'calculator', 'pushpin', 'bulb', 'select', 'switcher', 'rocket', 'bell', 'disconnect', 'database', 'compass', 'barcode', 'hourglass', 'key', 'flag', 'layout', 'printer', 'sound', 'usb', 'skin', 'tool', 'sync', 'wifi', 'car', 'schedule', 'user-add', 'user-delete', 'usergroup-add', 'usergroup-delete', 'man', 'woman', 'shop', 'gift', 'idcard', 'medicine-box', 'red-envelope', 'coffee', 'copyright', 'trademark', 'safety', 'wallet', 'bank', 'trophy', 'contacts', 'global', 'shake', 'api', 'fork', 'dashboard', 'table', 'profile', 'alert', 'audit', 'branches', 'build', 'border', 'crown', 'experiment', 'fire', 'money-collect', 'property-safety', 'read', 'reconciliation', 'rest', 'security-scan', 'insurance', 'interation', 'safety-certificate', 'project', 'thunderbolt', 'block', 'cluster', 'deployment-unit', 'dollar', 'euro', 'pound', 'file-done', 'file-exclamation', 'file-protect', 'file-search', 'file-sync', 'gateway', 'gold', 'robot', 'shopping'] + }, + { + key: 'application', + title: '品牌和标识', + icons: ['android', 'apple', 'windows', 'ie', 'chrome', 'github', 'aliwangwang', 'dingding', 'weibo-square', 'weibo-circle', 'taobao-circle', 'html5', 'weibo', 'twitter', 'wechat', 'youtube', 'alipay-circle', 'taobao', 'skype', 'qq', 'medium-workmark', 'gitlab', 'medium', 'linkedin', 'google-plus', 'dropbox', 'facebook', 'codepen', 'code-sandbox', 'amazon', 'google', 'codepen-circle', 'alipay', 'ant-design', 'aliyun', 'zhihu', 'slack', 'slack-square', 'behance', 'behance-square', 'dribbble', 'dribbble-square', 'instagram', 'yuque', 'alibaba', 'yahoo'] + } +] diff --git a/quantdinger_vue/src/components/IconSelector/index.js b/quantdinger_vue/src/components/IconSelector/index.js new file mode 100644 index 0000000..2d27d70 --- /dev/null +++ b/quantdinger_vue/src/components/IconSelector/index.js @@ -0,0 +1,2 @@ +import IconSelector from './IconSelector' +export default IconSelector diff --git a/quantdinger_vue/src/components/MultiTab/MultiTab.vue b/quantdinger_vue/src/components/MultiTab/MultiTab.vue new file mode 100644 index 0000000..fe2c3f7 --- /dev/null +++ b/quantdinger_vue/src/components/MultiTab/MultiTab.vue @@ -0,0 +1,161 @@ + diff --git a/quantdinger_vue/src/components/MultiTab/events.js b/quantdinger_vue/src/components/MultiTab/events.js new file mode 100644 index 0000000..b0230b5 --- /dev/null +++ b/quantdinger_vue/src/components/MultiTab/events.js @@ -0,0 +1,2 @@ +import Vue from 'vue' +export default new Vue() diff --git a/quantdinger_vue/src/components/MultiTab/index.js b/quantdinger_vue/src/components/MultiTab/index.js new file mode 100644 index 0000000..02a1c77 --- /dev/null +++ b/quantdinger_vue/src/components/MultiTab/index.js @@ -0,0 +1,40 @@ +import events from './events' +import MultiTab from './MultiTab' +import './index.less' + +const api = { + /** + * open new tab on route fullPath + * @param config + */ + open: function (config) { + events.$emit('open', config) + }, + rename: function (key, name) { + events.$emit('rename', { key: key, name: name }) + }, + /** + * close current page + */ + closeCurrentPage: function () { + this.close() + }, + /** + * close route fullPath tab + * @param config + */ + close: function (config) { + events.$emit('close', config) + } +} + +MultiTab.install = function (Vue) { + if (Vue.prototype.$multiTab) { + return + } + api.instance = events + Vue.prototype.$multiTab = api + Vue.component('multi-tab', MultiTab) +} + +export default MultiTab diff --git a/quantdinger_vue/src/components/MultiTab/index.less b/quantdinger_vue/src/components/MultiTab/index.less new file mode 100644 index 0000000..d9d9fa6 --- /dev/null +++ b/quantdinger_vue/src/components/MultiTab/index.less @@ -0,0 +1,25 @@ +@import '../index'; + +@multi-tab-prefix-cls: ~"@{ant-pro-prefix}-multi-tab"; +@multi-tab-wrapper-prefix-cls: ~"@{ant-pro-prefix}-multi-tab-wrapper"; + +/* +.topmenu .@{multi-tab-prefix-cls} { + max-width: 1200px; + margin: -23px auto 24px auto; +} +*/ +.@{multi-tab-prefix-cls} { + margin: -23px -24px 24px; + background: #fff; +} + +.topmenu .@{multi-tab-wrapper-prefix-cls} { + max-width: 1200px; + margin: 0 auto; +} + +.topmenu.content-width-Fluid .@{multi-tab-wrapper-prefix-cls} { + max-width: 100%; + margin: 0 auto; +} diff --git a/quantdinger_vue/src/components/NProgress/nprogress.less b/quantdinger_vue/src/components/NProgress/nprogress.less new file mode 100644 index 0000000..6593834 --- /dev/null +++ b/quantdinger_vue/src/components/NProgress/nprogress.less @@ -0,0 +1,70 @@ +@import url('../index.less'); + +/* Make clicks pass-through */ +#nprogress { + pointer-events: none; +} + +#nprogress .bar { + position: fixed; + top: 0; + left: 0; + z-index: 1031; + width: 100%; + height: 2px; + background: @primary-color; +} + +/* Fancy blur effect */ +#nprogress .peg { + position: absolute; + right: 0; + display: block; + width: 100px; + height: 100%; + opacity: 1; + transform: rotate(3deg) translate(0, -4px); + transform: rotate(3deg) translate(0, -4px); + transform: rotate(3deg) translate(0, -4px); + box-shadow: 0 0 10px @primary-color, 0 0 5px @primary-color; +} + +/* Remove these to get rid of the spinner */ +#nprogress .spinner { + position: fixed; + top: 15px; + right: 15px; + z-index: 1031; + display: block; +} + +#nprogress .spinner-icon { + width: 18px; + height: 18px; + box-sizing: border-box; + border: solid 2px transparent; + border-top-color: @primary-color; + border-left-color: @primary-color; + border-radius: 50%; + animation: nprogress-spinner 400ms linear infinite; + animation: nprogress-spinner 400ms linear infinite; +} + +.nprogress-custom-parent { + position: relative; + overflow: hidden; +} + +.nprogress-custom-parent #nprogress .spinner, +.nprogress-custom-parent #nprogress .bar { + position: absolute; +} + +@keyframes nprogress-spinner { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} +@keyframes nprogress-spinner { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} diff --git a/quantdinger_vue/src/components/NoticeIcon/NoticeIcon.vue b/quantdinger_vue/src/components/NoticeIcon/NoticeIcon.vue new file mode 100644 index 0000000..8ae1c80 --- /dev/null +++ b/quantdinger_vue/src/components/NoticeIcon/NoticeIcon.vue @@ -0,0 +1,90 @@ + + + + + + diff --git a/quantdinger_vue/src/components/NoticeIcon/index.js b/quantdinger_vue/src/components/NoticeIcon/index.js new file mode 100644 index 0000000..659b9ec --- /dev/null +++ b/quantdinger_vue/src/components/NoticeIcon/index.js @@ -0,0 +1,2 @@ +import NoticeIcon from './NoticeIcon' +export default NoticeIcon diff --git a/quantdinger_vue/src/components/NumberInfo/NumberInfo.vue b/quantdinger_vue/src/components/NumberInfo/NumberInfo.vue new file mode 100644 index 0000000..bdde3e0 --- /dev/null +++ b/quantdinger_vue/src/components/NumberInfo/NumberInfo.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/quantdinger_vue/src/components/NumberInfo/index.js b/quantdinger_vue/src/components/NumberInfo/index.js new file mode 100644 index 0000000..659a2f3 --- /dev/null +++ b/quantdinger_vue/src/components/NumberInfo/index.js @@ -0,0 +1,3 @@ +import NumberInfo from './NumberInfo' + +export default NumberInfo diff --git a/quantdinger_vue/src/components/NumberInfo/index.less b/quantdinger_vue/src/components/NumberInfo/index.less new file mode 100644 index 0000000..82cbd7e --- /dev/null +++ b/quantdinger_vue/src/components/NumberInfo/index.less @@ -0,0 +1,55 @@ +@import '../index'; + +@numberInfo-prefix-cls: ~"@{ant-pro-prefix}-number-info"; + +.@{numberInfo-prefix-cls} { + .ant-pro-number-info-subtitle { + height: 22px; + overflow: hidden; + font-size: @font-size-base; + line-height: 22px; + color: @text-color-secondary; + text-overflow: ellipsis; + word-break: break-all; + white-space: nowrap; + } + + .number-info-value { + margin-top: 4px; + overflow: hidden; + font-size: 0; + text-overflow: ellipsis; + word-break: break-all; + white-space: nowrap; + + & > span { + display: inline-block; + height: 32px; + margin-right: 32px; + font-size: 24px; + line-height: 32px; + color: @heading-color; + } + + .sub-total { + margin-right: 0; + font-size: @font-size-lg; + color: @text-color-secondary; + vertical-align: top; + + i { + margin-left: 4px; + font-size: 12px; + transform: scale(.82); + } + + .anticon-caret-up { + color: @red-6; + } + + .anticon-caret-down { + color: @green-6; + } + } + } +} diff --git a/quantdinger_vue/src/components/NumberInfo/index.md b/quantdinger_vue/src/components/NumberInfo/index.md new file mode 100644 index 0000000..147adc4 --- /dev/null +++ b/quantdinger_vue/src/components/NumberInfo/index.md @@ -0,0 +1,43 @@ +# NumberInfo 数据文本 + +常用在数据卡片中,用于突出展示某个业务数据。 + + + +引用方式: + +```javascript +import NumberInfo from '@/components/NumberInfo' + +export default { + components: { + NumberInfo + } +} +``` + + + +## 代码演示 [demo](https://pro.loacg.com/test/home) + +```html + +``` + + + +## API + +参数 | 说明 | 类型 | 默认值 +----|------|-----|------ +title | 标题 | ReactNode\|string | - +subTitle | 子标题 | ReactNode\|string | - +total | 总量 | ReactNode\|string | - +subTotal | 子总量 | ReactNode\|string | - +status | 增加状态 | 'up \| down' | - +theme | 状态样式 | string | 'light' +gap | 设置数字和描述之间的间距(像素)| number | 8 diff --git a/quantdinger_vue/src/components/Other/CarbonAds.vue b/quantdinger_vue/src/components/Other/CarbonAds.vue new file mode 100644 index 0000000..32099c6 --- /dev/null +++ b/quantdinger_vue/src/components/Other/CarbonAds.vue @@ -0,0 +1,62 @@ + + + diff --git a/quantdinger_vue/src/components/PageLoading/index.jsx b/quantdinger_vue/src/components/PageLoading/index.jsx new file mode 100644 index 0000000..af6d6d6 --- /dev/null +++ b/quantdinger_vue/src/components/PageLoading/index.jsx @@ -0,0 +1,106 @@ +import { Spin } from 'ant-design-vue' + +export const PageLoading = { + name: 'PageLoading', + props: { + tip: { + type: String, + default: 'Loading..' + }, + size: { + type: String, + default: 'large' + } + }, + render () { + const style = { + textAlign: 'center', + background: 'rgba(0,0,0,0.6)', + position: 'fixed', + top: 0, + bottom: 0, + left: 0, + right: 0, + zIndex: 1100 + } + const spinStyle = { + position: 'absolute', + left: '50%', + top: '40%', + transform: 'translate(-50%, -50%)' + } + return (
    + +
    ) + } +} + +const version = '0.0.1' +const loading = {} + +loading.newInstance = (Vue, options) => { + let loadingElement = document.querySelector('body>div[type=loading]') + if (!loadingElement) { + loadingElement = document.createElement('div') + loadingElement.setAttribute('type', 'loading') + loadingElement.setAttribute('class', 'ant-loading-wrapper') + document.body.appendChild(loadingElement) + } + + const cdProps = Object.assign({ visible: false, size: 'large', tip: 'Loading...' }, options) + + const instance = new Vue({ + data () { + return { + ...cdProps + } + }, + render () { + const { tip } = this + const props = {} + this.tip && (props.tip = tip) + if (this.visible) { + return + } + return null + } + }).$mount(loadingElement) + + function update (config) { + const { visible, size, tip } = { ...cdProps, ...config } + instance.$set(instance, 'visible', visible) + if (tip) { + instance.$set(instance, 'tip', tip) + } + if (size) { + instance.$set(instance, 'size', size) + } + } + + return { + instance, + update + } +} + +const api = { + show: function (options) { + this.instance.update({ ...options, visible: true }) + }, + hide: function () { + this.instance.update({ visible: false }) + } +} + +const install = function (Vue, options) { + if (Vue.prototype.$loading) { + return + } + api.instance = loading.newInstance(Vue, options) + Vue.prototype.$loading = api +} + +export default { + version, + install +} diff --git a/quantdinger_vue/src/components/Search/GlobalSearch.jsx b/quantdinger_vue/src/components/Search/GlobalSearch.jsx new file mode 100644 index 0000000..13bfa56 --- /dev/null +++ b/quantdinger_vue/src/components/Search/GlobalSearch.jsx @@ -0,0 +1,62 @@ +import { Select } from 'ant-design-vue' +import './index.less' + +const GlobalSearch = { + name: 'GlobalSearch', + data () { + return { + visible: false + } + }, + mounted () { + const keyboardHandle = (e) => { + e.preventDefault() + e.stopPropagation() + const { ctrlKey, shiftKey, altKey, keyCode } = e + // key is `K` and hold ctrl + if (keyCode === 75 && ctrlKey && !shiftKey && !altKey) { + this.visible = !this.visible + } + } + document.addEventListener('keydown', keyboardHandle) + }, + render () { + const { visible } = this + const handleSearch = (e) => { + this.$emit('search', e) + } + + const handleChange = (e) => { + this.$emit('change', e) + } + if (!visible) { + return null + } + return ( + + ) + } +} + +GlobalSearch.install = function (Vue) { + Vue.component(GlobalSearch.name, GlobalSearch) +} + +export default GlobalSearch diff --git a/quantdinger_vue/src/components/Search/index.less b/quantdinger_vue/src/components/Search/index.less new file mode 100644 index 0000000..f8b51b5 --- /dev/null +++ b/quantdinger_vue/src/components/Search/index.less @@ -0,0 +1,25 @@ +@import '~ant-design-vue/es/style/themes/default'; + +.global-search-wrapper { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: @zindex-modal-mask; + background: @modal-mask-bg; + + .global-search-box { + position: absolute; + top: 20%; + left: 50%; + width: 450px; + transform: translate(-50%, -50%); + + .global-search-tips { + font-size: @font-size-lg; + color: @white; + text-align: right; + } + } +} diff --git a/quantdinger_vue/src/components/SelectLang/index.jsx b/quantdinger_vue/src/components/SelectLang/index.jsx new file mode 100644 index 0000000..f0010e9 --- /dev/null +++ b/quantdinger_vue/src/components/SelectLang/index.jsx @@ -0,0 +1,70 @@ +import './index.less' + +import { Icon, Menu, Dropdown } from 'ant-design-vue' +import { i18nRender } from '@/locales' +import i18nMixin from '@/store/i18n-mixin' + +const locales = ['en-US', 'ja-JP', 'ko-KR', 'vi-VN', 'th-TH', 'ar-SA', 'fr-FR', 'de-DE', 'zh-TW'] +const languageLabels = { + // 'zh-CN': '简体中文', + 'zh-TW': '繁體中文', + 'en-US': 'English', + 'ja-JP': '日本語', + 'ko-KR': '한국어', + 'vi-VN': 'Tiếng Việt', + 'th-TH': 'ไทย', + 'ar-SA': 'العربية', + 'fr-FR': 'Français', + 'de-DE': 'Deutsch' +} +// eslint-disable-next-line +const languageIcons = { + // 'zh-CN': '🇨🇳', + 'zh-TW': 'sg', + 'en-US': '🇺🇸', + 'ja-JP': '🇯🇵', + 'ko-KR': '🇰🇷', + 'vi-VN': '🇻🇳', + 'th-TH': '🇹🇭', + 'ar-SA': '🇸🇦', + 'fr-FR': '🇫🇷', + 'de-DE': '🇩🇪' +} + +const SelectLang = { + props: { + prefixCls: { + type: String, + default: 'ant-pro-drop-down' + } + }, + name: 'SelectLang', + mixins: [i18nMixin], + render () { + const { prefixCls } = this + const changeLang = ({ key }) => { + this.setLang(key) + } + const langMenu = ( + + {locales.map(locale => ( + + + {languageIcons[locale]} + {' '} + {languageLabels[locale]} + + ))} + + ) + return ( + + + + + + ) + } +} + +export default SelectLang diff --git a/quantdinger_vue/src/components/SelectLang/index.less b/quantdinger_vue/src/components/SelectLang/index.less new file mode 100644 index 0000000..a71f69c --- /dev/null +++ b/quantdinger_vue/src/components/SelectLang/index.less @@ -0,0 +1,30 @@ +@import '~ant-design-vue/es/style/themes/default'; + +@header-menu-prefix-cls: ~'@{ant-prefix}-pro-header-menu'; +@header-drop-down-prefix-cls: ~'@{ant-prefix}-pro-drop-down'; + +.@{header-menu-prefix-cls} { + .anticon { + margin-right: 8px; + } + + .ant-dropdown-menu-item { + min-width: 160px; + } +} + +.@{header-drop-down-prefix-cls} { + line-height: @layout-header-height; + vertical-align: top; + cursor: pointer; + + > i { + font-size: 16px !important; + transform: none !important; + + svg { + position: relative; + top: -1px; + } + } +} diff --git a/quantdinger_vue/src/components/SettingDrawer/SettingDrawer.vue b/quantdinger_vue/src/components/SettingDrawer/SettingDrawer.vue new file mode 100644 index 0000000..7a19873 --- /dev/null +++ b/quantdinger_vue/src/components/SettingDrawer/SettingDrawer.vue @@ -0,0 +1,355 @@ + + + + + diff --git a/quantdinger_vue/src/components/SettingDrawer/SettingItem.vue b/quantdinger_vue/src/components/SettingDrawer/SettingItem.vue new file mode 100644 index 0000000..2b3b553 --- /dev/null +++ b/quantdinger_vue/src/components/SettingDrawer/SettingItem.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/quantdinger_vue/src/components/SettingDrawer/index.js b/quantdinger_vue/src/components/SettingDrawer/index.js new file mode 100644 index 0000000..8260f2d --- /dev/null +++ b/quantdinger_vue/src/components/SettingDrawer/index.js @@ -0,0 +1,2 @@ +import SettingDrawer from './SettingDrawer' +export default SettingDrawer diff --git a/quantdinger_vue/src/components/SettingDrawer/settingConfig.js b/quantdinger_vue/src/components/SettingDrawer/settingConfig.js new file mode 100644 index 0000000..9e715d7 --- /dev/null +++ b/quantdinger_vue/src/components/SettingDrawer/settingConfig.js @@ -0,0 +1,53 @@ +import message from 'ant-design-vue/es/message' +// import defaultSettings from '../defaultSettings'; +import themeColor from './themeColor.js' +import i18n from '@/locales' + +// let lessNodesAppended +const getColorList = () => { + return [ + { + key: i18n.t('app.setting.themecolor.dust'), color: '#F5222D' + }, + { + key: i18n.t('app.setting.themecolor.volcano'), color: '#FA541C' + }, + { + key: i18n.t('app.setting.themecolor.sunset'), color: '#FAAD14' + }, + { + key: i18n.t('app.setting.themecolor.cyan'), color: '#13C2C2' + }, + { + key: i18n.t('app.setting.themecolor.green'), color: '#52C41A' + }, + { + key: i18n.t('app.setting.themecolor.daybreak'), color: '#1890FF' + }, + { + key: i18n.t('app.setting.themecolor.geekblue'), color: '#2F54EB' + }, + { + key: i18n.t('app.setting.themecolor.purple'), color: '#722ED1' + } + ] +} + +const updateTheme = (newPrimaryColor, silent = false) => { + const hideMessage = silent ? null : message.loading(i18n.t('app.setting.theme.switching'), 0) + themeColor.changeColor(newPrimaryColor).finally(() => { + if (hideMessage) { + setTimeout(() => { + hideMessage() + }, 10) + } + }) +} + +const updateColorWeak = colorWeak => { + // document.body.className = colorWeak ? 'colorWeak' : ''; + const app = document.body.querySelector('#app') + colorWeak ? app.classList.add('colorWeak') : app.classList.remove('colorWeak') +} + +export { updateTheme, getColorList, updateColorWeak } diff --git a/quantdinger_vue/src/components/SettingDrawer/themeColor.js b/quantdinger_vue/src/components/SettingDrawer/themeColor.js new file mode 100644 index 0000000..68f1369 --- /dev/null +++ b/quantdinger_vue/src/components/SettingDrawer/themeColor.js @@ -0,0 +1,36 @@ +import client from 'webpack-theme-color-replacer/client' +import generate from '@ant-design/colors/lib/generate' + +export default { + getAntdSerials (color) { + // 淡化(即less的tint) + const lightens = new Array(9).fill().map((t, i) => { + return client.varyColor.lighten(color, i / 10) + }) + // colorPalette变换得到颜色值 + const colorPalettes = generate(color) + const rgb = client.varyColor.toNum3(color.replace('#', '')).join(',') + return lightens.concat(colorPalettes).concat(rgb) + }, + changeColor (newColor) { + // Check if client.changer is available (plugin might not be loaded in some build configs) + if (!client || !client.changer || typeof client.changer.changeColor !== 'function') { + // Return a resolved promise to avoid breaking the calling code + return Promise.resolve() + } + + var options = { + newColors: this.getAntdSerials(newColor), // new colors array, one-to-one corresponde with `matchColors` + changeUrl (cssUrl) { + return `/${cssUrl}` // while router is not `hash` mode, it needs absolute path + } + } + + try { + return client.changer.changeColor(options, Promise) + } catch (error) { + // Return a resolved promise to avoid breaking the calling code + return Promise.resolve() + } + } +} diff --git a/quantdinger_vue/src/components/StandardFormRow/StandardFormRow.vue b/quantdinger_vue/src/components/StandardFormRow/StandardFormRow.vue new file mode 100644 index 0000000..febc684 --- /dev/null +++ b/quantdinger_vue/src/components/StandardFormRow/StandardFormRow.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/quantdinger_vue/src/components/StandardFormRow/index.js b/quantdinger_vue/src/components/StandardFormRow/index.js new file mode 100644 index 0000000..8155cc7 --- /dev/null +++ b/quantdinger_vue/src/components/StandardFormRow/index.js @@ -0,0 +1,3 @@ +import StandardFormRow from './StandardFormRow' + +export default StandardFormRow diff --git a/quantdinger_vue/src/components/Table/README.md b/quantdinger_vue/src/components/Table/README.md new file mode 100644 index 0000000..1d2c9d0 --- /dev/null +++ b/quantdinger_vue/src/components/Table/README.md @@ -0,0 +1,341 @@ +Table 重封装组件说明 +==== + + +封装说明 +---- + +> 基础的使用方式与 API 与 [官方版(Table)](https://vuecomponent.github.io/ant-design-vue/components/table-cn/) 本一致,在其基础上,封装了加载数据的方法。 +> +> 你无需在你是用表格的页面进行分页逻辑处理,仅需向 Table 组件传递绑定 `:data="Promise"` 对象即可 + +该 `table` 由 [@Saraka](https://github.com/saraka-tsukai) 完成封装 + + +例子1 +---- +(基础使用) + +```vue + + + + + +``` + + + +例子2 +---- + +(简单的表格,最后一列是各种操作) + +```vue + + + +``` + + + +内置方法 +---- + +通过 `this.$refs.table` 调用 + +`this.$refs.table.refresh(true)` 刷新列表 (用户新增/修改数据后,重载列表数据) + +> 注意:要调用 `refresh(bool)` 需要给表格组件设定 `ref` 值 +> +> `refresh()` 方法可以传一个 `bool` 值,当有传值 或值为 `true` 时,则刷新时会强制刷新到第一页(常用户页面 搜索 按钮进行搜索时,结果从第一页开始分页) + + +内置属性 +---- +> 除去 `a-table` 自带属性外,还而外提供了一些额外属性属性 + + +| 属性 | 说明 | 类型 | 默认值 | +| -------------- | ----------------------------------------------- | ----------------- | ------ | +| alert | 设置是否显示表格信息栏 | [object, boolean] | null | +| showPagination | 显示分页选择器,可传 'auto' \| boolean | [string, boolean] | 'auto' | +| data | 加载数据方法 必须为 `Promise` 对象 **必须绑定** | Promise | - | + + +`alert` 属性对象: + +```javascript +alert: { + show: Boolean, + clear: [Function, Boolean] +} +``` + +注意事项 +---- + +> 你可能需要为了与后端提供的接口返回结果一致而去修改以下代码: +> (需要注意的是,这里的修改是全局性的,意味着整个项目所有使用该 table 组件都需要遵守这个返回结果定义的字段。) +> +> 文档中的结构有可能由于组件 bug 进行修正而改动。实际修改请以当时最新版本为准 + +修改 `@/components/table/index.js` 第 156 行起 + + + +```javascript +result.then(r => { + this.localPagination = this.showPagination && Object.assign({}, this.localPagination, { + current: r.pageNo, // 返回结果中的当前分页数 + total: r.totalCount, // 返回结果中的总记录数 + showSizeChanger: this.showSizeChanger, + pageSize: (pagination && pagination.pageSize) || + this.localPagination.pageSize + }) || false + // 为防止删除数据后导致页面当前页面数据长度为 0 ,自动翻页到上一页 + if (r.data.length === 0 && this.showPagination && this.localPagination.current > 1) { + this.localPagination.current-- + this.loadData() + return + } + + // 这里用于判断接口是否有返回 r.totalCount 且 this.showPagination = true 且 pageNo 和 pageSize 存在 且 totalCount 小于等于 pageNo * pageSize 的大小 + // 当情况满足时,表示数据不满足分页大小,关闭 table 分页功能 + try { + if ((['auto', true].includes(this.showPagination) && r.totalCount <= (r.pageNo * this.localPagination.pageSize))) { + this.localPagination.hideOnSinglePage = true + } + } catch (e) { + this.localPagination = false + } + console.log('loadData -> this.localPagination', this.localPagination) + this.localDataSource = r.data // 返回结果中的数组数据 + this.localLoading = false + }) +``` +返回 JSON 例子: +```json +{ + "message": "", + "result": { + "data": [{ + id: 1, + cover: 'https://gw.alipayobjects.com/zos/rmsportal/WdGqmHpayyMjiEhcKoVE.png', + title: 'Alipay', + description: '那是一种内在的东西, 他们到达不了,也无法触及的', + status: 1, + updatedAt: '2018-07-26 00:00:00' + }, + { + id: 2, + cover: 'https://gw.alipayobjects.com/zos/rmsportal/zOsKZmFRdUtvpqCImOVY.png', + title: 'Angular', + description: '希望是一个好东西,也许是最好的,好东西是不会消亡的', + status: 1, + updatedAt: '2018-07-26 00:00:00' + }, + { + id: 3, + cover: 'https://gw.alipayobjects.com/zos/rmsportal/dURIMkkrRFpPgTuzkwnB.png', + title: 'Ant Design', + description: '城镇中有那么多的酒馆,她却偏偏走进了我的酒馆', + status: 1, + updatedAt: '2018-07-26 00:00:00' + }, + { + id: 4, + cover: 'https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png', + title: 'Ant Design Pro', + description: '那时候我只会想自己想要什么,从不想自己拥有什么', + status: 1, + updatedAt: '2018-07-26 00:00:00' + }, + { + id: 5, + cover: 'https://gw.alipayobjects.com/zos/rmsportal/siCrBXXhmvTQGWPNLBow.png', + title: 'Bootstrap', + description: '凛冬将至', + status: 1, + updatedAt: '2018-07-26 00:00:00' + }, + { + id: 6, + cover: 'https://gw.alipayobjects.com/zos/rmsportal/ComBAopevLwENQdKWiIn.png', + title: 'Vue', + description: '生命就像一盒巧克力,结果往往出人意料', + status: 1, + updatedAt: '2018-07-26 00:00:00' + } + ], + "pageSize": 10, + "pageNo": 0, + "totalPage": 6, + "totalCount": 57 + }, + "status": 200, + "timestamp": 1534955098193 +} +``` + + + +更新时间 +---- + +该文档最后更新于: 2019-06-23 PM 17:19 \ No newline at end of file diff --git a/quantdinger_vue/src/components/Table/index.js b/quantdinger_vue/src/components/Table/index.js new file mode 100644 index 0000000..a407a41 --- /dev/null +++ b/quantdinger_vue/src/components/Table/index.js @@ -0,0 +1,325 @@ +import T from 'ant-design-vue/es/table/Table' +import get from 'lodash.get' + +export default { + data () { + return { + needTotalList: [], + + selectedRows: [], + selectedRowKeys: [], + + localLoading: false, + localDataSource: [], + localPagination: Object.assign({}, this.pagination), + + // 存储表格onchange时的filters, sorter对象 + filters: {}, + sorter: {} + } + }, + props: Object.assign({}, T.props, { + rowKey: { + type: [String, Function], + default: 'key' + }, + data: { + type: Function, + required: true + }, + pageNum: { + type: Number, + default: 1 + }, + pageSize: { + type: Number, + default: 10 + }, + showSizeChanger: { + type: Boolean, + default: true + }, + size: { + type: String, + default: 'default' + }, + /** + * alert: { + * show: true, + * clear: Function + * } + */ + alert: { + type: [Object, Boolean], + default: null + }, + rowSelection: { + type: Object, + default: null + }, + /** @Deprecated */ + showAlertInfo: { + type: Boolean, + default: false + }, + showPagination: { + type: String | Boolean, + default: 'auto' + }, + /** + * enable page URI mode + * + * e.g: + * /users/1 + * /users/2 + * /users/3?queryParam=test + * ... + */ + pageURI: { + type: Boolean, + default: false + } + }), + watch: { + 'localPagination.current' (val) { + this.pageURI && this.$router.push({ + ...this.$route, + name: this.$route.name, + params: Object.assign({}, this.$route.params, { + pageNo: val + }) + }) + // change pagination, reset total data + this.needTotalList = this.initTotalList(this.columns) + this.selectedRowKeys = [] + this.selectedRows = [] + }, + pageNum (val) { + Object.assign(this.localPagination, { + current: val + }) + }, + pageSize (val) { + Object.assign(this.localPagination, { + pageSize: val + }) + }, + showSizeChanger (val) { + Object.assign(this.localPagination, { + showSizeChanger: val + }) + } + }, + created () { + const { pageNo } = this.$route.params + const localPageNum = this.pageURI && (pageNo && parseInt(pageNo)) || this.pageNum + this.localPagination = ['auto', true].includes(this.showPagination) && Object.assign({}, this.localPagination, { + current: localPageNum, + pageSize: this.pageSize, + showSizeChanger: this.showSizeChanger + }) || false + this.needTotalList = this.initTotalList(this.columns) + this.loadData() + }, + methods: { + /** + * 表格重新加载方法 + * 如果参数为 true, 则强制刷新到第一页 + * @param Boolean bool + */ + refresh (bool = false) { + bool && (this.localPagination = Object.assign({}, { + current: 1, pageSize: this.pageSize + })) + this.loadData() + }, + /** + * 加载数据方法 + * @param {Object} pagination 分页选项器 + * @param {Object} filters 过滤条件 + * @param {Object} sorter 排序条件 + */ + loadData (pagination, filters = this.filters, sorter = this.sorter) { + this.filters = filters + this.sorter = sorter + + this.localLoading = true + const parameter = Object.assign({ + pageNo: (pagination && pagination.current) || + this.showPagination && this.localPagination.current || this.pageNum, + pageSize: (pagination && pagination.pageSize) || + this.showPagination && this.localPagination.pageSize || this.pageSize + }, + (sorter && sorter.field && { + sortField: sorter.field + }) || {}, + (sorter && sorter.order && { + sortOrder: sorter.order + }) || {}, { + ...filters + } + ) + const result = this.data(parameter) + // 对接自己的通用数据接口需要修改下方代码中的 r.pageNo, r.totalCount, r.data + // eslint-disable-next-line + if ((typeof result === 'object' || typeof result === 'function') && typeof result.then === 'function') { + result.then(r => { + this.localPagination = this.showPagination && Object.assign({}, this.localPagination, { + current: r.pageNo, // 返回结果中的当前分页数 + total: r.totalCount, // 返回结果中的总记录数 + showSizeChanger: this.showSizeChanger, + pageSize: (pagination && pagination.pageSize) || + this.localPagination.pageSize + }) || false + // 为防止删除数据后导致页面当前页面数据长度为 0 ,自动翻页到上一页 + if (r.data.length === 0 && this.showPagination && this.localPagination.current > 1) { + this.localPagination.current-- + this.loadData() + return + } + + // 这里用于判断接口是否有返回 r.totalCount 且 this.showPagination = true 且 pageNo 和 pageSize 存在 且 totalCount 小于等于 pageNo * pageSize 的大小 + // 当情况满足时,表示数据不满足分页大小,关闭 table 分页功能 + try { + if ((['auto', true].includes(this.showPagination) && r.totalCount <= (r.pageNo * this.localPagination.pageSize))) { + this.localPagination.hideOnSinglePage = true + } + } catch (e) { + this.localPagination = false + } + this.localDataSource = r.data // 返回结果中的数组数据 + }) + .finally(() => { + this.localLoading = false + }) + } + }, + initTotalList (columns) { + const totalList = [] + columns && columns instanceof Array && columns.forEach(column => { + if (column.needTotal) { + totalList.push({ + ...column, + total: 0 + }) + } + }) + return totalList + }, + /** + * 用于更新已选中的列表数据 total 统计 + * @param selectedRowKeys + * @param selectedRows + */ + updateSelect (selectedRowKeys, selectedRows) { + this.selectedRows = selectedRows + this.selectedRowKeys = selectedRowKeys + const list = this.needTotalList + this.needTotalList = list.map(item => { + return { + ...item, + total: selectedRows.reduce((sum, val) => { + const total = sum + parseInt(get(val, item.dataIndex)) + return isNaN(total) ? 0 : total + }, 0) + } + }) + }, + /** + * 清空 table 已选中项 + */ + clearSelected () { + if (this.rowSelection) { + this.rowSelection.onChange([], []) + this.updateSelect([], []) + } + }, + /** + * 处理交给 table 使用者去处理 clear 事件时,内部选中统计同时调用 + * @param callback + * @returns {*} + */ + renderClear (callback) { + if (this.selectedRowKeys.length <= 0) return null + return ( + { + callback() + this.clearSelected() + }}>清空 + ) + }, + renderAlert () { + // 绘制统计列数据 + const needTotalItems = this.needTotalList.map((item) => { + return ( + {item.title}总计 {!item.customRender ? item.total : item.customRender(item.total)} + ) + }) + + // 绘制 清空 按钮 + const clearItem = (typeof this.alert.clear === 'boolean' && this.alert.clear) ? ( + this.renderClear(this.clearSelected) + ) : (this.alert !== null && typeof this.alert.clear === 'function') ? ( + this.renderClear(this.alert.clear) + ) : null + + // 绘制 alert 组件 + return ( + + + + ) + } + }, + + render () { + const props = {} + const localKeys = Object.keys(this.$data) + const showAlert = (typeof this.alert === 'object' && this.alert !== null && this.alert.show) && typeof this.rowSelection.selectedRowKeys !== 'undefined' || this.alert + + Object.keys(T.props).forEach(k => { + const localKey = `local${k.substring(0, 1).toUpperCase()}${k.substring(1)}` + if (localKeys.includes(localKey)) { + props[k] = this[localKey] + return props[k] + } + if (k === 'rowSelection') { + if (showAlert && this.rowSelection) { + // 如果需要使用alert,则重新绑定 rowSelection 事件 + props[k] = { + ...this.rowSelection, + selectedRows: this.selectedRows, + selectedRowKeys: this.selectedRowKeys, + onChange: (selectedRowKeys, selectedRows) => { + this.updateSelect(selectedRowKeys, selectedRows) + typeof this[k].onChange !== 'undefined' && this[k].onChange(selectedRowKeys, selectedRows) + } + } + return props[k] + } else if (!this.rowSelection) { + // 如果没打算开启 rowSelection 则清空默认的选择项 + props[k] = null + return props[k] + } + } + this[k] && (props[k] = this[k]) + return props[k] + }) + const table = ( + { this.$emit('expand', expanded, record) } }> + { Object.keys(this.$slots).map(name => ()) } + + ) + + return ( +
    + { showAlert ? this.renderAlert() : null } + { table } +
    + ) + } +} diff --git a/quantdinger_vue/src/components/TagSelect/TagSelectOption.jsx b/quantdinger_vue/src/components/TagSelect/TagSelectOption.jsx new file mode 100644 index 0000000..b5ae799 --- /dev/null +++ b/quantdinger_vue/src/components/TagSelect/TagSelectOption.jsx @@ -0,0 +1,45 @@ +import { Tag } from 'ant-design-vue' +const { CheckableTag } = Tag + +export default { + name: 'TagSelectOption', + props: { + prefixCls: { + type: String, + default: 'ant-pro-tag-select-option' + }, + value: { + type: [String, Number, Object], + default: '' + }, + checked: { + type: Boolean, + default: false + } + }, + data () { + return { + localChecked: this.checked || false + } + }, + watch: { + 'checked' (val) { + this.localChecked = val + }, + '$parent.items': { + handler: function (val) { + this.value && val.hasOwnProperty(this.value) && (this.localChecked = val[this.value]) + }, + deep: true + } + }, + render () { + const { $slots, value } = this + const onChange = (checked) => { + this.$emit('change', { value, checked }) + } + return ( + {$slots.default} + ) + } +} diff --git a/quantdinger_vue/src/components/TagSelect/index.jsx b/quantdinger_vue/src/components/TagSelect/index.jsx new file mode 100644 index 0000000..af98ad7 --- /dev/null +++ b/quantdinger_vue/src/components/TagSelect/index.jsx @@ -0,0 +1,113 @@ +import PropTypes from 'ant-design-vue/es/_util/vue-types' +import Option from './TagSelectOption.jsx' +import { filterEmpty } from '@/components/_util/util' + +export default { + Option, + name: 'TagSelect', + model: { + prop: 'checked', + event: 'change' + }, + props: { + prefixCls: { + type: String, + default: 'ant-pro-tag-select' + }, + defaultValue: { + type: PropTypes.array, + default: null + }, + value: { + type: PropTypes.array, + default: null + }, + expandable: { + type: Boolean, + default: false + }, + hideCheckAll: { + type: Boolean, + default: false + } + }, + data () { + return { + expand: false, + localCheckAll: false, + items: this.getItemsKey(filterEmpty(this.$slots.default)), + val: this.value || this.defaultValue || [] + } + }, + methods: { + onChange (checked) { + const key = Object.keys(this.items).filter(key => key === checked.value) + this.items[key] = checked.checked + const bool = Object.values(this.items).lastIndexOf(false) + if (bool === -1) { + this.localCheckAll = true + } else { + this.localCheckAll = false + } + }, + onCheckAll (checked) { + Object.keys(this.items).forEach(v => { + this.items[v] = checked.checked + }) + this.localCheckAll = checked.checked + }, + getItemsKey (items) { + const totalItem = {} + items.forEach(item => { + totalItem[item.componentOptions.propsData && item.componentOptions.propsData.value] = false + }) + return totalItem + }, + // CheckAll Button + renderCheckAll () { + const props = { + on: { + change: (checked) => { + this.onCheckAll(checked) + checked.value = 'total' + this.$emit('change', checked) + } + } + } + const checkAllElement = + return !this.hideCheckAll && checkAllElement || null + }, + // expandable + renderExpandable () { + + }, + // render option + renderTags (items) { + const listeners = { + change: (checked) => { + this.onChange(checked) + this.$emit('change', checked) + } + } + + return items.map(vnode => { + const options = vnode.componentOptions + options.listeners = listeners + return vnode + }) + } + }, + render () { + const { $props: { prefixCls } } = this + const classString = { + [`${prefixCls}`]: true + } + const tagItems = filterEmpty(this.$slots.default) + return ( +
    + {this.renderCheckAll()} + {this.renderTags(tagItems)} +
    + ) + } +} diff --git a/quantdinger_vue/src/components/TextArea/index.jsx b/quantdinger_vue/src/components/TextArea/index.jsx new file mode 100644 index 0000000..1aa63ae --- /dev/null +++ b/quantdinger_vue/src/components/TextArea/index.jsx @@ -0,0 +1,67 @@ +import './style.less' +import { getStrFullLength, cutStrByFullLength } from '../_util/util' +import Input from 'ant-design-vue/es/input' +const TextArea = Input.TextArea + +export default { + name: 'LimitTextArea', + model: { + prop: 'value', + event: 'change' + }, + props: Object.assign({}, TextArea.props, { + prefixCls: { + type: String, + default: 'ant-textarea-limit' + }, + // eslint-disable-next-line + value: { + type: String + }, + limit: { + type: Number, + default: 200 + } + }), + data () { + return { + currentLimit: 0 + } + }, + watch: { + value (val) { + this.calcLimitNum(val) + } + }, + created () { + this.calcLimitNum(this.value) + }, + methods: { + handleChange (e) { + const value = e.target.value + const len = getStrFullLength(value) + if (len <= this.limit) { + this.currentLimit = len + this.$emit('change', value) + } else { + const str = cutStrByFullLength(value, this.limit) + this.currentLimit = getStrFullLength(str) + this.$emit('change', str) + } + }, + calcLimitNum (val) { + const len = getStrFullLength(val) + this.currentLimit = len + } + }, + render () { + const { prefixCls, ...props } = this.$props + return ( +
    + + {this.currentLimit}/{this.limit} +
    + ) + } +} diff --git a/quantdinger_vue/src/components/TextArea/style.less b/quantdinger_vue/src/components/TextArea/style.less new file mode 100644 index 0000000..ba544bf --- /dev/null +++ b/quantdinger_vue/src/components/TextArea/style.less @@ -0,0 +1,12 @@ +.ant-textarea-limit { + position: relative; + + .limit { + position: absolute; + right: 10px; + bottom: 5px; + font-size: 12px; + color: #909399; + background: #fff; + } +} diff --git a/quantdinger_vue/src/components/Tree/Tree.jsx b/quantdinger_vue/src/components/Tree/Tree.jsx new file mode 100644 index 0000000..e5a2a11 --- /dev/null +++ b/quantdinger_vue/src/components/Tree/Tree.jsx @@ -0,0 +1,124 @@ +import { Menu, Icon, Input } from 'ant-design-vue' + +const { Item, ItemGroup, SubMenu } = Menu +const { Search } = Input + +export default { + name: 'Tree', + props: { + dataSource: { + type: Array, + required: true + }, + openKeys: { + type: Array, + default: () => [] + }, + search: { + type: Boolean, + default: false + } + }, + created () { + this.localOpenKeys = this.openKeys.slice(0) + }, + data () { + return { + localOpenKeys: [] + } + }, + methods: { + handlePlus (item) { + this.$emit('add', item) + }, + handleTitleClick (...args) { + this.$emit('titleClick', { args }) + }, + + renderSearch () { + return ( + + ) + }, + renderIcon (icon) { + return icon && () || null + }, + renderMenuItem (item) { + return ( + + { this.renderIcon(item.icon) } + { item.title } + this.handlePlus(item) } }}> + + ) + }, + renderItem (item) { + return item.children ? this.renderSubItem(item, item.key) : this.renderMenuItem(item, item.key) + }, + renderItemGroup (item) { + const childrenItems = item.children.map(o => { + return this.renderItem(o, o.key) + }) + + return ( + + + { childrenItems } + + ) + }, + renderSubItem (item, key) { + const childrenItems = item.children && item.children.map(o => { + return this.renderItem(o, o.key) + }) + + const title = ( + + { this.renderIcon(item.icon) } + { item.title } + + ) + + if (item.group) { + return this.renderItemGroup(item) + } + // titleClick={this.handleTitleClick(item)} + return ( + + { title } + { childrenItems } + + ) + } + }, + render () { + const { dataSource, search } = this.$props + + // this.localOpenKeys = openKeys.slice(0) + const list = dataSource.map(item => { + return this.renderItem(item) + }) + + return ( +
    + { search ? this.renderSearch() : null } + this.$emit('click', item), 'update:openKeys': val => { this.localOpenKeys = val } } }} openKeys={this.localOpenKeys}> + { list } + +
    + ) + } +} diff --git a/quantdinger_vue/src/components/Trend/Trend.vue b/quantdinger_vue/src/components/Trend/Trend.vue new file mode 100644 index 0000000..526e1cc --- /dev/null +++ b/quantdinger_vue/src/components/Trend/Trend.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/quantdinger_vue/src/components/Trend/index.js b/quantdinger_vue/src/components/Trend/index.js new file mode 100644 index 0000000..9f14228 --- /dev/null +++ b/quantdinger_vue/src/components/Trend/index.js @@ -0,0 +1,3 @@ +import Trend from './Trend.vue' + +export default Trend diff --git a/quantdinger_vue/src/components/Trend/index.less b/quantdinger_vue/src/components/Trend/index.less new file mode 100644 index 0000000..d02ac16 --- /dev/null +++ b/quantdinger_vue/src/components/Trend/index.less @@ -0,0 +1,44 @@ +@import '../index'; + +@trend-prefix-cls: ~"@{ant-pro-prefix}-trend"; + +.@{trend-prefix-cls} { + display: inline-block; + font-size: @font-size-base; + line-height: 22px; + + .up, + .down { + position: relative; + top: 1px; + margin-left: 4px; + + i { + font-size: 12px; + transform: scale(.83); + } + } + + .item-text { + display: inline-block; + margin-left: 8px; + color: rgb(0 0 0 / 85%); + } + + .up { + color: @red-6; + } + + .down { + top: -1px; + color: @green-6; + } + + &.reverse-color .up { + color: @green-6; + } + + &.reverse-color .down { + color: @red-6; + } +} diff --git a/quantdinger_vue/src/components/Trend/index.md b/quantdinger_vue/src/components/Trend/index.md new file mode 100644 index 0000000..8881f0e --- /dev/null +++ b/quantdinger_vue/src/components/Trend/index.md @@ -0,0 +1,45 @@ +# Trend 趋势标记 + +趋势符号,标记上升和下降趋势。通常用绿色代表“好”,红色代表“不好”,股票涨跌场景除外。 + + + +引用方式: + +```javascript +import Trend from '@/components/Trend' + +export default { + components: { + Trend + } +} +``` + + + +## 代码演示 [demo](https://pro.loacg.com/test/home) + +```html +5% +``` +或 +```html + + 工资 + 5% + +``` +或 +```html +5% +``` + + +## API + +| 参数 | 说明 | 类型 | 默认值 | +|----------|------------------------------------------|-------------|-------| +| flag | 上升下降标识:`up|down` | string | - | +| reverseColor | 颜色反转 | Boolean | false | + diff --git a/quantdinger_vue/src/components/_util/util.js b/quantdinger_vue/src/components/_util/util.js new file mode 100644 index 0000000..dd33231 --- /dev/null +++ b/quantdinger_vue/src/components/_util/util.js @@ -0,0 +1,46 @@ +/** + * components util + */ + +/** + * 清理空值,对象 + * @param children + * @returns {*[]} + */ +export function filterEmpty (children = []) { + return children.filter(c => c.tag || (c.text && c.text.trim() !== '')) +} + +/** + * 获取字符串长度,英文字符 长度1,中文字符长度2 + * @param {*} str + */ +export const getStrFullLength = (str = '') => + str.split('').reduce((pre, cur) => { + const charCode = cur.charCodeAt(0) + if (charCode >= 0 && charCode <= 128) { + return pre + 1 + } + return pre + 2 + }, 0) + +/** + * 截取字符串,根据 maxLength 截取后返回 + * @param {*} str + * @param {*} maxLength + */ +export const cutStrByFullLength = (str = '', maxLength) => { + let showLength = 0 + return str.split('').reduce((pre, cur) => { + const charCode = cur.charCodeAt(0) + if (charCode >= 0 && charCode <= 128) { + showLength += 1 + } else { + showLength += 2 + } + if (showLength <= maxLength) { + return pre + cur + } + return pre + }, '') +} diff --git a/quantdinger_vue/src/components/index.js b/quantdinger_vue/src/components/index.js new file mode 100644 index 0000000..401ae0d --- /dev/null +++ b/quantdinger_vue/src/components/index.js @@ -0,0 +1,56 @@ +// chart +import Bar from '@/components/Charts/Bar' +import ChartCard from '@/components/Charts/ChartCard' +import Liquid from '@/components/Charts/Liquid' +import MiniArea from '@/components/Charts/MiniArea' +import MiniSmoothArea from '@/components/Charts/MiniSmoothArea' +import MiniBar from '@/components/Charts/MiniBar' +import MiniProgress from '@/components/Charts/MiniProgress' +import Radar from '@/components/Charts/Radar' +import RankList from '@/components/Charts/RankList' +import TransferBar from '@/components/Charts/TransferBar' +import TagCloud from '@/components/Charts/TagCloud' + +// pro components +import AvatarList from '@/components/AvatarList' +import Ellipsis from '@/components/Ellipsis' +import FooterToolbar from '@/components/FooterToolbar' +import NumberInfo from '@/components/NumberInfo' +import Tree from '@/components/Tree/Tree' +import Trend from '@/components/Trend' +import STable from '@/components/Table' +import MultiTab from '@/components/MultiTab' +import IconSelector from '@/components/IconSelector' +import TagSelect from '@/components/TagSelect' +import StandardFormRow from '@/components/StandardFormRow' +import ArticleListContent from '@/components/ArticleListContent' + +import Dialog from '@/components/Dialog' + +export { + AvatarList, + Bar, + ChartCard, + Liquid, + MiniArea, + MiniSmoothArea, + MiniBar, + MiniProgress, + Radar, + TagCloud, + RankList, + TransferBar, + Trend, + Ellipsis, + FooterToolbar, + NumberInfo, + Tree, + STable, + MultiTab, + IconSelector, + TagSelect, + StandardFormRow, + ArticleListContent, + + Dialog +} diff --git a/quantdinger_vue/src/components/index.less b/quantdinger_vue/src/components/index.less new file mode 100644 index 0000000..01ef050 --- /dev/null +++ b/quantdinger_vue/src/components/index.less @@ -0,0 +1,6 @@ +@import '~ant-design-vue/lib/style/index'; + +// The prefix to use on all css classes from ant-pro. +@ant-pro-prefix : ant-pro; +@ant-global-sider-zindex : 106; +@ant-global-header-zindex : 105; diff --git a/quantdinger_vue/src/components/tools/TwoStepCaptcha.vue b/quantdinger_vue/src/components/tools/TwoStepCaptcha.vue new file mode 100644 index 0000000..638489c --- /dev/null +++ b/quantdinger_vue/src/components/tools/TwoStepCaptcha.vue @@ -0,0 +1,102 @@ + + + + diff --git a/quantdinger_vue/src/config/aiModels.js b/quantdinger_vue/src/config/aiModels.js new file mode 100644 index 0000000..cfb3e4c --- /dev/null +++ b/quantdinger_vue/src/config/aiModels.js @@ -0,0 +1,41 @@ +// Unified AI model support list for frontend. +// Model IDs follow OpenRouter-style `provider/model` naming. + +export const DEFAULT_AI_MODEL_MAP = { + 'x-ai/grok-code-fast-1': 'xAI: Grok Code Fast 1', + 'x-ai/grok-4-fast': 'xAI: Grok 4 Fast', + 'x-ai/grok-4.1-fast': 'xAI: Grok 4.1 Fast', + 'google/gemini-2.5-flash': 'Google: Gemini 2.5 Flash', + 'google/gemini-2.0-flash-001': 'Google: Gemini 2.0 Flash', + 'google/gemini-3-pro-preview': 'Google: Gemini 3 Pro Preview', + 'google/gemini-2.5-flash-lite': 'Google: Gemini 2.5 Flash Lite', + 'google/gemini-2.5-pro': 'Google: Gemini 2.5 Pro', + 'openai/gpt-4o-mini': 'OpenAI: GPT-4o-mini', + 'openai/gpt-5-mini': 'OpenAI: GPT-5 Mini', + 'openai/gpt-oss-120b': 'OpenAI: gpt-oss-120b', + 'deepseek/deepseek-v3.2': 'DeepSeek: DeepSeek V3.2', + 'minimax/minimax-m2': 'MiniMax: MiniMax M2', + 'anthropic/claude-sonnet-4': 'Anthropic: Claude Sonnet 4', + 'anthropic/claude-sonnet-4.5': 'Anthropic: Claude Sonnet 4.5', + 'anthropic/claude-opus-4.5': 'Anthropic: Claude Opus 4.5', + 'anthropic/claude-haiku-4.5': 'Anthropic: Claude Haiku 4.5', + 'z-ai/glm-4.6': 'Z.AI: GLM 4.6' +} + +export function isPlainObject (val) { + return val !== null && typeof val === 'object' && !Array.isArray(val) +} + +export function mergeModelMaps (baseMap, overrideMap) { + const base = isPlainObject(baseMap) ? baseMap : {} + const override = isPlainObject(overrideMap) ? overrideMap : {} + return { ...base, ...override } +} + +export function modelMapToOptions (modelMap) { + const map = isPlainObject(modelMap) ? modelMap : {} + return Object.keys(map).map(key => ({ + value: key, + label: map[key] + })) +} diff --git a/quantdinger_vue/src/config/defaultSettings.js b/quantdinger_vue/src/config/defaultSettings.js new file mode 100644 index 0000000..7e4e629 --- /dev/null +++ b/quantdinger_vue/src/config/defaultSettings.js @@ -0,0 +1,33 @@ +/** + * 项目默认配置项 + * primaryColor - 默认主题色, 如果修改颜色不生效,请清理 localStorage + * navTheme - sidebar theme ['dark', 'light'] 两种主题 + * colorWeak - 色盲模式 + * layout - 整体布局方式 ['sidemenu', 'topmenu'] 两种布局 + * fixedHeader - 固定 Header : boolean + * fixSiderbar - 固定左侧菜单栏 : boolean + * contentWidth - 内容区布局: 流式 | 固定 + * + * storageOptions: {} - Vue-ls 插件配置项 (localStorage/sessionStorage) + * + */ + +export const PYTHON_API_BASE_URL = process.env.VUE_APP_PYTHON_API_BASE_URL || 'http://localhost:5000' + +export default { + navTheme: 'light', // theme for nav menu + primaryColor: '#13C2C2', // '#F5222D', // primary color of ant design + layout: 'sidemenu', // nav menu position: `sidemenu` or `topmenu` + contentWidth: 'Fluid', // layout of content: `Fluid` or `Fixed`, only works when layout is topmenu + fixedHeader: true, // sticky header - 固定顶部导航栏 + fixSiderbar: true, // sticky siderbar - 固定左侧边栏 + colorWeak: false, + menu: { + locale: true + }, + title: 'QuantDinger', + pwa: false, + iconfontUrl: '', + production: process.env.NODE_ENV === 'production' && process.env.VUE_APP_PREVIEW !== 'true' + +} diff --git a/quantdinger_vue/src/config/router.config.js b/quantdinger_vue/src/config/router.config.js new file mode 100644 index 0000000..0d7c994 --- /dev/null +++ b/quantdinger_vue/src/config/router.config.js @@ -0,0 +1,147 @@ +// eslint-disable-next-line +import { UserLayout, BasicLayout, BlankLayout } from '@/layouts' + +export const asyncRouterMap = [ + { + path: '/', + name: 'index', + component: BasicLayout, + meta: { title: 'menu.home' }, + redirect: '/dashboard', + children: [ + // 仪表盘 + { + path: '/dashboard', + name: 'Dashboard', + component: () => import('@/views/dashboard'), + meta: { title: 'menu.dashboard', keepAlive: true, icon: 'dashboard', permission: ['dashboard'] } + }, + // AI 分析 + { + path: '/ai-analysis/:pageNo([1-9]\\d*)?', + name: 'Analysis', + component: () => import('@/views/ai-analysis'), + meta: { title: 'menu.dashboard.analysis', keepAlive: false, icon: 'thunderbolt', permission: ['dashboard'] } + }, + // 指标分析 + { + path: '/indicator-analysis', + name: 'Indicator', + component: () => import('@/views/indicator-analysis'), + meta: { title: 'menu.dashboard.indicator', keepAlive: true, icon: 'line-chart', permission: ['dashboard'] } + }, + // 交易助手 + { + path: '/trading-assistant', + name: 'TradingAssistant', + component: () => import('@/views/trading-assistant'), + meta: { title: 'menu.dashboard.tradingAssistant', keepAlive: true, icon: 'robot', permission: ['dashboard'] } + }, + // 指标社区(keepAlive disabled intentionally for iframe page) + { + path: '/indicator-community', + name: 'IndicatorCommunity', + component: () => import('@/views/indicator-community'), + meta: { title: 'menu.dashboard.community', keepAlive: false, icon: 'shop', permission: ['dashboard'] } + } + + // other + /* + { + path: '/other', + name: 'otherPage', + component: PageView, + meta: { title: '其他组件', icon: 'slack', permission: [ 'dashboard' ] }, + redirect: '/other/icon-selector', + children: [ + { + path: '/other/icon-selector', + name: 'TestIconSelect', + component: () => import('@/views/other/IconSelectorView'), + meta: { title: 'IconSelector', icon: 'tool', keepAlive: true, permission: [ 'dashboard' ] } + }, + { + path: '/other/list', + component: RouteView, + meta: { title: '业务布局', icon: 'layout', permission: [ 'support' ] }, + redirect: '/other/list/tree-list', + children: [ + { + path: '/other/list/tree-list', + name: 'TreeList', + component: () => import('@/views/other/TreeList'), + meta: { title: '树目录表格', keepAlive: true } + }, + { + path: '/other/list/edit-table', + name: 'EditList', + component: () => import('@/views/other/TableInnerEditList'), + meta: { title: '内联编辑表格', keepAlive: true } + }, + { + path: '/other/list/user-list', + name: 'UserList', + component: () => import('@/views/other/UserList'), + meta: { title: '用户列表', keepAlive: true } + }, + { + path: '/other/list/role-list', + name: 'RoleList', + component: () => import('@/views/other/RoleList'), + meta: { title: '角色列表', keepAlive: true } + }, + { + path: '/other/list/system-role', + name: 'SystemRole', + component: () => import('@/views/role/RoleList'), + meta: { title: '角色列表2', keepAlive: true } + }, + { + path: '/other/list/permission-list', + name: 'PermissionList', + component: () => import('@/views/other/PermissionList'), + meta: { title: '权限列表', keepAlive: true } + } + ] + } + ] + } + */ + ] + }, + { + path: '*', + redirect: '/404', + hidden: true + } +] + +/** + * 基础路由 + * @type { *[] } + */ +export const constantRouterMap = [ + { + path: '/user', + component: UserLayout, + redirect: '/user/login', + hidden: true, + children: [ + { + path: 'login', + name: 'login', + component: () => import(/* webpackChunkName: "user" */ '@/views/user/Login') + }, + { + path: 'recover', + name: 'recover', + component: undefined + } + ] + }, + + { + path: '/404', + component: () => import(/* webpackChunkName: "fail" */ '@/views/exception/404') + } +] diff --git a/quantdinger_vue/src/core/bootstrap.js b/quantdinger_vue/src/core/bootstrap.js new file mode 100644 index 0000000..f9b282c --- /dev/null +++ b/quantdinger_vue/src/core/bootstrap.js @@ -0,0 +1,31 @@ +import store from '@/store' +import storage from 'store' +import { + ACCESS_TOKEN, + APP_LANGUAGE, + TOGGLE_CONTENT_WIDTH, + TOGGLE_FIXED_HEADER, + TOGGLE_FIXED_SIDEBAR, TOGGLE_HIDE_HEADER, + TOGGLE_LAYOUT, TOGGLE_NAV_THEME, TOGGLE_WEAK, + TOGGLE_COLOR, TOGGLE_MULTI_TAB +} from '@/store/mutation-types' +import { printANSI } from '@/utils/screenLog' +import defaultSettings from '@/config/defaultSettings' + +export default function Initializer () { + printANSI() // 请自行移除该行. please remove this line + + store.commit(TOGGLE_LAYOUT, storage.get(TOGGLE_LAYOUT, defaultSettings.layout)) + store.commit(TOGGLE_FIXED_HEADER, storage.get(TOGGLE_FIXED_HEADER, defaultSettings.fixedHeader)) + store.commit(TOGGLE_FIXED_SIDEBAR, storage.get(TOGGLE_FIXED_SIDEBAR, defaultSettings.fixSiderbar)) + store.commit(TOGGLE_CONTENT_WIDTH, storage.get(TOGGLE_CONTENT_WIDTH, defaultSettings.contentWidth)) + store.commit(TOGGLE_HIDE_HEADER, storage.get(TOGGLE_HIDE_HEADER, defaultSettings.autoHideHeader)) + store.commit(TOGGLE_NAV_THEME, storage.get(TOGGLE_NAV_THEME, defaultSettings.navTheme)) + store.commit(TOGGLE_WEAK, storage.get(TOGGLE_WEAK, defaultSettings.colorWeak)) + store.commit(TOGGLE_COLOR, storage.get(TOGGLE_COLOR, defaultSettings.primaryColor)) + store.commit(TOGGLE_MULTI_TAB, storage.get(TOGGLE_MULTI_TAB, defaultSettings.multiTab)) + store.commit('SET_TOKEN', storage.get(ACCESS_TOKEN)) + + store.dispatch('setLang', storage.get(APP_LANGUAGE, 'en-US')) + // last step +} diff --git a/quantdinger_vue/src/core/directives/action.js b/quantdinger_vue/src/core/directives/action.js new file mode 100644 index 0000000..42563f1 --- /dev/null +++ b/quantdinger_vue/src/core/directives/action.js @@ -0,0 +1,34 @@ +import Vue from 'vue' +import store from '@/store' + +/** + * Action 权限指令 + * 指令用法: + * - 在需要控制 action 级别权限的组件上使用 v-action:[method] , 如下: + * 添加用户 + * 删除用户 + * 修改 + * + * - 当前用户没有权限时,组件上使用了该指令则会被隐藏 + * - 当后台权限跟 pro 提供的模式不同时,只需要针对这里的权限过滤进行修改即可 + * + * @see https://github.com/vueComponent/ant-design-vue-pro/pull/53 + */ +const action = Vue.directive('action', { + inserted: function (el, binding, vnode) { + const actionName = binding.arg + const roles = store.getters.roles + const elVal = vnode.context.$route.meta.permission + const permissionId = Object.prototype.toString.call(elVal) === '[object String]' && [elVal] || elVal + roles.permissions.forEach(p => { + if (!permissionId.includes(p.permissionId)) { + return + } + if (p.actionList && !p.actionList.includes(actionName)) { + el.parentNode && el.parentNode.removeChild(el) || (el.style.display = 'none') + } + }) + } +}) + +export default action diff --git a/quantdinger_vue/src/core/icons.js b/quantdinger_vue/src/core/icons.js new file mode 100644 index 0000000..46b7261 --- /dev/null +++ b/quantdinger_vue/src/core/icons.js @@ -0,0 +1,11 @@ +/** + * Custom icon list + * All icons are loaded here for easy management + * @see https://vue.ant.design/components/icon/#Custom-Font-Icon + * + * 自定义图标加载表 + * 所有图标均从这里加载,方便管理 + */ +import bxAnaalyse from '@/assets/icons/bx-analyse.svg?inline' // path to your '*.svg?inline' file. + +export { bxAnaalyse } diff --git a/quantdinger_vue/src/core/lazy_use.js b/quantdinger_vue/src/core/lazy_use.js new file mode 100644 index 0000000..e104f92 --- /dev/null +++ b/quantdinger_vue/src/core/lazy_use.js @@ -0,0 +1,127 @@ +import Vue from 'vue' + +// base library +import { + ConfigProvider, + Layout, + Input, + InputNumber, + Button, + Switch, + Radio, + Checkbox, + Select, + Card, + Collapse, + Form, + FormModel, + Row, + Col, + Modal, + Table, + Tabs, + Icon, + Badge, + Popover, + Dropdown, + List, + Avatar, + Breadcrumb, + Steps, + Spin, + Menu, + Drawer, + Tooltip, + Alert, + Tag, + Divider, + DatePicker, + TimePicker, + Upload, + Progress, + Skeleton, + Popconfirm, + PageHeader, + Result, + Statistic, + Descriptions, + Space, + Empty, + Rate, + message, + notification +} from 'ant-design-vue' +// import Viser from 'viser-vue' + +// ext library +import VueCropper from 'vue-cropper' +import Dialog from '@/components/Dialog' +import MultiTab from '@/components/MultiTab' +import PageLoading from '@/components/PageLoading' +import PermissionHelper from '@/core/permission/permission' +import './directives/action' + +Vue.use(ConfigProvider) +Vue.use(Layout) +Vue.use(Input) +Vue.use(InputNumber) +Vue.use(Button) +Vue.use(Switch) +Vue.use(Radio) +Vue.use(Checkbox) +Vue.use(Select) +Vue.use(Card) +Vue.use(Collapse) +Vue.use(Form) +Vue.use(FormModel) +Vue.use(Row) +Vue.use(Col) +Vue.use(Modal) +Vue.use(Table) +Vue.use(Tabs) +Vue.use(Icon) +Vue.use(Badge) +Vue.use(Popover) +Vue.use(Dropdown) +Vue.use(List) +Vue.use(Avatar) +Vue.use(Breadcrumb) +Vue.use(Steps) +Vue.use(Spin) +Vue.use(Menu) +Vue.use(Drawer) +Vue.use(Tooltip) +Vue.use(Alert) +Vue.use(Tag) +Vue.use(Divider) +Vue.use(DatePicker) +Vue.use(TimePicker) +Vue.use(Upload) +Vue.use(Progress) +Vue.use(Skeleton) +Vue.use(Popconfirm) +Vue.use(PageHeader) +Vue.use(Result) +Vue.use(Statistic) +Vue.use(Descriptions) +Vue.use(Space) +Vue.use(Empty) +Vue.use(Rate) +// Textarea 是 Input 组件的一部分,通过 Vue.use(Input) 已自动注册 + +Vue.prototype.$confirm = Modal.confirm +Vue.prototype.$message = message +Vue.prototype.$notification = notification +Vue.prototype.$info = Modal.info +Vue.prototype.$success = Modal.success +Vue.prototype.$error = Modal.error +Vue.prototype.$warning = Modal.warning + +// Vue.use(Viser) +Vue.use(Dialog) // this.$dialog func +Vue.use(MultiTab) +Vue.use(PageLoading) +Vue.use(PermissionHelper) +Vue.use(VueCropper) + +process.env.NODE_ENV !== 'production' && console.warn('[antd-pro] NOTICE: Antd use lazy-load.') diff --git a/quantdinger_vue/src/core/permission/permission.js b/quantdinger_vue/src/core/permission/permission.js new file mode 100644 index 0000000..fb69844 --- /dev/null +++ b/quantdinger_vue/src/core/permission/permission.js @@ -0,0 +1,55 @@ +export const PERMISSION_ENUM = { + 'add': { key: 'add', label: '新增' }, + 'delete': { key: 'delete', label: '删除' }, + 'edit': { key: 'edit', label: '修改' }, + 'query': { key: 'query', label: '查询' }, + 'get': { key: 'get', label: '详情' }, + 'enable': { key: 'enable', label: '启用' }, + 'disable': { key: 'disable', label: '禁用' }, + 'import': { key: 'import', label: '导入' }, + 'export': { key: 'export', label: '导出' } +} + +/** + * Button + * @param Vue + */ +function plugin (Vue) { + if (plugin.installed) { + return + } + + !Vue.prototype.$auth && Object.defineProperties(Vue.prototype, { + $auth: { + get () { + const _this = this + return (permissions) => { + const [permission, action] = permissions.split('.') + const permissionList = _this.$store.getters.roles.permissions + return permissionList.find((val) => { + return val.permissionId === permission + }).actionList.findIndex((val) => { + return val === action + }) > -1 + } + } + } + }) + + !Vue.prototype.$enum && Object.defineProperties(Vue.prototype, { + $enum: { + get () { + // const _this = this; + return (val) => { + let result = PERMISSION_ENUM + val && val.split('.').forEach(v => { + result = result && result[v] || null + }) + return result + } + } + } + }) +} + +export default plugin diff --git a/quantdinger_vue/src/core/use.js b/quantdinger_vue/src/core/use.js new file mode 100644 index 0000000..d98cd16 --- /dev/null +++ b/quantdinger_vue/src/core/use.js @@ -0,0 +1,27 @@ +import Vue from 'vue' + +// base library +import Antd from 'ant-design-vue' +import Viser from 'viser-vue' +import VueCropper from 'vue-cropper' +import 'ant-design-vue/dist/antd.less' + +// ext library +import VueClipboard from 'vue-clipboard2' +import MultiTab from '@/components/MultiTab' +import PageLoading from '@/components/PageLoading' +import PermissionHelper from '@/core/permission/permission' +// import '@/components/use' +import './directives/action' + +VueClipboard.config.autoSetContainer = true + +Vue.use(Antd) +Vue.use(Viser) +Vue.use(MultiTab) +Vue.use(PageLoading) +Vue.use(VueClipboard) +Vue.use(PermissionHelper) +Vue.use(VueCropper) + +process.env.NODE_ENV !== 'production' && console.warn('[antd-pro] WARNING: Antd now use fulled imported.') diff --git a/quantdinger_vue/src/global.less b/quantdinger_vue/src/global.less new file mode 100644 index 0000000..680736d --- /dev/null +++ b/quantdinger_vue/src/global.less @@ -0,0 +1,117 @@ +@import '~ant-design-vue/es/style/themes/default.less'; + +html, +body, +#app, +#root { + height: 100%; +} + +.colorWeak { + filter: invert(80%); +} + +.ant-layout.layout-basic { + height: 100vh; + min-height: 100vh; +} + +canvas { + display: block; +} + +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizelegibility; +} + +ul, +ol { + list-style: none; +} + +// 数据列表 样式 +.table-alert { + margin-bottom: 16px; +} +// 数据列表 操作 +.table-operator { + margin-bottom: 18px; + + button { + margin-right: 8px; + } +} +// 数据列表 搜索条件 +.table-page-search-wrapper { + .ant-form-inline { + .ant-form-item { + display: flex; + margin-right: 0; + margin-bottom: 24px; + + .ant-form-item-control-wrapper { + flex: 1 1; + display: inline-block; + vertical-align: middle; + } + + > .ant-form-item-label { + width: auto; + padding-right: 8px; + line-height: 32px; + } + + .ant-form-item-control { + height: 32px; + line-height: 32px; + } + } + } + + .table-page-search-submitButtons { + display: block; + margin-bottom: 24px; + white-space: nowrap; + } +} + +@media (max-width: @screen-xs) { + .ant-table { + width: 100%; + overflow-x: auto; + + &-thead > tr, + &-tbody > tr { + > th, + > td { + white-space: pre; + + > span { + display: block; + } + } + } + } +} + +// Hide noisy webpack dev server overlay on mobile (ResizeObserver loop warnings) +#webpack-dev-server-client-overlay, +#webpack-hot-middleware-client-overlay, +.webpack-dev-server-client-overlay { + display: none !important; + pointer-events: none !important; + visibility: hidden !important; +} + +// 手机端强制覆盖 logo padding +@media (max-width: 768px) { + .ant-pro-global-header-logo { + padding: 0 !important; + padding-left: 0 !important; + padding-right: 0 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + } +} \ No newline at end of file diff --git a/quantdinger_vue/src/layouts/BasicLayout.less b/quantdinger_vue/src/layouts/BasicLayout.less new file mode 100644 index 0000000..81f02ff --- /dev/null +++ b/quantdinger_vue/src/layouts/BasicLayout.less @@ -0,0 +1,423 @@ +@import '~ant-design-vue/es/style/themes/default.less'; + +/* ========== 完全隐藏 ant-layout-footer ========== */ +:global { + /* 全局隐藏所有 ant-layout-footer */ + .ant-layout-footer, + footer.ant-layout-footer, + .ant-pro-layout .ant-layout-footer, + .ant-layout .ant-layout-footer { + display: none !important; + height: 0 !important; + padding: 0 !important; + margin: 0 !important; + border: none !important; + min-height: 0 !important; + visibility: hidden !important; + overflow: hidden !important; + } +} + +/* ========== ant-layout-header 主题适配 ========== */ +/* 全局样式:根据 body 或 layout 的主题类自动适配 */ +:global { + /* 暗黑主题下的 ant-layout-header */ + body.dark .ant-layout-header, + body.realdark .ant-layout-header, + .ant-layout.dark .ant-layout-header, + .ant-layout.realdark .ant-layout-header, + .ant-pro-layout.dark .ant-layout-header, + .ant-pro-layout.realdark .ant-layout-header { + background: #001529 !important; + border-bottom: 1px solid #1f1f1f !important; + box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08) !important; + color: rgba(255, 255, 255, 0.85) !important; + + /* Header 内的所有文字和图标 */ + * { + color: rgba(255, 255, 255, 0.85) !important; + } + + /* Header 内的图标按钮 */ + .anticon { + color: rgba(255, 255, 255, 0.85) !important; + + &:hover { + color: #1890ff !important; + background: rgba(255, 255, 255, 0.08) !important; + border-radius: 4px; + } + } + + /* Header 内的链接 */ + a { + color: rgba(255, 255, 255, 0.85) !important; + + &:hover { + color: #1890ff !important; + } + } + + /* Header 内的工具提示容器 */ + .ant-tooltip-open { + .anticon { + color: rgba(255, 255, 255, 0.85) !important; + } + } + } + + + /* 浅色主题下的 ant-layout-header(确保覆盖默认样式) */ + body.light .ant-layout-header, + .ant-layout.light .ant-layout-header, + .ant-pro-layout.light .ant-layout-header { + background: #fff !important; + border-bottom: 1px solid #e8e8e8 !important; + box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08) !important; + color: rgba(0, 0, 0, 0.85) !important; + } + + /* ========== ant-layout-content 主题适配(中间页面内容区域) ========== */ + /* 暗黑主题下的内容区域 */ + body.dark .ant-layout-content, + body.realdark .ant-layout-content, + .ant-layout.dark .ant-layout-content, + .ant-layout.realdark .ant-layout-content, + .ant-pro-layout.dark .ant-layout-content, + .ant-pro-layout.realdark .ant-layout-content { + background: #141414 !important; + color: rgba(255, 255, 255, 0.85) !important; + } + + + + /* 浅色主题下的内容区域 */ + body.light .ant-layout-content, + .ant-layout.light .ant-layout-content, + .ant-pro-layout.light .ant-layout-content { + background: #f0f2f5 !important; + color: rgba(0, 0, 0, 0.85) !important; + } + + + + + + /* ========== GlobalHeader 核心样式(基于 .ant-pro-global-header) ========== */ + /* 暗黑主题下的 .ant-pro-global-header - 这是控制顶部标题栏的关键样式 */ + body.dark .ant-pro-global-header, + body.realdark .ant-pro-global-header, + .ant-layout.dark .ant-pro-global-header, + .ant-layout.realdark .ant-pro-global-header, + .ant-pro-layout.dark .ant-pro-global-header, + .ant-pro-layout.realdark .ant-pro-global-header, + body.dark .ant-layout-header .ant-pro-global-header, + body.realdark .ant-layout-header .ant-pro-global-header, + .ant-pro-layout.dark .ant-layout-header .ant-pro-global-header, + .ant-pro-layout.realdark .ant-layout-header .ant-pro-global-header { + /* 核心:背景色和边框 */ + background: #001529 !important; + border-bottom: 1px solid #1f1f1f !important; + box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08) !important; + color: rgba(255, 255, 255, 0.85) !important; + + /* 强制所有子元素文字颜色 */ + * { + color: rgba(255, 255, 255, 0.85) !important; + } + + /* Logo 区域 */ + .ant-pro-global-header-logo { + background: transparent !important; + color: rgba(255, 255, 255, 0.85) !important; + + h1 { + color: rgba(255, 255, 255, 0.85) !important; + } + + img { + opacity: 0.9; + } + + * { + color: rgba(255, 255, 255, 0.85) !important; + } + } + } + + /* Header 内容区域(中间部分,包含刷新按钮等) */ + .ant-pro-global-header-content { + background: transparent !important; + color: rgba(255, 255, 255, 0.85) !important; + + * { + color: rgba(255, 255, 255, 0.85) !important; + } + + /* 刷新按钮等图标 */ + .anticon { + color: rgba(255, 255, 255, 0.85) !important; + + &:hover { + color: #1890ff !important; + background: rgba(255, 255, 255, 0.08) !important; + border-radius: 4px; + } + } + + /* 工具提示内的图标 */ + .ant-tooltip-open .anticon { + color: rgba(255, 255, 255, 0.85) !important; + } + } + + /* Header 右侧内容区域(包含头像、语言选择、设置按钮) */ + .ant-pro-global-header-index-right { + background: transparent !important; + color: rgba(255, 255, 255, 0.85) !important; + + * { + color: rgba(255, 255, 255, 0.85) !important; + } + + /* 操作按钮(头像、语言、设置) */ + .ant-pro-global-header-index-action { + color: rgba(255, 255, 255, 0.85) !important; + + &:hover { + color: #1890ff !important; + background: rgba(255, 255, 255, 0.08) !important; + } + + .anticon { + color: rgba(255, 255, 255, 0.85) !important; + } + } + + /* 头像区域 */ + .ant-pro-account-avatar { + color: rgba(255, 255, 255, 0.85) !important; + + span { + color: rgba(255, 255, 255, 0.85) !important; + } + + .antd-pro-global-header-index-avatar { + background: rgba(255, 255, 255, 0.25) !important; + } + } + + /* 下拉菜单触发器 */ + .ant-pro-drop-down, + .ant-dropdown-trigger { + color: rgba(255, 255, 255, 0.85) !important; + + &:hover { + color: #1890ff !important; + background: rgba(255, 255, 255, 0.08) !important; + } + + .anticon { + color: rgba(255, 255, 255, 0.85) !important; + } + } + } + + /* 链接样式 */ + a { + color: rgba(255, 255, 255, 0.85) !important; + + &:hover { + color: #1890ff !important; + } + } + + /* 所有图标 */ + .anticon { + color: rgba(255, 255, 255, 0.85) !important; + + &:hover { + color: #1890ff !important; + } + } + + /* 浅色主题下的 .ant-pro-global-header */ + body.light .ant-pro-global-header, + .ant-layout.light .ant-pro-global-header, + .ant-pro-layout.light .ant-pro-global-header, + body.light .ant-layout-header .ant-pro-global-header, + .ant-pro-layout.light .ant-layout-header .ant-pro-global-header { + background: #fff !important; + border-bottom: 1px solid #e8e8e8 !important; + color: rgba(0, 0, 0, 0.85) !important; + } +} + +/* ========== 全局布局主题适配 - 强化覆盖 ========== */ +:global(.ant-pro-layout) { + /* 暗黑主题下的完整布局 */ + &.dark, + &.realdark { + /* 0. 基础 Layout 背景 - 确保整体背景变暗 */ + :global(.ant-layout) { + background-color: #001529 !important; + } + + /* 1. 顶部 Header */ + :global(.ant-layout-header) { + background: #001529 !important; + border-bottom: 1px solid #1f1f1f !important; + color: rgba(255, 255, 255, 0.85) !important; + + :global(.ant-pro-global-header) { + background: #001529 !important; + border-bottom: none !important; + color: rgba(255, 255, 255, 0.85) !important; + + * { + color: rgba(255, 255, 255, 0.85) !important; + } + } + } + + /* 2. 中间内容区域 */ + :global(.ant-layout-content) { + background: #141414 !important; + color: rgba(255, 255, 255, 0.85) !important; + } + + :global(.ant-pro-basicLayout-content) { + background: #141414 !important; + color: rgba(255, 255, 255, 0.85) !important; + } + + } + + /* 浅色主题下的完整布局 */ + &.light { + /* 0. 基础 Layout 背景 */ + :global(.ant-layout) { + background-color: #f0f2f5 !important; + } + + /* 1. 顶部 Header */ + :global(.ant-layout-header) { + background: #fff !important; + border-bottom: 1px solid #e8e8e8 !important; + color: rgba(0, 0, 0, 0.85) !important; + } + + /* 2. 中间内容区域 */ + :global(.ant-layout-content) { + background: #f0f2f5 !important; + color: rgba(0, 0, 0, 0.85) !important; + } + + :global(.ant-pro-basicLayout-content) { + background: #f0f2f5 !important; + color: rgba(0, 0, 0, 0.85) !important; + } + + } +} + +.ant-pro-global-header-index-right { + margin-right: 8px; + + &.ant-pro-global-header-index-dark { + .ant-pro-global-header-index-action { + color: hsl(0deg 0% 100% / 85%); + + &:hover { + background: #1890ff; + } + } + + /* 暗黑主题下的头像下拉菜单 */ + .ant-pro-account-avatar { + color: rgba(255, 255, 255, 0.85); + + .antd-pro-global-header-index-avatar { + background: rgba(255, 255, 255, 0.25) !important; + } + + span { + color: rgba(255, 255, 255, 0.85); + } + } + + /* 语言选择器和设置按钮 */ + .ant-pro-drop-down { + color: rgba(255, 255, 255, 0.85); + + &:hover { + color: #1890ff; + background: rgba(255, 255, 255, 0.08); + } + } + } + + /* 浅色主题下的头像样式 */ + .ant-pro-account-avatar { + .antd-pro-global-header-index-avatar { + margin: ~'calc((@{layout-header-height} - 24px) / 2)' 0; + margin-right: 8px; + color: @primary-color; + vertical-align: top; + background: rgb(255 255 255 / 85%); + } + } + + .menu { + .anticon { + margin-right: 8px; + } + + .ant-dropdown-menu-item { + min-width: 100px; + } + } +} + +/* 暗黑主题下的下拉菜单样式 */ +:global { + .ant-pro-layout.dark, + .ant-pro-layout.realdark { + .ant-dropdown-menu { + background: #1f1f1f !important; + border: 1px solid #303030 !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important; + + .ant-dropdown-menu-item { + color: rgba(255, 255, 255, 0.85) !important; + + &:hover { + background: #262626 !important; + color: #1890ff !important; + } + } + + .ant-dropdown-menu-item-divider { + background: #303030 !important; + } + } + } +} + +/* 手机端适配 */ +@media (max-width: 768px) { + :global { + /* 使用更具体的选择器强制覆盖 */ + .ant-layout-header .ant-pro-global-header .ant-pro-global-header-logo, + .ant-pro-layout .ant-layout-header .ant-pro-global-header .ant-pro-global-header-logo, + body .ant-layout-header .ant-pro-global-header .ant-pro-global-header-logo, + .ant-pro-global-header-logo { + padding: 0 !important; + padding-left: 0 !important; + padding-right: 0 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + } + } +} + diff --git a/quantdinger_vue/src/layouts/BasicLayout.vue b/quantdinger_vue/src/layouts/BasicLayout.vue new file mode 100644 index 0000000..f1ebc75 --- /dev/null +++ b/quantdinger_vue/src/layouts/BasicLayout.vue @@ -0,0 +1,1025 @@ + + + + + diff --git a/quantdinger_vue/src/layouts/BlankLayout.vue b/quantdinger_vue/src/layouts/BlankLayout.vue new file mode 100644 index 0000000..1bfbfbf --- /dev/null +++ b/quantdinger_vue/src/layouts/BlankLayout.vue @@ -0,0 +1,16 @@ + + + + + diff --git a/quantdinger_vue/src/layouts/PageView.vue b/quantdinger_vue/src/layouts/PageView.vue new file mode 100644 index 0000000..86df485 --- /dev/null +++ b/quantdinger_vue/src/layouts/PageView.vue @@ -0,0 +1,12 @@ + + + diff --git a/quantdinger_vue/src/layouts/RouteView.vue b/quantdinger_vue/src/layouts/RouteView.vue new file mode 100644 index 0000000..edae19e --- /dev/null +++ b/quantdinger_vue/src/layouts/RouteView.vue @@ -0,0 +1,32 @@ + diff --git a/quantdinger_vue/src/layouts/UserLayout.vue b/quantdinger_vue/src/layouts/UserLayout.vue new file mode 100644 index 0000000..66ce38a --- /dev/null +++ b/quantdinger_vue/src/layouts/UserLayout.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/quantdinger_vue/src/layouts/index.js b/quantdinger_vue/src/layouts/index.js new file mode 100644 index 0000000..1d62d6c --- /dev/null +++ b/quantdinger_vue/src/layouts/index.js @@ -0,0 +1,7 @@ +import UserLayout from './UserLayout' +import BlankLayout from './BlankLayout' +import BasicLayout from './BasicLayout' +import RouteView from './RouteView' +import PageView from './PageView' + +export { UserLayout, BasicLayout, BlankLayout, RouteView, PageView } diff --git a/quantdinger_vue/src/locales/index.js b/quantdinger_vue/src/locales/index.js new file mode 100644 index 0000000..69beb80 --- /dev/null +++ b/quantdinger_vue/src/locales/index.js @@ -0,0 +1,68 @@ +import Vue from 'vue' +import VueI18n from 'vue-i18n' +import storage from 'store' +import moment from 'moment' + +// default lang +import enUS from './lang/en-US' + +Vue.use(VueI18n) + +export const defaultLang = 'en-US' + +const messages = { + 'en-US': { + ...enUS + } +} + +const i18n = new VueI18n({ + silentTranslationWarn: true, + locale: defaultLang, + fallbackLocale: defaultLang, + messages +}) + +const loadedLanguages = [defaultLang] + +function setI18nLanguage (lang) { + i18n.locale = lang + const html = document.documentElement + const isRtl = /^ar/i.test(lang) + if (html) { + // request.headers['Accept-Language'] = lang + html.setAttribute('lang', lang) + html.setAttribute('dir', isRtl ? 'rtl' : 'ltr') + } + if (document.body) { + document.body.setAttribute('dir', isRtl ? 'rtl' : 'ltr') + document.body.classList.toggle('rtl', isRtl) + } + return lang +} + +export function loadLanguageAsync (lang = defaultLang) { + return new Promise(resolve => { + // 缓存语言设置 + storage.set('lang', lang) + if (i18n.locale !== lang) { + if (!loadedLanguages.includes(lang)) { + return import(/* webpackChunkName: "lang-[request]" */ `./lang/${lang}`).then(msg => { + const locale = msg.default + i18n.setLocaleMessage(lang, locale) + loadedLanguages.push(lang) + moment.updateLocale(locale.momentName, locale.momentLocale) + return setI18nLanguage(lang) + }) + } + return resolve(setI18nLanguage(lang)) + } + return resolve(lang) + }) +} + +export function i18nRender (key) { + return i18n.t(`${key}`) +} + +export default i18n diff --git a/quantdinger_vue/src/locales/lang/ar-SA.js b/quantdinger_vue/src/locales/lang/ar-SA.js new file mode 100644 index 0000000..7758790 --- /dev/null +++ b/quantdinger_vue/src/locales/lang/ar-SA.js @@ -0,0 +1,1767 @@ +import antdArEG from 'ant-design-vue/es/locale-provider/ar_EG' +import momentAR from 'moment/locale/ar' + +const components = { + antLocale: antdArEG, + momentName: 'ar', + momentLocale: momentAR +} +const locale = { + 'submit': 'إرسال', + 'save': 'حفظ', + 'submit.ok': 'تم التقديم بنجاح', + 'save.ok': 'تم الحفظ بنجاح', + 'menu.welcome': 'مرحبا بكم', + 'menu.home': 'الصفحة الرئيسية', + 'menu.dashboard': 'لوحة القيادة', + 'menu.dashboard.indicator': 'تحليل المؤشر', + 'menu.dashboard.community': 'مجتمع المؤشرات', + 'menu.dashboard.analysis': 'تحليل الذكاء الاصطناعي', + 'menu.dashboard.tradingAssistant': 'مساعد التداول', + 'menu.dashboard.aiTradingAssistant': 'مساعد التداول بالذكاء الاصطناعي', + 'menu.dashboard.signalRobot': 'روبوت الإشارة', + 'menu.dashboard.monitor': 'صفحة المراقبة', + 'menu.dashboard.workplace': 'طاولة العمل', + 'menu.form': 'صفحة النموذج', + 'menu.form.basic-form': 'الشكل الأساسي', + 'menu.form.step-form': 'شكل خطوة بخطوة', + 'menu.form.step-form.info': 'نموذج خطوة بخطوة (املأ معلومات النقل)', + 'menu.form.step-form.confirm': 'نموذج خطوة بخطوة (تأكيد معلومات النقل)', + 'menu.form.step-form.result': 'نموذج خطوة بخطوة (كامل)', + 'menu.form.advanced-form': 'نماذج متقدمة', + 'menu.list': 'صفحة القائمة', + 'menu.list.table-list': 'نموذج الاستفسار', + 'menu.list.basic-list': 'القائمة القياسية', + 'menu.list.card-list': 'قائمة البطاقات', + 'menu.list.search-list': 'قائمة البحث', + 'menu.list.search-list.articles': 'قائمة البحث (المقالات)', + 'menu.list.search-list.projects': 'قائمة البحث (المشروع)', + 'menu.list.search-list.applications': 'قائمة البحث (التطبيق)', + 'menu.profile': 'صفحة التفاصيل', + 'menu.profile.basic': 'صفحة التفاصيل الأساسية', + 'menu.profile.advanced': 'صفحة التفاصيل المتقدمة', + 'menu.result': 'صفحة النتائج', + 'menu.result.success': 'صفحة النجاح', + 'menu.result.fail': 'صفحة الفشل', + 'menu.exception': 'صفحة الاستثناء', + 'menu.exception.not-permission': '403', + 'menu.exception.not-find': '404', + 'menu.exception.server-error': '500', + 'menu.exception.trigger': 'خطأ الزناد', + 'menu.account': 'الصفحة الشخصية', + 'menu.account.center': 'مركز شخصي', + 'menu.account.settings': 'الإعدادات الشخصية', + 'menu.account.trigger': 'خطأ الزناد', + 'menu.account.logout': 'تسجيل الخروج', + 'menu.wallet': 'محفظتي', + 'menu.docs': 'مركز الوثائق', + 'menu.docs.detail': 'تفاصيل الوثيقة', + 'menu.header.refreshPage': 'تحديث الصفحة', + 'menu.invite.friends': 'دعوة الأصدقاء', + 'app.setting.pagestyle': 'إعدادات النمط الشاملة', + 'app.setting.pagestyle.light': 'نمط القائمة مشرق', + 'app.setting.pagestyle.dark': 'نمط القائمة المظلمة', + 'app.setting.pagestyle.realdark': 'الوضع المظلم', + 'app.setting.themecolor': 'لون الموضوع', + 'app.setting.navigationmode': 'وضع التنقل', + 'app.setting.sidemenu.nav': 'التنقل في الشريط الجانبي', + 'app.setting.topmenu.nav': 'التنقل في الشريط العلوي', + 'app.setting.content-width': 'عرض منطقة المحتوى', + 'app.setting.content-width.tooltip': 'يكون هذا الإعداد فعالاً فقط عندما يكون [Top Bar Navigation]', + 'app.setting.content-width.fixed': 'ثابت', + 'app.setting.content-width.fluid': 'البث', + 'app.setting.fixedheader': 'رأس ثابت', + 'app.setting.fixedheader.tooltip': 'شكلي عندما رأس ثابت', + 'app.setting.autoHideHeader': 'إخفاء الرأس عند التمرير', + 'app.setting.fixedsidebar': 'القائمة الجانبية الثابتة', + 'app.setting.sidemenu': 'تخطيط القائمة الجانبية', + 'app.setting.topmenu': 'تخطيط القائمة العلوية', + 'app.setting.othersettings': 'إعدادات أخرى', + 'app.setting.weakmode': 'وضع ضعف اللون', + 'app.setting.multitab': 'وضع علامات التبويب المتعددة', + 'app.setting.copy': 'إعدادات النسخ', + 'app.setting.loading': 'جارٍ تحميل الموضوع', + 'app.setting.copyinfo': 'انسخ الإعدادات بنجاح src/config/defaultSettings.js', + 'app.setting.copy.success': 'اكتمل النسخ', + 'app.setting.copy.fail': 'فشل النسخ', + 'app.setting.theme.switching': 'تغيير الموضوع!', + 'app.setting.production.hint': 'يتم استخدام شريط التكوين فقط للمعاينة في بيئة التطوير ولن يتم عرضه في بيئة الإنتاج. يرجى نسخ وتعديل ملف التكوين يدويا.', + 'app.setting.themecolor.daybreak': 'الأزرق الداكن (افتراضي)', + 'app.setting.themecolor.dust': 'الغسق', + 'app.setting.themecolor.volcano': 'بركان', + 'app.setting.themecolor.sunset': 'غروب الشمس', + 'app.setting.themecolor.cyan': 'مينغكينغ', + 'app.setting.themecolor.green': 'أورورا الخضراء', + 'app.setting.themecolor.geekblue': 'المهوس الأزرق', + 'app.setting.themecolor.purple': 'جيانغ زي', + 'app.setting.tooltip': 'إعدادات الصفحة', + 'user.login.userName': 'اسم المستخدم', + 'user.login.password': 'كلمة المرور', + 'user.login.username.placeholder': 'الحساب: المشرف', + 'user.login.password.placeholder': 'كلمة المرور: admin أو ant.design', + 'user.login.message-invalid-credentials': 'فشل تسجيل الدخول، يرجى التحقق من البريد الإلكتروني الخاص بك ورمز التحقق', + 'user.login.message-invalid-verification-code': 'خطأ في رمز التحقق', + 'user.login.tab-login-credentials': 'تسجيل الدخول بكلمة مرور الحساب', + 'user.login.tab-login-email': 'تسجيل الدخول بالبريد الإلكتروني', + 'user.login.tab-login-mobile': 'تسجيل الدخول برقم الهاتف المحمول', + 'user.login.captcha.placeholder': 'الرجاء إدخال رمز التحقق الرسومي', + 'user.login.mobile.placeholder': 'رقم الهاتف المحمول', + 'user.login.mobile.verification-code.placeholder': 'رمز التحقق', + 'user.login.email.placeholder': 'الرجاء إدخال عنوان البريد الإلكتروني الخاص بك', + 'user.login.email.verification-code.placeholder': 'الرجاء إدخال رمز التحقق', + 'user.login.email.sending': 'جاري إرسال رمز التحقق...', + 'user.login.email.send-success-title': 'نصائح', + 'user.login.email.send-success': 'تم إرسال رمز التحقق بنجاح، يرجى التحقق من بريدك الإلكتروني', + 'user.login.sms.send-success': 'تم إرسال رمز التحقق بنجاح، يرجى التحقق من الرسالة النصية', + 'user.login.remember-me': 'تسجيل الدخول التلقائي', + 'user.login.forgot-password': 'نسيت كلمة المرور', + 'user.login.sign-in-with': 'طرق تسجيل الدخول الأخرى', + 'user.login.signup': 'تسجيل حساب', + 'user.login.login': 'تسجيل الدخول', + 'user.register.register': 'سجل', + 'user.register.email.placeholder': 'البريد الإلكتروني', + 'user.register.password.placeholder': 'الرجاء إدخال 6 أحرف على الأقل. يرجى عدم استخدام كلمات المرور التي يمكن تخمينها بسهولة.', + 'user.register.password.popover-message': 'الرجاء إدخال 6 أحرف على الأقل. يرجى عدم استخدام كلمات المرور التي يمكن تخمينها بسهولة.', + 'user.register.confirm-password.placeholder': 'تأكيد كلمة المرور', + 'user.register.get-verification-code': 'الحصول على رمز التحقق', + 'user.register.sign-in': 'قم بتسجيل الدخول باستخدام حساب موجود', + 'user.register-result.msg': 'حسابك: {email} تم التسجيل بنجاح', + 'user.register-result.activation-email': 'تم إرسال رسالة التفعيل إلى صندوق البريد الخاص بك وهي صالحة لمدة 24 ساعة. يرجى تسجيل الدخول إلى بريدك الإلكتروني على الفور والنقر على الرابط الموجود في البريد الإلكتروني لتفعيل حسابك.', + 'user.register-result.back-home': 'العودة إلى الصفحة الرئيسية', + 'user.register-result.view-mailbox': 'تحقق من صندوق البريد الخاص بك', + 'user.email.required': 'الرجاء إدخال عنوان البريد الإلكتروني الخاص بك!', + 'user.email.wrong-format': 'تنسيق عنوان البريد الإلكتروني خاطئ!', + 'user.userName.required': 'الرجاء إدخال اسم حسابك أو عنوان البريد الإلكتروني الخاص بك', + 'user.password.required': 'الرجاء إدخال كلمة المرور الخاصة بك!', + 'user.password.twice.msg': 'كلمات المرور التي تم إدخالها مرتين غير متطابقة!', + 'user.password.strength.msg': 'كلمة المرور ليست قوية بما فيه الكفاية', + 'user.password.strength.strong': 'القوة : قوية', + 'user.password.strength.medium': 'القوة: متوسطة', + 'user.password.strength.low': 'القوة: منخفضة', + 'user.password.strength.short': 'القوة : قصيرة جداً', + 'user.confirm-password.required': 'الرجاء تأكيد كلمة المرور الخاصة بك!', + 'user.phone-number.required': 'الرجاء إدخال رقم الهاتف المحمول الصحيح', + 'user.phone-number.wrong-format': 'تنسيق رقم الهاتف المحمول خاطئ!', + 'user.verification-code.required': 'الرجاء إدخال رمز التحقق!', + 'user.captcha.required': 'الرجاء إدخال رمز التحقق الرسومي!', + 'user.login.infos': 'QuantDinger هي أداة مساعدة لتحليل الأسهم متعددة الوكلاء تعمل بالذكاء الاصطناعي ولا تتمتع بمؤهلات استشارية في مجال الاستثمار في الأوراق المالية. يتم إنشاء جميع نتائج التحليل والدرجات والآراء المرجعية في المنصة تلقائيًا بواسطة الذكاء الاصطناعي استنادًا إلى البيانات التاريخية وتستخدم فقط للتعلم والبحث والتبادل الفني، ولا تشكل أي نصيحة استثمارية أو أساس لصنع القرار. ينطوي الاستثمار في الأسهم على مخاطر مختلفة مثل مخاطر السوق، ومخاطر السيولة، ومخاطر السياسة، وما إلى ذلك، مما قد يؤدي إلى خسارة رأس المال. يجب على المستخدمين اتخاذ قرارات مستقلة بناءً على قدرتهم على تحمل المخاطر، ويجب أن يتحمل المستخدم أي سلوك استثماري وعواقب تنشأ عن استخدام هذه الأداة. السوق محفوف بالمخاطر والاستثمار يحتاج إلى الحذر.', + 'user.login.tab-login-web3': 'تسجيل الدخول إلى Web3', + 'user.login.web3.tip': 'تسجيل الدخول بالتوقيع باستخدام المحفظة', + 'user.login.web3.connect': 'ربط المحفظة وتسجيل الدخول', + 'user.login.web3.no-wallet': 'لم يتم الكشف عن المحفظة', + 'user.login.web3.no-address': 'لم يتم الحصول على عنوان المحفظة', + 'user.login.web3.nonce-failed': 'فشل الحصول على رقم عشوائي', + 'user.login.web3.verify-failed': 'فشل التحقق من التوقيع', + 'user.login.web3.success': 'تم تسجيل الدخول بنجاح', + 'user.login.web3.failed': 'فشل تسجيل الدخول إلى المحفظة', + 'nav.no_wallet': 'لم يتم الكشف عن المحفظة', + 'nav.copy': 'نسخ', + 'nav.copy_success': 'تم النسخ بنجاح', + 'user.login.oauth.google': 'قم بتسجيل الدخول باستخدام جوجل', + 'user.login.oauth.github': 'قم بتسجيل الدخول باستخدام جيثب', + 'user.login.oauth.loading': 'جارٍ إعادة التوجيه إلى صفحة التفويض...', + 'user.login.oauth.failed': 'فشل تسجيل الدخول باستخدام OAuth', + 'user.login.oauth.get-url-failed': 'فشل الحصول على رابط الترخيص', + 'user.login.subtitle': 'رؤى كمية مدفوعة في الأسواق العالمية', + 'user.login.legal.title': 'إخلاء المسؤولية القانونية', + 'user.login.legal.view': 'عرض إخلاء المسؤولية القانونية', + 'user.login.legal.collapse': 'طي إخلاء المسؤولية القانونية', + 'user.login.legal.agree': 'لقد قرأت ووافقت على إخلاء المسؤولية القانونية', + 'user.login.legal.required': 'يرجى قراءة والتحقق من إخلاء المسؤولية القانونية أولا', + 'user.login.legal.content': 'QuantDinger هي أداة مساعدة لتحليل الأسهم متعددة الوكلاء تعمل بالذكاء الاصطناعي ولا تتمتع بمؤهلات استشارية في مجال الاستثمار في الأوراق المالية. يتم إنشاء جميع نتائج التحليل والتقييمات والآراء المرجعية في المنصة تلقائيًا بواسطة الذكاء الاصطناعي استنادًا إلى البيانات التاريخية وتستخدم فقط للتعلم والبحث والتبادل الفني، ولا تشكل أي نصيحة استثمارية أو أساس لصنع القرار. ينطوي الاستثمار في الأسهم على مخاطر مختلفة مثل مخاطر السوق، ومخاطر السيولة، ومخاطر السياسة، وما إلى ذلك، مما قد يؤدي إلى خسارة رأس المال. يجب على المستخدمين اتخاذ قرارات مستقلة بناءً على قدرتهم على تحمل المخاطر، ويجب أن يتحمل المستخدم أي سلوك استثماري وعواقب تنشأ عن استخدام هذه الأداة. السوق محفوف بالمخاطر والاستثمار يحتاج إلى الحذر.', + 'user.login.privacy.title': 'سياسة خصوصية المستخدم', + 'user.login.privacy.view': 'عرض سياسة خصوصية المستخدم', + 'user.login.privacy.collapse': 'إغلاق شروط خصوصية المستخدم', + 'user.login.privacy.content': 'نحن نقدر خصوصيتك وحماية البيانات. 1) نطاق التجميع: يتم جمع المعلومات المطلوبة لتنفيذ الوظيفة فقط (مثل البريد الإلكتروني ورقم الهاتف المحمول ورمز المنطقة وعنوان محفظة Web3) والسجلات الضرورية ومعلومات الجهاز. 2) الغرض من الاستخدام: لتسجيل الدخول إلى الحساب والتحقق من الأمان وتوفير وظيفة الخدمة واستكشاف الأخطاء وإصلاحها ومتطلبات الامتثال. 3) التخزين والأمان: يتم تشفير البيانات وتخزينها، ويتم اتخاذ الأذونات اللازمة وإجراءات التحكم في الوصول لمحاولة منع الوصول غير المصرح به أو الكشف عنها أو فقدانها. 4) المشاركة مع أطراف ثالثة: ما لم يكن ذلك مطلوبًا بموجب القوانين واللوائح أو ضروريًا لأداء الخدمات، لن تتم مشاركة معلوماتك الشخصية مع أطراف ثالثة؛ إذا كانت هناك خدمات تابعة لجهات خارجية (مثل المحافظ ومقدمي خدمات الرسائل النصية القصيرة)، فلن تتم معالجتها إلا إلى الحد الأدنى اللازم لتنفيذ الوظيفة. 5) ملفات تعريف الارتباط/التخزين المحلي: تُستخدم لحالة تسجيل الدخول وصيانة الجلسة الضرورية (مثل الرموز المميزة، PHPSESSID)، ويمكنك مسحها أو تقييدها في المتصفح. 6) الحقوق الشخصية: يمكنك ممارسة حقوقك في الاستفسار والتصحيح والحذف وسحب الموافقة وما إلى ذلك وفقًا للقوانين واللوائح. 7) التغييرات والإشعارات: عند تحديث هذه الشروط، سيتم عرضها بشكل بارز على الصفحة. من خلال الاستمرار في استخدام هذه الخدمة، يعتبر أنك قد قرأت المحتوى المحدث ووافقت عليه. إذا كنت لا توافق على هذه الشروط أو أي تحديثات لها، فيرجى التوقف عن استخدام الخدمة والاتصال بنا.', + 'account.basicInfo': 'المعلومات الأساسية', + 'account.id': 'معرف المستخدم', + 'account.username': 'اسم المستخدم', + 'account.nickname': 'اللقب', + 'account.email': 'البريد الإلكتروني', + 'account.mobile': 'رقم الهاتف المحمول', + 'account.web3address': 'عنوان المحفظة', + 'account.pid': 'معرف المرجع', + 'account.level': 'مستوى المستخدم', + 'account.money': 'التوازن', + 'account.qdtBalance': 'رصيد كيو دي تي', + 'account.score': 'النقاط', + 'account.createtime': 'وقت التسجيل', + 'account.inviteLink': 'رابط الدعوة', + 'account.recharge': 'إعادة الشحن', + 'account.rechargeTip': 'يرجى استخدام WeChat أو Alipay لمسح رمز الاستجابة السريعة أدناه لإعادة الشحن.', + 'account.qrCodePlaceholder': 'العنصر النائب لرمز الاستجابة السريعة (المحاكاة)', + 'account.rechargeAmount': 'مبلغ إعادة الشحن', + 'account.enterAmount': 'الرجاء إدخال مبلغ إعادة الشحن', + 'account.rechargeHint': 'الحد الأدنى لمبلغ الإيداع: 1 QDT', + 'account.confirmRecharge': 'تأكيد إعادة الشحن', + 'account.enterValidAmount': 'الرجاء إدخال مبلغ تعبئة صالح', + 'account.rechargeSuccess': 'تم إعادة الشحن بنجاح! تم إيداع {amount} QDT', + 'account.settings.menuMap.basic': 'الإعدادات الأساسية', + 'account.settings.menuMap.security': 'إعدادات الأمان', + 'account.settings.menuMap.notification': 'إشعار الرسالة الجديدة', + 'account.settings.menuMap.moneyLog': 'تفاصيل الصندوق', + 'account.moneyLog.empty': 'لا توجد تفاصيل الصندوق حتى الآن', + 'account.moneyLog.total': '{total} السجلات في المجموع', + 'account.moneyLog.type.purchase': 'مؤشر الشراء', + 'account.moneyLog.type.recharge': 'إعادة الشحن', + 'account.moneyLog.type.refund': 'استرداد', + 'account.moneyLog.type.reward': 'مكافأة', + 'account.moneyLog.type.income': 'دخل المؤشر', + 'account.moneyLog.type.commission': 'رسوم التعامل مع المنصة', + 'wallet.balance': 'رصيد كيو دي تي', + 'wallet.recharge': 'إعادة الشحن', + 'wallet.withdraw': 'سحب النقود', + 'wallet.filter': 'تصفية', + 'wallet.reset': 'إعادة تعيين', + 'wallet.totalRecharge': 'التغذية المتراكمة', + 'wallet.totalWithdraw': 'عمليات السحب التراكمية', + 'wallet.totalIncome': 'الدخل التراكمي', + 'wallet.records': 'سجلات المعاملات وتفاصيل الصندوق', + 'wallet.tradingRecords': 'تاريخ المعاملة', + 'wallet.moneyLog': 'تفاصيل الصندوق', + 'wallet.rechargeTip': 'يرجى استخدام WeChat أو Alipay لمسح رمز الاستجابة السريعة أدناه لإعادة الشحن.', + 'wallet.qrCodePlaceholder': 'العنصر النائب لرمز الاستجابة السريعة (المحاكاة)', + 'wallet.rechargeAmount': 'مبلغ إعادة الشحن', + 'wallet.enterAmount': 'الرجاء إدخال مبلغ إعادة الشحن', + 'wallet.rechargeHint': 'الحد الأدنى لمبلغ الإيداع: 1 QDT', + 'wallet.confirmRecharge': 'تأكيد إعادة الشحن', + 'wallet.enterValidAmount': 'الرجاء إدخال مبلغ تعبئة صالح', + 'wallet.rechargeSuccess': 'تم إعادة الشحن بنجاح! تم إيداع {amount} QDT', + 'wallet.rechargeFailed': 'فشلت عملية إعادة الشحن', + 'wallet.withdrawTip': 'الرجاء إدخال مبلغ السحب وعنوان السحب', + 'wallet.withdrawAmount': 'سحب المبلغ', + 'wallet.enterWithdrawAmount': 'الرجاء إدخال مبلغ السحب', + 'wallet.withdrawHint': 'الحد الأدنى لمبلغ السحب: 1 QDT', + 'wallet.withdrawAddress': 'عنوان السحب (اختياري)', + 'wallet.enterWithdrawAddress': 'الرجاء إدخال عنوان السحب', + 'wallet.confirmWithdraw': 'تأكيد الانسحاب', + 'wallet.enterValidWithdrawAmount': 'الرجاء إدخال مبلغ سحب صالح', + 'wallet.insufficientBalance': 'رصيد غير كاف', + 'wallet.withdrawSuccess': 'تم السحب بنجاح! تم سحب {المبلغ} QDT', + 'wallet.withdrawFailed': 'فشل الانسحاب', + 'wallet.noTradingRecords': 'لا يوجد سجل المعاملات حتى الآن', + 'wallet.noMoneyLog': 'لا توجد تفاصيل الصندوق حتى الآن', + 'wallet.loadTradingRecordsFailed': 'فشل في تحميل سجلات المعاملات', + 'wallet.loadMoneyLogFailed': 'فشل في تحميل تفاصيل الصندوق', + 'wallet.moneyLogTotal': '{total} السجلات في المجموع', + 'wallet.moneyLogTypeTitle': 'اكتب', + 'wallet.moneyLogType.all': 'جميع الأنواع', + 'wallet.table.time': 'الوقت', + 'wallet.table.type': 'اكتب', + 'wallet.table.price': 'السعر', + 'wallet.table.amount': 'الكمية', + 'wallet.table.money': 'المبلغ', + 'wallet.table.balance': 'التوازن', + 'wallet.table.memo': 'ملاحظات', + 'wallet.table.value': 'قيمة', + 'wallet.table.profit': 'الربح والخسارة', + 'wallet.table.commission': 'رسوم المناولة', + 'wallet.table.total': '{total} السجلات في المجموع', + 'wallet.tradeType.buy': 'شراء', + 'wallet.tradeType.sell': 'بيع', + 'wallet.tradeType.liquidation': 'التصفية القسرية', + 'wallet.tradeType.openLong': 'مفتوح لفترة طويلة', + 'wallet.tradeType.addLong': 'غادوت', + 'wallet.tradeType.closeLong': 'بيندو', + 'wallet.tradeType.closeLongStop': 'وقف الخسارة وطويلة', + 'wallet.tradeType.closeLongProfit': 'جني الربح ومستوى أكثر', + 'wallet.tradeType.openShort': 'فتح قصيرة', + 'wallet.tradeType.addShort': 'إضافة قصيرة', + 'wallet.tradeType.closeShort': 'فارغ', + 'wallet.tradeType.closeShortStop': 'وقف الخسارة', + 'wallet.tradeType.closeShortProfit': 'جني الأرباح وأغلق على المكشوف', + 'wallet.moneyLogType.purchase': 'مؤشر الشراء', + 'wallet.moneyLogType.recharge': 'إعادة الشحن', + 'wallet.moneyLogType.withdraw': 'سحب النقود', + 'wallet.moneyLogType.refund': 'استرداد', + 'wallet.moneyLogType.reward': 'مكافأة', + 'wallet.moneyLogType.income': 'الدخل', + 'wallet.moneyLogType.commission': 'رسوم المناولة', + 'wallet.web3Address.required': 'يرجى ملء عنوان محفظة Web3 أولاً', + 'wallet.web3Address.requiredDescription': 'قبل إعادة الشحن، تحتاج إلى ربط عنوان محفظة Web3 الخاص بك لتلقي إعادة الشحن.', + 'wallet.web3Address.placeholder': 'الرجاء إدخال عنوان محفظة Web3 الخاص بك (يبدأ بـ 0x)', + 'wallet.web3Address.save': 'حفظ عنوان المحفظة', + 'wallet.web3Address.saveSuccess': 'تم حفظ عنوان المحفظة بنجاح', + 'wallet.web3Address.saveFailed': 'فشل في حفظ عنوان المحفظة', + 'wallet.web3Address.invalidFormat': 'يرجى إدخال عنوان محفظة Web3 صالح (تنسيق Ethereum: 42 حرفًا يبدأ بـ 0x، أو تنسيق TRC20: 34 حرفًا يبدأ بـ T)', + 'wallet.selectCoin': 'اختر العملة', + 'wallet.selectChain': 'سلسلة الاختيار', + 'wallet.chain.eth': 'إيثريوم (ERC20)', + 'wallet.chain.trc20': 'TRC20', + 'wallet.chain.bsc': 'ش.م.ب', + 'wallet.targetQdtAmount': 'مقدار QDT الذي تريد استرداده (اختياري)', + 'wallet.targetQdtAmount.placeholder': 'يرجى إدخال كمية QDT التي تريد استردادها، وسعر QDT الحالي: {price} USDT', + 'wallet.targetQdtAmount.requiredUsdt': 'تحتاج إلى إعادة الشحن: {amount} USDT', + 'wallet.rechargeAddress': 'عنوان إعادة الشحن', + 'wallet.copyAddress': 'نسخ', + 'wallet.copySuccess': 'تم النسخ بنجاح!', + 'wallet.copyFailed': 'فشل النسخ، يرجى النسخ يدويًا', + 'wallet.rechargeAddressHint': 'يرجى التأكد من استخدام {chain} لإيداع {coin} في هذا العنوان', + 'wallet.qdtPrice.loading': 'جار التحميل...', + 'wallet.rechargeTip.new': 'يرجى تحديد العملة والسلسلة، ثم مسح رمز الاستجابة السريعة أدناه لإعادة الشحن', + 'wallet.exchangeRate': '1 QDT ≈ {معدل} USDT', + 'wallet.withdrawableAmount': 'المبلغ النقدي المتاح', + 'wallet.totalBalance': 'الرصيد الإجمالي', + 'wallet.insufficientWithdrawable': 'المبلغ النقدي الذي يمكن سحبه غير كاف. المبلغ الحالي الذي يمكن سحبه هو: {amount} QDT', + 'wallet.withdrawAddressRequired': 'الرجاء إدخال عنوان السحب', + 'wallet.withdrawAddressHint': 'يرجى التأكد من صحة العنوان. وبعد الانسحاب لا يمكن إلغاؤه.', + 'wallet.withdrawSubmitSuccess': 'تم إرسال طلب السحب بنجاح، برجاء انتظار المراجعة', + 'menu.footer.contactUs': 'اتصل بنا', + 'menu.footer.getSupport': 'احصل على الدعم', + 'menu.footer.socialAccounts': 'حساب اجتماعي', + 'menu.footer.userAgreement': 'اتفاقية المستخدم', + 'menu.footer.privacyPolicy': 'سياسة الخصوصية', + 'menu.footer.support': 'الدعم', + 'menu.footer.featureRequest': 'طلب الميزة', + 'menu.footer.email': 'البريد الإلكتروني', + 'menu.footer.liveChat': '24/7 الدردشة الحية', + 'dashboard.analysis.title': 'التحليل متعدد الأبعاد', + 'dashboard.analysis.subtitle': 'منصة تحليل مالي شاملة تعتمد على الذكاء الاصطناعي', + 'dashboard.analysis.selectSymbol': 'حدد أو أدخل الرمز الأساسي', + 'dashboard.analysis.selectModel': 'حدد النموذج', + 'dashboard.analysis.startAnalysis': 'ابدأ التحليل', + 'dashboard.analysis.history': 'التاريخ', + 'dashboard.analysis.tab.overview': 'تحليل شامل', + 'dashboard.analysis.tab.fundamental': 'الأساسيات', + 'dashboard.analysis.tab.technical': 'التكنولوجيا', + 'dashboard.analysis.tab.news': 'أخبار', + 'dashboard.analysis.tab.sentiment': 'العواطف', + 'dashboard.analysis.tab.risk': 'خطر', + 'dashboard.analysis.tab.debate': 'المناقشة الطويلة والقصيرة', + 'dashboard.analysis.tab.decision': 'القرار النهائي', + 'dashboard.analysis.empty.selectSymbol': 'حدد هدفًا لبدء التحليل', + 'dashboard.analysis.empty.selectSymbolDesc': 'اختر من قائمة الأسهم المحددة ذاتيًا أو أدخل الكود الأساسي للحصول على تقرير تحليل الذكاء الاصطناعي متعدد الأبعاد', + 'dashboard.analysis.empty.startAnalysis': 'انقر فوق الزر "بدء التحليل" لإجراء تحليل متعدد الأبعاد', + 'dashboard.analysis.empty.startAnalysisDesc': 'سنزودك بتحليل شامل من أبعاد متعددة مثل الأساسيات والتكنولوجيا والأخبار والمشاعر والمخاطر', + 'dashboard.analysis.empty.noData': 'لا توجد حاليًا أي بيانات تحليل {type}، يرجى إجراء تحليل شامل أولاً', + 'dashboard.analysis.empty.noWatchlist': 'لا يوجد حاليا أي أسهم مختارة ذاتيا', + 'dashboard.analysis.empty.noHistory': 'لا يوجد تاريخ بعد', + 'dashboard.analysis.empty.watchlistHint': 'لا يوجد حاليا أي أسهم مختارة ذاتيا، يرجى أولا', + 'dashboard.analysis.empty.noDebateData': 'لا توجد بيانات مناقشة حتى الآن', + 'dashboard.analysis.empty.noDecisionData': 'لا توجد بيانات القرار حتى الآن', + 'dashboard.analysis.empty.selectAgent': 'الرجاء تحديد وكيل لعرض نتائج التحليل', + 'dashboard.analysis.loading.analyzing': 'جارٍ التحليل، يرجى الانتظار...', + 'dashboard.analysis.loading.fundamental': 'تحليل البيانات الأساسية...', + 'dashboard.analysis.loading.technical': 'تحليل المؤشرات الفنية...', + 'dashboard.analysis.loading.news': 'تحليل بيانات الأخبار...', + 'dashboard.analysis.loading.sentiment': 'تحليل معنويات السوق..', + 'dashboard.analysis.loading.risk': 'تقييم المخاطر...', + 'dashboard.analysis.loading.debate': 'والنقاش الطويل والقصير مستمر..', + 'dashboard.analysis.loading.decision': 'إصدار القرار النهائي...', + 'dashboard.analysis.score.overall': 'التقييم العام', + 'dashboard.analysis.score.recommendation': 'نصيحة استثمارية', + 'dashboard.analysis.score.confidence': 'الثقة', + 'dashboard.analysis.dimension.fundamental': 'الأساسيات', + 'dashboard.analysis.dimension.technical': 'التكنولوجيا', + 'dashboard.analysis.dimension.news': 'أخبار', + 'dashboard.analysis.dimension.sentiment': 'العواطف', + 'dashboard.analysis.dimension.risk': 'خطر', + 'dashboard.analysis.card.dimensionScores': 'التقييمات لكل البعد', + 'dashboard.analysis.card.overviewReport': 'تقرير التحليل الشامل', + 'dashboard.analysis.card.financialMetrics': 'المؤشرات المالية', + 'dashboard.analysis.card.fundamentalReport': 'تقرير التحليل الأساسي', + 'dashboard.analysis.card.technicalIndicators': 'المؤشرات الفنية', + 'dashboard.analysis.card.technicalReport': 'تقرير التحليل الفني', + 'dashboard.analysis.card.newsList': 'أخبار ذات صلة', + 'dashboard.analysis.card.newsReport': 'تقرير تحليل الأخبار', + 'dashboard.analysis.card.sentimentIndicators': 'مؤشر المشاعر', + 'dashboard.analysis.card.sentimentReport': 'تقرير تحليل المشاعر', + 'dashboard.analysis.card.riskMetrics': 'مؤشرات المخاطر', + 'dashboard.analysis.card.riskReport': 'تقرير تقييم المخاطر', + 'dashboard.analysis.card.bullView': 'النظرة الصعودية (الثور)', + 'dashboard.analysis.card.bearView': 'وجهة النظر الهبوطية (الدب)', + 'dashboard.analysis.card.researchConclusion': 'استنتاج الباحث', + 'dashboard.analysis.card.traderPlan': 'خطة التاجر', + 'dashboard.analysis.card.riskDebate': 'مناقشة لجنة المخاطر', + 'dashboard.analysis.card.finalDecision': 'القرار النهائي', + 'dashboard.analysis.card.tradePlanDetail': 'تفاصيل خطة التداول', + 'dashboard.analysis.tradingPlan.entry_price': 'سعر القبول', + 'dashboard.analysis.tradingPlan.position_size': 'حجم الموقف', + 'dashboard.analysis.tradingPlan.stop_loss': 'وقف الخسارة', + 'dashboard.analysis.tradingPlan.take_profit': 'جني الربح', + 'dashboard.analysis.label.confidence': 'الثقة', + 'dashboard.analysis.label.keyPoints': 'النقاط الأساسية', + 'dashboard.analysis.label.riskWarning': 'تحذير من المخاطر', + 'dashboard.analysis.risk.risky': 'محفوف بالمخاطر', + 'dashboard.analysis.risk.neutral': 'وجهة نظر محايدة (محايدة)', + 'dashboard.analysis.risk.safe': 'وجهة نظر محافظة (آمنة)', + 'dashboard.analysis.risk.conclusion': 'الاستنتاج', + 'dashboard.analysis.feature.fundamental': 'التحليل الأساسي', + 'dashboard.analysis.feature.technical': 'التحليل الفني', + 'dashboard.analysis.feature.news': 'تحليل الأخبار', + 'dashboard.analysis.feature.sentiment': 'تحليل المشاعر', + 'dashboard.analysis.feature.risk': 'تقييم المخاطر', + 'dashboard.analysis.watchlist.title': 'اختيار الأسهم الخاصة بي', + 'dashboard.analysis.watchlist.add': 'إضافة', + 'dashboard.analysis.watchlist.addStock': 'إضافة مخزون', + 'dashboard.analysis.modal.addStock.title': 'إضافة أسهم اختيارية', + 'dashboard.analysis.modal.addStock.confirm': 'حسنًا', + 'dashboard.analysis.modal.addStock.cancel': 'إلغاء', + 'dashboard.analysis.modal.addStock.market': 'نوع السوق', + 'dashboard.analysis.modal.addStock.marketPlaceholder': 'الرجاء تحديد السوق', + 'dashboard.analysis.modal.addStock.marketRequired': 'الرجاء تحديد نوع السوق', + 'dashboard.analysis.modal.addStock.symbol': 'رمز المخزون', + 'dashboard.analysis.modal.addStock.symbolPlaceholder': 'على سبيل المثال: AAPL، TSLA، GOOGL، 000001، BTC', + 'dashboard.analysis.modal.addStock.symbolRequired': 'الرجاء إدخال رمز المخزون', + 'dashboard.analysis.modal.addStock.searchPlaceholder': 'البحث عن رمز الهدف أو الاسم', + 'dashboard.analysis.modal.addStock.searchOrInputPlaceholder': 'ابحث عن الرمز الأساسي أو أدخله (على سبيل المثال: AAPL، BTC/USDT، EUR/USD)', + 'dashboard.analysis.modal.addStock.searchOrInputHint': 'يدعم البحث عن الكائنات في قاعدة البيانات، أو إدخال الكود مباشرة (سيحصل النظام على الاسم تلقائيًا)', + 'dashboard.analysis.modal.addStock.search': 'بحث', + 'dashboard.analysis.modal.addStock.searchResults': 'نتائج البحث', + 'dashboard.analysis.modal.addStock.hotSymbols': 'أهداف شعبية', + 'dashboard.analysis.modal.addStock.noHotSymbols': 'لا توجد أهداف شعبية حتى الآن', + 'dashboard.analysis.modal.addStock.selectedSymbol': 'مختارة', + 'dashboard.analysis.modal.addStock.pleaseSelectSymbol': 'الرجاء تحديد الهدف أولاً', + 'dashboard.analysis.modal.addStock.pleaseSelectOrEnterSymbol': 'الرجاء تحديد هدف أو إدخال رمز الهدف أولاً', + 'dashboard.analysis.modal.addStock.pleaseEnterSymbol': 'الرجاء إدخال رمز الهدف', + 'dashboard.analysis.modal.addStock.pleaseSelectMarket': 'الرجاء تحديد نوع السوق أولا', + 'dashboard.analysis.modal.addStock.searchFailed': 'فشل البحث، يرجى المحاولة مرة أخرى في وقت لاحق', + 'dashboard.analysis.modal.addStock.noSearchResults': 'لم يتم العثور على هدف مطابق', + 'dashboard.analysis.modal.addStock.willAutoFetchName': 'سيحصل النظام تلقائيًا على الاسم', + 'dashboard.analysis.modal.addStock.addDirectly': 'أضف مباشرة', + 'dashboard.analysis.modal.addStock.nameWillBeFetched': 'سيتم التقاط الاسم تلقائيًا عند إضافته', + 'dashboard.analysis.market.AShare': 'أسهم', + 'dashboard.analysis.market.USStock': 'الأسهم الأمريكية', + 'dashboard.analysis.market.HShare': 'أسهم هونج كونج', + 'dashboard.analysis.market.Crypto': 'عملة مشفرة', + 'dashboard.analysis.market.Forex': 'الفوركس', + 'dashboard.analysis.market.Futures': 'العقود الآجلة', + 'dashboard.analysis.modal.history.title': 'سجلات التحليل التاريخي', + 'dashboard.analysis.modal.history.viewResult': 'عرض النتائج', + 'dashboard.analysis.modal.history.completeTime': 'وقت الانتهاء', + 'dashboard.analysis.modal.history.error': 'خطأ', + 'dashboard.analysis.status.pending': 'في انتظار', + 'dashboard.analysis.status.processing': 'المعالجة', + 'dashboard.analysis.status.completed': 'مكتمل', + 'dashboard.analysis.status.failed': 'فشل', + 'dashboard.analysis.message.selectSymbol': 'الرجاء تحديد الهدف أولا', + 'dashboard.analysis.message.taskCreated': 'تم إنشاء مهمة التحليل ويتم تنفيذها في الخلفية...', + 'dashboard.analysis.message.analysisComplete': 'اكتمل التحليل', + 'dashboard.analysis.message.analysisCompleteCache': 'اكتمل التحليل (باستخدام البيانات المخزنة مؤقتًا)', + 'dashboard.analysis.message.analysisFailed': 'فشل التحليل، يرجى المحاولة مرة أخرى في وقت لاحق', + 'dashboard.analysis.message.addStockSuccess': 'تمت الإضافة بنجاح', + 'dashboard.analysis.message.addStockFailed': 'فشلت الإضافة', + 'dashboard.analysis.message.removeStockSuccess': 'تمت الإزالة بنجاح', + 'dashboard.analysis.message.removeStockFailed': 'فشلت عملية الإزالة', + 'dashboard.analysis.test': 'متجر رقم {no}، طريق Gongzhuan', + 'dashboard.analysis.introduce': 'وصف المؤشر', + 'dashboard.analysis.total-sales': 'إجمالي المبيعات', + 'dashboard.analysis.day-sales': 'متوسط المبيعات اليومية¥', + 'dashboard.analysis.visits': 'الزيارات', + 'dashboard.analysis.visits-trend': 'اتجاهات المرور', + 'dashboard.analysis.visits-ranking': 'ترتيب زيارة المتجر', + 'dashboard.analysis.day-visits': 'زيارات يومية', + 'dashboard.analysis.week': 'أسبوعية على أساس سنوي', + 'dashboard.analysis.day': 'سنة بعد سنة', + 'dashboard.analysis.payments': 'عدد الدفعات', + 'dashboard.analysis.conversion-rate': 'معدل التحويل', + 'dashboard.analysis.operational-effect': 'آثار النشاط التشغيلي', + 'dashboard.analysis.sales-trend': 'اتجاهات المبيعات', + 'dashboard.analysis.sales-ranking': 'ترتيب مبيعات المتجر', + 'dashboard.analysis.all-year': 'على مدار السنة', + 'dashboard.analysis.all-month': 'هذا الشهر', + 'dashboard.analysis.all-week': 'هذا الاسبوع', + 'dashboard.analysis.all-day': 'اليوم', + 'dashboard.analysis.search-users': 'عدد مستخدمي البحث', + 'dashboard.analysis.per-capita-search': 'عمليات البحث للفرد', + 'dashboard.analysis.online-top-search': 'عمليات البحث الشائعة على الإنترنت', + 'dashboard.analysis.the-proportion-of-sales': 'نسبة فئة المبيعات', + 'dashboard.analysis.dropdown-option-one': 'العملية الأولى', + 'dashboard.analysis.dropdown-option-two': 'العملية 2', + 'dashboard.analysis.channel.all': 'جميع القنوات', + 'dashboard.analysis.channel.online': 'على الانترنت', + 'dashboard.analysis.channel.stores': 'متجر', + 'dashboard.analysis.sales': 'المبيعات', + 'dashboard.analysis.traffic': 'تدفق الركاب', + 'dashboard.analysis.table.rank': 'الترتيب', + 'dashboard.analysis.table.search-keyword': 'البحث عن الكلمات الرئيسية', + 'dashboard.analysis.table.users': 'عدد المستخدمين', + 'dashboard.analysis.table.weekly-range': 'زيادة أسبوعية', + 'dashboard.indicator.selectSymbol': 'حدد أو أدخل الرمز الأساسي', + 'dashboard.indicator.emptyWatchlistHint': 'لا يوجد حاليا أي أسهم مختارة ذاتيا، يرجى إضافتها أولا', + 'dashboard.indicator.hint.selectSymbol': 'الرجاء تحديد هدف لبدء التحليل', + 'dashboard.indicator.hint.selectSymbolDesc': 'حدد أو أدخل رمز السهم في مربع البحث أعلاه لعرض مخططات K-line والمؤشرات الفنية', + 'dashboard.indicator.retry': 'حاول مرة أخرى', + 'dashboard.indicator.panel.title': 'المؤشرات الفنية', + 'dashboard.indicator.panel.realtimeOn': 'قم بإيقاف تشغيل التحديثات في الوقت الفعلي', + 'dashboard.indicator.panel.realtimeOff': 'تمكين التحديثات في الوقت الحقيقي', + 'dashboard.indicator.panel.themeLight': 'التبديل إلى المظهر الداكن', + 'dashboard.indicator.panel.themeDark': 'التبديل إلى موضوع الضوء', + 'dashboard.indicator.section.enabled': 'ممكّن', + 'dashboard.indicator.section.added': 'المؤشرات التي أضفتها', + 'dashboard.indicator.section.custom': 'المؤشرات التي أنشأتها بنفسك', + 'dashboard.indicator.section.bought': 'المؤشرات التي اشتريتها', + 'dashboard.indicator.section.myCreated': 'المؤشرات التي قمت بإنشائها', + 'dashboard.indicator.empty': 'لا يوجد مؤشر حتى الآن، يرجى إضافة أو إنشاء مؤشر أولاً', + 'dashboard.indicator.buy': 'مؤشر الشراء', + 'dashboard.indicator.action.start': 'ابدأ', + 'dashboard.indicator.action.stop': 'إغلاق', + 'dashboard.indicator.action.edit': 'تحرير', + 'dashboard.indicator.action.delete': 'حذف', + 'dashboard.indicator.action.backtest': 'com.backtest', + 'dashboard.indicator.status.normal': 'عادي', + 'dashboard.indicator.status.normalPermanent': 'عادي (صالح بشكل دائم)', + 'dashboard.indicator.status.expired': 'انتهت صلاحيتها', + 'dashboard.indicator.expiry.permanent': 'صالحة بشكل دائم', + 'dashboard.indicator.expiry.noExpiry': 'لا يوجد وقت انتهاء الصلاحية', + 'dashboard.indicator.expiry.expired': 'انتهت الصلاحية: {التاريخ}', + 'dashboard.indicator.expiry.expiresOn': 'وقت انتهاء الصلاحية: {التاريخ}', + 'dashboard.indicator.delete.confirmTitle': 'تأكيد الحذف', + 'dashboard.indicator.delete.confirmContent': 'هل أنت متأكد أنك تريد حذف المؤشر "{name}"؟ هذه العملية لا رجعة فيها.', + 'dashboard.indicator.delete.confirmOk': 'حذف', + 'dashboard.indicator.delete.confirmCancel': 'إلغاء', + 'dashboard.indicator.delete.success': 'تم الحذف بنجاح', + 'dashboard.indicator.delete.failed': 'فشل الحذف', + 'dashboard.indicator.save.success': 'تم الحفظ بنجاح', + 'dashboard.indicator.save.failed': 'فشل الحفظ', + 'dashboard.indicator.error.loadWatchlistFailed': 'فشل تحميل الأسهم الاختيارية', + 'dashboard.indicator.error.chartNotReady': 'لم تتم تهيئة مكون المخطط. يرجى تحديد الهدف أولاً وانتظر حتى يتم تحميل المخطط.', + 'dashboard.indicator.error.chartMethodNotReady': 'طريقة مكون المخطط ليست جاهزة، يرجى المحاولة مرة أخرى لاحقًا', + 'dashboard.indicator.error.chartExecuteNotReady': 'طريقة تنفيذ مكون الرسم البياني ليست جاهزة، يرجى المحاولة مرة أخرى لاحقًا', + 'dashboard.indicator.error.parseFailed': 'غير قادر على تحليل كود بايثون', + 'dashboard.indicator.error.parseFailedCheck': 'غير قادر على تحليل كود بايثون، يرجى التحقق من تنسيق الكود', + 'dashboard.indicator.error.addIndicatorFailed': 'فشل في إضافة المؤشر', + 'dashboard.indicator.error.runIndicatorFailed': 'فشل مؤشر التشغيل', + 'dashboard.indicator.error.pleaseLogin': 'الرجاء تسجيل الدخول أولا', + 'dashboard.indicator.error.loadDataFailed': 'فشل تحميل البيانات', + 'dashboard.indicator.error.loadDataFailedDesc': 'يرجى التحقق من اتصال الشبكة', + 'dashboard.indicator.error.pythonEngineFailed': 'فشل محرك Python في التحميل وقد لا تكون وظيفة المؤشر متاحة.', + 'dashboard.indicator.error.chartInitFailed': 'فشلت تهيئة المخطط', + 'dashboard.indicator.warning.enterCode': 'الرجاء إدخال رمز المؤشر أولا', + 'dashboard.indicator.warning.pyodideLoadFailed': 'فشل محرك بايثون في التحميل', + 'dashboard.indicator.warning.pyodideLoadFailedDesc': 'هذه الميزة غير متوفرة في منطقتك الحالية أو بيئة الشبكة الحالية', + 'dashboard.indicator.warning.chartNotInitialized': 'لم تتم تهيئة المخطط ولا يمكن استخدام أداة رسم الخط.', + 'dashboard.indicator.success.runIndicator': 'يعمل المؤشر بنجاح', + 'dashboard.indicator.success.clearDrawings': 'تم مسح كافة الرسومات الخطية', + 'dashboard.indicator.sma': 'SMA (مجموعة المتوسطات المتحركة)', + 'dashboard.indicator.ema': 'EMA (مجموعة المتوسط المتحرك الأسي)', + 'dashboard.indicator.rsi': 'مؤشر القوة النسبية (القوة النسبية)', + 'dashboard.indicator.macd': 'ماكد', + 'dashboard.indicator.bb': 'بولينجر باند', + 'dashboard.indicator.atr': 'ATR (متوسط المدى الحقيقي)', + 'dashboard.indicator.cci': 'CCI (مؤشر قناة السلع)', + 'dashboard.indicator.williams': 'ويليامز %R (مؤشر ويليامز)', + 'dashboard.indicator.mfi': 'مؤسسات التمويل الأصغر (مؤشر تدفق الأموال)', + 'dashboard.indicator.adx': 'ADX (مؤشر الاتجاه المتوسط)', + 'dashboard.indicator.obv': 'OBV (موجة الطاقة)', + 'dashboard.indicator.adosc': 'ADOSC (مذبذب التجميع/الإرسال)', + 'dashboard.indicator.ad': 'AD (خط التجميع/التوزيع)', + 'dashboard.indicator.kdj': 'KDJ (مؤشر ستوكاستيك)', + 'dashboard.indicator.signal.buy': 'شراء', + 'dashboard.indicator.signal.sell': 'بيع', + 'dashboard.indicator.signal.supertrendBuy': 'شراء سوبر تريند', + 'dashboard.indicator.signal.supertrendSell': 'بيع سوبر تريند', + 'dashboard.indicator.chart.kline': 'خط K', + 'dashboard.indicator.chart.volume': 'الحجم', + 'dashboard.indicator.chart.uptrend': 'الاتجاه الصعودي', + 'dashboard.indicator.chart.downtrend': 'الاتجاه الهابط', + 'dashboard.indicator.tooltip.time': 'الوقت', + 'dashboard.indicator.tooltip.open': 'مفتوح', + 'dashboard.indicator.tooltip.close': 'تلقي', + 'dashboard.indicator.tooltip.high': 'عالية', + 'dashboard.indicator.tooltip.low': 'منخفض', + 'dashboard.indicator.tooltip.volume': 'الحجم', + 'dashboard.indicator.drawing.line': 'قطعة الخط', + 'dashboard.indicator.drawing.horizontalLine': 'خط أفقي', + 'dashboard.indicator.drawing.verticalLine': 'خط عمودي', + 'dashboard.indicator.drawing.ray': 'راي', + 'dashboard.indicator.drawing.straightLine': 'خط مستقيم', + 'dashboard.indicator.drawing.parallelLine': 'خطوط متوازية', + 'dashboard.indicator.drawing.priceLine': 'خط السعر', + 'dashboard.indicator.drawing.priceChannel': 'قناة السعر', + 'dashboard.indicator.drawing.fibonacciLine': 'خطوط فيبوناتشي', + 'dashboard.indicator.drawing.clearAll': 'مسح كافة الرسومات الخطية', + 'dashboard.indicator.market.AShare': 'أسهم', + 'dashboard.indicator.market.USStock': 'الأسهم الأمريكية', + 'dashboard.indicator.market.HShare': 'أسهم هونج كونج', + 'dashboard.indicator.market.Crypto': 'عملة مشفرة', + 'dashboard.indicator.market.Forex': 'الفوركس', + 'dashboard.indicator.market.Futures': 'العقود الآجلة', + 'dashboard.indicator.create': 'إنشاء مؤشر', + 'dashboard.indicator.editor.title': 'إنشاء/تحرير المؤشرات', + 'dashboard.indicator.editor.name': 'اسم المؤشر', + 'dashboard.indicator.editor.nameRequired': 'الرجاء إدخال اسم المؤشر', + 'dashboard.indicator.editor.namePlaceholder': 'الرجاء إدخال اسم المؤشر', + 'dashboard.indicator.editor.description': 'وصف المؤشر', + 'dashboard.indicator.editor.descriptionPlaceholder': 'الرجاء إدخال وصف للمؤشر (اختياري)', + 'dashboard.indicator.editor.code': 'كود بايثون', + 'dashboard.indicator.editor.codeRequired': 'الرجاء إدخال رمز المؤشر', + 'dashboard.indicator.editor.codePlaceholder': 'الرجاء إدخال رمز بايثون', + 'dashboard.indicator.editor.run': 'تشغيل', + 'dashboard.indicator.editor.runHint': 'انقر فوق زر التشغيل لمعاينة تأثير المؤشر على مخطط K-line', + 'dashboard.indicator.editor.guide': 'دليل التطوير', + 'dashboard.indicator.editor.guideTitle': 'دليل تطوير مؤشر بايثون', + 'dashboard.indicator.editor.save': 'حفظ', + 'dashboard.indicator.editor.cancel': 'إلغاء', + 'dashboard.indicator.editor.unnamed': 'مؤشر بدون اسم', + 'dashboard.indicator.editor.publishToCommunity': 'نشر إلى المجتمع', + 'dashboard.indicator.editor.publishToCommunityHint': 'بمجرد النشر، يمكن للمستخدمين الآخرين عرض واستخدام المؤشر الخاص بك في المجتمع', + 'dashboard.indicator.editor.indicatorType': 'نوع المؤشر', + 'dashboard.indicator.editor.indicatorTypeRequired': 'الرجاء تحديد نوع المؤشر', + 'dashboard.indicator.editor.indicatorTypePlaceholder': 'الرجاء تحديد نوع المؤشر', + 'dashboard.indicator.editor.previewImage': 'معاينة', + 'dashboard.indicator.editor.uploadImage': 'تحميل الصور', + 'dashboard.indicator.editor.previewImageHint': 'الحجم الموصى به: 800×400، ولا يزيد عن 2 ميجابايت', + 'dashboard.indicator.editor.pricing': 'سعر البيع (QDT)', + 'dashboard.indicator.editor.pricingType.free': 'مجانا', + 'dashboard.indicator.editor.pricingType.permanent': 'دائم', + 'dashboard.indicator.editor.pricingType.monthly': 'الدفع الشهري', + 'dashboard.indicator.editor.price': 'السعر', + 'dashboard.indicator.editor.priceRequired': 'الرجاء إدخال السعر', + 'dashboard.indicator.editor.pricePlaceholder': 'الرجاء إدخال السعر', + 'dashboard.indicator.editor.pricingHint': 'على الرغم من أن سعر المؤشرات المجانية هو 0، إلا أن المنصة ستكافئك (QDT) بناءً على استخدام المؤشر الخاص بك ومعدل الثناء.', + 'dashboard.indicator.editor.aiGenerate': 'جيل ذكي', + 'dashboard.indicator.editor.aiPromptPlaceholder': 'أخبرني بأفكارك وسأقوم بإنشاء رمز مؤشر بايثون لك', + 'dashboard.indicator.editor.aiGenerateBtn': 'كود تم إنشاؤه بواسطة الذكاء الاصطناعي', + 'dashboard.indicator.editor.aiPromptRequired': 'الرجاء إدخال أفكارك', + 'dashboard.indicator.editor.aiGenerateSuccess': 'تم إنشاء الكود بنجاح', + 'dashboard.indicator.editor.aiGenerateError': 'فشل إنشاء الكود، يرجى المحاولة مرة أخرى لاحقًا', + 'dashboard.indicator.backtest.title': 'اختبار خلفي للمؤشر', + 'dashboard.indicator.backtest.config': 'معلمات الاختبار الخلفي', + 'dashboard.indicator.backtest.startDate': 'تاريخ البدء', + 'dashboard.indicator.backtest.endDate': 'تاريخ الانتهاء', + 'dashboard.indicator.backtest.selectStartDate': 'حدد تاريخ البدء', + 'dashboard.indicator.backtest.selectEndDate': 'حدد تاريخ الانتهاء', + 'dashboard.indicator.backtest.startDateRequired': 'الرجاء تحديد تاريخ البدء', + 'dashboard.indicator.backtest.endDateRequired': 'الرجاء تحديد تاريخ الانتهاء', + 'dashboard.indicator.backtest.initialCapital': 'رأس المال الأولي', + 'dashboard.indicator.backtest.initialCapitalRequired': 'الرجاء إدخال الأموال الأولية', + 'dashboard.indicator.backtest.commission': 'رسوم المناولة', + 'dashboard.indicator.backtest.leverage': 'نسبة الرافعة المالية', + 'dashboard.indicator.backtest.tradeDirection': 'اتجاه التداول', + 'dashboard.indicator.backtest.longOnly': 'اذهب لفترة طويلة', + 'dashboard.indicator.backtest.shortOnly': 'قصيرة', + 'dashboard.indicator.backtest.both': 'في اتجاهين', + 'dashboard.indicator.backtest.run': 'ابدأ الاختبار الخلفي', + 'dashboard.indicator.backtest.rerun': 'باك تست مرة أخرى', + 'dashboard.indicator.backtest.close': 'إغلاق', + 'dashboard.indicator.backtest.running': 'الاختبار الخلفي للمؤشر قيد التقدم...', + 'dashboard.indicator.backtest.results': 'نتائج الاختبار الخلفي', + 'dashboard.indicator.backtest.totalReturn': 'العائد الإجمالي', + 'dashboard.indicator.backtest.annualReturn': 'الدخل السنوي', + 'dashboard.indicator.backtest.maxDrawdown': 'الحد الأقصى للسحب', + 'dashboard.indicator.backtest.sharpeRatio': 'نسبة شارب', + 'dashboard.indicator.backtest.winRate': 'معدل الفوز', + 'dashboard.indicator.backtest.profitFactor': 'نسبة الربح إلى الخسارة', + 'dashboard.indicator.backtest.totalTrades': 'عدد المعاملات', + 'dashboard.indicator.totalReturn': 'العائد الإجمالي', + 'dashboard.indicator.annualReturn': 'معدل العائد السنوي', + 'dashboard.indicator.maxDrawdown': 'الحد الأقصى للسحب', + 'dashboard.indicator.sharpeRatio': 'نسبة شارب', + 'dashboard.indicator.winRate': 'معدل الفوز', + 'dashboard.indicator.profitFactor': 'نسبة الربح إلى الخسارة', + 'dashboard.indicator.totalTrades': 'إجمالي عدد المعاملات', + 'dashboard.indicator.backtest.totalCommission': 'إجمالي رسوم المناولة', + 'dashboard.indicator.backtest.equityCurve': 'منحنى العائد', + 'dashboard.indicator.backtest.strategy': 'دخل المؤشر', + 'dashboard.indicator.backtest.benchmark': 'العودة المرجعية', + 'dashboard.indicator.backtest.tradeHistory': 'تاريخ المعاملة', + 'dashboard.indicator.backtest.tradeTime': 'الوقت', + 'dashboard.indicator.backtest.tradeType': 'اكتب', + 'dashboard.indicator.backtest.buy': 'شراء', + 'dashboard.indicator.backtest.sell': 'بيع', + 'dashboard.indicator.backtest.liquidation': 'التصفية', + 'dashboard.indicator.backtest.openLong': 'مفتوح لفترة طويلة', + 'dashboard.indicator.backtest.closeLong': 'بيندو', + 'dashboard.indicator.backtest.closeLongStop': 'قريب من الشراء (وقف الخسارة)', + 'dashboard.indicator.backtest.closeLongProfit': 'إغلاق المزيد (جني الأرباح)', + 'dashboard.indicator.backtest.addLong': 'إضافة موقف طويل', + 'dashboard.indicator.backtest.openShort': 'فتح قصيرة', + 'dashboard.indicator.backtest.closeShort': 'فارغ', + 'dashboard.indicator.backtest.closeShortStop': 'إغلاق (وقف الخسارة)', + 'dashboard.indicator.backtest.closeShortProfit': 'إغلاق (جني الأرباح)', + 'dashboard.indicator.backtest.addShort': 'إضافة موقف قصير', + 'dashboard.indicator.backtest.price': 'السعر', + 'dashboard.indicator.backtest.amount': 'الكمية', + 'dashboard.indicator.backtest.balance': 'رصيد الحساب', + 'dashboard.indicator.backtest.profit': 'الربح والخسارة', + 'dashboard.indicator.backtest.success': 'تم الانتهاء من الاختبار الخلفي', + 'dashboard.indicator.backtest.failed': 'فشل الاختبار الخلفي، يرجى المحاولة مرة أخرى لاحقًا', + 'dashboard.indicator.backtest.noIndicatorCode': 'لا يوجد رمز قابل للاختبار الخلفي لهذا المؤشر', + 'dashboard.indicator.backtest.noSymbol': 'يرجى تحديد هدف المعاملة أولاً', + 'dashboard.indicator.backtest.dateRangeExceeded': 'يتجاوز النطاق الزمني للاختبار الخلفي الحد الأقصى: يمكن اختبار الفترة الزمنية {timeframe} بحد أقصى {maxRange}', + 'dashboard.indicator.backtest.dateRangeExceededDays': 'Backtest range exceeds limit: {timeframe} max {maxRange} ({maxDays} days)', + 'dashboard.indicator.backtest.metaLine': 'Symbol: {symbol} | Market: {market} | Timeframe: {timeframe}', + 'dashboard.indicator.backtest.savedRunId': 'Backtest saved. Run ID: {id}', + 'dashboard.indicator.backtest.prev': 'Previous', + 'dashboard.indicator.backtest.next': 'Next', + 'dashboard.indicator.backtest.steps.strategy.title': 'Strategy', + 'dashboard.indicator.backtest.steps.strategy.desc': 'Position sizing & risk controls', + 'dashboard.indicator.backtest.steps.trading.title': 'Trading settings', + 'dashboard.indicator.backtest.steps.trading.desc': 'Time range, capital, fees, leverage', + 'dashboard.indicator.backtest.steps.results.title': 'Results', + 'dashboard.indicator.backtest.steps.results.desc': 'Backtest output', + 'dashboard.indicator.backtest.panel.risk': 'Risk management (SL/TP/Trailing)', + 'dashboard.indicator.backtest.panel.scale': 'Scale in / DCA (trend & mean-reversion)', + 'dashboard.indicator.backtest.panel.reduce': 'Reduce position (trend & adverse)', + 'dashboard.indicator.backtest.panel.position': 'Position sizing', + 'dashboard.indicator.backtest.field.stopLossPct': 'Stop Loss (%)', + 'dashboard.indicator.backtest.field.takeProfitPct': 'Take Profit (%)', + 'dashboard.indicator.backtest.field.trailingEnabled': 'Trailing stop / take profit', + 'dashboard.indicator.backtest.field.trailingStopPct': 'Trailing drawdown (%)', + 'dashboard.indicator.backtest.field.trailingActivationPct': 'Trailing activation (%)', + 'dashboard.indicator.backtest.field.slippage': 'Slippage (%)', + 'dashboard.indicator.backtest.field.trendAddEnabled': 'Trend-following scale-in', + 'dashboard.indicator.backtest.field.dcaAddEnabled': 'Mean-reversion DCA', + 'dashboard.indicator.backtest.field.trendAddStepPct': 'Scale-in trigger (%)', + 'dashboard.indicator.backtest.field.dcaAddStepPct': 'DCA trigger (%)', + 'dashboard.indicator.backtest.field.trendAddSizePct': 'Scale-in size (% of capital)', + 'dashboard.indicator.backtest.field.dcaAddSizePct': 'DCA size (% of capital)', + 'dashboard.indicator.backtest.field.trendAddMaxTimes': 'Max scale-in times', + 'dashboard.indicator.backtest.field.dcaAddMaxTimes': 'Max DCA times', + 'dashboard.indicator.backtest.field.trendReduceEnabled': 'Trend reduce', + 'dashboard.indicator.backtest.field.adverseReduceEnabled': 'Adverse reduce', + 'dashboard.indicator.backtest.field.trendReduceStepPct': 'Trend trigger (%)', + 'dashboard.indicator.backtest.field.adverseReduceStepPct': 'Adverse trigger (%)', + 'dashboard.indicator.backtest.field.trendReduceSizePct': 'Reduce size (% of position)', + 'dashboard.indicator.backtest.field.adverseReduceSizePct': 'Adverse reduce size (% of position)', + 'dashboard.indicator.backtest.field.trendReduceMaxTimes': 'Max trend reduce times', + 'dashboard.indicator.backtest.field.adverseReduceMaxTimes': 'Max adverse reduce times', + 'dashboard.indicator.backtest.field.entryPct': 'Entry size (% of capital)', + 'dashboard.indicator.backtest.field.minOrderPct': 'Min order size (% of capital) (optional)', + 'dashboard.indicator.backtest.closeLongTrailing': 'Close Long (Trailing)', + 'dashboard.indicator.backtest.reduceLong': 'Reduce Long', + 'dashboard.indicator.backtest.closeShortTrailing': 'Close Short (Trailing)', + 'dashboard.indicator.backtest.reduceShort': 'Reduce Short', + 'dashboard.docs.title': 'مركز الوثائق', + 'dashboard.docs.search.placeholder': 'بحث في المستندات...', + 'dashboard.docs.category.all': 'الكل', + 'dashboard.docs.category.guide': 'دليل التطوير', + 'dashboard.docs.category.api': 'وثائق واجهة برمجة التطبيقات', + 'dashboard.docs.category.tutorial': 'البرنامج التعليمي', + 'dashboard.docs.category.faq': 'الأسئلة الشائعة', + 'dashboard.docs.featured.title': 'الوثائق الموصى بها', + 'dashboard.docs.featured.tag': 'موصى به', + 'dashboard.docs.list.views': 'تصفح', + 'dashboard.docs.list.author': 'المؤلف', + 'dashboard.docs.list.empty': 'لا توجد وثيقة بعد', + 'dashboard.docs.list.backToAll': 'العودة إلى جميع الوثائق', + 'dashboard.docs.list.total': '{count} من المستندات في المجموع', + 'dashboard.docs.detail.back': 'العودة إلى قائمة المستندات', + 'dashboard.docs.detail.updatedAt': 'تم التحديث على', + 'dashboard.docs.detail.related': 'الوثائق ذات الصلة', + 'dashboard.docs.detail.notFound': 'الوثيقة غير موجودة', + 'dashboard.docs.detail.error': 'المستند غير موجود أو تم حذفه', + 'dashboard.docs.search.result': 'نتائج البحث', + 'dashboard.docs.search.keyword': 'الكلمات الرئيسية', + 'community.filter.indicatorType': 'نوع المؤشر', + 'community.filter.all': 'الكل', + 'community.filter.other': 'خيارات أخرى', + 'community.filter.pricing': 'نوع التسعير', + 'community.filter.allPricing': 'جميع التسعير', + 'community.filter.sortBy': 'الترتيب حسب', + 'community.filter.search': 'بحث', + 'community.filter.searchPlaceholder': 'البحث عن اسم المقياس', + 'community.indicatorType.trend': 'نوع الاتجاه', + 'community.indicatorType.momentum': 'نوع الزخم', + 'community.indicatorType.volatility': 'التقلب', + 'community.indicatorType.volume': 'الحجم', + 'community.indicatorType.custom': 'تخصيص', + 'community.pricing.free': 'مجانا', + 'community.pricing.paid': 'ادفع', + 'community.sort.downloads': 'التنزيلات', + 'community.sort.rating': 'التقييم', + 'community.sort.newest': 'أحدث الإصدارات', + 'community.pagination.total': '{total} مؤشرات في المجموع', + 'community.noDescription': 'لا يوجد وصف بعد', + 'community.detail.type': 'اكتب', + 'community.detail.pricing': 'التسعير', + 'community.detail.rating': 'التقييم', + 'community.detail.downloads': 'التنزيلات', + 'community.detail.author': 'المؤلف', + 'community.detail.description': 'مقدمة', + 'community.detail.detailContent': 'وصف تفصيلي', + 'community.detail.backtestStats': 'إحصائيات الاختبار الخلفي', + 'community.action.purchase': 'مؤشر الشراء', + 'community.action.addToMyIndicators': 'أضف إلى مؤشراتي', + 'community.action.favorite': 'المجموعة', + 'community.action.unfavorite': 'إلغاء المفضلة', + 'community.action.buyNow': 'اشتري الآن', + 'community.action.renew': 'التجديد', + 'community.purchase.price': 'السعر', + 'community.purchase.permanent': 'دائم', + 'community.purchase.monthly': 'شهر', + 'community.purchase.confirmBuy': 'هل تريد تأكيد شراء هذا المؤشر ({price} QDT)؟', + 'community.purchase.confirmRenew': 'هل تريد تأكيد تجديد هذا المؤشر ({price} QDT/month)؟', + 'community.purchase.confirmFree': 'هل تريد تأكيد إضافة هذا المؤشر المجاني؟', + 'community.purchase.confirmTitle': 'تأكيد الإجراء', + 'community.purchase.owned': 'لقد قمت بشراء هذا المؤشر (صالح بشكل دائم)', + 'community.purchase.ownIndicator': 'هذا هو المقياس الذي نشرته', + 'community.purchase.expired': 'لقد انتهت صلاحية اشتراكك', + 'community.purchase.expiresOn': 'وقت انتهاء الصلاحية: {التاريخ}', + 'community.tabs.detail': 'تفاصيل المؤشر', + 'community.tabs.backtest': 'بيانات الاختبار الخلفي', + 'community.tabs.ratings': 'مراجعات المستخدم', + 'community.rating.myRating': 'تقييمي', + 'community.rating.stars': '{عد} النجوم', + 'community.rating.commentPlaceholder': 'شارك تجربتك...', + 'community.rating.submit': 'إرسال التقييم', + 'community.rating.modify': 'تعديل', + 'community.rating.saveModify': 'حفظ التغييرات', + 'community.rating.cancel': 'إلغاء', + 'community.rating.selectRating': 'الرجاء تحديد التقييم', + 'community.rating.success': 'التقييم ناجح', + 'community.rating.modifySuccess': 'تم تعديل المراجعة بنجاح', + 'community.rating.failed': 'فشل التقييم', + 'community.rating.noRatings': 'لا توجد تعليقات حتى الآن', + 'community.backtest.note': 'البيانات المذكورة أعلاه هي نتائج اختبار خلفي تاريخية وهي للإشارة فقط ولا تمثل أرباحًا مستقبلية.', + 'community.backtest.noData': 'لا توجد بيانات باكتست حتى الآن', + 'community.backtest.uploadHint': 'لم يقم المؤلف بتحميل بيانات الاختبار الخلفي حتى الآن', + 'community.message.loadFailed': 'فشل تحميل قائمة المؤشرات', + 'community.message.purchaseProcessing': 'جارٍ معالجة طلب الشراء...', + 'community.message.downloadSuccess': 'تمت إضافة المقياس إلى قائمة المقاييس الخاصة بي', + 'community.message.favoriteSuccess': 'تم التجميع بنجاح', + 'community.message.unfavoriteSuccess': 'تم إلغاء التجميع بنجاح', + 'community.message.operationSuccess': 'العملية ناجحة', + 'community.message.operationFailed': 'فشلت العملية', + 'dashboard.totalEquity': 'إجمالي حقوق الملكية', + 'dashboard.totalPnL': 'إجمالي الربح والخسارة', + 'dashboard.aiStrategies': 'استراتيجية الذكاء الاصطناعي', + 'dashboard.indicatorStrategies': 'استراتيجية المؤشر', + 'dashboard.running': 'الجري', + 'dashboard.pnlHistory': 'الأرباح والخسائر التاريخية', + 'dashboard.strategyPerformance': 'نسبة الربح والخسارة في الاستراتيجية', + 'dashboard.recentTrades': 'المعاملات الأخيرة', + 'dashboard.currentPositions': 'الوضع الحالي', + 'dashboard.table.time': 'الوقت', + 'dashboard.table.strategy': 'اسم السياسة', + 'dashboard.table.symbol': 'الهدف', + 'dashboard.table.type': 'اكتب', + 'dashboard.table.side': 'الاتجاه', + 'dashboard.table.size': 'الفائدة المفتوحة', + 'dashboard.table.entryPrice': 'متوسط سعر الافتتاح', + 'dashboard.table.price': 'السعر', + 'dashboard.table.amount': 'الكمية', + 'dashboard.table.profit': 'الربح والخسارة', + 'dashboard.pendingOrders': 'سجل تنفيذ الطلبات', + 'dashboard.totalOrders': 'إجمالي {total} طلب', + 'dashboard.viewError': 'عرض الخطأ', + 'dashboard.filled': 'تم التنفيذ', + 'dashboard.orderTable.time': 'وقت الإنشاء', + 'dashboard.orderTable.strategy': 'الإستراتيجية', + 'dashboard.orderTable.symbol': 'زوج التداول', + 'dashboard.orderTable.signalType': 'نوع الإشارة', + 'dashboard.orderTable.amount': 'الكمية', + 'dashboard.orderTable.price': 'سعر التنفيذ', + 'dashboard.orderTable.status': 'الحالة', + 'dashboard.orderTable.executedAt': 'وقت التنفيذ', + 'dashboard.signalType.openLong': 'فتح Long', + 'dashboard.signalType.openShort': 'فتح Short', + 'dashboard.signalType.closeLong': 'إغلاق Long', + 'dashboard.signalType.closeShort': 'إغلاق Short', + 'dashboard.signalType.addLong': 'إضافة Long', + 'dashboard.signalType.addShort': 'إضافة Short', + 'dashboard.status.pending': 'قيد الانتظار', + 'dashboard.status.processing': 'قيد المعالجة', + 'dashboard.status.completed': 'مكتمل', + 'dashboard.status.failed': 'فشل', + 'dashboard.status.cancelled': 'ملغي', + 'dashboard.table.pnl': 'الربح أو الخسارة غير المحققة', + 'form.basic-form.basic.title': 'الشكل الأساسي', + 'form.basic-form.basic.description': 'تُستخدم صفحات النماذج لجمع المعلومات من المستخدمين أو التحقق منها. تُستخدم النماذج الأساسية بشكل شائع في سيناريوهات النماذج التي تحتوي على عدد قليل من عناصر البيانات.', + 'form.basic-form.title.label': 'العنوان', + 'form.basic-form.title.placeholder': 'إعطاء الهدف اسما', + 'form.basic-form.title.required': 'الرجاء إدخال عنوان', + 'form.basic-form.date.label': 'تاريخ البدء والانتهاء', + 'form.basic-form.placeholder.start': 'تاريخ البدء', + 'form.basic-form.placeholder.end': 'تاريخ الانتهاء', + 'form.basic-form.date.required': 'الرجاء تحديد تاريخي البدء والانتهاء', + 'form.basic-form.goal.label': 'وصف الهدف', + 'form.basic-form.goal.placeholder': 'الرجاء إدخال أهداف العمل المرحلية', + 'form.basic-form.goal.required': 'الرجاء إدخال وصف الهدف', + 'form.basic-form.standard.label': 'قياس', + 'form.basic-form.standard.placeholder': 'الرجاء إدخال المقاييس', + 'form.basic-form.standard.required': 'الرجاء إدخال المقاييس', + 'form.basic-form.client.label': 'العميل', + 'form.basic-form.client.required': 'يرجى وصف العملاء الذين تخدمهم', + 'form.basic-form.label.tooltip': 'متلقي الخدمة المستهدفين', + 'form.basic-form.client.placeholder': 'يرجى وصف العملاء الذين تخدمهم، والعملاء الداخليين مباشرةً @الاسم/رقم الوظيفة', + 'form.basic-form.invites.label': 'دعوة المراجعين', + 'form.basic-form.invites.placeholder': 'يرجى إرسال @الاسم/رقم الموظف مباشرةً، ويمكنك دعوة ما يصل إلى 5 أشخاص', + 'form.basic-form.weight.label': 'الوزن', + 'form.basic-form.weight.placeholder': 'من فضلك أدخل', + 'form.basic-form.public.label': 'تستهدف الجمهور', + 'form.basic-form.label.help': 'تتم مشاركة العملاء والمراجعين بشكل افتراضي', + 'form.basic-form.radio.public': 'عام', + 'form.basic-form.radio.partially-public': 'عامة جزئيا', + 'form.basic-form.radio.private': 'خاص', + 'form.basic-form.publicUsers.placeholder': 'مفتوح ل', + 'form.basic-form.option.A': 'زميل 1', + 'form.basic-form.option.B': 'زميل 2', + 'form.basic-form.option.C': 'زميل ثلاثة', + 'form.basic-form.email.required': 'الرجاء إدخال عنوان البريد الإلكتروني الخاص بك!', + 'form.basic-form.email.wrong-format': 'تنسيق عنوان البريد الإلكتروني خاطئ!', + 'form.basic-form.userName.required': 'الرجاء إدخال اسم المستخدم!', + 'form.basic-form.password.required': 'الرجاء إدخال كلمة المرور الخاصة بك!', + 'form.basic-form.password.twice': 'كلمات المرور التي تم إدخالها مرتين غير متطابقة!', + 'form.basic-form.strength.msg': 'الرجاء إدخال 6 أحرف على الأقل. يرجى عدم استخدام كلمات المرور التي يمكن تخمينها بسهولة.', + 'form.basic-form.strength.strong': 'القوة : قوية', + 'form.basic-form.strength.medium': 'القوة: متوسطة', + 'form.basic-form.strength.short': 'القوة : قصيرة جداً', + 'form.basic-form.confirm-password.required': 'الرجاء تأكيد كلمة المرور الخاصة بك!', + 'form.basic-form.phone-number.required': 'الرجاء إدخال رقم هاتفك المحمول!', + 'form.basic-form.phone-number.wrong-format': 'تنسيق رقم الهاتف المحمول خاطئ!', + 'form.basic-form.verification-code.required': 'الرجاء إدخال رمز التحقق!', + 'form.basic-form.form.get-captcha': 'الحصول على رمز التحقق', + 'form.basic-form.captcha.second': 'ثواني', + 'form.basic-form.form.optional': '(اختياري)', + 'form.basic-form.form.submit': 'إرسال', + 'form.basic-form.form.save': 'حفظ', + 'form.basic-form.email.placeholder': 'البريد الإلكتروني', + 'form.basic-form.password.placeholder': 'كلمة المرور مكونة من 6 أحرف على الأقل، حساسة لحالة الأحرف', + 'form.basic-form.confirm-password.placeholder': 'تأكيد كلمة المرور', + 'form.basic-form.phone-number.placeholder': 'رقم الهاتف المحمول', + 'form.basic-form.verification-code.placeholder': 'رمز التحقق', + 'result.success.title': 'تم التقديم بنجاح', + 'result.success.description': 'يتم استخدام صفحة نتائج الإرسال لتقديم تعليقات على نتائج المعالجة لسلسلة من مهام التشغيل. إذا كانت عملية بسيطة فقط، فاستخدم رسالة الملاحظات العامة السريعة. يمكن لمنطقة النص هذه عرض تعليمات تكميلية بسيطة. إذا كانت هناك حاجة لعرض "المستندات"، فيمكن للمنطقة الرمادية أدناه عرض محتوى أكثر تعقيدًا.', + 'result.success.operate-title': 'اسم المشروع', + 'result.success.operate-id': 'معرف المشروع', + 'result.success.principal': 'الشخص المسؤول', + 'result.success.operate-time': 'الوقت الفعال', + 'result.success.step1-title': 'إنشاء مشروع', + 'result.success.step1-operator': 'تشو ليلي', + 'result.success.step2-title': 'المراجعة الأولية للقسم', + 'result.success.step2-operator': 'تشو ماوماو', + 'result.success.step2-extra': 'عاجل', + 'result.success.step3-title': 'المراجعة المالية', + 'result.success.step4-title': 'كامل', + 'result.success.btn-return': 'العودة إلى القائمة', + 'result.success.btn-project': 'عرض العناصر', + 'result.success.btn-print': 'طباعة', + 'result.fail.error.title': 'فشل التقديم', + 'result.fail.error.description': 'يرجى التحقق من المعلومات التالية وتعديلها قبل إعادة الإرسال.', + 'result.fail.error.hint-title': 'يحتوي المحتوى الذي أرسلته على الأخطاء التالية:', + 'result.fail.error.hint-text1': 'لقد تم تجميد حسابك', + 'result.fail.error.hint-btn1': 'ذوبان الجليد على الفور', + 'result.fail.error.hint-text2': 'حسابك ليس مؤهلاً بعد للتقديم', + 'result.fail.error.hint-btn2': 'قم بالترقية الآن', + 'result.fail.error.btn-text': 'العودة إلى التعديل', + 'account.settings.menuMap.custom': 'التخصيص', + 'account.settings.menuMap.binding': 'ربط الحساب', + 'account.settings.basic.avatar': 'الصورة الرمزية', + 'account.settings.basic.change-avatar': 'تغيير الصورة الرمزية', + 'account.settings.basic.email': 'البريد الإلكتروني', + 'account.settings.basic.email-message': 'الرجاء إدخال البريد الإلكتروني الخاص بك!', + 'account.settings.basic.nickname': 'اللقب', + 'account.settings.basic.nickname-message': 'الرجاء إدخال اللقب الخاص بك!', + 'account.settings.basic.profile': 'الملف الشخصي', + 'account.settings.basic.profile-message': 'الرجاء إدخال ملف التعريف الشخصي الخاص بك!', + 'account.settings.basic.profile-placeholder': 'الملف الشخصي', + 'account.settings.basic.country': 'البلد/المنطقة', + 'account.settings.basic.country-message': 'الرجاء إدخال بلدك أو منطقتك!', + 'account.settings.basic.geographic': 'المحافظة والمدينة', + 'account.settings.basic.geographic-message': 'من فضلك أدخل محافظتك ومدينتك!', + 'account.settings.basic.address': 'عنوان الشارع', + 'account.settings.basic.address-message': 'الرجاء إدخال عنوان الشارع الخاص بك!', + 'account.settings.basic.phone': 'رقم الاتصال', + 'account.settings.basic.phone-message': 'الرجاء إدخال رقم الاتصال الخاص بك!', + 'account.settings.basic.update': 'تحديث المعلومات الأساسية', + 'account.settings.basic.update.success': 'تم تحديث المعلومات الأساسية بنجاح', + 'account.settings.security.strong': 'قوي', + 'account.settings.security.medium': 'في', + 'account.settings.security.weak': 'ضعيف', + 'account.settings.security.password': 'كلمة مرور الحساب', + 'account.settings.security.password-description': 'قوة كلمة المرور الحالية:', + 'account.settings.security.phone': 'الهاتف المحمول الأمن', + 'account.settings.security.phone-description': 'الهاتف المحمول المرتبط بالفعل:', + 'account.settings.security.question': 'القضايا الأمنية', + 'account.settings.security.question-description': 'لم يتم تعيين أي أسئلة أمنية، الأمر الذي يمكن أن يحمي أمان الحساب بشكل فعال.', + 'account.settings.security.email': 'ربط البريد الإلكتروني', + 'account.settings.security.email-description': 'عنوان البريد الإلكتروني المرتبط بالفعل:', + 'account.settings.security.mfa': 'جهاز ام اف ايه', + 'account.settings.security.mfa-description': 'جهاز MFA غير مقيد. بعد الربط، يمكنك التأكيد مرة أخرى.', + 'account.settings.security.modify': 'تعديل', + 'account.settings.security.set': 'الإعدادات', + 'account.settings.security.bind': 'ملزمة', + 'account.settings.binding.taobao': 'ربط تاوباو', + 'account.settings.binding.taobao-description': 'حساب تاوباو غير ملزم حاليا', + 'account.settings.binding.alipay': 'ربط عليباي', + 'account.settings.binding.alipay-description': 'حساب Alipay غير ملزم حاليا', + 'account.settings.binding.dingding': 'ملزمة DingTalk', + 'account.settings.binding.dingding-description': 'لا يوجد حساب DingTalk مرتبط حاليًا', + 'account.settings.binding.bind': 'ملزمة', + 'account.settings.notification.password': 'كلمة مرور الحساب', + 'account.settings.notification.password-description': 'سيتم إخطار الرسائل الواردة من المستخدمين الآخرين في شكل رسائل موقع.', + 'account.settings.notification.messages': 'رسائل النظام', + 'account.settings.notification.messages-description': 'سيتم إخطار رسائل النظام في شكل رسائل الموقع.', + 'account.settings.notification.todo': 'المهام الواجبة', + 'account.settings.notification.todo-description': 'سيتم إخطار المهام الواجبة في شكل رسائل داخل الموقع', + 'account.settings.settings.open': 'مفتوح', + 'account.settings.settings.close': 'قريب', + 'trading-assistant.title': 'مساعد التداول', + 'trading-assistant.strategyList': 'قائمة الإستراتيجية', + 'trading-assistant.createStrategy': 'إنشاء سياسة', + 'trading-assistant.noStrategy': 'لا توجد استراتيجية حتى الآن', + 'trading-assistant.selectStrategy': 'يرجى تحديد استراتيجية من اليسار لعرض التفاصيل', + 'trading-assistant.startStrategy': 'استراتيجية الإطلاق', + 'trading-assistant.stopStrategy': 'استراتيجية التوقف', + 'trading-assistant.editStrategy': 'استراتيجية التحرير', + 'trading-assistant.deleteStrategy': 'حذف السياسة', + 'trading-assistant.status.running': 'الجري', + 'trading-assistant.status.stopped': 'توقف', + 'trading-assistant.status.error': 'خطأ', + 'trading-assistant.strategyType.IndicatorStrategy': 'استراتيجية المؤشر الفني', + 'trading-assistant.strategyType.PromptBasedStrategy': 'استراتيجية الكلمات الدلالية', + 'trading-assistant.strategyType.GridStrategy': 'استراتيجية الشبكة', + 'trading-assistant.tabs.tradingRecords': 'تاريخ المعاملة', + 'trading-assistant.tabs.positions': 'سجل الموقف', + 'trading-assistant.tabs.equityCurve': 'منحنى الأسهم', + 'trading-assistant.form.step1': 'حدد المؤشرات', + 'trading-assistant.form.step2': 'تكوين الصرف', + 'trading-assistant.form.step3': 'معلمات الاستراتيجية', + 'trading-assistant.form.indicator': 'حدد المؤشرات', + 'trading-assistant.form.indicatorHint': 'يمكنك فقط تحديد المؤشرات الفنية التي اشتريتها أو أنشأتها', + 'trading-assistant.form.qdtCostHints': 'سيؤدي استخدام الإستراتيجية إلى استهلاك QDT، يرجى التأكد من أن الحساب به رصيد كافٍ من QDT', + 'trading-assistant.form.indicatorDescription': 'وصف المؤشر', + 'trading-assistant.form.noDescription': 'لا يوجد وصف بعد', + 'trading-assistant.form.exchange': 'اختر الصرف', + 'trading-assistant.form.apiKey': 'مفتاح واجهة برمجة التطبيقات', + 'trading-assistant.form.secretKey': 'المفتاح السري', + 'trading-assistant.form.passphrase': 'عبارة المرور', + 'trading-assistant.form.testConnection': 'اتصال الاختبار', + 'trading-assistant.form.strategyName': 'اسم السياسة', + 'trading-assistant.form.symbol': 'زوج التداول', + 'trading-assistant.form.symbolHint': 'حاليًا، يتم دعم أزواج تداول العملات المشفرة فقط', + 'trading-assistant.form.initialCapital': 'حجم الاستثمار', + 'trading-assistant.form.marketType': 'نوع السوق', + 'trading-assistant.form.marketTypeFutures': 'العقد', + 'trading-assistant.form.marketTypeSpot': 'بقعة', + 'trading-assistant.form.marketTypeHint': 'يدعم العقد التداول في اتجاهين والرافعة المالية، في حين أن السعر الفوري يدعم فقط المراكز الطويلة والرافعة المالية ثابتة عند 1x', + 'trading-assistant.form.leverage': 'الاستفادة المتعددة', + 'trading-assistant.form.leverageHint': 'العقد: 1-125 مرة، البقعة: ثابتة 1 مرة', + 'trading-assistant.form.spotLeverageFixed': 'الرافعة المالية للتداول الفوري ثابتة عند 1x', + 'trading-assistant.form.spotOnlyLongHint': 'التداول الفوري يدعم فقط المراكز الطويلة', + 'trading-assistant.form.tradeDirection': 'اتجاه التداول', + 'trading-assistant.form.tradeDirectionLong': 'طويلة فقط', + 'trading-assistant.form.tradeDirectionShort': 'قصيرة فقط', + 'trading-assistant.form.tradeDirectionBoth': 'معاملة في اتجاهين', + 'trading-assistant.form.timeframe': 'الفترة الزمنية', + 'trading-assistant.form.klinePeriod': 'فترة K-Line', + 'trading-assistant.form.timeframe1m': '1 دقيقة', + 'trading-assistant.form.timeframe5m': '5 دقائق', + 'trading-assistant.form.timeframe15m': '15 دقيقة', + 'trading-assistant.form.timeframe30m': '30 دقيقة', + 'trading-assistant.form.timeframe1H': '1 ساعة', + 'trading-assistant.form.timeframe4H': '4 ساعات', + 'trading-assistant.form.timeframe1D': 'يوم واحد', + 'trading-assistant.form.selectStrategyType': 'اختر نوع الاستراتيجية', + 'trading-assistant.form.indicatorStrategy': 'استراتيجية المؤشر', + 'trading-assistant.form.indicatorStrategyDesc': 'استراتيجية تداول آلية تعتمد على المؤشرات الفنية', + 'trading-assistant.form.aiStrategy': 'استراتيجية AI', + 'trading-assistant.form.aiStrategyDesc': 'استراتيجية تداول آلية تعتمد على اتخاذ القرار الذكي للذكاء الاصطناعي', + 'trading-assistant.form.enableAiFilter': 'تفعيل مرشح اتخاذ القرار الذكي للذكاء الاصطناعي', + 'trading-assistant.form.enableAiFilterHint': 'عند التفعيل، سيتم تصفية إشارات المؤشر بواسطة الذكاء الاصطناعي لتحسين جودة التداول', + 'trading-assistant.form.aiFilterPrompt': 'موجه مخصص', + 'trading-assistant.form.aiFilterPromptHint': 'توفير تعليمات مخصصة لتصفية الذكاء الاصطناعي، اتركه فارغًا لاستخدام الإعداد الافتراضي للنظام', + 'trading-assistant.validation.strategyTypeRequired': 'يرجى اختيار نوع الاستراتيجية', + 'trading-assistant.form.advancedSettings': 'الإعدادات المتقدمة', + 'trading-assistant.form.orderMode': 'وضع الطلب', + 'trading-assistant.form.orderModeMaker': 'صانع', + 'trading-assistant.form.orderModeTaker': 'سعر السوق (المتلقي)', + 'trading-assistant.form.orderModeHint': 'يستخدم وضع الأمر المعلق أوامر الحد وتكون رسوم المناولة أقل؛ يتم تنفيذ وضع سعر السوق على الفور وتكون رسوم المناولة أعلى.', + 'trading-assistant.form.makerWaitSec': 'وقت الانتظار للأوامر المعلقة (ثواني)', + 'trading-assistant.form.makerWaitSecHint': 'الوقت اللازم لانتظار المعاملة بعد تقديم الطلب. قم بالإلغاء وحاول مرة أخرى بعد انتهاء المهلة.', + 'trading-assistant.form.makerRetries': 'عدد مرات إعادة المحاولة للأوامر المعلقة', + 'trading-assistant.form.makerRetriesHint': 'الحد الأقصى لعدد مرات إعادة المحاولة عندما لا يتم تنفيذ الأمر المعلق', + 'trading-assistant.form.fallbackToMarket': 'يفشل الأمر المعلق ويتم تخفيض سعر السوق.', + 'trading-assistant.form.fallbackToMarketHint': 'عندما لا يتم إكمال أمر الفتح/الإغلاق المعلق، ما إذا كان ينبغي تخفيضه إلى أمر سوق لضمان اكتمال المعاملة.', + 'trading-assistant.form.marginMode': 'وضع الهامش', + 'trading-assistant.form.marginModeCross': 'مستودع كامل', + 'trading-assistant.form.marginModeIsolated': 'موقف معزول', + 'trading-assistant.form.stopLossPct': 'نسبة وقف الخسارة (٪)', + 'trading-assistant.form.stopLossPctHint': 'قم بتعيين نسبة وقف الخسارة، 0 يعني عدم تمكين وقف الخسارة', + 'trading-assistant.form.takeProfitPct': 'نسبة جني الأرباح (%)', + 'trading-assistant.form.takeProfitPctHint': 'قم بتعيين نسبة أخذ الربح، 0 يعني تعطيل أخذ الربح', + 'trading-assistant.form.signalMode': 'وضع الإشارة', + 'trading-assistant.form.signalModeConfirmed': 'تأكيد الوضع', + 'trading-assistant.form.signalModeAggressive': 'الوضع العدواني', + 'trading-assistant.form.signalModeHint': 'وضع التأكيد: يتحقق فقط من خطوط K المكتملة؛ الوضع العدواني: يتحقق أيضًا من تشكيل خطوط K', + 'trading-assistant.form.cancel': 'إلغاء', + 'trading-assistant.form.prev': 'الخطوة السابقة', + 'trading-assistant.form.next': 'الخطوة التالية', + 'trading-assistant.form.confirmCreate': 'تأكيد الإنشاء', + 'trading-assistant.form.confirmEdit': 'تأكيد التغييرات', + 'trading-assistant.messages.createSuccess': 'تم إنشاء الإستراتيجية بنجاح', + 'trading-assistant.messages.createFailed': 'فشل في إنشاء السياسة', + 'trading-assistant.messages.updateSuccess': 'تم تحديث السياسة بنجاح', + 'trading-assistant.messages.updateFailed': 'فشل تحديث السياسة', + 'trading-assistant.messages.deleteSuccess': 'تم حذف السياسة بنجاح', + 'trading-assistant.messages.deleteFailed': 'فشل سياسة الحذف', + 'trading-assistant.messages.startSuccess': 'بدأت الإستراتيجية بنجاح', + 'trading-assistant.messages.startFailed': 'فشل في بدء السياسة', + 'trading-assistant.messages.stopSuccess': 'تم إيقاف الإستراتيجية بنجاح', + 'trading-assistant.messages.stopFailed': 'فشلت استراتيجية التوقف', + 'trading-assistant.messages.loadFailed': 'فشل الحصول على قائمة السياسات', + 'trading-assistant.messages.runningWarning': 'الاستراتيجية قيد التشغيل. يرجى إيقاف الإستراتيجية قبل تعديلها.', + 'trading-assistant.messages.deleteConfirmWithName': 'هل أنت متأكد أنك تريد حذف السياسة "{name}"؟ هذه العملية لا رجعة فيها.', + 'trading-assistant.messages.deleteConfirm': 'هل أنت متأكد أنك تريد حذف هذه السياسة؟ هذه العملية لا رجعة فيها.', + 'trading-assistant.messages.loadTradesFailed': 'فشل في الحصول على سجلات المعاملات', + 'trading-assistant.messages.loadPositionsFailed': 'فشل الحصول على سجل الموقف', + 'trading-assistant.messages.loadEquityFailed': 'فشل في الحصول على منحنى الأسهم', + 'trading-assistant.messages.loadIndicatorsFailed': 'فشل تحميل قائمة المؤشرات، يرجى المحاولة مرة أخرى في وقت لاحق', + 'trading-assistant.messages.spotLimitations': 'تم ضبط التداول الفوري تلقائيًا على رافعة مالية طويلة فقط بمقدار 1x', + 'trading-assistant.messages.autoFillApiConfig': 'تمت تعبئة تكوين واجهة برمجة التطبيقات التاريخية للبورصة تلقائيًا', + 'trading-assistant.placeholders.selectIndicator': 'الرجاء تحديد مؤشر', + 'trading-assistant.placeholders.selectExchange': 'الرجاء تحديد التبادل', + 'trading-assistant.placeholders.inputApiKey': 'الرجاء إدخال مفتاح API', + 'trading-assistant.placeholders.inputSecretKey': 'الرجاء إدخال المفتاح السري', + 'trading-assistant.placeholders.inputPassphrase': 'الرجاء إدخال عبارة المرور', + 'trading-assistant.placeholders.inputStrategyName': 'الرجاء إدخال اسم السياسة', + 'trading-assistant.placeholders.selectSymbol': 'يرجى اختيار زوج التداول', + 'trading-assistant.placeholders.selectTimeframe': 'الرجاء تحديد الفترة الزمنية', + 'trading-assistant.placeholders.selectKlinePeriod': 'الرجاء تحديد فترة K-Line', + 'trading-assistant.placeholders.inputAiFilterPrompt': 'الرجاء إدخال موجه مخصص (اختياري)', + 'trading-assistant.validation.indicatorRequired': 'الرجاء تحديد مؤشر', + 'trading-assistant.validation.exchangeRequired': 'الرجاء تحديد التبادل', + 'trading-assistant.validation.apiKeyRequired': 'الرجاء إدخال مفتاح API', + 'trading-assistant.validation.secretKeyRequired': 'الرجاء إدخال المفتاح السري', + 'trading-assistant.validation.passphraseRequired': 'الرجاء إدخال عبارة المرور', + 'trading-assistant.validation.exchangeConfigIncomplete': 'يرجى ملء معلومات تكوين التبادل الكاملة', + 'trading-assistant.validation.testConnectionRequired': 'يرجى النقر على زر "اختبار الاتصال" أولاً والتأكد من نجاح الاتصال', + 'trading-assistant.validation.testConnectionFailed': 'فشل اختبار الاتصال، يرجى التحقق من التكوين وإعادة المحاولة', + 'trading-assistant.validation.strategyNameRequired': 'الرجاء إدخال اسم السياسة', + 'trading-assistant.validation.symbolRequired': 'يرجى اختيار زوج التداول', + 'trading-assistant.validation.initialCapitalRequired': 'الرجاء إدخال مبلغ الاستثمار', + 'trading-assistant.validation.leverageRequired': 'الرجاء إدخال نسبة الرافعة المالية', + 'trading-assistant.table.time': 'الوقت', + 'trading-assistant.table.type': 'اكتب', + 'trading-assistant.table.price': 'السعر', + 'trading-assistant.table.amount': 'الكمية', + 'trading-assistant.table.value': 'المبلغ', + 'trading-assistant.table.commission': 'رسوم المناولة', + 'trading-assistant.table.symbol': 'زوج التداول', + 'trading-assistant.table.side': 'الاتجاه', + 'trading-assistant.table.size': 'كمية الموقف', + 'trading-assistant.table.entryPrice': 'سعر الافتتاح', + 'trading-assistant.table.currentPrice': 'السعر الحالي', + 'trading-assistant.table.unrealizedPnl': 'الربح أو الخسارة غير المحققة', + 'trading-assistant.table.pnlPercent': 'نسبة الربح والخسارة', + 'trading-assistant.table.buy': 'شراء', + 'trading-assistant.table.sell': 'بيع', + 'trading-assistant.table.long': 'اذهب لفترة طويلة', + 'trading-assistant.table.short': 'قصيرة', + 'trading-assistant.table.noPositions': 'لا توجد مناصب حتى الآن', + 'trading-assistant.detail.title': 'تفاصيل الاستراتيجية', + 'trading-assistant.detail.strategyName': 'اسم السياسة', + 'trading-assistant.detail.strategyType': 'نوع الإستراتيجية', + 'trading-assistant.detail.status': 'الحالة', + 'trading-assistant.detail.tradingMode': 'نموذج التداول', + 'trading-assistant.detail.exchange': 'تبادل', + 'trading-assistant.detail.initialCapital': 'رأس المال الأولي', + 'trading-assistant.detail.totalInvestment': 'إجمالي مبلغ الاستثمار', + 'trading-assistant.detail.currentEquity': 'صافي القيمة الحالية', + 'trading-assistant.detail.totalPnl': 'إجمالي الربح والخسارة', + 'trading-assistant.detail.indicatorName': 'اسم المؤشر', + 'trading-assistant.detail.maxLeverage': 'الحد الأقصى للرافعة المالية', + 'trading-assistant.detail.decideInterval': 'الفاصل الزمني للقرار', + 'trading-assistant.detail.symbols': 'كائن المعاملة', + 'trading-assistant.detail.createdAt': 'وقت الخلق', + 'trading-assistant.detail.updatedAt': 'وقت التحديث', + 'trading-assistant.detail.llmConfig': 'تكوين نموذج الذكاء الاصطناعي', + 'trading-assistant.detail.exchangeConfig': 'تكوين الصرف', + 'trading-assistant.detail.provider': 'مزود', + 'trading-assistant.detail.modelId': 'معرف النموذج', + 'trading-assistant.detail.close': 'إغلاق', + 'trading-assistant.detail.loadFailed': 'فشل الحصول على تفاصيل السياسة', + 'trading-assistant.equity.noData': 'لا توجد بيانات عن صافي القيمة حتى الآن', + 'trading-assistant.equity.equity': 'صافي القيمة', + 'trading-assistant.exchange.tradingMode': 'نموذج التداول', + 'trading-assistant.exchange.virtual': 'محاكاة التداول', + 'trading-assistant.exchange.live': 'معاملة حقيقية', + 'trading-assistant.exchange.selectExchange': 'اختر الصرف', + 'trading-assistant.exchange.walletAddress': 'عنوان المحفظة', + 'trading-assistant.exchange.walletAddressPlaceholder': 'الرجاء إدخال عنوان محفظتك (يبدأ بـ 0x)', + 'trading-assistant.exchange.privateKey': 'مفتاح خاص', + 'trading-assistant.exchange.privateKeyPlaceholder': 'الرجاء إدخال المفتاح الخاص (64 حرفًا)', + 'trading-assistant.exchange.testConnection': 'اتصال الاختبار', + 'trading-assistant.exchange.connectionSuccess': 'تم الاتصال بنجاح', + 'trading-assistant.exchange.connectionFailed': 'فشل الاتصال', + 'trading-assistant.exchange.testFailed': 'فشل اختبار الاتصال', + 'trading-assistant.exchange.fillComplete': 'يرجى ملء معلومات تكوين التبادل الكاملة', + 'trading-assistant.strategyTypeOptions.ai': 'استراتيجية تعتمد على الذكاء الاصطناعي', + 'trading-assistant.strategyTypeOptions.indicator': 'استراتيجية المؤشر الفني', + 'trading-assistant.strategyTypeOptions.aiDeveloping': 'إن وظيفة الإستراتيجية المبنية على الذكاء الاصطناعي قيد التطوير، لذا ترقبوا ذلك', + 'trading-assistant.strategyTypeOptions.aiDevelopingWarning': 'ميزات الإستراتيجية المعتمدة على الذكاء الاصطناعي قيد التطوير', + 'trading-assistant.indicatorType.trend': 'الاتجاه', + 'trading-assistant.indicatorType.momentum': 'الزخم', + 'trading-assistant.indicatorType.volatility': 'التقلب', + 'trading-assistant.indicatorType.volume': 'الحجم', + 'trading-assistant.indicatorType.custom': 'تخصيص', + 'trading-assistant.exchangeNames': { + 'okx': 'أوكي إكس', + 'binance': 'بينانس', + 'hyperliquid': 'السائل الزائد', + 'blockchaincom': 'Blockchain.com', + 'coinbaseexchange': 'كوين بيس', + 'gate': 'بوابة.io', + 'mexc': 'ميكسك', + 'kraken': 'كراكن', + 'bitfinex': 'بيتفينكس', + 'bybit': 'بايبيت', + 'kucoin': 'كيو كوين', + 'huobi': 'هوبي', + 'bitget': 'بيتجيت', + 'bitmex': 'بيتميكس', + 'deribit': 'ديربيت', + 'phemex': 'فيميكس', + 'bitmart': 'بيتمارت', + 'bitstamp': 'بيتستامب', + 'bittrex': 'بيتريكس', + 'poloniex': 'بولونيكس', + 'gemini': 'الجوزاء', + 'cryptocom': 'تشفير.كوم', + 'bitflyer': 'bitFlyer', + 'upbit': 'أوبيت', + 'bithumb': 'بيتهامب', + 'coinone': 'عملة واحدة', + 'zb': 'ZB', + 'lbank': 'بنك إل', + 'bibox': 'بيبوكس', + 'bigone': 'BigONE', + 'bitrue': 'صحيح', + 'coinex': 'كوين إكس', + 'digifinex': 'DigiFinex', + 'ftx': 'إف تي إكس', + 'ftxus': 'إف تي إكس الولايات المتحدة', + 'binanceus': 'بينانس الولايات المتحدة', + 'binancecoinm': 'عملة بينانس-M', + 'binanceusdm': 'Binance USDⓈ-M' + }, + 'ai-trading-assistant.title': 'مساعد التداول بالذكاء الاصطناعي', + 'ai-trading-assistant.strategyList': 'قائمة الإستراتيجية', + 'ai-trading-assistant.createStrategy': 'إنشاء سياسة', + 'ai-trading-assistant.noStrategy': 'لا توجد استراتيجية حتى الآن', + 'ai-trading-assistant.selectStrategy': 'يرجى تحديد استراتيجية من اليسار لعرض التفاصيل', + 'ai-trading-assistant.startStrategy': 'استراتيجية الإطلاق', + 'ai-trading-assistant.stopStrategy': 'استراتيجية التوقف', + 'ai-trading-assistant.editStrategy': 'استراتيجية التحرير', + 'ai-trading-assistant.deleteStrategy': 'حذف السياسة', + 'ai-trading-assistant.status.running': 'الجري', + 'ai-trading-assistant.status.stopped': 'توقف', + 'ai-trading-assistant.status.error': 'خطأ', + 'ai-trading-assistant.tabs.tradingRecords': 'تاريخ المعاملة', + 'ai-trading-assistant.tabs.positions': 'سجل الموقف', + 'ai-trading-assistant.tabs.aiDecisions': 'سجل قرار الذكاء الاصطناعي', + 'ai-trading-assistant.tabs.equityCurve': 'منحنى الأسهم', + 'ai-trading-assistant.form.createTitle': 'إنشاء استراتيجيات التداول بالذكاء الاصطناعي', + 'ai-trading-assistant.form.editTitle': 'تحرير استراتيجية التداول بالذكاء الاصطناعي', + 'ai-trading-assistant.form.strategyName': 'اسم السياسة', + 'ai-trading-assistant.form.modelId': 'نموذج الذكاء الاصطناعي', + 'ai-trading-assistant.form.modelIdHint': 'استخدم خدمة OpenRouter الخاصة بالنظام دون تكوين مفتاح API', + 'ai-trading-assistant.form.decideInterval': 'الفاصل الزمني للقرار', + 'ai-trading-assistant.form.decideInterval5m': '5 دقائق', + 'ai-trading-assistant.form.decideInterval10m': '10 دقائق', + 'ai-trading-assistant.form.decideInterval30m': '30 دقيقة', + 'ai-trading-assistant.form.decideInterval1h': '1 ساعة', + 'ai-trading-assistant.form.decideInterval4h': '4 ساعات', + 'ai-trading-assistant.form.decideInterval1d': 'يوم واحد', + 'ai-trading-assistant.form.decideInterval1w': '1 أسبوع', + 'ai-trading-assistant.form.decideIntervalHint': 'الفاصل الزمني للذكاء الاصطناعي لاتخاذ القرارات', + 'ai-trading-assistant.form.runPeriod': 'تشغيل دورة', + 'ai-trading-assistant.form.runPeriodHint': 'وقت البدء ووقت الانتهاء لتشغيل الإستراتيجية', + 'ai-trading-assistant.form.startDate': 'تاريخ البدء', + 'ai-trading-assistant.form.endDate': 'تاريخ الانتهاء', + 'ai-trading-assistant.form.qdtCostTitle': 'تعليمات خصم QDT', + 'ai-trading-assistant.form.qdtCostHint': 'سيتم خصم {cost} QDT لكل قرار للذكاء الاصطناعي. يرجى التأكد من أن حسابك يحتوي على رصيد QDT كافٍ. أثناء تنفيذ الإستراتيجية، سيتم خصم الرسوم في الوقت الفعلي لكل قرار.', + 'ai-trading-assistant.form.apiKey': 'مفتاح واجهة برمجة التطبيقات', + 'ai-trading-assistant.form.exchange': 'اختر الصرف', + 'ai-trading-assistant.form.secretKey': 'المفتاح السري', + 'ai-trading-assistant.form.passphrase': 'عبارة المرور', + 'ai-trading-assistant.form.testConnection': 'اتصال الاختبار', + 'ai-trading-assistant.form.symbol': 'زوج التداول', + 'ai-trading-assistant.form.symbolHint': 'حدد زوج التداول للتداول', + 'ai-trading-assistant.form.initialCapital': 'المبلغ المستثمر (الهامش)', + 'ai-trading-assistant.form.leverage': 'الاستفادة المتعددة', + 'ai-trading-assistant.form.timeframe': 'الفترة الزمنية', + 'ai-trading-assistant.form.timeframe1m': '1 دقيقة', + 'ai-trading-assistant.form.timeframe5m': '5 دقائق', + 'ai-trading-assistant.form.timeframe15m': '15 دقيقة', + 'ai-trading-assistant.form.timeframe30m': '30 دقيقة', + 'ai-trading-assistant.form.timeframe1H': '1 ساعة', + 'ai-trading-assistant.form.timeframe4H': '4 ساعات', + 'ai-trading-assistant.form.timeframe1D': 'يوم واحد', + 'ai-trading-assistant.form.marketType': 'نوع السوق', + 'ai-trading-assistant.form.marketTypeFutures': 'العقد', + 'ai-trading-assistant.form.marketTypeSpot': 'بقعة', + 'ai-trading-assistant.form.totalPnl': 'إجمالي الربح والخسارة', + 'ai-trading-assistant.form.customPrompt': 'كلمات موجهة مخصصة', + 'ai-trading-assistant.form.customPromptHint': 'اختياري، يُستخدم لتخصيص استراتيجيات التداول بالذكاء الاصطناعي ومنطق اتخاذ القرار', + 'ai-trading-assistant.form.cancel': 'إلغاء', + 'ai-trading-assistant.form.prev': 'الخطوة السابقة', + 'ai-trading-assistant.form.next': 'الخطوة التالية', + 'ai-trading-assistant.form.confirmCreate': 'تأكيد الإنشاء', + 'ai-trading-assistant.form.confirmEdit': 'تأكيد التغييرات', + 'ai-trading-assistant.messages.createSuccess': 'تم إنشاء الإستراتيجية بنجاح', + 'ai-trading-assistant.messages.createFailed': 'فشل في إنشاء السياسة', + 'ai-trading-assistant.messages.updateSuccess': 'تم تحديث السياسة بنجاح', + 'ai-trading-assistant.messages.updateFailed': 'فشل تحديث السياسة', + 'ai-trading-assistant.messages.deleteSuccess': 'تم حذف السياسة بنجاح', + 'ai-trading-assistant.messages.deleteFailed': 'فشل سياسة الحذف', + 'ai-trading-assistant.messages.startSuccess': 'بدأت الإستراتيجية بنجاح', + 'ai-trading-assistant.messages.startFailed': 'فشل في بدء السياسة', + 'ai-trading-assistant.messages.stopSuccess': 'تم إيقاف الإستراتيجية بنجاح', + 'ai-trading-assistant.messages.stopFailed': 'فشلت استراتيجية التوقف', + 'ai-trading-assistant.messages.loadFailed': 'فشل الحصول على قائمة السياسات', + 'ai-trading-assistant.messages.loadDecisionsFailed': 'فشل في الحصول على سجل قرار الذكاء الاصطناعي', + 'ai-trading-assistant.messages.deleteConfirm': 'هل أنت متأكد أنك تريد حذف هذه السياسة؟ هذه العملية لا رجعة فيها.', + 'ai-trading-assistant.placeholders.inputStrategyName': 'الرجاء إدخال اسم السياسة', + 'ai-trading-assistant.placeholders.selectModelId': 'الرجاء تحديد نموذج الذكاء الاصطناعي', + 'ai-trading-assistant.placeholders.selectDecideInterval': 'الرجاء تحديد الفاصل الزمني للقرار', + 'ai-trading-assistant.placeholders.startTime': 'وقت البدء', + 'ai-trading-assistant.placeholders.endTime': 'وقت النهاية', + 'ai-trading-assistant.placeholders.inputApiKey': 'الرجاء إدخال مفتاح API', + 'ai-trading-assistant.placeholders.selectExchange': 'الرجاء تحديد التبادل', + 'ai-trading-assistant.placeholders.inputSecretKey': 'الرجاء إدخال المفتاح السري', + 'ai-trading-assistant.placeholders.inputPassphrase': 'الرجاء إدخال عبارة المرور', + 'ai-trading-assistant.placeholders.selectSymbol': 'يرجى تحديد زوج تداول، مثل: BTC/USDT', + 'ai-trading-assistant.placeholders.selectTimeframe': 'الرجاء تحديد الفترة الزمنية', + 'ai-trading-assistant.placeholders.inputCustomPrompt': 'الرجاء إدخال كلمة مطالبة مخصصة (اختياري)', + 'ai-trading-assistant.validation.strategyNameRequired': 'الرجاء إدخال اسم السياسة', + 'ai-trading-assistant.validation.modelIdRequired': 'الرجاء تحديد نموذج الذكاء الاصطناعي', + 'ai-trading-assistant.validation.runPeriodRequired': 'الرجاء تحديد دورة التشغيل', + 'ai-trading-assistant.validation.apiKeyRequired': 'الرجاء إدخال مفتاح API', + 'ai-trading-assistant.validation.exchangeRequired': 'الرجاء تحديد التبادل', + 'ai-trading-assistant.validation.secretKeyRequired': 'الرجاء إدخال المفتاح السري', + 'ai-trading-assistant.validation.symbolRequired': 'يرجى اختيار زوج التداول', + 'ai-trading-assistant.validation.initialCapitalRequired': 'الرجاء إدخال مبلغ الاستثمار', + 'ai-trading-assistant.table.time': 'الوقت', + 'ai-trading-assistant.table.type': 'اكتب', + 'ai-trading-assistant.table.price': 'السعر', + 'ai-trading-assistant.table.amount': 'الكمية', + 'ai-trading-assistant.table.value': 'المبلغ', + 'ai-trading-assistant.table.symbol': 'زوج التداول', + 'ai-trading-assistant.table.side': 'الاتجاه', + 'ai-trading-assistant.table.size': 'كمية الموقف', + 'ai-trading-assistant.table.entryPrice': 'سعر الافتتاح', + 'ai-trading-assistant.table.currentPrice': 'السعر الحالي', + 'ai-trading-assistant.table.unrealizedPnl': 'الربح أو الخسارة غير المحققة', + 'ai-trading-assistant.table.profit': 'الربح والخسارة', + 'ai-trading-assistant.table.openLong': 'مفتوح لفترة طويلة', + 'ai-trading-assistant.table.closeLong': 'بيندو', + 'ai-trading-assistant.table.openShort': 'فتح قصيرة', + 'ai-trading-assistant.table.closeShort': 'فارغ', + 'ai-trading-assistant.table.addLong': 'غادوت', + 'ai-trading-assistant.table.addShort': 'إضافة قصيرة', + 'ai-trading-assistant.table.closeShortProfit': 'جني الأرباح من المركز القصير', + 'ai-trading-assistant.table.closeShortStop': 'وقف الخسارة', + 'ai-trading-assistant.table.closeLongProfit': 'توقف لفترة طويلة وجني الأرباح', + 'ai-trading-assistant.table.closeLongStop': 'وقف الخسارة', + 'ai-trading-assistant.table.buy': 'شراء', + 'ai-trading-assistant.table.sell': 'بيع', + 'ai-trading-assistant.table.long': 'اذهب لفترة طويلة', + 'ai-trading-assistant.table.short': 'قصيرة', + 'ai-trading-assistant.table.hold': 'عقد', + 'ai-trading-assistant.table.reasoning': 'سبب التحليل', + 'ai-trading-assistant.table.decisions': 'صنع القرار', + 'ai-trading-assistant.table.riskAssessment': 'تقييم المخاطر', + 'ai-trading-assistant.table.confidence': 'الثقة', + 'ai-trading-assistant.table.totalRecords': '{total} السجلات في المجموع', + 'ai-trading-assistant.table.noPositions': 'لا توجد مناصب حتى الآن', + 'ai-trading-assistant.detail.title': 'تفاصيل الاستراتيجية', + 'ai-trading-assistant.equity.noData': 'لا توجد بيانات عن صافي القيمة حتى الآن', + 'ai-trading-assistant.equity.equity': 'صافي القيمة', + 'ai-trading-assistant.exchange.testFailed': 'فشل اختبار الاتصال', + 'ai-trading-assistant.exchange.connectionSuccess': 'تم الاتصال بنجاح', + 'ai-trading-assistant.exchange.connectionFailed': 'فشل الاتصال', + 'ai-trading-assistant.form.advancedSettings': 'الإعدادات المتقدمة', + 'ai-trading-assistant.form.orderMode': 'وضع الطلب', + 'ai-trading-assistant.form.orderModeMaker': 'صانع', + 'ai-trading-assistant.form.orderModeTaker': 'سعر السوق (المتلقي)', + 'ai-trading-assistant.form.orderModeHint': 'يستخدم وضع الأمر المعلق أوامر الحد وتكون رسوم المناولة أقل؛ يتم تنفيذ وضع سعر السوق على الفور وتكون رسوم المناولة أعلى.', + 'ai-trading-assistant.form.makerWaitSec': 'وقت الانتظار للأوامر المعلقة (ثواني)', + 'ai-trading-assistant.form.makerWaitSecHint': 'الوقت اللازم لانتظار المعاملة بعد تقديم الطلب. قم بالإلغاء وحاول مرة أخرى بعد انتهاء المهلة.', + 'ai-trading-assistant.form.makerRetries': 'عدد مرات إعادة المحاولة للأوامر المعلقة', + 'ai-trading-assistant.form.makerRetriesHint': 'الحد الأقصى لعدد مرات إعادة المحاولة عندما لا يتم تنفيذ الأمر المعلق', + 'ai-trading-assistant.form.fallbackToMarket': 'يفشل الأمر المعلق ويتم تخفيض سعر السوق.', + 'ai-trading-assistant.form.fallbackToMarketHint': 'عندما لا يتم إكمال أمر الفتح/الإغلاق المعلق، ما إذا كان ينبغي تخفيضه إلى أمر سوق لضمان اكتمال المعاملة.', + 'ai-trading-assistant.form.marginMode': 'وضع الهامش', + 'ai-trading-assistant.form.marginModeCross': 'مستودع كامل', + 'ai-trading-assistant.form.marginModeIsolated': 'موقف معزول', + 'ai-analysis.title': 'محرك التداول الكمي', + 'ai-analysis.system.online': 'على الانترنت', + 'ai-analysis.system.agents': 'وكيل', + 'ai-analysis.system.active': 'نشط', + 'ai-analysis.system.stage': 'المرحلة', + 'ai-analysis.panel.roster': 'تشكيلة الوكيل', + 'ai-analysis.panel.thinking': 'أفكر...', + 'ai-analysis.panel.done': 'كامل', + 'ai-analysis.panel.standby': 'الاستعداد', + 'ai-analysis.input.title': 'محرك التداول الكمي', + 'ai-analysis.input.placeholder': 'حدد الأصول المستهدفة (مثل BTC/USDT)', + 'ai-analysis.input.watchlist': 'الأسهم الاختيارية', + 'ai-analysis.input.start': 'ابدأ التحليل', + 'ai-analysis.input.recent': 'المهام الأخيرة:', + 'ai-analysis.vis.stage': 'المرحلة', + 'ai-analysis.vis.processing': 'المعالجة', + 'ai-analysis.result.complete': 'اكتمل التحليل', + 'ai-analysis.result.signal': 'الإشارة النهائية', + 'ai-analysis.result.confidence': 'الثقة:', + 'ai-analysis.result.new': 'تحليل جديد', + 'ai-analysis.result.full': 'عرض التقرير كاملا', + 'ai-analysis.logs.title': 'سجل النظام', + 'ai-analysis.modal.title': 'تقرير سري', + 'ai-analysis.modal.fundamental': 'التحليل الأساسي', + 'ai-analysis.modal.technical': 'التحليل الفني', + 'ai-analysis.modal.sentiment': 'التحليل العاطفي', + 'ai-analysis.modal.risk': 'تقييم المخاطر', + 'ai-analysis.stage.idle': 'الاستعداد', + 'ai-analysis.stage.1': 'المرحلة الأولى: التحليل متعدد الأبعاد', + 'ai-analysis.stage.2': 'المرحلة الثانية: المناقشة الطويلة والقصيرة', + 'ai-analysis.stage.3': 'المرحلة الثالثة: التخطيط الاستراتيجي', + 'ai-analysis.stage.4': 'المرحلة الرابعة: مراجعة مراقبة المخاطر', + 'ai-analysis.stage.complete': 'كامل', + 'ai-analysis.agent.investment_director': 'مدير الاستثمار', + 'ai-analysis.agent.role.investment_director': 'تحليل شامل واستنتاج نهائي', + 'ai-analysis.agent.market': 'محلل السوق', + 'ai-analysis.agent.role.market': 'التكنولوجيا وبيانات السوق', + 'ai-analysis.agent.fundamental': 'محلل أساسي', + 'ai-analysis.agent.role.fundamental': 'التمويل والتقييم', + 'ai-analysis.agent.technical': 'محلل فني', + 'ai-analysis.agent.role.technical': 'المؤشرات الفنية والرسوم البيانية', + 'ai-analysis.agent.news': 'محلل اخبار', + 'ai-analysis.agent.role.news': 'مرشح الأخبار العالمية', + 'ai-analysis.agent.sentiment': 'محلل المشاعر', + 'ai-analysis.agent.role.sentiment': 'الاجتماعية والعاطفية', + 'ai-analysis.agent.risk': 'محلل المخاطر', + 'ai-analysis.agent.role.risk': 'فحص المخاطر الأساسية', + 'ai-analysis.agent.bull': 'الباحث الصاعد', + 'ai-analysis.agent.role.bull': 'التعدين محفز النمو', + 'ai-analysis.agent.bear': 'الباحث الهابط', + 'ai-analysis.agent.role.bear': 'حفر المخاطر والعيوب', + 'ai-analysis.agent.manager': 'مدير البحوث', + 'ai-analysis.agent.role.manager': 'مشرف المناقشة', + 'ai-analysis.agent.trader': 'تاجر', + 'ai-analysis.agent.role.trader': 'استراتيجي تنفيذي', + 'ai-analysis.agent.risky': 'محلل ناشط', + 'ai-analysis.agent.role.risky': 'استراتيجية عدوانية', + 'ai-analysis.agent.neutral': 'محلل التوازن', + 'ai-analysis.agent.role.neutral': 'استراتيجية التوازن', + 'ai-analysis.agent.safe': 'محلل محافظ', + 'ai-analysis.agent.role.safe': 'استراتيجية محافظة', + 'ai-analysis.agent.cro': 'مدير مراقبة المخاطر (CRO)', + 'ai-analysis.agent.role.cro': 'سلطة اتخاذ القرار النهائي', + 'ai-analysis.script.market': 'جارٍ جلب بيانات OHLCV من البورصات الرئيسية...', + 'ai-analysis.script.fundamental': 'استخراج التقارير المالية الربع سنوية...', + 'ai-analysis.script.technical': 'تحليل المؤشرات الفنية وأنماط الرسم البياني.', + 'ai-analysis.script.news': 'متابعة الأخبار المالية العالمية...', + 'ai-analysis.script.sentiment': 'تحليل اتجاهات وسائل التواصل الاجتماعي...', + 'ai-analysis.script.risk': 'حساب التقلبات التاريخية...', + 'invite.inviteLink': 'رابط الدعوة', + 'invite.copy': 'انسخ الرابط', + 'invite.copySuccess': 'تم النسخ بنجاح!', + 'invite.copyFailed': 'فشل النسخ، يرجى النسخ يدويًا', + 'invite.noInviteLink': 'لم يتم إنشاء رابط الدعوة', + 'invite.totalInvites': 'الدعوات التراكمية', + 'invite.totalReward': 'المكافآت التراكمية', + 'invite.rules': 'قواعد الدعوة', + 'invite.rule1': 'في كل مرة تقوم فيها بدعوة صديق بنجاح للتسجيل، سوف تحصل على مكافأة', + 'invite.rule2': 'إذا أكمل الصديق المدعو المعاملة الأولى، فستحصل على مكافآت إضافية', + 'invite.rule3': 'سيتم إرسال مكافآت الدعوة مباشرة إلى حسابك', + 'invite.inviteList': 'قائمة الدعوة', + 'invite.tasks': 'مركز البعثة', + 'invite.inviteeName': 'المدعو', + 'invite.inviteTime': 'وقت الدعوة', + 'invite.status': 'الحالة', + 'invite.reward': 'مكافأة', + 'invite.active': 'نشط', + 'invite.inactive': 'لم يتم تفعيلها', + 'invite.completed': 'مكتمل', + 'invite.claimed': 'تم الاستلام', + 'invite.pending': 'ليتم الانتهاء منها', + 'invite.goToTask': 'لإكمال', + 'invite.claimReward': 'المطالبة بالمكافآت', + 'invite.verify': 'اكتمل التحقق', + 'invite.verifySuccess': 'تم التحقق بنجاح! اكتملت المهمة', + 'invite.verifyNotCompleted': 'المهمة لم تكتمل بعد، يرجى إكمال المهمة أولا', + 'invite.verifyFailed': 'فشل التحقق، يرجى المحاولة مرة أخرى في وقت لاحق', + 'invite.claimSuccess': 'تم استلام {reward} QDT بنجاح!', + 'invite.claimFailed': 'فشل التجميع، يرجى المحاولة مرة أخرى لاحقًا', + 'invite.totalRecords': '{total} السجلات في المجموع', + 'invite.task.twitter.title': 'إعادة تغريد إلى X (تويتر)', + 'invite.task.twitter.desc': 'شارك تغريداتنا الرسمية على حساب X (Twitter) الخاص بك', + 'invite.task.youtube.title': 'تابع قناتنا على اليوتيوب', + 'invite.task.youtube.desc': 'اشترك وتابع قناتنا الرسمية على اليوتيوب', + 'invite.task.telegram.title': 'انضم إلى مجموعة تيليجرام', + 'invite.task.telegram.desc': 'انضم إلى مجموعة مجتمع Telegram الرسمية لدينا', + 'invite.task.discord.title': 'انضم إلى خادم Discord', + 'invite.task.discord.desc': 'انضم إلى خادم مجتمع Discord الخاص بنا', + 'message': '-', + 'layouts.usermenu.dialog.title': 'معلومات', + 'layouts.usermenu.dialog.content': 'هل أنت متأكد أنك تريد تسجيل الخروج؟', + 'layouts.userLayout.title': 'ابحث عن الحقيقة في حالة عدم اليقين', + // Auto-filled missing keys from en-US (fallback to English) + 'dashboard.indicator.boundary.message': 'Note: The indicator script only computes plots + buy/sell signals. Position sizing, risk, scaling, fees/slippage belong to execution config.', + 'dashboard.indicator.boundary.indicatorRule': "Keep the script to buy/sell only (and set df['buy']/df['sell']). Do NOT implement position management, TP/SL, scaling in the script.", + 'dashboard.indicator.boundary.backtestRule': 'Rule: If a bar has a main signal (buy/sell → open/close/reverse), scaling in/out is skipped on the same bar.', + 'dashboard.indicator.guide.title': 'Python Indicator & Strategy Development Guide', + 'dashboard.indicator.guide.intro': 'This platform supports writing custom technical indicators and trading signals using Python. The system includes built-in pandas and numpy data analysis libraries. You can use standard DataFrame operations to process K-line data and draw charts or mark buy/sell signals.', + 'dashboard.indicator.guide.section1.title': '1. Runtime Environment & Predefined Variables', + 'dashboard.indicator.guide.section1.env': 'Your code runs in a browser-based Python environment (Pyodide).', + 'dashboard.indicator.guide.section1.libs': 'Pre-installed Libraries', + 'dashboard.indicator.guide.section1.libsDesc': 'The following libraries are imported by default, no need to import again:', + 'dashboard.indicator.guide.section1.libsList': 'pandas (pd), numpy (np), math, json', + 'dashboard.indicator.guide.section1.data': 'Input Data: df', + 'dashboard.indicator.guide.section1.dataDesc': 'The system automatically converts current K-line data into a Pandas DataFrame object, variable name is df.', + 'dashboard.indicator.guide.section1.dataColumns': 'df contains the following columns (all float type):', + 'dashboard.indicator.guide.section1.dataColumnsList': 'time (timestamp, seconds), open, high, low, close, volume', + 'dashboard.indicator.guide.section1.dataNote': 'Data index is arranged from old to new (0 is the earliest data, last row is the latest data).', + 'dashboard.indicator.guide.section1.dataIndex': 'Important: df index is timestamp (DatetimeIndex), please preserve the index, avoid using .values which loses the index.', + 'dashboard.indicator.guide.section2.title': '2. Parameter Definition', + 'dashboard.indicator.guide.section2.desc': 'You can define variables as parameters at the beginning of your code. The system will try to parse statements like variable = value as adjustable parameters.', + 'dashboard.indicator.guide.section2.example': 'Parameter Example', + 'dashboard.indicator.guide.section2.exampleCode': 'span = 20 or multiplier = 2.0', + 'dashboard.indicator.guide.section3.title': '3. Backtest Signal Setup (Important!)', + 'dashboard.indicator.guide.section3.desc': 'If you want your indicator to support backtesting, you must set the following two columns in your code:', + 'dashboard.indicator.guide.section3.backtestRequired': 'Required for Backtesting:', + 'dashboard.indicator.guide.section3.backtestBuySignal': "df['buy_signal'] - Boolean Series, True indicates buy signal", + 'dashboard.indicator.guide.section3.backtestSellSignal': "df['sell_signal'] - Boolean Series, True indicates sell signal", + 'dashboard.indicator.guide.section3.backtestExample': 'Example:', + 'dashboard.indicator.guide.section3.backtestExampleCode': "df['buy_signal'] = condition_buy
    df['sell_signal'] = condition_sell", + 'dashboard.indicator.guide.section3.backtestNote': 'Note: Even if you only set output.signals for chart display, you must set these two columns to enable backtesting.', + 'dashboard.indicator.guide.section4.title': '4. Output Requirements: output', + 'dashboard.indicator.guide.section4.desc': 'At the end of code execution, you must define a dictionary named output to tell the chart how to display results.', + 'dashboard.indicator.guide.section4.fields': 'The output dictionary contains the following fields:', + 'dashboard.indicator.guide.section4.plots': 'plots - For drawing lines (e.g., moving averages, RSI)', + 'dashboard.indicator.guide.section4.signals': 'signals - For marking buy/sell signals (e.g., arrows, labels)', + 'dashboard.indicator.guide.section4.name': 'name - Indicator name (optional)', + 'dashboard.indicator.guide.section4.calculatedVars': 'calculatedVars - Calculated variables (optional, for displaying additional information)', + 'dashboard.indicator.guide.section5.title': '5. Plot Configuration (Plots)', + 'dashboard.indicator.guide.section5.desc': 'Each object in the plots list represents a line.', + 'dashboard.indicator.guide.section5.table.field': 'Field', + 'dashboard.indicator.guide.section5.table.type': 'Type', + 'dashboard.indicator.guide.section5.table.required': 'Required', + 'dashboard.indicator.guide.section5.table.requiredYes': 'Yes', + 'dashboard.indicator.guide.section5.table.requiredNo': 'No', + 'dashboard.indicator.guide.section5.table.desc': 'Description', + 'dashboard.indicator.guide.section5.table.name': 'The name of the indicator line, displayed in the legend', + 'dashboard.indicator.guide.section5.table.data': 'Data list, length must match the number of rows in df. Recommended to use series.tolist()', + 'dashboard.indicator.guide.section5.table.color': "Line color (Hex format, e.g. '#ff0000'). If not filled, the system will automatically assign", + 'dashboard.indicator.guide.section5.table.typeDesc': "Chart type, default is 'line'", + 'dashboard.indicator.guide.section5.table.overlay': 'Important. True means display on main chart (with K-line), False means display on sub-chart (independent area)', + 'dashboard.indicator.guide.section5.example': 'Example: Draw Simple Moving Average (SMA)', + 'dashboard.indicator.guide.section5.exampleCalc': 'Calculate SMA', + 'dashboard.indicator.guide.section5.exampleOutput': 'Construct Output', + 'dashboard.indicator.guide.section5.exampleOverlay': 'Display on main chart', + 'dashboard.indicator.guide.section6.title': '6. Signal Markers (Signals)', + 'dashboard.indicator.guide.section6.desc': "signals are used to mark buy/sell points on specific K-lines through icons or text. Note: This is only for chart display. The backtest system uses df['buy_signal'] and df['sell_signal'].", + 'dashboard.indicator.guide.section6.table.type': "Signal type: 'buy' (buy, marked below K-line) or 'sell' (sell, marked above K-line)", + 'dashboard.indicator.guide.section6.table.data': 'Signal data list. Fill price value at signal positions, fill None (or np.nan) at non-signal positions', + 'dashboard.indicator.guide.section6.table.text': 'Text displayed on the label (e.g. "B", "S", "Buy", "Sell")', + 'dashboard.indicator.guide.section6.table.color': 'Marker color', + 'dashboard.indicator.guide.section6.hint': 'The system automatically handles positioning.', + 'dashboard.indicator.guide.section6.buy': 'Markers will be automatically positioned below the Low of the current K-line', + 'dashboard.indicator.guide.section6.sell': 'Markers will be automatically positioned above the High of the current K-line', + 'dashboard.indicator.guide.section6.example': 'Example: Draw Buy/Sell Signals', + 'dashboard.indicator.guide.section6.exampleInit': 'Initialize signal list, fill all with None', + 'dashboard.indicator.guide.section6.exampleLogic': 'Simple logic: mark buy when close price is greater than open price', + 'dashboard.indicator.guide.section6.exampleNote': 'In actual logic, please use loops or vectorized operations', + 'dashboard.indicator.guide.section6.examplePrice': 'Record price', + 'dashboard.indicator.guide.section6.exampleText': 'Buy', + 'dashboard.indicator.guide.section7.title': '7. Complete Code Examples', + 'dashboard.indicator.guide.section7.exampleA': 'Example A: Dual EMA Strategy (Supports Backtesting)', + 'dashboard.indicator.guide.section7.exampleACode': "# 1. Calculate indicators
    df['ema_fast'] = df['close'].ewm(span=5, adjust=False).mean()
    df['ema_slow'] = df['close'].ewm(span=13, adjust=False).mean()

    # 2. Calculate crossover signals
    cross_up = (df['ema_fast'] > df['ema_slow']) & (df['ema_fast'].shift(1) <= df['ema_slow'].shift(1))
    cross_down = (df['ema_fast'] < df['ema_slow']) & (df['ema_fast'].shift(1) >= df['ema_slow'].shift(1))

    # 3. 【Important】Set NEW backtest signal columns (bool)
    df['open_long'] = cross_up
    df['close_long'] = cross_down
    df['open_short'] = cross_down
    df['close_short'] = cross_up

    # 4. Generate signal data for chart display
    buy_signals = [df['low'].iloc[i] * 0.995 if cross_up.iloc[i] else None for i in range(len(df))]
    sell_signals = [df['high'].iloc[i] * 1.005 if cross_down.iloc[i] else None for i in range(len(df))]

    # 5. Build output
    output = {
    \"name\": \"Dual EMA Strategy\",
    \"plots\": [
    {\"name\": \"EMA 5\", \"data\": df['ema_fast'].tolist(), \"color\": \"#FF6B6B\", \"overlay\": True},
    {\"name\": \"EMA 13\", \"data\": df['ema_slow'].tolist(), \"color\": \"#4ECDC4\", \"overlay\": True}
    ],
    \"signals\": [
    {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},
    {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}
    ]
    }", + 'dashboard.indicator.guide.section7.exampleB': 'Example B: Bollinger Bands - Chart Display Only', + 'dashboard.indicator.guide.section7.exampleBCode': "# Calculate Bollinger Bands (chart display only, no backtesting)
    length = 20
    mult = 2.0
    df['sma'] = df['close'].rolling(length).mean()
    df['std'] = df['close'].rolling(length).std()
    df['upper'] = df['sma'] + mult * df['std']
    df['lower'] = df['sma'] - mult * df['std']

    output = {
    \"name\": \"Bollinger Bands\",
    \"plots\": [
    {\"name\": \"Upper\", \"data\": df['upper'].tolist(), \"color\": \"#FF5252\", \"overlay\": True},
    {\"name\": \"Middle\", \"data\": df['sma'].tolist(), \"color\": \"#2196F3\", \"overlay\": True},
    {\"name\": \"Lower\", \"data\": df['lower'].tolist(), \"color\": \"#00E676\", \"overlay\": True}
    ]
    }", + 'dashboard.indicator.guide.section7.exampleC': 'Example C: RSI Strategy (Supports Backtesting)', + 'dashboard.indicator.guide.section7.exampleCCode': "# Calculate RSI
    delta = df['close'].diff()
    up = delta.clip(lower=0)
    down = -1 * delta.clip(upper=0)
    ema_up = up.ewm(com=13, adjust=False).mean()
    ema_down = down.ewm(com=13, adjust=False).mean()
    rs = ema_up / ema_down
    df['rsi'] = 100 - (100 / (1 + rs))

    # Generate trading signals
    buy_condition = df['rsi'] < 30 # Oversold buy
    sell_condition = df['rsi'] > 70 # Overbought sell

    # 【Important】Set NEW backtest signal columns (bool)
    df['open_long'] = buy_condition
    df['close_long'] = sell_condition
    df['open_short'] = sell_condition
    df['close_short'] = buy_condition

    # Generate chart signals
    buy_signals = [df['low'].iloc[i] * 0.995 if buy_condition.iloc[i] else None for i in range(len(df))]
    sell_signals = [df['high'].iloc[i] * 1.005 if sell_condition.iloc[i] else None for i in range(len(df))]

    output = {
    \"name\": \"RSI Strategy\",
    \"plots\": [
    {\"name\": \"RSI\", \"data\": df['rsi'].tolist(), \"color\": \"#9C27B0\", \"overlay\": False},
    {\"name\": \"Overbought\", \"data\": [70] * len(df), \"color\": \"#FF5252\", \"style\": \"dashed\", \"overlay\": False},
    {\"name\": \"Oversold\", \"data\": [30] * len(df), \"color\": \"#00E676\", \"style\": \"dashed\", \"overlay\": False}
    ],
    \"signals\": [
    {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},
    {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}
    ]
    }", + 'dashboard.indicator.guide.section6.param': 'Parameters', + 'dashboard.indicator.guide.section6.calc': 'Calculate', + 'dashboard.indicator.guide.section6.output': 'Output', + 'dashboard.indicator.guide.section6.generate': 'Generate signal data (initialize with None)', + 'dashboard.indicator.guide.section6.subchart': 'Display on sub-chart', + 'dashboard.indicator.guide.section8.title': '8. Frequently Asked Questions (FAQ)', + 'dashboard.indicator.guide.section8.q1': "Q: Why can't my indicator be backtested?", + 'dashboard.indicator.guide.section8.a1': "A: The backtest system requires NEW signal columns: df['open_long'], df['close_long'], df['open_short'], df['close_short'] (boolean). Even if you set output.signals for chart display, you must set these columns to enable backtesting.", + 'dashboard.indicator.guide.section8.q2': 'Q: Data length mismatch?', + 'dashboard.indicator.guide.section8.a2': "A: The data list in plots and signals must have exactly the same length as the K-line data. If you use df['xxx'].tolist(), there is usually no problem.", + 'dashboard.indicator.guide.section8.q3': 'Q: How to handle NaN (empty values)?', + 'dashboard.indicator.guide.section8.a3': "A: Pandas calculations (such as rolling mean) will produce NaN at the beginning. The system will automatically convert NaN to null for chart rendering. You don't need to manually clean it, but if you need specific logic, you can use df.fillna(0) or other methods.", + 'dashboard.indicator.guide.section8.q4': 'Q: Index mismatch causing all signals to be NaN?', + 'dashboard.indicator.guide.section8.a4': "A: Please ensure you preserve the original df index (timestamp). Avoid using .values to create new Series. Use columns directly like df['close'] for operations.", + 'dashboard.indicator.guide.section8.q5': 'Q: Can I use third-party network libraries?', + 'dashboard.indicator.guide.section8.a5': 'A: No. The runtime environment is a browser sandbox and cannot use libraries like requests to request external APIs, nor can it install additional pip packages. Limited to built-in libraries such as pandas, numpy, math, json.', + 'dashboard.indicator.guide.section8.q6': 'Q: How to debug code?', + 'dashboard.indicator.guide.section8.a6': 'A: If the chart does not display indicators, it is usually because the Python code has an error. Please check for syntax errors or array index out of bounds. It is recommended to debug the algorithm logic in a local Python environment first, then copy it in.', + 'dashboard.indicator.guide.section8.q7': 'Q: Backtest shows 0 signals?', + 'dashboard.indicator.guide.section8.a7': "A: Please check the data type of df['open_long']/df['close_long']/df['open_short']/df['close_short']. They should be boolean (True/False), not numeric. Use condition.fillna(False).astype(bool) to ensure correct type.", + 'dashboard.indicator.backtest.commissionHint': 'Charged on notional value (incl. leverage) per trade; both entry & exit are deducted from balance.', + 'dashboard.indicator.backtest.historyTitle': 'Backtest History', + 'dashboard.indicator.backtest.historyRefresh': 'Refresh', + 'dashboard.indicator.backtest.historyView': 'View', + 'dashboard.indicator.backtest.historyNoData': 'No backtest history', + 'dashboard.indicator.backtest.historyUseCurrent': 'Only current symbol/timeframe', + 'dashboard.indicator.backtest.historyFilterSymbol': 'Symbol', + 'dashboard.indicator.backtest.historyFilterTimeframe': 'Timeframe', + 'dashboard.indicator.backtest.historyApply': 'Apply filters', + 'dashboard.indicator.backtest.historyAIAnalyze': 'AI Analyze', + 'dashboard.indicator.backtest.historyAIAnalyzeTitle': 'AI Suggestions', + 'dashboard.indicator.backtest.historyNoAIResult': 'No AI analysis result', + 'dashboard.indicator.backtest.historyRunId': 'Run ID', + 'dashboard.indicator.backtest.historyCreatedAt': 'Time', + 'dashboard.indicator.backtest.historyRange': 'Range', + 'dashboard.indicator.backtest.historyStatus': 'Status', + 'dashboard.indicator.backtest.historyStatusSuccess': 'Success', + 'dashboard.indicator.backtest.historyStatusFailed': 'Failed', + 'dashboard.indicator.backtest.historyActions': 'Actions', + 'dashboard.indicator.backtest.hint.entryPctMax': 'Max entry: {maxPct}% (reserve budget for future scale-ins)', + 'trading-assistant.exchange.ipWhitelistTip': 'Please add the following IPs to the whitelist in your exchange API settings:', + 'signal-robot.title': 'Signal Robot Console', + 'signal-robot.createBot': 'Create New Bot', + 'signal-robot.search.nameOrSymbol': 'Name/Symbol', + 'signal-robot.search.placeholder': 'Search bot name or symbol', + 'signal-robot.search.status': 'Status', + 'signal-robot.search.statusAll': 'All', + 'signal-robot.search.statusRunning': 'Running', + 'signal-robot.search.statusPaused': 'Paused', + 'signal-robot.search.query': 'Query', + 'signal-robot.search.reset': 'Reset', + 'signal-robot.table.botName': 'Bot Name', + 'signal-robot.table.symbolTimeframe': 'Symbol/Timeframe', + 'signal-robot.table.triggerStrategy': 'Trigger Strategy', + 'signal-robot.table.notificationChannels': 'Notification Channels', + 'signal-robot.table.status': 'Status', + 'signal-robot.table.action': 'Action', + 'signal-robot.table.triggerConditions': '{count} trigger conditions', + 'signal-robot.table.monitoring': 'Monitoring', + 'signal-robot.table.paused': 'Paused', + 'signal-robot.table.edit': 'Edit', + 'signal-robot.table.pause': 'Pause', + 'signal-robot.table.start': 'Start', + 'signal-robot.table.delete': 'Delete', + 'signal-robot.table.deleteConfirm': 'Are you sure you want to delete this bot?', + 'signal-robot.table.deleteSuccess': 'Deleted successfully', + 'signal-robot.table.startSuccess': 'Bot started', + 'signal-robot.table.pauseSuccess': 'Bot paused', + 'signal-robot.channel.telegram': 'Telegram', + 'signal-robot.channel.discord': 'Discord', + 'signal-robot.channel.email': 'Email', + 'signal-robot.channel.webhook': 'Webhook', + 'signal-robot.channel.browser': 'Browser Push', + 'signal-robot.modal.createTitle': 'Create New Signal Robot', + 'signal-robot.modal.editTitle': 'Edit Signal Robot', + 'signal-robot.modal.step.basic': 'Basic Settings', + 'signal-robot.modal.step.entry': 'Entry Strategy', + 'signal-robot.modal.step.position': 'Position Management', + 'signal-robot.modal.step.risk': 'Risk Control', + 'signal-robot.modal.step.notify': 'Notification Config', + 'signal-robot.modal.prev': 'Previous', + 'signal-robot.modal.next': 'Next', + 'signal-robot.modal.preview': 'Preview Backtest', + 'signal-robot.modal.complete': 'Complete & Run', + 'signal-robot.modal.saveSuccess': 'Saved successfully', + 'signal-robot.modal.previewDeveloping': 'Preview feature is under development...', + 'signal-robot.modal.basicInfoRequired': 'Please complete basic information', + 'signal-robot.basic.alert': 'Set basic information for the bot', + 'signal-robot.basic.title': 'Basic Information', + 'signal-robot.basic.botName': 'Bot Name', + 'signal-robot.basic.botNamePlaceholder': 'e.g.: BTC SuperTrend Strategy', + 'signal-robot.basic.symbol': 'Trading Pair', + 'signal-robot.basic.symbolPlaceholder': 'Select from watchlist', + 'signal-robot.basic.noWatchlist': 'No watchlist items, please add in market page first', + 'signal-robot.basic.timeframe': 'K-Line Period', + 'signal-robot.basic.timeframe15m': '15 Minutes', + 'signal-robot.basic.timeframe30m': '30 Minutes', + 'signal-robot.basic.timeframe1h': '1 Hour', + 'signal-robot.basic.timeframe4h': '4 Hours', + 'signal-robot.basic.timeframe1d': '1 Day', + 'signal-robot.basic.autoNameSuggestion': '{symbol} Signal Robot', + 'signal-robot.entry.alert': 'Trigger entry signal when all conditions below are met', + 'signal-robot.entry.condition': 'Condition {index}', + 'signal-robot.entry.indicator': 'Indicator', + 'signal-robot.entry.indicatorSupertrend': 'SuperTrend', + 'signal-robot.entry.indicatorEma': 'EMA (Exponential Moving Average)', + 'signal-robot.entry.indicatorRsi': 'RSI (Relative Strength Index)', + 'signal-robot.entry.indicatorMacd': 'MACD', + 'signal-robot.entry.indicatorBollinger': 'Bollinger Bands', + 'signal-robot.entry.atrPeriod': 'ATR Period', + 'signal-robot.entry.multiplier': 'Multiplier', + 'signal-robot.entry.triggerSignal': 'Trigger Signal', + 'signal-robot.entry.signalTrendBullish': 'Trend Bullish', + 'signal-robot.entry.signalTrendBearish': 'Trend Bearish', + 'signal-robot.entry.signalIsUptrend': 'Is Uptrend', + 'signal-robot.entry.signalIsDowntrend': 'Is Downtrend', + 'signal-robot.entry.period': 'Period', + 'signal-robot.entry.compareCondition': 'Compare Condition', + 'signal-robot.entry.priceAbove': 'Price > EMA (Price Above)', + 'signal-robot.entry.priceBelow': 'Price < EMA (Price Below)', + 'signal-robot.entry.crossUp': 'Price Crosses Above EMA (Golden Cross)', + 'signal-robot.entry.crossDown': 'Price Crosses Below EMA (Death Cross)', + 'signal-robot.entry.compare': 'Compare', + 'signal-robot.entry.greaterThan': 'Greater Than', + 'signal-robot.entry.lessThan': 'Less Than', + 'signal-robot.entry.crossUpShort': 'Cross Up', + 'signal-robot.entry.crossDownShort': 'Cross Down', + 'signal-robot.entry.threshold': 'Threshold', + 'signal-robot.entry.selectIndicator': 'Please select an indicator to configure parameters', + 'signal-robot.entry.addCondition': 'Add Condition', + 'signal-robot.position.alert': 'Configure initial position size, leverage and pyramiding rules', + 'signal-robot.position.basicTitle': 'Basic Position Management', + 'signal-robot.position.initialSize': 'Initial Size (Capital %)', + 'signal-robot.position.initialSizeHint': 'Recommended 5% - 20%', + 'signal-robot.position.leverage': 'Leverage', + 'signal-robot.position.leverageHint': 'Please ensure your exchange account supports this leverage', + 'signal-robot.position.maxPyramiding': 'Max Pyramiding Times', + 'signal-robot.position.maxPyramidingHint': '0 means no pyramiding', + 'signal-robot.position.pyramidingTitle': 'Pyramiding Rules', + 'signal-robot.position.pyramidingEnabled': 'Enabled', + 'signal-robot.position.pyramidingDisabled': 'Disabled', + 'signal-robot.position.pyramidingCondition': 'Pyramiding Trigger Condition', + 'signal-robot.position.priceRisePct': 'Price Rises (Long) / Falls (Short)', + 'signal-robot.position.profitPct': 'Profit Reaches', + 'signal-robot.position.triggerThreshold': 'Trigger Threshold (%)', + 'signal-robot.position.triggerThresholdHint': 'e.g. 3%: add position once every 3% rise', + 'signal-robot.position.addSize': 'Single Add Size (Capital %)', + 'signal-robot.position.pyramidingDisabledHint': 'Pyramiding disabled, only initial position will be executed', + 'signal-robot.risk.alert': 'Configure take profit, stop loss and exit rules', + 'signal-robot.risk.stopLossTitle': 'Stop Loss', + 'signal-robot.risk.stopLossEnabled': 'Enabled', + 'signal-robot.risk.stopLossDisabled': 'Disabled', + 'signal-robot.risk.stopLossType': 'Stop Loss Type', + 'signal-robot.risk.stopLossFixedPct': 'Fixed Percentage', + 'signal-robot.risk.stopLossAtrMultiplier': 'ATR Multiplier', + 'signal-robot.risk.stopLossValue': 'Value', + 'signal-robot.risk.stopLossFixedHint': 'e.g. 2%: stop loss when loss reaches 2%', + 'signal-robot.risk.stopLossAtrHint': 'e.g. 2.0: stop loss when price touches ATR*2.0', + 'signal-robot.risk.stopLossDisabledHint': 'Stop loss disabled (high risk)', + 'signal-robot.risk.trailingStopTitle': 'Trailing Stop', + 'signal-robot.risk.activationProfit': 'Activation Profit (%)', + 'signal-robot.risk.activationProfitHint': 'Start trailing when profit reaches this level', + 'signal-robot.risk.callbackPct': 'Callback (%)', + 'signal-robot.risk.callbackPctHint': 'Trigger take profit when price falls from peak by this percentage', + 'signal-robot.risk.trailingStopDisabledHint': 'Trailing stop disabled', + 'signal-robot.risk.otherExitTitle': 'Other Exit Rules', + 'signal-robot.risk.signalExit': 'Allow Signal Exit', + 'signal-robot.risk.signalExitHint': 'Close position immediately when reverse signal appears (e.g. close long when short signal appears)', + 'signal-robot.notify.alert': 'Configure notification methods when signal is triggered', + 'signal-robot.notify.channelTitle': 'Notification Channels', + 'signal-robot.notify.discordWebhook': 'Discord Webhook', + 'signal-robot.notify.genericWebhook': 'Generic Webhook', + 'signal-robot.notify.webhookUrl': 'Webhook URL', + 'signal-robot.notify.webhookUrlPlaceholder': 'https://example.com/webhook', + 'signal-robot.notify.discordUrl': 'Discord URL', + 'signal-robot.notify.discordUrlPlaceholder': 'https://discord.com/api/webhooks/...', + 'signal-robot.operator.greaterThan': 'Greater Than', + 'signal-robot.operator.lessThan': 'Less Than', + 'signal-robot.operator.equal': 'Equal', + 'signal-robot.operator.goldenCross': 'Golden Cross', + 'signal-robot.operator.deathCross': 'Death Cross', + 'signal-robot.operator.increaseBy': 'Increase By', + 'signal-robot.operator.decreaseBy': 'Decrease By', + 'signal-robot.indicator.price': 'Price', + 'signal-robot.indicator.rsi': 'RSI', + 'signal-robot.indicator.kdjK': 'KDJ(K)', + 'signal-robot.indicator.kdjJ': 'KDJ(J)', + 'signal-robot.indicator.macd': 'MACD', + 'signal-robot.indicator.macdHist': 'MACD Histogram', + 'signal-robot.indicator.bollingerUp': 'Bollinger Upper', + 'signal-robot.indicator.bollingerLow': 'Bollinger Lower', + 'signal-robot.indicator.volume': 'Volume', + 'signal-robot.indicator.changePct1h': '1H Change %', + 'signal-robot.indicator.changePct24h': '24H Change %', + 'signal-robot.indicator.signalLine': 'Signal Line' +} + +export default { + ...components, + ...locale +} diff --git a/quantdinger_vue/src/locales/lang/de-DE.js b/quantdinger_vue/src/locales/lang/de-DE.js new file mode 100644 index 0000000..833a554 --- /dev/null +++ b/quantdinger_vue/src/locales/lang/de-DE.js @@ -0,0 +1,1817 @@ +import antdDeDE from 'ant-design-vue/es/locale-provider/de_DE' +import momentDE from 'moment/locale/de' + +const components = { + antLocale: antdDeDE, + momentName: 'de', + momentLocale: momentDE +} + +const locale = { + 'submit': 'Senden', + 'save': 'speichern', + 'submit.ok': 'Übermittlung erfolgreich', + 'save.ok': 'Erfolgreich gespeichert', + 'menu.welcome': 'Willkommen', + 'menu.home': 'Startseite', + 'menu.dashboard': 'Armaturenbrett', + 'menu.dashboard.indicator': 'Indikatorenanalyse', + 'menu.dashboard.community': 'Indikatorgemeinschaft', + 'menu.dashboard.analysis': 'KI-Analyse', + 'menu.dashboard.tradingAssistant': 'Handelsassistent', + 'menu.dashboard.aiTradingAssistant': 'KI-Handelsassistent', + 'menu.dashboard.signalRobot': 'Signal-Roboter', + 'menu.dashboard.monitor': 'Überwachungsseite', + 'menu.dashboard.workplace': 'Werkbank', + 'menu.form': 'Formularseite', + 'menu.form.basic-form': 'Grundform', + 'menu.form.step-form': 'Schritt-für-Schritt-Formular', + 'menu.form.step-form.info': 'Schritt-für-Schritt-Formular (Übertragungsinformationen ausfüllen)', + 'menu.form.step-form.confirm': 'Schritt-für-Schritt-Formular (Übertragungsinformationen bestätigen)', + 'menu.form.step-form.result': 'Schritt-für-Schritt-Formular (vollständig)', + 'menu.form.advanced-form': 'Erweiterte Formulare', + 'menu.list': 'Listenseite', + 'menu.list.table-list': 'Anfrageformular', + 'menu.list.basic-list': 'Standardliste', + 'menu.list.card-list': 'Kartenliste', + 'menu.list.search-list': 'Suchliste', + 'menu.list.search-list.articles': 'Suchliste (Artikel)', + 'menu.list.search-list.projects': 'Suchliste (Projekt)', + 'menu.list.search-list.applications': 'Suchliste (App)', + 'menu.profile': 'Detailseite', + 'menu.profile.basic': 'Grundlegende Detailseite', + 'menu.profile.advanced': 'Erweiterte Detailseite', + 'menu.result': 'Ergebnisseite', + 'menu.result.success': 'Erfolgsseite', + 'menu.result.fail': 'Fehlerseite', + 'menu.exception': 'Ausnahmeseite', + 'menu.exception.not-permission': '403', + 'menu.exception.not-find': '404', + 'menu.exception.server-error': '500', + 'menu.exception.trigger': 'Fehler auslösen', + 'menu.account': 'Persönliche Seite', + 'menu.account.center': 'Persönliches Zentrum', + 'menu.account.settings': 'persönliche Einstellungen', + 'menu.account.trigger': 'Triggerfehler', + 'menu.account.logout': 'Abmelden', + 'menu.wallet': 'meine Brieftasche', + 'menu.docs': 'Dokumentenzentrum', + 'menu.docs.detail': 'Dokumentdetails', + 'menu.header.refreshPage': 'Seite aktualisieren', + 'menu.invite.friends': 'Freunde einladen', + 'app.setting.pagestyle': 'allgemeine Stileinstellungen', + 'app.setting.pagestyle.light': 'Heller Menüstil', + 'app.setting.pagestyle.dark': 'Dunkler Menüstil', + 'app.setting.pagestyle.realdark': 'Dunkelmodus', + 'app.setting.themecolor': 'Themenfarbe', + 'app.setting.navigationmode': 'Navigationsmodus', + 'app.setting.sidemenu.nav': 'Seitenleistennavigation', + 'app.setting.topmenu.nav': 'Navigation in der oberen Leiste', + 'app.setting.content-width': 'Breite des Inhaltsbereichs', + 'app.setting.content-width.tooltip': 'Diese Einstellung ist nur wirksam, wenn [Navigation in der oberen Leiste]', + 'app.setting.content-width.fixed': 'Behoben', + 'app.setting.content-width.fluid': 'Streaming', + 'app.setting.fixedheader': 'Fester Header', + 'app.setting.fixedheader.tooltip': 'Bei festem Header konfigurierbar', + 'app.setting.autoHideHeader': 'Kopfzeile beim Scrollen ausblenden', + 'app.setting.fixedsidebar': 'Seitenmenü korrigiert', + 'app.setting.sidemenu': 'Seitenmenü-Layout', + 'app.setting.topmenu': 'Oberes Menülayout', + 'app.setting.othersettings': 'Andere Einstellungen', + 'app.setting.weakmode': 'Farbschwächemodus', + 'app.setting.multitab': 'Multi-Tab-Modus', + 'app.setting.copy': 'Einstellungen kopieren', + 'app.setting.loading': 'Thema wird geladen', + 'app.setting.copyinfo': 'Einstellungen erfolgreich kopieren src/config/defaultSettings.js', + 'app.setting.copy.success': 'Kopieren abgeschlossen', + 'app.setting.copy.fail': 'Der Kopiervorgang ist fehlgeschlagen', + 'app.setting.theme.switching': 'Wechselndes Thema!', + 'app.setting.production.hint': 'Die Konfigurationsleiste dient nur der Vorschau in der Entwicklungsumgebung und wird in der Produktionsumgebung nicht angezeigt. Bitte kopieren und ändern Sie die Konfigurationsdatei manuell.', + 'app.setting.themecolor.daybreak': 'Morgenblau (Standard)', + 'app.setting.themecolor.dust': 'Dämmerung', + 'app.setting.themecolor.volcano': 'Vulkan', + 'app.setting.themecolor.sunset': 'Sonnenuntergang', + 'app.setting.themecolor.cyan': 'Mingqing', + 'app.setting.themecolor.green': 'Auroragrün', + 'app.setting.themecolor.geekblue': 'Geek blau', + 'app.setting.themecolor.purple': 'Jiang Zi', + 'app.setting.tooltip': 'Seiteneinstellungen', + 'user.login.userName': 'Benutzername', + 'user.login.password': 'Passwort', + 'user.login.username.placeholder': 'Konto: Admin', + 'user.login.password.placeholder': 'Passwort: admin oder ant.design', + 'user.login.message-invalid-credentials': 'Die Anmeldung ist fehlgeschlagen. Bitte überprüfen Sie Ihre E-Mail-Adresse und Ihren Bestätigungscode', + 'user.login.message-invalid-verification-code': 'Fehler beim Bestätigungscode', + 'user.login.tab-login-credentials': 'Anmeldung mit Kontopasswort', + 'user.login.tab-login-email': 'E-Mail-Login', + 'user.login.tab-login-mobile': 'Anmeldung über die Mobiltelefonnummer', + 'user.login.captcha.placeholder': 'Bitte geben Sie den grafischen Bestätigungscode ein', + 'user.login.mobile.placeholder': 'Mobiltelefonnummer', + 'user.login.mobile.verification-code.placeholder': 'Bestätigungscode', + 'user.login.email.placeholder': 'Bitte geben Sie Ihre E-Mail-Adresse ein', + 'user.login.email.verification-code.placeholder': 'Bitte geben Sie den Bestätigungscode ein', + 'user.login.email.sending': 'Bestätigungscode wird gesendet...', + 'user.login.email.send-success-title': 'Tipps', + 'user.login.email.send-success': 'Der Bestätigungscode wurde erfolgreich gesendet. Bitte überprüfen Sie Ihre E-Mails', + 'user.login.sms.send-success': 'Der Bestätigungscode wurde erfolgreich gesendet. Bitte überprüfen Sie die Textnachricht', + 'user.login.remember-me': 'Automatische Anmeldung', + 'user.login.forgot-password': 'Passwort vergessen', + 'user.login.sign-in-with': 'Andere Anmeldemethoden', + 'user.login.signup': 'Registrieren Sie ein Konto', + 'user.login.login': 'Anmelden', + 'user.register.register': 'Registrieren', + 'user.register.email.placeholder': 'E-Mail', + 'user.register.password.placeholder': 'Bitte geben Sie mindestens 6 Zeichen ein. Bitte verwenden Sie keine Passwörter, die leicht zu erraten sind.', + 'user.register.password.popover-message': 'Bitte geben Sie mindestens 6 Zeichen ein. Bitte verwenden Sie keine Passwörter, die leicht zu erraten sind.', + 'user.register.confirm-password.placeholder': 'Passwort bestätigen', + 'user.register.get-verification-code': 'Holen Sie sich den Bestätigungscode', + 'user.register.sign-in': 'Melden Sie sich mit einem bestehenden Konto an', + 'user.register-result.msg': 'Ihr Konto: {email} Registrierung erfolgreich', + 'user.register-result.activation-email': 'Die Aktivierungs-E-Mail wurde an Ihr Postfach gesendet und ist 24 Stunden lang gültig. Bitte melden Sie sich umgehend bei Ihrem E-Mail-Konto an und klicken Sie auf den Link in der E-Mail, um Ihr Konto zu aktivieren.', + 'user.register-result.back-home': 'Zurück zur Startseite', + 'user.register-result.view-mailbox': 'Überprüfen Sie Ihr Postfach', + 'user.email.required': 'Bitte geben Sie Ihre E-Mail-Adresse ein!', + 'user.email.wrong-format': 'Das E-Mail-Adressformat ist falsch!', + 'user.userName.required': 'Bitte geben Sie Ihren Kontonamen oder Ihre E-Mail-Adresse ein', + 'user.password.required': 'Bitte geben Sie Ihr Passwort ein!', + 'user.password.twice.msg': 'Die doppelt eingegebenen Passwörter stimmen nicht überein!', + 'user.password.strength.msg': 'Das Passwort ist nicht sicher genug', + 'user.password.strength.strong': 'Stärke: Stark', + 'user.password.strength.medium': 'Stärke: Mittel', + 'user.password.strength.low': 'Stärke: gering', + 'user.password.strength.short': 'Stärke: zu kurz', + 'user.confirm-password.required': 'Bitte bestätigen Sie Ihr Passwort!', + 'user.phone-number.required': 'Bitte geben Sie die korrekte Mobiltelefonnummer ein', + 'user.phone-number.wrong-format': 'Das Format der Mobiltelefonnummer ist falsch!', + 'user.verification-code.required': 'Bitte geben Sie den Verifizierungscode ein!', + 'user.captcha.required': 'Bitte geben Sie den grafischen Verifizierungscode ein!', + 'user.login.infos': 'QuantDinger ist ein AI-Multi-Agent-Hilfstool für die Aktienanalyse und verfügt nicht über Qualifikationen als Wertpapieranlageberater. Alle Analyseergebnisse, Scores und Referenzmeinungen in der Plattform werden von KI automatisch auf Basis historischer Daten generiert und dienen ausschließlich der Ausbildung, Forschung und dem technischen Austausch und stellen keine Anlageberatung oder Entscheidungsgrundlage dar. Aktienanlagen bergen verschiedene Risiken wie Marktrisiko, Liquiditätsrisiko, politisches Risiko usw., die zu einem Kapitalverlust führen können. Benutzer sollten unabhängige Entscheidungen auf der Grundlage ihrer eigenen Risikotoleranz treffen, und jegliches Investitionsverhalten und alle Konsequenzen, die sich aus der Verwendung dieses Tools ergeben, sind vom Benutzer zu tragen. Der Markt ist riskant und Investitionen müssen vorsichtig sein.', + 'user.login.tab-login-web3': 'Web3-Anmeldung', + 'user.login.web3.tip': 'Signatur-Login mit Wallet', + 'user.login.web3.connect': 'Wallet verbinden und anmelden', + 'user.login.web3.no-wallet': 'Wallet nicht erkannt', + 'user.login.web3.no-address': 'Wallet-Adresse nicht erhalten', + 'user.login.web3.nonce-failed': 'Zufallszahl konnte nicht abgerufen werden', + 'user.login.web3.verify-failed': 'Die Signaturüberprüfung ist fehlgeschlagen', + 'user.login.web3.success': 'Anmeldung erfolgreich', + 'user.login.web3.failed': 'Die Wallet-Anmeldung ist fehlgeschlagen', + 'nav.no_wallet': 'Wallet nicht erkannt', + 'nav.copy': 'Kopieren', + 'nav.copy_success': 'Erfolgreich kopiert', + 'user.login.oauth.google': 'Melden Sie sich mit Google an', + 'user.login.oauth.github': 'Melden Sie sich mit GitHub an', + 'user.login.oauth.loading': 'Weiterleitung zur Autorisierungsseite...', + 'user.login.oauth.failed': 'Die OAuth-Anmeldung ist fehlgeschlagen', + 'user.login.oauth.get-url-failed': 'Der Autorisierungslink konnte nicht abgerufen werden', + 'user.login.subtitle': 'Gezielte quantitative Einblicke in globale Märkte', + 'user.login.legal.title': 'Haftungsausschluss', + 'user.login.legal.view': 'Rechtlichen Haftungsausschluss anzeigen', + 'user.login.legal.collapse': 'Haftungsausschluss einklappen', + 'user.login.legal.agree': 'Ich habe den Haftungsausschluss gelesen und stimme ihm zu', + 'user.login.legal.required': 'Bitte lesen und überprüfen Sie zunächst den rechtlichen Haftungsausschluss', + 'user.login.legal.content': 'QuantDinger ist ein AI-Multi-Agent-Hilfstool für die Aktienanalyse und verfügt nicht über Qualifikationen als Wertpapieranlageberater. Alle Analyseergebnisse, Bewertungen und Referenzmeinungen in der Plattform werden von KI automatisch auf Basis historischer Daten generiert und dienen ausschließlich der Ausbildung, Forschung und dem technischen Austausch und stellen keine Anlageberatung oder Entscheidungsgrundlage dar. Aktienanlagen bergen verschiedene Risiken wie Marktrisiko, Liquiditätsrisiko, politisches Risiko usw., die zu einem Kapitalverlust führen können. Benutzer sollten unabhängige Entscheidungen auf der Grundlage ihrer eigenen Risikotoleranz treffen, und jegliches Investitionsverhalten und alle Konsequenzen, die sich aus der Verwendung dieses Tools ergeben, sind vom Benutzer zu tragen. Der Markt ist riskant und Investitionen müssen vorsichtig sein.', + 'user.login.privacy.title': 'Datenschutzrichtlinie für Benutzer', + 'user.login.privacy.view': 'Datenschutzrichtlinie für Benutzer anzeigen', + 'user.login.privacy.collapse': 'Schließen Sie die Datenschutzbestimmungen für Benutzer', + 'user.login.privacy.content': 'Wir legen Wert auf Ihre Privatsphäre und Ihren Datenschutz. 1) Erfassungsumfang: Es werden nur die zur Umsetzung der Funktion erforderlichen Informationen (z. B. E-Mail, Mobiltelefonnummer, Vorwahl, Web3-Wallet-Adresse) sowie notwendige Protokolle und Geräteinformationen erfasst. 2) Verwendungszweck: Zur Kontoanmeldung und Sicherheitsüberprüfung, Bereitstellung von Servicefunktionen, Fehlerbehebung und Compliance-Anforderungen. 3) Speicherung und Sicherheit: Daten werden verschlüsselt und gespeichert, und es werden die erforderlichen Berechtigungen und Zugriffskontrollmaßnahmen ergriffen, um unbefugten Zugriff, Offenlegung oder Verlust zu verhindern. 4) Weitergabe an Dritte: Sofern dies nicht durch Gesetze und Vorschriften vorgeschrieben oder für die Erbringung von Dienstleistungen erforderlich ist, werden Ihre personenbezogenen Daten nicht an Dritte weitergegeben. Bei Einbindung von Diensten Dritter (z. B. Wallets, SMS-Dienstleister) erfolgt die Verarbeitung nur im für die Umsetzung der Funktion erforderlichen Mindestumfang. 5) Cookies/lokale Speicherung: werden für den Anmeldestatus und die notwendige Sitzungswartung verwendet (z. B. Token, PHPSESSID). Sie können sie im Browser löschen oder einschränken. 6) Persönlichkeitsrechte: Sie können Ihre Rechte auf Auskunft, Berichtigung, Löschung, Widerruf der Einwilligung usw. gemäß den Gesetzen und Vorschriften ausüben. 7) Änderungen und Hinweise: Wenn diese Bedingungen aktualisiert werden, werden sie gut sichtbar auf der Seite angezeigt. Durch die weitere Nutzung dieses Dienstes wird davon ausgegangen, dass Sie den aktualisierten Inhalt gelesen und ihm zugestimmt haben. Wenn Sie diesen Bedingungen oder deren Aktualisierungen nicht zustimmen, stellen Sie bitte die Nutzung des Dienstes ein und kontaktieren Sie uns.', + 'account.basicInfo': 'Grundlegende Informationen', + 'account.id': 'Benutzer-ID', + 'account.username': 'Benutzername', + 'account.nickname': 'Spitzname', + 'account.email': 'E-Mail', + 'account.mobile': 'Mobiltelefonnummer', + 'account.web3address': 'Wallet-Adresse', + 'account.pid': 'Referrer-ID', + 'account.level': 'Benutzerebene', + 'account.money': 'Gleichgewicht', + 'account.qdtBalance': 'QDT-Balance', + 'account.score': 'Punkte', + 'account.createtime': 'Anmeldezeit', + 'account.inviteLink': 'Einladungslink', + 'account.recharge': 'Aufladen', + 'account.rechargeTip': 'Bitte verwenden Sie WeChat oder Alipay, um den untenstehenden QR-Code zum Aufladen zu scannen.', + 'account.qrCodePlaceholder': 'QR-Code-Platzhalter (Emulation)', + 'account.rechargeAmount': 'Aufladebetrag', + 'account.enterAmount': 'Bitte geben Sie den Aufladebetrag ein', + 'account.rechargeHint': 'Mindesteinzahlungsbetrag: 1 QDT', + 'account.confirmRecharge': 'Bestätigen Sie das Aufladen', + 'account.enterValidAmount': 'Bitte geben Sie einen gültigen Aufladebetrag ein', + 'account.rechargeSuccess': 'Aufladen erfolgreich! {amount} QDT eingezahlt', + 'account.settings.menuMap.basic': 'Grundeinstellungen', + 'account.settings.menuMap.security': 'Sicherheitseinstellungen', + 'account.settings.menuMap.notification': 'Benachrichtigung über neue Nachrichten', + 'account.settings.menuMap.moneyLog': 'Details zum Fonds', + 'account.moneyLog.empty': 'Noch keine Fondsdetails', + 'account.moneyLog.total': 'Insgesamt {total} Datensätze', + 'account.moneyLog.type.purchase': 'Kaufindikator', + 'account.moneyLog.type.recharge': 'Aufladen', + 'account.moneyLog.type.refund': 'Rückerstattung', + 'account.moneyLog.type.reward': 'Belohnung', + 'account.moneyLog.type.income': 'Indikatoreinkommen', + 'account.moneyLog.type.commission': 'Bearbeitungsgebühr für die Plattform', + 'wallet.balance': 'QDT-Balance', + 'wallet.recharge': 'Aufladen', + 'wallet.withdraw': 'Bargeld abheben', + 'wallet.filter': 'Filtern', + 'wallet.reset': 'zurückgesetzt', + 'wallet.totalRecharge': 'Akkumulierte Aufladung', + 'wallet.totalWithdraw': 'Kumulierte Abhebungen', + 'wallet.totalIncome': 'Kumuliertes Einkommen', + 'wallet.records': 'Transaktionsaufzeichnungen und Fondsdetails', + 'wallet.tradingRecords': 'Transaktionsverlauf', + 'wallet.moneyLog': 'Details zum Fonds', + 'wallet.rechargeTip': 'Bitte verwenden Sie WeChat oder Alipay, um den untenstehenden QR-Code zum Aufladen zu scannen.', + 'wallet.qrCodePlaceholder': 'QR-Code-Platzhalter (Emulation)', + 'wallet.rechargeAmount': 'Aufladebetrag', + 'wallet.enterAmount': 'Bitte geben Sie den Aufladebetrag ein', + 'wallet.rechargeHint': 'Mindesteinzahlungsbetrag: 1 QDT', + 'wallet.confirmRecharge': 'Bestätigen Sie das Aufladen', + 'wallet.enterValidAmount': 'Bitte geben Sie einen gültigen Aufladebetrag ein', + 'wallet.rechargeSuccess': 'Aufladen erfolgreich! {amount} QDT eingezahlt', + 'wallet.rechargeFailed': 'Das Aufladen ist fehlgeschlagen', + 'wallet.withdrawTip': 'Bitte geben Sie den Auszahlungsbetrag und die Auszahlungsadresse ein', + 'wallet.withdrawAmount': 'Betrag abheben', + 'wallet.enterWithdrawAmount': 'Bitte geben Sie den Auszahlungsbetrag ein', + 'wallet.withdrawHint': 'Mindestauszahlungsbetrag: 1 QDT', + 'wallet.withdrawAddress': 'Auszahlungsadresse (optional)', + 'wallet.enterWithdrawAddress': 'Bitte geben Sie die Auszahlungsadresse ein', + 'wallet.confirmWithdraw': 'Auszahlung bestätigen', + 'wallet.enterValidWithdrawAmount': 'Bitte geben Sie einen gültigen Auszahlungsbetrag ein', + 'wallet.insufficientBalance': 'Unzureichendes Gleichgewicht', + 'wallet.withdrawSuccess': 'Auszahlung erfolgreich! {Betrag} QDT abgehoben', + 'wallet.withdrawFailed': 'Die Auszahlung ist fehlgeschlagen', + 'wallet.noTradingRecords': 'Noch kein Transaktionsdatensatz', + 'wallet.noMoneyLog': 'Noch keine Fondsdetails', + 'wallet.loadTradingRecordsFailed': 'Transaktionsdatensätze konnten nicht geladen werden', + 'wallet.loadMoneyLogFailed': 'Die Fondsdetails konnten nicht geladen werden', + 'wallet.moneyLogTotal': 'Insgesamt {total} Datensätze', + 'wallet.moneyLogTypeTitle': 'Typ', + 'wallet.moneyLogType.all': 'Alle Arten', + 'wallet.table.time': 'Zeit', + 'wallet.table.type': 'Typ', + 'wallet.table.price': 'Preis', + 'wallet.table.amount': 'Menge', + 'wallet.table.money': 'Betrag', + 'wallet.table.balance': 'Gleichgewicht', + 'wallet.table.memo': 'Bemerkungen', + 'wallet.table.value': 'Wert', + 'wallet.table.profit': 'Gewinn und Verlust', + 'wallet.table.commission': 'Bearbeitungsgebühr', + 'wallet.table.total': 'Insgesamt {total} Datensätze', + 'wallet.tradeType.buy': 'kaufen', + 'wallet.tradeType.sell': 'verkaufen', + 'wallet.tradeType.liquidation': 'Zwangsliquidation', + 'wallet.tradeType.openLong': 'Lange geöffnet', + 'wallet.tradeType.addLong': 'Gadot', + 'wallet.tradeType.closeLong': 'Pinduo', + 'wallet.tradeType.closeLongStop': 'Stop-Loss und Long', + 'wallet.tradeType.closeLongProfit': 'Nehmen Sie Gewinn mit und steigern Sie Ihr Level', + 'wallet.tradeType.openShort': 'Kurz öffnen', + 'wallet.tradeType.addShort': 'kurz hinzufügen', + 'wallet.tradeType.closeShort': 'leer', + 'wallet.tradeType.closeShortStop': 'Stop-Loss', + 'wallet.tradeType.closeShortProfit': 'Nehmen Sie den Gewinn mit und schließen Sie mit Leerverkäufen', + 'wallet.moneyLogType.purchase': 'Kaufindikator', + 'wallet.moneyLogType.recharge': 'Aufladen', + 'wallet.moneyLogType.withdraw': 'Bargeld abheben', + 'wallet.moneyLogType.refund': 'Rückerstattung', + 'wallet.moneyLogType.reward': 'Belohnung', + 'wallet.moneyLogType.income': 'Einkommen', + 'wallet.moneyLogType.commission': 'Bearbeitungsgebühr', + 'wallet.web3Address.required': 'Bitte geben Sie zuerst die Web3-Wallet-Adresse ein', + 'wallet.web3Address.requiredDescription': 'Vor dem Aufladen müssen Sie Ihre Web3-Wallet-Adresse verknüpfen, um eine Aufladung zu erhalten.', + 'wallet.web3Address.placeholder': 'Bitte geben Sie Ihre Web3-Wallet-Adresse ein (beginnend mit 0x)', + 'wallet.web3Address.save': 'Wallet-Adresse speichern', + 'wallet.web3Address.saveSuccess': 'Wallet-Adresse erfolgreich gespeichert', + 'wallet.web3Address.saveFailed': 'Wallet-Adresse konnte nicht gespeichert werden', + 'wallet.web3Address.invalidFormat': 'Bitte geben Sie eine gültige Web3-Wallet-Adresse ein (Ethereum-Format: 42 Zeichen, beginnend mit 0x, oder TRC20-Format: 34 Zeichen, beginnend mit T)', + 'wallet.selectCoin': 'Währung auswählen', + 'wallet.selectChain': 'Auswahlkette', + 'wallet.chain.eth': 'ETH(ERC20)', + 'wallet.chain.trc20': 'TRC20', + 'wallet.chain.bsc': 'BSC', + 'wallet.targetQdtAmount': 'Der QDT-Betrag, den Sie einlösen möchten (optional)', + 'wallet.targetQdtAmount.placeholder': 'Bitte geben Sie die Menge an QDT ein, die Sie einlösen möchten, den aktuellen QDT-Preis: {price} USDT', + 'wallet.targetQdtAmount.requiredUsdt': 'Aufladung erforderlich: {amount} USDT', + 'wallet.rechargeAddress': 'Aufladeadresse', + 'wallet.copyAddress': 'Kopieren', + 'wallet.copySuccess': 'Erfolgreich kopieren!', + 'wallet.copyFailed': 'Der Kopiervorgang ist fehlgeschlagen. Bitte manuell kopieren', + 'wallet.rechargeAddressHint': 'Bitte stellen Sie sicher, dass Sie {chain} verwenden, um {coin} an diese Adresse einzuzahlen', + 'wallet.qdtPrice.loading': 'Laden...', + 'wallet.rechargeTip.new': 'Bitte wählen Sie die Währung und die Kette aus und scannen Sie dann den untenstehenden QR-Code zum Aufladen', + 'wallet.exchangeRate': '1 QDT ≈ {rate} USDT', + 'wallet.withdrawableAmount': 'Verfügbarer Bargeldbetrag', + 'wallet.totalBalance': 'Gesamtsaldo', + 'wallet.insufficientWithdrawable': 'Der Bargeldbetrag, der abgehoben werden kann, reicht nicht aus. Der aktuelle Betrag, der abgehoben werden kann, ist: {amount} QDT', + 'wallet.withdrawAddressRequired': 'Bitte geben Sie die Auszahlungsadresse ein', + 'wallet.withdrawAddressHint': 'Bitte stellen Sie sicher, dass die Adresse korrekt ist. Nach dem Widerruf ist ein Widerruf nicht mehr möglich.', + 'wallet.withdrawSubmitSuccess': 'Auszahlungsantrag erfolgreich eingereicht, bitte warten Sie auf die Prüfung', + 'menu.footer.contactUs': 'Kontaktieren Sie uns', + 'menu.footer.getSupport': 'Holen Sie sich Unterstützung', + 'menu.footer.socialAccounts': 'soziales Konto', + 'menu.footer.userAgreement': 'Benutzervereinbarung', + 'menu.footer.privacyPolicy': 'Datenschutzrichtlinie', + 'menu.footer.support': 'Unterstützung', + 'menu.footer.featureRequest': 'Funktionsanfrage', + 'menu.footer.email': 'E-Mail', + 'menu.footer.liveChat': 'Live-Chat rund um die Uhr', + 'dashboard.analysis.title': 'Mehrdimensionale Analyse', + 'dashboard.analysis.subtitle': 'KI-gesteuerte umfassende Finanzanalyseplattform', + 'dashboard.analysis.selectSymbol': 'Wählen Sie den zugrunde liegenden Code aus oder geben Sie ihn ein', + 'dashboard.analysis.selectModel': 'Modell auswählen', + 'dashboard.analysis.startAnalysis': 'Analyse starten', + 'dashboard.analysis.history': 'Geschichte', + 'dashboard.analysis.tab.overview': 'umfassende Analyse', + 'dashboard.analysis.tab.fundamental': 'Grundlagen', + 'dashboard.analysis.tab.technical': 'Technologie', + 'dashboard.analysis.tab.news': 'Nachrichten', + 'dashboard.analysis.tab.sentiment': 'Emotionen', + 'dashboard.analysis.tab.risk': 'Risiko', + 'dashboard.analysis.tab.debate': 'Die Lang-Kurz-Debatte', + 'dashboard.analysis.tab.decision': 'endgültige Entscheidung', + 'dashboard.analysis.empty.selectSymbol': 'Wählen Sie ein Ziel aus, um die Analyse zu starten', + 'dashboard.analysis.empty.selectSymbolDesc': 'Wählen Sie aus der selbstgewählten Aktienliste aus oder geben Sie den zugrunde liegenden Code ein, um einen mehrdimensionalen KI-Analysebericht zu erhalten', + 'dashboard.analysis.empty.startAnalysis': 'Klicken Sie auf die Schaltfläche „Analyse starten“, um eine mehrdimensionale Analyse durchzuführen', + 'dashboard.analysis.empty.startAnalysisDesc': 'Wir bieten Ihnen umfassende Analysen aus mehreren Dimensionen wie Fundamentaldaten, Technologie, Nachrichten, Stimmung und Risiko', + 'dashboard.analysis.empty.noData': 'Derzeit liegen keine {type}-Analysedaten vor. Bitte führen Sie zunächst eine umfassende Analyse durch', + 'dashboard.analysis.empty.noWatchlist': 'Derzeit gibt es keine selbstgewählten Aktien', + 'dashboard.analysis.empty.noHistory': 'Noch keine Geschichte', + 'dashboard.analysis.empty.watchlistHint': 'Derzeit sind keine selbstgewählten Aktien vorhanden, bitte zuerst', + 'dashboard.analysis.empty.noDebateData': 'Noch keine Debattendaten', + 'dashboard.analysis.empty.noDecisionData': 'Noch keine Entscheidungsdaten', + 'dashboard.analysis.empty.selectAgent': 'Bitte wählen Sie einen Agenten aus, um die Analyseergebnisse anzuzeigen', + 'dashboard.analysis.loading.analyzing': 'Analyse läuft, bitte warten...', + 'dashboard.analysis.loading.fundamental': 'Fundamentaldaten analysieren...', + 'dashboard.analysis.loading.technical': 'Analyse technischer Indikatoren...', + 'dashboard.analysis.loading.news': 'Nachrichtendaten werden analysiert...', + 'dashboard.analysis.loading.sentiment': 'Marktstimmung analysieren...', + 'dashboard.analysis.loading.risk': 'Risiko einschätzen...', + 'dashboard.analysis.loading.debate': 'Die Lang-Kurz-Debatte geht weiter...', + 'dashboard.analysis.loading.decision': 'Endgültige Entscheidung generieren...', + 'dashboard.analysis.score.overall': 'Gesamtbewertung', + 'dashboard.analysis.score.recommendation': 'Anlageberatung', + 'dashboard.analysis.score.confidence': 'Vertrauen', + 'dashboard.analysis.dimension.fundamental': 'Grundlagen', + 'dashboard.analysis.dimension.technical': 'Technologie', + 'dashboard.analysis.dimension.news': 'Nachrichten', + 'dashboard.analysis.dimension.sentiment': 'Emotionen', + 'dashboard.analysis.dimension.risk': 'Risiko', + 'dashboard.analysis.card.dimensionScores': 'Bewertungen für jede Dimension', + 'dashboard.analysis.card.overviewReport': 'Umfassender Analysebericht', + 'dashboard.analysis.card.financialMetrics': 'Finanzindikatoren', + 'dashboard.analysis.card.fundamentalReport': 'Fundamentalanalysebericht', + 'dashboard.analysis.card.technicalIndicators': 'Technische Indikatoren', + 'dashboard.analysis.card.technicalReport': 'Technischer Analysebericht', + 'dashboard.analysis.card.newsList': 'Verwandte Neuigkeiten', + 'dashboard.analysis.card.newsReport': 'Bericht zur Nachrichtenanalyse', + 'dashboard.analysis.card.sentimentIndicators': 'Stimmungsindikator', + 'dashboard.analysis.card.sentimentReport': 'Stimmungsanalysebericht', + 'dashboard.analysis.card.riskMetrics': 'Risikoindikatoren', + 'dashboard.analysis.card.riskReport': 'Risikobewertungsbericht', + 'dashboard.analysis.card.bullView': 'Bullische Sichtweise (Bull)', + 'dashboard.analysis.card.bearView': 'Bärische Sichtweise (Bär)', + 'dashboard.analysis.card.researchConclusion': 'Fazit des Forschers', + 'dashboard.analysis.card.traderPlan': 'Händlerplan', + 'dashboard.analysis.card.riskDebate': 'Debatte im Risikoausschuss', + 'dashboard.analysis.card.finalDecision': 'Endgültige Entscheidung', + 'dashboard.analysis.card.tradePlanDetail': 'Einzelheiten zum Handelsplan', + 'dashboard.analysis.tradingPlan.entry_price': 'Eintrittspreis', + 'dashboard.analysis.tradingPlan.position_size': 'Positionsgröße', + 'dashboard.analysis.tradingPlan.stop_loss': 'Stop-Loss', + 'dashboard.analysis.tradingPlan.take_profit': 'Nehmen Sie Gewinn mit', + 'dashboard.analysis.label.confidence': 'Vertrauen', + 'dashboard.analysis.label.keyPoints': 'Kernpunkte', + 'dashboard.analysis.label.riskWarning': 'Risikowarnung', + 'dashboard.analysis.risk.risky': 'Riskant', + 'dashboard.analysis.risk.neutral': 'Neutraler Standpunkt (Neutral)', + 'dashboard.analysis.risk.safe': 'Konservative Sichtweise (sicher)', + 'dashboard.analysis.risk.conclusion': 'Fazit', + 'dashboard.analysis.feature.fundamental': 'Fundamentalanalyse', + 'dashboard.analysis.feature.technical': 'technische Analyse', + 'dashboard.analysis.feature.news': 'Nachrichtenanalyse', + 'dashboard.analysis.feature.sentiment': 'Stimmungsanalyse', + 'dashboard.analysis.feature.risk': 'Risikobewertung', + 'dashboard.analysis.watchlist.title': 'Meine Aktienauswahl', + 'dashboard.analysis.watchlist.add': 'hinzufügen', + 'dashboard.analysis.watchlist.addStock': 'Brühe hinzufügen', + 'dashboard.analysis.modal.addStock.title': 'Fügen Sie optionale Aktien hinzu', + 'dashboard.analysis.modal.addStock.confirm': 'Okay', + 'dashboard.analysis.modal.addStock.cancel': 'Abbrechen', + 'dashboard.analysis.modal.addStock.market': 'Markttyp', + 'dashboard.analysis.modal.addStock.marketPlaceholder': 'Bitte wählen Sie einen Markt aus', + 'dashboard.analysis.modal.addStock.marketRequired': 'Bitte wählen Sie den Markttyp aus', + 'dashboard.analysis.modal.addStock.symbol': 'Lagercode', + 'dashboard.analysis.modal.addStock.symbolPlaceholder': 'Zum Beispiel: AAPL, TSLA, GOOGL, 000001, BTC', + 'dashboard.analysis.modal.addStock.symbolRequired': 'Bitte Lagercode eingeben', + 'dashboard.analysis.modal.addStock.searchPlaceholder': 'Suchen Sie nach Zielcode oder Name', + 'dashboard.analysis.modal.addStock.searchOrInputPlaceholder': 'Suchen Sie den zugrunde liegenden Code oder geben Sie ihn ein (z. B. AAPL, BTC/USDT, EUR/USD).', + 'dashboard.analysis.modal.addStock.searchOrInputHint': 'Unterstützt die Suche nach Objekten in der Datenbank oder die direkte Eingabe des Codes (das System erhält den Namen automatisch).', + 'dashboard.analysis.modal.addStock.search': 'Suchen', + 'dashboard.analysis.modal.addStock.searchResults': 'Suchergebnisse', + 'dashboard.analysis.modal.addStock.hotSymbols': 'Beliebte Ziele', + 'dashboard.analysis.modal.addStock.noHotSymbols': 'Noch keine beliebten Ziele', + 'dashboard.analysis.modal.addStock.selectedSymbol': 'Ausgewählt', + 'dashboard.analysis.modal.addStock.pleaseSelectSymbol': 'Bitte wählen Sie zunächst ein Ziel aus', + 'dashboard.analysis.modal.addStock.pleaseSelectOrEnterSymbol': 'Bitte wählen Sie ein Ziel aus oder geben Sie zuerst den Zielcode ein', + 'dashboard.analysis.modal.addStock.pleaseEnterSymbol': 'Bitte geben Sie den Zielcode ein', + 'dashboard.analysis.modal.addStock.pleaseSelectMarket': 'Bitte wählen Sie zunächst den Markttyp aus', + 'dashboard.analysis.modal.addStock.searchFailed': 'Die Suche ist fehlgeschlagen. Bitte versuchen Sie es später erneut', + 'dashboard.analysis.modal.addStock.noSearchResults': 'Kein passendes Ziel gefunden', + 'dashboard.analysis.modal.addStock.willAutoFetchName': 'Das System erhält den Namen automatisch', + 'dashboard.analysis.modal.addStock.addDirectly': 'Direkt hinzufügen', + 'dashboard.analysis.modal.addStock.nameWillBeFetched': 'Der Name wird beim Hinzufügen automatisch übernommen', + 'dashboard.analysis.market.AShare': 'A-Aktien', + 'dashboard.analysis.market.USStock': 'US-Aktien', + 'dashboard.analysis.market.HShare': 'Aktien aus Hongkong', + 'dashboard.analysis.market.Crypto': 'Kryptowährung', + 'dashboard.analysis.market.Forex': 'Forex', + 'dashboard.analysis.market.Futures': 'Futures', + 'dashboard.analysis.modal.history.title': 'Historische Analyseaufzeichnungen', + 'dashboard.analysis.modal.history.viewResult': 'Ergebnisse anzeigen', + 'dashboard.analysis.modal.history.completeTime': 'Fertigstellungszeit', + 'dashboard.analysis.modal.history.error': 'Fehler', + 'dashboard.analysis.status.pending': 'Ausstehend', + 'dashboard.analysis.status.processing': 'Verarbeitung', + 'dashboard.analysis.status.completed': 'Abgeschlossen', + 'dashboard.analysis.status.failed': 'gescheitert', + 'dashboard.analysis.message.selectSymbol': 'Bitte wählen Sie zuerst das Ziel aus', + 'dashboard.analysis.message.taskCreated': 'Die Analyseaufgabe wurde erstellt und wird im Hintergrund ausgeführt...', + 'dashboard.analysis.message.analysisComplete': 'Analyse abgeschlossen', + 'dashboard.analysis.message.analysisCompleteCache': 'Analyse abgeschlossen (unter Verwendung zwischengespeicherter Daten)', + 'dashboard.analysis.message.analysisFailed': 'Die Analyse ist fehlgeschlagen. Bitte versuchen Sie es später noch einmal', + 'dashboard.analysis.message.addStockSuccess': 'Erfolgreich hinzugefügt', + 'dashboard.analysis.message.addStockFailed': 'Das Hinzufügen ist fehlgeschlagen', + 'dashboard.analysis.message.removeStockSuccess': 'Erfolgreich entfernt', + 'dashboard.analysis.message.removeStockFailed': 'Das Entfernen ist fehlgeschlagen', + 'dashboard.analysis.test': 'Geschäft Nr. {no}, Gongzhuan Road', + 'dashboard.analysis.introduce': 'Beschreibung des Indikators', + 'dashboard.analysis.total-sales': 'Gesamtumsatz', + 'dashboard.analysis.day-sales': 'Durchschnittlicher Tagesumsatz¥', + 'dashboard.analysis.visits': 'Besuche', + 'dashboard.analysis.visits-trend': 'Verkehrstrends', + 'dashboard.analysis.visits-ranking': 'Ranking der Ladenbesuche', + 'dashboard.analysis.day-visits': 'Tägliche Besuche', + 'dashboard.analysis.week': 'Wöchentlich im Jahresvergleich', + 'dashboard.analysis.day': 'Jahr für Jahr', + 'dashboard.analysis.payments': 'Anzahl der Zahlungen', + 'dashboard.analysis.conversion-rate': 'Umrechnungskurs', + 'dashboard.analysis.operational-effect': 'Auswirkungen der betrieblichen Tätigkeit', + 'dashboard.analysis.sales-trend': 'Verkaufstrends', + 'dashboard.analysis.sales-ranking': 'Ranking der Filialverkäufe', + 'dashboard.analysis.all-year': 'Das ganze Jahr über', + 'dashboard.analysis.all-month': 'diesen Monat', + 'dashboard.analysis.all-week': 'diese Woche', + 'dashboard.analysis.all-day': 'heute', + 'dashboard.analysis.search-users': 'Anzahl der Suchbenutzer', + 'dashboard.analysis.per-capita-search': 'Suchanfragen pro Kopf', + 'dashboard.analysis.online-top-search': 'Beliebte Suchanfragen im Internet', + 'dashboard.analysis.the-proportion-of-sales': 'Anteil der Verkaufskategorie', + 'dashboard.analysis.dropdown-option-one': 'Operation eins', + 'dashboard.analysis.dropdown-option-two': 'Betrieb 2', + 'dashboard.analysis.channel.all': 'Alle Kanäle', + 'dashboard.analysis.channel.online': 'online', + 'dashboard.analysis.channel.stores': 'speichern', + 'dashboard.analysis.sales': 'Verkäufe', + 'dashboard.analysis.traffic': 'Passagierfluss', + 'dashboard.analysis.table.rank': 'Rangliste', + 'dashboard.analysis.table.search-keyword': 'Suchbegriffe suchen', + 'dashboard.analysis.table.users': 'Anzahl der Benutzer', + 'dashboard.analysis.table.weekly-range': 'Wöchentliche Erhöhung', + 'dashboard.indicator.selectSymbol': 'Wählen Sie den zugrunde liegenden Code aus oder geben Sie ihn ein', + 'dashboard.indicator.emptyWatchlistHint': 'Derzeit sind keine selbst ausgewählten Aktien vorhanden. Bitte fügen Sie diese zuerst hinzu', + 'dashboard.indicator.hint.selectSymbol': 'Bitte wählen Sie ein Ziel aus, um mit der Analyse zu beginnen', + 'dashboard.indicator.hint.selectSymbolDesc': 'Wählen Sie einen Aktiencode aus oder geben Sie ihn in das Suchfeld oben ein, um K-Line-Diagramme und technische Indikatoren anzuzeigen', + 'dashboard.indicator.retry': 'Versuchen Sie es erneut', + 'dashboard.indicator.panel.title': 'Technische Indikatoren', + 'dashboard.indicator.panel.realtimeOn': 'Deaktivieren Sie Echtzeit-Updates', + 'dashboard.indicator.panel.realtimeOff': 'Aktivieren Sie Echtzeit-Updates', + 'dashboard.indicator.panel.themeLight': 'Wechseln Sie zum dunklen Thema', + 'dashboard.indicator.panel.themeDark': 'Wechseln Sie zum Lichtthema', + 'dashboard.indicator.section.enabled': 'Aktiviert', + 'dashboard.indicator.section.added': 'Indikatoren, die ich hinzugefügt habe', + 'dashboard.indicator.section.custom': 'Von Ihnen selbst erstellte Indikatoren', + 'dashboard.indicator.section.bought': 'Indikatoren, die ich gekauft habe', + 'dashboard.indicator.section.myCreated': 'Von mir erstellte Indikatoren', + 'dashboard.indicator.empty': 'Es gibt noch keinen Indikator. Bitte fügen Sie zuerst einen Indikator hinzu oder erstellen Sie ihn', + 'dashboard.indicator.buy': 'Kaufindikator', + 'dashboard.indicator.action.start': 'beginnen', + 'dashboard.indicator.action.stop': 'Schließen', + 'dashboard.indicator.action.edit': 'Bearbeiten', + 'dashboard.indicator.action.delete': 'Löschen', + 'dashboard.indicator.action.backtest': 'Backtest', + 'dashboard.indicator.status.normal': 'normal', + 'dashboard.indicator.status.normalPermanent': 'Normal (dauerhaft gültig)', + 'dashboard.indicator.status.expired': 'Abgelaufen', + 'dashboard.indicator.expiry.permanent': 'Dauerhaft gültig', + 'dashboard.indicator.expiry.noExpiry': 'Keine Ablaufzeit', + 'dashboard.indicator.expiry.expired': 'Abgelaufen: {Datum}', + 'dashboard.indicator.expiry.expiresOn': 'Ablaufzeit: {Datum}', + 'dashboard.indicator.delete.confirmTitle': 'Bestätigen Sie den Löschvorgang', + 'dashboard.indicator.delete.confirmContent': 'Sind Sie sicher, dass Sie den Indikator „{name}“ löschen möchten? Dieser Vorgang ist irreversibel.', + 'dashboard.indicator.delete.confirmOk': 'Löschen', + 'dashboard.indicator.delete.confirmCancel': 'Abbrechen', + 'dashboard.indicator.delete.success': 'Erfolgreich löschen', + 'dashboard.indicator.delete.failed': 'Das Löschen ist fehlgeschlagen', + 'dashboard.indicator.save.success': 'Erfolgreich gespeichert', + 'dashboard.indicator.save.failed': 'Speichern fehlgeschlagen', + 'dashboard.indicator.error.loadWatchlistFailed': 'Optionale Bestände konnten nicht geladen werden', + 'dashboard.indicator.error.chartNotReady': 'Die Diagrammkomponente ist nicht initialisiert. Bitte wählen Sie zuerst das Ziel aus und warten Sie, bis das Diagramm geladen ist.', + 'dashboard.indicator.error.chartMethodNotReady': 'Die Diagrammkomponentenmethode ist nicht bereit. Bitte versuchen Sie es später erneut', + 'dashboard.indicator.error.chartExecuteNotReady': 'Die Ausführungsmethode der Diagrammkomponente ist nicht bereit. Bitte versuchen Sie es später erneut', + 'dashboard.indicator.error.parseFailed': 'Python-Code kann nicht analysiert werden', + 'dashboard.indicator.error.parseFailedCheck': 'Der Python-Code kann nicht analysiert werden. Bitte überprüfen Sie das Codeformat', + 'dashboard.indicator.error.addIndicatorFailed': 'Der Indikator konnte nicht hinzugefügt werden', + 'dashboard.indicator.error.runIndicatorFailed': 'Die Laufanzeige ist fehlgeschlagen', + 'dashboard.indicator.error.pleaseLogin': 'Bitte melden Sie sich zuerst an', + 'dashboard.indicator.error.loadDataFailed': 'Das Laden der Daten ist fehlgeschlagen', + 'dashboard.indicator.error.loadDataFailedDesc': 'Bitte überprüfen Sie die Netzwerkverbindung', + 'dashboard.indicator.error.pythonEngineFailed': 'Die Python-Engine konnte nicht geladen werden und die Indikatorfunktion ist möglicherweise nicht verfügbar.', + 'dashboard.indicator.error.chartInitFailed': 'Die Initialisierung des Diagramms ist fehlgeschlagen', + 'dashboard.indicator.warning.enterCode': 'Bitte geben Sie zuerst den Indikatorcode ein', + 'dashboard.indicator.warning.pyodideLoadFailed': 'Die Python-Engine konnte nicht geladen werden', + 'dashboard.indicator.warning.pyodideLoadFailedDesc': 'Diese Funktion ist in Ihrer aktuellen Region oder Netzwerkumgebung nicht verfügbar', + 'dashboard.indicator.warning.chartNotInitialized': 'Das Diagramm ist nicht initialisiert und das Linienzeichnungstool kann nicht verwendet werden.', + 'dashboard.indicator.success.runIndicator': 'Indikator läuft erfolgreich', + 'dashboard.indicator.success.clearDrawings': 'Alle Strichzeichnungen gelöscht', + 'dashboard.indicator.sma': 'SMA (gleitende Durchschnittskombination)', + 'dashboard.indicator.ema': 'EMA (exponentielle gleitende Durchschnittskombination)', + 'dashboard.indicator.rsi': 'RSI (relative Stärke)', + 'dashboard.indicator.macd': 'MACD', + 'dashboard.indicator.bb': 'Bollinger-Bänder', + 'dashboard.indicator.atr': 'ATR (Average True Range)', + 'dashboard.indicator.cci': 'CCI (Commodity Channel Index)', + 'dashboard.indicator.williams': 'Williams %R (Williams-Indikator)', + 'dashboard.indicator.mfi': 'MFI (Geldflussindex)', + 'dashboard.indicator.adx': 'ADX (durchschnittlicher Trendindex)', + 'dashboard.indicator.obv': 'OBV (Energiewelle)', + 'dashboard.indicator.adosc': 'ADOSC (Akkumulieren/Dispatch-Oszillator)', + 'dashboard.indicator.ad': 'AD (Sammel-/Verteilungsleitung)', + 'dashboard.indicator.kdj': 'KDJ (stochastischer Indikator)', + 'dashboard.indicator.signal.buy': 'KAUFEN', + 'dashboard.indicator.signal.sell': 'VERKAUFEN', + 'dashboard.indicator.signal.supertrendBuy': 'SuperTrend kaufen', + 'dashboard.indicator.signal.supertrendSell': 'SuperTrend verkaufen', + 'dashboard.indicator.chart.kline': 'K-Linie', + 'dashboard.indicator.chart.volume': 'Lautstärke', + 'dashboard.indicator.chart.uptrend': 'Aufwärtstrend', + 'dashboard.indicator.chart.downtrend': 'Abwärtstrend', + 'dashboard.indicator.tooltip.time': 'Zeit', + 'dashboard.indicator.tooltip.open': 'offen', + 'dashboard.indicator.tooltip.close': 'erhalten', + 'dashboard.indicator.tooltip.high': 'hoch', + 'dashboard.indicator.tooltip.low': 'niedrig', + 'dashboard.indicator.tooltip.volume': 'Lautstärke', + 'dashboard.indicator.drawing.line': 'Liniensegment', + 'dashboard.indicator.drawing.horizontalLine': 'horizontale Linie', + 'dashboard.indicator.drawing.verticalLine': 'vertikale Linie', + 'dashboard.indicator.drawing.ray': 'Strahl', + 'dashboard.indicator.drawing.straightLine': 'gerade Linie', + 'dashboard.indicator.drawing.parallelLine': 'parallele Linien', + 'dashboard.indicator.drawing.priceLine': 'Preislinie', + 'dashboard.indicator.drawing.priceChannel': 'Preiskanal', + 'dashboard.indicator.drawing.fibonacciLine': 'Fibonacci-Linien', + 'dashboard.indicator.drawing.clearAll': 'Löschen Sie alle Strichzeichnungen', + 'dashboard.indicator.market.AShare': 'A-Aktien', + 'dashboard.indicator.market.USStock': 'US-Aktien', + 'dashboard.indicator.market.HShare': 'Aktien aus Hongkong', + 'dashboard.indicator.market.Crypto': 'Kryptowährung', + 'dashboard.indicator.market.Forex': 'Forex', + 'dashboard.indicator.market.Futures': 'Futures', + 'dashboard.indicator.create': 'Indikator erstellen', + 'dashboard.indicator.editor.title': 'Indikatoren erstellen/bearbeiten', + 'dashboard.indicator.editor.name': 'Indikatorname', + 'dashboard.indicator.editor.nameRequired': 'Bitte geben Sie den Indikatornamen ein', + 'dashboard.indicator.editor.namePlaceholder': 'Bitte geben Sie den Indikatornamen ein', + 'dashboard.indicator.editor.description': 'Beschreibung des Indikators', + 'dashboard.indicator.editor.descriptionPlaceholder': 'Bitte geben Sie eine Beschreibung des Indikators ein (optional)', + 'dashboard.indicator.editor.code': 'Python-Code', + 'dashboard.indicator.editor.codeRequired': 'Bitte geben Sie den Indikatorcode ein', + 'dashboard.indicator.editor.codePlaceholder': 'Bitte geben Sie Python-Code ein', + 'dashboard.indicator.editor.run': 'laufen', + 'dashboard.indicator.editor.runHint': 'Klicken Sie auf die Schaltfläche „Ausführen“, um eine Vorschau des Indikatoreffekts im K-Liniendiagramm anzuzeigen', + 'dashboard.indicator.editor.guide': 'Entwicklungshandbuch', + 'dashboard.indicator.editor.guideTitle': 'Leitfaden zur Entwicklung von Python-Indikatoren', + 'dashboard.indicator.editor.save': 'speichern', + 'dashboard.indicator.editor.cancel': 'Abbrechen', + 'dashboard.indicator.editor.unnamed': 'Unbenannter Indikator', + 'dashboard.indicator.editor.publishToCommunity': 'In der Community posten', + 'dashboard.indicator.editor.publishToCommunityHint': 'Nach der Veröffentlichung können andere Benutzer Ihren Indikator in der Community anzeigen und verwenden', + 'dashboard.indicator.editor.indicatorType': 'Indikatortyp', + 'dashboard.indicator.editor.indicatorTypeRequired': 'Bitte wählen Sie den Indikatortyp aus', + 'dashboard.indicator.editor.indicatorTypePlaceholder': 'Bitte wählen Sie den Indikatortyp aus', + 'dashboard.indicator.editor.previewImage': 'Vorschau', + 'dashboard.indicator.editor.uploadImage': 'Bilder hochladen', + 'dashboard.indicator.editor.previewImageHint': 'Empfohlene Größe: 800 x 400, nicht mehr als 2 MB', + 'dashboard.indicator.editor.pricing': 'Verkaufspreis (QDT)', + 'dashboard.indicator.editor.pricingType.free': 'kostenlos', + 'dashboard.indicator.editor.pricingType.permanent': 'dauerhaft', + 'dashboard.indicator.editor.pricingType.monthly': 'monatliche Zahlung', + 'dashboard.indicator.editor.price': 'Preis', + 'dashboard.indicator.editor.priceRequired': 'Bitte geben Sie den Preis ein', + 'dashboard.indicator.editor.pricePlaceholder': 'Bitte geben Sie den Preis ein', + 'dashboard.indicator.editor.pricingHint': 'Obwohl der Preis für kostenlose Indikatoren 0 beträgt, belohnt Sie die Plattform (QDT) basierend auf Ihrer Indikatornutzung und Lobrate.', + 'dashboard.indicator.editor.aiGenerate': 'Intelligente Generation', + 'dashboard.indicator.editor.aiPromptPlaceholder': 'Teilen Sie mir Ihre Ideen mit und ich werde den Python-Indikatorcode für Sie generieren', + 'dashboard.indicator.editor.aiGenerateBtn': 'KI-generierter Code', + 'dashboard.indicator.editor.aiPromptRequired': 'Bitte geben Sie Ihre Gedanken ein', + 'dashboard.indicator.editor.aiGenerateSuccess': 'Codegenerierung erfolgreich', + 'dashboard.indicator.editor.aiGenerateError': 'Die Codegenerierung ist fehlgeschlagen. Bitte versuchen Sie es später noch einmal', + 'dashboard.indicator.backtest.title': 'Indikator-Backtest', + 'dashboard.indicator.backtest.config': 'Backtest-Parameter', + 'dashboard.indicator.backtest.startDate': 'Startdatum', + 'dashboard.indicator.backtest.endDate': 'Enddatum', + 'dashboard.indicator.backtest.selectStartDate': 'Wählen Sie das Startdatum', + 'dashboard.indicator.backtest.selectEndDate': 'Wählen Sie das Enddatum aus', + 'dashboard.indicator.backtest.startDateRequired': 'Bitte wählen Sie ein Startdatum aus', + 'dashboard.indicator.backtest.endDateRequired': 'Bitte wählen Sie ein Enddatum aus', + 'dashboard.indicator.backtest.initialCapital': 'Anfangskapital', + 'dashboard.indicator.backtest.initialCapitalRequired': 'Bitte geben Sie den Anfangsbetrag ein', + 'dashboard.indicator.backtest.commission': 'Bearbeitungsgebühr', + 'dashboard.indicator.backtest.leverage': 'Verschuldungsquote', + 'dashboard.indicator.backtest.tradeDirection': 'Handelsrichtung', + 'dashboard.indicator.backtest.longOnly': 'Geh lange', + 'dashboard.indicator.backtest.shortOnly': 'kurz', + 'dashboard.indicator.backtest.both': 'Zweiseitig', + 'dashboard.indicator.backtest.run': 'Beginnen Sie mit dem Backtesting', + 'dashboard.indicator.backtest.rerun': 'Nochmals Backtest', + 'dashboard.indicator.backtest.close': 'Schließen', + 'dashboard.indicator.backtest.running': 'Indikator-Backtest läuft...', + 'dashboard.indicator.backtest.results': 'Backtest-Ergebnisse', + 'dashboard.indicator.backtest.totalReturn': 'Gesamtrendite', + 'dashboard.indicator.backtest.annualReturn': 'annualisiertes Einkommen', + 'dashboard.indicator.backtest.maxDrawdown': 'maximaler Drawdown', + 'dashboard.indicator.backtest.sharpeRatio': 'Sharpe-Ratio', + 'dashboard.indicator.backtest.winRate': 'Gewinnquote', + 'dashboard.indicator.backtest.profitFactor': 'Gewinn-Verlust-Verhältnis', + 'dashboard.indicator.backtest.totalTrades': 'Anzahl der Transaktionen', + 'dashboard.indicator.totalReturn': 'Gesamtrendite', + 'dashboard.indicator.annualReturn': 'annualisierte Rendite', + 'dashboard.indicator.maxDrawdown': 'maximaler Drawdown', + 'dashboard.indicator.sharpeRatio': 'Sharpe-Ratio', + 'dashboard.indicator.winRate': 'Gewinnquote', + 'dashboard.indicator.profitFactor': 'Gewinn-Verlust-Verhältnis', + 'dashboard.indicator.totalTrades': 'Gesamtzahl der Transaktionen', + 'dashboard.indicator.backtest.totalCommission': 'Gesamtbearbeitungsgebühr', + 'dashboard.indicator.backtest.equityCurve': 'Zinskurve', + 'dashboard.indicator.backtest.strategy': 'Indikatoreinkommen', + 'dashboard.indicator.backtest.benchmark': 'Benchmark-Rendite', + 'dashboard.indicator.backtest.tradeHistory': 'Transaktionsverlauf', + 'dashboard.indicator.backtest.tradeTime': 'Zeit', + 'dashboard.indicator.backtest.tradeType': 'Typ', + 'dashboard.indicator.backtest.buy': 'kaufen', + 'dashboard.indicator.backtest.sell': 'verkaufen', + 'dashboard.indicator.backtest.liquidation': 'Liquidation', + 'dashboard.indicator.backtest.openLong': 'Lange geöffnet', + 'dashboard.indicator.backtest.closeLong': 'Pinduo', + 'dashboard.indicator.backtest.closeLongStop': 'Nahezu long (Stop-Loss)', + 'dashboard.indicator.backtest.closeLongProfit': 'Mehr schließen (Gewinn mitnehmen)', + 'dashboard.indicator.backtest.addLong': 'Long-Position hinzufügen', + 'dashboard.indicator.backtest.openShort': 'Kurz öffnen', + 'dashboard.indicator.backtest.closeShort': 'leer', + 'dashboard.indicator.backtest.closeShortStop': 'Schließen (Stop-Loss)', + 'dashboard.indicator.backtest.closeShortProfit': 'Schließen (Gewinn mitnehmen)', + 'dashboard.indicator.backtest.addShort': 'Short-Position hinzufügen', + 'dashboard.indicator.backtest.price': 'Preis', + 'dashboard.indicator.backtest.amount': 'Menge', + 'dashboard.indicator.backtest.balance': 'Kontostand', + 'dashboard.indicator.backtest.profit': 'Gewinn und Verlust', + 'dashboard.indicator.backtest.success': 'Backtest abgeschlossen', + 'dashboard.indicator.backtest.failed': 'Der Backtest ist fehlgeschlagen. Bitte versuchen Sie es später noch einmal', + 'dashboard.indicator.backtest.noIndicatorCode': 'Für diesen Indikator gibt es keinen rückprüfbaren Code', + 'dashboard.indicator.backtest.noSymbol': 'Bitte wählen Sie zunächst das Transaktionsziel aus', + 'dashboard.indicator.backtest.dateRangeExceeded': 'Der Backtest-Zeitbereich überschreitet das Limit: {timeframe} Zeitraum kann höchstens {maxRange} backtestet werden', + 'dashboard.indicator.backtest.dateRangeExceededDays': 'Backtest range exceeds limit: {timeframe} max {maxRange} ({maxDays} days)', + 'dashboard.indicator.backtest.metaLine': 'Symbol: {symbol} | Market: {market} | Timeframe: {timeframe}', + 'dashboard.indicator.backtest.savedRunId': 'Backtest saved. Run ID: {id}', + 'dashboard.indicator.backtest.prev': 'Previous', + 'dashboard.indicator.backtest.next': 'Next', + 'dashboard.indicator.backtest.steps.strategy.title': 'Strategy', + 'dashboard.indicator.backtest.steps.strategy.desc': 'Position sizing & risk controls', + 'dashboard.indicator.backtest.steps.trading.title': 'Trading settings', + 'dashboard.indicator.backtest.steps.trading.desc': 'Time range, capital, fees, leverage', + 'dashboard.indicator.backtest.steps.results.title': 'Results', + 'dashboard.indicator.backtest.steps.results.desc': 'Backtest output', + 'dashboard.indicator.backtest.panel.risk': 'Risk management (SL/TP/Trailing)', + 'dashboard.indicator.backtest.panel.scale': 'Scale in / DCA (trend & mean-reversion)', + 'dashboard.indicator.backtest.panel.reduce': 'Reduce position (trend & adverse)', + 'dashboard.indicator.backtest.panel.position': 'Position sizing', + 'dashboard.indicator.backtest.field.stopLossPct': 'Stop Loss (%)', + 'dashboard.indicator.backtest.field.takeProfitPct': 'Take Profit (%)', + 'dashboard.indicator.backtest.field.trailingEnabled': 'Trailing stop / take profit', + 'dashboard.indicator.backtest.field.trailingStopPct': 'Trailing drawdown (%)', + 'dashboard.indicator.backtest.field.trailingActivationPct': 'Trailing activation (%)', + 'dashboard.indicator.backtest.field.slippage': 'Slippage (%)', + 'dashboard.indicator.backtest.field.trendAddEnabled': 'Trend-following scale-in', + 'dashboard.indicator.backtest.field.dcaAddEnabled': 'Mean-reversion DCA', + 'dashboard.indicator.backtest.field.trendAddStepPct': 'Scale-in trigger (%)', + 'dashboard.indicator.backtest.field.dcaAddStepPct': 'DCA trigger (%)', + 'dashboard.indicator.backtest.field.trendAddSizePct': 'Scale-in size (% of capital)', + 'dashboard.indicator.backtest.field.dcaAddSizePct': 'DCA size (% of capital)', + 'dashboard.indicator.backtest.field.trendAddMaxTimes': 'Max scale-in times', + 'dashboard.indicator.backtest.field.dcaAddMaxTimes': 'Max DCA times', + 'dashboard.indicator.backtest.field.trendReduceEnabled': 'Trend reduce', + 'dashboard.indicator.backtest.field.adverseReduceEnabled': 'Adverse reduce', + 'dashboard.indicator.backtest.field.trendReduceStepPct': 'Trend trigger (%)', + 'dashboard.indicator.backtest.field.adverseReduceStepPct': 'Adverse trigger (%)', + 'dashboard.indicator.backtest.field.trendReduceSizePct': 'Reduce size (% of position)', + 'dashboard.indicator.backtest.field.adverseReduceSizePct': 'Adverse reduce size (% of position)', + 'dashboard.indicator.backtest.field.trendReduceMaxTimes': 'Max trend reduce times', + 'dashboard.indicator.backtest.field.adverseReduceMaxTimes': 'Max adverse reduce times', + 'dashboard.indicator.backtest.field.entryPct': 'Entry size (% of capital)', + 'dashboard.indicator.backtest.field.minOrderPct': 'Min order size (% of capital) (optional)', + 'dashboard.indicator.backtest.closeLongTrailing': 'Close Long (Trailing)', + 'dashboard.indicator.backtest.reduceLong': 'Reduce Long', + 'dashboard.indicator.backtest.closeShortTrailing': 'Close Short (Trailing)', + 'dashboard.indicator.backtest.reduceShort': 'Reduce Short', + 'dashboard.docs.title': 'Dokumentenzentrum', + 'dashboard.docs.search.placeholder': 'Dokumente durchsuchen...', + 'dashboard.docs.category.all': 'Alle', + 'dashboard.docs.category.guide': 'Entwicklungshandbuch', + 'dashboard.docs.category.api': 'API-Dokumentation', + 'dashboard.docs.category.tutorial': 'Anleitung', + 'dashboard.docs.category.faq': 'FAQ', + 'dashboard.docs.featured.title': 'Empfohlene Dokumentation', + 'dashboard.docs.featured.tag': 'Empfohlen', + 'dashboard.docs.list.views': 'Durchsuchen', + 'dashboard.docs.list.author': 'Autor', + 'dashboard.docs.list.empty': 'Noch kein Dokument', + 'dashboard.docs.list.backToAll': 'Zurück zu allen Dokumenten', + 'dashboard.docs.list.total': 'Insgesamt {count} Dokumente', + 'dashboard.docs.detail.back': 'Zurück zur Dokumentenliste', + 'dashboard.docs.detail.updatedAt': 'aktualisiert am', + 'dashboard.docs.detail.related': 'Verwandte Dokumente', + 'dashboard.docs.detail.notFound': 'Dokument existiert nicht', + 'dashboard.docs.detail.error': 'Das Dokument existiert nicht oder wurde gelöscht', + 'dashboard.docs.search.result': 'Suchergebnisse', + 'dashboard.docs.search.keyword': 'Schlüsselwörter', + 'community.filter.indicatorType': 'Indikatortyp', + 'community.filter.all': 'Alle', + 'community.filter.other': 'Andere Optionen', + 'community.filter.pricing': 'Preisart', + 'community.filter.allPricing': 'Alle Preise', + 'community.filter.sortBy': 'Sortieren nach', + 'community.filter.search': 'Suchen', + 'community.filter.searchPlaceholder': 'Metrikname suchen', + 'community.indicatorType.trend': 'Trendtyp', + 'community.indicatorType.momentum': 'Impulstyp', + 'community.indicatorType.volatility': 'Volatilität', + 'community.indicatorType.volume': 'Lautstärke', + 'community.indicatorType.custom': 'Anpassen', + 'community.pricing.free': 'kostenlos', + 'community.pricing.paid': 'Bezahlen', + 'community.sort.downloads': 'Downloads', + 'community.sort.rating': 'Bewertung', + 'community.sort.newest': 'Neueste Veröffentlichungen', + 'community.pagination.total': '{total} Indikatoren insgesamt', + 'community.noDescription': 'Noch keine Beschreibung', + 'community.detail.type': 'Typ', + 'community.detail.pricing': 'Preise', + 'community.detail.rating': 'Bewertung', + 'community.detail.downloads': 'Downloads', + 'community.detail.author': 'Autor', + 'community.detail.description': 'Einführung', + 'community.detail.detailContent': 'Detaillierte Beschreibung', + 'community.detail.backtestStats': 'Backtest-Statistiken', + 'community.action.purchase': 'Kaufindikator', + 'community.action.addToMyIndicators': 'Zu meinen Indikatoren hinzufügen', + 'community.action.favorite': 'Sammlung', + 'community.action.unfavorite': 'Favoriten abbrechen', + 'community.action.buyNow': 'Jetzt kaufen', + 'community.action.renew': 'Erneuerung', + 'community.purchase.price': 'Preis', + 'community.purchase.permanent': 'dauerhaft', + 'community.purchase.monthly': 'Monat', + 'community.purchase.confirmBuy': 'Bestätigen Sie den Kauf dieses Indikators ({price} QDT)?', + 'community.purchase.confirmRenew': 'Bestätigen Sie die Erneuerung dieses Indikators ({price} QDT/Monat)?', + 'community.purchase.confirmFree': 'Bestätigen Sie, dass Sie diesen kostenlosen Indikator hinzufügen möchten?', + 'community.purchase.confirmTitle': 'Aktion bestätigen', + 'community.purchase.owned': 'Sie haben diesen Indikator erworben (dauerhaft gültig)', + 'community.purchase.ownIndicator': 'Dies ist die von Ihnen veröffentlichte Metrik', + 'community.purchase.expired': 'Ihr Abonnement ist abgelaufen', + 'community.purchase.expiresOn': 'Ablaufzeit: {Datum}', + 'community.tabs.detail': 'Indikatordetails', + 'community.tabs.backtest': 'Backtest-Daten', + 'community.tabs.ratings': 'Benutzerbewertungen', + 'community.rating.myRating': 'Meine Bewertung', + 'community.rating.stars': '{count} Sterne', + 'community.rating.commentPlaceholder': 'Teilen Sie Ihre Erfahrungen...', + 'community.rating.submit': 'Bewertung abgeben', + 'community.rating.modify': 'Ändern', + 'community.rating.saveModify': 'Änderungen speichern', + 'community.rating.cancel': 'Abbrechen', + 'community.rating.selectRating': 'Bitte wählen Sie eine Bewertung aus', + 'community.rating.success': 'Bewertung erfolgreich', + 'community.rating.modifySuccess': 'Bewertung erfolgreich geändert', + 'community.rating.failed': 'Bewertung fehlgeschlagen', + 'community.rating.noRatings': 'Noch keine Kommentare', + 'community.backtest.note': 'Die oben genannten Daten sind historische Backtest-Ergebnisse und dienen nur als Referenz und stellen keine zukünftigen Erträge dar.', + 'community.backtest.noData': 'Noch keine Backtest-Daten', + 'community.backtest.uploadHint': 'Der Autor hat noch keine Backtest-Daten hochgeladen', + 'community.message.loadFailed': 'Die Indikatorliste konnte nicht geladen werden', + 'community.message.purchaseProcessing': 'Kaufanfrage wird bearbeitet...', + 'community.message.downloadSuccess': 'Die Metrik wurde zu meiner Metrikliste hinzugefügt', + 'community.message.favoriteSuccess': 'Sammlung erfolgreich', + 'community.message.unfavoriteSuccess': 'Abholung erfolgreich abbrechen', + 'community.message.operationSuccess': 'Operation erfolgreich', + 'community.message.operationFailed': 'Der Vorgang ist fehlgeschlagen', + 'dashboard.totalEquity': 'Gesamteigenkapital', + 'dashboard.totalPnL': 'Gesamtgewinn und -verlust', + 'dashboard.aiStrategies': 'KI-Strategie', + 'dashboard.indicatorStrategies': 'Indikatorstrategie', + 'dashboard.running': 'Laufen', + 'dashboard.pnlHistory': 'historischer Gewinn und Verlust', + 'dashboard.strategyPerformance': 'Gewinn- und Verlustquote der Strategie', + 'dashboard.recentTrades': 'aktuelle Transaktionen', + 'dashboard.currentPositions': 'Aktuelle Position', + 'dashboard.table.time': 'Zeit', + 'dashboard.table.strategy': 'Richtlinienname', + 'dashboard.table.symbol': 'Ziel', + 'dashboard.table.type': 'Typ', + 'dashboard.table.side': 'Richtung', + 'dashboard.table.size': 'Offenes Interesse', + 'dashboard.table.entryPrice': 'durchschnittlicher Eröffnungspreis', + 'dashboard.table.price': 'Preis', + 'dashboard.table.amount': 'Menge', + 'dashboard.table.profit': 'Gewinn und Verlust', + 'dashboard.pendingOrders': 'Auftragsausführungsprotokoll', + 'dashboard.totalOrders': 'Gesamt {total} Aufträge', + 'dashboard.viewError': 'Fehler anzeigen', + 'dashboard.filled': 'Ausgeführt', + 'dashboard.orderTable.time': 'Erstellungszeit', + 'dashboard.orderTable.strategy': 'Strategie', + 'dashboard.orderTable.symbol': 'Handelspaar', + 'dashboard.orderTable.signalType': 'Signaltyp', + 'dashboard.orderTable.amount': 'Menge', + 'dashboard.orderTable.price': 'Ausführungspreis', + 'dashboard.orderTable.status': 'Status', + 'dashboard.orderTable.executedAt': 'Ausführungszeit', + 'dashboard.signalType.openLong': 'Long öffnen', + 'dashboard.signalType.openShort': 'Short öffnen', + 'dashboard.signalType.closeLong': 'Long schließen', + 'dashboard.signalType.closeShort': 'Short schließen', + 'dashboard.signalType.addLong': 'Long hinzufügen', + 'dashboard.signalType.addShort': 'Short hinzufügen', + 'dashboard.status.pending': 'Ausstehend', + 'dashboard.status.processing': 'In Bearbeitung', + 'dashboard.status.completed': 'Abgeschlossen', + 'dashboard.status.failed': 'Fehlgeschlagen', + 'dashboard.status.cancelled': 'Storniert', + 'dashboard.table.pnl': 'nicht realisierter Gewinn oder Verlust', + 'form.basic-form.basic.title': 'Grundform', + 'form.basic-form.basic.description': 'Formularseiten werden verwendet, um Informationen von Benutzern zu sammeln oder zu überprüfen. Grundformulare werden häufig in Formularszenarien mit wenigen Datenelementen verwendet.', + 'form.basic-form.title.label': 'Titel', + 'form.basic-form.title.placeholder': 'Geben Sie dem Ziel einen Namen', + 'form.basic-form.title.required': 'Bitte geben Sie einen Titel ein', + 'form.basic-form.date.label': 'Start- und Enddatum', + 'form.basic-form.placeholder.start': 'Startdatum', + 'form.basic-form.placeholder.end': 'Enddatum', + 'form.basic-form.date.required': 'Bitte wählen Sie Start- und Enddatum aus', + 'form.basic-form.goal.label': 'Zielbeschreibung', + 'form.basic-form.goal.placeholder': 'Bitte geben Sie Ihre schrittweisen Arbeitsziele ein', + 'form.basic-form.goal.required': 'Bitte geben Sie eine Zielbeschreibung ein', + 'form.basic-form.standard.label': 'messen', + 'form.basic-form.standard.placeholder': 'Bitte geben Sie Messwerte ein', + 'form.basic-form.standard.required': 'Bitte geben Sie Messwerte ein', + 'form.basic-form.client.label': 'Kunde', + 'form.basic-form.client.required': 'Bitte beschreiben Sie die Kunden, die Sie betreuen', + 'form.basic-form.label.tooltip': 'Zielgruppe der Leistungsempfänger', + 'form.basic-form.client.placeholder': 'Bitte beschreiben Sie die von Ihnen betreuten Kunden, interne Kunden direkt @Name/Auftragsnummer', + 'form.basic-form.invites.label': 'Laden Sie Gutachter ein', + 'form.basic-form.invites.placeholder': 'Bitte direkt @Name/Mitarbeiternummer angeben, es können bis zu 5 Personen einladen', + 'form.basic-form.weight.label': 'Gewicht', + 'form.basic-form.weight.placeholder': 'Bitte treten Sie ein', + 'form.basic-form.public.label': 'Zielpublikum', + 'form.basic-form.label.help': 'Kunden und Rezensenten werden standardmäßig geteilt', + 'form.basic-form.radio.public': 'öffentlich', + 'form.basic-form.radio.partially-public': 'Teilweise öffentlich', + 'form.basic-form.radio.private': 'privat', + 'form.basic-form.publicUsers.placeholder': 'offen für', + 'form.basic-form.option.A': 'Kollege 1', + 'form.basic-form.option.B': 'Kollege 2', + 'form.basic-form.option.C': 'Kollege drei', + 'form.basic-form.email.required': 'Bitte geben Sie Ihre E-Mail-Adresse ein!', + 'form.basic-form.email.wrong-format': 'Das E-Mail-Adressformat ist falsch!', + 'form.basic-form.userName.required': 'Bitte Benutzernamen eingeben!', + 'form.basic-form.password.required': 'Bitte geben Sie Ihr Passwort ein!', + 'form.basic-form.password.twice': 'Die doppelt eingegebenen Passwörter stimmen nicht überein!', + 'form.basic-form.strength.msg': 'Bitte geben Sie mindestens 6 Zeichen ein. Bitte verwenden Sie keine Passwörter, die leicht zu erraten sind.', + 'form.basic-form.strength.strong': 'Stärke: Stark', + 'form.basic-form.strength.medium': 'Stärke: Mittel', + 'form.basic-form.strength.short': 'Stärke: zu kurz', + 'form.basic-form.confirm-password.required': 'Bitte bestätigen Sie Ihr Passwort!', + 'form.basic-form.phone-number.required': 'Bitte geben Sie Ihre Mobiltelefonnummer ein!', + 'form.basic-form.phone-number.wrong-format': 'Das Format der Mobiltelefonnummer ist falsch!', + 'form.basic-form.verification-code.required': 'Bitte geben Sie den Verifizierungscode ein!', + 'form.basic-form.form.get-captcha': 'Holen Sie sich den Bestätigungscode', + 'form.basic-form.captcha.second': 'Sekunden', + 'form.basic-form.form.optional': '(optional)', + 'form.basic-form.form.submit': 'Senden', + 'form.basic-form.form.save': 'speichern', + 'form.basic-form.email.placeholder': 'E-Mail', + 'form.basic-form.password.placeholder': 'Passwort mit mindestens 6 Zeichen, Groß- und Kleinschreibung beachten', + 'form.basic-form.confirm-password.placeholder': 'Passwort bestätigen', + 'form.basic-form.phone-number.placeholder': 'Mobiltelefonnummer', + 'form.basic-form.verification-code.placeholder': 'Bestätigungscode', + 'result.success.title': 'Übermittlung erfolgreich', + 'result.success.description': 'Die Seite mit den Übermittlungsergebnissen wird verwendet, um Rückmeldungen zu den Verarbeitungsergebnissen einer Reihe von Vorgangsaufgaben zu geben. Wenn es sich nur um einen einfachen Vorgang handelt, verwenden Sie das globale Eingabeaufforderungs-Feedback „Nachricht“. In diesem Textbereich können einfache Zusatzanweisungen angezeigt werden. Bei Bedarf zur Darstellung von „Dokumenten“ können im grauen Bereich darunter komplexere Inhalte angezeigt werden.', + 'result.success.operate-title': 'Projektname', + 'result.success.operate-id': 'Projekt-ID', + 'result.success.principal': 'Verantwortliche Person', + 'result.success.operate-time': 'Effektive Zeit', + 'result.success.step1-title': 'Projekt erstellen', + 'result.success.step1-operator': 'Qu Lili', + 'result.success.step2-title': 'Vorläufige Prüfung der Abteilung', + 'result.success.step2-operator': 'Zhou Maomao', + 'result.success.step2-extra': 'Dringend', + 'result.success.step3-title': 'Finanzielle Überprüfung', + 'result.success.step4-title': 'Komplett', + 'result.success.btn-return': 'Zurück zur Liste', + 'result.success.btn-project': 'Artikel ansehen', + 'result.success.btn-print': 'Drucken', + 'result.fail.error.title': 'Die Übermittlung ist fehlgeschlagen', + 'result.fail.error.description': 'Bitte überprüfen und ändern Sie die folgenden Informationen, bevor Sie sie erneut einreichen.', + 'result.fail.error.hint-title': 'Der von Ihnen übermittelte Inhalt enthält die folgenden Fehler:', + 'result.fail.error.hint-text1': 'Ihr Konto wurde gesperrt', + 'result.fail.error.hint-btn1': 'Sofort auftauen', + 'result.fail.error.hint-text2': 'Ihr Konto ist noch nicht antragsberechtigt', + 'result.fail.error.hint-btn2': 'Jetzt upgraden', + 'result.fail.error.btn-text': 'Zurück zur Änderung', + 'account.settings.menuMap.custom': 'Personalisierung', + 'account.settings.menuMap.binding': 'Kontobindung', + 'account.settings.basic.avatar': 'Avatar', + 'account.settings.basic.change-avatar': 'Avatar ändern', + 'account.settings.basic.email': 'E-Mail', + 'account.settings.basic.email-message': 'Bitte geben Sie Ihre E-Mail ein!', + 'account.settings.basic.nickname': 'Spitzname', + 'account.settings.basic.nickname-message': 'Bitte geben Sie Ihren Spitznamen ein!', + 'account.settings.basic.profile': 'Profil', + 'account.settings.basic.profile-message': 'Bitte geben Sie Ihr persönliches Profil ein!', + 'account.settings.basic.profile-placeholder': 'Profil', + 'account.settings.basic.country': 'Land/Region', + 'account.settings.basic.country-message': 'Bitte geben Sie Ihr Land oder Ihre Region ein!', + 'account.settings.basic.geographic': 'Provinz und Stadt', + 'account.settings.basic.geographic-message': 'Bitte geben Sie Ihr Bundesland und Ihre Stadt ein!', + 'account.settings.basic.address': 'Straßenadresse', + 'account.settings.basic.address-message': 'Bitte geben Sie Ihre Straße ein!', + 'account.settings.basic.phone': 'Kontaktnummer', + 'account.settings.basic.phone-message': 'Bitte geben Sie Ihre Kontaktnummer ein!', + 'account.settings.basic.update': 'Grundlegende Informationen aktualisieren', + 'account.settings.basic.update.success': 'Grundlegende Informationen erfolgreich aktualisieren', + 'account.settings.security.strong': 'Stark', + 'account.settings.security.medium': 'in', + 'account.settings.security.weak': 'schwach', + 'account.settings.security.password': 'Kontopasswort', + 'account.settings.security.password-description': 'Aktuelle Passwortstärke:', + 'account.settings.security.phone': 'Sicherheitshandy', + 'account.settings.security.phone-description': 'Bereits gebundenes Mobiltelefon:', + 'account.settings.security.question': 'Sicherheitsprobleme', + 'account.settings.security.question-description': 'Es werden keine Sicherheitsfragen gestellt, wodurch die Kontosicherheit wirksam geschützt werden kann.', + 'account.settings.security.email': 'E-Mail binden', + 'account.settings.security.email-description': 'Bereits gebundene E-Mail-Adresse:', + 'account.settings.security.mfa': 'MFA-Gerät', + 'account.settings.security.mfa-description': 'Das MFA-Gerät ist nicht gebunden. Nach der Bindung können Sie noch einmal bestätigen.', + 'account.settings.security.modify': 'Ändern', + 'account.settings.security.set': 'Einstellungen', + 'account.settings.security.bind': 'verbindlich', + 'account.settings.binding.taobao': 'Binde Taobao', + 'account.settings.binding.taobao-description': 'Das Taobao-Konto ist derzeit nicht gebunden', + 'account.settings.binding.alipay': 'Alipay binden', + 'account.settings.binding.alipay-description': 'Das Alipay-Konto ist derzeit nicht gebunden', + 'account.settings.binding.dingding': 'Bindung von DingTalk', + 'account.settings.binding.dingding-description': 'Derzeit ist kein DingTalk-Konto gebunden', + 'account.settings.binding.bind': 'verbindlich', + 'account.settings.notification.password': 'Kontopasswort', + 'account.settings.notification.password-description': 'Nachrichten von anderen Benutzern werden in Form von Site-Nachrichten benachrichtigt.', + 'account.settings.notification.messages': 'Systemmeldungen', + 'account.settings.notification.messages-description': 'Systemmeldungen werden in Form von Site-Nachrichten benachrichtigt.', + 'account.settings.notification.todo': 'Zu erledigende Aufgaben', + 'account.settings.notification.todo-description': 'Zu erledigende Aufgaben werden in Form von In-Site-Nachrichten benachrichtigt', + 'account.settings.settings.open': 'offen', + 'account.settings.settings.close': 'schließen', + 'trading-assistant.title': 'Handelsassistent', + 'trading-assistant.strategyList': 'Strategieliste', + 'trading-assistant.createStrategy': 'Richtlinie erstellen', + 'trading-assistant.noStrategy': 'Noch keine Strategie', + 'trading-assistant.selectStrategy': 'Bitte wählen Sie links eine Strategie aus, um Details anzuzeigen', + 'trading-assistant.startStrategy': 'Startstrategie', + 'trading-assistant.stopStrategy': 'Stoppstrategie', + 'trading-assistant.editStrategy': 'Redaktionelle Strategie', + 'trading-assistant.deleteStrategy': 'Richtlinie löschen', + 'trading-assistant.status.running': 'Laufen', + 'trading-assistant.status.stopped': 'Angehalten', + 'trading-assistant.status.error': 'Fehler', + 'trading-assistant.strategyType.IndicatorStrategy': 'Technische Indikatorstrategie', + 'trading-assistant.strategyType.PromptBasedStrategy': 'Stichwortstrategie', + 'trading-assistant.strategyType.GridStrategy': 'Grid-Strategie', + 'trading-assistant.tabs.tradingRecords': 'Transaktionsverlauf', + 'trading-assistant.tabs.positions': 'Positionsaufzeichnung', + 'trading-assistant.tabs.equityCurve': 'Eigenkapitalkurve', + 'trading-assistant.form.step1': 'Wählen Sie Indikatoren aus', + 'trading-assistant.form.step2': 'Exchange-Konfiguration', + 'trading-assistant.form.step3': 'Strategieparameter', + 'trading-assistant.form.step2Params': 'Parameter', + 'trading-assistant.form.step3Signal': 'Signalzustellung', + 'trading-assistant.form.marketCategory': 'Marktkategorie', + 'trading-assistant.form.marketCategoryHint': 'Wählen Sie zuerst den Markt. Nur Krypto kann Live-Trading aktivieren; andere Märkte sind nur Signal.', + 'trading-assistant.market.AShare': 'A-Aktien', + 'trading-assistant.market.USStock': 'US-Aktien', + 'trading-assistant.market.Crypto': 'Krypto', + 'trading-assistant.market.Forex': 'Forex', + 'trading-assistant.form.indicator': 'Wählen Sie Indikatoren aus', + 'trading-assistant.form.indicatorHint': 'Sie können nur technische Indikatoren auswählen, die Sie gekauft oder erstellt haben', + 'trading-assistant.form.qdtCostHints': 'Bei Verwendung dieser Strategie wird QDT verbraucht. Bitte stellen Sie sicher, dass das Konto über ausreichend QDT-Guthaben verfügt', + 'trading-assistant.form.indicatorDescription': 'Beschreibung des Indikators', + 'trading-assistant.form.noDescription': 'Noch keine Beschreibung', + 'trading-assistant.form.exchange': 'Austausch auswählen', + 'trading-assistant.form.apiKey': 'API-Schlüssel', + 'trading-assistant.form.secretKey': 'Geheimer Schlüssel', + 'trading-assistant.form.passphrase': 'Passphrase', + 'trading-assistant.form.testConnection': 'Testverbindung', + 'trading-assistant.form.strategyName': 'Richtlinienname', + 'trading-assistant.form.symbol': 'Handelspaar', + 'trading-assistant.form.symbolHint': 'Das Symbolformat hängt vom ausgewählten Markt ab', + 'trading-assistant.form.symbolHintCrypto': 'Krypto: z.B. BTC/USDT', + 'trading-assistant.form.symbolHintGeneral': 'Geben Sie das Symbol des gewählten Marktes ein (z.B. 600519, AAPL, EURUSD).', + 'trading-assistant.form.initialCapital': 'Höhe der Investition', + 'trading-assistant.form.marketType': 'Markttyp', + 'trading-assistant.form.marketTypeFutures': 'Vertrag', + 'trading-assistant.form.marketTypeSpot': 'Spot', + 'trading-assistant.form.marketTypeHint': 'Der Kontrakt unterstützt bidirektionalen Handel und Leverage, während der Spot nur Long-Positionen unterstützt und der Leverage auf 1x festgelegt ist', + 'trading-assistant.form.leverage': 'Nutzen Sie mehrere', + 'trading-assistant.form.leverageHint': 'Vertrag: 1-125 Mal, Spot: 1 Mal fest', + 'trading-assistant.form.spotLeverageFixed': 'Der Hebel für den Spothandel ist auf 1x festgelegt', + 'trading-assistant.form.spotOnlyLongHint': 'Der Spothandel unterstützt nur Long-Positionen', + 'trading-assistant.form.tradeDirection': 'Handelsrichtung', + 'trading-assistant.form.tradeDirectionLong': 'Nur lang', + 'trading-assistant.form.tradeDirectionShort': 'Nur kurz', + 'trading-assistant.form.tradeDirectionBoth': 'zweiseitige Transaktion', + 'trading-assistant.form.timeframe': 'Zeitraum', + 'trading-assistant.form.klinePeriod': 'K-Linien-Zeitraum', + 'trading-assistant.form.timeframe1m': '1 Minute', + 'trading-assistant.form.timeframe5m': '5 Minuten', + 'trading-assistant.form.timeframe15m': '15 Minuten', + 'trading-assistant.form.timeframe30m': '30 Minuten', + 'trading-assistant.form.timeframe1H': '1 Stunde', + 'trading-assistant.form.timeframe4H': '4 Stunden', + 'trading-assistant.form.timeframe1D': '1 Tag', + 'trading-assistant.form.selectStrategyType': 'Strategietyp auswählen', + 'trading-assistant.form.indicatorStrategy': 'Indikator-Strategie', + 'trading-assistant.form.indicatorStrategyDesc': 'Automatisierte Handelsstrategie basierend auf technischen Indikatoren', + 'trading-assistant.form.aiStrategy': 'KI-Strategie', + 'trading-assistant.form.aiStrategyDesc': 'Automatisierte Handelsstrategie basierend auf KI-Intelligenzentscheidungen', + 'trading-assistant.form.enableAiFilter': 'KI-Intelligenzentscheidungsfilter aktivieren', + 'trading-assistant.form.enableAiFilterHint': 'Wenn aktiviert, werden Indikatorsignale von KI gefiltert, um die Handelsqualität zu verbessern', + 'trading-assistant.form.aiFilterPrompt': 'Benutzerdefinierte Eingabeaufforderung', + 'trading-assistant.form.aiFilterPromptHint': 'Benutzerdefinierte Anweisungen für KI-Filterung bereitstellen, leer lassen, um die Systemstandardwerte zu verwenden', + 'trading-assistant.validation.strategyTypeRequired': 'Bitte wählen Sie einen Strategietyp', + 'trading-assistant.validation.marketCategoryRequired': 'Bitte wählen Sie eine Marktkategorie', + 'trading-assistant.form.advancedSettings': 'Erweiterte Einstellungen', + 'trading-assistant.form.orderMode': 'Bestellmodus', + 'trading-assistant.form.orderModeMaker': 'Hersteller', + 'trading-assistant.form.orderModeTaker': 'Marktpreis (Taker)', + 'trading-assistant.form.orderModeHint': 'Im Pending-Order-Modus werden Limit-Orders verwendet und die Bearbeitungsgebühr ist niedriger; Der Marktpreismodus wird sofort ausgeführt und die Bearbeitungsgebühr ist höher.', + 'trading-assistant.form.makerWaitSec': 'Wartezeit für ausstehende Bestellungen (Sekunden)', + 'trading-assistant.form.makerWaitSecHint': 'Die Zeit, die nach der Bestellung auf die Transaktion gewartet wird. Brechen Sie ab und versuchen Sie es nach einer Zeitüberschreitung erneut.', + 'trading-assistant.form.makerRetries': 'Anzahl der Wiederholungsversuche für ausstehende Bestellungen', + 'trading-assistant.form.makerRetriesHint': 'Die maximale Anzahl von Wiederholungsversuchen, wenn eine ausstehende Order nicht ausgeführt wird', + 'trading-assistant.form.fallbackToMarket': 'Die ausstehende Bestellung schlägt fehl und der Marktpreis wird herabgestuft.', + 'trading-assistant.form.fallbackToMarketHint': 'Wenn die ausstehende Eröffnungs-/Schließorder nicht abgeschlossen wurde, wird angegeben, ob sie auf eine Marktorder herabgestuft werden soll, um sicherzustellen, dass die Transaktion abgeschlossen wird.', + 'trading-assistant.form.marginMode': 'Margin-Modus', + 'trading-assistant.form.marginModeCross': 'Volles Lager', + 'trading-assistant.form.marginModeIsolated': 'Isolierte Lage', + 'trading-assistant.form.stopLossPct': 'Stop-Loss-Verhältnis (%)', + 'trading-assistant.form.stopLossPctHint': 'Legen Sie den Stop-Loss-Prozentsatz fest. 0 bedeutet, dass Stop-Loss nicht aktiviert ist', + 'trading-assistant.form.takeProfitPct': 'Take-Profit-Quote (%)', + 'trading-assistant.form.takeProfitPctHint': 'Legen Sie den Take-Profit-Prozentsatz fest. 0 bedeutet, dass Take-Profit deaktiviert ist', + 'trading-assistant.form.commission': 'Gebühr (%)', + 'trading-assistant.form.commissionHint': 'Handelsgebühr in Prozent (optional)', + 'trading-assistant.form.slippage': 'Slippage (%)', + 'trading-assistant.form.slippageHint': 'Geschätzter Slippage-Prozentsatz (optional)', + 'trading-assistant.form.executionMode': 'Ausführung', + 'trading-assistant.form.executionModeSignal': 'Nur Signal (Benachrichtigungen)', + 'trading-assistant.form.executionModeLive': 'Live-Trading (nur Krypto)', + 'trading-assistant.form.liveTradingCryptoOnlyHint': 'Live-Trading ist nur für Krypto verfügbar. Andere Märkte können nur Signale senden.', + 'trading-assistant.form.notifyChannels': 'Benachrichtigungskanäle', + 'trading-assistant.form.notifyChannelsHint': 'Wählen Sie aus, wie Sie Kauf/Verkauf- und Risikosignale erhalten möchten.', + 'trading-assistant.notify.browser': 'Browser', + 'trading-assistant.notify.email': 'E-Mail', + 'trading-assistant.notify.phone': 'Telefon', + 'trading-assistant.notify.telegram': 'Telegram', + 'trading-assistant.notify.discord': 'Discord', + 'trading-assistant.notify.webhook': 'Webhook', + 'trading-assistant.form.notifyEmail': 'E-Mail', + 'trading-assistant.form.notifyPhone': 'Telefonnummer', + 'trading-assistant.form.notifyTelegram': 'Telegram (Chat-ID / Benutzername)', + 'trading-assistant.form.notifyDiscord': 'Discord (Webhook-URL)', + 'trading-assistant.form.notifyWebhook': 'Webhook-URL', + 'trading-assistant.form.liveTradingConfigTitle': 'Exchange-Zugangsdaten', + 'trading-assistant.form.liveTradingConfigHint': 'Geben Sie Ihre Exchange-API-Zugangsdaten ein. Sie können die Verbindung vor dem Speichern testen.', + 'trading-assistant.form.savedCredential': 'Gespeicherte Zugangsdaten', + 'trading-assistant.form.savedCredentialHint': 'Optional: Wählen Sie gespeicherte Zugangsdaten zum automatischen Ausfüllen.', + 'trading-assistant.form.saveCredential': 'Diese Zugangsdaten für später speichern', + 'trading-assistant.form.credentialName': 'Name (optional)', + 'trading-assistant.form.signalMode': 'Signalmodus', + 'trading-assistant.form.signalModeConfirmed': 'Bestätigen Sie den Modus', + 'trading-assistant.form.signalModeAggressive': 'Aggressiver Modus', + 'trading-assistant.form.signalModeHint': 'Bestätigungsmodus: prüft nur abgeschlossene K-Zeilen; Aggressiver Modus: Überprüft auch die Bildung von K-Linien', + 'trading-assistant.form.cancel': 'Abbrechen', + 'trading-assistant.form.prev': 'Vorheriger Schritt', + 'trading-assistant.form.next': 'Nächster Schritt', + 'trading-assistant.form.confirmCreate': 'Bestätigen Sie die Erstellung', + 'trading-assistant.form.confirmEdit': 'Bestätigen Sie die Änderungen', + 'trading-assistant.messages.createSuccess': 'Strategie erfolgreich erstellt', + 'trading-assistant.messages.createFailed': 'Richtlinie konnte nicht erstellt werden', + 'trading-assistant.messages.updateSuccess': 'Richtlinienaktualisierung erfolgreich', + 'trading-assistant.messages.updateFailed': 'Die Aktualisierungsrichtlinie ist fehlgeschlagen', + 'trading-assistant.messages.deleteSuccess': 'Richtlinienlöschung erfolgreich', + 'trading-assistant.messages.deleteFailed': 'Das Löschen der Richtlinie ist fehlgeschlagen', + 'trading-assistant.messages.startSuccess': 'Strategie erfolgreich gestartet', + 'trading-assistant.messages.startFailed': 'Die Richtlinie konnte nicht gestartet werden', + 'trading-assistant.messages.stopSuccess': 'Die Strategie wurde erfolgreich beendet', + 'trading-assistant.messages.stopFailed': 'Die Stoppstrategie ist fehlgeschlagen', + 'trading-assistant.messages.loadFailed': 'Richtlinienliste konnte nicht abgerufen werden', + 'trading-assistant.messages.runningWarning': 'Die Strategie läuft. Bitte stoppen Sie die Strategie, bevor Sie sie ändern.', + 'trading-assistant.messages.deleteConfirmWithName': 'Sind Sie sicher, dass Sie die Richtlinie „{name}“ löschen möchten? Dieser Vorgang ist irreversibel.', + 'trading-assistant.messages.deleteConfirm': 'Sind Sie sicher, dass Sie diese Richtlinie löschen möchten? Dieser Vorgang ist irreversibel.', + 'trading-assistant.messages.loadTradesFailed': 'Transaktionsdatensätze konnten nicht abgerufen werden', + 'trading-assistant.messages.loadPositionsFailed': 'Positionsdatensatz konnte nicht abgerufen werden', + 'trading-assistant.messages.loadEquityFailed': 'Die Eigenkapitalkurve konnte nicht ermittelt werden', + 'trading-assistant.messages.loadIndicatorsFailed': 'Die Indikatorliste konnte nicht geladen werden. Bitte versuchen Sie es später noch einmal', + 'trading-assistant.messages.spotLimitations': 'Der Spot-Handel wurde automatisch auf „Nur Long“ mit 1-facher Hebelwirkung eingestellt', + 'trading-assistant.messages.autoFillApiConfig': 'Die historische API-Konfiguration der Börse wurde automatisch ausgefüllt', + 'trading-assistant.placeholders.selectIndicator': 'Bitte wählen Sie einen Indikator aus', + 'trading-assistant.placeholders.selectExchange': 'Bitte wählen Sie einen Austausch aus', + 'trading-assistant.placeholders.selectMarketCategory': 'Bitte Marktkategorie auswählen', + 'trading-assistant.placeholders.inputApiKey': 'Bitte geben Sie den API-Schlüssel ein', + 'trading-assistant.placeholders.inputSecretKey': 'Bitte geben Sie den Geheimschlüssel ein', + 'trading-assistant.placeholders.inputPassphrase': 'Bitte geben Sie die Passphrase ein', + 'trading-assistant.placeholders.inputStrategyName': 'Bitte geben Sie einen Richtliniennamen ein', + 'trading-assistant.placeholders.selectSymbol': 'Bitte wählen Sie ein Handelspaar aus', + 'trading-assistant.placeholders.inputSymbol': 'Bitte Symbol eingeben', + 'trading-assistant.placeholders.selectTimeframe': 'Bitte wählen Sie einen Zeitraum aus', + 'trading-assistant.placeholders.selectKlinePeriod': 'Bitte wählen Sie einen K-Linien-Zeitraum aus', + 'trading-assistant.placeholders.inputAiFilterPrompt': 'Bitte geben Sie eine benutzerdefinierte Eingabeaufforderung ein (optional)', + 'trading-assistant.placeholders.inputEmail': 'Bitte E-Mail eingeben', + 'trading-assistant.placeholders.inputPhone': 'Bitte Telefonnummer eingeben', + 'trading-assistant.placeholders.inputTelegram': 'Bitte Telegram Chat-ID / Benutzername eingeben', + 'trading-assistant.placeholders.inputDiscord': 'Bitte Discord Webhook-URL eingeben', + 'trading-assistant.placeholders.inputWebhook': 'Bitte Webhook-URL eingeben', + 'trading-assistant.placeholders.selectSavedCredential': 'Gespeicherte Zugangsdaten auswählen', + 'trading-assistant.placeholders.inputCredentialName': 'Zum Beispiel: Binance Hauptschlüssel', + 'trading-assistant.validation.indicatorRequired': 'Bitte wählen Sie einen Indikator aus', + 'trading-assistant.validation.exchangeRequired': 'Bitte wählen Sie einen Austausch aus', + 'trading-assistant.validation.apiKeyRequired': 'Bitte geben Sie den API-Schlüssel ein', + 'trading-assistant.validation.secretKeyRequired': 'Bitte geben Sie den Geheimschlüssel ein', + 'trading-assistant.validation.passphraseRequired': 'Bitte geben Sie die Passphrase ein', + 'trading-assistant.validation.exchangeConfigIncomplete': 'Bitte geben Sie die vollständigen Exchange-Konfigurationsinformationen ein', + 'trading-assistant.validation.testConnectionRequired': 'Bitte klicken Sie zuerst auf die Schaltfläche "Verbindung testen" und stellen Sie sicher, dass die Verbindung erfolgreich ist', + 'trading-assistant.validation.testConnectionFailed': 'Verbindungstest fehlgeschlagen, bitte überprüfen Sie die Konfiguration und testen Sie erneut', + 'trading-assistant.validation.strategyNameRequired': 'Bitte geben Sie einen Richtliniennamen ein', + 'trading-assistant.validation.symbolRequired': 'Bitte wählen Sie ein Handelspaar aus', + 'trading-assistant.validation.initialCapitalRequired': 'Bitte geben Sie den Anlagebetrag ein', + 'trading-assistant.validation.leverageRequired': 'Bitte geben Sie die Leverage Ratio ein', + 'trading-assistant.validation.emailInvalid': 'Ungültige E-Mail-Adresse', + 'trading-assistant.validation.notifyChannelRequired': 'Bitte wählen Sie mindestens einen Benachrichtigungskanal aus', + 'trading-assistant.table.time': 'Zeit', + 'trading-assistant.table.type': 'Typ', + 'trading-assistant.table.price': 'Preis', + 'trading-assistant.table.amount': 'Menge', + 'trading-assistant.table.value': 'Betrag', + 'trading-assistant.table.commission': 'Bearbeitungsgebühr', + 'trading-assistant.table.symbol': 'Handelspaar', + 'trading-assistant.table.side': 'Richtung', + 'trading-assistant.table.size': 'Positionsmenge', + 'trading-assistant.table.entryPrice': 'Eröffnungspreis', + 'trading-assistant.table.currentPrice': 'aktueller Preis', + 'trading-assistant.table.unrealizedPnl': 'nicht realisierter Gewinn oder Verlust', + 'trading-assistant.table.pnlPercent': 'Gewinn- und Verlustquote', + 'trading-assistant.table.buy': 'kaufen', + 'trading-assistant.table.sell': 'verkaufen', + 'trading-assistant.table.long': 'Geh lange', + 'trading-assistant.table.short': 'kurz', + 'trading-assistant.table.noPositions': 'Noch keine Stellen', + 'trading-assistant.detail.title': 'Strategiedetails', + 'trading-assistant.detail.strategyName': 'Richtlinienname', + 'trading-assistant.detail.strategyType': 'Strategietyp', + 'trading-assistant.detail.status': 'Status', + 'trading-assistant.detail.tradingMode': 'Handelsmodell', + 'trading-assistant.detail.exchange': 'Austausch', + 'trading-assistant.detail.initialCapital': 'Anfangskapital', + 'trading-assistant.detail.totalInvestment': 'Gesamtinvestitionsbetrag', + 'trading-assistant.detail.currentEquity': 'Aktuelles Nettovermögen', + 'trading-assistant.detail.totalPnl': 'Gesamtgewinn und -verlust', + 'trading-assistant.detail.indicatorName': 'Indikatorname', + 'trading-assistant.detail.maxLeverage': 'Maximale Hebelwirkung', + 'trading-assistant.detail.decideInterval': 'Entscheidungsintervall', + 'trading-assistant.detail.symbols': 'Transaktionsobjekt', + 'trading-assistant.detail.createdAt': 'Schöpfungszeit', + 'trading-assistant.detail.updatedAt': 'Aktualisierungszeit', + 'trading-assistant.detail.llmConfig': 'Konfiguration des KI-Modells', + 'trading-assistant.detail.exchangeConfig': 'Exchange-Konfiguration', + 'trading-assistant.detail.provider': 'Anbieter', + 'trading-assistant.detail.modelId': 'Modell-ID', + 'trading-assistant.detail.close': 'Schließen', + 'trading-assistant.detail.loadFailed': 'Richtliniendetails konnten nicht abgerufen werden', + 'trading-assistant.equity.noData': 'Noch keine Daten zum Vermögen', + 'trading-assistant.equity.equity': 'Nettovermögen', + 'trading-assistant.exchange.tradingMode': 'Handelsmodell', + 'trading-assistant.exchange.virtual': 'simulierter Handel', + 'trading-assistant.exchange.live': 'Echte Transaktion', + 'trading-assistant.exchange.selectExchange': 'Austausch auswählen', + 'trading-assistant.exchange.walletAddress': 'Wallet-Adresse', + 'trading-assistant.exchange.walletAddressPlaceholder': 'Bitte geben Sie Ihre Wallet-Adresse ein (beginnend mit 0x)', + 'trading-assistant.exchange.privateKey': 'privater Schlüssel', + 'trading-assistant.exchange.privateKeyPlaceholder': 'Bitte geben Sie den privaten Schlüssel ein (64 Zeichen)', + 'trading-assistant.exchange.testConnection': 'Testverbindung', + 'trading-assistant.exchange.connectionSuccess': 'Verbindung erfolgreich', + 'trading-assistant.exchange.connectionFailed': 'Verbindung fehlgeschlagen', + 'trading-assistant.exchange.testFailed': 'Verbindungstest fehlgeschlagen', + 'trading-assistant.exchange.fillComplete': 'Bitte geben Sie die vollständigen Exchange-Konfigurationsinformationen ein', + 'trading-assistant.strategyTypeOptions.ai': 'KI-gesteuerte Strategie', + 'trading-assistant.strategyTypeOptions.indicator': 'Technische Indikatorstrategie', + 'trading-assistant.strategyTypeOptions.aiDeveloping': 'Die KI-gesteuerte Strategiefunktion befindet sich in der Entwicklung, also bleiben Sie dran', + 'trading-assistant.strategyTypeOptions.aiDevelopingWarning': 'KI-gesteuerte Strategiefunktionen sind in der Entwicklung', + 'trading-assistant.indicatorType.trend': 'Trend', + 'trading-assistant.indicatorType.momentum': 'Schwung', + 'trading-assistant.indicatorType.volatility': 'Fluktuation', + 'trading-assistant.indicatorType.volume': 'Lautstärke', + 'trading-assistant.indicatorType.custom': 'Anpassen', + 'trading-assistant.exchangeNames': { + 'okx': 'OKX', + 'binance': 'Binance', + 'hyperliquid': 'Hyperliquid', + 'blockchaincom': 'Blockchain.com', + 'coinbaseexchange': 'Coinbase', + 'gate': 'Gate.io', + 'mexc': 'MEXC', + 'kraken': 'Kraken', + 'bitfinex': 'Bitfinex', + 'bybit': 'Bybit', + 'kucoin': 'KuCoin', + 'huobi': 'Huobi', + 'bitget': 'Bitget', + 'bitmex': 'BitMEX', + 'deribit': 'Deribit', + 'phemex': 'Phemex', + 'bitmart': 'BitMart', + 'bitstamp': 'Bitstamp', + 'bittrex': 'Bittrex', + 'poloniex': 'Poloniex', + 'gemini': 'Zwillinge', + 'cryptocom': 'Crypto.com', + 'bitflyer': 'bitFlyer', + 'upbit': 'Upbit', + 'bithumb': 'Bithumb', + 'coinone': 'Coinone', + 'zb': 'ZB', + 'lbank': 'LBank', + 'bibox': 'Bibox', + 'bigone': 'BigONE', + 'bitrue': 'Bitrue', + 'coinex': 'CoinEx', + 'digifinex': 'DigiFinex', + 'ftx': 'FTX', + 'ftxus': 'FTX USA', + 'binanceus': 'Binance USA', + 'binancecoinm': 'Binance COIN-M', + 'binanceusdm': 'Binance USDⓈ-M' + }, + 'ai-trading-assistant.title': 'KI-Handelsassistent', + 'ai-trading-assistant.strategyList': 'Strategieliste', + 'ai-trading-assistant.createStrategy': 'Richtlinie erstellen', + 'ai-trading-assistant.noStrategy': 'Noch keine Strategie', + 'ai-trading-assistant.selectStrategy': 'Bitte wählen Sie links eine Strategie aus, um Details anzuzeigen', + 'ai-trading-assistant.startStrategy': 'Startstrategie', + 'ai-trading-assistant.stopStrategy': 'Stoppstrategie', + 'ai-trading-assistant.editStrategy': 'Redaktionelle Strategie', + 'ai-trading-assistant.deleteStrategy': 'Richtlinie löschen', + 'ai-trading-assistant.status.running': 'Laufen', + 'ai-trading-assistant.status.stopped': 'Angehalten', + 'ai-trading-assistant.status.error': 'Fehler', + 'ai-trading-assistant.tabs.tradingRecords': 'Transaktionsverlauf', + 'ai-trading-assistant.tabs.positions': 'Positionsaufzeichnung', + 'ai-trading-assistant.tabs.aiDecisions': 'KI-Entscheidungsaufzeichnung', + 'ai-trading-assistant.tabs.equityCurve': 'Eigenkapitalkurve', + 'ai-trading-assistant.form.createTitle': 'Erstellen Sie KI-Handelsstrategien', + 'ai-trading-assistant.form.editTitle': 'Bearbeiten Sie die KI-Handelsstrategie', + 'ai-trading-assistant.form.strategyName': 'Richtlinienname', + 'ai-trading-assistant.form.modelId': 'KI-Modell', + 'ai-trading-assistant.form.modelIdHint': 'Verwenden Sie den OpenRouter-Systemdienst, ohne den API-Schlüssel zu konfigurieren', + 'ai-trading-assistant.form.decideInterval': 'Entscheidungsintervall', + 'ai-trading-assistant.form.decideInterval5m': '5 Minuten', + 'ai-trading-assistant.form.decideInterval10m': '10 Minuten', + 'ai-trading-assistant.form.decideInterval30m': '30 Minuten', + 'ai-trading-assistant.form.decideInterval1h': '1 Stunde', + 'ai-trading-assistant.form.decideInterval4h': '4 Stunden', + 'ai-trading-assistant.form.decideInterval1d': '1 Tag', + 'ai-trading-assistant.form.decideInterval1w': '1 Woche', + 'ai-trading-assistant.form.decideIntervalHint': 'Das Zeitintervall, in dem die KI Entscheidungen trifft', + 'ai-trading-assistant.form.runPeriod': 'Laufzyklus', + 'ai-trading-assistant.form.runPeriodHint': 'Die Startzeit und Endzeit des Strategielaufs', + 'ai-trading-assistant.form.startDate': 'Startdatum', + 'ai-trading-assistant.form.endDate': 'Enddatum', + 'ai-trading-assistant.form.qdtCostTitle': 'Anweisungen zum QDT-Abzug', + 'ai-trading-assistant.form.qdtCostHint': '{cost} QDT wird für jede KI-Entscheidung abgezogen. Bitte stellen Sie sicher, dass Ihr Konto über ausreichend QDT-Guthaben verfügt. Während der Umsetzung der Strategie werden für jede Entscheidung Gebühren in Echtzeit abgezogen.', + 'ai-trading-assistant.form.apiKey': 'API-Schlüssel', + 'ai-trading-assistant.form.exchange': 'Austausch auswählen', + 'ai-trading-assistant.form.secretKey': 'Geheimer Schlüssel', + 'ai-trading-assistant.form.passphrase': 'Passphrase', + 'ai-trading-assistant.form.testConnection': 'Testverbindung', + 'ai-trading-assistant.form.symbol': 'Handelspaar', + 'ai-trading-assistant.form.symbolHint': 'Wählen Sie das Handelspaar aus, mit dem Sie handeln möchten', + 'ai-trading-assistant.form.initialCapital': 'Investierter Betrag (Marge)', + 'ai-trading-assistant.form.leverage': 'Nutzen Sie mehrere', + 'ai-trading-assistant.form.timeframe': 'Zeitraum', + 'ai-trading-assistant.form.timeframe1m': '1 Minute', + 'ai-trading-assistant.form.timeframe5m': '5 Minuten', + 'ai-trading-assistant.form.timeframe15m': '15 Minuten', + 'ai-trading-assistant.form.timeframe30m': '30 Minuten', + 'ai-trading-assistant.form.timeframe1H': '1 Stunde', + 'ai-trading-assistant.form.timeframe4H': '4 Stunden', + 'ai-trading-assistant.form.timeframe1D': '1 Tag', + 'ai-trading-assistant.form.marketType': 'Markttyp', + 'ai-trading-assistant.form.marketTypeFutures': 'Vertrag', + 'ai-trading-assistant.form.marketTypeSpot': 'Spot', + 'ai-trading-assistant.form.totalPnl': 'Gesamtgewinn und -verlust', + 'ai-trading-assistant.form.customPrompt': 'Benutzerdefinierte Aufforderungswörter', + 'ai-trading-assistant.form.customPromptHint': 'Optional, wird zur Anpassung von KI-Handelsstrategien und Entscheidungslogik verwendet', + 'ai-trading-assistant.form.cancel': 'Abbrechen', + 'ai-trading-assistant.form.prev': 'Vorheriger Schritt', + 'ai-trading-assistant.form.next': 'Nächster Schritt', + 'ai-trading-assistant.form.confirmCreate': 'Bestätigen Sie die Erstellung', + 'ai-trading-assistant.form.confirmEdit': 'Bestätigen Sie die Änderungen', + 'ai-trading-assistant.messages.createSuccess': 'Strategie erfolgreich erstellt', + 'ai-trading-assistant.messages.createFailed': 'Richtlinie konnte nicht erstellt werden', + 'ai-trading-assistant.messages.updateSuccess': 'Richtlinienaktualisierung erfolgreich', + 'ai-trading-assistant.messages.updateFailed': 'Die Aktualisierungsrichtlinie ist fehlgeschlagen', + 'ai-trading-assistant.messages.deleteSuccess': 'Richtlinienlöschung erfolgreich', + 'ai-trading-assistant.messages.deleteFailed': 'Das Löschen der Richtlinie ist fehlgeschlagen', + 'ai-trading-assistant.messages.startSuccess': 'Strategie erfolgreich gestartet', + 'ai-trading-assistant.messages.startFailed': 'Die Richtlinie konnte nicht gestartet werden', + 'ai-trading-assistant.messages.stopSuccess': 'Die Strategie wurde erfolgreich beendet', + 'ai-trading-assistant.messages.stopFailed': 'Die Stoppstrategie ist fehlgeschlagen', + 'ai-trading-assistant.messages.loadFailed': 'Richtlinienliste konnte nicht abgerufen werden', + 'ai-trading-assistant.messages.loadDecisionsFailed': 'Der KI-Entscheidungsdatensatz konnte nicht abgerufen werden', + 'ai-trading-assistant.messages.deleteConfirm': 'Sind Sie sicher, dass Sie diese Richtlinie löschen möchten? Dieser Vorgang ist irreversibel.', + 'ai-trading-assistant.placeholders.inputStrategyName': 'Bitte geben Sie einen Richtliniennamen ein', + 'ai-trading-assistant.placeholders.selectModelId': 'Bitte wählen Sie ein KI-Modell aus', + 'ai-trading-assistant.placeholders.selectDecideInterval': 'Bitte wählen Sie ein Entscheidungsintervall aus', + 'ai-trading-assistant.placeholders.startTime': 'Startzeit', + 'ai-trading-assistant.placeholders.endTime': 'Endzeit', + 'ai-trading-assistant.placeholders.inputApiKey': 'Bitte geben Sie den API-Schlüssel ein', + 'ai-trading-assistant.placeholders.selectExchange': 'Bitte wählen Sie einen Austausch aus', + 'ai-trading-assistant.placeholders.inputSecretKey': 'Bitte geben Sie den Geheimschlüssel ein', + 'ai-trading-assistant.placeholders.inputPassphrase': 'Bitte geben Sie die Passphrase ein', + 'ai-trading-assistant.placeholders.selectSymbol': 'Bitte wählen Sie ein Handelspaar aus, zum Beispiel: BTC/USDT', + 'ai-trading-assistant.placeholders.selectTimeframe': 'Bitte wählen Sie einen Zeitraum aus', + 'ai-trading-assistant.placeholders.inputCustomPrompt': 'Bitte geben Sie ein benutzerdefiniertes Aufforderungswort ein (optional)', + 'ai-trading-assistant.validation.strategyNameRequired': 'Bitte geben Sie einen Richtliniennamen ein', + 'ai-trading-assistant.validation.modelIdRequired': 'Bitte wählen Sie ein KI-Modell aus', + 'ai-trading-assistant.validation.runPeriodRequired': 'Bitte wählen Sie einen Laufzyklus aus', + 'ai-trading-assistant.validation.apiKeyRequired': 'Bitte geben Sie den API-Schlüssel ein', + 'ai-trading-assistant.validation.exchangeRequired': 'Bitte wählen Sie einen Austausch aus', + 'ai-trading-assistant.validation.secretKeyRequired': 'Bitte geben Sie den Geheimschlüssel ein', + 'ai-trading-assistant.validation.symbolRequired': 'Bitte wählen Sie ein Handelspaar aus', + 'ai-trading-assistant.validation.initialCapitalRequired': 'Bitte geben Sie den Anlagebetrag ein', + 'ai-trading-assistant.table.time': 'Zeit', + 'ai-trading-assistant.table.type': 'Typ', + 'ai-trading-assistant.table.price': 'Preis', + 'ai-trading-assistant.table.amount': 'Menge', + 'ai-trading-assistant.table.value': 'Betrag', + 'ai-trading-assistant.table.symbol': 'Handelspaar', + 'ai-trading-assistant.table.side': 'Richtung', + 'ai-trading-assistant.table.size': 'Positionsmenge', + 'ai-trading-assistant.table.entryPrice': 'Eröffnungspreis', + 'ai-trading-assistant.table.currentPrice': 'aktueller Preis', + 'ai-trading-assistant.table.unrealizedPnl': 'nicht realisierter Gewinn oder Verlust', + 'ai-trading-assistant.table.profit': 'Gewinn und Verlust', + 'ai-trading-assistant.table.openLong': 'Lange geöffnet', + 'ai-trading-assistant.table.closeLong': 'Pinduo', + 'ai-trading-assistant.table.openShort': 'Kurz öffnen', + 'ai-trading-assistant.table.closeShort': 'leer', + 'ai-trading-assistant.table.addLong': 'Gadot', + 'ai-trading-assistant.table.addShort': 'kurz hinzufügen', + 'ai-trading-assistant.table.closeShortProfit': 'Nehmen Sie Gewinne aus Short-Positionen mit', + 'ai-trading-assistant.table.closeShortStop': 'Stop-Loss', + 'ai-trading-assistant.table.closeLongProfit': 'Halten Sie lange inne und nehmen Sie Gewinn mit', + 'ai-trading-assistant.table.closeLongStop': 'Stop-Loss', + 'ai-trading-assistant.table.buy': 'kaufen', + 'ai-trading-assistant.table.sell': 'verkaufen', + 'ai-trading-assistant.table.long': 'Geh lange', + 'ai-trading-assistant.table.short': 'kurz', + 'ai-trading-assistant.table.hold': 'halten', + 'ai-trading-assistant.table.reasoning': 'Analysegrund', + 'ai-trading-assistant.table.decisions': 'Entscheidungsfindung', + 'ai-trading-assistant.table.riskAssessment': 'Risikobewertung', + 'ai-trading-assistant.table.confidence': 'Vertrauen', + 'ai-trading-assistant.table.totalRecords': 'Insgesamt {total} Datensätze', + 'ai-trading-assistant.table.noPositions': 'Noch keine Stellen', + 'ai-trading-assistant.detail.title': 'Strategiedetails', + 'ai-trading-assistant.equity.noData': 'Noch keine Daten zum Vermögen', + 'ai-trading-assistant.equity.equity': 'Nettovermögen', + 'ai-trading-assistant.exchange.testFailed': 'Verbindungstest fehlgeschlagen', + 'ai-trading-assistant.exchange.connectionSuccess': 'Verbindung erfolgreich', + 'ai-trading-assistant.exchange.connectionFailed': 'Verbindung fehlgeschlagen', + 'ai-trading-assistant.form.advancedSettings': 'Erweiterte Einstellungen', + 'ai-trading-assistant.form.orderMode': 'Bestellmodus', + 'ai-trading-assistant.form.orderModeMaker': 'Hersteller', + 'ai-trading-assistant.form.orderModeTaker': 'Marktpreis (Taker)', + 'ai-trading-assistant.form.orderModeHint': 'Im Pending-Order-Modus werden Limit-Orders verwendet und die Bearbeitungsgebühr ist niedriger; Der Marktpreismodus wird sofort ausgeführt und die Bearbeitungsgebühr ist höher.', + 'ai-trading-assistant.form.makerWaitSec': 'Wartezeit für ausstehende Bestellungen (Sekunden)', + 'ai-trading-assistant.form.makerWaitSecHint': 'Die Zeit, die nach der Bestellung auf die Transaktion gewartet wird. Brechen Sie ab und versuchen Sie es nach einer Zeitüberschreitung erneut.', + 'ai-trading-assistant.form.makerRetries': 'Anzahl der Wiederholungsversuche für ausstehende Bestellungen', + 'ai-trading-assistant.form.makerRetriesHint': 'Die maximale Anzahl von Wiederholungsversuchen, wenn eine ausstehende Order nicht ausgeführt wird', + 'ai-trading-assistant.form.fallbackToMarket': 'Die ausstehende Bestellung schlägt fehl und der Marktpreis wird herabgestuft.', + 'ai-trading-assistant.form.fallbackToMarketHint': 'Wenn die ausstehende Eröffnungs-/Schließorder nicht abgeschlossen wurde, wird angegeben, ob sie auf eine Marktorder herabgestuft werden soll, um sicherzustellen, dass die Transaktion abgeschlossen wird.', + 'ai-trading-assistant.form.marginMode': 'Margin-Modus', + 'ai-trading-assistant.form.marginModeCross': 'Volles Lager', + 'ai-trading-assistant.form.marginModeIsolated': 'Isolierte Lage', + 'ai-analysis.title': 'Quantenhandelsmaschine', + 'ai-analysis.system.online': 'online', + 'ai-analysis.system.agents': 'Agent', + 'ai-analysis.system.active': 'aktiv', + 'ai-analysis.system.stage': 'Bühne', + 'ai-analysis.panel.roster': 'Agentenaufstellung', + 'ai-analysis.panel.thinking': 'Denken...', + 'ai-analysis.panel.done': 'Komplett', + 'ai-analysis.panel.standby': 'Standby', + 'ai-analysis.input.title': 'Quantenhandelsmaschine', + 'ai-analysis.input.placeholder': 'Zielwert auswählen (z. B. BTC/USDT)', + 'ai-analysis.input.watchlist': 'Optionale Bestände', + 'ai-analysis.input.start': 'Analyse starten', + 'ai-analysis.input.recent': 'Letzte Aufgaben:', + 'ai-analysis.vis.stage': 'Bühne', + 'ai-analysis.vis.processing': 'Verarbeitung', + 'ai-analysis.result.complete': 'Analyse abgeschlossen', + 'ai-analysis.result.signal': 'Schlusssignal', + 'ai-analysis.result.confidence': 'Vertrauen:', + 'ai-analysis.result.new': 'neue Analyse', + 'ai-analysis.result.full': 'Vollständigen Bericht ansehen', + 'ai-analysis.logs.title': 'Systemprotokoll', + 'ai-analysis.modal.title': 'vertraulicher Bericht', + 'ai-analysis.modal.fundamental': 'Fundamentalanalyse', + 'ai-analysis.modal.technical': 'Technische Analyse', + 'ai-analysis.modal.sentiment': 'Emotionale Analyse', + 'ai-analysis.modal.risk': 'Risikobewertung', + 'ai-analysis.stage.idle': 'Standby', + 'ai-analysis.stage.1': 'Phase Eins: Mehrdimensionale Analyse', + 'ai-analysis.stage.2': 'Phase Zwei: Lang-Kurz-Debatte', + 'ai-analysis.stage.3': 'Phase drei: Strategische Planung', + 'ai-analysis.stage.4': 'Die vierte Stufe: Überprüfung der Risikokontrolle', + 'ai-analysis.stage.complete': 'Komplett', + 'ai-analysis.agent.investment_director': 'Investmentdirektor', + 'ai-analysis.agent.role.investment_director': 'Umfassende Analyse und abschließendes Fazit', + 'ai-analysis.agent.market': 'Marktanalyst', + 'ai-analysis.agent.role.market': 'Technologie- und Marktdaten', + 'ai-analysis.agent.fundamental': 'Fundamentalanalytiker', + 'ai-analysis.agent.role.fundamental': 'Finanzen und Bewertung', + 'ai-analysis.agent.technical': 'Technischer Analyst', + 'ai-analysis.agent.role.technical': 'Technische Indikatoren und Diagramme', + 'ai-analysis.agent.news': 'Nachrichtenanalyst', + 'ai-analysis.agent.role.news': 'Globaler Nachrichtenfilter', + 'ai-analysis.agent.sentiment': 'Stimmungsanalytiker', + 'ai-analysis.agent.role.sentiment': 'Sozial und emotional', + 'ai-analysis.agent.risk': 'Risikoanalytiker', + 'ai-analysis.agent.role.risk': 'Grundlegender Risikocheck', + 'ai-analysis.agent.bull': 'bullischer Forscher', + 'ai-analysis.agent.role.bull': 'Wachstumskatalysator-Bergbau', + 'ai-analysis.agent.bear': 'bärischer Forscher', + 'ai-analysis.agent.role.bear': 'Risiko- und Fehlersuche', + 'ai-analysis.agent.manager': 'Forschungsmanager', + 'ai-analysis.agent.role.manager': 'Debattenmoderator', + 'ai-analysis.agent.trader': 'Händler', + 'ai-analysis.agent.role.trader': 'Führungsstratege', + 'ai-analysis.agent.risky': 'aktivistischer Analytiker', + 'ai-analysis.agent.role.risky': 'aggressive Strategie', + 'ai-analysis.agent.neutral': 'Bilanzanalytiker', + 'ai-analysis.agent.role.neutral': 'Ausgleichsstrategie', + 'ai-analysis.agent.safe': 'konservativer Analytiker', + 'ai-analysis.agent.role.safe': 'konservative Strategie', + 'ai-analysis.agent.cro': 'Risikokontrollmanager (CRO)', + 'ai-analysis.agent.role.cro': 'letzte Entscheidungsbefugnis', + 'ai-analysis.script.market': 'OHLCV-Daten werden von wichtigen Börsen abgerufen...', + 'ai-analysis.script.fundamental': 'Quartalsfinanzberichte abrufen...', + 'ai-analysis.script.technical': 'Analyse technischer Indikatoren und Chartmuster...', + 'ai-analysis.script.news': 'Globale Finanznachrichten scannen...', + 'ai-analysis.script.sentiment': 'Analyse von Social-Media-Trends...', + 'ai-analysis.script.risk': 'Berechnung der historischen Volatilität...', + 'invite.inviteLink': 'Einladungslink', + 'invite.copy': 'Link kopieren', + 'invite.copySuccess': 'Erfolgreich kopieren!', + 'invite.copyFailed': 'Der Kopiervorgang ist fehlgeschlagen. Bitte manuell kopieren', + 'invite.noInviteLink': 'Der Einladungslink wurde nicht generiert', + 'invite.totalInvites': 'Kumulierte Einladungen', + 'invite.totalReward': 'Kumulative Belohnungen', + 'invite.rules': 'Einladungsregeln', + 'invite.rule1': 'Jedes Mal, wenn Sie einen Freund erfolgreich zur Registrierung einladen, erhalten Sie eine Belohnung', + 'invite.rule2': 'Wenn der eingeladene Freund die erste Transaktion abschließt, erhalten Sie zusätzliche Belohnungen', + 'invite.rule3': 'Einladungsprämien werden direkt an Ihr Konto gesendet', + 'invite.inviteList': 'Einladungsliste', + 'invite.tasks': 'Missionszentrum', + 'invite.inviteeName': 'Eingeladen', + 'invite.inviteTime': 'Einladungszeit', + 'invite.status': 'Status', + 'invite.reward': 'Belohnung', + 'invite.active': 'aktiv', + 'invite.inactive': 'Nicht aktiviert', + 'invite.completed': 'Abgeschlossen', + 'invite.claimed': 'Erhalten', + 'invite.pending': 'Zu vervollständigen', + 'invite.goToTask': 'zu vervollständigen', + 'invite.claimReward': 'Belohnungen einfordern', + 'invite.verify': 'Verifizierung abgeschlossen', + 'invite.verifySuccess': 'Verifizierung erfolgreich! Aufgabe abgeschlossen', + 'invite.verifyNotCompleted': 'Die Aufgabe wurde noch nicht abgeschlossen. Bitte schließen Sie die Aufgabe zuerst ab', + 'invite.verifyFailed': 'Die Verifizierung ist fehlgeschlagen. Bitte versuchen Sie es später noch einmal', + 'invite.claimSuccess': '{reward} QDT erfolgreich erhalten!', + 'invite.claimFailed': 'Fehler beim Sammeln. Bitte versuchen Sie es später noch einmal', + 'invite.totalRecords': 'Insgesamt {total} Datensätze', + 'invite.task.twitter.title': 'Retweeten an X (Twitter)', + 'invite.task.twitter.desc': 'Teilen Sie unsere offiziellen Tweets mit Ihrem X-Konto (Twitter).', + 'invite.task.youtube.title': 'Folgen Sie unserem YouTube-Kanal', + 'invite.task.youtube.desc': 'Abonnieren und folgen Sie unserem offiziellen YouTube-Kanal', + 'invite.task.telegram.title': 'Treten Sie der Telegram-Gruppe bei', + 'invite.task.telegram.desc': 'Treten Sie unserer offiziellen Telegram-Community-Gruppe bei', + 'invite.task.discord.title': 'Treten Sie einem Discord-Server bei', + 'invite.task.discord.desc': 'Treten Sie unserem Discord-Community-Server bei', + 'message': '-', + 'layouts.usermenu.dialog.title': 'Informationen', + 'layouts.usermenu.dialog.content': 'Möchten Sie sich wirklich abmelden?', + 'layouts.userLayout.title': 'Finden Sie die Wahrheit in der Unsicherheit', + // Auto-filled missing keys from en-US (fallback to English) + 'dashboard.indicator.boundary.message': 'Note: The indicator script only computes plots + buy/sell signals. Position sizing, risk, scaling, fees/slippage belong to execution config.', + 'dashboard.indicator.boundary.indicatorRule': "Keep the script to buy/sell only (and set df['buy']/df['sell']). Do NOT implement position management, TP/SL, scaling in the script.", + 'dashboard.indicator.boundary.backtestRule': 'Rule: If a bar has a main signal (buy/sell → open/close/reverse), scaling in/out is skipped on the same bar.', + 'dashboard.indicator.guide.title': 'Python Indicator & Strategy Development Guide', + 'dashboard.indicator.guide.intro': 'This platform supports writing custom technical indicators and trading signals using Python. The system includes built-in pandas and numpy data analysis libraries. You can use standard DataFrame operations to process K-line data and draw charts or mark buy/sell signals.', + 'dashboard.indicator.guide.section1.title': '1. Runtime Environment & Predefined Variables', + 'dashboard.indicator.guide.section1.env': 'Your code runs in a browser-based Python environment (Pyodide).', + 'dashboard.indicator.guide.section1.libs': 'Pre-installed Libraries', + 'dashboard.indicator.guide.section1.libsDesc': 'The following libraries are imported by default, no need to import again:', + 'dashboard.indicator.guide.section1.libsList': 'pandas (pd), numpy (np), math, json', + 'dashboard.indicator.guide.section1.data': 'Input Data: df', + 'dashboard.indicator.guide.section1.dataDesc': 'The system automatically converts current K-line data into a Pandas DataFrame object, variable name is df.', + 'dashboard.indicator.guide.section1.dataColumns': 'df contains the following columns (all float type):', + 'dashboard.indicator.guide.section1.dataColumnsList': 'time (timestamp, seconds), open, high, low, close, volume', + 'dashboard.indicator.guide.section1.dataNote': 'Data index is arranged from old to new (0 is the earliest data, last row is the latest data).', + 'dashboard.indicator.guide.section1.dataIndex': 'Important: df index is timestamp (DatetimeIndex), please preserve the index, avoid using .values which loses the index.', + 'dashboard.indicator.guide.section2.title': '2. Parameter Definition', + 'dashboard.indicator.guide.section2.desc': 'You can define variables as parameters at the beginning of your code. The system will try to parse statements like variable = value as adjustable parameters.', + 'dashboard.indicator.guide.section2.example': 'Parameter Example', + 'dashboard.indicator.guide.section2.exampleCode': 'span = 20 or multiplier = 2.0', + 'dashboard.indicator.guide.section3.title': '3. Backtest Signal Setup (Important!)', + 'dashboard.indicator.guide.section3.desc': 'If you want your indicator to support backtesting, you must set the following two columns in your code:', + 'dashboard.indicator.guide.section3.backtestRequired': 'Required for Backtesting:', + 'dashboard.indicator.guide.section3.backtestBuySignal': "df['buy_signal'] - Boolean Series, True indicates buy signal", + 'dashboard.indicator.guide.section3.backtestSellSignal': "df['sell_signal'] - Boolean Series, True indicates sell signal", + 'dashboard.indicator.guide.section3.backtestExample': 'Example:', + 'dashboard.indicator.guide.section3.backtestExampleCode': "df['buy_signal'] = condition_buy
    df['sell_signal'] = condition_sell", + 'dashboard.indicator.guide.section3.backtestNote': 'Note: Even if you only set output.signals for chart display, you must set these two columns to enable backtesting.', + 'dashboard.indicator.guide.section4.title': '4. Output Requirements: output', + 'dashboard.indicator.guide.section4.desc': 'At the end of code execution, you must define a dictionary named output to tell the chart how to display results.', + 'dashboard.indicator.guide.section4.fields': 'The output dictionary contains the following fields:', + 'dashboard.indicator.guide.section4.plots': 'plots - For drawing lines (e.g., moving averages, RSI)', + 'dashboard.indicator.guide.section4.signals': 'signals - For marking buy/sell signals (e.g., arrows, labels)', + 'dashboard.indicator.guide.section4.name': 'name - Indicator name (optional)', + 'dashboard.indicator.guide.section4.calculatedVars': 'calculatedVars - Calculated variables (optional, for displaying additional information)', + 'dashboard.indicator.guide.section5.title': '5. Plot Configuration (Plots)', + 'dashboard.indicator.guide.section5.desc': 'Each object in the plots list represents a line.', + 'dashboard.indicator.guide.section5.table.field': 'Field', + 'dashboard.indicator.guide.section5.table.type': 'Type', + 'dashboard.indicator.guide.section5.table.required': 'Required', + 'dashboard.indicator.guide.section5.table.requiredYes': 'Yes', + 'dashboard.indicator.guide.section5.table.requiredNo': 'No', + 'dashboard.indicator.guide.section5.table.desc': 'Description', + 'dashboard.indicator.guide.section5.table.name': 'The name of the indicator line, displayed in the legend', + 'dashboard.indicator.guide.section5.table.data': 'Data list, length must match the number of rows in df. Recommended to use series.tolist()', + 'dashboard.indicator.guide.section5.table.color': "Line color (Hex format, e.g. '#ff0000'). If not filled, the system will automatically assign", + 'dashboard.indicator.guide.section5.table.typeDesc': "Chart type, default is 'line'", + 'dashboard.indicator.guide.section5.table.overlay': 'Important. True means display on main chart (with K-line), False means display on sub-chart (independent area)', + 'dashboard.indicator.guide.section5.example': 'Example: Draw Simple Moving Average (SMA)', + 'dashboard.indicator.guide.section5.exampleCalc': 'Calculate SMA', + 'dashboard.indicator.guide.section5.exampleOutput': 'Construct Output', + 'dashboard.indicator.guide.section5.exampleOverlay': 'Display on main chart', + 'dashboard.indicator.guide.section6.title': '6. Signal Markers (Signals)', + 'dashboard.indicator.guide.section6.desc': "signals are used to mark buy/sell points on specific K-lines through icons or text. Note: This is only for chart display. The backtest system uses df['buy_signal'] and df['sell_signal'].", + 'dashboard.indicator.guide.section6.table.type': "Signal type: 'buy' (buy, marked below K-line) or 'sell' (sell, marked above K-line)", + 'dashboard.indicator.guide.section6.table.data': 'Signal data list. Fill price value at signal positions, fill None (or np.nan) at non-signal positions', + 'dashboard.indicator.guide.section6.table.text': 'Text displayed on the label (e.g. "B", "S", "Buy", "Sell")', + 'dashboard.indicator.guide.section6.table.color': 'Marker color', + 'dashboard.indicator.guide.section6.hint': 'The system automatically handles positioning.', + 'dashboard.indicator.guide.section6.buy': 'Markers will be automatically positioned below the Low of the current K-line', + 'dashboard.indicator.guide.section6.sell': 'Markers will be automatically positioned above the High of the current K-line', + 'dashboard.indicator.guide.section6.example': 'Example: Draw Buy/Sell Signals', + 'dashboard.indicator.guide.section6.exampleInit': 'Initialize signal list, fill all with None', + 'dashboard.indicator.guide.section6.exampleLogic': 'Simple logic: mark buy when close price is greater than open price', + 'dashboard.indicator.guide.section6.exampleNote': 'In actual logic, please use loops or vectorized operations', + 'dashboard.indicator.guide.section6.examplePrice': 'Record price', + 'dashboard.indicator.guide.section6.exampleText': 'Buy', + 'dashboard.indicator.guide.section7.title': '7. Complete Code Examples', + 'dashboard.indicator.guide.section7.exampleA': 'Example A: Dual EMA Strategy (Supports Backtesting)', + 'dashboard.indicator.guide.section7.exampleACode': "# 1. Calculate indicators
    df['ema_fast'] = df['close'].ewm(span=5, adjust=False).mean()
    df['ema_slow'] = df['close'].ewm(span=13, adjust=False).mean()

    # 2. Calculate crossover signals
    cross_up = (df['ema_fast'] > df['ema_slow']) & (df['ema_fast'].shift(1) <= df['ema_slow'].shift(1))
    cross_down = (df['ema_fast'] < df['ema_slow']) & (df['ema_fast'].shift(1) >= df['ema_slow'].shift(1))

    # 3. 【Important】Set NEW backtest signal columns (bool)
    df['open_long'] = cross_up
    df['close_long'] = cross_down
    df['open_short'] = cross_down
    df['close_short'] = cross_up

    # 4. Generate signal data for chart display
    buy_signals = [df['low'].iloc[i] * 0.995 if cross_up.iloc[i] else None for i in range(len(df))]
    sell_signals = [df['high'].iloc[i] * 1.005 if cross_down.iloc[i] else None for i in range(len(df))]

    # 5. Build output
    output = {
    \"name\": \"Dual EMA Strategy\",
    \"plots\": [
    {\"name\": \"EMA 5\", \"data\": df['ema_fast'].tolist(), \"color\": \"#FF6B6B\", \"overlay\": True},
    {\"name\": \"EMA 13\", \"data\": df['ema_slow'].tolist(), \"color\": \"#4ECDC4\", \"overlay\": True}
    ],
    \"signals\": [
    {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},
    {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}
    ]
    }", + 'dashboard.indicator.guide.section7.exampleB': 'Example B: Bollinger Bands - Chart Display Only', + 'dashboard.indicator.guide.section7.exampleBCode': "# Calculate Bollinger Bands (chart display only, no backtesting)
    length = 20
    mult = 2.0
    df['sma'] = df['close'].rolling(length).mean()
    df['std'] = df['close'].rolling(length).std()
    df['upper'] = df['sma'] + mult * df['std']
    df['lower'] = df['sma'] - mult * df['std']

    output = {
    \"name\": \"Bollinger Bands\",
    \"plots\": [
    {\"name\": \"Upper\", \"data\": df['upper'].tolist(), \"color\": \"#FF5252\", \"overlay\": True},
    {\"name\": \"Middle\", \"data\": df['sma'].tolist(), \"color\": \"#2196F3\", \"overlay\": True},
    {\"name\": \"Lower\", \"data\": df['lower'].tolist(), \"color\": \"#00E676\", \"overlay\": True}
    ]
    }", + 'dashboard.indicator.guide.section7.exampleC': 'Example C: RSI Strategy (Supports Backtesting)', + 'dashboard.indicator.guide.section7.exampleCCode': "# Calculate RSI
    delta = df['close'].diff()
    up = delta.clip(lower=0)
    down = -1 * delta.clip(upper=0)
    ema_up = up.ewm(com=13, adjust=False).mean()
    ema_down = down.ewm(com=13, adjust=False).mean()
    rs = ema_up / ema_down
    df['rsi'] = 100 - (100 / (1 + rs))

    # Generate trading signals
    buy_condition = df['rsi'] < 30 # Oversold buy
    sell_condition = df['rsi'] > 70 # Overbought sell

    # 【Important】Set NEW backtest signal columns (bool)
    df['open_long'] = buy_condition
    df['close_long'] = sell_condition
    df['open_short'] = sell_condition
    df['close_short'] = buy_condition

    # Generate chart signals
    buy_signals = [df['low'].iloc[i] * 0.995 if buy_condition.iloc[i] else None for i in range(len(df))]
    sell_signals = [df['high'].iloc[i] * 1.005 if sell_condition.iloc[i] else None for i in range(len(df))]

    output = {
    \"name\": \"RSI Strategy\",
    \"plots\": [
    {\"name\": \"RSI\", \"data\": df['rsi'].tolist(), \"color\": \"#9C27B0\", \"overlay\": False},
    {\"name\": \"Overbought\", \"data\": [70] * len(df), \"color\": \"#FF5252\", \"style\": \"dashed\", \"overlay\": False},
    {\"name\": \"Oversold\", \"data\": [30] * len(df), \"color\": \"#00E676\", \"style\": \"dashed\", \"overlay\": False}
    ],
    \"signals\": [
    {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},
    {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}
    ]
    }", + 'dashboard.indicator.guide.section6.param': 'Parameters', + 'dashboard.indicator.guide.section6.calc': 'Calculate', + 'dashboard.indicator.guide.section6.output': 'Output', + 'dashboard.indicator.guide.section6.generate': 'Generate signal data (initialize with None)', + 'dashboard.indicator.guide.section6.subchart': 'Display on sub-chart', + 'dashboard.indicator.guide.section8.title': '8. Frequently Asked Questions (FAQ)', + 'dashboard.indicator.guide.section8.q1': "Q: Why can't my indicator be backtested?", + 'dashboard.indicator.guide.section8.a1': "A: The backtest system requires NEW signal columns: df['open_long'], df['close_long'], df['open_short'], df['close_short'] (boolean). Even if you set output.signals for chart display, you must set these columns to enable backtesting.", + 'dashboard.indicator.guide.section8.q2': 'Q: Data length mismatch?', + 'dashboard.indicator.guide.section8.a2': "A: The data list in plots and signals must have exactly the same length as the K-line data. If you use df['xxx'].tolist(), there is usually no problem.", + 'dashboard.indicator.guide.section8.q3': 'Q: How to handle NaN (empty values)?', + 'dashboard.indicator.guide.section8.a3': "A: Pandas calculations (such as rolling mean) will produce NaN at the beginning. The system will automatically convert NaN to null for chart rendering. You don't need to manually clean it, but if you need specific logic, you can use df.fillna(0) or other methods.", + 'dashboard.indicator.guide.section8.q4': 'Q: Index mismatch causing all signals to be NaN?', + 'dashboard.indicator.guide.section8.a4': "A: Please ensure you preserve the original df index (timestamp). Avoid using .values to create new Series. Use columns directly like df['close'] for operations.", + 'dashboard.indicator.guide.section8.q5': 'Q: Can I use third-party network libraries?', + 'dashboard.indicator.guide.section8.a5': 'A: No. The runtime environment is a browser sandbox and cannot use libraries like requests to request external APIs, nor can it install additional pip packages. Limited to built-in libraries such as pandas, numpy, math, json.', + 'dashboard.indicator.guide.section8.q6': 'Q: How to debug code?', + 'dashboard.indicator.guide.section8.a6': 'A: If the chart does not display indicators, it is usually because the Python code has an error. Please check for syntax errors or array index out of bounds. It is recommended to debug the algorithm logic in a local Python environment first, then copy it in.', + 'dashboard.indicator.guide.section8.q7': 'Q: Backtest shows 0 signals?', + 'dashboard.indicator.guide.section8.a7': "A: Please check the data type of df['open_long']/df['close_long']/df['open_short']/df['close_short']. They should be boolean (True/False), not numeric. Use condition.fillna(False).astype(bool) to ensure correct type.", + 'dashboard.indicator.backtest.commissionHint': 'Charged on notional value (incl. leverage) per trade; both entry & exit are deducted from balance.', + 'dashboard.indicator.backtest.historyTitle': 'Backtest History', + 'dashboard.indicator.backtest.historyRefresh': 'Refresh', + 'dashboard.indicator.backtest.historyView': 'View', + 'dashboard.indicator.backtest.historyNoData': 'No backtest history', + 'dashboard.indicator.backtest.historyUseCurrent': 'Only current symbol/timeframe', + 'dashboard.indicator.backtest.historyFilterSymbol': 'Symbol', + 'dashboard.indicator.backtest.historyFilterTimeframe': 'Timeframe', + 'dashboard.indicator.backtest.historyApply': 'Apply filters', + 'dashboard.indicator.backtest.historyAIAnalyze': 'AI Analyze', + 'dashboard.indicator.backtest.historyAIAnalyzeTitle': 'AI Suggestions', + 'dashboard.indicator.backtest.historyNoAIResult': 'No AI analysis result', + 'dashboard.indicator.backtest.historyRunId': 'Run ID', + 'dashboard.indicator.backtest.historyCreatedAt': 'Time', + 'dashboard.indicator.backtest.historyRange': 'Range', + 'dashboard.indicator.backtest.historyStatus': 'Status', + 'dashboard.indicator.backtest.historyStatusSuccess': 'Success', + 'dashboard.indicator.backtest.historyStatusFailed': 'Failed', + 'dashboard.indicator.backtest.historyActions': 'Actions', + 'dashboard.indicator.backtest.hint.entryPctMax': 'Max entry: {maxPct}% (reserve budget for future scale-ins)', + 'trading-assistant.exchange.ipWhitelistTip': 'Please add the following IPs to the whitelist in your exchange API settings:', + 'signal-robot.title': 'Signal Robot Console', + 'signal-robot.createBot': 'Create New Bot', + 'signal-robot.search.nameOrSymbol': 'Name/Symbol', + 'signal-robot.search.placeholder': 'Search bot name or symbol', + 'signal-robot.search.status': 'Status', + 'signal-robot.search.statusAll': 'All', + 'signal-robot.search.statusRunning': 'Running', + 'signal-robot.search.statusPaused': 'Paused', + 'signal-robot.search.query': 'Query', + 'signal-robot.search.reset': 'Reset', + 'signal-robot.table.botName': 'Bot Name', + 'signal-robot.table.symbolTimeframe': 'Symbol/Timeframe', + 'signal-robot.table.triggerStrategy': 'Trigger Strategy', + 'signal-robot.table.notificationChannels': 'Notification Channels', + 'signal-robot.table.status': 'Status', + 'signal-robot.table.action': 'Action', + 'signal-robot.table.triggerConditions': '{count} trigger conditions', + 'signal-robot.table.monitoring': 'Monitoring', + 'signal-robot.table.paused': 'Paused', + 'signal-robot.table.edit': 'Edit', + 'signal-robot.table.pause': 'Pause', + 'signal-robot.table.start': 'Start', + 'signal-robot.table.delete': 'Delete', + 'signal-robot.table.deleteConfirm': 'Are you sure you want to delete this bot?', + 'signal-robot.table.deleteSuccess': 'Deleted successfully', + 'signal-robot.table.startSuccess': 'Bot started', + 'signal-robot.table.pauseSuccess': 'Bot paused', + 'signal-robot.channel.telegram': 'Telegram', + 'signal-robot.channel.discord': 'Discord', + 'signal-robot.channel.email': 'Email', + 'signal-robot.channel.webhook': 'Webhook', + 'signal-robot.channel.browser': 'Browser Push', + 'signal-robot.modal.createTitle': 'Create New Signal Robot', + 'signal-robot.modal.editTitle': 'Edit Signal Robot', + 'signal-robot.modal.step.basic': 'Basic Settings', + 'signal-robot.modal.step.entry': 'Entry Strategy', + 'signal-robot.modal.step.position': 'Position Management', + 'signal-robot.modal.step.risk': 'Risk Control', + 'signal-robot.modal.step.notify': 'Notification Config', + 'signal-robot.modal.prev': 'Previous', + 'signal-robot.modal.next': 'Next', + 'signal-robot.modal.preview': 'Preview Backtest', + 'signal-robot.modal.complete': 'Complete & Run', + 'signal-robot.modal.saveSuccess': 'Saved successfully', + 'signal-robot.modal.previewDeveloping': 'Preview feature is under development...', + 'signal-robot.modal.basicInfoRequired': 'Please complete basic information', + 'signal-robot.basic.alert': 'Set basic information for the bot', + 'signal-robot.basic.title': 'Basic Information', + 'signal-robot.basic.botName': 'Bot Name', + 'signal-robot.basic.botNamePlaceholder': 'e.g.: BTC SuperTrend Strategy', + 'signal-robot.basic.symbol': 'Trading Pair', + 'signal-robot.basic.symbolPlaceholder': 'Select from watchlist', + 'signal-robot.basic.noWatchlist': 'No watchlist items, please add in market page first', + 'signal-robot.basic.timeframe': 'K-Line Period', + 'signal-robot.basic.timeframe15m': '15 Minutes', + 'signal-robot.basic.timeframe30m': '30 Minutes', + 'signal-robot.basic.timeframe1h': '1 Hour', + 'signal-robot.basic.timeframe4h': '4 Hours', + 'signal-robot.basic.timeframe1d': '1 Day', + 'signal-robot.basic.autoNameSuggestion': '{symbol} Signal Robot', + 'signal-robot.entry.alert': 'Trigger entry signal when all conditions below are met', + 'signal-robot.entry.condition': 'Condition {index}', + 'signal-robot.entry.indicator': 'Indicator', + 'signal-robot.entry.indicatorSupertrend': 'SuperTrend', + 'signal-robot.entry.indicatorEma': 'EMA (Exponential Moving Average)', + 'signal-robot.entry.indicatorRsi': 'RSI (Relative Strength Index)', + 'signal-robot.entry.indicatorMacd': 'MACD', + 'signal-robot.entry.indicatorBollinger': 'Bollinger Bands', + 'signal-robot.entry.atrPeriod': 'ATR Period', + 'signal-robot.entry.multiplier': 'Multiplier', + 'signal-robot.entry.triggerSignal': 'Trigger Signal', + 'signal-robot.entry.signalTrendBullish': 'Trend Bullish', + 'signal-robot.entry.signalTrendBearish': 'Trend Bearish', + 'signal-robot.entry.signalIsUptrend': 'Is Uptrend', + 'signal-robot.entry.signalIsDowntrend': 'Is Downtrend', + 'signal-robot.entry.period': 'Period', + 'signal-robot.entry.compareCondition': 'Compare Condition', + 'signal-robot.entry.priceAbove': 'Price > EMA (Price Above)', + 'signal-robot.entry.priceBelow': 'Price < EMA (Price Below)', + 'signal-robot.entry.crossUp': 'Price Crosses Above EMA (Golden Cross)', + 'signal-robot.entry.crossDown': 'Price Crosses Below EMA (Death Cross)', + 'signal-robot.entry.compare': 'Compare', + 'signal-robot.entry.greaterThan': 'Greater Than', + 'signal-robot.entry.lessThan': 'Less Than', + 'signal-robot.entry.crossUpShort': 'Cross Up', + 'signal-robot.entry.crossDownShort': 'Cross Down', + 'signal-robot.entry.threshold': 'Threshold', + 'signal-robot.entry.selectIndicator': 'Please select an indicator to configure parameters', + 'signal-robot.entry.addCondition': 'Add Condition', + 'signal-robot.position.alert': 'Configure initial position size, leverage and pyramiding rules', + 'signal-robot.position.basicTitle': 'Basic Position Management', + 'signal-robot.position.initialSize': 'Initial Size (Capital %)', + 'signal-robot.position.initialSizeHint': 'Recommended 5% - 20%', + 'signal-robot.position.leverage': 'Leverage', + 'signal-robot.position.leverageHint': 'Please ensure your exchange account supports this leverage', + 'signal-robot.position.maxPyramiding': 'Max Pyramiding Times', + 'signal-robot.position.maxPyramidingHint': '0 means no pyramiding', + 'signal-robot.position.pyramidingTitle': 'Pyramiding Rules', + 'signal-robot.position.pyramidingEnabled': 'Enabled', + 'signal-robot.position.pyramidingDisabled': 'Disabled', + 'signal-robot.position.pyramidingCondition': 'Pyramiding Trigger Condition', + 'signal-robot.position.priceRisePct': 'Price Rises (Long) / Falls (Short)', + 'signal-robot.position.profitPct': 'Profit Reaches', + 'signal-robot.position.triggerThreshold': 'Trigger Threshold (%)', + 'signal-robot.position.triggerThresholdHint': 'e.g. 3%: add position once every 3% rise', + 'signal-robot.position.addSize': 'Single Add Size (Capital %)', + 'signal-robot.position.pyramidingDisabledHint': 'Pyramiding disabled, only initial position will be executed', + 'signal-robot.risk.alert': 'Configure take profit, stop loss and exit rules', + 'signal-robot.risk.stopLossTitle': 'Stop Loss', + 'signal-robot.risk.stopLossEnabled': 'Enabled', + 'signal-robot.risk.stopLossDisabled': 'Disabled', + 'signal-robot.risk.stopLossType': 'Stop Loss Type', + 'signal-robot.risk.stopLossFixedPct': 'Fixed Percentage', + 'signal-robot.risk.stopLossAtrMultiplier': 'ATR Multiplier', + 'signal-robot.risk.stopLossValue': 'Value', + 'signal-robot.risk.stopLossFixedHint': 'e.g. 2%: stop loss when loss reaches 2%', + 'signal-robot.risk.stopLossAtrHint': 'e.g. 2.0: stop loss when price touches ATR*2.0', + 'signal-robot.risk.stopLossDisabledHint': 'Stop loss disabled (high risk)', + 'signal-robot.risk.trailingStopTitle': 'Trailing Stop', + 'signal-robot.risk.activationProfit': 'Activation Profit (%)', + 'signal-robot.risk.activationProfitHint': 'Start trailing when profit reaches this level', + 'signal-robot.risk.callbackPct': 'Callback (%)', + 'signal-robot.risk.callbackPctHint': 'Trigger take profit when price falls from peak by this percentage', + 'signal-robot.risk.trailingStopDisabledHint': 'Trailing stop disabled', + 'signal-robot.risk.otherExitTitle': 'Other Exit Rules', + 'signal-robot.risk.signalExit': 'Allow Signal Exit', + 'signal-robot.risk.signalExitHint': 'Close position immediately when reverse signal appears (e.g. close long when short signal appears)', + 'signal-robot.notify.alert': 'Configure notification methods when signal is triggered', + 'signal-robot.notify.channelTitle': 'Notification Channels', + 'signal-robot.notify.discordWebhook': 'Discord Webhook', + 'signal-robot.notify.genericWebhook': 'Generic Webhook', + 'signal-robot.notify.webhookUrl': 'Webhook URL', + 'signal-robot.notify.webhookUrlPlaceholder': 'https://example.com/webhook', + 'signal-robot.notify.discordUrl': 'Discord URL', + 'signal-robot.notify.discordUrlPlaceholder': 'https://discord.com/api/webhooks/...', + 'signal-robot.operator.greaterThan': 'Greater Than', + 'signal-robot.operator.lessThan': 'Less Than', + 'signal-robot.operator.equal': 'Equal', + 'signal-robot.operator.goldenCross': 'Golden Cross', + 'signal-robot.operator.deathCross': 'Death Cross', + 'signal-robot.operator.increaseBy': 'Increase By', + 'signal-robot.operator.decreaseBy': 'Decrease By', + 'signal-robot.indicator.price': 'Price', + 'signal-robot.indicator.rsi': 'RSI', + 'signal-robot.indicator.kdjK': 'KDJ(K)', + 'signal-robot.indicator.kdjJ': 'KDJ(J)', + 'signal-robot.indicator.macd': 'MACD', + 'signal-robot.indicator.macdHist': 'MACD Histogram', + 'signal-robot.indicator.bollingerUp': 'Bollinger Upper', + 'signal-robot.indicator.bollingerLow': 'Bollinger Lower', + 'signal-robot.indicator.volume': 'Volume', + 'signal-robot.indicator.changePct1h': '1H Change %', + 'signal-robot.indicator.changePct24h': '24H Change %', + 'signal-robot.indicator.signalLine': 'Signal Line' +} + +export default { + ...components, + ...locale +} diff --git a/quantdinger_vue/src/locales/lang/en-US.js b/quantdinger_vue/src/locales/lang/en-US.js new file mode 100644 index 0000000..51f45f5 --- /dev/null +++ b/quantdinger_vue/src/locales/lang/en-US.js @@ -0,0 +1,1824 @@ +import antdEnUS from 'ant-design-vue/es/locale-provider/en_US' +import momentEU from 'moment/locale/eu' + +const components = { + antLocale: antdEnUS, + momentName: 'eu', + momentLocale: momentEU +} + +const locale = { + 'submit': 'Submit', + 'save': 'Save', + 'submit.ok': 'Submit successfully', + 'save.ok': 'Saved successfully', + 'menu.welcome': 'Welcome', + 'menu.home': 'Home', + 'menu.dashboard': 'Dashboard', + 'menu.dashboard.analysis': 'AI Analysis', + 'menu.dashboard.indicator': 'Indicator Analysis', + 'menu.dashboard.community': 'Indicator Community', + 'menu.dashboard.tradingAssistant': 'Trading Assistant', + 'menu.dashboard.aiTradingAssistant': 'AI Trading Assistant', + 'menu.dashboard.signalRobot': 'Signal Robot', + 'menu.dashboard.monitor': 'Monitor', + 'menu.dashboard.workplace': 'Workplace', + 'menu.form': 'Form', + 'menu.form.basic-form': 'Basic Form', + 'menu.form.step-form': 'Step Form', + 'menu.form.step-form.info': 'Step Form(write transfer information)', + 'menu.form.step-form.confirm': 'Step Form(confirm transfer information)', + 'menu.form.step-form.result': 'Step Form(finished)', + 'menu.form.advanced-form': 'Advanced Form', + 'menu.list': 'List', + 'menu.list.table-list': 'Search Table', + 'menu.list.basic-list': 'Basic List', + 'menu.list.card-list': 'Card List', + 'menu.list.search-list': 'Search List', + 'menu.list.search-list.articles': 'Search List(articles)', + 'menu.list.search-list.projects': 'Search List(projects)', + 'menu.list.search-list.applications': 'Search List(applications)', + 'menu.profile': 'Profile', + 'menu.profile.basic': 'Basic Profile', + 'menu.profile.advanced': 'Advanced Profile', + 'menu.result': 'Result', + 'menu.result.success': 'Success', + 'menu.result.fail': 'Fail', + 'menu.exception': 'Exception', + 'menu.exception.not-permission': '403', + 'menu.exception.not-find': '404', + 'menu.exception.server-error': '500', + 'menu.exception.trigger': 'Trigger', + 'menu.account': 'Account', + 'menu.account.center': 'Account Center', + 'menu.account.settings': 'Account Settings', + 'menu.account.trigger': 'Trigger Error', + 'menu.account.logout': 'Logout', + 'menu.wallet': 'My Wallet', + 'menu.docs': 'Documentation', + 'menu.docs.detail': 'Doc Detail', + 'menu.header.refreshPage': 'Refresh Page', + 'menu.invite.friends': 'Invite Friends', + 'app.setting.pagestyle': 'Page Style Setting', + 'app.setting.pagestyle.light': 'Light Menu Style', + 'app.setting.pagestyle.dark': 'Dark Menu Style', + 'app.setting.pagestyle.realdark': 'RealDark style', + 'app.setting.themecolor': 'Theme Color', + 'app.setting.navigationmode': 'Navigation Mode', + 'app.setting.sidemenu.nav': 'Sidebar Navigation', + 'app.setting.topmenu.nav': 'Top Navigation', + 'app.setting.content-width': 'Content Width', + 'app.setting.content-width.tooltip': 'This setting is only effective for [Top Navigation]', + 'app.setting.content-width.fixed': 'Fixed', + 'app.setting.content-width.fluid': 'Fluid', + 'app.setting.fixedheader': 'Fixed Header', + 'app.setting.fixedheader.tooltip': 'Configurable when Header is fixed', + 'app.setting.autoHideHeader': 'Hide Header on Scroll', + 'app.setting.fixedsidebar': 'Fixed Sidebar Menu', + 'app.setting.sidemenu': 'Side Menu Layout', + 'app.setting.topmenu': 'Top Menu Layout', + 'app.setting.othersettings': 'Other Settings', + 'app.setting.weakmode': 'Weak Mode', + 'app.setting.multitab': 'Multi-Tab Mode', + 'app.setting.copy': 'Copy Setting', + 'app.setting.loading': 'Loading theme', + 'app.setting.copyinfo': 'copy success,please replace defaultSettings in src/config/defaultSettings.js', + 'app.setting.copy.success': 'Copy Success', + 'app.setting.copy.fail': 'Copy Failed', + 'app.setting.theme.switching': 'Switching theme...', + 'app.setting.production.hint': 'Setting panel shows in development environment only, please manually modify', + 'app.setting.themecolor.daybreak': 'Daybreak Blue (Default)', + 'app.setting.themecolor.dust': 'Dust Red', + 'app.setting.themecolor.volcano': 'Volcano', + 'app.setting.themecolor.sunset': 'Sunset Orange', + 'app.setting.themecolor.cyan': 'Cyan', + 'app.setting.themecolor.green': 'Polar Green', + 'app.setting.themecolor.geekblue': 'Geek Blue', + 'app.setting.themecolor.purple': 'Golden Purple', + 'app.setting.tooltip': 'Page Settings', + 'user.login.userName': 'userName', + 'user.login.password': 'password', + 'user.login.username.placeholder': 'Account: admin', + 'user.login.password.placeholder': 'password: admin or ant.design', + 'user.login.message-invalid-credentials': 'Login failed, please check your email and verification code', + 'user.login.message-invalid-verification-code': 'Invalid verification code', + 'user.login.tab-login-credentials': 'Credentials', + 'user.login.tab-login-email': 'Email', + 'user.login.tab-login-mobile': 'Mobile number', + 'user.login.captcha.placeholder': 'Please enter captcha', + 'user.login.mobile.placeholder': 'Mobile number', + 'user.login.mobile.verification-code.placeholder': 'Verification code', + 'user.login.email.placeholder': 'Please enter your email address', + 'user.login.email.verification-code.placeholder': 'Please enter verification code', + 'user.login.email.sending': 'Sending verification code...', + 'user.login.email.send-success-title': 'Notice', + 'user.login.email.send-success': 'Verification code sent successfully, please check your email', + 'user.login.sms.send-success': 'Verification code sent successfully, please check your SMS', + 'user.login.remember-me': 'Remember me', + 'user.login.forgot-password': 'Forgot your password?', + 'user.login.sign-in-with': 'Sign in with', + 'user.login.signup': 'Sign up', + 'user.login.login': 'Login', + 'user.register.register': 'Register', + 'user.register.email.placeholder': 'Email', + 'user.register.password.placeholder': 'Password ', + 'user.register.password.popover-message': 'Please enter at least 6 characters. Please do not use passwords that are easy to guess. ', + 'user.register.confirm-password.placeholder': 'Confirm password', + 'user.register.get-verification-code': 'Get code', + 'user.register.sign-in': 'Already have an account?', + 'user.register-result.msg': 'Account:registered at {email}', + 'user.register-result.activation-email': 'The activation email has been sent to your email address and is valid for 24 hours. Please log in to the email in time and click on the link in the email to activate the account.', + 'user.register-result.back-home': 'Back to home', + 'user.register-result.view-mailbox': 'View mailbox', + 'user.email.required': 'Please enter your email!', + 'user.email.wrong-format': 'The email address is in the wrong format!', + 'user.userName.required': 'Please enter account name or email address', + 'user.password.required': 'Please enter your password!', + 'user.password.twice.msg': 'The passwords entered twice do not match!', + 'user.password.strength.msg': 'The password is not strong enough', + 'user.password.strength.strong': 'Strength: strong', + 'user.password.strength.medium': 'Strength: medium', + 'user.password.strength.low': 'Strength: low', + 'user.password.strength.short': 'Strength: too short', + 'user.confirm-password.required': 'Please confirm your password!', + 'user.phone-number.required': 'Please enter your phone number!', + 'user.phone-number.wrong-format': 'Please enter a valid phone number', + 'user.verification-code.required': 'Please enter the verification code!', + 'user.captcha.required': 'Please enter captcha!', + 'user.login.infos': 'QuantDinger is an AI multi-agent stock analysis tool that does not have securities investment consulting qualifications. All analysis results, scores, and reference opinions on the platform are automatically generated by AI based on historical data, and are only for learning, research, and technical exchanges, and do not constitute any investment advice or decision-making basis. Stock investment involves market risk, liquidity risk, policy risk and other risks, which may lead to losses of principal. Users should make independent decisions based on their risk tolerance capacity, and bear the consequences of any investment behavior and its results using this tool. Market risk, investment needs to be cautious.', + 'user.login.tab-login-web3': 'Web3 Login', + 'user.login.web3.tip': 'Sign in with your wallet via signature', + 'user.login.web3.connect': 'Connect wallet and login', + 'user.login.web3.no-wallet': 'No wallet detected', + 'user.login.web3.no-address': 'Wallet address not found', + 'user.login.web3.nonce-failed': 'Failed to get nonce', + 'user.login.web3.verify-failed': 'Signature verification failed', + 'user.login.web3.success': 'Login success', + 'user.login.web3.failed': 'Wallet login failed', + 'nav.no_wallet': 'No wallet detected', + 'nav.copy': 'Copy', + 'nav.copy_success': 'Copied', + 'user.login.oauth.google': 'Sign in with Google', + 'user.login.oauth.github': 'Sign in with GitHub', + 'user.login.oauth.loading': 'Redirecting to authorization page...', + 'user.login.oauth.failed': 'OAuth login failed', + 'user.login.oauth.get-url-failed': 'Failed to get authorization URL', + 'user.login.subtitle': 'driven quantitative insights for global markets', + 'user.login.legal.title': 'Legal Disclaimer', + 'user.login.legal.view': 'View Legal Disclaimer', + 'user.login.legal.collapse': 'Hide Legal Disclaimer', + 'user.login.legal.agree': 'I have read and agree to the Legal Disclaimer', + 'user.login.legal.required': 'Please read and check the Legal Disclaimer first', + 'user.login.legal.content': 'QuantDinger is an AI multi-agent stock analysis tool and does not provide investment advice. All analysis results, scores, and references are generated by AI based on historical data and are for learning, research, and technical exchange only. They do not constitute investment advice or a basis for decision-making. Stock investments involve market, liquidity, and policy risks, which may lead to loss of principal. Users should make independent decisions based on their risk tolerance and bear the consequences of any investment behavior and results using this tool.', + 'user.login.privacy.title': 'Privacy Policy', + 'user.login.privacy.view': 'View Privacy Policy', + 'user.login.privacy.collapse': 'Hide Privacy Policy', + 'user.login.privacy.content': 'We value your privacy and data protection. (1) Scope of collection: We only collect information necessary to provide the service (e.g., email, mobile number, country code, Web3 wallet address) and limited logs/device data. (2) Purpose of use: Account login and security verification, feature provisioning, troubleshooting, and compliance requirements. (3) Storage & security: Data is encrypted and access-controlled to prevent unauthorized access, disclosure, or loss. (4) Sharing & third parties: We do not share personal data with third parties except as required by law or to deliver the service; where third-party services are involved (e.g., wallets, SMS providers), processing is limited to the minimum scope required. (5) Cookies/local storage: Used for session and login state (e.g., tokens, PHPSESSID). You may clear or restrict them in your browser. (6) Your rights: You may exercise rights of access, correction, deletion, and consent withdrawal as permitted by law. (7) Changes & notices: We will provide prominent notice for updates. Continued use indicates that you have read and agreed to the updated terms. If you do not agree, please stop using the service and contact us.', + 'account.basicInfo': 'Basic Information', + 'account.id': 'User ID', + 'account.username': 'Username', + 'account.nickname': 'Nickname', + 'account.email': 'Email', + 'account.mobile': 'Mobile', + 'account.web3address': 'Wallet Address', + 'account.pid': 'Referrer ID', + 'account.level': 'Level', + 'account.money': 'Balance', + 'account.qdtBalance': 'QDT Balance', + 'account.score': 'Score', + 'account.createtime': 'Registration Time', + 'account.inviteLink': 'Invite Link', + 'account.recharge': 'Recharge', + 'account.rechargeTip': 'Please scan the QR code below with WeChat or Alipay to recharge', + 'account.qrCodePlaceholder': 'QR Code Placeholder (Mock)', + 'account.rechargeAmount': 'Recharge Amount', + 'account.enterAmount': 'Please enter recharge amount', + 'account.rechargeHint': 'Minimum recharge amount: 1 QDT', + 'account.confirmRecharge': 'Confirm Recharge', + 'account.enterValidAmount': 'Please enter a valid recharge amount', + 'account.rechargeSuccess': 'Recharge successful! Recharged {amount} QDT', + 'account.settings.menuMap.basic': 'Basic Settings', + 'account.settings.menuMap.security': 'Security Settings', + 'account.settings.menuMap.notification': 'New Message Notification', + 'account.settings.menuMap.moneyLog': 'Transaction History', + 'account.moneyLog.empty': 'No transaction history', + 'account.moneyLog.total': 'Total {total} records', + 'account.moneyLog.type.purchase': 'Purchase Indicator', + 'account.moneyLog.type.recharge': 'Recharge', + 'account.moneyLog.type.refund': 'Refund', + 'account.moneyLog.type.reward': 'Reward', + 'account.moneyLog.type.income': 'Indicator Income', + 'account.moneyLog.type.commission': 'Platform Fee', + 'wallet.balance': 'QDT Balance', + 'wallet.recharge': 'Recharge', + 'wallet.withdraw': 'Withdraw', + 'wallet.filter': 'Filter', + 'wallet.reset': 'Reset', + 'wallet.totalRecharge': 'Total Recharge', + 'wallet.totalWithdraw': 'Total Withdraw', + 'wallet.totalIncome': 'Total Income', + 'wallet.records': 'Trading Records & Transaction History', + 'wallet.tradingRecords': 'Trading Records', + 'wallet.moneyLog': 'Transaction History', + 'wallet.rechargeTip': 'Please scan the QR code below with WeChat or Alipay to recharge', + 'wallet.qrCodePlaceholder': 'QR Code Placeholder (Simulated)', + 'wallet.rechargeAmount': 'Recharge Amount', + 'wallet.enterAmount': 'Please enter recharge amount', + 'wallet.rechargeHint': 'Minimum recharge amount: 1 QDT', + 'wallet.confirmRecharge': 'Confirm Recharge', + 'wallet.enterValidAmount': 'Please enter a valid recharge amount', + 'wallet.rechargeSuccess': 'Recharge successful! Recharged {amount} QDT', + 'wallet.rechargeFailed': 'Recharge failed', + 'wallet.withdrawTip': 'Please enter withdrawal amount and address', + 'wallet.withdrawAmount': 'Withdrawal Amount', + 'wallet.enterWithdrawAmount': 'Please enter withdrawal amount', + 'wallet.withdrawHint': 'Minimum withdrawal amount: 1 QDT', + 'wallet.withdrawAddress': 'Withdrawal Address (Optional)', + 'wallet.enterWithdrawAddress': 'Please enter withdrawal address', + 'wallet.confirmWithdraw': 'Confirm Withdrawal', + 'wallet.enterValidWithdrawAmount': 'Please enter a valid withdrawal amount', + 'wallet.insufficientBalance': 'Insufficient balance', + 'wallet.withdrawSuccess': 'Withdrawal successful! Withdrew {amount} QDT', + 'wallet.withdrawFailed': 'Withdrawal failed', + 'wallet.noTradingRecords': 'No trading records', + 'wallet.noMoneyLog': 'No transaction history', + 'wallet.loadTradingRecordsFailed': 'Failed to load trading records', + 'wallet.loadMoneyLogFailed': 'Failed to load transaction history', + 'wallet.moneyLogTotal': 'Total {total} records', + 'wallet.moneyLogTypeTitle': 'Type', + 'wallet.moneyLogType.all': 'All types', + 'wallet.table.time': 'Time', + 'wallet.table.type': 'Type', + 'wallet.table.price': 'Price', + 'wallet.table.amount': 'Amount', + 'wallet.table.money': 'Amount', + 'wallet.table.balance': 'Balance', + 'wallet.table.memo': 'Memo', + 'wallet.table.value': 'Value', + 'wallet.table.profit': 'P/L', + 'wallet.table.commission': 'Commission', + 'wallet.table.total': 'Total {total} records', + 'wallet.tradeType.buy': 'Buy', + 'wallet.tradeType.sell': 'Sell', + 'wallet.tradeType.liquidation': 'Liquidation', + 'wallet.tradeType.openLong': 'Open Long', + 'wallet.tradeType.addLong': 'Add Long', + 'wallet.tradeType.closeLong': 'Close Long', + 'wallet.tradeType.closeLongStop': 'Stop Loss Close Long', + 'wallet.tradeType.closeLongProfit': 'Take Profit Close Long', + 'wallet.tradeType.openShort': 'Open Short', + 'wallet.tradeType.addShort': 'Add Short', + 'wallet.tradeType.closeShort': 'Close Short', + 'wallet.tradeType.closeShortStop': 'Stop Loss Close Short', + 'wallet.tradeType.closeShortProfit': 'Take Profit Close Short', + 'wallet.moneyLogType.purchase': 'Purchase Indicator', + 'wallet.moneyLogType.recharge': 'Recharge', + 'wallet.moneyLogType.withdraw': 'Withdraw', + 'wallet.moneyLogType.refund': 'Refund', + 'wallet.moneyLogType.reward': 'Reward', + 'wallet.moneyLogType.income': 'Income', + 'wallet.moneyLogType.commission': 'Commission', + 'wallet.web3Address.required': 'Please fill in Web3 wallet address first', + 'wallet.web3Address.requiredDescription': 'You need to bind your Web3 wallet address before recharging to receive deposits', + 'wallet.web3Address.placeholder': 'Please enter your Web3 wallet address (starts with 0x)', + 'wallet.web3Address.save': 'Save Wallet Address', + 'wallet.web3Address.saveSuccess': 'Wallet address saved successfully', + 'wallet.web3Address.saveFailed': 'Failed to save wallet address', + 'wallet.web3Address.invalidFormat': 'Please enter a valid Web3 wallet address (Ethereum format: 42 characters starting with 0x, or TRC20 format: 34 characters starting with T)', + 'wallet.selectCoin': 'Select Coin', + 'wallet.selectChain': 'Select Chain', + 'wallet.chain.eth': 'ETH(ERC20)', + 'wallet.chain.trc20': 'TRC20', + 'wallet.chain.bsc': 'BSC', + 'wallet.targetQdtAmount': 'Target QDT Amount (Optional)', + 'wallet.targetQdtAmount.placeholder': 'Enter the QDT amount you want to exchange, current QDT price: {price} USDT', + 'wallet.targetQdtAmount.requiredUsdt': 'Required USDT: {amount}', + 'wallet.rechargeAddress': 'Recharge Address', + 'wallet.copyAddress': 'Copy', + 'wallet.copySuccess': 'Copied successfully!', + 'wallet.copyFailed': 'Copy failed, please copy manually', + 'wallet.rechargeAddressHint': 'Please ensure you use {chain} chain to recharge {coin} to this address', + 'wallet.qdtPrice.loading': 'Loading...', + 'wallet.rechargeTip.new': 'Please select coin and chain, then scan the QR code below to recharge', + 'wallet.exchangeRate': '1 QDT ≈ {rate} USDT', + 'wallet.withdrawableAmount': 'Withdrawable Amount', + 'wallet.totalBalance': 'Total Balance', + 'wallet.insufficientWithdrawable': 'Insufficient withdrawable amount, current withdrawable: {amount} QDT', + 'wallet.withdrawAddressRequired': 'Please enter withdrawal address', + 'wallet.withdrawAddressHint': 'Please ensure the address is correct, withdrawal cannot be revoked', + 'wallet.withdrawSubmitSuccess': 'Withdrawal application submitted successfully, please wait for review', + 'menu.footer.contactUs': 'Contact Us', + 'menu.footer.getSupport': 'Get Support', + 'menu.footer.socialAccounts': 'Social Accounts', + 'menu.footer.userAgreement': 'User Agreement', + 'menu.footer.privacyPolicy': 'Privacy Policy', + 'menu.footer.support': 'Support', + 'menu.footer.featureRequest': 'Feature request', + 'menu.footer.email': 'Email', + 'menu.footer.liveChat': '24/7 live chat', + 'dashboard.analysis.title': 'Multi-Dimensional Analysis', + 'dashboard.analysis.subtitle': 'AI-Powered Comprehensive Financial Analysis Platform', + 'dashboard.analysis.selectSymbol': 'Select or enter symbol code', + 'dashboard.analysis.selectModel': 'Select Model', + 'dashboard.analysis.startAnalysis': 'Start Analysis', + 'dashboard.analysis.history': 'History', + 'dashboard.analysis.tab.overview': 'Overview', + 'dashboard.analysis.tab.fundamental': 'Fundamental', + 'dashboard.analysis.tab.technical': 'Technical', + 'dashboard.analysis.tab.news': 'News', + 'dashboard.analysis.tab.sentiment': 'Sentiment', + 'dashboard.analysis.tab.risk': 'Risk', + 'dashboard.analysis.tab.debate': 'Debate', + 'dashboard.analysis.tab.decision': 'Final Decision', + 'dashboard.analysis.empty.selectSymbol': 'Select Symbol to Start Analysis', + 'dashboard.analysis.empty.selectSymbolDesc': 'Select from watchlist or enter symbol code to get multi-dimensional AI analysis report', + 'dashboard.analysis.empty.startAnalysis': 'Click "Start Analysis" button to perform multi-dimensional analysis', + 'dashboard.analysis.empty.startAnalysisDesc': 'We will provide comprehensive analysis from multiple dimensions including fundamental, technical, news, sentiment and risk', + 'dashboard.analysis.empty.noData': 'No {type} analysis data available, please perform comprehensive analysis first', + 'dashboard.analysis.empty.noWatchlist': 'No watchlist items', + 'dashboard.analysis.empty.noHistory': 'No history records', + 'dashboard.analysis.empty.watchlistHint': 'No watchlist, please', + 'dashboard.analysis.empty.noDebateData': 'No debate data available', + 'dashboard.analysis.empty.noDecisionData': 'No decision data available', + 'dashboard.analysis.empty.selectAgent': 'Please select an Agent to view analysis results', + 'dashboard.analysis.loading.analyzing': 'Analyzing, please wait...', + 'dashboard.analysis.loading.fundamental': 'Analyzing fundamental data...', + 'dashboard.analysis.loading.technical': 'Analyzing technical indicators...', + 'dashboard.analysis.loading.news': 'Analyzing news data...', + 'dashboard.analysis.loading.sentiment': 'Analyzing market sentiment...', + 'dashboard.analysis.loading.risk': 'Assessing risks...', + 'dashboard.analysis.loading.debate': 'Debating...', + 'dashboard.analysis.loading.decision': 'Generating final decision...', + 'dashboard.analysis.score.overall': 'Overall Score', + 'dashboard.analysis.score.recommendation': 'Recommendation', + 'dashboard.analysis.score.confidence': 'Confidence', + 'dashboard.analysis.dimension.fundamental': 'Fundamental', + 'dashboard.analysis.dimension.technical': 'Technical', + 'dashboard.analysis.dimension.news': 'News', + 'dashboard.analysis.dimension.sentiment': 'Sentiment', + 'dashboard.analysis.dimension.risk': 'Risk', + 'dashboard.analysis.card.dimensionScores': 'Dimension Scores', + 'dashboard.analysis.card.overviewReport': 'Overview Report', + 'dashboard.analysis.card.financialMetrics': 'Financial Metrics', + 'dashboard.analysis.card.fundamentalReport': 'Fundamental Analysis Report', + 'dashboard.analysis.card.technicalIndicators': 'Technical Indicators', + 'dashboard.analysis.card.technicalReport': 'Technical Analysis Report', + 'dashboard.analysis.card.newsList': 'Related News', + 'dashboard.analysis.card.newsReport': 'News Analysis Report', + 'dashboard.analysis.card.sentimentIndicators': 'Sentiment Indicators', + 'dashboard.analysis.card.sentimentReport': 'Sentiment Analysis Report', + 'dashboard.analysis.card.riskMetrics': 'Risk Metrics', + 'dashboard.analysis.card.riskReport': 'Risk Assessment Report', + 'dashboard.analysis.card.bullView': 'Bullish View (Bull)', + 'dashboard.analysis.card.bearView': 'Bearish View (Bear)', + 'dashboard.analysis.card.researchConclusion': 'Researcher Conclusion', + 'dashboard.analysis.card.traderPlan': 'Trader Plan', + 'dashboard.analysis.card.riskDebate': 'Risk Committee Debate', + 'dashboard.analysis.card.finalDecision': 'Final Decision', + 'dashboard.analysis.card.tradePlanDetail': 'Trading Plan Details', + 'dashboard.analysis.tradingPlan.entry_price': 'Entry Price', + 'dashboard.analysis.tradingPlan.position_size': 'Position Size', + 'dashboard.analysis.tradingPlan.stop_loss': 'Stop Loss', + 'dashboard.analysis.tradingPlan.take_profit': 'Take Profit', + 'dashboard.analysis.label.confidence': 'Confidence', + 'dashboard.analysis.label.keyPoints': 'Key Points', + 'dashboard.analysis.label.riskWarning': 'Risk Warning', + 'dashboard.analysis.risk.risky': 'Aggressive View (Risky)', + 'dashboard.analysis.risk.neutral': 'Neutral View (Neutral)', + 'dashboard.analysis.risk.safe': 'Conservative View (Safe)', + 'dashboard.analysis.risk.conclusion': 'Conclusion', + 'dashboard.analysis.feature.fundamental': 'Fundamental Analysis', + 'dashboard.analysis.feature.technical': 'Technical Analysis', + 'dashboard.analysis.feature.news': 'News Analysis', + 'dashboard.analysis.feature.sentiment': 'Sentiment Analysis', + 'dashboard.analysis.feature.risk': 'Risk Assessment', + 'dashboard.analysis.watchlist.title': 'My Watchlist', + 'dashboard.analysis.watchlist.add': 'Add', + 'dashboard.analysis.watchlist.addStock': 'Add Stock', + 'dashboard.analysis.modal.addStock.title': 'Add to Watchlist', + 'dashboard.analysis.modal.addStock.confirm': 'Confirm', + 'dashboard.analysis.modal.addStock.cancel': 'Cancel', + 'dashboard.analysis.modal.addStock.market': 'Market Type', + 'dashboard.analysis.modal.addStock.marketPlaceholder': 'Please select market', + 'dashboard.analysis.modal.addStock.marketRequired': 'Please select market type', + 'dashboard.analysis.modal.addStock.symbol': 'Symbol', + 'dashboard.analysis.modal.addStock.symbolPlaceholder': 'e.g.: AAPL, TSLA, GOOGL, 000001, BTC', + 'dashboard.analysis.modal.addStock.symbolRequired': 'Please enter symbol code', + 'dashboard.analysis.modal.addStock.searchPlaceholder': 'Search symbol code or name', + 'dashboard.analysis.modal.addStock.searchOrInputPlaceholder': 'Search or enter symbol code (e.g.: AAPL, BTC/USDT, EUR/USD)', + 'dashboard.analysis.modal.addStock.searchOrInputHint': 'Search in database or enter code directly (name will be fetched automatically)', + 'dashboard.analysis.modal.addStock.search': 'Search', + 'dashboard.analysis.modal.addStock.searchResults': 'Search Results', + 'dashboard.analysis.modal.addStock.hotSymbols': 'Hot Symbols', + 'dashboard.analysis.modal.addStock.noHotSymbols': 'No hot symbols available', + 'dashboard.analysis.modal.addStock.selectedSymbol': 'Selected Symbol', + 'dashboard.analysis.modal.addStock.pleaseSelectSymbol': 'Please select a symbol first', + 'dashboard.analysis.modal.addStock.pleaseSelectOrEnterSymbol': 'Please select a symbol or enter symbol code', + 'dashboard.analysis.modal.addStock.pleaseEnterSymbol': 'Please enter symbol code', + 'dashboard.analysis.modal.addStock.pleaseSelectMarket': 'Please select market type first', + 'dashboard.analysis.modal.addStock.searchFailed': 'Search failed, please try again later', + 'dashboard.analysis.modal.addStock.noSearchResults': 'No matching symbols found', + 'dashboard.analysis.modal.addStock.willAutoFetchName': 'Name will be fetched automatically', + 'dashboard.analysis.modal.addStock.addDirectly': 'Add Directly', + 'dashboard.analysis.modal.addStock.nameWillBeFetched': 'Name will be fetched automatically when adding', + 'dashboard.analysis.market.AShare': 'A-Share', + 'dashboard.analysis.market.USStock': 'US Stock', + 'dashboard.analysis.market.HShare': 'H-Share', + 'dashboard.analysis.market.Crypto': 'Cryptocurrency', + 'dashboard.analysis.market.Forex': 'Forex', + 'dashboard.analysis.market.Futures': 'Futures', + 'dashboard.analysis.modal.history.title': 'Analysis History', + 'dashboard.analysis.modal.history.viewResult': 'View Result', + 'dashboard.analysis.modal.history.completeTime': 'Complete Time', + 'dashboard.analysis.modal.history.error': 'Error', + 'dashboard.analysis.status.pending': 'Pending', + 'dashboard.analysis.status.processing': 'Processing', + 'dashboard.analysis.status.completed': 'Completed', + 'dashboard.analysis.status.failed': 'Failed', + 'dashboard.analysis.message.selectSymbol': 'Please select a symbol first', + 'dashboard.analysis.message.taskCreated': 'Analysis task created, executing in background...', + 'dashboard.analysis.message.analysisComplete': 'Analysis Complete', + 'dashboard.analysis.message.analysisCompleteCache': 'Analysis Complete (Using Cache)', + 'dashboard.analysis.message.analysisFailed': 'Analysis failed, please try again later', + 'dashboard.analysis.message.addStockSuccess': 'Added successfully', + 'dashboard.analysis.message.addStockFailed': 'Failed to add', + 'dashboard.analysis.message.removeStockSuccess': 'Removed successfully', + 'dashboard.analysis.message.removeStockFailed': 'Failed to remove', + 'dashboard.analysis.test': 'Gongzhuan No.{no} shop', + 'dashboard.analysis.introduce': 'Introduce', + 'dashboard.analysis.total-sales': 'Total Sales', + 'dashboard.analysis.day-sales': 'Daily Sales', + 'dashboard.analysis.visits': 'Visits', + 'dashboard.analysis.visits-trend': 'Visits Trend', + 'dashboard.analysis.visits-ranking': 'Visits Ranking', + 'dashboard.analysis.day-visits': 'Daily Visits', + 'dashboard.analysis.week': 'WoW Change', + 'dashboard.analysis.day': 'DoD Change', + 'dashboard.analysis.payments': 'Payments', + 'dashboard.analysis.conversion-rate': 'Conversion Rate', + 'dashboard.analysis.operational-effect': 'Operational Effect', + 'dashboard.analysis.sales-trend': 'Stores Sales Trend', + 'dashboard.analysis.sales-ranking': 'Sales Ranking', + 'dashboard.analysis.all-year': 'All Year', + 'dashboard.analysis.all-month': 'All Month', + 'dashboard.analysis.all-week': 'All Week', + 'dashboard.analysis.all-day': 'All day', + 'dashboard.analysis.search-users': 'Search Users', + 'dashboard.analysis.per-capita-search': 'Per Capita Search', + 'dashboard.analysis.online-top-search': 'Online Top Search', + 'dashboard.analysis.the-proportion-of-sales': 'The Proportion Of Sales', + 'dashboard.analysis.dropdown-option-one': 'Operation one', + 'dashboard.analysis.dropdown-option-two': 'Operation two', + 'dashboard.analysis.channel.all': 'ALL', + 'dashboard.analysis.channel.online': 'Online', + 'dashboard.analysis.channel.stores': 'Stores', + 'dashboard.analysis.sales': 'Sales', + 'dashboard.analysis.traffic': 'Traffic', + 'dashboard.analysis.table.rank': 'Rank', + 'dashboard.analysis.table.search-keyword': 'Keyword', + 'dashboard.analysis.table.users': 'Users', + 'dashboard.analysis.table.weekly-range': 'Weekly Range', + 'dashboard.indicator.selectSymbol': 'Select or enter symbol code', + 'dashboard.indicator.emptyWatchlistHint': 'No watchlist, please add first', + 'dashboard.indicator.hint.selectSymbol': 'Please select a symbol to start analysis', + 'dashboard.indicator.hint.selectSymbolDesc': 'Select or enter stock code in the search box above to view K-line chart and technical indicators', + 'dashboard.indicator.retry': 'Retry', + 'dashboard.indicator.panel.title': 'Technical Indicators', + 'dashboard.indicator.panel.realtimeOn': 'Turn off real-time update', + 'dashboard.indicator.panel.realtimeOff': 'Turn on real-time update', + 'dashboard.indicator.panel.themeLight': 'Switch to dark theme', + 'dashboard.indicator.panel.themeDark': 'Switch to light theme', + 'dashboard.indicator.section.enabled': 'Enabled', + 'dashboard.indicator.section.added': 'My Added Indicators', + 'dashboard.indicator.section.custom': 'My Created Indicators', + 'dashboard.indicator.section.bought': 'Purchased Indicators', + 'dashboard.indicator.section.myCreated': 'My Created Indicators', + 'dashboard.indicator.empty': 'No indicators, please add or create indicators first', + 'dashboard.indicator.buy': 'Buy Indicators', + 'dashboard.indicator.action.start': 'Start', + 'dashboard.indicator.action.stop': 'Stop', + 'dashboard.indicator.action.edit': 'Edit', + 'dashboard.indicator.action.delete': 'Delete', + 'dashboard.indicator.action.backtest': 'Backtest', + 'dashboard.indicator.status.normal': 'Active', + 'dashboard.indicator.status.normalPermanent': 'Active (Permanent)', + 'dashboard.indicator.status.expired': 'Expired', + 'dashboard.indicator.expiry.permanent': 'Permanent', + 'dashboard.indicator.expiry.noExpiry': 'No expiration', + 'dashboard.indicator.expiry.expired': 'Expired: {date}', + 'dashboard.indicator.expiry.expiresOn': 'Expires: {date}', + 'dashboard.indicator.delete.confirmTitle': 'Confirm Deletion', + 'dashboard.indicator.delete.confirmContent': 'Are you sure you want to delete indicator "{name}"? This action cannot be undone.', + 'dashboard.indicator.delete.confirmOk': 'Delete', + 'dashboard.indicator.delete.confirmCancel': 'Cancel', + 'dashboard.indicator.delete.success': 'Deleted successfully', + 'dashboard.indicator.delete.failed': 'Failed to delete', + 'dashboard.indicator.save.success': 'Saved successfully', + 'dashboard.indicator.save.failed': 'Failed to save', + 'dashboard.indicator.error.loadWatchlistFailed': 'Failed to load watchlist', + 'dashboard.indicator.error.chartNotReady': 'Chart component not initialized, please select a symbol and wait for the chart to load', + 'dashboard.indicator.error.chartMethodNotReady': 'Chart component method not ready, please try again later', + 'dashboard.indicator.error.chartExecuteNotReady': 'Chart component execute method not ready, please try again later', + 'dashboard.indicator.error.parseFailed': 'Failed to parse Python code', + 'dashboard.indicator.error.parseFailedCheck': 'Failed to parse Python code, please check the code format', + 'dashboard.indicator.error.addIndicatorFailed': 'Failed to add indicator', + 'dashboard.indicator.error.runIndicatorFailed': 'Failed to run indicator', + 'dashboard.indicator.error.pleaseLogin': 'Please login first', + 'dashboard.indicator.error.loadDataFailed': 'Failed to load data', + 'dashboard.indicator.error.loadDataFailedDesc': 'Please check network connection', + 'dashboard.indicator.error.pythonEngineFailed': 'Python engine failed to load, indicator functionality may be unavailable', + 'dashboard.indicator.error.chartInitFailed': 'Chart initialization failed', + 'dashboard.indicator.warning.enterCode': 'Please enter indicator code first', + 'dashboard.indicator.warning.chartNotInitialized': 'Chart not initialized, cannot use drawing tools', + 'dashboard.indicator.warning.pyodideLoadFailed': 'Python Engine Load Failed', + 'dashboard.indicator.warning.pyodideLoadFailedDesc': 'Your current region or network environment cannot use this feature', + 'dashboard.indicator.success.runIndicator': 'Indicator ran successfully', + 'dashboard.indicator.success.clearDrawings': 'All drawings cleared', + 'dashboard.indicator.sma': 'SMA (Moving Average Group)', + 'dashboard.indicator.ema': 'EMA (Exponential Moving Average Group)', + 'dashboard.indicator.rsi': 'RSI (Relative Strength Index)', + 'dashboard.indicator.macd': 'MACD', + 'dashboard.indicator.bb': 'Bollinger Bands', + 'dashboard.indicator.atr': 'ATR (Average True Range)', + 'dashboard.indicator.cci': 'CCI (Commodity Channel Index)', + 'dashboard.indicator.williams': 'Williams %R', + 'dashboard.indicator.mfi': 'MFI (Money Flow Index)', + 'dashboard.indicator.adx': 'ADX (Average Directional Index)', + 'dashboard.indicator.obv': 'OBV (On Balance Volume)', + 'dashboard.indicator.adosc': 'ADOSC (Accumulation/Distribution Oscillator)', + 'dashboard.indicator.ad': 'AD (Accumulation/Distribution Line)', + 'dashboard.indicator.kdj': 'KDJ (Stochastic Indicator)', + 'dashboard.indicator.signal.buy': 'BUY', + 'dashboard.indicator.signal.sell': 'SELL', + 'dashboard.indicator.signal.supertrendBuy': 'SuperTrend Buy', + 'dashboard.indicator.signal.supertrendSell': 'SuperTrend Sell', + 'dashboard.indicator.chart.kline': 'K-Line', + 'dashboard.indicator.chart.volume': 'Volume', + 'dashboard.indicator.chart.uptrend': 'Up Trend', + 'dashboard.indicator.chart.downtrend': 'Down Trend', + 'dashboard.indicator.tooltip.time': 'Time', + 'dashboard.indicator.tooltip.open': 'O', + 'dashboard.indicator.tooltip.close': 'C', + 'dashboard.indicator.tooltip.high': 'H', + 'dashboard.indicator.tooltip.low': 'L', + 'dashboard.indicator.tooltip.volume': 'Volume', + 'dashboard.indicator.drawing.line': 'Line', + 'dashboard.indicator.drawing.horizontalLine': 'Horizontal Line', + 'dashboard.indicator.drawing.verticalLine': 'Vertical Line', + 'dashboard.indicator.drawing.ray': 'Ray', + 'dashboard.indicator.drawing.straightLine': 'Straight Line', + 'dashboard.indicator.drawing.parallelLine': 'Parallel Line', + 'dashboard.indicator.drawing.priceLine': 'Price Line', + 'dashboard.indicator.drawing.priceChannel': 'Price Channel', + 'dashboard.indicator.drawing.fibonacciLine': 'Fibonacci Line', + 'dashboard.indicator.drawing.clearAll': 'Clear All Drawings', + 'dashboard.indicator.market.AShare': 'A-Share', + 'dashboard.indicator.market.USStock': 'US Stock', + 'dashboard.indicator.market.HShare': 'H-Share', + 'dashboard.indicator.market.Crypto': 'Cryptocurrency', + 'dashboard.indicator.market.Forex': 'Forex', + 'dashboard.indicator.market.Futures': 'Futures', + 'dashboard.indicator.create': 'Create Indicator', + 'dashboard.indicator.editor.title': 'Create/Edit Indicator', + 'dashboard.indicator.editor.name': 'Indicator Name', + 'dashboard.indicator.editor.nameRequired': 'Please enter indicator name', + 'dashboard.indicator.editor.namePlaceholder': 'Enter indicator name', + 'dashboard.indicator.editor.description': 'Description', + 'dashboard.indicator.editor.descriptionPlaceholder': 'Enter description (optional)', + 'dashboard.indicator.editor.code': 'Python Code', + 'dashboard.indicator.editor.codeRequired': 'Please enter indicator code', + 'dashboard.indicator.editor.codePlaceholder': 'Enter Python code', + 'dashboard.indicator.editor.run': 'Run', + 'dashboard.indicator.editor.runHint': 'Click Run button to preview indicator on K-line chart', + 'dashboard.indicator.editor.guide': 'Development Guide', + 'dashboard.indicator.editor.guideTitle': 'Python Indicator Development Guide', + 'dashboard.indicator.editor.save': 'Save', + 'dashboard.indicator.editor.cancel': 'Cancel', + 'dashboard.indicator.editor.unnamed': 'Unnamed Indicator', + 'dashboard.indicator.editor.publishToCommunity': 'Publish to Community', + 'dashboard.indicator.editor.publishToCommunityHint': 'After publishing, other users can view and use your indicator in the community', + 'dashboard.indicator.editor.indicatorType': 'Indicator Type', + 'dashboard.indicator.editor.indicatorTypeRequired': 'Please select indicator type', + 'dashboard.indicator.editor.indicatorTypePlaceholder': 'Please select indicator type', + 'dashboard.indicator.editor.previewImage': 'Preview Image', + 'dashboard.indicator.editor.uploadImage': 'Upload Image', + 'dashboard.indicator.editor.previewImageHint': 'Recommended size: 800x400, max 2MB', + 'dashboard.indicator.editor.pricing': 'Pricing (QDT)', + 'dashboard.indicator.editor.pricingType.free': 'Free', + 'dashboard.indicator.editor.pricingType.permanent': 'Permanent', + 'dashboard.indicator.editor.pricingType.monthly': 'Monthly', + 'dashboard.indicator.editor.price': 'Price', + 'dashboard.indicator.editor.priceRequired': 'Please enter price', + 'dashboard.indicator.editor.pricePlaceholder': 'Please enter price', + 'dashboard.indicator.editor.pricingHint': 'Free indicators (price 0) will still receive platform rewards based on usage and ratings (QDT)', + 'dashboard.indicator.editor.aiGenerate': 'AI Generate', + 'dashboard.indicator.editor.aiPromptPlaceholder': 'Describe your signal logic (buy/sell only) and plots. Position sizing, risk, scaling, fees/slippage are configured in backtest.', + 'dashboard.indicator.boundary.message': 'Note: The indicator script only computes plots + buy/sell signals. Position sizing, risk, scaling, fees/slippage belong to execution config.', + 'dashboard.indicator.boundary.indicatorRule': "Keep the script to buy/sell only (and set df['buy']/df['sell']). Do NOT implement position management, TP/SL, scaling in the script.", + 'dashboard.indicator.boundary.backtestRule': 'Rule: If a bar has a main signal (buy/sell → open/close/reverse), scaling in/out is skipped on the same bar.', + 'dashboard.indicator.editor.aiGenerateBtn': 'AI Generate Code', + 'dashboard.indicator.editor.aiPromptRequired': 'Please enter your idea', + 'dashboard.indicator.editor.aiGenerateSuccess': 'Code generated successfully', + 'dashboard.indicator.editor.aiGenerateError': 'Code generation failed, please try again later', + 'dashboard.indicator.guide.title': 'Python Indicator & Strategy Development Guide', + 'dashboard.indicator.guide.intro': 'This platform supports writing custom technical indicators and trading signals using Python. The system includes built-in pandas and numpy data analysis libraries. You can use standard DataFrame operations to process K-line data and draw charts or mark buy/sell signals.', + 'dashboard.indicator.guide.section1.title': '1. Runtime Environment & Predefined Variables', + 'dashboard.indicator.guide.section1.env': 'Your code runs in a browser-based Python environment (Pyodide).', + 'dashboard.indicator.guide.section1.libs': 'Pre-installed Libraries', + 'dashboard.indicator.guide.section1.libsDesc': 'The following libraries are imported by default, no need to import again:', + 'dashboard.indicator.guide.section1.libsList': 'pandas (pd), numpy (np), math, json', + 'dashboard.indicator.guide.section1.data': 'Input Data: df', + 'dashboard.indicator.guide.section1.dataDesc': 'The system automatically converts current K-line data into a Pandas DataFrame object, variable name is df.', + 'dashboard.indicator.guide.section1.dataColumns': 'df contains the following columns (all float type):', + 'dashboard.indicator.guide.section1.dataColumnsList': 'time (timestamp, seconds), open, high, low, close, volume', + 'dashboard.indicator.guide.section1.dataNote': 'Data index is arranged from old to new (0 is the earliest data, last row is the latest data).', + 'dashboard.indicator.guide.section1.dataIndex': 'Important: df index is timestamp (DatetimeIndex), please preserve the index, avoid using .values which loses the index.', + 'dashboard.indicator.guide.section2.title': '2. Parameter Definition', + 'dashboard.indicator.guide.section2.desc': 'You can define variables as parameters at the beginning of your code. The system will try to parse statements like variable = value as adjustable parameters.', + 'dashboard.indicator.guide.section2.example': 'Parameter Example', + 'dashboard.indicator.guide.section2.exampleCode': 'span = 20 or multiplier = 2.0', + 'dashboard.indicator.guide.section3.title': '3. Backtest Signal Setup (Important!)', + 'dashboard.indicator.guide.section3.desc': 'If you want your indicator to support backtesting, you must set the following two columns in your code:', + 'dashboard.indicator.guide.section3.backtestRequired': 'Required for Backtesting:', + 'dashboard.indicator.guide.section3.backtestBuySignal': "df['buy_signal'] - Boolean Series, True indicates buy signal", + 'dashboard.indicator.guide.section3.backtestSellSignal': "df['sell_signal'] - Boolean Series, True indicates sell signal", + 'dashboard.indicator.guide.section3.backtestExample': 'Example:', + 'dashboard.indicator.guide.section3.backtestExampleCode': "df['buy_signal'] = condition_buy
    df['sell_signal'] = condition_sell", + 'dashboard.indicator.guide.section3.backtestNote': 'Note: Even if you only set output.signals for chart display, you must set these two columns to enable backtesting.', + 'dashboard.indicator.guide.section4.title': '4. Output Requirements: output', + 'dashboard.indicator.guide.section4.desc': 'At the end of code execution, you must define a dictionary named output to tell the chart how to display results.', + 'dashboard.indicator.guide.section4.fields': 'The output dictionary contains the following fields:', + 'dashboard.indicator.guide.section4.plots': 'plots - For drawing lines (e.g., moving averages, RSI)', + 'dashboard.indicator.guide.section4.signals': 'signals - For marking buy/sell signals (e.g., arrows, labels)', + 'dashboard.indicator.guide.section4.name': 'name - Indicator name (optional)', + 'dashboard.indicator.guide.section4.calculatedVars': 'calculatedVars - Calculated variables (optional, for displaying additional information)', + 'dashboard.indicator.guide.section5.title': '5. Plot Configuration (Plots)', + 'dashboard.indicator.guide.section5.desc': 'Each object in the plots list represents a line.', + 'dashboard.indicator.guide.section5.table.field': 'Field', + 'dashboard.indicator.guide.section5.table.type': 'Type', + 'dashboard.indicator.guide.section5.table.required': 'Required', + 'dashboard.indicator.guide.section5.table.requiredYes': 'Yes', + 'dashboard.indicator.guide.section5.table.requiredNo': 'No', + 'dashboard.indicator.guide.section5.table.desc': 'Description', + 'dashboard.indicator.guide.section5.table.name': 'The name of the indicator line, displayed in the legend', + 'dashboard.indicator.guide.section5.table.data': 'Data list, length must match the number of rows in df. Recommended to use series.tolist()', + 'dashboard.indicator.guide.section5.table.color': "Line color (Hex format, e.g. '#ff0000'). If not filled, the system will automatically assign", + 'dashboard.indicator.guide.section5.table.typeDesc': "Chart type, default is 'line'", + 'dashboard.indicator.guide.section5.table.overlay': 'Important. True means display on main chart (with K-line), False means display on sub-chart (independent area)', + 'dashboard.indicator.guide.section5.example': 'Example: Draw Simple Moving Average (SMA)', + 'dashboard.indicator.guide.section5.exampleCalc': 'Calculate SMA', + 'dashboard.indicator.guide.section5.exampleOutput': 'Construct Output', + 'dashboard.indicator.guide.section5.exampleOverlay': 'Display on main chart', + 'dashboard.indicator.guide.section6.title': '6. Signal Markers (Signals)', + 'dashboard.indicator.guide.section6.desc': "signals are used to mark buy/sell points on specific K-lines through icons or text. Note: This is only for chart display. The backtest system uses df['buy_signal'] and df['sell_signal'].", + 'dashboard.indicator.guide.section6.table.type': "Signal type: 'buy' (buy, marked below K-line) or 'sell' (sell, marked above K-line)", + 'dashboard.indicator.guide.section6.table.data': 'Signal data list. Fill price value at signal positions, fill None (or np.nan) at non-signal positions', + 'dashboard.indicator.guide.section6.table.text': 'Text displayed on the label (e.g. "B", "S", "Buy", "Sell")', + 'dashboard.indicator.guide.section6.table.color': 'Marker color', + 'dashboard.indicator.guide.section6.hint': 'The system automatically handles positioning.', + 'dashboard.indicator.guide.section6.buy': 'Markers will be automatically positioned below the Low of the current K-line', + 'dashboard.indicator.guide.section6.sell': 'Markers will be automatically positioned above the High of the current K-line', + 'dashboard.indicator.guide.section6.example': 'Example: Draw Buy/Sell Signals', + 'dashboard.indicator.guide.section6.exampleInit': 'Initialize signal list, fill all with None', + 'dashboard.indicator.guide.section6.exampleLogic': 'Simple logic: mark buy when close price is greater than open price', + 'dashboard.indicator.guide.section6.exampleNote': 'In actual logic, please use loops or vectorized operations', + 'dashboard.indicator.guide.section6.examplePrice': 'Record price', + 'dashboard.indicator.guide.section6.exampleText': 'Buy', + 'dashboard.indicator.guide.section7.title': '7. Complete Code Examples', + 'dashboard.indicator.guide.section7.exampleA': 'Example A: Dual EMA Strategy (Supports Backtesting)', + 'dashboard.indicator.guide.section7.exampleACode': "# 1. Calculate indicators
    df['ema_fast'] = df['close'].ewm(span=5, adjust=False).mean()
    df['ema_slow'] = df['close'].ewm(span=13, adjust=False).mean()

    # 2. Calculate crossover signals
    cross_up = (df['ema_fast'] > df['ema_slow']) & (df['ema_fast'].shift(1) <= df['ema_slow'].shift(1))
    cross_down = (df['ema_fast'] < df['ema_slow']) & (df['ema_fast'].shift(1) >= df['ema_slow'].shift(1))

    # 3. 【Important】Set NEW backtest signal columns (bool)
    df['open_long'] = cross_up
    df['close_long'] = cross_down
    df['open_short'] = cross_down
    df['close_short'] = cross_up

    # 4. Generate signal data for chart display
    buy_signals = [df['low'].iloc[i] * 0.995 if cross_up.iloc[i] else None for i in range(len(df))]
    sell_signals = [df['high'].iloc[i] * 1.005 if cross_down.iloc[i] else None for i in range(len(df))]

    # 5. Build output
    output = {
    \"name\": \"Dual EMA Strategy\",
    \"plots\": [
    {\"name\": \"EMA 5\", \"data\": df['ema_fast'].tolist(), \"color\": \"#FF6B6B\", \"overlay\": True},
    {\"name\": \"EMA 13\", \"data\": df['ema_slow'].tolist(), \"color\": \"#4ECDC4\", \"overlay\": True}
    ],
    \"signals\": [
    {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},
    {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}
    ]
    }", + 'dashboard.indicator.guide.section7.exampleB': 'Example B: Bollinger Bands - Chart Display Only', + 'dashboard.indicator.guide.section7.exampleBCode': "# Calculate Bollinger Bands (chart display only, no backtesting)
    length = 20
    mult = 2.0
    df['sma'] = df['close'].rolling(length).mean()
    df['std'] = df['close'].rolling(length).std()
    df['upper'] = df['sma'] + mult * df['std']
    df['lower'] = df['sma'] - mult * df['std']

    output = {
    \"name\": \"Bollinger Bands\",
    \"plots\": [
    {\"name\": \"Upper\", \"data\": df['upper'].tolist(), \"color\": \"#FF5252\", \"overlay\": True},
    {\"name\": \"Middle\", \"data\": df['sma'].tolist(), \"color\": \"#2196F3\", \"overlay\": True},
    {\"name\": \"Lower\", \"data\": df['lower'].tolist(), \"color\": \"#00E676\", \"overlay\": True}
    ]
    }", + 'dashboard.indicator.guide.section7.exampleC': 'Example C: RSI Strategy (Supports Backtesting)', + 'dashboard.indicator.guide.section7.exampleCCode': "# Calculate RSI
    delta = df['close'].diff()
    up = delta.clip(lower=0)
    down = -1 * delta.clip(upper=0)
    ema_up = up.ewm(com=13, adjust=False).mean()
    ema_down = down.ewm(com=13, adjust=False).mean()
    rs = ema_up / ema_down
    df['rsi'] = 100 - (100 / (1 + rs))

    # Generate trading signals
    buy_condition = df['rsi'] < 30 # Oversold buy
    sell_condition = df['rsi'] > 70 # Overbought sell

    # 【Important】Set NEW backtest signal columns (bool)
    df['open_long'] = buy_condition
    df['close_long'] = sell_condition
    df['open_short'] = sell_condition
    df['close_short'] = buy_condition

    # Generate chart signals
    buy_signals = [df['low'].iloc[i] * 0.995 if buy_condition.iloc[i] else None for i in range(len(df))]
    sell_signals = [df['high'].iloc[i] * 1.005 if sell_condition.iloc[i] else None for i in range(len(df))]

    output = {
    \"name\": \"RSI Strategy\",
    \"plots\": [
    {\"name\": \"RSI\", \"data\": df['rsi'].tolist(), \"color\": \"#9C27B0\", \"overlay\": False},
    {\"name\": \"Overbought\", \"data\": [70] * len(df), \"color\": \"#FF5252\", \"style\": \"dashed\", \"overlay\": False},
    {\"name\": \"Oversold\", \"data\": [30] * len(df), \"color\": \"#00E676\", \"style\": \"dashed\", \"overlay\": False}
    ],
    \"signals\": [
    {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},
    {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}
    ]
    }", + 'dashboard.indicator.guide.section6.param': 'Parameters', + 'dashboard.indicator.guide.section6.calc': 'Calculate', + 'dashboard.indicator.guide.section6.output': 'Output', + 'dashboard.indicator.guide.section6.generate': 'Generate signal data (initialize with None)', + 'dashboard.indicator.guide.section6.subchart': 'Display on sub-chart', + 'dashboard.indicator.guide.section8.title': '8. Frequently Asked Questions (FAQ)', + 'dashboard.indicator.guide.section8.q1': "Q: Why can't my indicator be backtested?", + 'dashboard.indicator.guide.section8.a1': "A: The backtest system requires NEW signal columns: df['open_long'], df['close_long'], df['open_short'], df['close_short'] (boolean). Even if you set output.signals for chart display, you must set these columns to enable backtesting.", + 'dashboard.indicator.guide.section8.q2': 'Q: Data length mismatch?', + 'dashboard.indicator.guide.section8.a2': "A: The data list in plots and signals must have exactly the same length as the K-line data. If you use df['xxx'].tolist(), there is usually no problem.", + 'dashboard.indicator.guide.section8.q3': 'Q: How to handle NaN (empty values)?', + 'dashboard.indicator.guide.section8.a3': "A: Pandas calculations (such as rolling mean) will produce NaN at the beginning. The system will automatically convert NaN to null for chart rendering. You don't need to manually clean it, but if you need specific logic, you can use df.fillna(0) or other methods.", + 'dashboard.indicator.guide.section8.q4': 'Q: Index mismatch causing all signals to be NaN?', + 'dashboard.indicator.guide.section8.a4': "A: Please ensure you preserve the original df index (timestamp). Avoid using .values to create new Series. Use columns directly like df['close'] for operations.", + 'dashboard.indicator.guide.section8.q5': 'Q: Can I use third-party network libraries?', + 'dashboard.indicator.guide.section8.a5': 'A: No. The runtime environment is a browser sandbox and cannot use libraries like requests to request external APIs, nor can it install additional pip packages. Limited to built-in libraries such as pandas, numpy, math, json.', + 'dashboard.indicator.guide.section8.q6': 'Q: How to debug code?', + 'dashboard.indicator.guide.section8.a6': 'A: If the chart does not display indicators, it is usually because the Python code has an error. Please check for syntax errors or array index out of bounds. It is recommended to debug the algorithm logic in a local Python environment first, then copy it in.', + 'dashboard.indicator.guide.section8.q7': 'Q: Backtest shows 0 signals?', + 'dashboard.indicator.guide.section8.a7': "A: Please check the data type of df['open_long']/df['close_long']/df['open_short']/df['close_short']. They should be boolean (True/False), not numeric. Use condition.fillna(False).astype(bool) to ensure correct type.", + 'dashboard.indicator.backtest.title': 'Indicator Backtest', + 'dashboard.indicator.backtest.config': 'Backtest Parameters', + 'dashboard.indicator.backtest.startDate': 'Start Date', + 'dashboard.indicator.backtest.endDate': 'End Date', + 'dashboard.indicator.backtest.selectStartDate': 'Select start date', + 'dashboard.indicator.backtest.selectEndDate': 'Select end date', + 'dashboard.indicator.backtest.startDateRequired': 'Please select start date', + 'dashboard.indicator.backtest.endDateRequired': 'Please select end date', + 'dashboard.indicator.backtest.initialCapital': 'Initial Capital', + 'dashboard.indicator.backtest.initialCapitalRequired': 'Please enter initial capital', + 'dashboard.indicator.backtest.commission': 'Commission Rate (%)', + 'dashboard.indicator.backtest.commissionHint': 'Charged on notional value (incl. leverage) per trade; both entry & exit are deducted from balance.', + 'dashboard.indicator.backtest.leverage': 'Leverage', + 'dashboard.indicator.backtest.tradeDirection': 'Trade Direction', + 'dashboard.indicator.backtest.longOnly': 'Long Only', + 'dashboard.indicator.backtest.shortOnly': 'Short Only', + 'dashboard.indicator.backtest.both': 'Both', + 'dashboard.indicator.backtest.run': 'Run Backtest', + 'dashboard.indicator.backtest.rerun': 'Run Again', + 'dashboard.indicator.backtest.close': 'Close', + 'dashboard.indicator.backtest.running': 'Running indicator backtest...', + 'dashboard.indicator.backtest.results': 'Backtest Results', + 'dashboard.indicator.backtest.totalReturn': 'Total Return', + 'dashboard.indicator.backtest.annualReturn': 'Annual Return', + 'dashboard.indicator.backtest.maxDrawdown': 'Max Drawdown', + 'dashboard.indicator.backtest.sharpeRatio': 'Sharpe Ratio', + 'dashboard.indicator.backtest.winRate': 'Win Rate', + 'dashboard.indicator.backtest.profitFactor': 'Profit Factor', + 'dashboard.indicator.backtest.totalTrades': 'Total Trades', + 'dashboard.indicator.totalReturn': 'Total Return', + 'dashboard.indicator.annualReturn': 'Annual Return', + 'dashboard.indicator.maxDrawdown': 'Max Drawdown', + 'dashboard.indicator.sharpeRatio': 'Sharpe Ratio', + 'dashboard.indicator.winRate': 'Win Rate', + 'dashboard.indicator.profitFactor': 'Profit Factor', + 'dashboard.indicator.totalTrades': 'Total Trades', + 'dashboard.indicator.backtest.totalCommission': 'Total Commission', + 'dashboard.indicator.backtest.equityCurve': 'Equity Curve', + 'dashboard.indicator.backtest.strategy': 'Indicator', + 'dashboard.indicator.backtest.benchmark': 'Benchmark', + 'dashboard.indicator.backtest.tradeHistory': 'Trade History', + 'dashboard.indicator.backtest.tradeTime': 'Time', + 'dashboard.indicator.backtest.tradeType': 'Type', + 'dashboard.indicator.backtest.buy': 'Buy', + 'dashboard.indicator.backtest.sell': 'Sell', + 'dashboard.indicator.backtest.liquidation': 'Liquidation', + 'dashboard.indicator.backtest.openLong': 'Open Long', + 'dashboard.indicator.backtest.closeLong': 'Close Long', + 'dashboard.indicator.backtest.closeLongStop': 'Close Long (Stop Loss)', + 'dashboard.indicator.backtest.closeLongProfit': 'Close Long (Take Profit)', + 'dashboard.indicator.backtest.addLong': 'Add Long', + 'dashboard.indicator.backtest.openShort': 'Open Short', + 'dashboard.indicator.backtest.closeShort': 'Close Short', + 'dashboard.indicator.backtest.closeShortStop': 'Close Short (Stop Loss)', + 'dashboard.indicator.backtest.closeShortProfit': 'Close Short (Take Profit)', + 'dashboard.indicator.backtest.addShort': 'Add Short', + 'dashboard.indicator.backtest.closeLongTrailing': 'Close Long (Trailing)', + 'dashboard.indicator.backtest.reduceLong': 'Reduce Long', + 'dashboard.indicator.backtest.closeShortTrailing': 'Close Short (Trailing)', + 'dashboard.indicator.backtest.reduceShort': 'Reduce Short', + 'dashboard.indicator.backtest.price': 'Price', + 'dashboard.indicator.backtest.amount': 'Amount', + 'dashboard.indicator.backtest.balance': 'Balance', + 'dashboard.indicator.backtest.profit': 'P&L', + 'dashboard.indicator.backtest.success': 'Backtest completed', + 'dashboard.indicator.backtest.failed': 'Backtest failed, please try again later', + 'dashboard.indicator.backtest.noIndicatorCode': 'This indicator has no backtestable code', + 'dashboard.indicator.backtest.noSymbol': 'Please select a trading symbol first', + 'dashboard.indicator.backtest.dateRangeExceeded': 'Backtest date range exceeded: {timeframe} timeframe allows max {maxRange}', + 'dashboard.indicator.backtest.dateRangeExceededDays': 'Backtest range exceeds limit: {timeframe} max {maxRange} ({maxDays} days)', + 'dashboard.indicator.backtest.metaLine': 'Symbol: {symbol} | Market: {market} | Timeframe: {timeframe}', + 'dashboard.indicator.backtest.savedRunId': 'Backtest saved. Run ID: {id}', + 'dashboard.indicator.backtest.historyTitle': 'Backtest History', + 'dashboard.indicator.backtest.historyRefresh': 'Refresh', + 'dashboard.indicator.backtest.historyView': 'View', + 'dashboard.indicator.backtest.historyNoData': 'No backtest history', + 'dashboard.indicator.backtest.historyUseCurrent': 'Only current symbol/timeframe', + 'dashboard.indicator.backtest.historyFilterSymbol': 'Symbol', + 'dashboard.indicator.backtest.historyFilterTimeframe': 'Timeframe', + 'dashboard.indicator.backtest.historyApply': 'Apply filters', + 'dashboard.indicator.backtest.historyAIAnalyze': 'AI Analyze', + 'dashboard.indicator.backtest.historyAIAnalyzeTitle': 'AI Suggestions', + 'dashboard.indicator.backtest.historyNoAIResult': 'No AI analysis result', + 'dashboard.indicator.backtest.historyRunId': 'Run ID', + 'dashboard.indicator.backtest.historyCreatedAt': 'Time', + 'dashboard.indicator.backtest.historyRange': 'Range', + 'dashboard.indicator.backtest.historyStatus': 'Status', + 'dashboard.indicator.backtest.historyStatusSuccess': 'Success', + 'dashboard.indicator.backtest.historyStatusFailed': 'Failed', + 'dashboard.indicator.backtest.historyActions': 'Actions', + 'dashboard.indicator.backtest.prev': 'Previous', + 'dashboard.indicator.backtest.next': 'Next', + 'dashboard.indicator.backtest.steps.strategy.title': 'Execution Config', + 'dashboard.indicator.backtest.steps.strategy.desc': 'Position, risk, scaling', + 'dashboard.indicator.backtest.steps.trading.title': 'Backtest Setup', + 'dashboard.indicator.backtest.steps.trading.desc': 'Time, capital, fees, leverage, slippage', + 'dashboard.indicator.backtest.steps.results.title': 'Results', + 'dashboard.indicator.backtest.steps.results.desc': 'Summary & trades', + 'dashboard.indicator.backtest.panel.risk': 'Execution: Risk (SL / trailing)', + 'dashboard.indicator.backtest.panel.scale': 'Execution: Scale-in (Trend / DCA)', + 'dashboard.indicator.backtest.panel.reduce': 'Execution: Scale-out (Trend / Adverse)', + 'dashboard.indicator.backtest.panel.position': 'Execution: Entry sizing', + 'dashboard.indicator.backtest.field.stopLossPct': 'Stop Loss (%)', + 'dashboard.indicator.backtest.field.takeProfitPct': 'Take Profit (%)', + 'dashboard.indicator.backtest.field.trailingEnabled': 'Trailing stop / take profit', + 'dashboard.indicator.backtest.field.trailingStopPct': 'Trailing drawdown (%)', + 'dashboard.indicator.backtest.field.trailingActivationPct': 'Trailing activation (%)', + 'dashboard.indicator.backtest.field.slippage': 'Slippage (%)', + 'dashboard.indicator.backtest.field.trendAddEnabled': 'Trend-following scale-in', + 'dashboard.indicator.backtest.field.dcaAddEnabled': 'Mean-reversion DCA', + 'dashboard.indicator.backtest.field.trendAddStepPct': 'Scale-in trigger (%)', + 'dashboard.indicator.backtest.field.dcaAddStepPct': 'DCA trigger (%)', + 'dashboard.indicator.backtest.field.trendAddSizePct': 'Scale-in size (% of capital)', + 'dashboard.indicator.backtest.field.dcaAddSizePct': 'DCA size (% of capital)', + 'dashboard.indicator.backtest.field.trendAddMaxTimes': 'Max scale-in times', + 'dashboard.indicator.backtest.field.dcaAddMaxTimes': 'Max DCA times', + 'dashboard.indicator.backtest.field.trendReduceEnabled': 'Trend reduce', + 'dashboard.indicator.backtest.field.adverseReduceEnabled': 'Adverse reduce', + 'dashboard.indicator.backtest.field.trendReduceStepPct': 'Trend trigger (%)', + 'dashboard.indicator.backtest.field.adverseReduceStepPct': 'Adverse trigger (%)', + 'dashboard.indicator.backtest.field.trendReduceSizePct': 'Reduce size (% of position)', + 'dashboard.indicator.backtest.field.adverseReduceSizePct': 'Adverse reduce size (% of position)', + 'dashboard.indicator.backtest.field.trendReduceMaxTimes': 'Max trend reduce times', + 'dashboard.indicator.backtest.field.adverseReduceMaxTimes': 'Max adverse reduce times', + 'dashboard.indicator.backtest.field.entryPct': 'Entry size (% of capital)', + 'dashboard.indicator.backtest.field.minOrderPct': 'Min order size (% of capital) (optional)', + 'dashboard.indicator.backtest.hint.entryPctMax': 'Max entry: {maxPct}% (reserve budget for future scale-ins)', + 'dashboard.docs.title': 'Documentation', + 'dashboard.docs.search.placeholder': 'Search documents...', + 'dashboard.docs.category.all': 'All', + 'dashboard.docs.category.guide': 'Guides', + 'dashboard.docs.category.api': 'API Reference', + 'dashboard.docs.category.tutorial': 'Tutorials', + 'dashboard.docs.category.faq': 'FAQ', + 'dashboard.docs.featured.title': 'Featured Documents', + 'dashboard.docs.featured.tag': 'Featured', + 'dashboard.docs.list.views': 'views', + 'dashboard.docs.list.author': 'Author', + 'dashboard.docs.list.empty': 'No documents', + 'dashboard.docs.list.backToAll': 'Back to All Documents', + 'dashboard.docs.list.total': 'Total {count} documents', + 'dashboard.docs.detail.back': 'Back to List', + 'dashboard.docs.detail.updatedAt': 'Updated at', + 'dashboard.docs.detail.related': 'Related Documents', + 'dashboard.docs.detail.notFound': 'Document Not Found', + 'dashboard.docs.detail.error': 'Document does not exist or has been deleted', + 'dashboard.docs.search.result': 'Search Results', + 'dashboard.docs.search.keyword': 'Keyword', + 'community.filter.indicatorType': 'Indicator Type', + 'community.filter.all': 'All', + 'community.filter.other': 'Other Options', + 'community.filter.pricing': 'Pricing Type', + 'community.filter.allPricing': 'All Pricing', + 'community.filter.sortBy': 'Sort By', + 'community.filter.search': 'Search', + 'community.filter.searchPlaceholder': 'Search indicator name', + 'community.indicatorType.trend': 'Trend', + 'community.indicatorType.momentum': 'Momentum', + 'community.indicatorType.volatility': 'Volatility', + 'community.indicatorType.volume': 'Volume', + 'community.indicatorType.custom': 'Custom', + 'community.pricing.free': 'Free', + 'community.pricing.paid': 'Paid', + 'community.sort.downloads': 'Downloads', + 'community.sort.rating': 'Rating', + 'community.sort.newest': 'Newest', + 'community.pagination.total': 'Total {total} indicators', + 'community.noDescription': 'No description', + 'community.detail.type': 'Type', + 'community.detail.pricing': 'Pricing', + 'community.detail.rating': 'Rating', + 'community.detail.downloads': 'Downloads', + 'community.detail.author': 'Author', + 'community.detail.description': 'Description', + 'community.detail.detailContent': 'Detail Content', + 'community.detail.backtestStats': 'Backtest Statistics', + 'community.action.purchase': 'Purchase', + 'community.action.addToMyIndicators': 'Add to My Indicators', + 'community.action.favorite': 'Favorite', + 'community.action.unfavorite': 'Unfavorite', + 'community.action.buyNow': 'Buy Now', + 'community.action.renew': 'Renew', + 'community.purchase.price': 'Price', + 'community.purchase.permanent': 'Permanent', + 'community.purchase.monthly': 'Month', + 'community.purchase.confirmBuy': 'Confirm purchase ({price} QDT)?', + 'community.purchase.confirmRenew': 'Confirm renewal ({price} QDT/month)?', + 'community.purchase.confirmFree': 'Confirm to add this free indicator?', + 'community.purchase.confirmTitle': 'Confirm', + 'community.purchase.owned': 'You own this indicator (permanent)', + 'community.purchase.ownIndicator': 'This is your published indicator', + 'community.purchase.expired': 'Your subscription has expired', + 'community.purchase.expiresOn': 'Expires on: {date}', + 'community.tabs.detail': 'Details', + 'community.tabs.backtest': 'Backtest', + 'community.tabs.ratings': 'Reviews', + 'community.rating.myRating': 'My Rating', + 'community.rating.stars': '{count} Stars', + 'community.rating.commentPlaceholder': 'Share your experience...', + 'community.rating.submit': 'Submit Rating', + 'community.rating.modify': 'Edit', + 'community.rating.saveModify': 'Save Changes', + 'community.rating.cancel': 'Cancel', + 'community.rating.selectRating': 'Please select a rating', + 'community.rating.success': 'Rating submitted successfully', + 'community.rating.modifySuccess': 'Review modified successfully', + 'community.rating.failed': 'Failed to submit rating', + 'community.rating.noRatings': 'No reviews yet', + 'community.backtest.note': 'The above data is from historical backtesting, for reference only, and does not represent future returns', + 'community.backtest.noData': 'No backtest data available', + 'community.backtest.uploadHint': 'Author has not uploaded backtest data yet', + 'community.message.loadFailed': 'Failed to load indicators', + 'community.message.purchaseProcessing': 'Processing purchase...', + 'community.message.downloadSuccess': 'Indicator added to your list', + 'community.message.favoriteSuccess': 'Favorited successfully', + 'community.message.unfavoriteSuccess': 'Unfavorited successfully', + 'community.message.operationSuccess': 'Operation successful', + 'community.message.operationFailed': 'Operation failed', + 'dashboard.totalEquity': 'Total Equity', + 'dashboard.totalPnL': 'Total P&L', + 'dashboard.aiStrategies': 'AI Strategies', + 'dashboard.indicatorStrategies': 'Indicator Strategies', + 'dashboard.running': 'Running', + 'dashboard.pnlHistory': 'P&L History', + 'dashboard.strategyPerformance': 'Strategy Performance', + 'dashboard.recentTrades': 'Recent Trades', + 'dashboard.currentPositions': 'Current Positions', + 'dashboard.table.time': 'Time', + 'dashboard.table.strategy': 'Strategy', + 'dashboard.table.symbol': 'Symbol', + 'dashboard.table.type': 'Type', + 'dashboard.table.side': 'Side', + 'dashboard.table.size': 'Size', + 'dashboard.table.entryPrice': 'Entry Price', + 'dashboard.table.price': 'Price', + 'dashboard.table.amount': 'Amount', + 'dashboard.table.profit': 'Profit', + 'dashboard.pendingOrders': 'Order Execution Records', + 'dashboard.totalOrders': 'Total {total} orders', + 'dashboard.viewError': 'View Error', + 'dashboard.filled': 'Filled', + 'dashboard.orderTable.time': 'Created Time', + 'dashboard.orderTable.strategy': 'Strategy', + 'dashboard.orderTable.symbol': 'Symbol', + 'dashboard.orderTable.signalType': 'Signal Type', + 'dashboard.orderTable.amount': 'Amount', + 'dashboard.orderTable.price': 'Filled Price', + 'dashboard.orderTable.status': 'Status', + 'dashboard.orderTable.executedAt': 'Executed Time', + 'dashboard.orderTable.exchange': 'Exchange', + 'dashboard.orderTable.notify': 'Notify', + 'dashboard.orderTable.actions': 'Actions', + 'dashboard.orderTable.delete': 'Delete', + 'dashboard.orderTable.deleteConfirm': 'Delete this record?', + 'dashboard.orderTable.deleteSuccess': 'Deleted', + 'dashboard.orderTable.deleteFailed': 'Delete failed', + 'dashboard.signalType.openLong': 'Open Long', + 'dashboard.signalType.openShort': 'Open Short', + 'dashboard.signalType.closeLong': 'Close Long', + 'dashboard.signalType.closeShort': 'Close Short', + 'dashboard.signalType.addLong': 'Add Long', + 'dashboard.signalType.addShort': 'Add Short', + 'dashboard.status.pending': 'Pending', + 'dashboard.status.processing': 'Processing', + 'dashboard.status.completed': 'Completed', + 'dashboard.status.failed': 'Failed', + 'dashboard.status.cancelled': 'Cancelled', + 'dashboard.table.pnl': 'Unrealized P&L', + 'form.basic-form.basic.title': 'Basic form', + 'form.basic-form.basic.description': 'Form pages are used to collect or verify information to users, and basic forms are common in scenarios where there are fewer data items.', + 'form.basic-form.title.label': 'Title', + 'form.basic-form.title.placeholder': 'Give the target a name', + 'form.basic-form.title.required': 'Please enter a title', + 'form.basic-form.date.label': 'Start and end date', + 'form.basic-form.placeholder.start': 'Start date', + 'form.basic-form.placeholder.end': 'End date', + 'form.basic-form.date.required': 'Please select the start and end date', + 'form.basic-form.goal.label': 'Goal description', + 'form.basic-form.goal.placeholder': 'Please enter your work goals', + 'form.basic-form.goal.required': 'Please enter a description of the goal', + 'form.basic-form.standard.label': 'Metrics', + 'form.basic-form.standard.placeholder': 'Please enter a metric', + 'form.basic-form.standard.required': 'Please enter a metric', + 'form.basic-form.client.label': 'Client', + 'form.basic-form.label.tooltip': 'Target service object', + 'form.basic-form.client.placeholder': 'Please describe your customer service, internal customers directly @ Name / job number', + 'form.basic-form.client.required': 'Please describe the customers you serve', + 'form.basic-form.invites.label': 'Inviting critics', + 'form.basic-form.invites.placeholder': 'Please direct @ Name / job number, you can invite up to 5 people', + 'form.basic-form.weight.label': 'Weight', + 'form.basic-form.weight.placeholder': 'Please enter weight', + 'form.basic-form.public.label': 'Target disclosure', + 'form.basic-form.label.help': 'Customers and invitees are shared by default', + 'form.basic-form.radio.public': 'Public', + 'form.basic-form.radio.partially-public': 'Partially public', + 'form.basic-form.radio.private': 'Private', + 'form.basic-form.publicUsers.placeholder': 'Open to', + 'form.basic-form.option.A': 'Colleague A', + 'form.basic-form.option.B': 'Colleague B', + 'form.basic-form.option.C': 'Colleague C', + 'form.basic-form.email.required': 'Please enter your email!', + 'form.basic-form.email.wrong-format': 'The email address is in the wrong format!', + 'form.basic-form.userName.required': 'Please enter your userName!', + 'form.basic-form.password.required': 'Please enter your password!', + 'form.basic-form.password.twice': 'The passwords entered twice do not match!', + 'form.basic-form.strength.msg': "Please enter at least 6 characters and don't use passwords that are easy to guess.", + 'form.basic-form.strength.strong': 'Strength: strong', + 'form.basic-form.strength.medium': 'Strength: medium', + 'form.basic-form.strength.short': 'Strength: too short', + 'form.basic-form.confirm-password.required': 'Please confirm your password!', + 'form.basic-form.phone-number.required': 'Please enter your phone number!', + 'form.basic-form.phone-number.wrong-format': 'Malformed phone number!', + 'form.basic-form.verification-code.required': 'Please enter the verification code!', + 'form.basic-form.form.get-captcha': 'Get Captcha', + 'form.basic-form.captcha.second': 'sec', + 'form.basic-form.form.optional': ' (optional) ', + 'form.basic-form.form.submit': 'Submit', + 'form.basic-form.form.save': 'Save', + 'form.basic-form.email.placeholder': 'Email', + 'form.basic-form.password.placeholder': 'Password', + 'form.basic-form.confirm-password.placeholder': 'Confirm password', + 'form.basic-form.phone-number.placeholder': 'Phone number', + 'form.basic-form.verification-code.placeholder': 'Verification code', + 'result.success.title': 'Submission Success', + 'result.success.description': 'The submission results page is used to feed back the results of a series of operational tasks. If it is a simple operation, use the Message global prompt feedback. This text area can show a simple supplementary explanation. If there is a similar requirement for displaying “documents”, the following gray area can present more complicated content.', + 'result.success.operate-title': 'Project Name', + 'result.success.operate-id': 'Project ID', + 'result.success.principal': 'Principal', + 'result.success.operate-time': 'Effective time', + 'result.success.step1-title': 'Create project', + 'result.success.step1-operator': 'Qu Lili', + 'result.success.step2-title': 'Departmental preliminary review', + 'result.success.step2-operator': 'Zhou Maomao', + 'result.success.step2-extra': 'Urge', + 'result.success.step3-title': 'Financial review', + 'result.success.step4-title': 'Finish', + 'result.success.btn-return': 'Back List', + 'result.success.btn-project': 'View Project', + 'result.success.btn-print': 'Print', + 'result.fail.error.title': 'Submission Failed', + 'result.fail.error.description': 'Please check and modify the following information before resubmitting.', + 'result.fail.error.hint-title': 'The content you submitted has the following error:', + 'result.fail.error.hint-text1': 'Your account has been frozen', + 'result.fail.error.hint-btn1': 'Thaw immediately', + 'result.fail.error.hint-text2': 'Your account is not yet eligible to apply', + 'result.fail.error.hint-btn2': 'Upgrade immediately', + 'result.fail.error.btn-text': 'Return to modify', + 'account.settings.menuMap.custom': 'Custom Settings', + 'account.settings.menuMap.binding': 'Account Binding', + 'account.settings.basic.avatar': 'Avatar', + 'account.settings.basic.change-avatar': 'Change avatar', + 'account.settings.basic.email': 'Email', + 'account.settings.basic.email-message': 'Please input your email!', + 'account.settings.basic.nickname': 'Nickname', + 'account.settings.basic.nickname-message': 'Please input your Nickname!', + 'account.settings.basic.profile': 'Personal profile', + 'account.settings.basic.profile-message': 'Please input your personal profile!', + 'account.settings.basic.profile-placeholder': 'Brief introduction to yourself', + 'account.settings.basic.country': 'Country/Region', + 'account.settings.basic.country-message': 'Please input your country!', + 'account.settings.basic.geographic': 'Province or city', + 'account.settings.basic.geographic-message': 'Please input your geographic info!', + 'account.settings.basic.address': 'Street Address', + 'account.settings.basic.address-message': 'Please input your address!', + 'account.settings.basic.phone': 'Phone Number', + 'account.settings.basic.phone-message': 'Please input your phone!', + 'account.settings.basic.update': 'Update Information', + 'account.settings.basic.update.success': 'Update basic information successfully', + 'account.settings.security.strong': 'Strong', + 'account.settings.security.medium': 'Medium', + 'account.settings.security.weak': 'Weak', + 'account.settings.security.password': 'Account Password', + 'account.settings.security.password-description': 'Current password strength:', + 'account.settings.security.phone': 'Security Phone', + 'account.settings.security.phone-description': 'Bound phone:', + 'account.settings.security.question': 'Security Question', + 'account.settings.security.question-description': 'The security question is not set, and the security policy can effectively protect the account security', + 'account.settings.security.email': 'Bound Email', + 'account.settings.security.email-description': 'Bound Email:', + 'account.settings.security.mfa': 'MFA Device', + 'account.settings.security.mfa-description': 'Unbound MFA device, after binding, can be confirmed twice', + 'account.settings.security.modify': 'Modify', + 'account.settings.security.set': 'Set', + 'account.settings.security.bind': 'Bind', + 'account.settings.binding.taobao': 'Binding Taobao', + 'account.settings.binding.taobao-description': 'Currently unbound Taobao account', + 'account.settings.binding.alipay': 'Binding Alipay', + 'account.settings.binding.alipay-description': 'Currently unbound Alipay account', + 'account.settings.binding.dingding': 'Binding DingTalk', + 'account.settings.binding.dingding-description': 'Currently unbound DingTalk account', + 'account.settings.binding.bind': 'Bind', + 'account.settings.notification.password': 'Account Password', + 'account.settings.notification.password-description': 'Messages from other users will be notified in the form of a station letter', + 'account.settings.notification.messages': 'System Messages', + 'account.settings.notification.messages-description': 'System messages will be notified in the form of a station letter', + 'account.settings.notification.todo': 'To-do Notification', + 'account.settings.notification.todo-description': 'The to-do list will be notified in the form of a letter from the station', + 'account.settings.settings.open': 'Open', + 'account.settings.settings.close': 'Close', + 'trading-assistant.title': 'Trading Assistant', + 'trading-assistant.strategyList': 'Strategy List', + 'trading-assistant.createStrategy': 'Create Strategy', + 'trading-assistant.noStrategy': 'No strategies', + 'trading-assistant.selectStrategy': 'Please select a strategy from the left to view details', + 'trading-assistant.startStrategy': 'Start Strategy', + 'trading-assistant.stopStrategy': 'Stop Strategy', + 'trading-assistant.editStrategy': 'Edit Strategy', + 'trading-assistant.deleteStrategy': 'Delete Strategy', + 'trading-assistant.status.running': 'Running', + 'trading-assistant.status.stopped': 'Stopped', + 'trading-assistant.status.error': 'Error', + 'trading-assistant.strategyType.IndicatorStrategy': 'Indicator Strategy', + 'trading-assistant.strategyType.PromptBasedStrategy': 'Prompt Strategy', + 'trading-assistant.strategyType.GridStrategy': 'Grid Strategy', + 'trading-assistant.tabs.tradingRecords': 'Trading Records', + 'trading-assistant.tabs.positions': 'Positions', + 'trading-assistant.tabs.equityCurve': 'Equity Curve', + 'trading-assistant.form.step1': 'Select Indicator', + 'trading-assistant.form.step2': 'Exchange Configuration', + 'trading-assistant.form.step3': 'Strategy Parameters', + 'trading-assistant.form.step2Params': 'Parameters', + 'trading-assistant.form.step3Signal': 'Signal Delivery', + 'trading-assistant.form.marketCategory': 'Market Category', + 'trading-assistant.form.marketCategoryHint': 'Select a market first. Only Crypto can enable live trading; other markets are signal-only.', + 'trading-assistant.market.AShare': 'A-Share', + 'trading-assistant.market.USStock': 'US Stock', + 'trading-assistant.market.Crypto': 'Crypto', + 'trading-assistant.market.Forex': 'Forex', + 'trading-assistant.form.indicator': 'Select Indicator', + 'trading-assistant.form.indicatorHint': 'You can only select indicators you have purchased or created', + 'trading-assistant.form.qdtCostHints': 'Using strategies will consume QDT, please ensure your account has sufficient QDT balance', + 'trading-assistant.form.indicatorDescription': 'Indicator Description', + 'trading-assistant.form.noDescription': 'No description', + 'trading-assistant.form.exchange': 'Select Exchange', + 'trading-assistant.form.apiKey': 'API Key', + 'trading-assistant.form.secretKey': 'Secret Key', + 'trading-assistant.form.passphrase': 'Passphrase', + 'trading-assistant.form.testConnection': 'Test Connection', + 'trading-assistant.form.strategyName': 'Strategy Name', + 'trading-assistant.form.symbol': 'Trading Pair', + 'trading-assistant.form.symbolHint': 'Symbol format depends on the selected market', + 'trading-assistant.form.symbolHintCrypto': 'Crypto: use trading pairs like BTC/USDT', + 'trading-assistant.form.symbolHintGeneral': 'Enter the symbol for the selected market (e.g. 600519, AAPL, EURUSD).', + 'trading-assistant.form.initialCapital': 'Initial Capital', + 'trading-assistant.form.marketType': 'Market Type', + 'trading-assistant.form.marketTypeFutures': 'Futures', + 'trading-assistant.form.marketTypeSpot': 'Spot', + 'trading-assistant.form.marketTypeHint': 'Futures support bidirectional trading and leverage, spot only supports long positions with fixed 1x leverage', + 'trading-assistant.form.leverage': 'Leverage', + 'trading-assistant.form.leverageHint': 'Futures: 1-125x, Spot: Fixed 1x', + 'trading-assistant.form.spotLeverageFixed': 'Spot trading leverage is fixed at 1x', + 'trading-assistant.form.spotOnlyLongHint': 'Spot trading only supports long positions', + 'trading-assistant.form.tradeDirection': 'Trade Direction', + 'trading-assistant.form.tradeDirectionLong': 'Long Only', + 'trading-assistant.form.tradeDirectionShort': 'Short Only', + 'trading-assistant.form.tradeDirectionBoth': 'Both', + 'trading-assistant.form.timeframe': 'Timeframe', + 'trading-assistant.form.klinePeriod': 'K-Line Period', + 'trading-assistant.form.timeframe1m': '1 Minute', + 'trading-assistant.form.timeframe5m': '5 Minutes', + 'trading-assistant.form.timeframe15m': '15 Minutes', + 'trading-assistant.form.timeframe30m': '30 Minutes', + 'trading-assistant.form.timeframe1H': '1 Hour', + 'trading-assistant.form.timeframe4H': '4 Hours', + 'trading-assistant.form.timeframe1D': '1 Day', + 'trading-assistant.form.selectStrategyType': 'Select Strategy Type', + 'trading-assistant.form.indicatorStrategy': 'Indicator Strategy', + 'trading-assistant.form.indicatorStrategyDesc': 'Automated trading strategy based on technical indicators', + 'trading-assistant.form.aiStrategy': 'AI Strategy', + 'trading-assistant.form.aiStrategyDesc': 'Automated trading strategy based on AI intelligent decision-making', + 'trading-assistant.form.enableAiFilter': 'Enable AI Intelligent Decision Filter', + 'trading-assistant.form.enableAiFilterHint': 'When enabled, indicator signals will be filtered by AI to improve trading quality', + 'trading-assistant.form.aiFilterPrompt': 'Custom Prompt', + 'trading-assistant.form.aiFilterPromptHint': 'Provide custom instructions for AI filtering, leave blank to use system default', + 'trading-assistant.validation.strategyTypeRequired': 'Please select a strategy type', + 'trading-assistant.validation.marketCategoryRequired': 'Please select a market category', + 'trading-assistant.form.advancedSettings': 'Advanced Settings', + 'trading-assistant.form.orderMode': 'Order Mode', + 'trading-assistant.form.orderModeMaker': 'Maker (Limit)', + 'trading-assistant.form.orderModeTaker': 'Taker (Market)', + 'trading-assistant.form.orderModeHint': 'Maker mode uses limit orders with lower fees; Taker mode executes immediately with higher fees', + 'trading-assistant.form.makerWaitSec': 'Maker Wait Time (seconds)', + 'trading-assistant.form.makerWaitSecHint': 'Time to wait for order fill before canceling and retrying', + 'trading-assistant.form.makerRetries': 'Maker Retries', + 'trading-assistant.form.makerRetriesHint': 'Maximum number of retries when maker order is not filled', + 'trading-assistant.form.fallbackToMarket': 'Fallback to Market on failure', + 'trading-assistant.form.fallbackToMarketHint': 'If limit order (open/close) is not filled, downgrade to market order to ensure execution', + 'trading-assistant.form.marginMode': 'Margin Mode', + 'trading-assistant.form.marginModeCross': 'Cross Margin', + 'trading-assistant.form.marginModeIsolated': 'Isolated Margin', + 'trading-assistant.form.stopLossPct': 'Stop Loss (%)', + 'trading-assistant.form.stopLossPctHint': 'Set stop loss percentage, 0 means disabled', + 'trading-assistant.form.takeProfitPct': 'Take Profit (%)', + 'trading-assistant.form.takeProfitPctHint': 'Set take profit percentage, 0 means disabled', + 'trading-assistant.form.commission': 'Commission (%)', + 'trading-assistant.form.commissionHint': 'Trading fee percentage (optional)', + 'trading-assistant.form.slippage': 'Slippage (%)', + 'trading-assistant.form.slippageHint': 'Estimated slippage percentage (optional)', + 'trading-assistant.form.executionMode': 'Execution', + 'trading-assistant.form.executionModeSignal': 'Signal only (push notifications)', + 'trading-assistant.form.executionModeLive': 'Live trading (Crypto only)', + 'trading-assistant.form.liveTradingCryptoOnlyHint': 'Live trading is available for Crypto only. Other markets can only push signals.', + 'trading-assistant.form.notifyChannels': 'Notification Channels', + 'trading-assistant.form.notifyChannelsHint': 'Choose how you want to receive buy/sell and risk-management signals.', + 'trading-assistant.notify.browser': 'Browser', + 'trading-assistant.notify.email': 'Email', + 'trading-assistant.notify.phone': 'Phone', + 'trading-assistant.notify.telegram': 'Telegram', + 'trading-assistant.notify.discord': 'Discord', + 'trading-assistant.notify.webhook': 'Webhook', + 'trading-assistant.form.notifyEmail': 'Email', + 'trading-assistant.form.notifyPhone': 'Phone number', + 'trading-assistant.form.notifyTelegram': 'Telegram (chat id / username)', + 'trading-assistant.form.notifyDiscord': 'Discord (webhook url)', + 'trading-assistant.form.notifyWebhook': 'Webhook URL', + 'trading-assistant.form.liveTradingConfigTitle': 'Exchange Credentials', + 'trading-assistant.form.liveTradingConfigHint': 'Provide your exchange API credentials. You can test the connection before saving.', + 'trading-assistant.form.savedCredential': 'Saved credential', + 'trading-assistant.form.savedCredentialHint': 'Select a saved credential to auto-fill API keys (optional).', + 'trading-assistant.form.saveCredential': 'Save this credential for future use', + 'trading-assistant.form.credentialName': 'Credential name (optional)', + 'trading-assistant.form.signalMode': 'Signal Mode', + 'trading-assistant.form.signalModeConfirmed': 'Confirmed', + 'trading-assistant.form.signalModeAggressive': 'Aggressive', + 'trading-assistant.form.signalModeHint': 'Confirmed: only check completed candles; Aggressive: also check forming candles', + 'trading-assistant.form.cancel': 'Cancel', + 'trading-assistant.form.prev': 'Previous', + 'trading-assistant.form.next': 'Next', + 'trading-assistant.form.confirmCreate': 'Confirm Create', + 'trading-assistant.form.confirmEdit': 'Confirm Edit', + 'trading-assistant.messages.createSuccess': 'Strategy created successfully', + 'trading-assistant.messages.createFailed': 'Failed to create strategy', + 'trading-assistant.messages.updateSuccess': 'Strategy updated successfully', + 'trading-assistant.messages.updateFailed': 'Failed to update strategy', + 'trading-assistant.messages.deleteSuccess': 'Strategy deleted successfully', + 'trading-assistant.messages.deleteFailed': 'Failed to delete strategy', + 'trading-assistant.messages.startSuccess': 'Strategy started successfully', + 'trading-assistant.messages.startFailed': 'Failed to start strategy', + 'trading-assistant.messages.stopSuccess': 'Strategy stopped successfully', + 'trading-assistant.messages.stopFailed': 'Failed to stop strategy', + 'trading-assistant.messages.loadFailed': 'Failed to load strategy list', + 'trading-assistant.messages.runningWarning': 'Strategy is running, please stop it before editing', + 'trading-assistant.messages.deleteConfirmWithName': 'Are you sure you want to delete strategy "{name}"? This action cannot be undone.', + 'trading-assistant.messages.deleteConfirm': 'Are you sure you want to delete this strategy? This action cannot be undone.', + 'trading-assistant.messages.loadTradesFailed': 'Failed to load trading records', + 'trading-assistant.messages.loadPositionsFailed': 'Failed to load position records', + 'trading-assistant.messages.loadEquityFailed': 'Failed to load equity curve', + 'trading-assistant.messages.loadIndicatorsFailed': 'Failed to load indicator list, please try again later', + 'trading-assistant.messages.spotLimitations': 'Spot trading has been automatically set to long only with 1x leverage', + 'trading-assistant.messages.autoFillApiConfig': 'Auto-filled API configuration for this exchange from history', + 'trading-assistant.placeholders.selectIndicator': 'Please select an indicator', + 'trading-assistant.placeholders.selectExchange': 'Please select an exchange', + 'trading-assistant.placeholders.selectMarketCategory': 'Please select market category', + 'trading-assistant.placeholders.inputApiKey': 'Please enter API Key', + 'trading-assistant.placeholders.inputSecretKey': 'Please enter Secret Key', + 'trading-assistant.placeholders.inputPassphrase': 'Please enter Passphrase', + 'trading-assistant.placeholders.inputStrategyName': 'Please enter strategy name', + 'trading-assistant.placeholders.selectSymbol': 'Please select trading pair', + 'trading-assistant.placeholders.inputSymbol': 'Please enter symbol', + 'trading-assistant.placeholders.selectTimeframe': 'Please select timeframe', + 'trading-assistant.placeholders.selectKlinePeriod': 'Please select K-line period', + 'trading-assistant.placeholders.inputAiFilterPrompt': 'Please enter custom prompt (optional)', + 'trading-assistant.placeholders.inputEmail': 'Please enter email', + 'trading-assistant.placeholders.inputPhone': 'Please enter phone number', + 'trading-assistant.placeholders.inputTelegram': 'Please enter Telegram chat id / username', + 'trading-assistant.placeholders.inputDiscord': 'Please enter Discord webhook url', + 'trading-assistant.placeholders.inputWebhook': 'Please enter webhook url', + 'trading-assistant.placeholders.selectSavedCredential': 'Select saved credential', + 'trading-assistant.placeholders.inputCredentialName': 'For example: Binance main key', + 'trading-assistant.validation.indicatorRequired': 'Please select an indicator', + 'trading-assistant.validation.exchangeRequired': 'Please select an exchange', + 'trading-assistant.validation.apiKeyRequired': 'Please enter API Key', + 'trading-assistant.validation.secretKeyRequired': 'Please enter Secret Key', + 'trading-assistant.validation.passphraseRequired': 'Please enter Passphrase', + 'trading-assistant.validation.exchangeConfigIncomplete': 'Please fill in complete exchange configuration information', + 'trading-assistant.validation.testConnectionRequired': 'Please click "Test Connection" button and ensure the connection is successful', + 'trading-assistant.validation.testConnectionFailed': 'Connection test failed, please check the configuration and test again', + 'trading-assistant.validation.strategyNameRequired': 'Please enter strategy name', + 'trading-assistant.validation.symbolRequired': 'Please select trading pair', + 'trading-assistant.validation.initialCapitalRequired': 'Please enter initial capital', + 'trading-assistant.validation.leverageRequired': 'Please enter leverage', + 'trading-assistant.validation.emailInvalid': 'Invalid email address', + 'trading-assistant.validation.notifyChannelRequired': 'Please select at least one notification channel', + 'trading-assistant.table.time': 'Time', + 'trading-assistant.table.type': 'Type', + 'trading-assistant.table.price': 'Price', + 'trading-assistant.table.amount': 'Amount', + 'trading-assistant.table.value': 'Value', + 'trading-assistant.table.commission': 'Commission', + 'trading-assistant.table.symbol': 'Trading Pair', + 'trading-assistant.table.side': 'Side', + 'trading-assistant.table.size': 'Position Size', + 'trading-assistant.table.entryPrice': 'Entry Price', + 'trading-assistant.table.currentPrice': 'Current Price', + 'trading-assistant.table.unrealizedPnl': 'Unrealized P&L', + 'trading-assistant.table.pnlPercent': 'P&L %', + 'trading-assistant.table.buy': 'Buy', + 'trading-assistant.table.sell': 'Sell', + 'trading-assistant.table.long': 'Long', + 'trading-assistant.table.short': 'Short', + 'trading-assistant.table.noPositions': 'No positions', + 'trading-assistant.detail.title': 'Strategy Details', + 'trading-assistant.detail.strategyName': 'Strategy Name', + 'trading-assistant.detail.strategyType': 'Strategy Type', + 'trading-assistant.detail.status': 'Status', + 'trading-assistant.detail.tradingMode': 'Trading Mode', + 'trading-assistant.detail.exchange': 'Exchange', + 'trading-assistant.detail.initialCapital': 'Initial Capital', + 'trading-assistant.detail.totalInvestment': 'Total Investment', + 'trading-assistant.detail.currentEquity': 'Current Equity', + 'trading-assistant.detail.totalPnl': 'Total P&L', + 'trading-assistant.detail.indicatorName': 'Indicator Name', + 'trading-assistant.detail.maxLeverage': 'Max Leverage', + 'trading-assistant.detail.decideInterval': 'Decision Interval', + 'trading-assistant.detail.symbols': 'Trading Symbols', + 'trading-assistant.detail.createdAt': 'Created At', + 'trading-assistant.detail.updatedAt': 'Updated At', + 'trading-assistant.detail.llmConfig': 'AI Model Configuration', + 'trading-assistant.detail.exchangeConfig': 'Exchange Configuration', + 'trading-assistant.detail.provider': 'Provider', + 'trading-assistant.detail.modelId': 'Model ID', + 'trading-assistant.detail.close': 'Close', + 'trading-assistant.detail.loadFailed': 'Failed to load strategy details', + 'trading-assistant.equity.noData': 'No equity data', + 'trading-assistant.equity.equity': 'Equity', + 'trading-assistant.exchange.tradingMode': 'Trading Mode', + 'trading-assistant.exchange.virtual': 'Virtual Trading', + 'trading-assistant.exchange.live': 'Live Trading', + 'trading-assistant.exchange.selectExchange': 'Select Exchange', + 'trading-assistant.exchange.walletAddress': 'Wallet Address', + 'trading-assistant.exchange.walletAddressPlaceholder': 'Please enter wallet address (starts with 0x)', + 'trading-assistant.exchange.privateKey': 'Private Key', + 'trading-assistant.exchange.privateKeyPlaceholder': 'Please enter private key (64 characters)', + 'trading-assistant.exchange.testConnection': 'Test Connection', + 'trading-assistant.exchange.connectionSuccess': 'Connection successful', + 'trading-assistant.exchange.connectionFailed': 'Connection failed', + 'trading-assistant.exchange.testFailed': 'Connection test failed', + 'trading-assistant.exchange.fillComplete': 'Please fill in complete exchange configuration information', + 'trading-assistant.exchange.ipWhitelistTip': 'Please add the following IPs to the whitelist in your exchange API settings:', + 'trading-assistant.strategyTypeOptions.ai': 'AI-Driven Strategy', + 'trading-assistant.strategyTypeOptions.indicator': 'Indicator Strategy', + 'trading-assistant.strategyTypeOptions.aiDeveloping': 'AI-Driven Strategy feature is under development, stay tuned', + 'trading-assistant.strategyTypeOptions.aiDevelopingWarning': 'AI-Driven Strategy feature is under development', + 'trading-assistant.indicatorType.trend': 'Trend', + 'trading-assistant.indicatorType.momentum': 'Momentum', + 'trading-assistant.indicatorType.volatility': 'Volatility', + 'trading-assistant.indicatorType.volume': 'Volume', + 'trading-assistant.indicatorType.custom': 'Custom', + 'trading-assistant.exchangeNames': { + 'okx': 'OKX', + 'binance': 'Binance', + 'hyperliquid': 'Hyperliquid', + 'blockchaincom': 'Blockchain.com', + 'coinbaseexchange': 'Coinbase', + 'gate': 'Gate.io', + 'mexc': 'MEXC', + 'kraken': 'Kraken', + 'bitfinex': 'Bitfinex', + 'bybit': 'Bybit', + 'kucoin': 'KuCoin', + 'huobi': 'Huobi', + 'bitget': 'Bitget', + 'bitmex': 'BitMEX', + 'deribit': 'Deribit', + 'phemex': 'Phemex', + 'bitmart': 'BitMart', + 'bitstamp': 'Bitstamp', + 'bittrex': 'Bittrex', + 'poloniex': 'Poloniex', + 'gemini': 'Gemini', + 'cryptocom': 'Crypto.com', + 'bitflyer': 'bitFlyer', + 'upbit': 'Upbit', + 'bithumb': 'Bithumb', + 'coinone': 'Coinone', + 'zb': 'ZB', + 'lbank': 'LBank', + 'bibox': 'Bibox', + 'bigone': 'BigONE', + 'bitrue': 'Bitrue', + 'coinex': 'CoinEx', + 'digifinex': 'DigiFinex', + 'ftx': 'FTX', + 'ftxus': 'FTX US', + 'binanceus': 'Binance US', + 'binancecoinm': 'Binance COIN-M', + 'binanceusdm': 'Binance USDⓈ-M' + }, + 'ai-trading-assistant.title': 'AI Trading Assistant', + 'ai-trading-assistant.strategyList': 'Strategy List', + 'ai-trading-assistant.createStrategy': 'Create Strategy', + 'ai-trading-assistant.noStrategy': 'No strategies', + 'ai-trading-assistant.selectStrategy': 'Please select a strategy from the left to view details', + 'ai-trading-assistant.startStrategy': 'Start Strategy', + 'ai-trading-assistant.stopStrategy': 'Stop Strategy', + 'ai-trading-assistant.editStrategy': 'Edit Strategy', + 'ai-trading-assistant.deleteStrategy': 'Delete Strategy', + 'ai-trading-assistant.status.running': 'Running', + 'ai-trading-assistant.status.stopped': 'Stopped', + 'ai-trading-assistant.status.error': 'Error', + 'ai-trading-assistant.tabs.tradingRecords': 'Trading Records', + 'ai-trading-assistant.tabs.positions': 'Positions', + 'ai-trading-assistant.tabs.aiDecisions': 'AI Decisions', + 'ai-trading-assistant.tabs.equityCurve': 'Equity Curve', + 'ai-trading-assistant.form.createTitle': 'Create AI Trading Strategy', + 'ai-trading-assistant.form.editTitle': 'Edit AI Trading Strategy', + 'ai-trading-assistant.form.strategyName': 'Strategy Name', + 'ai-trading-assistant.form.modelId': 'AI Model', + 'ai-trading-assistant.form.modelIdHint': 'Using system OpenRouter service, no API Key configuration needed', + 'ai-trading-assistant.form.decideInterval': 'Decision Interval', + 'ai-trading-assistant.form.decideInterval5m': '5 Minutes', + 'ai-trading-assistant.form.decideInterval10m': '10 Minutes', + 'ai-trading-assistant.form.decideInterval30m': '30 Minutes', + 'ai-trading-assistant.form.decideInterval1h': '1 Hour', + 'ai-trading-assistant.form.decideInterval4h': '4 Hours', + 'ai-trading-assistant.form.decideInterval1d': '1 Day', + 'ai-trading-assistant.form.decideInterval1w': '1 Week', + 'ai-trading-assistant.form.decideIntervalHint': 'Time interval for AI decision making', + 'ai-trading-assistant.form.runPeriod': 'Run Period', + 'ai-trading-assistant.form.runPeriodHint': 'Start and end time for strategy execution', + 'ai-trading-assistant.form.startDate': 'Start Date', + 'ai-trading-assistant.form.endDate': 'End Date', + 'ai-trading-assistant.form.qdtCostTitle': 'QDT Cost Notice', + 'ai-trading-assistant.form.qdtCostHint': 'Each AI decision will cost {cost} QDT. Please ensure your account has sufficient QDT balance. During strategy execution, each decision will be charged in real-time.', + 'ai-trading-assistant.form.apiKey': 'API Key', + 'ai-trading-assistant.form.exchange': 'Select Exchange', + 'ai-trading-assistant.form.secretKey': 'Secret Key', + 'ai-trading-assistant.form.passphrase': 'Passphrase', + 'ai-trading-assistant.form.testConnection': 'Test Connection', + 'ai-trading-assistant.form.symbol': 'Trading Pair', + 'ai-trading-assistant.form.symbolHint': 'Select the trading pair to trade', + 'ai-trading-assistant.form.initialCapital': 'Initial Capital (Margin)', + 'ai-trading-assistant.form.leverage': 'Leverage', + 'ai-trading-assistant.form.timeframe': 'Timeframe', + 'ai-trading-assistant.form.timeframe1m': '1 Minute', + 'ai-trading-assistant.form.timeframe5m': '5 Minutes', + 'ai-trading-assistant.form.timeframe15m': '15 Minutes', + 'ai-trading-assistant.form.timeframe30m': '30 Minutes', + 'ai-trading-assistant.form.timeframe1H': '1 Hour', + 'ai-trading-assistant.form.timeframe4H': '4 Hours', + 'ai-trading-assistant.form.timeframe1D': '1 Day', + 'ai-trading-assistant.form.marketType': 'Market Type', + 'ai-trading-assistant.form.marketTypeFutures': 'Futures', + 'ai-trading-assistant.form.marketTypeSpot': 'Spot', + 'ai-trading-assistant.form.totalPnl': 'Total PnL', + 'ai-trading-assistant.form.customPrompt': 'Custom Prompt', + 'ai-trading-assistant.form.customPromptHint': 'Optional, used to customize AI trading strategy and decision logic', + 'ai-trading-assistant.form.cancel': 'Cancel', + 'ai-trading-assistant.form.prev': 'Previous', + 'ai-trading-assistant.form.next': 'Next', + 'ai-trading-assistant.form.confirmCreate': 'Confirm Create', + 'ai-trading-assistant.form.confirmEdit': 'Confirm Edit', + 'ai-trading-assistant.messages.createSuccess': 'Strategy created successfully', + 'ai-trading-assistant.messages.createFailed': 'Failed to create strategy', + 'ai-trading-assistant.messages.updateSuccess': 'Strategy updated successfully', + 'ai-trading-assistant.messages.updateFailed': 'Failed to update strategy', + 'ai-trading-assistant.messages.deleteSuccess': 'Strategy deleted successfully', + 'ai-trading-assistant.messages.deleteFailed': 'Failed to delete strategy', + 'ai-trading-assistant.messages.startSuccess': 'Strategy started successfully', + 'ai-trading-assistant.messages.startFailed': 'Failed to start strategy', + 'ai-trading-assistant.messages.stopSuccess': 'Strategy stopped successfully', + 'ai-trading-assistant.messages.stopFailed': 'Failed to stop strategy', + 'ai-trading-assistant.messages.loadFailed': 'Failed to load strategy list', + 'ai-trading-assistant.messages.loadDecisionsFailed': 'Failed to load AI decision records', + 'ai-trading-assistant.messages.deleteConfirm': 'Are you sure you want to delete this strategy? This action cannot be undone.', + 'ai-trading-assistant.placeholders.inputStrategyName': 'Please enter strategy name', + 'ai-trading-assistant.placeholders.selectModelId': 'Please select AI model', + 'ai-trading-assistant.placeholders.selectDecideInterval': 'Please select decision interval', + 'ai-trading-assistant.placeholders.startTime': 'Start Time', + 'ai-trading-assistant.placeholders.endTime': 'End Time', + 'ai-trading-assistant.placeholders.inputApiKey': 'Please enter API Key', + 'ai-trading-assistant.placeholders.selectExchange': 'Please select an exchange', + 'ai-trading-assistant.placeholders.inputSecretKey': 'Please enter Secret Key', + 'ai-trading-assistant.placeholders.inputPassphrase': 'Please enter Passphrase', + 'ai-trading-assistant.placeholders.selectSymbol': 'Please select trading pair, e.g.: BTC/USDT', + 'ai-trading-assistant.placeholders.selectTimeframe': 'Please select timeframe', + 'ai-trading-assistant.placeholders.inputCustomPrompt': 'Please enter custom prompt (optional)', + 'ai-trading-assistant.validation.strategyNameRequired': 'Please enter strategy name', + 'ai-trading-assistant.validation.modelIdRequired': 'Please select AI model', + 'ai-trading-assistant.validation.runPeriodRequired': 'Please select run period', + 'ai-trading-assistant.validation.apiKeyRequired': 'Please enter API Key', + 'ai-trading-assistant.validation.exchangeRequired': 'Please select an exchange', + 'ai-trading-assistant.validation.secretKeyRequired': 'Please enter Secret Key', + 'ai-trading-assistant.validation.symbolRequired': 'Please select trading pair', + 'ai-trading-assistant.validation.initialCapitalRequired': 'Please enter initial capital', + 'ai-trading-assistant.table.time': 'Time', + 'ai-trading-assistant.table.type': 'Type', + 'ai-trading-assistant.table.price': 'Price', + 'ai-trading-assistant.table.amount': 'Amount', + 'ai-trading-assistant.table.value': 'Value', + 'ai-trading-assistant.table.symbol': 'Trading Pair', + 'ai-trading-assistant.table.side': 'Side', + 'ai-trading-assistant.table.size': 'Position Size', + 'ai-trading-assistant.table.entryPrice': 'Entry Price', + 'ai-trading-assistant.table.currentPrice': 'Current Price', + 'ai-trading-assistant.table.unrealizedPnl': 'Unrealized P&L', + 'ai-trading-assistant.table.profit': 'Profit', + 'ai-trading-assistant.table.openLong': 'Open Long', + 'ai-trading-assistant.table.closeLong': 'Close Long', + 'ai-trading-assistant.table.openShort': 'Open Short', + 'ai-trading-assistant.table.closeShort': 'Close Short', + 'ai-trading-assistant.table.addLong': 'Add Long', + 'ai-trading-assistant.table.addShort': 'Add Short', + 'ai-trading-assistant.table.closeShortProfit': 'Close Short (TP)', + 'ai-trading-assistant.table.closeShortStop': 'Close Short (SL)', + 'ai-trading-assistant.table.closeLongProfit': 'Close Long (TP)', + 'ai-trading-assistant.table.closeLongStop': 'Close Long (SL)', + 'ai-trading-assistant.table.buy': 'Buy', + 'ai-trading-assistant.table.sell': 'Sell', + 'ai-trading-assistant.table.long': 'Long', + 'ai-trading-assistant.table.short': 'Short', + 'ai-trading-assistant.table.hold': 'Hold', + 'ai-trading-assistant.table.reasoning': 'Analysis Reasoning', + 'ai-trading-assistant.table.decisions': 'Decisions', + 'ai-trading-assistant.table.riskAssessment': 'Risk', + 'ai-trading-assistant.table.confidence': 'Confidence', + 'ai-trading-assistant.table.totalRecords': 'Total {total} records', + 'ai-trading-assistant.table.noPositions': 'No positions', + 'ai-trading-assistant.detail.title': 'Strategy Details', + 'ai-trading-assistant.equity.noData': 'No equity data', + 'ai-trading-assistant.equity.equity': 'Equity', + 'ai-trading-assistant.exchange.testFailed': 'Connection test failed', + 'ai-trading-assistant.exchange.connectionSuccess': 'Connection successful', + 'ai-trading-assistant.exchange.connectionFailed': 'Connection failed', + 'ai-trading-assistant.form.advancedSettings': 'Advanced Settings', + 'ai-trading-assistant.form.orderMode': 'Order Mode', + 'ai-trading-assistant.form.orderModeMaker': 'Maker (Limit)', + 'ai-trading-assistant.form.orderModeTaker': 'Taker (Market)', + 'ai-trading-assistant.form.orderModeHint': 'Maker mode uses limit orders with lower fees; Taker mode executes immediately with higher fees', + 'ai-trading-assistant.form.makerWaitSec': 'Maker Wait Time (seconds)', + 'ai-trading-assistant.form.makerWaitSecHint': 'Time to wait for order fill before canceling and retrying', + 'ai-trading-assistant.form.makerRetries': 'Maker Retries', + 'ai-trading-assistant.form.makerRetriesHint': 'Maximum number of retries when maker order is not filled', + 'ai-trading-assistant.form.fallbackToMarket': 'Fallback to Market on failure', + 'ai-trading-assistant.form.fallbackToMarketHint': 'If limit order (open/close) is not filled, downgrade to market order to ensure execution', + 'ai-trading-assistant.form.marginMode': 'Margin Mode', + 'ai-trading-assistant.form.marginModeCross': 'Cross Margin', + 'ai-trading-assistant.form.marginModeIsolated': 'Isolated Margin', + 'ai-analysis.title': 'Quantum Trading Engine', + 'ai-analysis.system.online': 'ONLINE', + 'ai-analysis.system.agents': 'AGENTS', + 'ai-analysis.system.active': 'ACTIVE', + 'ai-analysis.system.stage': 'STAGE', + 'ai-analysis.panel.roster': 'AGENTS ROSTER', + 'ai-analysis.panel.thinking': 'Thinking...', + 'ai-analysis.panel.done': 'Done', + 'ai-analysis.panel.standby': 'Standby', + 'ai-analysis.input.title': 'QUANTUM TRADING ENGINE', + 'ai-analysis.input.placeholder': 'SELECT TARGET ASSET (e.g. BTC/USDT)', + 'ai-analysis.input.watchlist': 'Watchlist', + 'ai-analysis.input.start': 'START ANALYSIS', + 'ai-analysis.input.recent': 'RECENT MISSIONS:', + 'ai-analysis.vis.stage': 'STAGE', + 'ai-analysis.vis.processing': 'PROCESSING', + 'ai-analysis.result.complete': 'ANALYSIS COMPLETE', + 'ai-analysis.result.signal': 'FINAL SIGNAL', + 'ai-analysis.result.confidence': 'CONFIDENCE:', + 'ai-analysis.result.new': 'NEW ANALYSIS', + 'ai-analysis.result.full': 'VIEW FULL DOSSIER', + 'ai-analysis.logs.title': 'SYSTEM LOGS', + 'ai-analysis.modal.title': 'CLASSIFIED REPORT', + 'ai-analysis.modal.fundamental': 'FUNDAMENTAL ANALYSIS', + 'ai-analysis.modal.technical': 'TECHNICAL ANALYSIS', + 'ai-analysis.modal.sentiment': 'SENTIMENT ANALYSIS', + 'ai-analysis.modal.risk': 'RISK ASSESSMENT', + 'ai-analysis.stage.idle': 'IDLE', + 'ai-analysis.stage.1': 'PHASE 1: MULTI-DIMENSIONAL ANALYSIS', + 'ai-analysis.stage.2': 'PHASE 2: BULL/BEAR DEBATE', + 'ai-analysis.stage.3': 'PHASE 3: STRATEGIC PLANNING', + 'ai-analysis.stage.4': 'PHASE 4: RISK COMMITTEE REVIEW', + 'ai-analysis.stage.complete': 'COMPLETE', + 'ai-analysis.agent.investment_director': 'Investment Director', + 'ai-analysis.agent.role.investment_director': 'Comprehensive Analysis & Final Conclusion', + 'ai-analysis.agent.market': 'Market Analyst', + 'ai-analysis.agent.role.market': 'Technical & Market Data', + 'ai-analysis.agent.fundamental': 'Fundamental Analyst', + 'ai-analysis.agent.role.fundamental': 'Financials & Valuation', + 'ai-analysis.agent.technical': 'Technical Analyst', + 'ai-analysis.agent.role.technical': 'Technical Indicators & Charts', + 'ai-analysis.agent.news': 'News Analyst', + 'ai-analysis.agent.role.news': 'Global News Filter', + 'ai-analysis.agent.sentiment': 'Sentiment Analyst', + 'ai-analysis.agent.role.sentiment': 'Social & Emotional', + 'ai-analysis.agent.risk': 'Risk Analyst', + 'ai-analysis.agent.role.risk': 'Basic Risk Check', + 'ai-analysis.agent.bull': 'Bull Researcher', + 'ai-analysis.agent.role.bull': 'Growth Catalyst Hunter', + 'ai-analysis.agent.bear': 'Bear Researcher', + 'ai-analysis.agent.role.bear': 'Risk & Flaw Hunter', + 'ai-analysis.agent.manager': 'Research Manager', + 'ai-analysis.agent.role.manager': 'Debate Moderator', + 'ai-analysis.agent.trader': 'Trader Agent', + 'ai-analysis.agent.role.trader': 'Execution Strategist', + 'ai-analysis.agent.risky': 'Risky Analyst', + 'ai-analysis.agent.role.risky': 'Aggressive Strategy', + 'ai-analysis.agent.neutral': 'Neutral Analyst', + 'ai-analysis.agent.role.neutral': 'Balanced Strategy', + 'ai-analysis.agent.safe': 'Safe Analyst', + 'ai-analysis.agent.role.safe': 'Conservative Strategy', + 'ai-analysis.agent.cro': 'Risk Manager (CRO)', + 'ai-analysis.agent.role.cro': 'Final Decision Authority', + 'ai-analysis.script.market': 'Fetching OHLCV data from major exchanges...', + 'ai-analysis.script.fundamental': 'Retrieving quarterly financial reports...', + 'ai-analysis.script.technical': 'Analyzing technical indicators and chart patterns...', + 'ai-analysis.script.news': 'Scanning global financial news feeds...', + 'ai-analysis.script.sentiment': 'Analyzing social media trends...', + 'ai-analysis.script.risk': 'Calculating historical volatility...', + 'invite.inviteLink': 'Invite Link', + 'invite.copy': 'Copy Link', + 'invite.copySuccess': 'Copied successfully!', + 'invite.copyFailed': 'Copy failed, please copy manually', + 'invite.noInviteLink': 'Invite link not generated', + 'invite.totalInvites': 'Total Invites', + 'invite.totalReward': 'Total Reward', + 'invite.rules': 'Invite Rules', + 'invite.rule1': 'You will receive a reward for each friend you successfully invite to register', + 'invite.rule2': 'When your invited friend completes their first transaction, you will receive an additional reward', + 'invite.rule3': 'Invite rewards will be directly credited to your account', + 'invite.inviteList': 'Invite List', + 'invite.tasks': 'Task Center', + 'invite.inviteeName': 'Invitee', + 'invite.inviteTime': 'Invite Time', + 'invite.status': 'Status', + 'invite.reward': 'Reward', + 'invite.active': 'Active', + 'invite.inactive': 'Inactive', + 'invite.completed': 'Completed', + 'invite.claimed': 'Claimed', + 'invite.pending': 'Pending', + 'invite.goToTask': 'Go to Task', + 'invite.claimReward': 'Claim Reward', + 'invite.verify': 'Verify', + 'invite.verifySuccess': 'Verification successful! Task completed', + 'invite.verifyNotCompleted': 'Task not completed yet, please complete the task first', + 'invite.verifyFailed': 'Verification failed, please try again later', + 'invite.claimSuccess': 'Successfully claimed {reward} QDT!', + 'invite.claimFailed': 'Claim failed, please try again later', + 'invite.totalRecords': 'Total {total} records', + 'invite.task.twitter.title': 'Share Tweet on X (Twitter)', + 'invite.task.twitter.desc': 'Share our official tweet to your X (Twitter) account', + 'invite.task.youtube.title': 'Follow our YouTube Channel', + 'invite.task.youtube.desc': 'Subscribe and follow our official YouTube channel', + 'invite.task.telegram.title': 'Join Telegram Group', + 'invite.task.telegram.desc': 'Join our official Telegram community group', + 'invite.task.discord.title': 'Join Discord Server', + 'invite.task.discord.desc': 'Join our Discord community server', + 'message': '-', + 'layouts.usermenu.dialog.title': 'Message', + 'layouts.usermenu.dialog.content': 'Are you sure you would like to logout?', + 'layouts.userLayout.title': 'Clarity from Uncertainty', + // Signal Robot + 'signal-robot.title': 'Signal Robot Console', + 'signal-robot.createBot': 'Create New Bot', + 'signal-robot.search.nameOrSymbol': 'Name/Symbol', + 'signal-robot.search.placeholder': 'Search bot name or symbol', + 'signal-robot.search.status': 'Status', + 'signal-robot.search.statusAll': 'All', + 'signal-robot.search.statusRunning': 'Running', + 'signal-robot.search.statusPaused': 'Paused', + 'signal-robot.search.query': 'Query', + 'signal-robot.search.reset': 'Reset', + 'signal-robot.table.botName': 'Bot Name', + 'signal-robot.table.symbolTimeframe': 'Symbol/Timeframe', + 'signal-robot.table.triggerStrategy': 'Trigger Strategy', + 'signal-robot.table.notificationChannels': 'Notification Channels', + 'signal-robot.table.status': 'Status', + 'signal-robot.table.action': 'Action', + 'signal-robot.table.triggerConditions': '{count} trigger conditions', + 'signal-robot.table.monitoring': 'Monitoring', + 'signal-robot.table.paused': 'Paused', + 'signal-robot.table.edit': 'Edit', + 'signal-robot.table.pause': 'Pause', + 'signal-robot.table.start': 'Start', + 'signal-robot.table.delete': 'Delete', + 'signal-robot.table.deleteConfirm': 'Are you sure you want to delete this bot?', + 'signal-robot.table.deleteSuccess': 'Deleted successfully', + 'signal-robot.table.startSuccess': 'Bot started', + 'signal-robot.table.pauseSuccess': 'Bot paused', + 'signal-robot.channel.telegram': 'Telegram', + 'signal-robot.channel.discord': 'Discord', + 'signal-robot.channel.email': 'Email', + 'signal-robot.channel.webhook': 'Webhook', + 'signal-robot.channel.browser': 'Browser Push', + 'signal-robot.modal.createTitle': 'Create New Signal Robot', + 'signal-robot.modal.editTitle': 'Edit Signal Robot', + 'signal-robot.modal.step.basic': 'Basic Settings', + 'signal-robot.modal.step.entry': 'Entry Strategy', + 'signal-robot.modal.step.position': 'Position Management', + 'signal-robot.modal.step.risk': 'Risk Control', + 'signal-robot.modal.step.notify': 'Notification Config', + 'signal-robot.modal.prev': 'Previous', + 'signal-robot.modal.next': 'Next', + 'signal-robot.modal.preview': 'Preview Backtest', + 'signal-robot.modal.complete': 'Complete & Run', + 'signal-robot.modal.saveSuccess': 'Saved successfully', + 'signal-robot.modal.previewDeveloping': 'Preview feature is under development...', + 'signal-robot.modal.basicInfoRequired': 'Please complete basic information', + 'signal-robot.basic.alert': 'Set basic information for the bot', + 'signal-robot.basic.title': 'Basic Information', + 'signal-robot.basic.botName': 'Bot Name', + 'signal-robot.basic.botNamePlaceholder': 'e.g.: BTC SuperTrend Strategy', + 'signal-robot.basic.symbol': 'Trading Pair', + 'signal-robot.basic.symbolPlaceholder': 'Select from watchlist', + 'signal-robot.basic.noWatchlist': 'No watchlist items, please add in market page first', + 'signal-robot.basic.timeframe': 'K-Line Period', + 'signal-robot.basic.timeframe15m': '15 Minutes', + 'signal-robot.basic.timeframe30m': '30 Minutes', + 'signal-robot.basic.timeframe1h': '1 Hour', + 'signal-robot.basic.timeframe4h': '4 Hours', + 'signal-robot.basic.timeframe1d': '1 Day', + 'signal-robot.basic.autoNameSuggestion': '{symbol} Signal Robot', + 'signal-robot.entry.alert': 'Trigger entry signal when all conditions below are met', + 'signal-robot.entry.condition': 'Condition {index}', + 'signal-robot.entry.indicator': 'Indicator', + 'signal-robot.entry.indicatorSupertrend': 'SuperTrend', + 'signal-robot.entry.indicatorEma': 'EMA (Exponential Moving Average)', + 'signal-robot.entry.indicatorRsi': 'RSI (Relative Strength Index)', + 'signal-robot.entry.indicatorMacd': 'MACD', + 'signal-robot.entry.indicatorBollinger': 'Bollinger Bands', + 'signal-robot.entry.atrPeriod': 'ATR Period', + 'signal-robot.entry.multiplier': 'Multiplier', + 'signal-robot.entry.triggerSignal': 'Trigger Signal', + 'signal-robot.entry.signalTrendBullish': 'Trend Bullish', + 'signal-robot.entry.signalTrendBearish': 'Trend Bearish', + 'signal-robot.entry.signalIsUptrend': 'Is Uptrend', + 'signal-robot.entry.signalIsDowntrend': 'Is Downtrend', + 'signal-robot.entry.period': 'Period', + 'signal-robot.entry.compareCondition': 'Compare Condition', + 'signal-robot.entry.priceAbove': 'Price > EMA (Price Above)', + 'signal-robot.entry.priceBelow': 'Price < EMA (Price Below)', + 'signal-robot.entry.crossUp': 'Price Crosses Above EMA (Golden Cross)', + 'signal-robot.entry.crossDown': 'Price Crosses Below EMA (Death Cross)', + 'signal-robot.entry.compare': 'Compare', + 'signal-robot.entry.greaterThan': 'Greater Than', + 'signal-robot.entry.lessThan': 'Less Than', + 'signal-robot.entry.crossUpShort': 'Cross Up', + 'signal-robot.entry.crossDownShort': 'Cross Down', + 'signal-robot.entry.threshold': 'Threshold', + 'signal-robot.entry.selectIndicator': 'Please select an indicator to configure parameters', + 'signal-robot.entry.addCondition': 'Add Condition', + 'signal-robot.position.alert': 'Configure initial position size, leverage and pyramiding rules', + 'signal-robot.position.basicTitle': 'Basic Position Management', + 'signal-robot.position.initialSize': 'Initial Size (Capital %)', + 'signal-robot.position.initialSizeHint': 'Recommended 5% - 20%', + 'signal-robot.position.leverage': 'Leverage', + 'signal-robot.position.leverageHint': 'Please ensure your exchange account supports this leverage', + 'signal-robot.position.maxPyramiding': 'Max Pyramiding Times', + 'signal-robot.position.maxPyramidingHint': '0 means no pyramiding', + 'signal-robot.position.pyramidingTitle': 'Pyramiding Rules', + 'signal-robot.position.pyramidingEnabled': 'Enabled', + 'signal-robot.position.pyramidingDisabled': 'Disabled', + 'signal-robot.position.pyramidingCondition': 'Pyramiding Trigger Condition', + 'signal-robot.position.priceRisePct': 'Price Rises (Long) / Falls (Short)', + 'signal-robot.position.profitPct': 'Profit Reaches', + 'signal-robot.position.triggerThreshold': 'Trigger Threshold (%)', + 'signal-robot.position.triggerThresholdHint': 'e.g. 3%: add position once every 3% rise', + 'signal-robot.position.addSize': 'Single Add Size (Capital %)', + 'signal-robot.position.pyramidingDisabledHint': 'Pyramiding disabled, only initial position will be executed', + 'signal-robot.risk.alert': 'Configure take profit, stop loss and exit rules', + 'signal-robot.risk.stopLossTitle': 'Stop Loss', + 'signal-robot.risk.stopLossEnabled': 'Enabled', + 'signal-robot.risk.stopLossDisabled': 'Disabled', + 'signal-robot.risk.stopLossType': 'Stop Loss Type', + 'signal-robot.risk.stopLossFixedPct': 'Fixed Percentage', + 'signal-robot.risk.stopLossAtrMultiplier': 'ATR Multiplier', + 'signal-robot.risk.stopLossValue': 'Value', + 'signal-robot.risk.stopLossFixedHint': 'e.g. 2%: stop loss when loss reaches 2%', + 'signal-robot.risk.stopLossAtrHint': 'e.g. 2.0: stop loss when price touches ATR*2.0', + 'signal-robot.risk.stopLossDisabledHint': 'Stop loss disabled (high risk)', + 'signal-robot.risk.trailingStopTitle': 'Trailing Stop', + 'signal-robot.risk.activationProfit': 'Activation Profit (%)', + 'signal-robot.risk.activationProfitHint': 'Start trailing when profit reaches this level', + 'signal-robot.risk.callbackPct': 'Callback (%)', + 'signal-robot.risk.callbackPctHint': 'Trigger take profit when price falls from peak by this percentage', + 'signal-robot.risk.trailingStopDisabledHint': 'Trailing stop disabled', + 'signal-robot.risk.otherExitTitle': 'Other Exit Rules', + 'signal-robot.risk.signalExit': 'Allow Signal Exit', + 'signal-robot.risk.signalExitHint': 'Close position immediately when reverse signal appears (e.g. close long when short signal appears)', + 'signal-robot.notify.alert': 'Configure notification methods when signal is triggered', + 'signal-robot.notify.channelTitle': 'Notification Channels', + 'signal-robot.notify.discordWebhook': 'Discord Webhook', + 'signal-robot.notify.genericWebhook': 'Generic Webhook', + 'signal-robot.notify.webhookUrl': 'Webhook URL', + 'signal-robot.notify.webhookUrlPlaceholder': 'https://example.com/webhook', + 'signal-robot.notify.discordUrl': 'Discord URL', + 'signal-robot.notify.discordUrlPlaceholder': 'https://discord.com/api/webhooks/...', + 'signal-robot.operator.greaterThan': 'Greater Than', + 'signal-robot.operator.lessThan': 'Less Than', + 'signal-robot.operator.equal': 'Equal', + 'signal-robot.operator.goldenCross': 'Golden Cross', + 'signal-robot.operator.deathCross': 'Death Cross', + 'signal-robot.operator.increaseBy': 'Increase By', + 'signal-robot.operator.decreaseBy': 'Decrease By', + 'signal-robot.indicator.price': 'Price', + 'signal-robot.indicator.rsi': 'RSI', + 'signal-robot.indicator.kdjK': 'KDJ(K)', + 'signal-robot.indicator.kdjJ': 'KDJ(J)', + 'signal-robot.indicator.macd': 'MACD', + 'signal-robot.indicator.macdHist': 'MACD Histogram', + 'signal-robot.indicator.bollingerUp': 'Bollinger Upper', + 'signal-robot.indicator.bollingerLow': 'Bollinger Lower', + 'signal-robot.indicator.volume': 'Volume', + 'signal-robot.indicator.changePct1h': '1H Change %', + 'signal-robot.indicator.changePct24h': '24H Change %', + 'signal-robot.indicator.signalLine': 'Signal Line' +} + +export default { + ...components, + ...locale +} diff --git a/quantdinger_vue/src/locales/lang/fr-FR.js b/quantdinger_vue/src/locales/lang/fr-FR.js new file mode 100644 index 0000000..b80a095 --- /dev/null +++ b/quantdinger_vue/src/locales/lang/fr-FR.js @@ -0,0 +1,1768 @@ +import antdFrFR from 'ant-design-vue/es/locale-provider/fr_FR' +import momentFR from 'moment/locale/fr' + +const components = { + antLocale: antdFrFR, + momentName: 'fr', + momentLocale: momentFR +} + +const locale = { + 'submit': 'Soumettre', + 'save': 'enregistrer', + 'submit.ok': 'Soumission réussie', + 'save.ok': 'Enregistré avec succès', + 'menu.welcome': 'bienvenue', + 'menu.home': "Page d'accueil", + 'menu.dashboard': 'Tableau de bord', + 'menu.dashboard.indicator': 'Analyse des indicateurs', + 'menu.dashboard.community': 'communauté des indicateurs', + 'menu.dashboard.analysis': "Analyse de l'IA", + 'menu.dashboard.tradingAssistant': 'Assistante commerciale', + 'menu.dashboard.aiTradingAssistant': 'Assistant de trading IA', + 'menu.dashboard.signalRobot': 'Robot de signal', + 'menu.dashboard.monitor': 'Page de surveillance', + 'menu.dashboard.workplace': 'établi', + 'menu.form': 'page de formulaire', + 'menu.form.basic-form': 'forme de base', + 'menu.form.step-form': 'formulaire étape par étape', + 'menu.form.step-form.info': 'Formulaire étape par étape (remplir les informations de transfert)', + 'menu.form.step-form.confirm': 'Formulaire étape par étape (confirmer les informations de transfert)', + 'menu.form.step-form.result': 'Formulaire étape par étape (à compléter)', + 'menu.form.advanced-form': 'Formulaires avancés', + 'menu.list': 'Page de liste', + 'menu.list.table-list': 'Formulaire de demande', + 'menu.list.basic-list': 'Liste standard', + 'menu.list.card-list': 'liste de cartes', + 'menu.list.search-list': 'liste de recherche', + 'menu.list.search-list.articles': 'Liste de recherche (articles)', + 'menu.list.search-list.projects': 'Liste de recherche (projet)', + 'menu.list.search-list.applications': 'Liste de recherche (application)', + 'menu.profile': 'Page de détails', + 'menu.profile.basic': 'Page de détails de base', + 'menu.profile.advanced': 'Page de détails avancés', + 'menu.result': 'Page de résultats', + 'menu.result.success': 'page de réussite', + 'menu.result.fail': "page d'échec", + 'menu.exception': "Page d'exceptions", + 'menu.exception.not-permission': '403', + 'menu.exception.not-find': '404', + 'menu.exception.server-error': '500', + 'menu.exception.trigger': 'erreur de déclenchement', + 'menu.account': 'Page personnelle', + 'menu.account.center': 'Centre personnel', + 'menu.account.settings': 'paramètres personnels', + 'menu.account.trigger': 'Erreur de déclenchement', + 'menu.account.logout': 'Se déconnecter', + 'menu.wallet': 'mon portefeuille', + 'menu.docs': 'Centre de documentation', + 'menu.docs.detail': 'Détails du document', + 'menu.header.refreshPage': 'actualiser la page', + 'menu.invite.friends': 'Inviter des amis', + 'app.setting.pagestyle': 'paramètres de style généraux', + 'app.setting.pagestyle.light': 'Style de menu lumineux', + 'app.setting.pagestyle.dark': 'style de menu sombre', + 'app.setting.pagestyle.realdark': 'mode sombre', + 'app.setting.themecolor': 'couleur du thème', + 'app.setting.navigationmode': 'Mode navigation', + 'app.setting.sidemenu.nav': 'Navigation dans la barre latérale', + 'app.setting.topmenu.nav': 'Navigation dans la barre supérieure', + 'app.setting.content-width': 'largeur de la zone de contenu', + 'app.setting.content-width.tooltip': "Ce paramètre n'est efficace que lorsque [Navigation dans la barre supérieure]", + 'app.setting.content-width.fixed': 'Corrigé', + 'app.setting.content-width.fluid': 'diffusion en continu', + 'app.setting.fixedheader': 'En-tête fixe', + 'app.setting.fixedheader.tooltip': "Configurable lorsqu'il est fixe", + 'app.setting.autoHideHeader': "Masquer l'en-tête lors du défilement", + 'app.setting.fixedsidebar': 'Menu latéral fixe', + 'app.setting.sidemenu': 'Disposition du menu latéral', + 'app.setting.topmenu': 'Disposition du menu supérieur', + 'app.setting.othersettings': 'Autres paramètres', + 'app.setting.weakmode': 'Mode de faiblesse des couleurs', + 'app.setting.multitab': 'Mode multi-onglets', + 'app.setting.copy': 'Paramètres de copie', + 'app.setting.loading': 'Chargement du thème', + 'app.setting.copyinfo': 'Copiez les paramètres avec succès src/config/defaultSettings.js', + 'app.setting.copy.success': 'Copie terminée', + 'app.setting.copy.fail': 'Échec de la copie', + 'app.setting.theme.switching': 'Changer de thème !', + 'app.setting.production.hint': "La barre de configuration est uniquement utilisée pour l'aperçu dans l'environnement de développement et ne sera pas affichée dans l'environnement de production. Veuillez copier et modifier le fichier de configuration manuellement.", + 'app.setting.themecolor.daybreak': 'Bleu aube (par défaut)', + 'app.setting.themecolor.dust': 'crépuscule', + 'app.setting.themecolor.volcano': 'volcan', + 'app.setting.themecolor.sunset': 'coucher de soleil', + 'app.setting.themecolor.cyan': 'Mingqing', + 'app.setting.themecolor.green': 'aurore verte', + 'app.setting.themecolor.geekblue': 'bleu geek', + 'app.setting.themecolor.purple': 'Jiang Zi', + 'app.setting.tooltip': 'Paramètres des pages', + 'user.login.userName': "Nom d'utilisateur", + 'user.login.password': 'Mot de passe', + 'user.login.username.placeholder': 'Compte : administrateur', + 'user.login.password.placeholder': 'Mot de passe : admin ou ant.design', + 'user.login.message-invalid-credentials': 'La connexion a échoué, veuillez vérifier votre e-mail et votre code de vérification', + 'user.login.message-invalid-verification-code': 'Erreur de code de vérification', + 'user.login.tab-login-credentials': 'Connexion par mot de passe du compte', + 'user.login.tab-login-email': 'Connexion par e-mail', + 'user.login.tab-login-mobile': 'Connexion au numéro de téléphone portable', + 'user.login.captcha.placeholder': 'Veuillez saisir le code de vérification graphique', + 'user.login.mobile.placeholder': 'Numéro de téléphone portable', + 'user.login.mobile.verification-code.placeholder': 'Code de vérification', + 'user.login.email.placeholder': 'Veuillez entrer votre adresse e-mail', + 'user.login.email.verification-code.placeholder': 'Veuillez entrer le code de vérification', + 'user.login.email.sending': "Le code de vérification est en cours d'envoi...", + 'user.login.email.send-success-title': 'Conseils', + 'user.login.email.send-success': 'Code de vérification envoyé avec succès, veuillez vérifier votre email', + 'user.login.sms.send-success': 'Code de vérification envoyé avec succès, veuillez vérifier le message texte', + 'user.login.remember-me': 'Connexion automatique', + 'user.login.forgot-password': 'Mot de passe oublié', + 'user.login.sign-in-with': 'Autres méthodes de connexion', + 'user.login.signup': 'Créer un compte', + 'user.login.login': 'Connexion', + 'user.register.register': "S'inscrire", + 'user.register.email.placeholder': 'Courriel', + 'user.register.password.placeholder': 'Veuillez saisir au moins 6 caractères. Veuillez ne pas utiliser de mots de passe faciles à deviner.', + 'user.register.password.popover-message': 'Veuillez saisir au moins 6 caractères. Veuillez ne pas utiliser de mots de passe faciles à deviner.', + 'user.register.confirm-password.placeholder': 'Confirmer le mot de passe', + 'user.register.get-verification-code': 'Obtenir le code de vérification', + 'user.register.sign-in': 'Connectez-vous en utilisant un compte existant', + 'user.register-result.msg': 'Votre compte : {email} Inscription réussie', + 'user.register-result.activation-email': "L'e-mail d'activation a été envoyé dans votre boîte mail et est valable 24 heures. Veuillez vous connecter rapidement à votre messagerie et cliquer sur le lien contenu dans l'e-mail pour activer votre compte.", + 'user.register-result.back-home': "Retour à la page d'accueil", + 'user.register-result.view-mailbox': 'Vérifiez votre boîte aux lettres', + 'user.email.required': 'Veuillez entrer votre adresse e-mail !', + 'user.email.wrong-format': "Le format de l'adresse e-mail est incorrect !", + 'user.userName.required': 'Veuillez saisir votre nom de compte ou votre adresse e-mail', + 'user.password.required': 'Veuillez entrer votre mot de passe !', + 'user.password.twice.msg': 'Les mots de passe saisis deux fois ne correspondent pas !', + 'user.password.strength.msg': "Le mot de passe n'est pas assez fort", + 'user.password.strength.strong': 'Force : Forte', + 'user.password.strength.medium': 'Force : moyenne', + 'user.password.strength.low': 'Force : faible', + 'user.password.strength.short': 'Force : trop courte', + 'user.confirm-password.required': 'Veuillez confirmer votre mot de passe !', + 'user.phone-number.required': 'Veuillez saisir le bon numéro de téléphone portable', + 'user.phone-number.wrong-format': 'Le format du numéro de téléphone portable est erroné !', + 'user.verification-code.required': 'Veuillez entrer le code de vérification !', + 'user.captcha.required': 'Veuillez saisir le code de vérification graphique !', + 'user.login.infos': "QuantDinger est un outil auxiliaire d'analyse boursière multi-agents d'IA et n'a pas de qualifications en conseil en investissement en valeurs mobilières. Tous les résultats d'analyse, scores et avis de référence de la plateforme sont automatiquement générés par l'IA sur la base de données historiques et sont utilisés uniquement à des fins d'apprentissage, de recherche et d'échange technique, et ne constituent aucun conseil d'investissement ou base de prise de décision. L'investissement en actions implique divers risques tels que le risque de marché, le risque de liquidité, le risque politique, etc., qui peuvent entraîner une perte du principal. Les utilisateurs doivent prendre des décisions indépendantes en fonction de leur propre tolérance au risque, et tout comportement d'investissement et conséquences découlant de l'utilisation de cet outil seront à la charge de l'utilisateur. Le marché est risqué et les investissements doivent être prudents.", + 'user.login.tab-login-web3': 'Connexion Web3', + 'user.login.web3.tip': "Connexion par signature à l'aide du portefeuille", + 'user.login.web3.connect': 'Connectez le portefeuille et connectez-vous', + 'user.login.web3.no-wallet': 'Portefeuille non détecté', + 'user.login.web3.no-address': 'Adresse du portefeuille non obtenue', + 'user.login.web3.nonce-failed': "Impossible d'obtenir un nombre aléatoire", + 'user.login.web3.verify-failed': 'La vérification de la signature a échoué', + 'user.login.web3.success': 'Connexion réussie', + 'user.login.web3.failed': 'Échec de la connexion au portefeuille', + 'nav.no_wallet': 'Portefeuille non détecté', + 'nav.copy': 'Copier', + 'nav.copy_success': 'Copié avec succès', + 'user.login.oauth.google': 'Connectez-vous avec Google', + 'user.login.oauth.github': 'Connectez-vous avec GitHub', + 'user.login.oauth.loading': "Redirection vers la page d'autorisation...", + 'user.login.oauth.failed': 'Échec de la connexion OAuth', + 'user.login.oauth.get-url-failed': "Échec de l'obtention du lien d'autorisation", + 'user.login.subtitle': 'Informations quantitatives sur les marchés mondiaux', + 'user.login.legal.title': 'Mentions légales', + 'user.login.legal.view': 'Voir les mentions légales', + 'user.login.legal.collapse': 'Réduire les mentions légales', + 'user.login.legal.agree': "J'ai lu et j'accepte les mentions légales", + 'user.login.legal.required': "Veuillez d'abord lire et vérifier les mentions légales", + 'user.login.legal.content': "QuantDinger est un outil auxiliaire d'analyse boursière multi-agents d'IA et n'a pas de qualifications en conseil en investissement en valeurs mobilières. Tous les résultats d'analyse, notes et avis de référence de la plateforme sont automatiquement générés par l'IA sur la base de données historiques et sont utilisés uniquement à des fins d'apprentissage, de recherche et d'échange technique, et ne constituent aucun conseil d'investissement ou base de prise de décision. L'investissement en actions implique divers risques tels que le risque de marché, le risque de liquidité, le risque politique, etc., qui peuvent entraîner une perte du principal. Les utilisateurs doivent prendre des décisions indépendantes en fonction de leur propre tolérance au risque, et tout comportement d'investissement et conséquences découlant de l'utilisation de cet outil seront à la charge de l'utilisateur. Le marché est risqué et les investissements doivent être prudents.", + 'user.login.privacy.title': 'Politique de confidentialité des utilisateurs', + 'user.login.privacy.view': 'Afficher la politique de confidentialité des utilisateurs', + 'user.login.privacy.collapse': 'Fermer les conditions de confidentialité des utilisateurs', + 'user.login.privacy.content': "Nous accordons une grande importance à votre vie privée et à la protection de vos données. 1) Portée de la collecte : seules les informations nécessaires à la mise en œuvre de la fonction (telles que l'e-mail, le numéro de téléphone mobile, l'indicatif régional, l'adresse du portefeuille Web3) ainsi que les journaux et informations sur l'appareil nécessaires sont collectés. 2) Objectif d'utilisation : Pour la connexion au compte et la vérification de la sécurité, la fourniture de fonctions de service, le dépannage et les exigences de conformité. 3) Stockage et sécurité : les données sont cryptées et stockées, et les autorisations et mesures de contrôle d'accès nécessaires sont prises pour tenter d'empêcher tout accès non autorisé, toute divulgation ou toute perte. 4) Partage avec des tiers : sauf si les lois et réglementations l'exigent ou si cela est nécessaire à l'exécution des services, vos informations personnelles ne seront pas partagées avec des tiers ; si des services tiers (tels que des portefeuilles, des fournisseurs de services SMS) sont impliqués, ils ne seront traités que dans la mesure minimale nécessaire à la mise en œuvre de la fonction. 5) Cookies/stockage local : utilisés pour le statut de connexion et la maintenance de session nécessaire (tels que les jetons, PHPSESSID), vous pouvez les effacer ou les restreindre dans le navigateur. 6) Droits personnels : Vous pouvez exercer vos droits d'information, de rectification, de suppression, de retrait de consentement, etc. conformément aux lois et règlements. 7) Modifications et avis : lorsque ces conditions seront mises à jour, elles seront affichées bien en évidence sur la page. En continuant à utiliser ce service, vous êtes réputé avoir lu et accepté le contenu mis à jour. Si vous n'acceptez pas ces Conditions ou toute mise à jour de celles-ci, veuillez cesser d'utiliser le Service et nous contacter.", + 'account.basicInfo': 'Informations de base', + 'account.id': 'Identifiant utilisateur', + 'account.username': "Nom d'utilisateur", + 'account.nickname': 'Surnom', + 'account.email': 'Courriel', + 'account.mobile': 'Numéro de téléphone portable', + 'account.web3address': 'adresse du portefeuille', + 'account.pid': 'Identifiant du référent', + 'account.level': 'Niveau utilisateur', + 'account.money': 'équilibre', + 'account.qdtBalance': 'Solde QDT', + 'account.score': 'Points', + 'account.createtime': "Heure d'inscription", + 'account.inviteLink': "Lien d'invitation", + 'account.recharge': 'Recharger', + 'account.rechargeTip': 'Veuillez utiliser WeChat ou Alipay pour scanner le code QR ci-dessous pour recharger.', + 'account.qrCodePlaceholder': 'Espace réservé au code QR (émulation)', + 'account.rechargeAmount': 'Montant de la recharge', + 'account.enterAmount': 'Veuillez saisir le montant de la recharge', + 'account.rechargeHint': 'Montant minimum du dépôt : 1 QDT', + 'account.confirmRecharge': 'Confirmer la recharge', + 'account.enterValidAmount': 'Veuillez saisir un montant de recharge valide', + 'account.rechargeSuccess': 'Recharge réussie ! Déposé {montant} QDT', + 'account.settings.menuMap.basic': 'Paramètres de base', + 'account.settings.menuMap.security': 'Paramètres de sécurité', + 'account.settings.menuMap.notification': 'Notification de nouveau message', + 'account.settings.menuMap.moneyLog': 'Détails du fonds', + 'account.moneyLog.empty': "Aucun détail sur le fonds pour l'instant", + 'account.moneyLog.total': '{total} enregistrements au total', + 'account.moneyLog.type.purchase': "indicateur d'achat", + 'account.moneyLog.type.recharge': 'Recharger', + 'account.moneyLog.type.refund': 'Remboursement', + 'account.moneyLog.type.reward': 'récompense', + 'account.moneyLog.type.income': "Revenu de l'indicateur", + 'account.moneyLog.type.commission': 'Frais de gestion de la plateforme', + 'wallet.balance': 'Solde QDT', + 'wallet.recharge': 'Recharger', + 'wallet.withdraw': "Retirer de l'argent", + 'wallet.filter': 'Filtrer', + 'wallet.reset': 'réinitialiser', + 'wallet.totalRecharge': 'Recharge accumulée', + 'wallet.totalWithdraw': 'Retraits cumulés', + 'wallet.totalIncome': 'Revenu cumulé', + 'wallet.records': 'Enregistrements de transactions et détails des fonds', + 'wallet.tradingRecords': 'historique des transactions', + 'wallet.moneyLog': 'Détails du fonds', + 'wallet.rechargeTip': 'Veuillez utiliser WeChat ou Alipay pour scanner le code QR ci-dessous pour recharger.', + 'wallet.qrCodePlaceholder': 'Espace réservé au code QR (émulation)', + 'wallet.rechargeAmount': 'Montant de la recharge', + 'wallet.enterAmount': 'Veuillez saisir le montant de la recharge', + 'wallet.rechargeHint': 'Montant minimum du dépôt : 1 QDT', + 'wallet.confirmRecharge': 'Confirmer la recharge', + 'wallet.enterValidAmount': 'Veuillez saisir un montant de recharge valide', + 'wallet.rechargeSuccess': 'Recharge réussie ! Déposé {montant} QDT', + 'wallet.rechargeFailed': 'La recharge a échoué', + 'wallet.withdrawTip': "Veuillez saisir le montant du retrait et l'adresse de retrait", + 'wallet.withdrawAmount': 'Montant du retrait', + 'wallet.enterWithdrawAmount': 'Veuillez saisir le montant du retrait', + 'wallet.withdrawHint': 'Montant minimum de retrait : 1 QDT', + 'wallet.withdrawAddress': 'Adresse de retrait (facultatif)', + 'wallet.enterWithdrawAddress': "Veuillez saisir l'adresse de retrait", + 'wallet.confirmWithdraw': 'Confirmer le retrait', + 'wallet.enterValidWithdrawAmount': 'Veuillez saisir un montant de retrait valide', + 'wallet.insufficientBalance': 'Solde insuffisant', + 'wallet.withdrawSuccess': 'Retrait réussi ! Retiré {montant} QDT', + 'wallet.withdrawFailed': 'Échec du retrait', + 'wallet.noTradingRecords': "Aucun enregistrement de transaction pour l'instant", + 'wallet.noMoneyLog': "Aucun détail sur le fonds pour l'instant", + 'wallet.loadTradingRecordsFailed': 'Échec du chargement des enregistrements de transaction', + 'wallet.loadMoneyLogFailed': 'Échec du chargement des détails du fonds', + 'wallet.moneyLogTotal': '{total} enregistrements au total', + 'wallet.moneyLogTypeTitle': 'Tapez', + 'wallet.moneyLogType.all': 'Tous types', + 'wallet.table.time': 'temps', + 'wallet.table.type': 'Tapez', + 'wallet.table.price': 'prix', + 'wallet.table.amount': 'Quantité', + 'wallet.table.money': 'Montant', + 'wallet.table.balance': 'équilibre', + 'wallet.table.memo': 'Remarques', + 'wallet.table.value': 'valeur', + 'wallet.table.profit': 'Profits et pertes', + 'wallet.table.commission': 'frais de traitement', + 'wallet.table.total': '{total} enregistrements au total', + 'wallet.tradeType.buy': 'acheter', + 'wallet.tradeType.sell': 'vendre', + 'wallet.tradeType.liquidation': 'liquidation forcée', + 'wallet.tradeType.openLong': 'Ouvert longtemps', + 'wallet.tradeType.addLong': 'Gadot', + 'wallet.tradeType.closeLong': 'Pinduo', + 'wallet.tradeType.closeLongStop': 'Stop loss et long', + 'wallet.tradeType.closeLongProfit': 'Prenez des bénéfices et gagnez plus de niveaux', + 'wallet.tradeType.openShort': 'Ouvert court', + 'wallet.tradeType.addShort': 'ajouter un court', + 'wallet.tradeType.closeShort': 'vide', + 'wallet.tradeType.closeShortStop': 'Arrêter la perte', + 'wallet.tradeType.closeShortProfit': 'Prenez des bénéfices et clôturez à découvert', + 'wallet.moneyLogType.purchase': "indicateur d'achat", + 'wallet.moneyLogType.recharge': 'Recharger', + 'wallet.moneyLogType.withdraw': "Retirer de l'argent", + 'wallet.moneyLogType.refund': 'Remboursement', + 'wallet.moneyLogType.reward': 'récompense', + 'wallet.moneyLogType.income': 'revenu', + 'wallet.moneyLogType.commission': 'frais de traitement', + 'wallet.web3Address.required': "Veuillez d'abord remplir l'adresse du portefeuille Web3", + 'wallet.web3Address.requiredDescription': "Avant de recharger, vous devez lier l'adresse de votre portefeuille Web3 pour recevoir la recharge.", + 'wallet.web3Address.placeholder': "Veuillez saisir l'adresse de votre portefeuille Web3 (en commençant par 0x)", + 'wallet.web3Address.save': "Enregistrer l'adresse du portefeuille", + 'wallet.web3Address.saveSuccess': 'Adresse du portefeuille enregistrée avec succès', + 'wallet.web3Address.saveFailed': "Échec de l'enregistrement de l'adresse du portefeuille", + 'wallet.web3Address.invalidFormat': 'Veuillez saisir une adresse de portefeuille Web3 valide (format Ethereum : 42 caractères commençant par 0x, ou format TRC20 : 34 caractères commençant par T)', + 'wallet.selectCoin': 'Sélectionnez la devise', + 'wallet.selectChain': 'chaîne de sélection', + 'wallet.chain.eth': 'ETH(ERC20)', + 'wallet.chain.trc20': 'TRC20', + 'wallet.chain.bsc': 'BSC', + 'wallet.targetQdtAmount': 'Le montant de QDT que vous souhaitez échanger (facultatif)', + 'wallet.targetQdtAmount.placeholder': 'Veuillez saisir la quantité de QDT que vous souhaitez utiliser, le prix actuel du QDT : {price} USDT', + 'wallet.targetQdtAmount.requiredUsdt': 'Besoin de recharge : {amount} USDT', + 'wallet.rechargeAddress': 'Adresse de recharge', + 'wallet.copyAddress': 'Copier', + 'wallet.copySuccess': 'Copiez avec succès !', + 'wallet.copyFailed': 'Échec de la copie, veuillez copier manuellement', + 'wallet.rechargeAddressHint': "Veuillez vous assurer d'utiliser {chain} pour déposer {coin} à cette adresse", + 'wallet.qdtPrice.loading': 'Chargement...', + 'wallet.rechargeTip.new': 'Veuillez sélectionner la devise et la chaîne, puis scannez le code QR ci-dessous pour recharger', + 'wallet.exchangeRate': '1 QDT ≈ {taux} USDT', + 'wallet.withdrawableAmount': 'Montant des liquidités disponibles', + 'wallet.totalBalance': 'solde total', + 'wallet.insufficientWithdrawable': 'Le montant en espèces pouvant être retiré est insuffisant. Le montant actuel qui peut être retiré est : {amount} QDT', + 'wallet.withdrawAddressRequired': "Veuillez saisir l'adresse de retrait", + 'wallet.withdrawAddressHint': "Veuillez vous assurer que l'adresse est correcte. Après le retrait, il ne peut être révoqué.", + 'wallet.withdrawSubmitSuccess': "Demande de retrait soumise avec succès, veuillez attendre l'examen", + 'menu.footer.contactUs': 'Contactez-nous', + 'menu.footer.getSupport': "Obtenir de l'aide", + 'menu.footer.socialAccounts': 'compte social', + 'menu.footer.userAgreement': "Contrat d'utilisation", + 'menu.footer.privacyPolicy': 'politique de confidentialité', + 'menu.footer.support': 'Assistance', + 'menu.footer.featureRequest': 'Demande de fonctionnalité', + 'menu.footer.email': 'Courriel', + 'menu.footer.liveChat': 'Chat en direct 24h/24 et 7j/7', + 'dashboard.analysis.title': 'Analyse multidimensionnelle', + 'dashboard.analysis.subtitle': "Plateforme d'analyse financière complète basée sur l'IA", + 'dashboard.analysis.selectSymbol': 'Sélectionnez ou saisissez le code sous-jacent', + 'dashboard.analysis.selectModel': 'Sélectionnez le modèle', + 'dashboard.analysis.startAnalysis': "Démarrer l'analyse", + 'dashboard.analysis.history': 'Histoire', + 'dashboard.analysis.tab.overview': 'analyse complète', + 'dashboard.analysis.tab.fundamental': 'Fondamentaux', + 'dashboard.analysis.tab.technical': 'technologie', + 'dashboard.analysis.tab.news': 'Actualités', + 'dashboard.analysis.tab.sentiment': 'émotions', + 'dashboard.analysis.tab.risk': 'risque', + 'dashboard.analysis.tab.debate': 'Le débat long-court', + 'dashboard.analysis.tab.decision': 'décision finale', + 'dashboard.analysis.empty.selectSymbol': "Sélectionnez une cible pour démarrer l'analyse", + 'dashboard.analysis.empty.selectSymbolDesc': "Sélectionnez dans la liste d'actions auto-sélectionnée ou entrez le code sous-jacent pour obtenir un rapport d'analyse IA multidimensionnel", + 'dashboard.analysis.empty.startAnalysis': "Cliquez sur le bouton \"Démarrer l'analyse\" pour effectuer une analyse multidimensionnelle", + 'dashboard.analysis.empty.startAnalysisDesc': "Nous vous fournirons une analyse complète portant sur plusieurs dimensions telles que les fondamentaux, la technologie, l'actualité, le sentiment et le risque.", + 'dashboard.analysis.empty.noData': "Il n'y a actuellement aucune donnée d'analyse {type}, veuillez d'abord effectuer une analyse complète", + 'dashboard.analysis.empty.noWatchlist': "Il n'y a actuellement aucun titre auto-sélectionné", + 'dashboard.analysis.empty.noHistory': "Pas encore d'historique", + 'dashboard.analysis.empty.watchlistHint': "Il n'y a actuellement aucun stock auto-sélectionné, veuillez d'abord", + 'dashboard.analysis.empty.noDebateData': "Aucune donnée de débat pour l'instant", + 'dashboard.analysis.empty.noDecisionData': "Aucune donnée de décision pour l'instant", + 'dashboard.analysis.empty.selectAgent': "Veuillez sélectionner un agent pour afficher les résultats de l'analyse", + 'dashboard.analysis.loading.analyzing': 'Analyse, veuillez patienter...', + 'dashboard.analysis.loading.fundamental': 'Analyser les données fondamentales...', + 'dashboard.analysis.loading.technical': 'Analyse des indicateurs techniques...', + 'dashboard.analysis.loading.news': "Analyser les données d'actualité...", + 'dashboard.analysis.loading.sentiment': 'Analyser le sentiment du marché...', + 'dashboard.analysis.loading.risk': 'Évaluer le risque...', + 'dashboard.analysis.loading.debate': 'Le débat long-court est en cours...', + 'dashboard.analysis.loading.decision': 'Générer la décision finale...', + 'dashboard.analysis.score.overall': 'Note globale', + 'dashboard.analysis.score.recommendation': 'conseil en investissement', + 'dashboard.analysis.score.confidence': 'Confiance', + 'dashboard.analysis.dimension.fundamental': 'Fondamentaux', + 'dashboard.analysis.dimension.technical': 'technologie', + 'dashboard.analysis.dimension.news': 'Actualités', + 'dashboard.analysis.dimension.sentiment': 'émotions', + 'dashboard.analysis.dimension.risk': 'risque', + 'dashboard.analysis.card.dimensionScores': 'Notes pour chaque dimension', + 'dashboard.analysis.card.overviewReport': "Rapport d'analyse complet", + 'dashboard.analysis.card.financialMetrics': 'indicateurs financiers', + 'dashboard.analysis.card.fundamentalReport': "Rapport d'analyse fondamentale", + 'dashboard.analysis.card.technicalIndicators': 'Indicateurs techniques', + 'dashboard.analysis.card.technicalReport': "rapport d'analyse technique", + 'dashboard.analysis.card.newsList': 'Actualités connexes', + 'dashboard.analysis.card.newsReport': "Rapport d'analyse de l'actualité", + 'dashboard.analysis.card.sentimentIndicators': 'indicateur de sentiment', + 'dashboard.analysis.card.sentimentReport': "Rapport d'analyse des sentiments", + 'dashboard.analysis.card.riskMetrics': 'indicateurs de risque', + 'dashboard.analysis.card.riskReport': "rapport d'évaluation des risques", + 'dashboard.analysis.card.bullView': 'Vue haussière (Taureau)', + 'dashboard.analysis.card.bearView': 'Vue baissière (ours)', + 'dashboard.analysis.card.researchConclusion': 'Conclusion du chercheur', + 'dashboard.analysis.card.traderPlan': 'plan de commerçant', + 'dashboard.analysis.card.riskDebate': 'Débat en commission des risques', + 'dashboard.analysis.card.finalDecision': 'Décision finale', + 'dashboard.analysis.card.tradePlanDetail': 'Détails du plan de trading', + 'dashboard.analysis.tradingPlan.entry_price': "Prix d'entrée", + 'dashboard.analysis.tradingPlan.position_size': 'Taille du poste', + 'dashboard.analysis.tradingPlan.stop_loss': 'arrêter les pertes', + 'dashboard.analysis.tradingPlan.take_profit': 'Profiter', + 'dashboard.analysis.label.confidence': 'Confiance', + 'dashboard.analysis.label.keyPoints': 'points essentiels', + 'dashboard.analysis.label.riskWarning': 'Avertissement de risque', + 'dashboard.analysis.risk.risky': 'Risqué', + 'dashboard.analysis.risk.neutral': 'Point de vue neutre (Neutre)', + 'dashboard.analysis.risk.safe': 'Vue conservatrice (sûr)', + 'dashboard.analysis.risk.conclusion': 'Conclusion', + 'dashboard.analysis.feature.fundamental': 'analyse fondamentale', + 'dashboard.analysis.feature.technical': 'analyse technique', + 'dashboard.analysis.feature.news': "analyse de l'actualité", + 'dashboard.analysis.feature.sentiment': 'analyse des sentiments', + 'dashboard.analysis.feature.risk': 'évaluation des risques', + 'dashboard.analysis.watchlist.title': 'Ma sélection de titres', + 'dashboard.analysis.watchlist.add': 'ajouter', + 'dashboard.analysis.watchlist.addStock': 'ajouter du stock', + 'dashboard.analysis.modal.addStock.title': 'Ajouter des actions facultatives', + 'dashboard.analysis.modal.addStock.confirm': "D'accord", + 'dashboard.analysis.modal.addStock.cancel': 'Annuler', + 'dashboard.analysis.modal.addStock.market': 'type de marché', + 'dashboard.analysis.modal.addStock.marketPlaceholder': 'Veuillez sélectionner un marché', + 'dashboard.analysis.modal.addStock.marketRequired': 'Veuillez sélectionner le type de marché', + 'dashboard.analysis.modal.addStock.symbol': 'Code de stock', + 'dashboard.analysis.modal.addStock.symbolPlaceholder': 'Par exemple : AAPL, TSLA, GOOGL, 000001, BTC', + 'dashboard.analysis.modal.addStock.symbolRequired': 'Veuillez entrer le code de stock', + 'dashboard.analysis.modal.addStock.searchPlaceholder': 'Rechercher le code ou le nom de la cible', + 'dashboard.analysis.modal.addStock.searchOrInputPlaceholder': 'Recherchez ou saisissez le code sous-jacent (par exemple : AAPL, BTC/USDT, EUR/USD)', + 'dashboard.analysis.modal.addStock.searchOrInputHint': "Prend en charge la recherche d'objets dans la base de données ou la saisie directe du code (le système obtiendra automatiquement le nom)", + 'dashboard.analysis.modal.addStock.search': 'Rechercher', + 'dashboard.analysis.modal.addStock.searchResults': 'Résultats de la recherche', + 'dashboard.analysis.modal.addStock.hotSymbols': 'Cibles populaires', + 'dashboard.analysis.modal.addStock.noHotSymbols': "Aucune cible populaire pour l'instant", + 'dashboard.analysis.modal.addStock.selectedSymbol': 'Sélectionné', + 'dashboard.analysis.modal.addStock.pleaseSelectSymbol': "Veuillez d'abord sélectionner une cible", + 'dashboard.analysis.modal.addStock.pleaseSelectOrEnterSymbol': "Veuillez sélectionner une cible ou saisir d'abord le code cible", + 'dashboard.analysis.modal.addStock.pleaseEnterSymbol': 'Veuillez entrer le code cible', + 'dashboard.analysis.modal.addStock.pleaseSelectMarket': "Veuillez d'abord sélectionner le type de marché", + 'dashboard.analysis.modal.addStock.searchFailed': 'La recherche a échoué, veuillez réessayer plus tard', + 'dashboard.analysis.modal.addStock.noSearchResults': 'Aucune cible correspondante trouvée', + 'dashboard.analysis.modal.addStock.willAutoFetchName': 'Le système obtiendra automatiquement le nom', + 'dashboard.analysis.modal.addStock.addDirectly': 'Ajouter directement', + 'dashboard.analysis.modal.addStock.nameWillBeFetched': 'Le nom sera récupéré automatiquement une fois ajouté', + 'dashboard.analysis.market.AShare': 'Un partage', + 'dashboard.analysis.market.USStock': 'Actions américaines', + 'dashboard.analysis.market.HShare': 'Actions de Hong Kong', + 'dashboard.analysis.market.Crypto': 'crypto-monnaie', + 'dashboard.analysis.market.Forex': 'Forex', + 'dashboard.analysis.market.Futures': 'Contrats à terme', + 'dashboard.analysis.modal.history.title': "Enregistrements d'analyse historique", + 'dashboard.analysis.modal.history.viewResult': 'Afficher les résultats', + 'dashboard.analysis.modal.history.completeTime': "temps d'achèvement", + 'dashboard.analysis.modal.history.error': 'Erreur', + 'dashboard.analysis.status.pending': 'En attente', + 'dashboard.analysis.status.processing': 'Traitement', + 'dashboard.analysis.status.completed': 'Terminé', + 'dashboard.analysis.status.failed': 'échoué', + 'dashboard.analysis.message.selectSymbol': "Veuillez d'abord sélectionner la cible", + 'dashboard.analysis.message.taskCreated': "La tâche d'analyse a été créée et est exécutée en arrière-plan...", + 'dashboard.analysis.message.analysisComplete': 'Analyse terminée', + 'dashboard.analysis.message.analysisCompleteCache': "Analyse terminée (à l'aide des données mises en cache)", + 'dashboard.analysis.message.analysisFailed': "L'analyse a échoué, veuillez réessayer plus tard", + 'dashboard.analysis.message.addStockSuccess': 'Ajouté avec succès', + 'dashboard.analysis.message.addStockFailed': "L'ajout a échoué", + 'dashboard.analysis.message.removeStockSuccess': 'Supprimé avec succès', + 'dashboard.analysis.message.removeStockFailed': 'Échec de la suppression', + 'dashboard.analysis.test': 'Magasin n° {no}, route Gongzhuan', + 'dashboard.analysis.introduce': "Description de l'indicateur", + 'dashboard.analysis.total-sales': 'ventes totales', + 'dashboard.analysis.day-sales': 'Ventes quotidiennes moyennes¥', + 'dashboard.analysis.visits': 'Visites', + 'dashboard.analysis.visits-trend': 'Tendances du trafic', + 'dashboard.analysis.visits-ranking': 'Classement des visites en magasin', + 'dashboard.analysis.day-visits': 'Visites quotidiennes', + 'dashboard.analysis.week': 'Hebdomadaire, année sur année', + 'dashboard.analysis.day': 'Année après année', + 'dashboard.analysis.payments': 'Nombre de paiements', + 'dashboard.analysis.conversion-rate': 'taux de conversion', + 'dashboard.analysis.operational-effect': "Effets sur l'activité opérationnelle", + 'dashboard.analysis.sales-trend': 'tendances des ventes', + 'dashboard.analysis.sales-ranking': 'Classement des ventes en magasin', + 'dashboard.analysis.all-year': "Toute l'année", + 'dashboard.analysis.all-month': 'ce mois-ci', + 'dashboard.analysis.all-week': 'cette semaine', + 'dashboard.analysis.all-day': "aujourd'hui", + 'dashboard.analysis.search-users': "Nombre d'utilisateurs de recherche", + 'dashboard.analysis.per-capita-search': 'Recherches par habitant', + 'dashboard.analysis.online-top-search': 'Recherches populaires en ligne', + 'dashboard.analysis.the-proportion-of-sales': 'Proportion de la catégorie de ventes', + 'dashboard.analysis.dropdown-option-one': 'Première opération', + 'dashboard.analysis.dropdown-option-two': 'Opération 2', + 'dashboard.analysis.channel.all': 'Toutes les chaînes', + 'dashboard.analysis.channel.online': 'en ligne', + 'dashboard.analysis.channel.stores': 'magasin', + 'dashboard.analysis.sales': 'ventes', + 'dashboard.analysis.traffic': 'flux de passagers', + 'dashboard.analysis.table.rank': 'Classement', + 'dashboard.analysis.table.search-keyword': 'Rechercher des mots-clés', + 'dashboard.analysis.table.users': "Nombre d'utilisateurs", + 'dashboard.analysis.table.weekly-range': 'Augmentation hebdomadaire', + 'dashboard.indicator.selectSymbol': 'Sélectionnez ou saisissez le code sous-jacent', + 'dashboard.indicator.emptyWatchlistHint': "Il n'y a actuellement aucun titre auto-sélectionné, veuillez d'abord les ajouter", + 'dashboard.indicator.hint.selectSymbol': "Veuillez sélectionner une cible pour démarrer l'analyse", + 'dashboard.indicator.hint.selectSymbolDesc': 'Sélectionnez ou entrez un code boursier dans la zone de recherche ci-dessus pour afficher les graphiques K-line et les indicateurs techniques.', + 'dashboard.indicator.retry': 'Réessayez', + 'dashboard.indicator.panel.title': 'Indicateurs techniques', + 'dashboard.indicator.panel.realtimeOn': 'Désactivez les mises à jour en temps réel', + 'dashboard.indicator.panel.realtimeOff': 'Activer les mises à jour en temps réel', + 'dashboard.indicator.panel.themeLight': 'Passer au thème sombre', + 'dashboard.indicator.panel.themeDark': 'Passer au thème clair', + 'dashboard.indicator.section.enabled': 'Activé', + 'dashboard.indicator.section.added': "Indicateurs que j'ai ajoutés", + 'dashboard.indicator.section.custom': 'Indicateurs créés par vous-même', + 'dashboard.indicator.section.bought': "Indicateurs que j'ai achetés", + 'dashboard.indicator.section.myCreated': "Indicateurs que j'ai créés", + 'dashboard.indicator.empty': "Il n'y a pas encore d'indicateur, veuillez d'abord ajouter ou créer un indicateur", + 'dashboard.indicator.buy': "indicateur d'achat", + 'dashboard.indicator.action.start': 'commencer', + 'dashboard.indicator.action.stop': 'Fermer', + 'dashboard.indicator.action.edit': 'Modifier', + 'dashboard.indicator.action.delete': 'Supprimer', + 'dashboard.indicator.action.backtest': 'backtest', + 'dashboard.indicator.status.normal': 'normal', + 'dashboard.indicator.status.normalPermanent': 'Normal (valable en permanence)', + 'dashboard.indicator.status.expired': 'Expiré', + 'dashboard.indicator.expiry.permanent': 'Valable en permanence', + 'dashboard.indicator.expiry.noExpiry': "Pas de délai d'expiration", + 'dashboard.indicator.expiry.expired': 'Expiré : {date}', + 'dashboard.indicator.expiry.expiresOn': "Heure d'expiration : {date}", + 'dashboard.indicator.delete.confirmTitle': 'Confirmer la suppression', + 'dashboard.indicator.delete.confirmContent': "Êtes-vous sûr de vouloir supprimer l'indicateur \"{name}\" ? Cette opération est irréversible.", + 'dashboard.indicator.delete.confirmOk': 'Supprimer', + 'dashboard.indicator.delete.confirmCancel': 'Annuler', + 'dashboard.indicator.delete.success': 'Supprimer avec succès', + 'dashboard.indicator.delete.failed': 'Échec de la suppression', + 'dashboard.indicator.save.success': 'Enregistré avec succès', + 'dashboard.indicator.save.failed': "Échec de l'enregistrement", + 'dashboard.indicator.error.loadWatchlistFailed': 'Échec du chargement des stocks facultatifs', + 'dashboard.indicator.error.chartNotReady': "Le composant graphique n'est pas initialisé. Veuillez d'abord sélectionner la cible et attendre que le graphique se charge.", + 'dashboard.indicator.error.chartMethodNotReady': "La méthode du composant graphique n'est pas prête, veuillez réessayer plus tard", + 'dashboard.indicator.error.chartExecuteNotReady': "La méthode d'exécution du composant graphique n'est pas prête, veuillez réessayer plus tard", + 'dashboard.indicator.error.parseFailed': "Impossible d'analyser le code Python", + 'dashboard.indicator.error.parseFailedCheck': "Impossible d'analyser le code Python, veuillez vérifier le format du code", + 'dashboard.indicator.error.addIndicatorFailed': "Échec de l'ajout de l'indicateur", + 'dashboard.indicator.error.runIndicatorFailed': "L'indicateur de fonctionnement a échoué", + 'dashboard.indicator.error.pleaseLogin': "Veuillez d'abord vous connecter", + 'dashboard.indicator.error.loadDataFailed': 'Le chargement des données a échoué', + 'dashboard.indicator.error.loadDataFailedDesc': 'Veuillez vérifier la connexion réseau', + 'dashboard.indicator.error.pythonEngineFailed': "Le moteur Python n'a pas pu se charger et la fonction d'indicateur peut ne pas être disponible.", + 'dashboard.indicator.error.chartInitFailed': "L'initialisation du graphique a échoué", + 'dashboard.indicator.warning.enterCode': "Veuillez d'abord saisir le code de l'indicateur", + 'dashboard.indicator.warning.pyodideLoadFailed': "Le moteur Python n'a pas pu se charger", + 'dashboard.indicator.warning.pyodideLoadFailedDesc': "Cette fonctionnalité n'est pas disponible dans votre région ou environnement réseau actuel", + 'dashboard.indicator.warning.chartNotInitialized': "Le graphique n'est pas initialisé et l'outil de dessin au trait ne peut pas être utilisé.", + 'dashboard.indicator.success.runIndicator': "L'indicateur s'exécute avec succès", + 'dashboard.indicator.success.clearDrawings': 'Tous les dessins au trait effacés', + 'dashboard.indicator.sma': 'SMA (combinaison de moyenne mobile)', + 'dashboard.indicator.ema': 'EMA (combinaison de moyennes mobiles exponentielles)', + 'dashboard.indicator.rsi': 'RSI (force relative)', + 'dashboard.indicator.macd': 'MACD', + 'dashboard.indicator.bb': 'Bandes de Bollinger', + 'dashboard.indicator.atr': 'ATR (plage réelle moyenne)', + 'dashboard.indicator.cci': 'CCI (indice des canaux de matières premières)', + 'dashboard.indicator.williams': 'Williams %R (indicateur Williams)', + 'dashboard.indicator.mfi': 'IMF (indice de flux monétaire)', + 'dashboard.indicator.adx': 'ADX (indice de tendance moyen)', + 'dashboard.indicator.obv': "OBV (vague d'énergie)", + 'dashboard.indicator.adosc': "ADOSC (oscillateur d'accumulation/répartition)", + 'dashboard.indicator.ad': "AD (ligne d'accumulation/distribution)", + 'dashboard.indicator.kdj': 'KDJ (indicateur stochastique)', + 'dashboard.indicator.signal.buy': 'ACHETER', + 'dashboard.indicator.signal.sell': 'VENDRE', + 'dashboard.indicator.signal.supertrendBuy': 'Acheter SuperTrend', + 'dashboard.indicator.signal.supertrendSell': 'Vente SuperTrend', + 'dashboard.indicator.chart.kline': 'Ligne K', + 'dashboard.indicator.chart.volume': 'Volume', + 'dashboard.indicator.chart.uptrend': 'Tendance à la hausse', + 'dashboard.indicator.chart.downtrend': 'Tendance à la baisse', + 'dashboard.indicator.tooltip.time': 'temps', + 'dashboard.indicator.tooltip.open': 'ouvert', + 'dashboard.indicator.tooltip.close': 'recevoir', + 'dashboard.indicator.tooltip.high': 'haut', + 'dashboard.indicator.tooltip.low': 'faible', + 'dashboard.indicator.tooltip.volume': 'Volume', + 'dashboard.indicator.drawing.line': 'segment de ligne', + 'dashboard.indicator.drawing.horizontalLine': 'ligne horizontale', + 'dashboard.indicator.drawing.verticalLine': 'ligne verticale', + 'dashboard.indicator.drawing.ray': 'rayon', + 'dashboard.indicator.drawing.straightLine': 'ligne droite', + 'dashboard.indicator.drawing.parallelLine': 'lignes parallèles', + 'dashboard.indicator.drawing.priceLine': 'ligne de prix', + 'dashboard.indicator.drawing.priceChannel': 'canal de prix', + 'dashboard.indicator.drawing.fibonacciLine': 'Lignes de Fibonacci', + 'dashboard.indicator.drawing.clearAll': 'Effacer tous les dessins au trait', + 'dashboard.indicator.market.AShare': 'Un partage', + 'dashboard.indicator.market.USStock': 'Actions américaines', + 'dashboard.indicator.market.HShare': 'Actions de Hong Kong', + 'dashboard.indicator.market.Crypto': 'crypto-monnaie', + 'dashboard.indicator.market.Forex': 'Forex', + 'dashboard.indicator.market.Futures': 'Contrats à terme', + 'dashboard.indicator.create': 'Créer un indicateur', + 'dashboard.indicator.editor.title': 'Créer/modifier des indicateurs', + 'dashboard.indicator.editor.name': "Nom de l'indicateur", + 'dashboard.indicator.editor.nameRequired': "Veuillez saisir le nom de l'indicateur", + 'dashboard.indicator.editor.namePlaceholder': "Veuillez saisir le nom de l'indicateur", + 'dashboard.indicator.editor.description': "Description de l'indicateur", + 'dashboard.indicator.editor.descriptionPlaceholder': "Veuillez saisir une description de l'indicateur (facultatif)", + 'dashboard.indicator.editor.code': 'Code Python', + 'dashboard.indicator.editor.codeRequired': "Veuillez entrer le code de l'indicateur", + 'dashboard.indicator.editor.codePlaceholder': 'Veuillez saisir le code Python', + 'dashboard.indicator.editor.run': 'courir', + 'dashboard.indicator.editor.runHint': "Cliquez sur le bouton Exécuter pour prévisualiser l'effet de l'indicateur sur le graphique K-line", + 'dashboard.indicator.editor.guide': 'Guide de développement', + 'dashboard.indicator.editor.guideTitle': "Guide de développement d'indicateurs Python", + 'dashboard.indicator.editor.save': 'enregistrer', + 'dashboard.indicator.editor.cancel': 'Annuler', + 'dashboard.indicator.editor.unnamed': 'Indicateur sans nom', + 'dashboard.indicator.editor.publishToCommunity': 'Publier sur la communauté', + 'dashboard.indicator.editor.publishToCommunityHint': 'Une fois publié, les autres utilisateurs peuvent visualiser et utiliser votre indicateur dans la communauté', + 'dashboard.indicator.editor.indicatorType': "Type d'indicateur", + 'dashboard.indicator.editor.indicatorTypeRequired': "Veuillez sélectionner le type d'indicateur", + 'dashboard.indicator.editor.indicatorTypePlaceholder': "Veuillez sélectionner le type d'indicateur", + 'dashboard.indicator.editor.previewImage': 'Aperçu', + 'dashboard.indicator.editor.uploadImage': 'Télécharger des photos', + 'dashboard.indicator.editor.previewImageHint': 'Taille recommandée : 800 x 400, pas plus de 2 Mo', + 'dashboard.indicator.editor.pricing': 'Prix de vente (QDT)', + 'dashboard.indicator.editor.pricingType.free': 'gratuit', + 'dashboard.indicator.editor.pricingType.permanent': 'permanent', + 'dashboard.indicator.editor.pricingType.monthly': 'paiement mensuel', + 'dashboard.indicator.editor.price': 'prix', + 'dashboard.indicator.editor.priceRequired': 'Veuillez entrer le prix', + 'dashboard.indicator.editor.pricePlaceholder': 'Veuillez entrer le prix', + 'dashboard.indicator.editor.pricingHint': "Bien que le prix des indicateurs gratuits soit de 0, la plateforme vous récompensera (QDT) en fonction de votre utilisation de l'indicateur et de votre taux d'éloges.", + 'dashboard.indicator.editor.aiGenerate': 'Génération intelligente', + 'dashboard.indicator.editor.aiPromptPlaceholder': "Dites-moi vos idées et je générerai le code de l'indicateur Python pour vous", + 'dashboard.indicator.editor.aiGenerateBtn': "Code généré par l'IA", + 'dashboard.indicator.editor.aiPromptRequired': 'Veuillez entrer vos pensées', + 'dashboard.indicator.editor.aiGenerateSuccess': 'Génération de code réussie', + 'dashboard.indicator.editor.aiGenerateError': 'Échec de la génération du code, veuillez réessayer plus tard', + 'dashboard.indicator.backtest.title': 'Backtest des indicateurs', + 'dashboard.indicator.backtest.config': 'Paramètres de backtest', + 'dashboard.indicator.backtest.startDate': 'date de début', + 'dashboard.indicator.backtest.endDate': 'date de fin', + 'dashboard.indicator.backtest.selectStartDate': 'Sélectionnez la date de début', + 'dashboard.indicator.backtest.selectEndDate': 'Sélectionnez la date de fin', + 'dashboard.indicator.backtest.startDateRequired': 'Veuillez sélectionner une date de début', + 'dashboard.indicator.backtest.endDateRequired': 'Veuillez sélectionner une date de fin', + 'dashboard.indicator.backtest.initialCapital': 'Capital initial', + 'dashboard.indicator.backtest.initialCapitalRequired': 'Veuillez saisir les fonds initiaux', + 'dashboard.indicator.backtest.commission': 'Frais de traitement', + 'dashboard.indicator.backtest.leverage': 'Ratio de levier', + 'dashboard.indicator.backtest.tradeDirection': 'Orientation commerciale', + 'dashboard.indicator.backtest.longOnly': 'Allez-y longtemps', + 'dashboard.indicator.backtest.shortOnly': 'court', + 'dashboard.indicator.backtest.both': 'Bidirectionnel', + 'dashboard.indicator.backtest.run': 'Commencer le backtest', + 'dashboard.indicator.backtest.rerun': 'Backtester à nouveau', + 'dashboard.indicator.backtest.close': 'Fermer', + 'dashboard.indicator.backtest.running': 'Backtest des indicateurs en cours...', + 'dashboard.indicator.backtest.results': 'Résultats du backtest', + 'dashboard.indicator.backtest.totalReturn': 'rendement total', + 'dashboard.indicator.backtest.annualReturn': 'revenu annualisé', + 'dashboard.indicator.backtest.maxDrawdown': 'prélèvement maximum', + 'dashboard.indicator.backtest.sharpeRatio': 'Rapport de netteté', + 'dashboard.indicator.backtest.winRate': 'taux de réussite', + 'dashboard.indicator.backtest.profitFactor': 'ratio profits/pertes', + 'dashboard.indicator.backtest.totalTrades': 'Nombre de transactions', + 'dashboard.indicator.totalReturn': 'rendement total', + 'dashboard.indicator.annualReturn': 'taux de rendement annualisé', + 'dashboard.indicator.maxDrawdown': 'prélèvement maximum', + 'dashboard.indicator.sharpeRatio': 'Rapport de netteté', + 'dashboard.indicator.winRate': 'taux de réussite', + 'dashboard.indicator.profitFactor': 'ratio profits/pertes', + 'dashboard.indicator.totalTrades': 'nombre total de transactions', + 'dashboard.indicator.backtest.totalCommission': 'frais de traitement totaux', + 'dashboard.indicator.backtest.equityCurve': 'courbe des taux', + 'dashboard.indicator.backtest.strategy': "Revenu de l'indicateur", + 'dashboard.indicator.backtest.benchmark': 'Rendement de référence', + 'dashboard.indicator.backtest.tradeHistory': 'historique des transactions', + 'dashboard.indicator.backtest.tradeTime': 'temps', + 'dashboard.indicator.backtest.tradeType': 'Tapez', + 'dashboard.indicator.backtest.buy': 'acheter', + 'dashboard.indicator.backtest.sell': 'vendre', + 'dashboard.indicator.backtest.liquidation': 'Liquidation', + 'dashboard.indicator.backtest.openLong': 'Ouvert longtemps', + 'dashboard.indicator.backtest.closeLong': 'Pinduo', + 'dashboard.indicator.backtest.closeLongStop': 'Près du long (stop loss)', + 'dashboard.indicator.backtest.closeLongProfit': 'Fermez davantage (prenez des bénéfices)', + 'dashboard.indicator.backtest.addLong': 'Ajouter une position longue', + 'dashboard.indicator.backtest.openShort': 'Ouvert court', + 'dashboard.indicator.backtest.closeShort': 'vide', + 'dashboard.indicator.backtest.closeShortStop': 'Fermer (stopper la perte)', + 'dashboard.indicator.backtest.closeShortProfit': 'Fermer (prendre des bénéfices)', + 'dashboard.indicator.backtest.addShort': 'Ajouter une position courte', + 'dashboard.indicator.backtest.price': 'prix', + 'dashboard.indicator.backtest.amount': 'Quantité', + 'dashboard.indicator.backtest.balance': 'Solde du compte', + 'dashboard.indicator.backtest.profit': 'Profits et pertes', + 'dashboard.indicator.backtest.success': 'Backtest terminé', + 'dashboard.indicator.backtest.failed': 'Échec du backtest, veuillez réessayer plus tard', + 'dashboard.indicator.backtest.noIndicatorCode': "Il n'y a pas de code backtestable pour cet indicateur", + 'dashboard.indicator.backtest.noSymbol': "Veuillez d'abord sélectionner la cible de la transaction", + 'dashboard.indicator.backtest.dateRangeExceeded': 'La plage de temps du backtest dépasse la limite : {timeframe} la période peut être backtestée au maximum {maxRange}', + 'dashboard.indicator.backtest.dateRangeExceededDays': 'Backtest range exceeds limit: {timeframe} max {maxRange} ({maxDays} days)', + 'dashboard.indicator.backtest.metaLine': 'Symbol: {symbol} | Market: {market} | Timeframe: {timeframe}', + 'dashboard.indicator.backtest.savedRunId': 'Backtest saved. Run ID: {id}', + 'dashboard.indicator.backtest.prev': 'Previous', + 'dashboard.indicator.backtest.next': 'Next', + 'dashboard.indicator.backtest.steps.strategy.title': 'Strategy', + 'dashboard.indicator.backtest.steps.strategy.desc': 'Position sizing & risk controls', + 'dashboard.indicator.backtest.steps.trading.title': 'Trading settings', + 'dashboard.indicator.backtest.steps.trading.desc': 'Time range, capital, fees, leverage', + 'dashboard.indicator.backtest.steps.results.title': 'Results', + 'dashboard.indicator.backtest.steps.results.desc': 'Backtest output', + 'dashboard.indicator.backtest.panel.risk': 'Risk management (SL/TP/Trailing)', + 'dashboard.indicator.backtest.panel.scale': 'Scale in / DCA (trend & mean-reversion)', + 'dashboard.indicator.backtest.panel.reduce': 'Reduce position (trend & adverse)', + 'dashboard.indicator.backtest.panel.position': 'Position sizing', + 'dashboard.indicator.backtest.field.stopLossPct': 'Stop Loss (%)', + 'dashboard.indicator.backtest.field.takeProfitPct': 'Take Profit (%)', + 'dashboard.indicator.backtest.field.trailingEnabled': 'Trailing stop / take profit', + 'dashboard.indicator.backtest.field.trailingStopPct': 'Trailing drawdown (%)', + 'dashboard.indicator.backtest.field.trailingActivationPct': 'Trailing activation (%)', + 'dashboard.indicator.backtest.field.slippage': 'Slippage (%)', + 'dashboard.indicator.backtest.field.trendAddEnabled': 'Trend-following scale-in', + 'dashboard.indicator.backtest.field.dcaAddEnabled': 'Mean-reversion DCA', + 'dashboard.indicator.backtest.field.trendAddStepPct': 'Scale-in trigger (%)', + 'dashboard.indicator.backtest.field.dcaAddStepPct': 'DCA trigger (%)', + 'dashboard.indicator.backtest.field.trendAddSizePct': 'Scale-in size (% of capital)', + 'dashboard.indicator.backtest.field.dcaAddSizePct': 'DCA size (% of capital)', + 'dashboard.indicator.backtest.field.trendAddMaxTimes': 'Max scale-in times', + 'dashboard.indicator.backtest.field.dcaAddMaxTimes': 'Max DCA times', + 'dashboard.indicator.backtest.field.trendReduceEnabled': 'Trend reduce', + 'dashboard.indicator.backtest.field.adverseReduceEnabled': 'Adverse reduce', + 'dashboard.indicator.backtest.field.trendReduceStepPct': 'Trend trigger (%)', + 'dashboard.indicator.backtest.field.adverseReduceStepPct': 'Adverse trigger (%)', + 'dashboard.indicator.backtest.field.trendReduceSizePct': 'Reduce size (% of position)', + 'dashboard.indicator.backtest.field.adverseReduceSizePct': 'Adverse reduce size (% of position)', + 'dashboard.indicator.backtest.field.trendReduceMaxTimes': 'Max trend reduce times', + 'dashboard.indicator.backtest.field.adverseReduceMaxTimes': 'Max adverse reduce times', + 'dashboard.indicator.backtest.field.entryPct': 'Entry size (% of capital)', + 'dashboard.indicator.backtest.field.minOrderPct': 'Min order size (% of capital) (optional)', + 'dashboard.indicator.backtest.closeLongTrailing': 'Close Long (Trailing)', + 'dashboard.indicator.backtest.reduceLong': 'Reduce Long', + 'dashboard.indicator.backtest.closeShortTrailing': 'Close Short (Trailing)', + 'dashboard.indicator.backtest.reduceShort': 'Reduce Short', + 'dashboard.docs.title': 'Centre de documentation', + 'dashboard.docs.search.placeholder': 'Rechercher des documents...', + 'dashboard.docs.category.all': 'Tout', + 'dashboard.docs.category.guide': 'Guide de développement', + 'dashboard.docs.category.api': 'Documentation API', + 'dashboard.docs.category.tutorial': 'Tutoriel', + 'dashboard.docs.category.faq': 'FAQ', + 'dashboard.docs.featured.title': 'Documentation recommandée', + 'dashboard.docs.featured.tag': 'Recommandé', + 'dashboard.docs.list.views': 'Parcourir', + 'dashboard.docs.list.author': 'Auteur', + 'dashboard.docs.list.empty': "Aucun document pour l'instant", + 'dashboard.docs.list.backToAll': 'Retour à tous les documents', + 'dashboard.docs.list.total': '{count} documents au total', + 'dashboard.docs.detail.back': 'Retour à la liste des documents', + 'dashboard.docs.detail.updatedAt': 'mis à jour sur', + 'dashboard.docs.detail.related': 'Documents associés', + 'dashboard.docs.detail.notFound': "Le document n'existe pas", + 'dashboard.docs.detail.error': "Le document n'existe pas ou a été supprimé", + 'dashboard.docs.search.result': 'Résultats de la recherche', + 'dashboard.docs.search.keyword': 'mots-clés', + 'community.filter.indicatorType': "Type d'indicateur", + 'community.filter.all': 'Tout', + 'community.filter.other': 'Autres options', + 'community.filter.pricing': 'Type de prix', + 'community.filter.allPricing': 'Tous les tarifs', + 'community.filter.sortBy': 'Trier par', + 'community.filter.search': 'Rechercher', + 'community.filter.searchPlaceholder': 'Rechercher le nom de la métrique', + 'community.indicatorType.trend': 'Type de tendance', + 'community.indicatorType.momentum': "type d'élan", + 'community.indicatorType.volatility': 'Volatilité', + 'community.indicatorType.volume': 'Volume', + 'community.indicatorType.custom': 'Personnaliser', + 'community.pricing.free': 'gratuit', + 'community.pricing.paid': 'Payer', + 'community.sort.downloads': 'Téléchargements', + 'community.sort.rating': 'Note', + 'community.sort.newest': 'Dernières versions', + 'community.pagination.total': '{total} indicateurs au total', + 'community.noDescription': 'Pas encore de description', + 'community.detail.type': 'Tapez', + 'community.detail.pricing': 'Tarifs', + 'community.detail.rating': 'Note', + 'community.detail.downloads': 'Téléchargements', + 'community.detail.author': 'Auteur', + 'community.detail.description': 'Présentation', + 'community.detail.detailContent': 'Description détaillée', + 'community.detail.backtestStats': 'Statistiques de backtest', + 'community.action.purchase': "indicateur d'achat", + 'community.action.addToMyIndicators': 'Ajouter à mes indicateurs', + 'community.action.favorite': 'Collecte', + 'community.action.unfavorite': 'Annuler les favoris', + 'community.action.buyNow': 'Acheter maintenant', + 'community.action.renew': 'Renouvellement', + 'community.purchase.price': 'prix', + 'community.purchase.permanent': 'permanent', + 'community.purchase.monthly': 'mois', + 'community.purchase.confirmBuy': 'Confirmer pour acheter cet indicateur ({price} QDT) ?', + 'community.purchase.confirmRenew': 'Confirmer le renouvellement de cet indicateur ({price} QDT/mois) ?', + 'community.purchase.confirmFree': 'Confirmer pour ajouter cet indicateur gratuit ?', + 'community.purchase.confirmTitle': "Confirmer l'action", + 'community.purchase.owned': 'Vous avez acheté cet indicateur (valable en permanence)', + 'community.purchase.ownIndicator': "Il s'agit de la métrique que vous avez publiée", + 'community.purchase.expired': 'Votre abonnement a expiré', + 'community.purchase.expiresOn': "Heure d'expiration : {date}", + 'community.tabs.detail': "Détails de l'indicateur", + 'community.tabs.backtest': 'Données de backtest', + 'community.tabs.ratings': "Avis d'utilisateurs", + 'community.rating.myRating': 'Ma note', + 'community.rating.stars': '{count} étoiles', + 'community.rating.commentPlaceholder': 'Partagez votre expérience...', + 'community.rating.submit': 'Soumettre une note', + 'community.rating.modify': 'Modifier', + 'community.rating.saveModify': 'Enregistrer les modifications', + 'community.rating.cancel': 'Annuler', + 'community.rating.selectRating': 'Veuillez sélectionner une note', + 'community.rating.success': 'Évaluation réussie', + 'community.rating.modifySuccess': 'Avis modifié avec succès', + 'community.rating.failed': 'Échec de la notation', + 'community.rating.noRatings': 'Pas encore de commentaires', + 'community.backtest.note': 'Les données ci-dessus sont des résultats de backtest historiques et sont fournies à titre de référence uniquement et ne représentent pas les bénéfices futurs.', + 'community.backtest.noData': "Aucune donnée de backtest pour l'instant", + 'community.backtest.uploadHint': "L'auteur n'a pas encore téléchargé les données de backtest", + 'community.message.loadFailed': 'Échec du chargement de la liste des indicateurs', + 'community.message.purchaseProcessing': "Traitement de la demande d'achat...", + 'community.message.downloadSuccess': 'La métrique a été ajoutée à ma liste de métriques', + 'community.message.favoriteSuccess': 'Collecte réussie', + 'community.message.unfavoriteSuccess': 'Annuler la collecte avec succès', + 'community.message.operationSuccess': 'Opération réussie', + 'community.message.operationFailed': "L'opération a échoué", + 'dashboard.totalEquity': 'capitaux propres totaux', + 'dashboard.totalPnL': 'Total des profits et pertes', + 'dashboard.aiStrategies': "Stratégie d'IA", + 'dashboard.indicatorStrategies': 'Stratégie des indicateurs', + 'dashboard.running': 'Courir', + 'dashboard.pnlHistory': 'profits et pertes historiques', + 'dashboard.strategyPerformance': 'Ratio de profits et pertes de la stratégie', + 'dashboard.recentTrades': 'transactions récentes', + 'dashboard.currentPositions': 'Poste actuel', + 'dashboard.table.time': 'temps', + 'dashboard.table.strategy': 'Nom de la stratégie', + 'dashboard.table.symbol': 'Cible', + 'dashboard.table.type': 'Tapez', + 'dashboard.table.side': 'direction', + 'dashboard.table.size': 'Intérêt ouvert', + 'dashboard.table.entryPrice': "cours d'ouverture moyen", + 'dashboard.table.price': 'prix', + 'dashboard.table.amount': 'Quantité', + 'dashboard.table.profit': 'Profits et pertes', + 'dashboard.pendingOrders': 'Historique d\'exécution des ordres', + 'dashboard.totalOrders': 'Total {total} ordres', + 'dashboard.viewError': 'Voir l\'erreur', + 'dashboard.filled': 'Rempli', + 'dashboard.orderTable.time': 'Heure de création', + 'dashboard.orderTable.strategy': 'Stratégie', + 'dashboard.orderTable.symbol': 'Paire de trading', + 'dashboard.orderTable.signalType': 'Type de signal', + 'dashboard.orderTable.amount': 'Quantité', + 'dashboard.orderTable.price': 'Prix de remplissage', + 'dashboard.orderTable.status': 'Statut', + 'dashboard.orderTable.executedAt': 'Heure d\'exécution', + 'dashboard.signalType.openLong': 'Ouvrir Long', + 'dashboard.signalType.openShort': 'Ouvrir Short', + 'dashboard.signalType.closeLong': 'Fermer Long', + 'dashboard.signalType.closeShort': 'Fermer Short', + 'dashboard.signalType.addLong': 'Ajouter Long', + 'dashboard.signalType.addShort': 'Ajouter Short', + 'dashboard.status.pending': 'En attente', + 'dashboard.status.processing': 'En cours', + 'dashboard.status.completed': 'Terminé', + 'dashboard.status.failed': 'Échoué', + 'dashboard.status.cancelled': 'Annulé', + 'dashboard.table.pnl': 'profit ou perte non réalisé', + 'form.basic-form.basic.title': 'forme de base', + 'form.basic-form.basic.description': "Les pages de formulaire sont utilisées pour collecter ou vérifier les informations des utilisateurs. Les formulaires de base sont couramment utilisés dans des scénarios de formulaire comportant peu d'éléments de données.", + 'form.basic-form.title.label': 'Titre', + 'form.basic-form.title.placeholder': "Donnez un nom à l'objectif", + 'form.basic-form.title.required': 'Veuillez entrer un titre', + 'form.basic-form.date.label': 'Date de début et de fin', + 'form.basic-form.placeholder.start': 'date de début', + 'form.basic-form.placeholder.end': 'date de fin', + 'form.basic-form.date.required': 'Veuillez sélectionner les dates de début et de fin', + 'form.basic-form.goal.label': "Description de l'objectif", + 'form.basic-form.goal.placeholder': 'Veuillez saisir vos objectifs de travail par étapes', + 'form.basic-form.goal.required': "Veuillez saisir une description de l'objectif", + 'form.basic-form.standard.label': 'mesurer', + 'form.basic-form.standard.placeholder': 'Veuillez saisir des statistiques', + 'form.basic-form.standard.required': 'Veuillez saisir des statistiques', + 'form.basic-form.client.label': 'Client', + 'form.basic-form.client.required': 'Veuillez décrire les clients que vous servez', + 'form.basic-form.label.tooltip': 'destinataires de services cibles', + 'form.basic-form.client.placeholder': 'Veuillez décrire les clients que vous servez, les clients internes directement @nom/numéro de poste', + 'form.basic-form.invites.label': 'Inviter des réviseurs', + 'form.basic-form.invites.placeholder': "Veuillez directement @nom/numéro d'employé, vous pouvez inviter jusqu'à 5 personnes", + 'form.basic-form.weight.label': 'poids', + 'form.basic-form.weight.placeholder': 'Veuillez entrer', + 'form.basic-form.public.label': 'public cible', + 'form.basic-form.label.help': 'Les clients et les évaluateurs sont partagés par défaut', + 'form.basic-form.radio.public': 'publique', + 'form.basic-form.radio.partially-public': 'Partiellement public', + 'form.basic-form.radio.private': 'privé', + 'form.basic-form.publicUsers.placeholder': 'ouvert à', + 'form.basic-form.option.A': 'Collègue 1', + 'form.basic-form.option.B': 'Collègue 2', + 'form.basic-form.option.C': 'Collègue trois', + 'form.basic-form.email.required': 'Veuillez entrer votre adresse e-mail !', + 'form.basic-form.email.wrong-format': "Le format de l'adresse e-mail est incorrect !", + 'form.basic-form.userName.required': "Veuillez entrer votre nom d'utilisateur !", + 'form.basic-form.password.required': 'Veuillez entrer votre mot de passe !', + 'form.basic-form.password.twice': 'Les mots de passe saisis deux fois ne correspondent pas !', + 'form.basic-form.strength.msg': 'Veuillez saisir au moins 6 caractères. Veuillez ne pas utiliser de mots de passe faciles à deviner.', + 'form.basic-form.strength.strong': 'Force : Forte', + 'form.basic-form.strength.medium': 'Force : moyenne', + 'form.basic-form.strength.short': 'Force : trop courte', + 'form.basic-form.confirm-password.required': 'Veuillez confirmer votre mot de passe !', + 'form.basic-form.phone-number.required': 'Veuillez entrer votre numéro de téléphone portable !', + 'form.basic-form.phone-number.wrong-format': 'Le format du numéro de téléphone portable est erroné !', + 'form.basic-form.verification-code.required': 'Veuillez entrer le code de vérification !', + 'form.basic-form.form.get-captcha': 'Obtenir le code de vérification', + 'form.basic-form.captcha.second': 'secondes', + 'form.basic-form.form.optional': '(facultatif)', + 'form.basic-form.form.submit': 'Soumettre', + 'form.basic-form.form.save': 'enregistrer', + 'form.basic-form.email.placeholder': 'Courriel', + 'form.basic-form.password.placeholder': "Mot de passe d'au moins 6 caractères, sensible à la casse", + 'form.basic-form.confirm-password.placeholder': 'Confirmer le mot de passe', + 'form.basic-form.phone-number.placeholder': 'Numéro de téléphone portable', + 'form.basic-form.verification-code.placeholder': 'Code de vérification', + 'result.success.title': 'Soumission réussie', + 'result.success.description': "La page de résultats de soumission est utilisée pour renvoyer les résultats de traitement d'une série de tâches opérationnelles. S'il ne s'agit que d'une opération simple, utilisez le retour d'invite global du message. Cette zone de texte peut afficher des instructions supplémentaires simples. S'il est nécessaire d'afficher des « documents », la zone grise ci-dessous peut afficher des contenus plus complexes.", + 'result.success.operate-title': 'Nom du projet', + 'result.success.operate-id': 'ID du projet', + 'result.success.principal': 'responsable', + 'result.success.operate-time': 'Temps effectif', + 'result.success.step1-title': 'Créer un projet', + 'result.success.step1-operator': 'Qu Lili', + 'result.success.step2-title': 'Examen préliminaire du ministère', + 'result.success.step2-operator': 'Zhou Maomao', + 'result.success.step2-extra': 'Urgent', + 'result.success.step3-title': 'examen financier', + 'result.success.step4-title': 'Terminé', + 'result.success.btn-return': 'Retour à la liste', + 'result.success.btn-project': 'Afficher les articles', + 'result.success.btn-print': 'Imprimer', + 'result.fail.error.title': 'Échec de la soumission', + 'result.fail.error.description': 'Veuillez vérifier et modifier les informations suivantes avant de soumettre à nouveau.', + 'result.fail.error.hint-title': 'Le contenu que vous avez soumis contient les erreurs suivantes :', + 'result.fail.error.hint-text1': 'Votre compte a été gelé', + 'result.fail.error.hint-btn1': 'Décongeler immédiatement', + 'result.fail.error.hint-text2': "Votre compte n'est pas encore éligible pour postuler", + 'result.fail.error.hint-btn2': 'Mettre à niveau maintenant', + 'result.fail.error.btn-text': 'Retour à la modification', + 'account.settings.menuMap.custom': 'personnalisation', + 'account.settings.menuMap.binding': 'Liaison de compte', + 'account.settings.basic.avatar': 'avatar', + 'account.settings.basic.change-avatar': "Changer d'avatar", + 'account.settings.basic.email': 'Courriel', + 'account.settings.basic.email-message': 'Veuillez entrer votre email!', + 'account.settings.basic.nickname': 'Surnom', + 'account.settings.basic.nickname-message': 'Veuillez entrer votre pseudo !', + 'account.settings.basic.profile': 'Profil', + 'account.settings.basic.profile-message': 'Veuillez entrer votre profil personnel !', + 'account.settings.basic.profile-placeholder': 'Profil', + 'account.settings.basic.country': 'Pays/Région', + 'account.settings.basic.country-message': 'Veuillez entrer votre pays ou région !', + 'account.settings.basic.geographic': 'Province et ville', + 'account.settings.basic.geographic-message': 'Veuillez entrer votre province et votre ville !', + 'account.settings.basic.address': 'adresse postale', + 'account.settings.basic.address-message': 'Veuillez entrer votre adresse postale !', + 'account.settings.basic.phone': 'Numéro de contact', + 'account.settings.basic.phone-message': 'Veuillez entrer votre numéro de contact !', + 'account.settings.basic.update': 'Mettre à jour les informations de base', + 'account.settings.basic.update.success': 'Mettre à jour les informations de base avec succès', + 'account.settings.security.strong': 'Fort', + 'account.settings.security.medium': 'dans', + 'account.settings.security.weak': 'faible', + 'account.settings.security.password': 'Mot de passe du compte', + 'account.settings.security.password-description': 'Force actuelle du mot de passe :', + 'account.settings.security.phone': 'Téléphone mobile de sécurité', + 'account.settings.security.phone-description': 'Téléphone mobile déjà lié :', + 'account.settings.security.question': 'Problèmes de sécurité', + 'account.settings.security.question-description': "Aucune question de sécurité n'est définie, ce qui peut protéger efficacement la sécurité du compte.", + 'account.settings.security.email': 'Lier un e-mail', + 'account.settings.security.email-description': 'Adresse e-mail déjà liée :', + 'account.settings.security.mfa': 'Appareil MFA', + 'account.settings.security.mfa-description': 'Le périphérique MFA n’est pas lié. Après la liaison, vous pouvez confirmer à nouveau.', + 'account.settings.security.modify': 'Modifier', + 'account.settings.security.set': 'paramètres', + 'account.settings.security.bind': 'reliure', + 'account.settings.binding.taobao': 'Lier Taobao', + 'account.settings.binding.taobao-description': "Le compte Taobao n'est actuellement pas lié", + 'account.settings.binding.alipay': 'Lier Alipay', + 'account.settings.binding.alipay-description': "Le compte Alipay n'est actuellement pas lié", + 'account.settings.binding.dingding': 'Liaison DingTalk', + 'account.settings.binding.dingding-description': "Aucun compte DingTalk n'est actuellement lié", + 'account.settings.binding.bind': 'reliure', + 'account.settings.notification.password': 'Mot de passe du compte', + 'account.settings.notification.password-description': 'Les messages des autres utilisateurs seront notifiés sous forme de messages du site.', + 'account.settings.notification.messages': 'Messages système', + 'account.settings.notification.messages-description': 'Les messages système seront notifiés sous forme de messages de site.', + 'account.settings.notification.todo': 'Tâches à faire', + 'account.settings.notification.todo-description': 'Les tâches à effectuer seront notifiées sous forme de messages sur site', + 'account.settings.settings.open': 'ouvert', + 'account.settings.settings.close': 'fermer', + 'trading-assistant.title': 'Assistante commerciale', + 'trading-assistant.strategyList': 'Liste de stratégies', + 'trading-assistant.createStrategy': 'Créer une politique', + 'trading-assistant.noStrategy': 'Pas encore de stratégie', + 'trading-assistant.selectStrategy': 'Veuillez sélectionner une stratégie à gauche pour afficher les détails', + 'trading-assistant.startStrategy': 'stratégie de lancement', + 'trading-assistant.stopStrategy': "stratégie d'arrêt", + 'trading-assistant.editStrategy': 'Stratégie éditoriale', + 'trading-assistant.deleteStrategy': 'Supprimer la stratégie', + 'trading-assistant.status.running': 'Courir', + 'trading-assistant.status.stopped': 'Arrêté', + 'trading-assistant.status.error': 'Erreur', + 'trading-assistant.strategyType.IndicatorStrategy': "Stratégie d'indicateurs techniques", + 'trading-assistant.strategyType.PromptBasedStrategy': 'stratégie de mots indicateurs', + 'trading-assistant.strategyType.GridStrategy': 'stratégie de grille', + 'trading-assistant.tabs.tradingRecords': 'historique des transactions', + 'trading-assistant.tabs.positions': 'Enregistrement de poste', + 'trading-assistant.tabs.equityCurve': 'courbe des actions', + 'trading-assistant.form.step1': 'Sélectionner des indicateurs', + 'trading-assistant.form.step2': "Configuration d'échange", + 'trading-assistant.form.step3': 'Paramètres de stratégie', + 'trading-assistant.form.indicator': 'Sélectionner des indicateurs', + 'trading-assistant.form.indicatorHint': 'Vous ne pouvez sélectionner que les indicateurs techniques que vous avez achetés ou créés', + 'trading-assistant.form.qdtCostHints': "L'utilisation de la stratégie consommera du QDT, veuillez vous assurer que le compte dispose d'un solde QDT suffisant", + 'trading-assistant.form.indicatorDescription': "Description de l'indicateur", + 'trading-assistant.form.noDescription': 'Pas encore de description', + 'trading-assistant.form.exchange': 'Sélectionnez un échange', + 'trading-assistant.form.apiKey': 'Clé API', + 'trading-assistant.form.secretKey': 'Clé secrète', + 'trading-assistant.form.passphrase': 'Phrase secrète', + 'trading-assistant.form.testConnection': 'tester la connexion', + 'trading-assistant.form.strategyName': 'Nom de la stratégie', + 'trading-assistant.form.symbol': 'paire de trading', + 'trading-assistant.form.symbolHint': 'Actuellement, seules les paires de trading de cryptomonnaies sont prises en charge', + 'trading-assistant.form.initialCapital': "Montant de l'investissement", + 'trading-assistant.form.marketType': 'type de marché', + 'trading-assistant.form.marketTypeFutures': 'contrat', + 'trading-assistant.form.marketTypeSpot': 'Spot', + 'trading-assistant.form.marketTypeHint': "Le contrat prend en charge le trading bidirectionnel et l'effet de levier, tandis que le spot ne prend en charge que les positions longues et l'effet de levier est fixé à 1x.", + 'trading-assistant.form.leverage': 'Tirer parti de plusieurs', + 'trading-assistant.form.leverageHint': 'Contrat : 1-125 fois, spot : fixe 1 fois', + 'trading-assistant.form.spotLeverageFixed': "L'effet de levier du trading au comptant est fixé à 1x", + 'trading-assistant.form.spotOnlyLongHint': 'Le trading au comptant ne prend en charge que les positions longues', + 'trading-assistant.form.tradeDirection': 'Orientation commerciale', + 'trading-assistant.form.tradeDirectionLong': 'Seulement longtemps', + 'trading-assistant.form.tradeDirectionShort': 'Seulement court', + 'trading-assistant.form.tradeDirectionBoth': 'transaction bidirectionnelle', + 'trading-assistant.form.timeframe': 'période', + 'trading-assistant.form.klinePeriod': 'Période K-Line', + 'trading-assistant.form.timeframe1m': '1 minute', + 'trading-assistant.form.timeframe5m': '5 minutes', + 'trading-assistant.form.timeframe15m': '15 minutes', + 'trading-assistant.form.timeframe30m': '30 minutes', + 'trading-assistant.form.timeframe1H': '1 heure', + 'trading-assistant.form.timeframe4H': '4 heures', + 'trading-assistant.form.timeframe1D': '1 jour', + 'trading-assistant.form.selectStrategyType': 'Sélectionner le type de stratégie', + 'trading-assistant.form.indicatorStrategy': 'Stratégie d\'indicateur', + 'trading-assistant.form.indicatorStrategyDesc': 'Stratégie de trading automatisée basée sur des indicateurs techniques', + 'trading-assistant.form.aiStrategy': 'Stratégie IA', + 'trading-assistant.form.aiStrategyDesc': 'Stratégie de trading automatisée basée sur la prise de décision intelligente IA', + 'trading-assistant.form.enableAiFilter': 'Activer le filtre de décision intelligente IA', + 'trading-assistant.form.enableAiFilterHint': 'Lorsqu\'il est activé, les signaux d\'indicateur seront filtrés par l\'IA pour améliorer la qualité des transactions', + 'trading-assistant.form.aiFilterPrompt': 'Invite personnalisée', + 'trading-assistant.form.aiFilterPromptHint': 'Fournir des instructions personnalisées pour le filtrage IA, laisser vide pour utiliser la valeur par défaut du système', + 'trading-assistant.validation.strategyTypeRequired': 'Veuillez sélectionner un type de stratégie', + 'trading-assistant.form.advancedSettings': 'Paramètres avancés', + 'trading-assistant.form.orderMode': 'Mode de commande', + 'trading-assistant.form.orderModeMaker': 'Créateur', + 'trading-assistant.form.orderModeTaker': 'Prix du marché (Preneur)', + 'trading-assistant.form.orderModeHint': "Le mode d'ordre en attente utilise des ordres limités et les frais de traitement sont inférieurs ; le mode prix du marché est exécuté immédiatement et les frais de traitement sont plus élevés.", + 'trading-assistant.form.makerWaitSec': "Temps d'attente pour les commandes en attente (secondes)", + 'trading-assistant.form.makerWaitSecHint': "Le temps d'attente pour la transaction après avoir passé une commande. Annulez et réessayez après l'expiration du délai.", + 'trading-assistant.form.makerRetries': 'Nombre de tentatives pour les commandes en attente', + 'trading-assistant.form.makerRetriesHint': "Le nombre maximum de tentatives lorsqu'un ordre en attente n'est pas exécuté", + 'trading-assistant.form.fallbackToMarket': "L'ordre en attente échoue et le prix du marché est dégradé.", + 'trading-assistant.form.fallbackToMarketHint': "Lorsque l'ordre en attente d'ouverture/de clôture n'a pas été exécuté, s'il doit être rétrogradé en ordre au marché pour garantir que la transaction soit terminée.", + 'trading-assistant.form.marginMode': 'Mode marge', + 'trading-assistant.form.marginModeCross': 'Entrepôt complet', + 'trading-assistant.form.marginModeIsolated': 'Position isolée', + 'trading-assistant.form.stopLossPct': "Taux d'arrêt des pertes (%)", + 'trading-assistant.form.stopLossPctHint': "Définissez le pourcentage de stop loss, 0 signifie que le stop loss n'est pas activé", + 'trading-assistant.form.takeProfitPct': 'Ratio de profit (%)', + 'trading-assistant.form.takeProfitPctHint': 'Définissez le pourcentage de profit, 0 signifie désactiver le profit', + 'trading-assistant.form.signalMode': 'Mode signal', + 'trading-assistant.form.signalModeConfirmed': 'Mode de confirmation', + 'trading-assistant.form.signalModeAggressive': 'Mode agressif', + 'trading-assistant.form.signalModeHint': 'Mode de confirmation : vérifie uniquement les lignes K terminées ; Mode agressif : vérifie également la formation de lignes K', + 'trading-assistant.form.cancel': 'Annuler', + 'trading-assistant.form.prev': 'Étape précédente', + 'trading-assistant.form.next': 'Étape suivante', + 'trading-assistant.form.confirmCreate': 'Confirmer la création', + 'trading-assistant.form.confirmEdit': 'Confirmer les modifications', + 'trading-assistant.messages.createSuccess': 'Stratégie créée avec succès', + 'trading-assistant.messages.createFailed': 'Échec de la création de la stratégie', + 'trading-assistant.messages.updateSuccess': 'Mise à jour de la stratégie réussie', + 'trading-assistant.messages.updateFailed': 'Échec de la mise à jour de la stratégie', + 'trading-assistant.messages.deleteSuccess': 'Suppression de la stratégie réussie', + 'trading-assistant.messages.deleteFailed': 'Échec de la suppression de la stratégie', + 'trading-assistant.messages.startSuccess': 'La stratégie a démarré avec succès', + 'trading-assistant.messages.startFailed': 'Échec du démarrage de la stratégie', + 'trading-assistant.messages.stopSuccess': "La stratégie s'est arrêtée avec succès", + 'trading-assistant.messages.stopFailed': "La stratégie d'arrêt a échoué", + 'trading-assistant.messages.loadFailed': "Échec de l'obtention de la liste des règles", + 'trading-assistant.messages.runningWarning': 'La stratégie est en marche. Veuillez arrêter la stratégie avant de la modifier.', + 'trading-assistant.messages.deleteConfirmWithName': 'Êtes-vous sûr de vouloir supprimer la stratégie « {name} » ? Cette opération est irréversible.', + 'trading-assistant.messages.deleteConfirm': 'Êtes-vous sûr de vouloir supprimer cette stratégie ? Cette opération est irréversible.', + 'trading-assistant.messages.loadTradesFailed': "Échec de l'obtention des enregistrements de transaction", + 'trading-assistant.messages.loadPositionsFailed': "Impossible d'obtenir l'enregistrement de position", + 'trading-assistant.messages.loadEquityFailed': "Impossible d'obtenir la courbe des capitaux propres", + 'trading-assistant.messages.loadIndicatorsFailed': 'Échec du chargement de la liste des indicateurs, veuillez réessayer plus tard', + 'trading-assistant.messages.spotLimitations': 'Le trading au comptant a été automatiquement réglé sur position longue uniquement, effet de levier 1x', + 'trading-assistant.messages.autoFillApiConfig': "La configuration historique de l'API de l'échange a été automatiquement renseignée", + 'trading-assistant.placeholders.selectIndicator': 'Veuillez sélectionner un indicateur', + 'trading-assistant.placeholders.selectExchange': 'Veuillez sélectionner un échange', + 'trading-assistant.placeholders.inputApiKey': 'Veuillez saisir la clé API', + 'trading-assistant.placeholders.inputSecretKey': 'Veuillez entrer la clé secrète', + 'trading-assistant.placeholders.inputPassphrase': 'Veuillez saisir la phrase secrète', + 'trading-assistant.placeholders.inputStrategyName': 'Veuillez saisir un nom de stratégie', + 'trading-assistant.placeholders.selectSymbol': 'Veuillez sélectionner une paire de trading', + 'trading-assistant.placeholders.selectTimeframe': 'Veuillez sélectionner une période', + 'trading-assistant.placeholders.selectKlinePeriod': 'Veuillez sélectionner une période K-Line', + 'trading-assistant.placeholders.inputAiFilterPrompt': 'Veuillez entrer une invite personnalisée (optionnel)', + 'trading-assistant.validation.indicatorRequired': 'Veuillez sélectionner un indicateur', + 'trading-assistant.validation.exchangeRequired': 'Veuillez sélectionner un échange', + 'trading-assistant.validation.apiKeyRequired': 'Veuillez saisir la clé API', + 'trading-assistant.validation.secretKeyRequired': 'Veuillez entrer la clé secrète', + 'trading-assistant.validation.passphraseRequired': 'Veuillez saisir la phrase secrète', + 'trading-assistant.validation.exchangeConfigIncomplete': "Veuillez remplir les informations complètes de configuration de l'échange", + 'trading-assistant.validation.testConnectionRequired': 'Veuillez d\'abord cliquer sur le bouton "Tester la connexion" et vous assurer que la connexion est réussie', + 'trading-assistant.validation.testConnectionFailed': 'Le test de connexion a échoué, veuillez vérifier la configuration et réessayer', + 'trading-assistant.validation.strategyNameRequired': 'Veuillez saisir un nom de stratégie', + 'trading-assistant.validation.symbolRequired': 'Veuillez sélectionner une paire de trading', + 'trading-assistant.validation.initialCapitalRequired': "Veuillez saisir le montant de l'investissement", + 'trading-assistant.validation.leverageRequired': 'Veuillez saisir le ratio de levier', + 'trading-assistant.table.time': 'temps', + 'trading-assistant.table.type': 'Tapez', + 'trading-assistant.table.price': 'prix', + 'trading-assistant.table.amount': 'Quantité', + 'trading-assistant.table.value': 'Montant', + 'trading-assistant.table.commission': 'frais de traitement', + 'trading-assistant.table.symbol': 'paire de trading', + 'trading-assistant.table.side': 'direction', + 'trading-assistant.table.size': 'Quantité de position', + 'trading-assistant.table.entryPrice': "Prix d'ouverture", + 'trading-assistant.table.currentPrice': 'prix actuel', + 'trading-assistant.table.unrealizedPnl': 'profit ou perte non réalisé', + 'trading-assistant.table.pnlPercent': 'Ratio de profits et pertes', + 'trading-assistant.table.buy': 'acheter', + 'trading-assistant.table.sell': 'vendre', + 'trading-assistant.table.long': 'Allez-y longtemps', + 'trading-assistant.table.short': 'court', + 'trading-assistant.table.noPositions': "Aucun poste pour l'instant", + 'trading-assistant.detail.title': 'Détails de la stratégie', + 'trading-assistant.detail.strategyName': 'Nom de la stratégie', + 'trading-assistant.detail.strategyType': 'Type de stratégie', + 'trading-assistant.detail.status': 'Statut', + 'trading-assistant.detail.tradingMode': 'modèle commercial', + 'trading-assistant.detail.exchange': 'échange', + 'trading-assistant.detail.initialCapital': 'Capital initial', + 'trading-assistant.detail.totalInvestment': "montant total de l'investissement", + 'trading-assistant.detail.currentEquity': 'Valeur nette actuelle', + 'trading-assistant.detail.totalPnl': 'Total des profits et pertes', + 'trading-assistant.detail.indicatorName': "Nom de l'indicateur", + 'trading-assistant.detail.maxLeverage': 'Effet de levier maximal', + 'trading-assistant.detail.decideInterval': 'intervalle de décision', + 'trading-assistant.detail.symbols': 'Objet de transaction', + 'trading-assistant.detail.createdAt': 'temps de création', + 'trading-assistant.detail.updatedAt': 'Heure de mise à jour', + 'trading-assistant.detail.llmConfig': "Configuration du modèle d'IA", + 'trading-assistant.detail.exchangeConfig': "Configuration d'échange", + 'trading-assistant.detail.provider': 'fournisseur', + 'trading-assistant.detail.modelId': 'ID du modèle', + 'trading-assistant.detail.close': 'Fermer', + 'trading-assistant.detail.loadFailed': "Échec de l'obtention des détails de la stratégie", + 'trading-assistant.equity.noData': "Aucune donnée sur la valeur nette pour l'instant", + 'trading-assistant.equity.equity': 'valeur nette', + 'trading-assistant.exchange.tradingMode': 'modèle commercial', + 'trading-assistant.exchange.virtual': 'trading simulé', + 'trading-assistant.exchange.live': 'Transaction réelle', + 'trading-assistant.exchange.selectExchange': 'Sélectionnez un échange', + 'trading-assistant.exchange.walletAddress': 'adresse du portefeuille', + 'trading-assistant.exchange.walletAddressPlaceholder': "Veuillez saisir l'adresse de votre portefeuille (en commençant par 0x)", + 'trading-assistant.exchange.privateKey': 'clé privée', + 'trading-assistant.exchange.privateKeyPlaceholder': 'Veuillez saisir la clé privée (64 caractères)', + 'trading-assistant.exchange.testConnection': 'tester la connexion', + 'trading-assistant.exchange.connectionSuccess': 'Connexion réussie', + 'trading-assistant.exchange.connectionFailed': 'La connexion a échoué', + 'trading-assistant.exchange.testFailed': 'Le test de connexion a échoué', + 'trading-assistant.exchange.fillComplete': "Veuillez remplir les informations complètes de configuration de l'échange", + 'trading-assistant.strategyTypeOptions.ai': "Stratégie basée sur l'IA", + 'trading-assistant.strategyTypeOptions.indicator': "Stratégie d'indicateurs techniques", + 'trading-assistant.strategyTypeOptions.aiDeveloping': "La fonction de stratégie basée sur l'IA est en cours de développement, alors restez à l'écoute", + 'trading-assistant.strategyTypeOptions.aiDevelopingWarning': "Des fonctionnalités de stratégie basées sur l'IA sont en cours de développement", + 'trading-assistant.indicatorType.trend': 'Tendance', + 'trading-assistant.indicatorType.momentum': 'élan', + 'trading-assistant.indicatorType.volatility': 'Fluctuations', + 'trading-assistant.indicatorType.volume': 'Volume', + 'trading-assistant.indicatorType.custom': 'Personnaliser', + 'trading-assistant.exchangeNames': { + 'okx': 'OKX', + 'binance': 'Binance', + 'hyperliquid': 'Hyperliquide', + 'blockchaincom': 'Blockchain.com', + 'coinbaseexchange': 'Coinbase', + 'gate': 'Porte.io', + 'mexc': 'MEXIQUE', + 'kraken': 'Kraken', + 'bitfinex': 'Bitfinex', + 'bybit': 'Bybit', + 'kucoin': 'KuCoin', + 'huobi': 'Huobi', + 'bitget': 'Bitget', + 'bitmex': 'BitMEX', + 'deribit': 'Déribuer', + 'phemex': 'Phemex', + 'bitmart': 'BitMart', + 'bitstamp': 'Timbre de bits', + 'bittrex': 'Bittrex', + 'poloniex': 'Poloniex', + 'gemini': 'Gémeaux', + 'cryptocom': 'Crypto.com', + 'bitflyer': 'bitFlyer', + 'upbit': 'Débit ascendant', + 'bithumb': 'Bithumb', + 'coinone': 'Coinone', + 'zb': 'ZB', + 'lbank': 'LBanque', + 'bibox': 'Bibox', + 'bigone': 'BigONE', + 'bitrue': 'Bitrue', + 'coinex': 'CoinEx', + 'digifinex': 'DigiFinex', + 'ftx': 'FTX', + 'ftxus': 'FTX États-Unis', + 'binanceus': 'Binance États-Unis', + 'binancecoinm': 'Binance COIN-M', + 'binanceusdm': 'Binance USDⓈ-M' + }, + 'ai-trading-assistant.title': 'Assistant de trading IA', + 'ai-trading-assistant.strategyList': 'Liste de stratégies', + 'ai-trading-assistant.createStrategy': 'Créer une politique', + 'ai-trading-assistant.noStrategy': 'Pas encore de stratégie', + 'ai-trading-assistant.selectStrategy': 'Veuillez sélectionner une stratégie à gauche pour afficher les détails', + 'ai-trading-assistant.startStrategy': 'stratégie de lancement', + 'ai-trading-assistant.stopStrategy': "stratégie d'arrêt", + 'ai-trading-assistant.editStrategy': 'Stratégie éditoriale', + 'ai-trading-assistant.deleteStrategy': 'Supprimer la stratégie', + 'ai-trading-assistant.status.running': 'Courir', + 'ai-trading-assistant.status.stopped': 'Arrêté', + 'ai-trading-assistant.status.error': 'Erreur', + 'ai-trading-assistant.tabs.tradingRecords': 'historique des transactions', + 'ai-trading-assistant.tabs.positions': 'Enregistrement de poste', + 'ai-trading-assistant.tabs.aiDecisions': 'Enregistrement de décision IA', + 'ai-trading-assistant.tabs.equityCurve': 'courbe des actions', + 'ai-trading-assistant.form.createTitle': 'Créer des stratégies de trading IA', + 'ai-trading-assistant.form.editTitle': "Modifier la stratégie de trading de l'IA", + 'ai-trading-assistant.form.strategyName': 'Nom de la stratégie', + 'ai-trading-assistant.form.modelId': "Modèle d'IA", + 'ai-trading-assistant.form.modelIdHint': 'Utilisez le service système OpenRouter sans configurer la clé API', + 'ai-trading-assistant.form.decideInterval': 'intervalle de décision', + 'ai-trading-assistant.form.decideInterval5m': '5 minutes', + 'ai-trading-assistant.form.decideInterval10m': '10 minutes', + 'ai-trading-assistant.form.decideInterval30m': '30 minutes', + 'ai-trading-assistant.form.decideInterval1h': '1 heure', + 'ai-trading-assistant.form.decideInterval4h': '4 heures', + 'ai-trading-assistant.form.decideInterval1d': '1 jour', + 'ai-trading-assistant.form.decideInterval1w': '1 semaine', + 'ai-trading-assistant.form.decideIntervalHint': 'L’intervalle de temps nécessaire à l’IA pour prendre des décisions', + 'ai-trading-assistant.form.runPeriod': "Cycle d'exécution", + 'ai-trading-assistant.form.runPeriodHint': "L'heure de début et l'heure de fin de l'exécution de la stratégie", + 'ai-trading-assistant.form.startDate': 'date de début', + 'ai-trading-assistant.form.endDate': 'date de fin', + 'ai-trading-assistant.form.qdtCostTitle': 'Instructions de déduction QDT', + 'ai-trading-assistant.form.qdtCostHint': "{cost} QDT sera déduit pour chaque décision de l'IA. Veuillez vous assurer que votre compte dispose d'un solde QDT suffisant. Lors de l'exécution de la stratégie, des frais seront déduits en temps réel pour chaque décision.", + 'ai-trading-assistant.form.apiKey': 'Clé API', + 'ai-trading-assistant.form.exchange': 'Sélectionnez un échange', + 'ai-trading-assistant.form.secretKey': 'Clé secrète', + 'ai-trading-assistant.form.passphrase': 'Phrase secrète', + 'ai-trading-assistant.form.testConnection': 'tester la connexion', + 'ai-trading-assistant.form.symbol': 'paire de trading', + 'ai-trading-assistant.form.symbolHint': 'Sélectionnez la paire de trading à trader', + 'ai-trading-assistant.form.initialCapital': 'Montant investi (marge)', + 'ai-trading-assistant.form.leverage': 'Tirer parti de plusieurs', + 'ai-trading-assistant.form.timeframe': 'période', + 'ai-trading-assistant.form.timeframe1m': '1 minute', + 'ai-trading-assistant.form.timeframe5m': '5 minutes', + 'ai-trading-assistant.form.timeframe15m': '15 minutes', + 'ai-trading-assistant.form.timeframe30m': '30 minutes', + 'ai-trading-assistant.form.timeframe1H': '1 heure', + 'ai-trading-assistant.form.timeframe4H': '4 heures', + 'ai-trading-assistant.form.timeframe1D': '1 jour', + 'ai-trading-assistant.form.marketType': 'type de marché', + 'ai-trading-assistant.form.marketTypeFutures': 'contrat', + 'ai-trading-assistant.form.marketTypeSpot': 'Spot', + 'ai-trading-assistant.form.totalPnl': 'Total des profits et pertes', + 'ai-trading-assistant.form.customPrompt': "Mots d'invite personnalisés", + 'ai-trading-assistant.form.customPromptHint': "Facultatif, utilisé pour personnaliser les stratégies de trading de l'IA et la logique de prise de décision", + 'ai-trading-assistant.form.cancel': 'Annuler', + 'ai-trading-assistant.form.prev': 'Étape précédente', + 'ai-trading-assistant.form.next': 'Étape suivante', + 'ai-trading-assistant.form.confirmCreate': 'Confirmer la création', + 'ai-trading-assistant.form.confirmEdit': 'Confirmer les modifications', + 'ai-trading-assistant.messages.createSuccess': 'Stratégie créée avec succès', + 'ai-trading-assistant.messages.createFailed': 'Échec de la création de la stratégie', + 'ai-trading-assistant.messages.updateSuccess': 'Mise à jour de la stratégie réussie', + 'ai-trading-assistant.messages.updateFailed': 'Échec de la mise à jour de la stratégie', + 'ai-trading-assistant.messages.deleteSuccess': 'Suppression de la stratégie réussie', + 'ai-trading-assistant.messages.deleteFailed': 'Échec de la suppression de la stratégie', + 'ai-trading-assistant.messages.startSuccess': 'La stratégie a démarré avec succès', + 'ai-trading-assistant.messages.startFailed': 'Échec du démarrage de la stratégie', + 'ai-trading-assistant.messages.stopSuccess': "La stratégie s'est arrêtée avec succès", + 'ai-trading-assistant.messages.stopFailed': "La stratégie d'arrêt a échoué", + 'ai-trading-assistant.messages.loadFailed': "Échec de l'obtention de la liste des règles", + 'ai-trading-assistant.messages.loadDecisionsFailed': "Échec de l'obtention du dossier de décision de l'IA", + 'ai-trading-assistant.messages.deleteConfirm': 'Êtes-vous sûr de vouloir supprimer cette stratégie ? Cette opération est irréversible.', + 'ai-trading-assistant.placeholders.inputStrategyName': 'Veuillez saisir un nom de stratégie', + 'ai-trading-assistant.placeholders.selectModelId': "Veuillez sélectionner un modèle d'IA", + 'ai-trading-assistant.placeholders.selectDecideInterval': 'Veuillez sélectionner un intervalle de décision', + 'ai-trading-assistant.placeholders.startTime': 'heure de début', + 'ai-trading-assistant.placeholders.endTime': 'heure de fin', + 'ai-trading-assistant.placeholders.inputApiKey': 'Veuillez saisir la clé API', + 'ai-trading-assistant.placeholders.selectExchange': 'Veuillez sélectionner un échange', + 'ai-trading-assistant.placeholders.inputSecretKey': 'Veuillez entrer la clé secrète', + 'ai-trading-assistant.placeholders.inputPassphrase': 'Veuillez saisir la phrase secrète', + 'ai-trading-assistant.placeholders.selectSymbol': 'Veuillez sélectionner une paire de trading, telle que : BTC/USDT', + 'ai-trading-assistant.placeholders.selectTimeframe': 'Veuillez sélectionner une période', + 'ai-trading-assistant.placeholders.inputCustomPrompt': "Veuillez saisir un mot d'invite personnalisé (facultatif)", + 'ai-trading-assistant.validation.strategyNameRequired': 'Veuillez saisir un nom de stratégie', + 'ai-trading-assistant.validation.modelIdRequired': "Veuillez sélectionner un modèle d'IA", + 'ai-trading-assistant.validation.runPeriodRequired': "Veuillez sélectionner un cycle d'exécution", + 'ai-trading-assistant.validation.apiKeyRequired': 'Veuillez saisir la clé API', + 'ai-trading-assistant.validation.exchangeRequired': 'Veuillez sélectionner un échange', + 'ai-trading-assistant.validation.secretKeyRequired': 'Veuillez entrer la clé secrète', + 'ai-trading-assistant.validation.symbolRequired': 'Veuillez sélectionner une paire de trading', + 'ai-trading-assistant.validation.initialCapitalRequired': "Veuillez saisir le montant de l'investissement", + 'ai-trading-assistant.table.time': 'temps', + 'ai-trading-assistant.table.type': 'Tapez', + 'ai-trading-assistant.table.price': 'prix', + 'ai-trading-assistant.table.amount': 'Quantité', + 'ai-trading-assistant.table.value': 'Montant', + 'ai-trading-assistant.table.symbol': 'paire de trading', + 'ai-trading-assistant.table.side': 'direction', + 'ai-trading-assistant.table.size': 'Quantité de position', + 'ai-trading-assistant.table.entryPrice': "Prix d'ouverture", + 'ai-trading-assistant.table.currentPrice': 'prix actuel', + 'ai-trading-assistant.table.unrealizedPnl': 'profit ou perte non réalisé', + 'ai-trading-assistant.table.profit': 'Profits et pertes', + 'ai-trading-assistant.table.openLong': 'Ouvert longtemps', + 'ai-trading-assistant.table.closeLong': 'Pinduo', + 'ai-trading-assistant.table.openShort': 'Ouvert court', + 'ai-trading-assistant.table.closeShort': 'vide', + 'ai-trading-assistant.table.addLong': 'Gadot', + 'ai-trading-assistant.table.addShort': 'ajouter un court', + 'ai-trading-assistant.table.closeShortProfit': 'Prendre des bénéfices sur une position courte', + 'ai-trading-assistant.table.closeShortStop': 'Arrêter la perte', + 'ai-trading-assistant.table.closeLongProfit': 'Arrêtez-vous longtemps et profitez', + 'ai-trading-assistant.table.closeLongStop': 'Arrêter la perte', + 'ai-trading-assistant.table.buy': 'acheter', + 'ai-trading-assistant.table.sell': 'vendre', + 'ai-trading-assistant.table.long': 'Allez-y longtemps', + 'ai-trading-assistant.table.short': 'court', + 'ai-trading-assistant.table.hold': 'tenir', + 'ai-trading-assistant.table.reasoning': "Raison de l'analyse", + 'ai-trading-assistant.table.decisions': 'prise de décision', + 'ai-trading-assistant.table.riskAssessment': 'évaluation des risques', + 'ai-trading-assistant.table.confidence': 'Confiance', + 'ai-trading-assistant.table.totalRecords': '{total} enregistrements au total', + 'ai-trading-assistant.table.noPositions': "Aucun poste pour l'instant", + 'ai-trading-assistant.detail.title': 'Détails de la stratégie', + 'ai-trading-assistant.equity.noData': "Aucune donnée sur la valeur nette pour l'instant", + 'ai-trading-assistant.equity.equity': 'valeur nette', + 'ai-trading-assistant.exchange.testFailed': 'Le test de connexion a échoué', + 'ai-trading-assistant.exchange.connectionSuccess': 'Connexion réussie', + 'ai-trading-assistant.exchange.connectionFailed': 'La connexion a échoué', + 'ai-trading-assistant.form.advancedSettings': 'Paramètres avancés', + 'ai-trading-assistant.form.orderMode': 'Mode de commande', + 'ai-trading-assistant.form.orderModeMaker': 'Créateur', + 'ai-trading-assistant.form.orderModeTaker': 'Prix du marché (Preneur)', + 'ai-trading-assistant.form.orderModeHint': "Le mode d'ordre en attente utilise des ordres limités et les frais de traitement sont inférieurs ; le mode prix du marché est exécuté immédiatement et les frais de traitement sont plus élevés.", + 'ai-trading-assistant.form.makerWaitSec': "Temps d'attente pour les commandes en attente (secondes)", + 'ai-trading-assistant.form.makerWaitSecHint': "Le temps d'attente pour la transaction après avoir passé une commande. Annulez et réessayez après l'expiration du délai.", + 'ai-trading-assistant.form.makerRetries': 'Nombre de tentatives pour les commandes en attente', + 'ai-trading-assistant.form.makerRetriesHint': "Le nombre maximum de tentatives lorsqu'un ordre en attente n'est pas exécuté", + 'ai-trading-assistant.form.fallbackToMarket': "L'ordre en attente échoue et le prix du marché est dégradé.", + 'ai-trading-assistant.form.fallbackToMarketHint': "Lorsque l'ordre en attente d'ouverture/de clôture n'a pas été exécuté, s'il doit être rétrogradé en ordre au marché pour garantir que la transaction soit terminée.", + 'ai-trading-assistant.form.marginMode': 'Mode marge', + 'ai-trading-assistant.form.marginModeCross': 'Entrepôt complet', + 'ai-trading-assistant.form.marginModeIsolated': 'Position isolée', + 'ai-analysis.title': 'Moteur de trading quantique', + 'ai-analysis.system.online': 'en ligne', + 'ai-analysis.system.agents': 'agent', + 'ai-analysis.system.active': 'actif', + 'ai-analysis.system.stage': 'scène', + 'ai-analysis.panel.roster': 'Composition des agents', + 'ai-analysis.panel.thinking': 'En pensant...', + 'ai-analysis.panel.done': 'Terminé', + 'ai-analysis.panel.standby': 'veille', + 'ai-analysis.input.title': 'Moteur de trading quantique', + 'ai-analysis.input.placeholder': "Sélectionnez l'actif cible (par exemple BTC/USDT)", + 'ai-analysis.input.watchlist': 'Stocks optionnels', + 'ai-analysis.input.start': "Démarrer l'analyse", + 'ai-analysis.input.recent': 'Tâches récentes :', + 'ai-analysis.vis.stage': 'étape', + 'ai-analysis.vis.processing': 'Traitement', + 'ai-analysis.result.complete': 'Analyse terminée', + 'ai-analysis.result.signal': 'signal final', + 'ai-analysis.result.confidence': 'Confiance :', + 'ai-analysis.result.new': 'nouvelle analyse', + 'ai-analysis.result.full': 'Voir le rapport complet', + 'ai-analysis.logs.title': 'Journal système', + 'ai-analysis.modal.title': 'rapport confidentiel', + 'ai-analysis.modal.fundamental': 'analyse fondamentale', + 'ai-analysis.modal.technical': 'Analyse technique', + 'ai-analysis.modal.sentiment': 'Analyse émotionnelle', + 'ai-analysis.modal.risk': 'évaluation des risques', + 'ai-analysis.stage.idle': 'veille', + 'ai-analysis.stage.1': 'Première phase : analyse multidimensionnelle', + 'ai-analysis.stage.2': 'Phase deux : débat long-court', + 'ai-analysis.stage.3': 'Phase trois : planification stratégique', + 'ai-analysis.stage.4': 'La quatrième étape : la revue du contrôle des risques', + 'ai-analysis.stage.complete': 'Terminé', + 'ai-analysis.agent.investment_director': 'Directeur des investissements', + 'ai-analysis.agent.role.investment_director': 'Analyse complète et conclusion finale', + 'ai-analysis.agent.market': 'analyste de marché', + 'ai-analysis.agent.role.market': 'Données technologiques et de marché', + 'ai-analysis.agent.fundamental': 'analyste fondamental', + 'ai-analysis.agent.role.fundamental': 'Finances et valorisation', + 'ai-analysis.agent.technical': 'analyste technique', + 'ai-analysis.agent.role.technical': 'Indicateurs et graphiques techniques', + 'ai-analysis.agent.news': 'analyste de nouvelles', + 'ai-analysis.agent.role.news': "Filtre d'actualités mondiales", + 'ai-analysis.agent.sentiment': 'analyste des sentiments', + 'ai-analysis.agent.role.sentiment': 'Social et émotionnel', + 'ai-analysis.agent.risk': 'analyste des risques', + 'ai-analysis.agent.role.risk': 'Vérification des risques de base', + 'ai-analysis.agent.bull': 'chercheur optimiste', + 'ai-analysis.agent.role.bull': 'Extraction de catalyseurs de croissance', + 'ai-analysis.agent.bear': 'chercheur baissier', + 'ai-analysis.agent.role.bear': 'Recherche de risques et de défauts', + 'ai-analysis.agent.manager': 'directeur de recherche', + 'ai-analysis.agent.role.manager': 'modérateur du débat', + 'ai-analysis.agent.trader': 'commerçant', + 'ai-analysis.agent.role.trader': 'Stratège exécutif', + 'ai-analysis.agent.risky': 'analyste activiste', + 'ai-analysis.agent.role.risky': 'stratégie agressive', + 'ai-analysis.agent.neutral': "analyste d'équilibre", + 'ai-analysis.agent.role.neutral': "stratégie d'équilibrage", + 'ai-analysis.agent.safe': 'analyste conservateur', + 'ai-analysis.agent.role.safe': 'stratégie conservatrice', + 'ai-analysis.agent.cro': 'Responsable du contrôle des risques (CRO)', + 'ai-analysis.agent.role.cro': 'autorité décisionnelle finale', + 'ai-analysis.script.market': 'Récupération des données OHLCV des principaux échanges...', + 'ai-analysis.script.fundamental': 'Récupération des rapports financiers trimestriels...', + 'ai-analysis.script.technical': 'Analyser les indicateurs techniques et les modèles de graphiques...', + 'ai-analysis.script.news': "Analyse de l'actualité financière mondiale...", + 'ai-analysis.script.sentiment': 'Analyser les tendances des réseaux sociaux...', + 'ai-analysis.script.risk': 'Calcul de la volatilité historique...', + 'invite.inviteLink': "Lien d'invitation", + 'invite.copy': 'Copier le lien', + 'invite.copySuccess': 'Copiez avec succès !', + 'invite.copyFailed': 'Échec de la copie, veuillez copier manuellement', + 'invite.noInviteLink': "Lien d'invitation non généré", + 'invite.totalInvites': 'Invitations cumulées', + 'invite.totalReward': 'Récompenses cumulées', + 'invite.rules': "Règles d'invitation", + 'invite.rule1': "Chaque fois que vous invitez avec succès un ami à s'inscrire, vous recevrez une récompense", + 'invite.rule2': "Si l'ami invité termine la première transaction, vous recevrez des récompenses supplémentaires", + 'invite.rule3': "Les récompenses d'invitation seront envoyées directement sur votre compte", + 'invite.inviteList': "Liste d'invitations", + 'invite.tasks': 'centre de mission', + 'invite.inviteeName': 'Invité', + 'invite.inviteTime': "Heure d'invitation", + 'invite.status': 'Statut', + 'invite.reward': 'récompense', + 'invite.active': 'actif', + 'invite.inactive': 'Non activé', + 'invite.completed': 'Terminé', + 'invite.claimed': 'Reçu', + 'invite.pending': 'A compléter', + 'invite.goToTask': 'compléter', + 'invite.claimReward': 'réclamer des récompenses', + 'invite.verify': 'Vérification terminée', + 'invite.verifySuccess': 'Vérification réussie ! Tâche terminée', + 'invite.verifyNotCompleted': "La tâche n'est pas encore terminée, veuillez d'abord la terminer", + 'invite.verifyFailed': 'La vérification a échoué, veuillez réessayer plus tard', + 'invite.claimSuccess': 'Vous avez reçu avec succès {reward} QDT !', + 'invite.claimFailed': 'Échec de la collecte, veuillez réessayer plus tard', + 'invite.totalRecords': '{total} enregistrements au total', + 'invite.task.twitter.title': 'Retweeter sur X (Twitter)', + 'invite.task.twitter.desc': 'Partagez nos tweets officiels sur votre compte X (Twitter)', + 'invite.task.youtube.title': 'Suivez notre chaîne YouTube', + 'invite.task.youtube.desc': 'Abonnez-vous et suivez notre chaîne YouTube officielle', + 'invite.task.telegram.title': 'Rejoindre le groupe Telegram', + 'invite.task.telegram.desc': 'Rejoignez notre groupe communautaire officiel Telegram', + 'invite.task.discord.title': 'Rejoignez un serveur Discord', + 'invite.task.discord.desc': 'Rejoignez notre serveur communautaire Discord', + 'message': '-', + 'layouts.usermenu.dialog.title': 'informations', + 'layouts.usermenu.dialog.content': 'Êtes-vous sûr de vouloir vous déconnecter ?', + 'layouts.userLayout.title': "Trouver la vérité dans l'incertitude", + // Auto-filled missing keys from en-US (fallback to English) + 'dashboard.indicator.boundary.message': 'Note: The indicator script only computes plots + buy/sell signals. Position sizing, risk, scaling, fees/slippage belong to execution config.', + 'dashboard.indicator.boundary.indicatorRule': "Keep the script to buy/sell only (and set df['buy']/df['sell']). Do NOT implement position management, TP/SL, scaling in the script.", + 'dashboard.indicator.boundary.backtestRule': 'Rule: If a bar has a main signal (buy/sell → open/close/reverse), scaling in/out is skipped on the same bar.', + 'dashboard.indicator.guide.title': 'Python Indicator & Strategy Development Guide', + 'dashboard.indicator.guide.intro': 'This platform supports writing custom technical indicators and trading signals using Python. The system includes built-in pandas and numpy data analysis libraries. You can use standard DataFrame operations to process K-line data and draw charts or mark buy/sell signals.', + 'dashboard.indicator.guide.section1.title': '1. Runtime Environment & Predefined Variables', + 'dashboard.indicator.guide.section1.env': 'Your code runs in a browser-based Python environment (Pyodide).', + 'dashboard.indicator.guide.section1.libs': 'Pre-installed Libraries', + 'dashboard.indicator.guide.section1.libsDesc': 'The following libraries are imported by default, no need to import again:', + 'dashboard.indicator.guide.section1.libsList': 'pandas (pd), numpy (np), math, json', + 'dashboard.indicator.guide.section1.data': 'Input Data: df', + 'dashboard.indicator.guide.section1.dataDesc': 'The system automatically converts current K-line data into a Pandas DataFrame object, variable name is df.', + 'dashboard.indicator.guide.section1.dataColumns': 'df contains the following columns (all float type):', + 'dashboard.indicator.guide.section1.dataColumnsList': 'time (timestamp, seconds), open, high, low, close, volume', + 'dashboard.indicator.guide.section1.dataNote': 'Data index is arranged from old to new (0 is the earliest data, last row is the latest data).', + 'dashboard.indicator.guide.section1.dataIndex': 'Important: df index is timestamp (DatetimeIndex), please preserve the index, avoid using .values which loses the index.', + 'dashboard.indicator.guide.section2.title': '2. Parameter Definition', + 'dashboard.indicator.guide.section2.desc': 'You can define variables as parameters at the beginning of your code. The system will try to parse statements like variable = value as adjustable parameters.', + 'dashboard.indicator.guide.section2.example': 'Parameter Example', + 'dashboard.indicator.guide.section2.exampleCode': 'span = 20 or multiplier = 2.0', + 'dashboard.indicator.guide.section3.title': '3. Backtest Signal Setup (Important!)', + 'dashboard.indicator.guide.section3.desc': 'If you want your indicator to support backtesting, you must set the following two columns in your code:', + 'dashboard.indicator.guide.section3.backtestRequired': 'Required for Backtesting:', + 'dashboard.indicator.guide.section3.backtestBuySignal': "df['buy_signal'] - Boolean Series, True indicates buy signal", + 'dashboard.indicator.guide.section3.backtestSellSignal': "df['sell_signal'] - Boolean Series, True indicates sell signal", + 'dashboard.indicator.guide.section3.backtestExample': 'Example:', + 'dashboard.indicator.guide.section3.backtestExampleCode': "df['buy_signal'] = condition_buy
    df['sell_signal'] = condition_sell", + 'dashboard.indicator.guide.section3.backtestNote': 'Note: Even if you only set output.signals for chart display, you must set these two columns to enable backtesting.', + 'dashboard.indicator.guide.section4.title': '4. Output Requirements: output', + 'dashboard.indicator.guide.section4.desc': 'At the end of code execution, you must define a dictionary named output to tell the chart how to display results.', + 'dashboard.indicator.guide.section4.fields': 'The output dictionary contains the following fields:', + 'dashboard.indicator.guide.section4.plots': 'plots - For drawing lines (e.g., moving averages, RSI)', + 'dashboard.indicator.guide.section4.signals': 'signals - For marking buy/sell signals (e.g., arrows, labels)', + 'dashboard.indicator.guide.section4.name': 'name - Indicator name (optional)', + 'dashboard.indicator.guide.section4.calculatedVars': 'calculatedVars - Calculated variables (optional, for displaying additional information)', + 'dashboard.indicator.guide.section5.title': '5. Plot Configuration (Plots)', + 'dashboard.indicator.guide.section5.desc': 'Each object in the plots list represents a line.', + 'dashboard.indicator.guide.section5.table.field': 'Field', + 'dashboard.indicator.guide.section5.table.type': 'Type', + 'dashboard.indicator.guide.section5.table.required': 'Required', + 'dashboard.indicator.guide.section5.table.requiredYes': 'Yes', + 'dashboard.indicator.guide.section5.table.requiredNo': 'No', + 'dashboard.indicator.guide.section5.table.desc': 'Description', + 'dashboard.indicator.guide.section5.table.name': 'The name of the indicator line, displayed in the legend', + 'dashboard.indicator.guide.section5.table.data': 'Data list, length must match the number of rows in df. Recommended to use series.tolist()', + 'dashboard.indicator.guide.section5.table.color': "Line color (Hex format, e.g. '#ff0000'). If not filled, the system will automatically assign", + 'dashboard.indicator.guide.section5.table.typeDesc': "Chart type, default is 'line'", + 'dashboard.indicator.guide.section5.table.overlay': 'Important. True means display on main chart (with K-line), False means display on sub-chart (independent area)', + 'dashboard.indicator.guide.section5.example': 'Example: Draw Simple Moving Average (SMA)', + 'dashboard.indicator.guide.section5.exampleCalc': 'Calculate SMA', + 'dashboard.indicator.guide.section5.exampleOutput': 'Construct Output', + 'dashboard.indicator.guide.section5.exampleOverlay': 'Display on main chart', + 'dashboard.indicator.guide.section6.title': '6. Signal Markers (Signals)', + 'dashboard.indicator.guide.section6.desc': "signals are used to mark buy/sell points on specific K-lines through icons or text. Note: This is only for chart display. The backtest system uses df['buy_signal'] and df['sell_signal'].", + 'dashboard.indicator.guide.section6.table.type': "Signal type: 'buy' (buy, marked below K-line) or 'sell' (sell, marked above K-line)", + 'dashboard.indicator.guide.section6.table.data': 'Signal data list. Fill price value at signal positions, fill None (or np.nan) at non-signal positions', + 'dashboard.indicator.guide.section6.table.text': 'Text displayed on the label (e.g. "B", "S", "Buy", "Sell")', + 'dashboard.indicator.guide.section6.table.color': 'Marker color', + 'dashboard.indicator.guide.section6.hint': 'The system automatically handles positioning.', + 'dashboard.indicator.guide.section6.buy': 'Markers will be automatically positioned below the Low of the current K-line', + 'dashboard.indicator.guide.section6.sell': 'Markers will be automatically positioned above the High of the current K-line', + 'dashboard.indicator.guide.section6.example': 'Example: Draw Buy/Sell Signals', + 'dashboard.indicator.guide.section6.exampleInit': 'Initialize signal list, fill all with None', + 'dashboard.indicator.guide.section6.exampleLogic': 'Simple logic: mark buy when close price is greater than open price', + 'dashboard.indicator.guide.section6.exampleNote': 'In actual logic, please use loops or vectorized operations', + 'dashboard.indicator.guide.section6.examplePrice': 'Record price', + 'dashboard.indicator.guide.section6.exampleText': 'Buy', + 'dashboard.indicator.guide.section7.title': '7. Complete Code Examples', + 'dashboard.indicator.guide.section7.exampleA': 'Example A: Dual EMA Strategy (Supports Backtesting)', + 'dashboard.indicator.guide.section7.exampleACode': "# 1. Calculate indicators
    df['ema_fast'] = df['close'].ewm(span=5, adjust=False).mean()
    df['ema_slow'] = df['close'].ewm(span=13, adjust=False).mean()

    # 2. Calculate crossover signals
    cross_up = (df['ema_fast'] > df['ema_slow']) & (df['ema_fast'].shift(1) <= df['ema_slow'].shift(1))
    cross_down = (df['ema_fast'] < df['ema_slow']) & (df['ema_fast'].shift(1) >= df['ema_slow'].shift(1))

    # 3. 【Important】Set NEW backtest signal columns (bool)
    df['open_long'] = cross_up
    df['close_long'] = cross_down
    df['open_short'] = cross_down
    df['close_short'] = cross_up

    # 4. Generate signal data for chart display
    buy_signals = [df['low'].iloc[i] * 0.995 if cross_up.iloc[i] else None for i in range(len(df))]
    sell_signals = [df['high'].iloc[i] * 1.005 if cross_down.iloc[i] else None for i in range(len(df))]

    # 5. Build output
    output = {
    \"name\": \"Dual EMA Strategy\",
    \"plots\": [
    {\"name\": \"EMA 5\", \"data\": df['ema_fast'].tolist(), \"color\": \"#FF6B6B\", \"overlay\": True},
    {\"name\": \"EMA 13\", \"data\": df['ema_slow'].tolist(), \"color\": \"#4ECDC4\", \"overlay\": True}
    ],
    \"signals\": [
    {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},
    {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}
    ]
    }", + 'dashboard.indicator.guide.section7.exampleB': 'Example B: Bollinger Bands - Chart Display Only', + 'dashboard.indicator.guide.section7.exampleBCode': "# Calculate Bollinger Bands (chart display only, no backtesting)
    length = 20
    mult = 2.0
    df['sma'] = df['close'].rolling(length).mean()
    df['std'] = df['close'].rolling(length).std()
    df['upper'] = df['sma'] + mult * df['std']
    df['lower'] = df['sma'] - mult * df['std']

    output = {
    \"name\": \"Bollinger Bands\",
    \"plots\": [
    {\"name\": \"Upper\", \"data\": df['upper'].tolist(), \"color\": \"#FF5252\", \"overlay\": True},
    {\"name\": \"Middle\", \"data\": df['sma'].tolist(), \"color\": \"#2196F3\", \"overlay\": True},
    {\"name\": \"Lower\", \"data\": df['lower'].tolist(), \"color\": \"#00E676\", \"overlay\": True}
    ]
    }", + 'dashboard.indicator.guide.section7.exampleC': 'Example C: RSI Strategy (Supports Backtesting)', + 'dashboard.indicator.guide.section7.exampleCCode': "# Calculate RSI
    delta = df['close'].diff()
    up = delta.clip(lower=0)
    down = -1 * delta.clip(upper=0)
    ema_up = up.ewm(com=13, adjust=False).mean()
    ema_down = down.ewm(com=13, adjust=False).mean()
    rs = ema_up / ema_down
    df['rsi'] = 100 - (100 / (1 + rs))

    # Generate trading signals
    buy_condition = df['rsi'] < 30 # Oversold buy
    sell_condition = df['rsi'] > 70 # Overbought sell

    # 【Important】Set NEW backtest signal columns (bool)
    df['open_long'] = buy_condition
    df['close_long'] = sell_condition
    df['open_short'] = sell_condition
    df['close_short'] = buy_condition

    # Generate chart signals
    buy_signals = [df['low'].iloc[i] * 0.995 if buy_condition.iloc[i] else None for i in range(len(df))]
    sell_signals = [df['high'].iloc[i] * 1.005 if sell_condition.iloc[i] else None for i in range(len(df))]

    output = {
    \"name\": \"RSI Strategy\",
    \"plots\": [
    {\"name\": \"RSI\", \"data\": df['rsi'].tolist(), \"color\": \"#9C27B0\", \"overlay\": False},
    {\"name\": \"Overbought\", \"data\": [70] * len(df), \"color\": \"#FF5252\", \"style\": \"dashed\", \"overlay\": False},
    {\"name\": \"Oversold\", \"data\": [30] * len(df), \"color\": \"#00E676\", \"style\": \"dashed\", \"overlay\": False}
    ],
    \"signals\": [
    {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},
    {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}
    ]
    }", + 'dashboard.indicator.guide.section6.param': 'Parameters', + 'dashboard.indicator.guide.section6.calc': 'Calculate', + 'dashboard.indicator.guide.section6.output': 'Output', + 'dashboard.indicator.guide.section6.generate': 'Generate signal data (initialize with None)', + 'dashboard.indicator.guide.section6.subchart': 'Display on sub-chart', + 'dashboard.indicator.guide.section8.title': '8. Frequently Asked Questions (FAQ)', + 'dashboard.indicator.guide.section8.q1': "Q: Why can't my indicator be backtested?", + 'dashboard.indicator.guide.section8.a1': "A: The backtest system requires NEW signal columns: df['open_long'], df['close_long'], df['open_short'], df['close_short'] (boolean). Even if you set output.signals for chart display, you must set these columns to enable backtesting.", + 'dashboard.indicator.guide.section8.q2': 'Q: Data length mismatch?', + 'dashboard.indicator.guide.section8.a2': "A: The data list in plots and signals must have exactly the same length as the K-line data. If you use df['xxx'].tolist(), there is usually no problem.", + 'dashboard.indicator.guide.section8.q3': 'Q: How to handle NaN (empty values)?', + 'dashboard.indicator.guide.section8.a3': "A: Pandas calculations (such as rolling mean) will produce NaN at the beginning. The system will automatically convert NaN to null for chart rendering. You don't need to manually clean it, but if you need specific logic, you can use df.fillna(0) or other methods.", + 'dashboard.indicator.guide.section8.q4': 'Q: Index mismatch causing all signals to be NaN?', + 'dashboard.indicator.guide.section8.a4': "A: Please ensure you preserve the original df index (timestamp). Avoid using .values to create new Series. Use columns directly like df['close'] for operations.", + 'dashboard.indicator.guide.section8.q5': 'Q: Can I use third-party network libraries?', + 'dashboard.indicator.guide.section8.a5': 'A: No. The runtime environment is a browser sandbox and cannot use libraries like requests to request external APIs, nor can it install additional pip packages. Limited to built-in libraries such as pandas, numpy, math, json.', + 'dashboard.indicator.guide.section8.q6': 'Q: How to debug code?', + 'dashboard.indicator.guide.section8.a6': 'A: If the chart does not display indicators, it is usually because the Python code has an error. Please check for syntax errors or array index out of bounds. It is recommended to debug the algorithm logic in a local Python environment first, then copy it in.', + 'dashboard.indicator.guide.section8.q7': 'Q: Backtest shows 0 signals?', + 'dashboard.indicator.guide.section8.a7': "A: Please check the data type of df['open_long']/df['close_long']/df['open_short']/df['close_short']. They should be boolean (True/False), not numeric. Use condition.fillna(False).astype(bool) to ensure correct type.", + 'dashboard.indicator.backtest.commissionHint': 'Charged on notional value (incl. leverage) per trade; both entry & exit are deducted from balance.', + 'dashboard.indicator.backtest.historyTitle': 'Backtest History', + 'dashboard.indicator.backtest.historyRefresh': 'Refresh', + 'dashboard.indicator.backtest.historyView': 'View', + 'dashboard.indicator.backtest.historyNoData': 'No backtest history', + 'dashboard.indicator.backtest.historyUseCurrent': 'Only current symbol/timeframe', + 'dashboard.indicator.backtest.historyFilterSymbol': 'Symbol', + 'dashboard.indicator.backtest.historyFilterTimeframe': 'Timeframe', + 'dashboard.indicator.backtest.historyApply': 'Apply filters', + 'dashboard.indicator.backtest.historyAIAnalyze': 'AI Analyze', + 'dashboard.indicator.backtest.historyAIAnalyzeTitle': 'AI Suggestions', + 'dashboard.indicator.backtest.historyNoAIResult': 'No AI analysis result', + 'dashboard.indicator.backtest.historyRunId': 'Run ID', + 'dashboard.indicator.backtest.historyCreatedAt': 'Time', + 'dashboard.indicator.backtest.historyRange': 'Range', + 'dashboard.indicator.backtest.historyStatus': 'Status', + 'dashboard.indicator.backtest.historyStatusSuccess': 'Success', + 'dashboard.indicator.backtest.historyStatusFailed': 'Failed', + 'dashboard.indicator.backtest.historyActions': 'Actions', + 'dashboard.indicator.backtest.hint.entryPctMax': 'Max entry: {maxPct}% (reserve budget for future scale-ins)', + 'trading-assistant.exchange.ipWhitelistTip': 'Please add the following IPs to the whitelist in your exchange API settings:', + 'signal-robot.title': 'Signal Robot Console', + 'signal-robot.createBot': 'Create New Bot', + 'signal-robot.search.nameOrSymbol': 'Name/Symbol', + 'signal-robot.search.placeholder': 'Search bot name or symbol', + 'signal-robot.search.status': 'Status', + 'signal-robot.search.statusAll': 'All', + 'signal-robot.search.statusRunning': 'Running', + 'signal-robot.search.statusPaused': 'Paused', + 'signal-robot.search.query': 'Query', + 'signal-robot.search.reset': 'Reset', + 'signal-robot.table.botName': 'Bot Name', + 'signal-robot.table.symbolTimeframe': 'Symbol/Timeframe', + 'signal-robot.table.triggerStrategy': 'Trigger Strategy', + 'signal-robot.table.notificationChannels': 'Notification Channels', + 'signal-robot.table.status': 'Status', + 'signal-robot.table.action': 'Action', + 'signal-robot.table.triggerConditions': '{count} trigger conditions', + 'signal-robot.table.monitoring': 'Monitoring', + 'signal-robot.table.paused': 'Paused', + 'signal-robot.table.edit': 'Edit', + 'signal-robot.table.pause': 'Pause', + 'signal-robot.table.start': 'Start', + 'signal-robot.table.delete': 'Delete', + 'signal-robot.table.deleteConfirm': 'Are you sure you want to delete this bot?', + 'signal-robot.table.deleteSuccess': 'Deleted successfully', + 'signal-robot.table.startSuccess': 'Bot started', + 'signal-robot.table.pauseSuccess': 'Bot paused', + 'signal-robot.channel.telegram': 'Telegram', + 'signal-robot.channel.discord': 'Discord', + 'signal-robot.channel.email': 'Email', + 'signal-robot.channel.webhook': 'Webhook', + 'signal-robot.channel.browser': 'Browser Push', + 'signal-robot.modal.createTitle': 'Create New Signal Robot', + 'signal-robot.modal.editTitle': 'Edit Signal Robot', + 'signal-robot.modal.step.basic': 'Basic Settings', + 'signal-robot.modal.step.entry': 'Entry Strategy', + 'signal-robot.modal.step.position': 'Position Management', + 'signal-robot.modal.step.risk': 'Risk Control', + 'signal-robot.modal.step.notify': 'Notification Config', + 'signal-robot.modal.prev': 'Previous', + 'signal-robot.modal.next': 'Next', + 'signal-robot.modal.preview': 'Preview Backtest', + 'signal-robot.modal.complete': 'Complete & Run', + 'signal-robot.modal.saveSuccess': 'Saved successfully', + 'signal-robot.modal.previewDeveloping': 'Preview feature is under development...', + 'signal-robot.modal.basicInfoRequired': 'Please complete basic information', + 'signal-robot.basic.alert': 'Set basic information for the bot', + 'signal-robot.basic.title': 'Basic Information', + 'signal-robot.basic.botName': 'Bot Name', + 'signal-robot.basic.botNamePlaceholder': 'e.g.: BTC SuperTrend Strategy', + 'signal-robot.basic.symbol': 'Trading Pair', + 'signal-robot.basic.symbolPlaceholder': 'Select from watchlist', + 'signal-robot.basic.noWatchlist': 'No watchlist items, please add in market page first', + 'signal-robot.basic.timeframe': 'K-Line Period', + 'signal-robot.basic.timeframe15m': '15 Minutes', + 'signal-robot.basic.timeframe30m': '30 Minutes', + 'signal-robot.basic.timeframe1h': '1 Hour', + 'signal-robot.basic.timeframe4h': '4 Hours', + 'signal-robot.basic.timeframe1d': '1 Day', + 'signal-robot.basic.autoNameSuggestion': '{symbol} Signal Robot', + 'signal-robot.entry.alert': 'Trigger entry signal when all conditions below are met', + 'signal-robot.entry.condition': 'Condition {index}', + 'signal-robot.entry.indicator': 'Indicator', + 'signal-robot.entry.indicatorSupertrend': 'SuperTrend', + 'signal-robot.entry.indicatorEma': 'EMA (Exponential Moving Average)', + 'signal-robot.entry.indicatorRsi': 'RSI (Relative Strength Index)', + 'signal-robot.entry.indicatorMacd': 'MACD', + 'signal-robot.entry.indicatorBollinger': 'Bollinger Bands', + 'signal-robot.entry.atrPeriod': 'ATR Period', + 'signal-robot.entry.multiplier': 'Multiplier', + 'signal-robot.entry.triggerSignal': 'Trigger Signal', + 'signal-robot.entry.signalTrendBullish': 'Trend Bullish', + 'signal-robot.entry.signalTrendBearish': 'Trend Bearish', + 'signal-robot.entry.signalIsUptrend': 'Is Uptrend', + 'signal-robot.entry.signalIsDowntrend': 'Is Downtrend', + 'signal-robot.entry.period': 'Period', + 'signal-robot.entry.compareCondition': 'Compare Condition', + 'signal-robot.entry.priceAbove': 'Price > EMA (Price Above)', + 'signal-robot.entry.priceBelow': 'Price < EMA (Price Below)', + 'signal-robot.entry.crossUp': 'Price Crosses Above EMA (Golden Cross)', + 'signal-robot.entry.crossDown': 'Price Crosses Below EMA (Death Cross)', + 'signal-robot.entry.compare': 'Compare', + 'signal-robot.entry.greaterThan': 'Greater Than', + 'signal-robot.entry.lessThan': 'Less Than', + 'signal-robot.entry.crossUpShort': 'Cross Up', + 'signal-robot.entry.crossDownShort': 'Cross Down', + 'signal-robot.entry.threshold': 'Threshold', + 'signal-robot.entry.selectIndicator': 'Please select an indicator to configure parameters', + 'signal-robot.entry.addCondition': 'Add Condition', + 'signal-robot.position.alert': 'Configure initial position size, leverage and pyramiding rules', + 'signal-robot.position.basicTitle': 'Basic Position Management', + 'signal-robot.position.initialSize': 'Initial Size (Capital %)', + 'signal-robot.position.initialSizeHint': 'Recommended 5% - 20%', + 'signal-robot.position.leverage': 'Leverage', + 'signal-robot.position.leverageHint': 'Please ensure your exchange account supports this leverage', + 'signal-robot.position.maxPyramiding': 'Max Pyramiding Times', + 'signal-robot.position.maxPyramidingHint': '0 means no pyramiding', + 'signal-robot.position.pyramidingTitle': 'Pyramiding Rules', + 'signal-robot.position.pyramidingEnabled': 'Enabled', + 'signal-robot.position.pyramidingDisabled': 'Disabled', + 'signal-robot.position.pyramidingCondition': 'Pyramiding Trigger Condition', + 'signal-robot.position.priceRisePct': 'Price Rises (Long) / Falls (Short)', + 'signal-robot.position.profitPct': 'Profit Reaches', + 'signal-robot.position.triggerThreshold': 'Trigger Threshold (%)', + 'signal-robot.position.triggerThresholdHint': 'e.g. 3%: add position once every 3% rise', + 'signal-robot.position.addSize': 'Single Add Size (Capital %)', + 'signal-robot.position.pyramidingDisabledHint': 'Pyramiding disabled, only initial position will be executed', + 'signal-robot.risk.alert': 'Configure take profit, stop loss and exit rules', + 'signal-robot.risk.stopLossTitle': 'Stop Loss', + 'signal-robot.risk.stopLossEnabled': 'Enabled', + 'signal-robot.risk.stopLossDisabled': 'Disabled', + 'signal-robot.risk.stopLossType': 'Stop Loss Type', + 'signal-robot.risk.stopLossFixedPct': 'Fixed Percentage', + 'signal-robot.risk.stopLossAtrMultiplier': 'ATR Multiplier', + 'signal-robot.risk.stopLossValue': 'Value', + 'signal-robot.risk.stopLossFixedHint': 'e.g. 2%: stop loss when loss reaches 2%', + 'signal-robot.risk.stopLossAtrHint': 'e.g. 2.0: stop loss when price touches ATR*2.0', + 'signal-robot.risk.stopLossDisabledHint': 'Stop loss disabled (high risk)', + 'signal-robot.risk.trailingStopTitle': 'Trailing Stop', + 'signal-robot.risk.activationProfit': 'Activation Profit (%)', + 'signal-robot.risk.activationProfitHint': 'Start trailing when profit reaches this level', + 'signal-robot.risk.callbackPct': 'Callback (%)', + 'signal-robot.risk.callbackPctHint': 'Trigger take profit when price falls from peak by this percentage', + 'signal-robot.risk.trailingStopDisabledHint': 'Trailing stop disabled', + 'signal-robot.risk.otherExitTitle': 'Other Exit Rules', + 'signal-robot.risk.signalExit': 'Allow Signal Exit', + 'signal-robot.risk.signalExitHint': 'Close position immediately when reverse signal appears (e.g. close long when short signal appears)', + 'signal-robot.notify.alert': 'Configure notification methods when signal is triggered', + 'signal-robot.notify.channelTitle': 'Notification Channels', + 'signal-robot.notify.discordWebhook': 'Discord Webhook', + 'signal-robot.notify.genericWebhook': 'Generic Webhook', + 'signal-robot.notify.webhookUrl': 'Webhook URL', + 'signal-robot.notify.webhookUrlPlaceholder': 'https://example.com/webhook', + 'signal-robot.notify.discordUrl': 'Discord URL', + 'signal-robot.notify.discordUrlPlaceholder': 'https://discord.com/api/webhooks/...', + 'signal-robot.operator.greaterThan': 'Greater Than', + 'signal-robot.operator.lessThan': 'Less Than', + 'signal-robot.operator.equal': 'Equal', + 'signal-robot.operator.goldenCross': 'Golden Cross', + 'signal-robot.operator.deathCross': 'Death Cross', + 'signal-robot.operator.increaseBy': 'Increase By', + 'signal-robot.operator.decreaseBy': 'Decrease By', + 'signal-robot.indicator.price': 'Price', + 'signal-robot.indicator.rsi': 'RSI', + 'signal-robot.indicator.kdjK': 'KDJ(K)', + 'signal-robot.indicator.kdjJ': 'KDJ(J)', + 'signal-robot.indicator.macd': 'MACD', + 'signal-robot.indicator.macdHist': 'MACD Histogram', + 'signal-robot.indicator.bollingerUp': 'Bollinger Upper', + 'signal-robot.indicator.bollingerLow': 'Bollinger Lower', + 'signal-robot.indicator.volume': 'Volume', + 'signal-robot.indicator.changePct1h': '1H Change %', + 'signal-robot.indicator.changePct24h': '24H Change %', + 'signal-robot.indicator.signalLine': 'Signal Line' +} + +export default { + ...components, + ...locale +} diff --git a/quantdinger_vue/src/locales/lang/ja-JP.js b/quantdinger_vue/src/locales/lang/ja-JP.js new file mode 100644 index 0000000..b6ce6fb --- /dev/null +++ b/quantdinger_vue/src/locales/lang/ja-JP.js @@ -0,0 +1,1768 @@ +import antdJaJP from 'ant-design-vue/es/locale-provider/ja_JP' +import momentJA from 'moment/locale/ja' + +const components = { + antLocale: antdJaJP, + momentName: 'ja', + momentLocale: momentJA +} + +const locale = { + 'submit': '送信する', + 'save': '保存する', + 'submit.ok': '送信成功', + 'save.ok': '正常に保存されました', + 'menu.welcome': 'ようこそ', + 'menu.home': 'ホームページ', + 'menu.dashboard': 'ダッシュボード', + 'menu.dashboard.indicator': '指標分析', + 'menu.dashboard.community': 'インジケーターコミュニティ', + 'menu.dashboard.analysis': 'AI分析', + 'menu.dashboard.tradingAssistant': '取引アシスタント', + 'menu.dashboard.aiTradingAssistant': 'AI取引アシスタント', + 'menu.dashboard.signalRobot': 'シグナルロボット', + 'menu.dashboard.monitor': 'モニタリングページ', + 'menu.dashboard.workplace': '作業台', + 'menu.form': 'フォームページ', + 'menu.form.basic-form': '基本形', + 'menu.form.step-form': 'ステップバイステップフォーム', + 'menu.form.step-form.info': 'ステップバイステップフォーム(転送情報を記入)', + 'menu.form.step-form.confirm': 'ステップバイステップフォーム(振込情報の確認)', + 'menu.form.step-form.result': 'ステップバイステップのフォーム(完了)', + 'menu.form.advanced-form': '高度なフォーム', + 'menu.list': '一覧ページ', + 'menu.list.table-list': 'お問い合わせフォーム', + 'menu.list.basic-list': '規格一覧', + 'menu.list.card-list': 'カードリスト', + 'menu.list.search-list': '検索リスト', + 'menu.list.search-list.articles': '検索リスト(記事)', + 'menu.list.search-list.projects': '検索リスト(プロジェクト)', + 'menu.list.search-list.applications': '検索リスト(アプリ)', + 'menu.profile': '詳細ページ', + 'menu.profile.basic': '基本詳細ページ', + 'menu.profile.advanced': '詳細ページ', + 'menu.result': '結果ページ', + 'menu.result.success': '成功ページ', + 'menu.result.fail': '失敗ページ', + 'menu.exception': '例外ページ', + 'menu.exception.not-permission': '403', + 'menu.exception.not-find': '404', + 'menu.exception.server-error': '500', + 'menu.exception.trigger': 'トリガーエラー', + 'menu.account': '個人ページ', + 'menu.account.center': 'パーソナルセンター', + 'menu.account.settings': '個人設定', + 'menu.account.trigger': 'トリガーエラー', + 'menu.account.logout': 'ログアウト', + 'menu.wallet': '私の財布', + 'menu.docs': 'ドキュメントセンター', + 'menu.docs.detail': '文書の詳細', + 'menu.header.refreshPage': 'ページを更新する', + 'menu.invite.friends': '友達を招待する', + 'app.setting.pagestyle': '全体的なスタイル設定', + 'app.setting.pagestyle.light': '明るいメニュースタイル', + 'app.setting.pagestyle.dark': 'ダークメニュースタイル', + 'app.setting.pagestyle.realdark': 'ダークモード', + 'app.setting.themecolor': 'テーマカラー', + 'app.setting.navigationmode': 'ナビゲーションモード', + 'app.setting.sidemenu.nav': 'サイドバーのナビゲーション', + 'app.setting.topmenu.nav': 'トップバーのナビゲーション', + 'app.setting.content-width': 'コンテンツ領域の幅', + 'app.setting.content-width.tooltip': 'この設定は、[トップバーナビゲーション]時のみ有効です。', + 'app.setting.content-width.fixed': '修正済み', + 'app.setting.content-width.fluid': 'ストリーミング', + 'app.setting.fixedheader': '固定ヘッダー', + 'app.setting.fixedheader.tooltip': '固定ヘッダー時に設定可能', + 'app.setting.autoHideHeader': 'スクロール時にヘッダーを非表示にする', + 'app.setting.fixedsidebar': 'サイドメニューを修正', + 'app.setting.sidemenu': 'サイドメニューのレイアウト', + 'app.setting.topmenu': 'トップメニューのレイアウト', + 'app.setting.othersettings': 'その他の設定', + 'app.setting.weakmode': 'カラーウィークネスモード', + 'app.setting.multitab': 'マルチタブモード', + 'app.setting.copy': '設定をコピーする', + 'app.setting.loading': 'テーマを読み込み中', + 'app.setting.copyinfo': '設定が正常にコピーされました src/config/defaultSettings.js', + 'app.setting.copy.success': 'コピー完了', + 'app.setting.copy.fail': 'コピーに失敗しました', + 'app.setting.theme.switching': 'テーマ変更!', + 'app.setting.production.hint': '構成バーは開発環境でのプレビューにのみ使用され、運用環境では表示されません。 構成ファイルを手動でコピーして変更してください。', + 'app.setting.themecolor.daybreak': 'ドーンブルー(デフォルト)', + 'app.setting.themecolor.dust': '夕暮れ', + 'app.setting.themecolor.volcano': '火山', + 'app.setting.themecolor.sunset': '日没', + 'app.setting.themecolor.cyan': '明清', + 'app.setting.themecolor.green': 'オーロラグリーン', + 'app.setting.themecolor.geekblue': 'オタクブルー', + 'app.setting.themecolor.purple': 'ジャン・ジー', + 'app.setting.tooltip': 'ページ設定', + 'user.login.userName': 'ユーザー名', + 'user.login.password': 'パスワード', + 'user.login.username.placeholder': 'アカウント: 管理者', + 'user.login.password.placeholder': 'パスワード: admin または ant.design', + 'user.login.message-invalid-credentials': 'ログインに失敗しました。メールアドレスと確認コードを確認してください', + 'user.login.message-invalid-verification-code': '認証コードエラー', + 'user.login.tab-login-credentials': 'アカウントパスワードログイン', + 'user.login.tab-login-email': 'メールログイン', + 'user.login.tab-login-mobile': '携帯電話番号ログイン', + 'user.login.captcha.placeholder': 'グラフィック認証コードを入力してください', + 'user.login.mobile.placeholder': '携帯電話番号', + 'user.login.mobile.verification-code.placeholder': '認証コード', + 'user.login.email.placeholder': 'メールアドレスを入力してください', + 'user.login.email.verification-code.placeholder': '確認コードを入力してください', + 'user.login.email.sending': '認証コードが送信されています...', + 'user.login.email.send-success-title': 'ヒント', + 'user.login.email.send-success': '確認コードは正常に送信されました。メールを確認してください', + 'user.login.sms.send-success': '確認コードが正常に送信されました。テキスト メッセージを確認してください。', + 'user.login.remember-me': '自動ログイン', + 'user.login.forgot-password': 'パスワードを忘れた場合', + 'user.login.sign-in-with': 'その他のログイン方法', + 'user.login.signup': 'アカウントを登録する', + 'user.login.login': 'ログイン', + 'user.register.register': '登録する', + 'user.register.email.placeholder': '電子メール', + 'user.register.password.placeholder': '6文字以上を入力してください。 推測されやすいパスワードは使用しないでください。', + 'user.register.password.popover-message': '6文字以上を入力してください。 推測されやすいパスワードは使用しないでください。', + 'user.register.confirm-password.placeholder': 'パスワードの確認', + 'user.register.get-verification-code': '確認コードを取得する', + 'user.register.sign-in': '既存のアカウントを使用してログインする', + 'user.register-result.msg': 'あなたのアカウント: {email} 登録が成功しました', + 'user.register-result.activation-email': 'アクティベーション電子メールはメールボックスに送信され、24 時間有効です。 すぐにメールにログインし、メール内のリンクをクリックしてアカウントをアクティブにしてください。', + 'user.register-result.back-home': 'ホームページに戻る', + 'user.register-result.view-mailbox': 'メールボックスを確認してください', + 'user.email.required': 'メールアドレスを入力してください!', + 'user.email.wrong-format': 'メールアドレスの形式が間違っています!', + 'user.userName.required': 'アカウント名またはメールアドレスを入力してください', + 'user.password.required': 'パスワードを入力してください。', + 'user.password.twice.msg': '2 回入力したパスワードが一致しません。', + 'user.password.strength.msg': 'パスワードの強度が十分ではありません', + 'user.password.strength.strong': '強さ: 強い', + 'user.password.strength.medium': '強さ: 中', + 'user.password.strength.low': '強度: 低い', + 'user.password.strength.short': '強さ:短すぎる', + 'user.confirm-password.required': 'パスワードを確認してください。', + 'user.phone-number.required': '正しい携帯電話番号を入力してください', + 'user.phone-number.wrong-format': '携帯電話番号の形式が間違っています。', + 'user.verification-code.required': '確認コードを入力してください。', + 'user.captcha.required': 'グラフィック認証コードを入力してください。', + 'user.login.infos': 'QuantDingerはAIマルチエージェント株分析補助ツールであり、証券投資コンサルティング資格はございません。 プラットフォーム内のすべての分析結果、スコア、参考意見は過去のデータに基づいてAIによって自動的に生成され、学習、研究、技術交流のみに使用され、投資アドバイスや意思決定の基礎を構成するものではありません。 株式投資には市場リスク、流動性リスク、政策リスク等の様々なリスクが伴い、元本を割り込む可能性があります。 ユーザーは自身のリスク許容度に基づいて独立した決定を行う必要があり、本ツールの使用から生じる投資行動および結果はユーザーが負担するものとします。 市場にはリスクがあり、投資は慎重になる必要があります。', + 'user.login.tab-login-web3': 'Web3ログイン', + 'user.login.web3.tip': 'ウォレットを使用した署名ログイン', + 'user.login.web3.connect': 'ウォレットに接続してログインする', + 'user.login.web3.no-wallet': 'ウォレットが検出されない', + 'user.login.web3.no-address': 'ウォレットアドレスが取得できませんでした', + 'user.login.web3.nonce-failed': '乱数の取得に失敗しました', + 'user.login.web3.verify-failed': '署名検証に失敗しました', + 'user.login.web3.success': 'ログイン成功', + 'user.login.web3.failed': 'ウォレットへのログインに失敗しました', + 'nav.no_wallet': 'ウォレットが検出されない', + 'nav.copy': 'コピー', + 'nav.copy_success': '正常にコピーされました', + 'user.login.oauth.google': 'Googleでサインイン', + 'user.login.oauth.github': 'GitHub でサインインする', + 'user.login.oauth.loading': '認証ページにリダイレクトしています...', + 'user.login.oauth.failed': 'OAuthログインに失敗しました', + 'user.login.oauth.get-url-failed': '認証リンクの取得に失敗しました', + 'user.login.subtitle': '世界市場に対する定量的な洞察を推進', + 'user.login.legal.title': '法的免責事項', + 'user.login.legal.view': '法的免責事項を表示', + 'user.login.legal.collapse': '法的免責事項を折りたたむ', + 'user.login.legal.agree': '法的免責事項を読み、同意します', + 'user.login.legal.required': 'まず法的免責事項を読んで確認してください', + 'user.login.legal.content': 'QuantDingerはAIマルチエージェント株分析補助ツールであり、証券投資コンサルティング資格はございません。 プラットフォーム内のすべての分析結果、評価、参考意見は過去のデータに基づいてAIによって自動的に生成され、学習、研究、技術交流のみに使用され、投資アドバイスや意思決定の基礎を構成するものではありません。 株式投資には市場リスク、流動性リスク、政策リスク等の様々なリスクが伴い、元本を割り込む可能性があります。 ユーザーは自身のリスク許容度に基づいて独立した決定を行う必要があり、本ツールの使用から生じる投資行動および結果はユーザーが負担するものとします。 市場にはリスクがあり、投資は慎重になる必要があります。', + 'user.login.privacy.title': 'ユーザープライバシーポリシー', + 'user.login.privacy.view': 'ユーザーのプライバシー ポリシーを表示する', + 'user.login.privacy.collapse': 'ユーザーのプライバシー規約を閉じる', + 'user.login.privacy.content': '私たちはあなたのプライバシーとデータ保護を大切にしています。 1) 収集範囲:機能の実現に必要な情報(メールアドレス、携帯電話番号、市外局番、Web3ウォレットアドレスなど)と必要なログ、端末情報のみを収集します。 2) 利用目的:アカウントへのログインおよびセキュリティ確認、サービス機能の提供、トラブルシューティングおよびコンプライアンス要件のため。 3) 保管とセキュリティ: データは暗号化されて保管され、不正なアクセス、開示、損失を防ぐために必要な許可とアクセス制御手段が講じられます。 4) 第三者との共有: 法令で義務付けられている場合、またはサービスの実行に必要な場合を除き、お客様の個人情報は第三者と共有されることはありません。サードパーティのサービス (ウォレット、SMS サービスプロバイダーなど) が関与する場合、機能の実装に必要な最小限の範囲でのみ処理されます。 5) Cookie/ローカル ストレージ: ログイン ステータスと必要なセッション維持 (トークン、PHPSESSID など) に使用され、ブラウザーでクリアまたは制限できます。 6) 個人の権利:法令に基づき、照会、訂正、削除、同意の撤回等を行う権利を行使することができます。 7) 変更と通知: これらの条件が更新されると、ページ上で目立つように表示されます。 このサービスを継続して使用することにより、更新された内容を読み、同意したものとみなされます。 本規約またはその更新に同意できない場合は、サービスの使用を中止し、当社までご連絡ください。', + 'account.basicInfo': '基本情報', + 'account.id': 'ユーザーID', + 'account.username': 'ユーザー名', + 'account.nickname': 'ニックネーム', + 'account.email': '電子メール', + 'account.mobile': '携帯電話番号', + 'account.web3address': 'ウォレットアドレス', + 'account.pid': '紹介者ID', + 'account.level': 'ユーザーレベル', + 'account.money': 'バランス', + 'account.qdtBalance': 'QDTバランス', + 'account.score': 'ポイント', + 'account.createtime': '登録時間', + 'account.inviteLink': '招待リンク', + 'account.recharge': 'リチャージ', + 'account.rechargeTip': 'WeChat または Alipay を使用して、以下の QR コードをスキャンしてチャージしてください。', + 'account.qrCodePlaceholder': 'QR コード プレースホルダー (エミュレーション)', + 'account.rechargeAmount': 'チャージ金額', + 'account.enterAmount': 'チャージ金額を入力してください', + 'account.rechargeHint': '最低入金額: 1 QDT', + 'account.confirmRecharge': 'リチャージを確認する', + 'account.enterValidAmount': '有効なリチャージ金額を入力してください', + 'account.rechargeSuccess': 'リチャージ成功! {金額} QDT を入金しました', + 'account.settings.menuMap.basic': '基本設定', + 'account.settings.menuMap.security': 'セキュリティ設定', + 'account.settings.menuMap.notification': '新着メッセージ通知', + 'account.settings.menuMap.moneyLog': 'ファンドの詳細', + 'account.moneyLog.empty': 'ファンドの詳細はまだありません', + 'account.moneyLog.total': '合計 {total} レコード', + 'account.moneyLog.type.purchase': '購入インジケーター', + 'account.moneyLog.type.recharge': 'リチャージ', + 'account.moneyLog.type.refund': '払い戻し', + 'account.moneyLog.type.reward': '報酬', + 'account.moneyLog.type.income': '指標収入', + 'account.moneyLog.type.commission': 'プラットフォーム手数料', + 'wallet.balance': 'QDTバランス', + 'wallet.recharge': 'リチャージ', + 'wallet.withdraw': '現金を引き出す', + 'wallet.filter': 'フィルター', + 'wallet.reset': 'リセット', + 'wallet.totalRecharge': '累積リチャージ', + 'wallet.totalWithdraw': '累積引き出し額', + 'wallet.totalIncome': '累計収入', + 'wallet.records': '取引記録と資金の詳細', + 'wallet.tradingRecords': '取引履歴', + 'wallet.moneyLog': 'ファンドの詳細', + 'wallet.rechargeTip': 'WeChat または Alipay を使用して、以下の QR コードをスキャンしてチャージしてください。', + 'wallet.qrCodePlaceholder': 'QR コード プレースホルダー (エミュレーション)', + 'wallet.rechargeAmount': 'チャージ金額', + 'wallet.enterAmount': 'チャージ金額を入力してください', + 'wallet.rechargeHint': '最低入金額: 1 QDT', + 'wallet.confirmRecharge': 'リチャージを確認する', + 'wallet.enterValidAmount': '有効なリチャージ金額を入力してください', + 'wallet.rechargeSuccess': 'リチャージ成功! {金額} QDT を入金しました', + 'wallet.rechargeFailed': '再充電に失敗しました', + 'wallet.withdrawTip': '出金金額と出金アドレスを入力してください', + 'wallet.withdrawAmount': '出金額', + 'wallet.enterWithdrawAmount': '出金額を入力してください', + 'wallet.withdrawHint': '最低出金額: 1 QDT', + 'wallet.withdrawAddress': '出金アドレス (オプション)', + 'wallet.enterWithdrawAddress': '出金アドレスを入力してください', + 'wallet.confirmWithdraw': '出金の確認', + 'wallet.enterValidWithdrawAmount': '有効な出金額を入力してください', + 'wallet.insufficientBalance': '残高不足', + 'wallet.withdrawSuccess': '引き出し成功! {金額} QDT を引き出しました', + 'wallet.withdrawFailed': '出金に失敗しました', + 'wallet.noTradingRecords': 'まだ取引記録がありません', + 'wallet.noMoneyLog': 'ファンドの詳細はまだありません', + 'wallet.loadTradingRecordsFailed': 'トランザクションレコードのロードに失敗しました', + 'wallet.loadMoneyLogFailed': 'ファンドの詳細をロードできませんでした', + 'wallet.moneyLogTotal': '合計 {total} レコード', + 'wallet.moneyLogTypeTitle': 'タイプ', + 'wallet.moneyLogType.all': '全種類', + 'wallet.table.time': '時間', + 'wallet.table.type': 'タイプ', + 'wallet.table.price': '価格', + 'wallet.table.amount': '数量', + 'wallet.table.money': '金額', + 'wallet.table.balance': 'バランス', + 'wallet.table.memo': '備考', + 'wallet.table.value': '値', + 'wallet.table.profit': '損益', + 'wallet.table.commission': '手数料', + 'wallet.table.total': '合計 {total} レコード', + 'wallet.tradeType.buy': '買う', + 'wallet.tradeType.sell': '売る', + 'wallet.tradeType.liquidation': '強制清算', + 'wallet.tradeType.openLong': '長く開く', + 'wallet.tradeType.addLong': 'ガドット', + 'wallet.tradeType.closeLong': 'ピンドゥオ', + 'wallet.tradeType.closeLongStop': 'ストップロスとロング', + 'wallet.tradeType.closeLongProfit': '利益を得てさらに平準化する', + 'wallet.tradeType.openShort': 'オープンショート', + 'wallet.tradeType.addShort': '短く追加する', + 'wallet.tradeType.closeShort': '空の', + 'wallet.tradeType.closeShortStop': 'ストップロス', + 'wallet.tradeType.closeShortProfit': '利益を確定して空売りを終了する', + 'wallet.moneyLogType.purchase': '購入インジケーター', + 'wallet.moneyLogType.recharge': 'リチャージ', + 'wallet.moneyLogType.withdraw': '現金を引き出す', + 'wallet.moneyLogType.refund': '払い戻し', + 'wallet.moneyLogType.reward': '報酬', + 'wallet.moneyLogType.income': '収入', + 'wallet.moneyLogType.commission': '手数料', + 'wallet.web3Address.required': '最初に Web3 ウォレットのアドレスを入力してください', + 'wallet.web3Address.requiredDescription': 'リチャージする前に、リチャージを受け取るために Web3 ウォレット アドレスをバインドする必要があります。', + 'wallet.web3Address.placeholder': 'Web3 ウォレットのアドレスを入力してください (0x で始まる)', + 'wallet.web3Address.save': 'ウォレットアドレスを保存する', + 'wallet.web3Address.saveSuccess': 'ウォレットアドレスが正常に保存されました', + 'wallet.web3Address.saveFailed': 'ウォレットアドレスの保存に失敗しました', + 'wallet.web3Address.invalidFormat': '有効な Web3 ウォレット アドレスを入力してください (イーサリアム形式: 0x で始まる 42 文字、または TRC20 形式: T で始まる 34 文字)', + 'wallet.selectCoin': '通貨を選択してください', + 'wallet.selectChain': '選択チェーン', + 'wallet.chain.eth': 'イーサリアム(ERC20)', + 'wallet.chain.trc20': 'TRC20', + 'wallet.chain.bsc': 'BSC', + 'wallet.targetQdtAmount': '引き換えたいQDTの量(オプション)', + 'wallet.targetQdtAmount.placeholder': '引き換えたい QDT の数量、現在の QDT 価格を入力してください: {price} USDT', + 'wallet.targetQdtAmount.requiredUsdt': 'リチャージが必要: {amount} USDT', + 'wallet.rechargeAddress': 'リチャージアドレス', + 'wallet.copyAddress': 'コピー', + 'wallet.copySuccess': 'コピー成功!', + 'wallet.copyFailed': 'コピーに失敗しました。手動でコピーしてください', + 'wallet.rechargeAddressHint': 'このアドレスに {coin} を入金するには、必ず {chain} を使用してください。', + 'wallet.qdtPrice.loading': '読み込み中...', + 'wallet.rechargeTip.new': '通貨とチェーンを選択し、下の QR コードをスキャンしてリチャージしてください', + 'wallet.exchangeRate': '1 QDT ≈ {レート} USDT', + 'wallet.withdrawableAmount': '利用可能な現金の量', + 'wallet.totalBalance': '合計残高', + 'wallet.insufficientWithdrawable': '引き出すことができる現金の量が不足しています。現在出金できる金額は次のとおりです: {amount} QDT', + 'wallet.withdrawAddressRequired': '出金アドレスを入力してください', + 'wallet.withdrawAddressHint': 'アドレスが正しいことを確認してください。退会後は取り消すことはできません。', + 'wallet.withdrawSubmitSuccess': '出金申請が正常に送信されました。審査をお待ちください', + 'menu.footer.contactUs': 'お問い合わせ', + 'menu.footer.getSupport': 'サポートを受ける', + 'menu.footer.socialAccounts': 'ソーシャルアカウント', + 'menu.footer.userAgreement': 'ユーザー同意書', + 'menu.footer.privacyPolicy': 'プライバシーポリシー', + 'menu.footer.support': 'サポート', + 'menu.footer.featureRequest': '機能リクエスト', + 'menu.footer.email': '電子メール', + 'menu.footer.liveChat': '24時間年中無休のライブチャット', + 'dashboard.analysis.title': '多次元分析', + 'dashboard.analysis.subtitle': 'AIを活用した総合財務分析プラットフォーム', + 'dashboard.analysis.selectSymbol': '基礎となるコードを選択または入力します', + 'dashboard.analysis.selectModel': 'モデルを選択してください', + 'dashboard.analysis.startAnalysis': '分析を開始する', + 'dashboard.analysis.history': '歴史', + 'dashboard.analysis.tab.overview': '総合的な分析', + 'dashboard.analysis.tab.fundamental': '基本', + 'dashboard.analysis.tab.technical': 'テクノロジー', + 'dashboard.analysis.tab.news': 'ニュース', + 'dashboard.analysis.tab.sentiment': '感情', + 'dashboard.analysis.tab.risk': 'リスク', + 'dashboard.analysis.tab.debate': '長短の議論', + 'dashboard.analysis.tab.decision': '最終決定', + 'dashboard.analysis.empty.selectSymbol': '分析を開始するターゲットを選択してください', + 'dashboard.analysis.empty.selectSymbolDesc': '独自に選択した銘柄リストから選択するか、基礎となるコードを入力して多次元 AI 分析レポートを取得します', + 'dashboard.analysis.empty.startAnalysis': '「分析開始」ボタンをクリックすると、多次元分析が実行されます。', + 'dashboard.analysis.empty.startAnalysisDesc': 'ファンダメンタルズ、テクノロジー、ニュース、センチメント、リスクなどの多次元から総合的な分析を提供します。', + 'dashboard.analysis.empty.noData': '現在、{type} の分析データはありません。最初に包括的な分析を行ってください。', + 'dashboard.analysis.empty.noWatchlist': '現在、自主選定銘柄はありません', + 'dashboard.analysis.empty.noHistory': 'まだ履歴がありません', + 'dashboard.analysis.empty.watchlistHint': '現在、独自に選択した銘柄はありません。まずご確認ください。', + 'dashboard.analysis.empty.noDebateData': '議論のデータはまだありません', + 'dashboard.analysis.empty.noDecisionData': '決定データはまだありません', + 'dashboard.analysis.empty.selectAgent': '分析結果を表示するにはエージェントを選択してください', + 'dashboard.analysis.loading.analyzing': '分析中です、お待ちください...', + 'dashboard.analysis.loading.fundamental': '基本データを分析中...', + 'dashboard.analysis.loading.technical': 'テクニカル指標を分析中...', + 'dashboard.analysis.loading.news': 'ニュースデータを分析中...', + 'dashboard.analysis.loading.sentiment': '市場センチメントを分析中...', + 'dashboard.analysis.loading.risk': 'リスクを評価中...', + 'dashboard.analysis.loading.debate': '長短の議論は続いています...', + 'dashboard.analysis.loading.decision': '最終決定を生成中...', + 'dashboard.analysis.score.overall': '総合評価', + 'dashboard.analysis.score.recommendation': '投資アドバイス', + 'dashboard.analysis.score.confidence': '自信', + 'dashboard.analysis.dimension.fundamental': '基本', + 'dashboard.analysis.dimension.technical': 'テクノロジー', + 'dashboard.analysis.dimension.news': 'ニュース', + 'dashboard.analysis.dimension.sentiment': '感情', + 'dashboard.analysis.dimension.risk': 'リスク', + 'dashboard.analysis.card.dimensionScores': '各次元の評価', + 'dashboard.analysis.card.overviewReport': '総合分析レポート', + 'dashboard.analysis.card.financialMetrics': '財務指標', + 'dashboard.analysis.card.fundamentalReport': 'ファンダメンタルズ分析レポート', + 'dashboard.analysis.card.technicalIndicators': 'テクニカル指標', + 'dashboard.analysis.card.technicalReport': 'テクニカル分析レポート', + 'dashboard.analysis.card.newsList': '関連ニュース', + 'dashboard.analysis.card.newsReport': 'ニュース分析レポート', + 'dashboard.analysis.card.sentimentIndicators': '感情指標', + 'dashboard.analysis.card.sentimentReport': 'センチメント分析レポート', + 'dashboard.analysis.card.riskMetrics': 'リスク指標', + 'dashboard.analysis.card.riskReport': 'リスク評価レポート', + 'dashboard.analysis.card.bullView': '強気の見方(強気)', + 'dashboard.analysis.card.bearView': '弱気の見方(ベア)', + 'dashboard.analysis.card.researchConclusion': '研究者の結論', + 'dashboard.analysis.card.traderPlan': 'トレーダープラン', + 'dashboard.analysis.card.riskDebate': 'リスク委員会の議論', + 'dashboard.analysis.card.finalDecision': '最終決定', + 'dashboard.analysis.card.tradePlanDetail': '取引プランの詳細', + 'dashboard.analysis.tradingPlan.entry_price': '入場料', + 'dashboard.analysis.tradingPlan.position_size': 'ポジションサイズ', + 'dashboard.analysis.tradingPlan.stop_loss': 'ストップロス', + 'dashboard.analysis.tradingPlan.take_profit': '利益確定', + 'dashboard.analysis.label.confidence': '自信', + 'dashboard.analysis.label.keyPoints': 'コアポイント', + 'dashboard.analysis.label.riskWarning': 'リスク警告', + 'dashboard.analysis.risk.risky': '危険な', + 'dashboard.analysis.risk.neutral': '中立的な視点(ニュートラル)', + 'dashboard.analysis.risk.safe': '保守的な見解(安全)', + 'dashboard.analysis.risk.conclusion': '結論', + 'dashboard.analysis.feature.fundamental': 'ファンダメンタルズ分析', + 'dashboard.analysis.feature.technical': 'テクニカル分析', + 'dashboard.analysis.feature.news': 'ニュース分析', + 'dashboard.analysis.feature.sentiment': '感情分析', + 'dashboard.analysis.feature.risk': 'リスク評価', + 'dashboard.analysis.watchlist.title': '私の銘柄選択', + 'dashboard.analysis.watchlist.add': '追加する', + 'dashboard.analysis.watchlist.addStock': '在庫を追加する', + 'dashboard.analysis.modal.addStock.title': 'オプションのストックを追加する', + 'dashboard.analysis.modal.addStock.confirm': 'OK', + 'dashboard.analysis.modal.addStock.cancel': 'キャンセル', + 'dashboard.analysis.modal.addStock.market': '市場タイプ', + 'dashboard.analysis.modal.addStock.marketPlaceholder': 'マーケットを選択してください', + 'dashboard.analysis.modal.addStock.marketRequired': 'マーケットタイプを選択してください', + 'dashboard.analysis.modal.addStock.symbol': '証券コード', + 'dashboard.analysis.modal.addStock.symbolPlaceholder': '例: AAPL、TSLA、GOOGL、000001、BTC', + 'dashboard.analysis.modal.addStock.symbolRequired': '証券コードを入力してください', + 'dashboard.analysis.modal.addStock.searchPlaceholder': '検索対象のコードまたは名前', + 'dashboard.analysis.modal.addStock.searchOrInputPlaceholder': '基礎となるコードを検索または入力します (例: AAPL、BTC/USDT、EUR/USD)', + 'dashboard.analysis.modal.addStock.searchOrInputHint': 'データベース内のオブジェクトの検索、またはコードの直接入力をサポート (システムが自動的に名前を取得します)', + 'dashboard.analysis.modal.addStock.search': '検索', + 'dashboard.analysis.modal.addStock.searchResults': '検索結果', + 'dashboard.analysis.modal.addStock.hotSymbols': '人気のターゲット', + 'dashboard.analysis.modal.addStock.noHotSymbols': '人気のあるターゲットはまだありません', + 'dashboard.analysis.modal.addStock.selectedSymbol': '選択済み', + 'dashboard.analysis.modal.addStock.pleaseSelectSymbol': '最初にターゲットを選択してください', + 'dashboard.analysis.modal.addStock.pleaseSelectOrEnterSymbol': 'ターゲットを選択するか、最初にターゲット コードを入力してください', + 'dashboard.analysis.modal.addStock.pleaseEnterSymbol': 'ターゲットコードを入力してください', + 'dashboard.analysis.modal.addStock.pleaseSelectMarket': '最初にマーケットタイプを選択してください', + 'dashboard.analysis.modal.addStock.searchFailed': '検索に失敗しました。後でもう一度お試しください', + 'dashboard.analysis.modal.addStock.noSearchResults': '一致するターゲットが見つかりません', + 'dashboard.analysis.modal.addStock.willAutoFetchName': 'システムが自動的に名前を取得します', + 'dashboard.analysis.modal.addStock.addDirectly': '直接追加', + 'dashboard.analysis.modal.addStock.nameWillBeFetched': '名前は追加時に自動的に取得されます', + 'dashboard.analysis.market.AShare': 'A株', + 'dashboard.analysis.market.USStock': '米国株', + 'dashboard.analysis.market.HShare': '香港株', + 'dashboard.analysis.market.Crypto': '暗号通貨', + 'dashboard.analysis.market.Forex': '外国為替', + 'dashboard.analysis.market.Futures': '先物', + 'dashboard.analysis.modal.history.title': '過去の分析記録', + 'dashboard.analysis.modal.history.viewResult': '結果を見る', + 'dashboard.analysis.modal.history.completeTime': '完了時間', + 'dashboard.analysis.modal.history.error': 'エラー', + 'dashboard.analysis.status.pending': '保留中', + 'dashboard.analysis.status.processing': '処理中', + 'dashboard.analysis.status.completed': '完了', + 'dashboard.analysis.status.failed': '失敗しました', + 'dashboard.analysis.message.selectSymbol': '最初にターゲットを選択してください', + 'dashboard.analysis.message.taskCreated': '分析タスクが作成され、バックグラウンドで実行されています...', + 'dashboard.analysis.message.analysisComplete': '分析完了', + 'dashboard.analysis.message.analysisCompleteCache': '分析完了(キャッシュされたデータを使用)', + 'dashboard.analysis.message.analysisFailed': '分析に失敗しました。後でもう一度お試しください。', + 'dashboard.analysis.message.addStockSuccess': '正常に追加されました', + 'dashboard.analysis.message.addStockFailed': '追加に失敗しました', + 'dashboard.analysis.message.removeStockSuccess': '正常に削除されました', + 'dashboard.analysis.message.removeStockFailed': '削除に失敗しました', + 'dashboard.analysis.test': 'ショップ番号 {no}、公庄路', + 'dashboard.analysis.introduce': 'インジケーターの説明', + 'dashboard.analysis.total-sales': '総売上高', + 'dashboard.analysis.day-sales': '平均日販¥', + 'dashboard.analysis.visits': '訪問', + 'dashboard.analysis.visits-trend': 'トラフィックの傾向', + 'dashboard.analysis.visits-ranking': '来店ランキング', + 'dashboard.analysis.day-visits': '毎日の訪問', + 'dashboard.analysis.week': '毎週前年比', + 'dashboard.analysis.day': '前年比', + 'dashboard.analysis.payments': '支払い回数', + 'dashboard.analysis.conversion-rate': 'コンバージョン率', + 'dashboard.analysis.operational-effect': '運営活動への影響', + 'dashboard.analysis.sales-trend': '販売動向', + 'dashboard.analysis.sales-ranking': '店舗売上ランキング', + 'dashboard.analysis.all-year': '一年中', + 'dashboard.analysis.all-month': '今月', + 'dashboard.analysis.all-week': '今週', + 'dashboard.analysis.all-day': '今日', + 'dashboard.analysis.search-users': '検索ユーザー数', + 'dashboard.analysis.per-capita-search': '一人当たりの検索数', + 'dashboard.analysis.online-top-search': 'オンラインで人気の検索', + 'dashboard.analysis.the-proportion-of-sales': '売上カテゴリー比率', + 'dashboard.analysis.dropdown-option-one': '作戦1', + 'dashboard.analysis.dropdown-option-two': '操作 2', + 'dashboard.analysis.channel.all': 'すべてのチャンネル', + 'dashboard.analysis.channel.online': 'オンライン', + 'dashboard.analysis.channel.stores': '店', + 'dashboard.analysis.sales': '販売', + 'dashboard.analysis.traffic': '乗客の流れ', + 'dashboard.analysis.table.rank': 'ランキング', + 'dashboard.analysis.table.search-keyword': '検索キーワード', + 'dashboard.analysis.table.users': 'ユーザー数', + 'dashboard.analysis.table.weekly-range': '毎週の増加', + 'dashboard.indicator.selectSymbol': '基礎となるコードを選択または入力します', + 'dashboard.indicator.emptyWatchlistHint': '現在、独自に選択した銘柄はありません。最初に追加してください。', + 'dashboard.indicator.hint.selectSymbol': '分析を開始するにはターゲットを選択してください', + 'dashboard.indicator.hint.selectSymbolDesc': 'K ライン チャートとテクニカル指標を表示するには、上の検索ボックスに銘柄コードを選択または入力します。', + 'dashboard.indicator.retry': 'もう一度試してください', + 'dashboard.indicator.panel.title': 'テクニカル指標', + 'dashboard.indicator.panel.realtimeOn': 'リアルタイム更新をオフにする', + 'dashboard.indicator.panel.realtimeOff': 'リアルタイム更新を有効にする', + 'dashboard.indicator.panel.themeLight': 'ダークテーマに切り替える', + 'dashboard.indicator.panel.themeDark': 'ライトテーマに切り替える', + 'dashboard.indicator.section.enabled': '有効', + 'dashboard.indicator.section.added': '私が追加したインジケーター', + 'dashboard.indicator.section.custom': '自分で作成したインジケーター', + 'dashboard.indicator.section.bought': '購入したインジケーター', + 'dashboard.indicator.section.myCreated': '私が作成したインジケーター', + 'dashboard.indicator.empty': 'まだインジケーターがありません。最初にインジケーターを追加または作成してください', + 'dashboard.indicator.buy': '購入インジケーター', + 'dashboard.indicator.action.start': '始める', + 'dashboard.indicator.action.stop': '閉じる', + 'dashboard.indicator.action.edit': '編集', + 'dashboard.indicator.action.delete': '削除', + 'dashboard.indicator.action.backtest': 'バックテスト', + 'dashboard.indicator.status.normal': '普通の', + 'dashboard.indicator.status.normalPermanent': '通常(永続的に有効)', + 'dashboard.indicator.status.expired': '期限切れ', + 'dashboard.indicator.expiry.permanent': '永久に有効', + 'dashboard.indicator.expiry.noExpiry': '有効期限なし', + 'dashboard.indicator.expiry.expired': '期限切れ: {日付}', + 'dashboard.indicator.expiry.expiresOn': '有効期限: {日付}', + 'dashboard.indicator.delete.confirmTitle': '削除の確認', + 'dashboard.indicator.delete.confirmContent': 'インジケーター「{name}」を削除してもよろしいですか?この操作は元に戻すことができません。', + 'dashboard.indicator.delete.confirmOk': '削除', + 'dashboard.indicator.delete.confirmCancel': 'キャンセル', + 'dashboard.indicator.delete.success': '正常に削除されました', + 'dashboard.indicator.delete.failed': '削除に失敗しました', + 'dashboard.indicator.save.success': '正常に保存されました', + 'dashboard.indicator.save.failed': '保存に失敗しました', + 'dashboard.indicator.error.loadWatchlistFailed': 'オプションのストックをロードできませんでした', + 'dashboard.indicator.error.chartNotReady': 'チャートコンポーネントは初期化されていません。最初にターゲットを選択し、チャートがロードされるまで待ってください。', + 'dashboard.indicator.error.chartMethodNotReady': 'グラフ コンポーネント メソッドの準備ができていません。後でもう一度お試しください。', + 'dashboard.indicator.error.chartExecuteNotReady': 'チャートコンポーネントの実行メソッドの準備ができていません。後でもう一度試してください。', + 'dashboard.indicator.error.parseFailed': 'Pythonコードを解析できません', + 'dashboard.indicator.error.parseFailedCheck': 'Python コードを解析できません。コード形式を確認してください。', + 'dashboard.indicator.error.addIndicatorFailed': 'インジケーターの追加に失敗しました', + 'dashboard.indicator.error.runIndicatorFailed': 'インジケーターの実行に失敗しました', + 'dashboard.indicator.error.pleaseLogin': 'まずログインしてください', + 'dashboard.indicator.error.loadDataFailed': 'データのロードに失敗しました', + 'dashboard.indicator.error.loadDataFailedDesc': 'ネットワーク接続を確認してください', + 'dashboard.indicator.error.pythonEngineFailed': 'Python エンジンのロードに失敗したため、インジケーター機能が使用できない可能性があります。', + 'dashboard.indicator.error.chartInitFailed': 'チャートの初期化に失敗しました', + 'dashboard.indicator.warning.enterCode': '最初にインジケーターコードを入力してください', + 'dashboard.indicator.warning.pyodideLoadFailed': 'Python エンジンのロードに失敗しました', + 'dashboard.indicator.warning.pyodideLoadFailedDesc': 'この機能は、現在の地域またはネットワーク環境では利用できません', + 'dashboard.indicator.warning.chartNotInitialized': 'グラフが初期化されていないため、線描画ツールが使用できません。', + 'dashboard.indicator.success.runIndicator': 'インジケーターは正常に実行されます', + 'dashboard.indicator.success.clearDrawings': '線画をすべてクリアしました', + 'dashboard.indicator.sma': 'SMA(移動平均結合)', + 'dashboard.indicator.ema': 'EMA (指数移動平均結合)', + 'dashboard.indicator.rsi': 'RSI (相対強度)', + 'dashboard.indicator.macd': 'MACD', + 'dashboard.indicator.bb': 'ボリンジャーバンド', + 'dashboard.indicator.atr': 'ATR (平均トゥルーレンジ)', + 'dashboard.indicator.cci': 'CCI (商品チャネル指数)', + 'dashboard.indicator.williams': 'ウィリアムズ %R (ウィリアムズ指標)', + 'dashboard.indicator.mfi': 'MFI(マネーフローインデックス)', + 'dashboard.indicator.adx': 'ADX(平均トレンド指数)', + 'dashboard.indicator.obv': 'OBV(エネルギー波)', + 'dashboard.indicator.adosc': 'ADOSC (アキュムレート/ディスパッチオシレーター)', + 'dashboard.indicator.ad': 'AD(蓄電・配電線)', + 'dashboard.indicator.kdj': 'KDJ (確率的指標)', + 'dashboard.indicator.signal.buy': '買う', + 'dashboard.indicator.signal.sell': '売る', + 'dashboard.indicator.signal.supertrendBuy': 'スーパートレンド購入', + 'dashboard.indicator.signal.supertrendSell': 'スーパートレンドセル', + 'dashboard.indicator.chart.kline': 'Kライン', + 'dashboard.indicator.chart.volume': 'ボリューム', + 'dashboard.indicator.chart.uptrend': '上昇傾向', + 'dashboard.indicator.chart.downtrend': '下降傾向', + 'dashboard.indicator.tooltip.time': '時間', + 'dashboard.indicator.tooltip.open': '開く', + 'dashboard.indicator.tooltip.close': '受け取る', + 'dashboard.indicator.tooltip.high': '高い', + 'dashboard.indicator.tooltip.low': '低い', + 'dashboard.indicator.tooltip.volume': 'ボリューム', + 'dashboard.indicator.drawing.line': '線分', + 'dashboard.indicator.drawing.horizontalLine': '水平線', + 'dashboard.indicator.drawing.verticalLine': '縦線', + 'dashboard.indicator.drawing.ray': '光線', + 'dashboard.indicator.drawing.straightLine': '直線', + 'dashboard.indicator.drawing.parallelLine': '平行線', + 'dashboard.indicator.drawing.priceLine': '価格ライン', + 'dashboard.indicator.drawing.priceChannel': '価格チャネル', + 'dashboard.indicator.drawing.fibonacciLine': 'フィボナッチライン', + 'dashboard.indicator.drawing.clearAll': '線画をすべてクリアする', + 'dashboard.indicator.market.AShare': 'A株', + 'dashboard.indicator.market.USStock': '米国株', + 'dashboard.indicator.market.HShare': '香港株', + 'dashboard.indicator.market.Crypto': '暗号通貨', + 'dashboard.indicator.market.Forex': '外国為替', + 'dashboard.indicator.market.Futures': '先物', + 'dashboard.indicator.create': 'インジケーターの作成', + 'dashboard.indicator.editor.title': 'インジケーターの作成/編集', + 'dashboard.indicator.editor.name': 'インジケーター名', + 'dashboard.indicator.editor.nameRequired': 'インジケーター名を入力してください', + 'dashboard.indicator.editor.namePlaceholder': 'インジケーター名を入力してください', + 'dashboard.indicator.editor.description': 'インジケーターの説明', + 'dashboard.indicator.editor.descriptionPlaceholder': 'インジケーターの説明を入力してください (オプション)', + 'dashboard.indicator.editor.code': 'Pythonコード', + 'dashboard.indicator.editor.codeRequired': 'インジケーターコードを入力してください', + 'dashboard.indicator.editor.codePlaceholder': 'Python コードを入力してください', + 'dashboard.indicator.editor.run': '走る', + 'dashboard.indicator.editor.runHint': '実行ボタンをクリックして、K ライン チャート上のインジケーターの効果をプレビューします。', + 'dashboard.indicator.editor.guide': '開発ガイド', + 'dashboard.indicator.editor.guideTitle': 'Python インジケーター開発ガイド', + 'dashboard.indicator.editor.save': '保存する', + 'dashboard.indicator.editor.cancel': 'キャンセル', + 'dashboard.indicator.editor.unnamed': '名前のないインジケーター', + 'dashboard.indicator.editor.publishToCommunity': 'コミュニティに投稿する', + 'dashboard.indicator.editor.publishToCommunityHint': '公開されると、他のユーザーがコミュニティでインジケーターを表示して使用できるようになります', + 'dashboard.indicator.editor.indicatorType': 'インジケーターの種類', + 'dashboard.indicator.editor.indicatorTypeRequired': 'インジケーターのタイプを選択してください', + 'dashboard.indicator.editor.indicatorTypePlaceholder': 'インジケーターのタイプを選択してください', + 'dashboard.indicator.editor.previewImage': 'プレビュー', + 'dashboard.indicator.editor.uploadImage': '写真をアップロードする', + 'dashboard.indicator.editor.previewImageHint': '推奨サイズ: 800x400、2MB 以下', + 'dashboard.indicator.editor.pricing': '販売価格(QDT)', + 'dashboard.indicator.editor.pricingType.free': '無料', + 'dashboard.indicator.editor.pricingType.permanent': '永久的な', + 'dashboard.indicator.editor.pricingType.monthly': '月々の支払い', + 'dashboard.indicator.editor.price': '価格', + 'dashboard.indicator.editor.priceRequired': '価格を入力してください', + 'dashboard.indicator.editor.pricePlaceholder': '価格を入力してください', + 'dashboard.indicator.editor.pricingHint': '無料のインジケーターの価格は 0 ですが、プラットフォームはインジケーターの使用量と賞賛率に基づいて報酬 (QDT) を与えます。', + 'dashboard.indicator.editor.aiGenerate': 'インテリジェントな世代', + 'dashboard.indicator.editor.aiPromptPlaceholder': 'あなたのアイデアを教えてください。Python インジケーター コードを生成します。', + 'dashboard.indicator.editor.aiGenerateBtn': 'AIが生成したコード', + 'dashboard.indicator.editor.aiPromptRequired': 'あなたの考えを入力してください', + 'dashboard.indicator.editor.aiGenerateSuccess': 'コード生成が成功しました', + 'dashboard.indicator.editor.aiGenerateError': 'コード生成に失敗しました。後でもう一度お試しください。', + 'dashboard.indicator.backtest.title': 'インジケーターのバックテスト', + 'dashboard.indicator.backtest.config': 'バックテストパラメータ', + 'dashboard.indicator.backtest.startDate': '開始日', + 'dashboard.indicator.backtest.endDate': '終了日', + 'dashboard.indicator.backtest.selectStartDate': '開始日を選択してください', + 'dashboard.indicator.backtest.selectEndDate': '終了日を選択してください', + 'dashboard.indicator.backtest.startDateRequired': '開始日を選択してください', + 'dashboard.indicator.backtest.endDateRequired': '終了日を選択してください', + 'dashboard.indicator.backtest.initialCapital': '初期資本', + 'dashboard.indicator.backtest.initialCapitalRequired': '初期資金を入力してください', + 'dashboard.indicator.backtest.commission': '手数料', + 'dashboard.indicator.backtest.leverage': 'レバレッジ比率', + 'dashboard.indicator.backtest.tradeDirection': '取引の方向性', + 'dashboard.indicator.backtest.longOnly': '長く続けてください', + 'dashboard.indicator.backtest.shortOnly': '短い', + 'dashboard.indicator.backtest.both': '双方向', + 'dashboard.indicator.backtest.run': 'バックテストを開始する', + 'dashboard.indicator.backtest.rerun': 'もう一度バックテスト', + 'dashboard.indicator.backtest.close': '閉じる', + 'dashboard.indicator.backtest.running': 'インジケーターのバックテストが進行中です...', + 'dashboard.indicator.backtest.results': 'バックテスト結果', + 'dashboard.indicator.backtest.totalReturn': 'トータルリターン', + 'dashboard.indicator.backtest.annualReturn': '年収', + 'dashboard.indicator.backtest.maxDrawdown': '最大ドローダウン', + 'dashboard.indicator.backtest.sharpeRatio': 'シャープレシオ', + 'dashboard.indicator.backtest.winRate': '勝率', + 'dashboard.indicator.backtest.profitFactor': '損益率', + 'dashboard.indicator.backtest.totalTrades': 'トランザクション数', + 'dashboard.indicator.totalReturn': 'トータルリターン', + 'dashboard.indicator.annualReturn': '年換算収益率', + 'dashboard.indicator.maxDrawdown': '最大ドローダウン', + 'dashboard.indicator.sharpeRatio': 'シャープレシオ', + 'dashboard.indicator.winRate': '勝率', + 'dashboard.indicator.profitFactor': '損益率', + 'dashboard.indicator.totalTrades': '総トランザクション数', + 'dashboard.indicator.backtest.totalCommission': '合計手数料', + 'dashboard.indicator.backtest.equityCurve': 'イールドカーブ', + 'dashboard.indicator.backtest.strategy': '指標収入', + 'dashboard.indicator.backtest.benchmark': 'ベンチマークリターン', + 'dashboard.indicator.backtest.tradeHistory': '取引履歴', + 'dashboard.indicator.backtest.tradeTime': '時間', + 'dashboard.indicator.backtest.tradeType': 'タイプ', + 'dashboard.indicator.backtest.buy': '買う', + 'dashboard.indicator.backtest.sell': '売る', + 'dashboard.indicator.backtest.liquidation': '清算', + 'dashboard.indicator.backtest.openLong': '長く開く', + 'dashboard.indicator.backtest.closeLong': 'ピンドゥオ', + 'dashboard.indicator.backtest.closeLongStop': 'ロングに近い(ストップロス)', + 'dashboard.indicator.backtest.closeLongProfit': 'さらに閉じる(利益確定)', + 'dashboard.indicator.backtest.addLong': 'ロングポジションを追加', + 'dashboard.indicator.backtest.openShort': 'オープンショート', + 'dashboard.indicator.backtest.closeShort': '空の', + 'dashboard.indicator.backtest.closeShortStop': 'クローズ(ストップロス)', + 'dashboard.indicator.backtest.closeShortProfit': 'クローズ(利益確定)', + 'dashboard.indicator.backtest.addShort': 'ショートポジションを追加', + 'dashboard.indicator.backtest.price': '価格', + 'dashboard.indicator.backtest.amount': '数量', + 'dashboard.indicator.backtest.balance': '口座残高', + 'dashboard.indicator.backtest.profit': '損益', + 'dashboard.indicator.backtest.success': 'バックテスト完了', + 'dashboard.indicator.backtest.failed': 'バックテストに失敗しました。後でもう一度お試しください', + 'dashboard.indicator.backtest.noIndicatorCode': 'このインジケーターにはバックテスト可能なコードはありません', + 'dashboard.indicator.backtest.noSymbol': '最初に取引対象を選択してください', + 'dashboard.indicator.backtest.dateRangeExceeded': 'バックテストの時間範囲が制限を超えています: {timeframe} 期間は最大でも {maxRange} までバックテストできます', + 'dashboard.indicator.backtest.dateRangeExceededDays': 'Backtest range exceeds limit: {timeframe} max {maxRange} ({maxDays} days)', + 'dashboard.indicator.backtest.metaLine': 'Symbol: {symbol} | Market: {market} | Timeframe: {timeframe}', + 'dashboard.indicator.backtest.savedRunId': 'Backtest saved. Run ID: {id}', + 'dashboard.indicator.backtest.prev': 'Previous', + 'dashboard.indicator.backtest.next': 'Next', + 'dashboard.indicator.backtest.steps.strategy.title': 'Strategy', + 'dashboard.indicator.backtest.steps.strategy.desc': 'Position sizing & risk controls', + 'dashboard.indicator.backtest.steps.trading.title': 'Trading settings', + 'dashboard.indicator.backtest.steps.trading.desc': 'Time range, capital, fees, leverage', + 'dashboard.indicator.backtest.steps.results.title': 'Results', + 'dashboard.indicator.backtest.steps.results.desc': 'Backtest output', + 'dashboard.indicator.backtest.panel.risk': 'Risk management (SL/TP/Trailing)', + 'dashboard.indicator.backtest.panel.scale': 'Scale in / DCA (trend & mean-reversion)', + 'dashboard.indicator.backtest.panel.reduce': 'Reduce position (trend & adverse)', + 'dashboard.indicator.backtest.panel.position': 'Position sizing', + 'dashboard.indicator.backtest.field.stopLossPct': 'Stop Loss (%)', + 'dashboard.indicator.backtest.field.takeProfitPct': 'Take Profit (%)', + 'dashboard.indicator.backtest.field.trailingEnabled': 'Trailing stop / take profit', + 'dashboard.indicator.backtest.field.trailingStopPct': 'Trailing drawdown (%)', + 'dashboard.indicator.backtest.field.trailingActivationPct': 'Trailing activation (%)', + 'dashboard.indicator.backtest.field.slippage': 'Slippage (%)', + 'dashboard.indicator.backtest.field.trendAddEnabled': 'Trend-following scale-in', + 'dashboard.indicator.backtest.field.dcaAddEnabled': 'Mean-reversion DCA', + 'dashboard.indicator.backtest.field.trendAddStepPct': 'Scale-in trigger (%)', + 'dashboard.indicator.backtest.field.dcaAddStepPct': 'DCA trigger (%)', + 'dashboard.indicator.backtest.field.trendAddSizePct': 'Scale-in size (% of capital)', + 'dashboard.indicator.backtest.field.dcaAddSizePct': 'DCA size (% of capital)', + 'dashboard.indicator.backtest.field.trendAddMaxTimes': 'Max scale-in times', + 'dashboard.indicator.backtest.field.dcaAddMaxTimes': 'Max DCA times', + 'dashboard.indicator.backtest.field.trendReduceEnabled': 'Trend reduce', + 'dashboard.indicator.backtest.field.adverseReduceEnabled': 'Adverse reduce', + 'dashboard.indicator.backtest.field.trendReduceStepPct': 'Trend trigger (%)', + 'dashboard.indicator.backtest.field.adverseReduceStepPct': 'Adverse trigger (%)', + 'dashboard.indicator.backtest.field.trendReduceSizePct': 'Reduce size (% of position)', + 'dashboard.indicator.backtest.field.adverseReduceSizePct': 'Adverse reduce size (% of position)', + 'dashboard.indicator.backtest.field.trendReduceMaxTimes': 'Max trend reduce times', + 'dashboard.indicator.backtest.field.adverseReduceMaxTimes': 'Max adverse reduce times', + 'dashboard.indicator.backtest.field.entryPct': 'Entry size (% of capital)', + 'dashboard.indicator.backtest.field.minOrderPct': 'Min order size (% of capital) (optional)', + 'dashboard.indicator.backtest.closeLongTrailing': 'Close Long (Trailing)', + 'dashboard.indicator.backtest.reduceLong': 'Reduce Long', + 'dashboard.indicator.backtest.closeShortTrailing': 'Close Short (Trailing)', + 'dashboard.indicator.backtest.reduceShort': 'Reduce Short', + 'dashboard.docs.title': 'ドキュメントセンター', + 'dashboard.docs.search.placeholder': 'ドキュメントを検索...', + 'dashboard.docs.category.all': 'すべて', + 'dashboard.docs.category.guide': '開発ガイド', + 'dashboard.docs.category.api': 'APIドキュメント', + 'dashboard.docs.category.tutorial': 'チュートリアル', + 'dashboard.docs.category.faq': 'よくある質問', + 'dashboard.docs.featured.title': '推奨ドキュメント', + 'dashboard.docs.featured.tag': 'おすすめ', + 'dashboard.docs.list.views': '閲覧する', + 'dashboard.docs.list.author': '著者', + 'dashboard.docs.list.empty': 'まだ文書がありません', + 'dashboard.docs.list.backToAll': 'すべてのドキュメントに戻る', + 'dashboard.docs.list.total': '合計 {count} 個のドキュメント', + 'dashboard.docs.detail.back': 'ドキュメントリストに戻る', + 'dashboard.docs.detail.updatedAt': 'に更新されました', + 'dashboard.docs.detail.related': '関連資料', + 'dashboard.docs.detail.notFound': 'ドキュメントが存在しません', + 'dashboard.docs.detail.error': 'ドキュメントが存在しないか削除されています', + 'dashboard.docs.search.result': '検索結果', + 'dashboard.docs.search.keyword': 'キーワード', + 'community.filter.indicatorType': 'インジケーターの種類', + 'community.filter.all': 'すべて', + 'community.filter.other': 'その他のオプション', + 'community.filter.pricing': '価格タイプ', + 'community.filter.allPricing': 'すべての価格設定', + 'community.filter.sortBy': '並べ替え順', + 'community.filter.search': '検索', + 'community.filter.searchPlaceholder': '検索メトリクス名', + 'community.indicatorType.trend': 'トレンドタイプ', + 'community.indicatorType.momentum': '勢いタイプ', + 'community.indicatorType.volatility': 'ボラティリティ', + 'community.indicatorType.volume': 'ボリューム', + 'community.indicatorType.custom': 'カスタマイズ', + 'community.pricing.free': '無料', + 'community.pricing.paid': '支払う', + 'community.sort.downloads': 'ダウンロード', + 'community.sort.rating': '評価', + 'community.sort.newest': '最新リリース', + 'community.pagination.total': '合計 {total} 個のインジケーター', + 'community.noDescription': 'まだ説明がありません', + 'community.detail.type': 'タイプ', + 'community.detail.pricing': '価格設定', + 'community.detail.rating': '評価', + 'community.detail.downloads': 'ダウンロード', + 'community.detail.author': '著者', + 'community.detail.description': 'はじめに', + 'community.detail.detailContent': '詳細な説明', + 'community.detail.backtestStats': 'バックテスト統計', + 'community.action.purchase': '購入インジケーター', + 'community.action.addToMyIndicators': '私のインジケーターに追加', + 'community.action.favorite': 'コレクション', + 'community.action.unfavorite': 'お気に入りをキャンセルする', + 'community.action.buyNow': '今すぐ購入', + 'community.action.renew': 'リニューアル', + 'community.purchase.price': '価格', + 'community.purchase.permanent': '永久的な', + 'community.purchase.monthly': '月', + 'community.purchase.confirmBuy': 'このインジケーター ({price} QDT) を購入することを確認しますか?', + 'community.purchase.confirmRenew': 'このインジケーター ({price} QDT/月) を更新することを確認しますか?', + 'community.purchase.confirmFree': 'この無料インジケーターを追加することを確認しますか?', + 'community.purchase.confirmTitle': 'アクションの確認', + 'community.purchase.owned': 'このインジケーターを購入しました (永久に有効です)', + 'community.purchase.ownIndicator': 'これはあなたが公開した指標です', + 'community.purchase.expired': 'サブスクリプションの有効期限が切れました', + 'community.purchase.expiresOn': '有効期限: {日付}', + 'community.tabs.detail': 'インジケーターの詳細', + 'community.tabs.backtest': 'バックテストデータ', + 'community.tabs.ratings': 'ユーザーレビュー', + 'community.rating.myRating': '私の評価', + 'community.rating.stars': '{count} 個の星', + 'community.rating.commentPlaceholder': 'あなたの経験を共有してください...', + 'community.rating.submit': '評価を送信する', + 'community.rating.modify': '変更', + 'community.rating.saveModify': '変更を保存する', + 'community.rating.cancel': 'キャンセル', + 'community.rating.selectRating': '評価を選択してください', + 'community.rating.success': '評価が成功しました', + 'community.rating.modifySuccess': 'レビューが正常に変更されました', + 'community.rating.failed': '評価に失敗しました', + 'community.rating.noRatings': 'まだコメントはありません', + 'community.backtest.note': '上記のデータは過去のバックテスト結果であり、参考のみを目的としており、将来の収益を示すものではありません。', + 'community.backtest.noData': 'バックテストデータはまだありません', + 'community.backtest.uploadHint': '作者はまだバックテストデータをアップロードしていません', + 'community.message.loadFailed': 'インジケーターリストのロードに失敗しました', + 'community.message.purchaseProcessing': '購入リクエストを処理しています...', + 'community.message.downloadSuccess': 'メトリックがメトリック リストに追加されました', + 'community.message.favoriteSuccess': '収集に成功しました', + 'community.message.unfavoriteSuccess': '収集を正常にキャンセルしました', + 'community.message.operationSuccess': '操作は成功しました', + 'community.message.operationFailed': '操作が失敗しました', + 'dashboard.totalEquity': '総資本', + 'dashboard.totalPnL': '損益通算', + 'dashboard.aiStrategies': 'AI戦略', + 'dashboard.indicatorStrategies': '指標戦略', + 'dashboard.running': 'ランニング', + 'dashboard.pnlHistory': '過去の損益', + 'dashboard.strategyPerformance': '戦略損益率', + 'dashboard.recentTrades': '最近の取引', + 'dashboard.currentPositions': '現在位置', + 'dashboard.table.time': '時間', + 'dashboard.table.strategy': 'ポリシー名', + 'dashboard.table.symbol': 'ターゲット', + 'dashboard.table.type': 'タイプ', + 'dashboard.table.side': '方向', + 'dashboard.table.size': '建玉', + 'dashboard.table.entryPrice': '平均始値', + 'dashboard.table.price': '価格', + 'dashboard.table.amount': '数量', + 'dashboard.table.profit': '損益', + 'dashboard.pendingOrders': '注文実行記録', + 'dashboard.totalOrders': '合計 {total} 件', + 'dashboard.viewError': 'エラーを表示', + 'dashboard.filled': '約定済み', + 'dashboard.orderTable.time': '作成時間', + 'dashboard.orderTable.strategy': '戦略', + 'dashboard.orderTable.symbol': '取引ペア', + 'dashboard.orderTable.signalType': 'シグナルタイプ', + 'dashboard.orderTable.amount': '数量', + 'dashboard.orderTable.price': '約定価格', + 'dashboard.orderTable.status': 'ステータス', + 'dashboard.orderTable.executedAt': '実行時間', + 'dashboard.signalType.openLong': 'ロング開始', + 'dashboard.signalType.openShort': 'ショート開始', + 'dashboard.signalType.closeLong': 'ロング決済', + 'dashboard.signalType.closeShort': 'ショート決済', + 'dashboard.signalType.addLong': 'ロング追加', + 'dashboard.signalType.addShort': 'ショート追加', + 'dashboard.status.pending': '保留中', + 'dashboard.status.processing': '処理中', + 'dashboard.status.completed': '完了', + 'dashboard.status.failed': '失敗', + 'dashboard.status.cancelled': 'キャンセル済み', + 'dashboard.table.pnl': '未実現損益', + 'form.basic-form.basic.title': '基本形', + 'form.basic-form.basic.description': 'フォーム ページは、ユーザーからの情報を収集または確認するために使用されます。 基本フォームは、データ項目が少ないフォーム シナリオでよく使用されます。', + 'form.basic-form.title.label': 'タイトル', + 'form.basic-form.title.placeholder': '目標に名前を付けます', + 'form.basic-form.title.required': 'タイトルを入力してください', + 'form.basic-form.date.label': '開始日と終了日', + 'form.basic-form.placeholder.start': '開始日', + 'form.basic-form.placeholder.end': '終了日', + 'form.basic-form.date.required': '開始日と終了日を選択してください', + 'form.basic-form.goal.label': '目標の説明', + 'form.basic-form.goal.placeholder': '段階的な作業目標を入力してください', + 'form.basic-form.goal.required': '目標の説明を入力してください', + 'form.basic-form.standard.label': '測る', + 'form.basic-form.standard.placeholder': '指標を入力してください', + 'form.basic-form.standard.required': '指標を入力してください', + 'form.basic-form.client.label': 'お客様', + 'form.basic-form.client.required': 'あなたがサービスを提供しているクライアントについて説明してください', + 'form.basic-form.label.tooltip': '対象サービス受信者', + 'form.basic-form.client.placeholder': 'あなたがサービスを提供している顧客、内部顧客について直接説明してください @名前/役職番号', + 'form.basic-form.invites.label': '査読者を招待する', + 'form.basic-form.invites.placeholder': '@名前/従業員番号を直接送信してください。最大 5 人まで招待できます', + 'form.basic-form.weight.label': '重量', + 'form.basic-form.weight.placeholder': '入力してください', + 'form.basic-form.public.label': 'ターゲットパブリック', + 'form.basic-form.label.help': '顧客とレビュー担当者はデフォルトで共有されます', + 'form.basic-form.radio.public': '公共の', + 'form.basic-form.radio.partially-public': '部分的に公開', + 'form.basic-form.radio.private': 'プライベート', + 'form.basic-form.publicUsers.placeholder': 'にオープン', + 'form.basic-form.option.A': '同僚1', + 'form.basic-form.option.B': '同僚2', + 'form.basic-form.option.C': '同僚3人', + 'form.basic-form.email.required': 'メールアドレスを入力してください!', + 'form.basic-form.email.wrong-format': 'メールアドレスの形式が間違っています!', + 'form.basic-form.userName.required': 'ユーザー名を入力してください!', + 'form.basic-form.password.required': 'パスワードを入力してください。', + 'form.basic-form.password.twice': '2 回入力したパスワードが一致しません。', + 'form.basic-form.strength.msg': '6文字以上を入力してください。 推測されやすいパスワードは使用しないでください。', + 'form.basic-form.strength.strong': '強さ: 強い', + 'form.basic-form.strength.medium': '強さ: 中', + 'form.basic-form.strength.short': '強さ:短すぎる', + 'form.basic-form.confirm-password.required': 'パスワードを確認してください。', + 'form.basic-form.phone-number.required': '携帯電話番号を入力してください!', + 'form.basic-form.phone-number.wrong-format': '携帯電話番号の形式が間違っています。', + 'form.basic-form.verification-code.required': '確認コードを入力してください。', + 'form.basic-form.form.get-captcha': '確認コードを取得する', + 'form.basic-form.captcha.second': '秒', + 'form.basic-form.form.optional': '(オプション)', + 'form.basic-form.form.submit': '送信する', + 'form.basic-form.form.save': '保存する', + 'form.basic-form.email.placeholder': '電子メール', + 'form.basic-form.password.placeholder': 'パスワードは 6 文字以上、大文字と小文字は区別されます', + 'form.basic-form.confirm-password.placeholder': 'パスワードの確認', + 'form.basic-form.phone-number.placeholder': '携帯電話番号', + 'form.basic-form.verification-code.placeholder': '認証コード', + 'result.success.title': '送信成功', + 'result.success.description': '投入結果ページは、一連の運用タスクの処理結果をフィードバックするために使用されます。単純な操作のみの場合は、メッセージ グローバル プロンプト フィードバックを使用します。このテキストエリアには簡単な補足説明を表示できます。 「ドキュメント」を表示する必要がある場合は、下の灰色の領域にさらに複雑なコンテンツを表示できます。', + 'result.success.operate-title': 'プロジェクト名', + 'result.success.operate-id': 'プロジェクトID', + 'result.success.principal': '担当者', + 'result.success.operate-time': '効果時間', + 'result.success.step1-title': 'プロジェクトの作成', + 'result.success.step1-operator': 'ク・リリ', + 'result.success.step2-title': '部門の事前審査', + 'result.success.step2-operator': '周猫猫', + 'result.success.step2-extra': '緊急', + 'result.success.step3-title': '財務レビュー', + 'result.success.step4-title': '完了', + 'result.success.btn-return': 'リストに戻る', + 'result.success.btn-project': 'アイテムを見る', + 'result.success.btn-print': '印刷する', + 'result.fail.error.title': '送信に失敗しました', + 'result.fail.error.description': '再送信する前に、次の情報を確認して変更してください。', + 'result.fail.error.hint-title': '送信したコンテンツには次のエラーが含まれています。', + 'result.fail.error.hint-text1': 'あなたのアカウントは凍結されました', + 'result.fail.error.hint-btn1': 'すぐに解凍してください', + 'result.fail.error.hint-text2': 'あなたのアカウントはまだ申請する資格がありません', + 'result.fail.error.hint-btn2': '今すぐアップグレードしてください', + 'result.fail.error.btn-text': '修正に戻る', + 'account.settings.menuMap.custom': 'パーソナライゼーション', + 'account.settings.menuMap.binding': 'アカウントバインディング', + 'account.settings.basic.avatar': 'アバター', + 'account.settings.basic.change-avatar': 'アバターの変更', + 'account.settings.basic.email': '電子メール', + 'account.settings.basic.email-message': 'メールアドレスを入力してください。', + 'account.settings.basic.nickname': 'ニックネーム', + 'account.settings.basic.nickname-message': 'ニックネームを入力してください!', + 'account.settings.basic.profile': 'プロフィール', + 'account.settings.basic.profile-message': 'あなたの個人プロフィールを入力してください!', + 'account.settings.basic.profile-placeholder': 'プロフィール', + 'account.settings.basic.country': '国/地域', + 'account.settings.basic.country-message': 'あなたの国または地域を入力してください。', + 'account.settings.basic.geographic': '県と市', + 'account.settings.basic.geographic-message': 'あなたの都道府県と市区町村を入力してください。', + 'account.settings.basic.address': '住所', + 'account.settings.basic.address-message': '住所を入力してください。', + 'account.settings.basic.phone': '連絡先番号', + 'account.settings.basic.phone-message': '連絡先番号を入力してください。', + 'account.settings.basic.update': '基本情報を更新', + 'account.settings.basic.update.success': '基本情報が正常に更新されました', + 'account.settings.security.strong': '強い', + 'account.settings.security.medium': 'で', + 'account.settings.security.weak': '弱い', + 'account.settings.security.password': 'アカウントのパスワード', + 'account.settings.security.password-description': '現在のパスワードの強度:', + 'account.settings.security.phone': 'セキュリティ携帯電話', + 'account.settings.security.phone-description': 'すでにバインドされている携帯電話:', + 'account.settings.security.question': 'セキュリティの問題', + 'account.settings.security.question-description': '秘密の質問は設定されていないため、アカウントのセキュリティを効果的に保護できます。', + 'account.settings.security.email': '電子メールをバインドする', + 'account.settings.security.email-description': 'すでにバインドされている電子メール アドレス:', + 'account.settings.security.mfa': 'MFAデバイス', + 'account.settings.security.mfa-description': 'MFA デバイスはバインドされていません。バインド後、再度確認できます。', + 'account.settings.security.modify': '変更', + 'account.settings.security.set': '設定', + 'account.settings.security.bind': 'バインディング', + 'account.settings.binding.taobao': 'タオバオをバインドする', + 'account.settings.binding.taobao-description': 'タオバオアカウントは現在バインドされていません', + 'account.settings.binding.alipay': 'Alipayをバインドする', + 'account.settings.binding.alipay-description': 'Alipay アカウントは現在バインドされていません', + 'account.settings.binding.dingding': 'バインディングディントーク', + 'account.settings.binding.dingding-description': '現在バインドされている DingTalk アカウントはありません', + 'account.settings.binding.bind': 'バインディング', + 'account.settings.notification.password': 'アカウントのパスワード', + 'account.settings.notification.password-description': '他のユーザーからのメッセージは、サイトメッセージの形式で通知されます。', + 'account.settings.notification.messages': 'システムメッセージ', + 'account.settings.notification.messages-description': 'システム メッセージはサイト メッセージの形式で通知されます。', + 'account.settings.notification.todo': 'ToDoタスク', + 'account.settings.notification.todo-description': 'To Do タスクはサイト内メッセージの形式で通知されます', + 'account.settings.settings.open': '開く', + 'account.settings.settings.close': '閉じる', + 'trading-assistant.title': '取引アシスタント', + 'trading-assistant.strategyList': '戦略一覧', + 'trading-assistant.createStrategy': 'ポリシーの作成', + 'trading-assistant.noStrategy': 'まだ戦略はありません', + 'trading-assistant.selectStrategy': '詳細を表示するには、左側から戦略を選択してください', + 'trading-assistant.startStrategy': '戦略を立ち上げる', + 'trading-assistant.stopStrategy': '戦略を停止する', + 'trading-assistant.editStrategy': '編集戦略', + 'trading-assistant.deleteStrategy': 'ポリシーの削除', + 'trading-assistant.status.running': 'ランニング', + 'trading-assistant.status.stopped': '停止しました', + 'trading-assistant.status.error': 'エラー', + 'trading-assistant.strategyType.IndicatorStrategy': 'テクニカル指標戦略', + 'trading-assistant.strategyType.PromptBasedStrategy': '合言葉戦略', + 'trading-assistant.strategyType.GridStrategy': 'グリッド戦略', + 'trading-assistant.tabs.tradingRecords': '取引履歴', + 'trading-assistant.tabs.positions': '位置記録', + 'trading-assistant.tabs.equityCurve': '資本曲線', + 'trading-assistant.form.step1': 'インジケーターの選択', + 'trading-assistant.form.step2': 'Exchangeの構成', + 'trading-assistant.form.step3': '戦略パラメータ', + 'trading-assistant.form.indicator': 'インジケーターの選択', + 'trading-assistant.form.indicatorHint': '購入または作成したテクニカル指標のみを選択できます', + 'trading-assistant.form.qdtCostHints': '戦略を使用すると QDT が消費されます。アカウントに十分な QDT 残高があることを確認してください。', + 'trading-assistant.form.indicatorDescription': 'インジケーターの説明', + 'trading-assistant.form.noDescription': 'まだ説明がありません', + 'trading-assistant.form.exchange': '交換を選択', + 'trading-assistant.form.apiKey': 'APIキー', + 'trading-assistant.form.secretKey': '秘密鍵', + 'trading-assistant.form.passphrase': 'パスフレーズ', + 'trading-assistant.form.testConnection': 'テスト接続', + 'trading-assistant.form.strategyName': 'ポリシー名', + 'trading-assistant.form.symbol': '取引ペア', + 'trading-assistant.form.symbolHint': '現在、暗号通貨取引ペアのみがサポートされています', + 'trading-assistant.form.initialCapital': '投資額', + 'trading-assistant.form.marketType': '市場タイプ', + 'trading-assistant.form.marketTypeFutures': '契約', + 'trading-assistant.form.marketTypeSpot': 'スポット', + 'trading-assistant.form.marketTypeHint': 'この契約は双方向の取引とレバレッジをサポートしますが、スポットはロングポジションのみをサポートし、レバレッジは 1 倍に固定されます。', + 'trading-assistant.form.leverage': '複数を活用する', + 'trading-assistant.form.leverageHint': '契約:1~125回、スポット:固定1回', + 'trading-assistant.form.spotLeverageFixed': '現物取引のレバレッジは1倍に固定されます', + 'trading-assistant.form.spotOnlyLongHint': 'スポット取引はロングポジションのみをサポートします', + 'trading-assistant.form.tradeDirection': '取引の方向性', + 'trading-assistant.form.tradeDirectionLong': 'ただ長いだけ', + 'trading-assistant.form.tradeDirectionShort': '短いだけ', + 'trading-assistant.form.tradeDirectionBoth': '双方向取引', + 'trading-assistant.form.timeframe': '期間', + 'trading-assistant.form.klinePeriod': 'K線期間', + 'trading-assistant.form.timeframe1m': '1分', + 'trading-assistant.form.timeframe5m': '5分', + 'trading-assistant.form.timeframe15m': '15分', + 'trading-assistant.form.timeframe30m': '30分', + 'trading-assistant.form.timeframe1H': '1時間', + 'trading-assistant.form.timeframe4H': '4時間', + 'trading-assistant.form.timeframe1D': '1日', + 'trading-assistant.form.selectStrategyType': '戦略タイプを選択', + 'trading-assistant.form.indicatorStrategy': 'インジケーター戦略', + 'trading-assistant.form.indicatorStrategyDesc': 'テクニカルインジケーターに基づく自動取引戦略', + 'trading-assistant.form.aiStrategy': 'AI戦略', + 'trading-assistant.form.aiStrategyDesc': 'AIインテリジェント意思決定に基づく自動取引戦略', + 'trading-assistant.form.enableAiFilter': 'AIインテリジェント意思決定フィルターを有効化', + 'trading-assistant.form.enableAiFilterHint': '有効にすると、インジケーターシグナルがAIによってフィルタリングされ、取引品質が向上します', + 'trading-assistant.form.aiFilterPrompt': 'カスタムプロンプト', + 'trading-assistant.form.aiFilterPromptHint': 'AIフィルタリングにカスタム指示を提供し、空白のままにするとシステムデフォルトを使用します', + 'trading-assistant.validation.strategyTypeRequired': '戦略タイプを選択してください', + 'trading-assistant.form.advancedSettings': '詳細設定', + 'trading-assistant.form.orderMode': 'オーダーモード', + 'trading-assistant.form.orderModeMaker': 'メーカー', + 'trading-assistant.form.orderModeTaker': '市場価格(テイカー)', + 'trading-assistant.form.orderModeHint': '未決注文モードでは指値注文が使用され、手数料が安くなります。市場価格モードはすぐに実行され、手数料が高くなります。', + 'trading-assistant.form.makerWaitSec': '未決注文の待ち時間 (秒)', + 'trading-assistant.form.makerWaitSecHint': '注文後の取引を待つ時間。キャンセルして、タイムアウト後に再試行してください。', + 'trading-assistant.form.makerRetries': '未決注文の再試行回数', + 'trading-assistant.form.makerRetriesHint': '未決注文が約定しない場合の最大リトライ回数', + 'trading-assistant.form.fallbackToMarket': '未決注文は失敗し、市場価格は引き下げられます。', + 'trading-assistant.form.fallbackToMarketHint': '開始/終了未決注文が完了していない場合、トランザクションを確実に完了させるために成行注文にダウングレードする必要があるかどうか。', + 'trading-assistant.form.marginMode': 'マージンモード', + 'trading-assistant.form.marginModeCross': '倉庫がいっぱい', + 'trading-assistant.form.marginModeIsolated': '孤立した位置', + 'trading-assistant.form.stopLossPct': 'ストップロス率(%)', + 'trading-assistant.form.stopLossPctHint': 'ストップロスのパーセンテージを設定します。0 はストップロスが有効になっていないことを意味します', + 'trading-assistant.form.takeProfitPct': 'テイクプロフィット率(%)', + 'trading-assistant.form.takeProfitPctHint': 'テイクプロフィットのパーセンテージを設定します。0 はテイクプロフィットを無効にすることを意味します', + 'trading-assistant.form.signalMode': '信号モード', + 'trading-assistant.form.signalModeConfirmed': '確認モード', + 'trading-assistant.form.signalModeAggressive': 'アグレッシブモード', + 'trading-assistant.form.signalModeHint': '確認モード: 完了した K 行のみをチェックします。アグレッシブ モード: K ラインの形成もチェックします', + 'trading-assistant.form.cancel': 'キャンセル', + 'trading-assistant.form.prev': '前のステップ', + 'trading-assistant.form.next': '次のステップ', + 'trading-assistant.form.confirmCreate': '作成の確認', + 'trading-assistant.form.confirmEdit': '変更を確認する', + 'trading-assistant.messages.createSuccess': '戦略が正常に作成されました', + 'trading-assistant.messages.createFailed': 'ポリシーの作成に失敗しました', + 'trading-assistant.messages.updateSuccess': 'ポリシーの更新が成功しました', + 'trading-assistant.messages.updateFailed': 'ポリシーの更新に失敗しました', + 'trading-assistant.messages.deleteSuccess': 'ポリシーの削除が成功しました', + 'trading-assistant.messages.deleteFailed': 'ポリシーの削除に失敗しました', + 'trading-assistant.messages.startSuccess': '戦略は無事に開始されました', + 'trading-assistant.messages.startFailed': 'ポリシーの開始に失敗しました', + 'trading-assistant.messages.stopSuccess': '戦略は正常に停止されました', + 'trading-assistant.messages.stopFailed': '停止戦略は失敗しました', + 'trading-assistant.messages.loadFailed': 'ポリシーリストの取得に失敗しました', + 'trading-assistant.messages.runningWarning': '戦略は実行されています。 戦略を変更する前に戦略を停止してください。', + 'trading-assistant.messages.deleteConfirmWithName': 'ポリシー「{name}」を削除してもよろしいですか?この操作は元に戻すことができません。', + 'trading-assistant.messages.deleteConfirm': 'このポリシーを削除してもよろしいですか?この操作は元に戻すことができません。', + 'trading-assistant.messages.loadTradesFailed': '取引記録の取得に失敗しました', + 'trading-assistant.messages.loadPositionsFailed': '位置レコードの取得に失敗しました', + 'trading-assistant.messages.loadEquityFailed': '資本曲線の取得に失敗しました', + 'trading-assistant.messages.loadIndicatorsFailed': 'インジケーター リストの読み込みに失敗しました。後でもう一度お試しください。', + 'trading-assistant.messages.spotLimitations': 'スポット取引は自動的にロングのみ、レバレッジ1倍に設定されています', + 'trading-assistant.messages.autoFillApiConfig': '取引所の過去の API 設定が自動的に入力されます', + 'trading-assistant.placeholders.selectIndicator': 'インジケーターを選択してください', + 'trading-assistant.placeholders.selectExchange': '交換を選択してください', + 'trading-assistant.placeholders.inputApiKey': 'APIキーを入力してください', + 'trading-assistant.placeholders.inputSecretKey': '秘密キーを入力してください', + 'trading-assistant.placeholders.inputPassphrase': 'パスフレーズを入力してください', + 'trading-assistant.placeholders.inputStrategyName': 'ポリシー名を入力してください', + 'trading-assistant.placeholders.selectSymbol': '取引ペアを選択してください', + 'trading-assistant.placeholders.selectTimeframe': '期間を選択してください', + 'trading-assistant.placeholders.selectKlinePeriod': 'K線期間を選択してください', + 'trading-assistant.placeholders.inputAiFilterPrompt': 'カスタムプロンプトを入力してください(オプション)', + 'trading-assistant.validation.indicatorRequired': 'インジケーターを選択してください', + 'trading-assistant.validation.exchangeRequired': '交換を選択してください', + 'trading-assistant.validation.apiKeyRequired': 'APIキーを入力してください', + 'trading-assistant.validation.secretKeyRequired': '秘密キーを入力してください', + 'trading-assistant.validation.passphraseRequired': 'パスフレーズを入力してください', + 'trading-assistant.validation.exchangeConfigIncomplete': '完全な交換構成情報を入力してください', + 'trading-assistant.validation.testConnectionRequired': 'まず「テスト接続」ボタンをクリックし、接続が成功することを確認してください', + 'trading-assistant.validation.testConnectionFailed': '接続テストに失敗しました。設定を確認して再度テストしてください', + 'trading-assistant.validation.strategyNameRequired': 'ポリシー名を入力してください', + 'trading-assistant.validation.symbolRequired': '取引ペアを選択してください', + 'trading-assistant.validation.initialCapitalRequired': '投資額を入力してください', + 'trading-assistant.validation.leverageRequired': 'レバレッジ比率を入力してください', + 'trading-assistant.table.time': '時間', + 'trading-assistant.table.type': 'タイプ', + 'trading-assistant.table.price': '価格', + 'trading-assistant.table.amount': '数量', + 'trading-assistant.table.value': '金額', + 'trading-assistant.table.commission': '手数料', + 'trading-assistant.table.symbol': '取引ペア', + 'trading-assistant.table.side': '方向', + 'trading-assistant.table.size': 'ポジション数量', + 'trading-assistant.table.entryPrice': 'オープン価格', + 'trading-assistant.table.currentPrice': '現在の価格', + 'trading-assistant.table.unrealizedPnl': '未実現損益', + 'trading-assistant.table.pnlPercent': '損益率', + 'trading-assistant.table.buy': '買う', + 'trading-assistant.table.sell': '売る', + 'trading-assistant.table.long': '長く続けてください', + 'trading-assistant.table.short': '短い', + 'trading-assistant.table.noPositions': 'まだポジションがありません', + 'trading-assistant.detail.title': '戦略の詳細', + 'trading-assistant.detail.strategyName': 'ポリシー名', + 'trading-assistant.detail.strategyType': '戦略タイプ', + 'trading-assistant.detail.status': 'ステータス', + 'trading-assistant.detail.tradingMode': '取引モデル', + 'trading-assistant.detail.exchange': '交換', + 'trading-assistant.detail.initialCapital': '初期資本', + 'trading-assistant.detail.totalInvestment': '投資総額', + 'trading-assistant.detail.currentEquity': '現在の純資産', + 'trading-assistant.detail.totalPnl': '損益通算', + 'trading-assistant.detail.indicatorName': 'インジケーター名', + 'trading-assistant.detail.maxLeverage': '最大レバレッジ', + 'trading-assistant.detail.decideInterval': '決定間隔', + 'trading-assistant.detail.symbols': 'トランザクションオブジェクト', + 'trading-assistant.detail.createdAt': '作成時間', + 'trading-assistant.detail.updatedAt': '更新時間', + 'trading-assistant.detail.llmConfig': 'AIモデル構成', + 'trading-assistant.detail.exchangeConfig': 'Exchangeの構成', + 'trading-assistant.detail.provider': 'プロバイダー', + 'trading-assistant.detail.modelId': 'モデルID', + 'trading-assistant.detail.close': '閉じる', + 'trading-assistant.detail.loadFailed': 'ポリシーの詳細を取得できませんでした', + 'trading-assistant.equity.noData': '純資産データはまだありません', + 'trading-assistant.equity.equity': '純資産', + 'trading-assistant.exchange.tradingMode': '取引モデル', + 'trading-assistant.exchange.virtual': '模擬取引', + 'trading-assistant.exchange.live': '実際のトランザクション', + 'trading-assistant.exchange.selectExchange': '交換を選択', + 'trading-assistant.exchange.walletAddress': 'ウォレットアドレス', + 'trading-assistant.exchange.walletAddressPlaceholder': 'ウォレットアドレスを入力してください(0xで始まる)', + 'trading-assistant.exchange.privateKey': '秘密鍵', + 'trading-assistant.exchange.privateKeyPlaceholder': '秘密キーを入力してください(64文字)', + 'trading-assistant.exchange.testConnection': 'テスト接続', + 'trading-assistant.exchange.connectionSuccess': '接続成功', + 'trading-assistant.exchange.connectionFailed': '接続に失敗しました', + 'trading-assistant.exchange.testFailed': '接続テストに失敗しました', + 'trading-assistant.exchange.fillComplete': '完全な交換構成情報を入力してください', + 'trading-assistant.strategyTypeOptions.ai': 'AI主導の戦略', + 'trading-assistant.strategyTypeOptions.indicator': 'テクニカル指標戦略', + 'trading-assistant.strategyTypeOptions.aiDeveloping': 'AIを活用した戦略機能は開発中ですのでご期待ください', + 'trading-assistant.strategyTypeOptions.aiDevelopingWarning': 'AI主導の戦略機能は開発中です', + 'trading-assistant.indicatorType.trend': 'トレンド', + 'trading-assistant.indicatorType.momentum': '勢い', + 'trading-assistant.indicatorType.volatility': '変動', + 'trading-assistant.indicatorType.volume': 'ボリューム', + 'trading-assistant.indicatorType.custom': 'カスタマイズ', + 'trading-assistant.exchangeNames': { + 'okx': 'OKX', + 'binance': 'バイナンス', + 'hyperliquid': '超流動性', + 'blockchaincom': 'Blockchain.com', + 'coinbaseexchange': 'コインベース', + 'gate': 'Gate.io', + 'mexc': 'メキシコ', + 'kraken': 'クラーケン', + 'bitfinex': 'ビットフィネックス', + 'bybit': 'バイビット', + 'kucoin': 'クーコイン', + 'huobi': 'フォビ', + 'bitget': 'ビゲット', + 'bitmex': 'ビットメックス', + 'deribit': 'デリビット', + 'phemex': 'フェメックス', + 'bitmart': 'ビットマート', + 'bitstamp': 'ビットスタンプ', + 'bittrex': 'ビットレックス', + 'poloniex': 'ポロニエックス', + 'gemini': 'ジェミニ', + 'cryptocom': 'Crypto.com', + 'bitflyer': 'ビットフライヤー', + 'upbit': 'アップビット', + 'bithumb': 'ビサム', + 'coinone': 'コイノン', + 'zb': 'ZB', + 'lbank': 'Lバンク', + 'bibox': 'ビボックス', + 'bigone': 'ビッグワン', + 'bitrue': 'ビトゥルー', + 'coinex': 'CoinEx', + 'digifinex': 'デジフィネックス', + 'ftx': 'FTX', + 'ftxus': 'FTX米国', + 'binanceus': 'バイナンスUS', + 'binancecoinm': 'バイナンス COIN-M', + 'binanceusdm': 'バイナンスUSDⓈ-M' + }, + 'ai-trading-assistant.title': 'AI取引アシスタント', + 'ai-trading-assistant.strategyList': '戦略一覧', + 'ai-trading-assistant.createStrategy': 'ポリシーの作成', + 'ai-trading-assistant.noStrategy': 'まだ戦略はありません', + 'ai-trading-assistant.selectStrategy': '詳細を表示するには、左側から戦略を選択してください', + 'ai-trading-assistant.startStrategy': '戦略を立ち上げる', + 'ai-trading-assistant.stopStrategy': '戦略を停止する', + 'ai-trading-assistant.editStrategy': '編集戦略', + 'ai-trading-assistant.deleteStrategy': 'ポリシーの削除', + 'ai-trading-assistant.status.running': 'ランニング', + 'ai-trading-assistant.status.stopped': '停止しました', + 'ai-trading-assistant.status.error': 'エラー', + 'ai-trading-assistant.tabs.tradingRecords': '取引履歴', + 'ai-trading-assistant.tabs.positions': '位置記録', + 'ai-trading-assistant.tabs.aiDecisions': 'AI判定記録', + 'ai-trading-assistant.tabs.equityCurve': '資本曲線', + 'ai-trading-assistant.form.createTitle': 'AI取引戦略を作成する', + 'ai-trading-assistant.form.editTitle': 'AI取引戦略を編集する', + 'ai-trading-assistant.form.strategyName': 'ポリシー名', + 'ai-trading-assistant.form.modelId': 'AIモデル', + 'ai-trading-assistant.form.modelIdHint': 'API キーを設定せずにシステム OpenRouter サービスを使用する', + 'ai-trading-assistant.form.decideInterval': '決定間隔', + 'ai-trading-assistant.form.decideInterval5m': '5分', + 'ai-trading-assistant.form.decideInterval10m': '10分', + 'ai-trading-assistant.form.decideInterval30m': '30分', + 'ai-trading-assistant.form.decideInterval1h': '1時間', + 'ai-trading-assistant.form.decideInterval4h': '4時間', + 'ai-trading-assistant.form.decideInterval1d': '1日', + 'ai-trading-assistant.form.decideInterval1w': '1週間', + 'ai-trading-assistant.form.decideIntervalHint': 'AIが意思決定を行うまでの時間間隔', + 'ai-trading-assistant.form.runPeriod': '実行サイクル', + 'ai-trading-assistant.form.runPeriodHint': '戦略実行の開始時刻と終了時刻', + 'ai-trading-assistant.form.startDate': '開始日', + 'ai-trading-assistant.form.endDate': '終了日', + 'ai-trading-assistant.form.qdtCostTitle': 'QDT 控除の指示', + 'ai-trading-assistant.form.qdtCostHint': 'AI の決定ごとに {cost} QDT が差し引かれます。アカウントに十分な QDT 残高があることを確認してください。戦略の実行中、決定ごとに料金がリアルタイムで差し引かれます。', + 'ai-trading-assistant.form.apiKey': 'APIキー', + 'ai-trading-assistant.form.exchange': '交換を選択', + 'ai-trading-assistant.form.secretKey': '秘密鍵', + 'ai-trading-assistant.form.passphrase': 'パスフレーズ', + 'ai-trading-assistant.form.testConnection': 'テスト接続', + 'ai-trading-assistant.form.symbol': '取引ペア', + 'ai-trading-assistant.form.symbolHint': '取引する取引ペアを選択してください', + 'ai-trading-assistant.form.initialCapital': '投資額(証拠金)', + 'ai-trading-assistant.form.leverage': '複数を活用する', + 'ai-trading-assistant.form.timeframe': '期間', + 'ai-trading-assistant.form.timeframe1m': '1分', + 'ai-trading-assistant.form.timeframe5m': '5分', + 'ai-trading-assistant.form.timeframe15m': '15分', + 'ai-trading-assistant.form.timeframe30m': '30分', + 'ai-trading-assistant.form.timeframe1H': '1時間', + 'ai-trading-assistant.form.timeframe4H': '4時間', + 'ai-trading-assistant.form.timeframe1D': '1日', + 'ai-trading-assistant.form.marketType': '市場タイプ', + 'ai-trading-assistant.form.marketTypeFutures': '契約', + 'ai-trading-assistant.form.marketTypeSpot': 'スポット', + 'ai-trading-assistant.form.totalPnl': '損益通算', + 'ai-trading-assistant.form.customPrompt': 'カスタムのプロンプト単語', + 'ai-trading-assistant.form.customPromptHint': 'オプション。 AI 取引戦略と意思決定ロジックをカスタマイズするために使用されます。', + 'ai-trading-assistant.form.cancel': 'キャンセル', + 'ai-trading-assistant.form.prev': '前のステップ', + 'ai-trading-assistant.form.next': '次のステップ', + 'ai-trading-assistant.form.confirmCreate': '作成の確認', + 'ai-trading-assistant.form.confirmEdit': '変更を確認する', + 'ai-trading-assistant.messages.createSuccess': '戦略が正常に作成されました', + 'ai-trading-assistant.messages.createFailed': 'ポリシーの作成に失敗しました', + 'ai-trading-assistant.messages.updateSuccess': 'ポリシーの更新が成功しました', + 'ai-trading-assistant.messages.updateFailed': 'ポリシーの更新に失敗しました', + 'ai-trading-assistant.messages.deleteSuccess': 'ポリシーの削除が成功しました', + 'ai-trading-assistant.messages.deleteFailed': 'ポリシーの削除に失敗しました', + 'ai-trading-assistant.messages.startSuccess': '戦略は無事に開始されました', + 'ai-trading-assistant.messages.startFailed': 'ポリシーの開始に失敗しました', + 'ai-trading-assistant.messages.stopSuccess': '戦略は正常に停止されました', + 'ai-trading-assistant.messages.stopFailed': '停止戦略は失敗しました', + 'ai-trading-assistant.messages.loadFailed': 'ポリシーリストの取得に失敗しました', + 'ai-trading-assistant.messages.loadDecisionsFailed': 'AI判定記録の取得に失敗しました', + 'ai-trading-assistant.messages.deleteConfirm': 'このポリシーを削除してもよろしいですか?この操作は元に戻すことができません。', + 'ai-trading-assistant.placeholders.inputStrategyName': 'ポリシー名を入力してください', + 'ai-trading-assistant.placeholders.selectModelId': 'AI モデルを選択してください', + 'ai-trading-assistant.placeholders.selectDecideInterval': '決定間隔を選択してください', + 'ai-trading-assistant.placeholders.startTime': '開始時間', + 'ai-trading-assistant.placeholders.endTime': '終了時間', + 'ai-trading-assistant.placeholders.inputApiKey': 'APIキーを入力してください', + 'ai-trading-assistant.placeholders.selectExchange': '交換を選択してください', + 'ai-trading-assistant.placeholders.inputSecretKey': '秘密キーを入力してください', + 'ai-trading-assistant.placeholders.inputPassphrase': 'パスフレーズを入力してください', + 'ai-trading-assistant.placeholders.selectSymbol': 'BTC/USDT などの取引ペアを選択してください。', + 'ai-trading-assistant.placeholders.selectTimeframe': '期間を選択してください', + 'ai-trading-assistant.placeholders.inputCustomPrompt': 'カスタムのプロンプト単語を入力してください (オプション)', + 'ai-trading-assistant.validation.strategyNameRequired': 'ポリシー名を入力してください', + 'ai-trading-assistant.validation.modelIdRequired': 'AI モデルを選択してください', + 'ai-trading-assistant.validation.runPeriodRequired': '実行サイクルを選択してください', + 'ai-trading-assistant.validation.apiKeyRequired': 'APIキーを入力してください', + 'ai-trading-assistant.validation.exchangeRequired': '交換を選択してください', + 'ai-trading-assistant.validation.secretKeyRequired': '秘密キーを入力してください', + 'ai-trading-assistant.validation.symbolRequired': '取引ペアを選択してください', + 'ai-trading-assistant.validation.initialCapitalRequired': '投資額を入力してください', + 'ai-trading-assistant.table.time': '時間', + 'ai-trading-assistant.table.type': 'タイプ', + 'ai-trading-assistant.table.price': '価格', + 'ai-trading-assistant.table.amount': '数量', + 'ai-trading-assistant.table.value': '金額', + 'ai-trading-assistant.table.symbol': '取引ペア', + 'ai-trading-assistant.table.side': '方向', + 'ai-trading-assistant.table.size': 'ポジション数量', + 'ai-trading-assistant.table.entryPrice': 'オープン価格', + 'ai-trading-assistant.table.currentPrice': '現在の価格', + 'ai-trading-assistant.table.unrealizedPnl': '未実現損益', + 'ai-trading-assistant.table.profit': '損益', + 'ai-trading-assistant.table.openLong': '長く開く', + 'ai-trading-assistant.table.closeLong': 'ピンドゥオ', + 'ai-trading-assistant.table.openShort': 'オープンショート', + 'ai-trading-assistant.table.closeShort': '空の', + 'ai-trading-assistant.table.addLong': 'ガドット', + 'ai-trading-assistant.table.addShort': '短く追加する', + 'ai-trading-assistant.table.closeShortProfit': 'ショートポジションで利益確定', + 'ai-trading-assistant.table.closeShortStop': 'ストップロス', + 'ai-trading-assistant.table.closeLongProfit': '長く停止して利益を得る', + 'ai-trading-assistant.table.closeLongStop': 'ストップロス', + 'ai-trading-assistant.table.buy': '買う', + 'ai-trading-assistant.table.sell': '売る', + 'ai-trading-assistant.table.long': '長く続けてください', + 'ai-trading-assistant.table.short': '短い', + 'ai-trading-assistant.table.hold': 'ホールド', + 'ai-trading-assistant.table.reasoning': '分析理由', + 'ai-trading-assistant.table.decisions': '意思決定', + 'ai-trading-assistant.table.riskAssessment': 'リスク評価', + 'ai-trading-assistant.table.confidence': '自信', + 'ai-trading-assistant.table.totalRecords': '合計 {total} レコード', + 'ai-trading-assistant.table.noPositions': 'まだポジションがありません', + 'ai-trading-assistant.detail.title': '戦略の詳細', + 'ai-trading-assistant.equity.noData': '純資産データはまだありません', + 'ai-trading-assistant.equity.equity': '純資産', + 'ai-trading-assistant.exchange.testFailed': '接続テストに失敗しました', + 'ai-trading-assistant.exchange.connectionSuccess': '接続成功', + 'ai-trading-assistant.exchange.connectionFailed': '接続に失敗しました', + 'ai-trading-assistant.form.advancedSettings': '詳細設定', + 'ai-trading-assistant.form.orderMode': 'オーダーモード', + 'ai-trading-assistant.form.orderModeMaker': 'メーカー', + 'ai-trading-assistant.form.orderModeTaker': '市場価格(テイカー)', + 'ai-trading-assistant.form.orderModeHint': '未決注文モードでは指値注文が使用され、手数料が安くなります。市場価格モードはすぐに実行され、手数料が高くなります。', + 'ai-trading-assistant.form.makerWaitSec': '未決注文の待ち時間 (秒)', + 'ai-trading-assistant.form.makerWaitSecHint': '注文後の取引を待つ時間。キャンセルして、タイムアウト後に再試行してください。', + 'ai-trading-assistant.form.makerRetries': '未決注文の再試行回数', + 'ai-trading-assistant.form.makerRetriesHint': '未決注文が約定しない場合の最大リトライ回数', + 'ai-trading-assistant.form.fallbackToMarket': '未決注文は失敗し、市場価格は引き下げられます。', + 'ai-trading-assistant.form.fallbackToMarketHint': '開始/終了未決注文が完了していない場合、トランザクションを確実に完了させるために成行注文にダウングレードする必要があるかどうか。', + 'ai-trading-assistant.form.marginMode': 'マージンモード', + 'ai-trading-assistant.form.marginModeCross': '倉庫がいっぱい', + 'ai-trading-assistant.form.marginModeIsolated': '孤立した位置', + 'ai-analysis.title': 'クォンタムトレーディングエンジン', + 'ai-analysis.system.online': 'オンライン', + 'ai-analysis.system.agents': 'エージェント', + 'ai-analysis.system.active': 'アクティブな', + 'ai-analysis.system.stage': 'ステージ', + 'ai-analysis.panel.roster': 'エージェントラインナップ', + 'ai-analysis.panel.thinking': '考え中...', + 'ai-analysis.panel.done': '完了', + 'ai-analysis.panel.standby': 'スタンバイ', + 'ai-analysis.input.title': 'クォンタムトレーディングエンジン', + 'ai-analysis.input.placeholder': 'ターゲット資産を選択します (例: BTC/USDT)', + 'ai-analysis.input.watchlist': 'オプションストック', + 'ai-analysis.input.start': '分析を開始する', + 'ai-analysis.input.recent': '最近のタスク:', + 'ai-analysis.vis.stage': 'ステージ', + 'ai-analysis.vis.processing': '処理中', + 'ai-analysis.result.complete': '分析完了', + 'ai-analysis.result.signal': '最終信号', + 'ai-analysis.result.confidence': '自信:', + 'ai-analysis.result.new': '新しい分析', + 'ai-analysis.result.full': 'レポート全体を表示', + 'ai-analysis.logs.title': 'システムログ', + 'ai-analysis.modal.title': '機密報告書', + 'ai-analysis.modal.fundamental': 'ファンダメンタルズ分析', + 'ai-analysis.modal.technical': 'テクニカル分析', + 'ai-analysis.modal.sentiment': '感情分析', + 'ai-analysis.modal.risk': 'リスク評価', + 'ai-analysis.stage.idle': 'スタンバイ', + 'ai-analysis.stage.1': 'フェーズ 1: 多次元分析', + 'ai-analysis.stage.2': 'フェーズ 2: 長短討論', + 'ai-analysis.stage.3': 'フェーズ 3: 戦略計画', + 'ai-analysis.stage.4': '第 4 段階: リスク管理のレビュー', + 'ai-analysis.stage.complete': '完了', + 'ai-analysis.agent.investment_director': '投資ディレクター', + 'ai-analysis.agent.role.investment_director': '包括的な分析と最終結論', + 'ai-analysis.agent.market': '市場アナリスト', + 'ai-analysis.agent.role.market': 'テクノロジーと市場データ', + 'ai-analysis.agent.fundamental': 'ファンダメンタルズアナリスト', + 'ai-analysis.agent.role.fundamental': '財務と評価', + 'ai-analysis.agent.technical': 'テクニカルアナリスト', + 'ai-analysis.agent.role.technical': 'テクニカル指標とチャート', + 'ai-analysis.agent.news': 'ニュースアナリスト', + 'ai-analysis.agent.role.news': 'グローバルニュースフィルター', + 'ai-analysis.agent.sentiment': 'センチメントアナリスト', + 'ai-analysis.agent.role.sentiment': '社会的&感情的', + 'ai-analysis.agent.risk': 'リスクアナリスト', + 'ai-analysis.agent.role.risk': '基本的なリスクチェック', + 'ai-analysis.agent.bull': '強気な研究者', + 'ai-analysis.agent.role.bull': '成長触媒の採掘', + 'ai-analysis.agent.bear': '弱気な研究者', + 'ai-analysis.agent.role.bear': 'リスクと欠陥の掘り下げ', + 'ai-analysis.agent.manager': '研究マネージャー', + 'ai-analysis.agent.role.manager': 'ディベートモデレーター', + 'ai-analysis.agent.trader': 'トレーダー', + 'ai-analysis.agent.role.trader': 'エグゼクティブ・ストラテジスト', + 'ai-analysis.agent.risky': '活動家アナリスト', + 'ai-analysis.agent.role.risky': '積極的な戦略', + 'ai-analysis.agent.neutral': 'バランスアナリスト', + 'ai-analysis.agent.role.neutral': 'バランス戦略', + 'ai-analysis.agent.safe': '保守的なアナリスト', + 'ai-analysis.agent.role.safe': '保守的な戦略', + 'ai-analysis.agent.cro': 'リスクコントロールマネージャー (CRO)', + 'ai-analysis.agent.role.cro': '最終的な意思決定権限', + 'ai-analysis.script.market': '主要な取引所から OHLCV データを取得しています...', + 'ai-analysis.script.fundamental': '四半期財務報告書を取得しています...', + 'ai-analysis.script.technical': 'テクニカル指標とチャートパターンを分析しています...', + 'ai-analysis.script.news': '世界的な金融ニュースを調べています...', + 'ai-analysis.script.sentiment': 'ソーシャルメディアのトレンドを分析中...', + 'ai-analysis.script.risk': '過去のボラティリティを計算しています...', + 'invite.inviteLink': '招待リンク', + 'invite.copy': 'リンクをコピー', + 'invite.copySuccess': 'コピー成功!', + 'invite.copyFailed': 'コピーに失敗しました。 手動でコピーしてください', + 'invite.noInviteLink': '招待リンクが生成されませんでした', + 'invite.totalInvites': '累積招待状', + 'invite.totalReward': '累計報酬', + 'invite.rules': '招待ルール', + 'invite.rule1': '友達の登録に成功するたびに、報酬を受け取ります', + 'invite.rule2': '招待された友達が最初のトランザクションを完了すると、追加の報酬を受け取ります', + 'invite.rule3': '招待特典はアカウントに直接送信されます', + 'invite.inviteList': '招待リスト', + 'invite.tasks': 'ミッションセンター', + 'invite.inviteeName': '招待者', + 'invite.inviteTime': '招待時間', + 'invite.status': 'ステータス', + 'invite.reward': '報酬', + 'invite.active': 'アクティブな', + 'invite.inactive': 'アクティブ化されていません', + 'invite.completed': '完了', + 'invite.claimed': '受け取りました', + 'invite.pending': '完成予定', + 'invite.goToTask': '完了する', + 'invite.claimReward': '報酬を請求する', + 'invite.verify': '検証完了', + 'invite.verifySuccess': '検証成功!タスクが完了しました', + 'invite.verifyNotCompleted': 'タスクはまだ完了していません。 最初にタスクを完了してください', + 'invite.verifyFailed': '検証に失敗しました。後でもう一度お試しください', + 'invite.claimSuccess': '{reward} QDT を正常に受け取りました!', + 'invite.claimFailed': '収集に失敗しました。 後でもう一度お試しください', + 'invite.totalRecords': '合計 {total} レコード', + 'invite.task.twitter.title': 'X(Twitter)にリツイート', + 'invite.task.twitter.desc': '公式ツイートを X (Twitter) アカウントに共有します', + 'invite.task.youtube.title': 'YouTube チャンネルをフォローしてください', + 'invite.task.youtube.desc': '公式 YouTube チャンネルを購読してフォローしてください', + 'invite.task.telegram.title': 'テレグラムグループに参加する', + 'invite.task.telegram.desc': '公式 Telegram コミュニティ グループに参加してください', + 'invite.task.discord.title': 'Discordサーバーに参加する', + 'invite.task.discord.desc': 'Discordコミュニティサーバーに参加してください', + 'message': '-', + 'layouts.usermenu.dialog.title': '情報', + 'layouts.usermenu.dialog.content': 'ログアウトしてもよろしいですか?', + 'layouts.userLayout.title': '不確実性の中で真実を見つける', + // Auto-filled missing keys from en-US (fallback to English) + 'dashboard.indicator.boundary.message': 'Note: The indicator script only computes plots + buy/sell signals. Position sizing, risk, scaling, fees/slippage belong to execution config.', + 'dashboard.indicator.boundary.indicatorRule': "Keep the script to buy/sell only (and set df['buy']/df['sell']). Do NOT implement position management, TP/SL, scaling in the script.", + 'dashboard.indicator.boundary.backtestRule': 'Rule: If a bar has a main signal (buy/sell → open/close/reverse), scaling in/out is skipped on the same bar.', + 'dashboard.indicator.guide.title': 'Python Indicator & Strategy Development Guide', + 'dashboard.indicator.guide.intro': 'This platform supports writing custom technical indicators and trading signals using Python. The system includes built-in pandas and numpy data analysis libraries. You can use standard DataFrame operations to process K-line data and draw charts or mark buy/sell signals.', + 'dashboard.indicator.guide.section1.title': '1. Runtime Environment & Predefined Variables', + 'dashboard.indicator.guide.section1.env': 'Your code runs in a browser-based Python environment (Pyodide).', + 'dashboard.indicator.guide.section1.libs': 'Pre-installed Libraries', + 'dashboard.indicator.guide.section1.libsDesc': 'The following libraries are imported by default, no need to import again:', + 'dashboard.indicator.guide.section1.libsList': 'pandas (pd), numpy (np), math, json', + 'dashboard.indicator.guide.section1.data': 'Input Data: df', + 'dashboard.indicator.guide.section1.dataDesc': 'The system automatically converts current K-line data into a Pandas DataFrame object, variable name is df.', + 'dashboard.indicator.guide.section1.dataColumns': 'df contains the following columns (all float type):', + 'dashboard.indicator.guide.section1.dataColumnsList': 'time (timestamp, seconds), open, high, low, close, volume', + 'dashboard.indicator.guide.section1.dataNote': 'Data index is arranged from old to new (0 is the earliest data, last row is the latest data).', + 'dashboard.indicator.guide.section1.dataIndex': 'Important: df index is timestamp (DatetimeIndex), please preserve the index, avoid using .values which loses the index.', + 'dashboard.indicator.guide.section2.title': '2. Parameter Definition', + 'dashboard.indicator.guide.section2.desc': 'You can define variables as parameters at the beginning of your code. The system will try to parse statements like variable = value as adjustable parameters.', + 'dashboard.indicator.guide.section2.example': 'Parameter Example', + 'dashboard.indicator.guide.section2.exampleCode': 'span = 20 or multiplier = 2.0', + 'dashboard.indicator.guide.section3.title': '3. Backtest Signal Setup (Important!)', + 'dashboard.indicator.guide.section3.desc': 'If you want your indicator to support backtesting, you must set the following two columns in your code:', + 'dashboard.indicator.guide.section3.backtestRequired': 'Required for Backtesting:', + 'dashboard.indicator.guide.section3.backtestBuySignal': "df['buy_signal'] - Boolean Series, True indicates buy signal", + 'dashboard.indicator.guide.section3.backtestSellSignal': "df['sell_signal'] - Boolean Series, True indicates sell signal", + 'dashboard.indicator.guide.section3.backtestExample': 'Example:', + 'dashboard.indicator.guide.section3.backtestExampleCode': "df['buy_signal'] = condition_buy
    df['sell_signal'] = condition_sell", + 'dashboard.indicator.guide.section3.backtestNote': 'Note: Even if you only set output.signals for chart display, you must set these two columns to enable backtesting.', + 'dashboard.indicator.guide.section4.title': '4. Output Requirements: output', + 'dashboard.indicator.guide.section4.desc': 'At the end of code execution, you must define a dictionary named output to tell the chart how to display results.', + 'dashboard.indicator.guide.section4.fields': 'The output dictionary contains the following fields:', + 'dashboard.indicator.guide.section4.plots': 'plots - For drawing lines (e.g., moving averages, RSI)', + 'dashboard.indicator.guide.section4.signals': 'signals - For marking buy/sell signals (e.g., arrows, labels)', + 'dashboard.indicator.guide.section4.name': 'name - Indicator name (optional)', + 'dashboard.indicator.guide.section4.calculatedVars': 'calculatedVars - Calculated variables (optional, for displaying additional information)', + 'dashboard.indicator.guide.section5.title': '5. Plot Configuration (Plots)', + 'dashboard.indicator.guide.section5.desc': 'Each object in the plots list represents a line.', + 'dashboard.indicator.guide.section5.table.field': 'Field', + 'dashboard.indicator.guide.section5.table.type': 'Type', + 'dashboard.indicator.guide.section5.table.required': 'Required', + 'dashboard.indicator.guide.section5.table.requiredYes': 'Yes', + 'dashboard.indicator.guide.section5.table.requiredNo': 'No', + 'dashboard.indicator.guide.section5.table.desc': 'Description', + 'dashboard.indicator.guide.section5.table.name': 'The name of the indicator line, displayed in the legend', + 'dashboard.indicator.guide.section5.table.data': 'Data list, length must match the number of rows in df. Recommended to use series.tolist()', + 'dashboard.indicator.guide.section5.table.color': "Line color (Hex format, e.g. '#ff0000'). If not filled, the system will automatically assign", + 'dashboard.indicator.guide.section5.table.typeDesc': "Chart type, default is 'line'", + 'dashboard.indicator.guide.section5.table.overlay': 'Important. True means display on main chart (with K-line), False means display on sub-chart (independent area)', + 'dashboard.indicator.guide.section5.example': 'Example: Draw Simple Moving Average (SMA)', + 'dashboard.indicator.guide.section5.exampleCalc': 'Calculate SMA', + 'dashboard.indicator.guide.section5.exampleOutput': 'Construct Output', + 'dashboard.indicator.guide.section5.exampleOverlay': 'Display on main chart', + 'dashboard.indicator.guide.section6.title': '6. Signal Markers (Signals)', + 'dashboard.indicator.guide.section6.desc': "signals are used to mark buy/sell points on specific K-lines through icons or text. Note: This is only for chart display. The backtest system uses df['buy_signal'] and df['sell_signal'].", + 'dashboard.indicator.guide.section6.table.type': "Signal type: 'buy' (buy, marked below K-line) or 'sell' (sell, marked above K-line)", + 'dashboard.indicator.guide.section6.table.data': 'Signal data list. Fill price value at signal positions, fill None (or np.nan) at non-signal positions', + 'dashboard.indicator.guide.section6.table.text': 'Text displayed on the label (e.g. "B", "S", "Buy", "Sell")', + 'dashboard.indicator.guide.section6.table.color': 'Marker color', + 'dashboard.indicator.guide.section6.hint': 'The system automatically handles positioning.', + 'dashboard.indicator.guide.section6.buy': 'Markers will be automatically positioned below the Low of the current K-line', + 'dashboard.indicator.guide.section6.sell': 'Markers will be automatically positioned above the High of the current K-line', + 'dashboard.indicator.guide.section6.example': 'Example: Draw Buy/Sell Signals', + 'dashboard.indicator.guide.section6.exampleInit': 'Initialize signal list, fill all with None', + 'dashboard.indicator.guide.section6.exampleLogic': 'Simple logic: mark buy when close price is greater than open price', + 'dashboard.indicator.guide.section6.exampleNote': 'In actual logic, please use loops or vectorized operations', + 'dashboard.indicator.guide.section6.examplePrice': 'Record price', + 'dashboard.indicator.guide.section6.exampleText': 'Buy', + 'dashboard.indicator.guide.section7.title': '7. Complete Code Examples', + 'dashboard.indicator.guide.section7.exampleA': 'Example A: Dual EMA Strategy (Supports Backtesting)', + 'dashboard.indicator.guide.section7.exampleACode': "# 1. Calculate indicators
    df['ema_fast'] = df['close'].ewm(span=5, adjust=False).mean()
    df['ema_slow'] = df['close'].ewm(span=13, adjust=False).mean()

    # 2. Calculate crossover signals
    cross_up = (df['ema_fast'] > df['ema_slow']) & (df['ema_fast'].shift(1) <= df['ema_slow'].shift(1))
    cross_down = (df['ema_fast'] < df['ema_slow']) & (df['ema_fast'].shift(1) >= df['ema_slow'].shift(1))

    # 3. 【Important】Set NEW backtest signal columns (bool)
    df['open_long'] = cross_up
    df['close_long'] = cross_down
    df['open_short'] = cross_down
    df['close_short'] = cross_up

    # 4. Generate signal data for chart display
    buy_signals = [df['low'].iloc[i] * 0.995 if cross_up.iloc[i] else None for i in range(len(df))]
    sell_signals = [df['high'].iloc[i] * 1.005 if cross_down.iloc[i] else None for i in range(len(df))]

    # 5. Build output
    output = {
    \"name\": \"Dual EMA Strategy\",
    \"plots\": [
    {\"name\": \"EMA 5\", \"data\": df['ema_fast'].tolist(), \"color\": \"#FF6B6B\", \"overlay\": True},
    {\"name\": \"EMA 13\", \"data\": df['ema_slow'].tolist(), \"color\": \"#4ECDC4\", \"overlay\": True}
    ],
    \"signals\": [
    {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},
    {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}
    ]
    }", + 'dashboard.indicator.guide.section7.exampleB': 'Example B: Bollinger Bands - Chart Display Only', + 'dashboard.indicator.guide.section7.exampleBCode': "# Calculate Bollinger Bands (chart display only, no backtesting)
    length = 20
    mult = 2.0
    df['sma'] = df['close'].rolling(length).mean()
    df['std'] = df['close'].rolling(length).std()
    df['upper'] = df['sma'] + mult * df['std']
    df['lower'] = df['sma'] - mult * df['std']

    output = {
    \"name\": \"Bollinger Bands\",
    \"plots\": [
    {\"name\": \"Upper\", \"data\": df['upper'].tolist(), \"color\": \"#FF5252\", \"overlay\": True},
    {\"name\": \"Middle\", \"data\": df['sma'].tolist(), \"color\": \"#2196F3\", \"overlay\": True},
    {\"name\": \"Lower\", \"data\": df['lower'].tolist(), \"color\": \"#00E676\", \"overlay\": True}
    ]
    }", + 'dashboard.indicator.guide.section7.exampleC': 'Example C: RSI Strategy (Supports Backtesting)', + 'dashboard.indicator.guide.section7.exampleCCode': "# Calculate RSI
    delta = df['close'].diff()
    up = delta.clip(lower=0)
    down = -1 * delta.clip(upper=0)
    ema_up = up.ewm(com=13, adjust=False).mean()
    ema_down = down.ewm(com=13, adjust=False).mean()
    rs = ema_up / ema_down
    df['rsi'] = 100 - (100 / (1 + rs))

    # Generate trading signals
    buy_condition = df['rsi'] < 30 # Oversold buy
    sell_condition = df['rsi'] > 70 # Overbought sell

    # 【Important】Set NEW backtest signal columns (bool)
    df['open_long'] = buy_condition
    df['close_long'] = sell_condition
    df['open_short'] = sell_condition
    df['close_short'] = buy_condition

    # Generate chart signals
    buy_signals = [df['low'].iloc[i] * 0.995 if buy_condition.iloc[i] else None for i in range(len(df))]
    sell_signals = [df['high'].iloc[i] * 1.005 if sell_condition.iloc[i] else None for i in range(len(df))]

    output = {
    \"name\": \"RSI Strategy\",
    \"plots\": [
    {\"name\": \"RSI\", \"data\": df['rsi'].tolist(), \"color\": \"#9C27B0\", \"overlay\": False},
    {\"name\": \"Overbought\", \"data\": [70] * len(df), \"color\": \"#FF5252\", \"style\": \"dashed\", \"overlay\": False},
    {\"name\": \"Oversold\", \"data\": [30] * len(df), \"color\": \"#00E676\", \"style\": \"dashed\", \"overlay\": False}
    ],
    \"signals\": [
    {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},
    {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}
    ]
    }", + 'dashboard.indicator.guide.section6.param': 'Parameters', + 'dashboard.indicator.guide.section6.calc': 'Calculate', + 'dashboard.indicator.guide.section6.output': 'Output', + 'dashboard.indicator.guide.section6.generate': 'Generate signal data (initialize with None)', + 'dashboard.indicator.guide.section6.subchart': 'Display on sub-chart', + 'dashboard.indicator.guide.section8.title': '8. Frequently Asked Questions (FAQ)', + 'dashboard.indicator.guide.section8.q1': "Q: Why can't my indicator be backtested?", + 'dashboard.indicator.guide.section8.a1': "A: The backtest system requires NEW signal columns: df['open_long'], df['close_long'], df['open_short'], df['close_short'] (boolean). Even if you set output.signals for chart display, you must set these columns to enable backtesting.", + 'dashboard.indicator.guide.section8.q2': 'Q: Data length mismatch?', + 'dashboard.indicator.guide.section8.a2': "A: The data list in plots and signals must have exactly the same length as the K-line data. If you use df['xxx'].tolist(), there is usually no problem.", + 'dashboard.indicator.guide.section8.q3': 'Q: How to handle NaN (empty values)?', + 'dashboard.indicator.guide.section8.a3': "A: Pandas calculations (such as rolling mean) will produce NaN at the beginning. The system will automatically convert NaN to null for chart rendering. You don't need to manually clean it, but if you need specific logic, you can use df.fillna(0) or other methods.", + 'dashboard.indicator.guide.section8.q4': 'Q: Index mismatch causing all signals to be NaN?', + 'dashboard.indicator.guide.section8.a4': "A: Please ensure you preserve the original df index (timestamp). Avoid using .values to create new Series. Use columns directly like df['close'] for operations.", + 'dashboard.indicator.guide.section8.q5': 'Q: Can I use third-party network libraries?', + 'dashboard.indicator.guide.section8.a5': 'A: No. The runtime environment is a browser sandbox and cannot use libraries like requests to request external APIs, nor can it install additional pip packages. Limited to built-in libraries such as pandas, numpy, math, json.', + 'dashboard.indicator.guide.section8.q6': 'Q: How to debug code?', + 'dashboard.indicator.guide.section8.a6': 'A: If the chart does not display indicators, it is usually because the Python code has an error. Please check for syntax errors or array index out of bounds. It is recommended to debug the algorithm logic in a local Python environment first, then copy it in.', + 'dashboard.indicator.guide.section8.q7': 'Q: Backtest shows 0 signals?', + 'dashboard.indicator.guide.section8.a7': "A: Please check the data type of df['open_long']/df['close_long']/df['open_short']/df['close_short']. They should be boolean (True/False), not numeric. Use condition.fillna(False).astype(bool) to ensure correct type.", + 'dashboard.indicator.backtest.commissionHint': 'Charged on notional value (incl. leverage) per trade; both entry & exit are deducted from balance.', + 'dashboard.indicator.backtest.historyTitle': 'Backtest History', + 'dashboard.indicator.backtest.historyRefresh': 'Refresh', + 'dashboard.indicator.backtest.historyView': 'View', + 'dashboard.indicator.backtest.historyNoData': 'No backtest history', + 'dashboard.indicator.backtest.historyUseCurrent': 'Only current symbol/timeframe', + 'dashboard.indicator.backtest.historyFilterSymbol': 'Symbol', + 'dashboard.indicator.backtest.historyFilterTimeframe': 'Timeframe', + 'dashboard.indicator.backtest.historyApply': 'Apply filters', + 'dashboard.indicator.backtest.historyAIAnalyze': 'AI Analyze', + 'dashboard.indicator.backtest.historyAIAnalyzeTitle': 'AI Suggestions', + 'dashboard.indicator.backtest.historyNoAIResult': 'No AI analysis result', + 'dashboard.indicator.backtest.historyRunId': 'Run ID', + 'dashboard.indicator.backtest.historyCreatedAt': 'Time', + 'dashboard.indicator.backtest.historyRange': 'Range', + 'dashboard.indicator.backtest.historyStatus': 'Status', + 'dashboard.indicator.backtest.historyStatusSuccess': 'Success', + 'dashboard.indicator.backtest.historyStatusFailed': 'Failed', + 'dashboard.indicator.backtest.historyActions': 'Actions', + 'dashboard.indicator.backtest.hint.entryPctMax': 'Max entry: {maxPct}% (reserve budget for future scale-ins)', + 'trading-assistant.exchange.ipWhitelistTip': 'Please add the following IPs to the whitelist in your exchange API settings:', + 'signal-robot.title': 'Signal Robot Console', + 'signal-robot.createBot': 'Create New Bot', + 'signal-robot.search.nameOrSymbol': 'Name/Symbol', + 'signal-robot.search.placeholder': 'Search bot name or symbol', + 'signal-robot.search.status': 'Status', + 'signal-robot.search.statusAll': 'All', + 'signal-robot.search.statusRunning': 'Running', + 'signal-robot.search.statusPaused': 'Paused', + 'signal-robot.search.query': 'Query', + 'signal-robot.search.reset': 'Reset', + 'signal-robot.table.botName': 'Bot Name', + 'signal-robot.table.symbolTimeframe': 'Symbol/Timeframe', + 'signal-robot.table.triggerStrategy': 'Trigger Strategy', + 'signal-robot.table.notificationChannels': 'Notification Channels', + 'signal-robot.table.status': 'Status', + 'signal-robot.table.action': 'Action', + 'signal-robot.table.triggerConditions': '{count} trigger conditions', + 'signal-robot.table.monitoring': 'Monitoring', + 'signal-robot.table.paused': 'Paused', + 'signal-robot.table.edit': 'Edit', + 'signal-robot.table.pause': 'Pause', + 'signal-robot.table.start': 'Start', + 'signal-robot.table.delete': 'Delete', + 'signal-robot.table.deleteConfirm': 'Are you sure you want to delete this bot?', + 'signal-robot.table.deleteSuccess': 'Deleted successfully', + 'signal-robot.table.startSuccess': 'Bot started', + 'signal-robot.table.pauseSuccess': 'Bot paused', + 'signal-robot.channel.telegram': 'Telegram', + 'signal-robot.channel.discord': 'Discord', + 'signal-robot.channel.email': 'Email', + 'signal-robot.channel.webhook': 'Webhook', + 'signal-robot.channel.browser': 'Browser Push', + 'signal-robot.modal.createTitle': 'Create New Signal Robot', + 'signal-robot.modal.editTitle': 'Edit Signal Robot', + 'signal-robot.modal.step.basic': 'Basic Settings', + 'signal-robot.modal.step.entry': 'Entry Strategy', + 'signal-robot.modal.step.position': 'Position Management', + 'signal-robot.modal.step.risk': 'Risk Control', + 'signal-robot.modal.step.notify': 'Notification Config', + 'signal-robot.modal.prev': 'Previous', + 'signal-robot.modal.next': 'Next', + 'signal-robot.modal.preview': 'Preview Backtest', + 'signal-robot.modal.complete': 'Complete & Run', + 'signal-robot.modal.saveSuccess': 'Saved successfully', + 'signal-robot.modal.previewDeveloping': 'Preview feature is under development...', + 'signal-robot.modal.basicInfoRequired': 'Please complete basic information', + 'signal-robot.basic.alert': 'Set basic information for the bot', + 'signal-robot.basic.title': 'Basic Information', + 'signal-robot.basic.botName': 'Bot Name', + 'signal-robot.basic.botNamePlaceholder': 'e.g.: BTC SuperTrend Strategy', + 'signal-robot.basic.symbol': 'Trading Pair', + 'signal-robot.basic.symbolPlaceholder': 'Select from watchlist', + 'signal-robot.basic.noWatchlist': 'No watchlist items, please add in market page first', + 'signal-robot.basic.timeframe': 'K-Line Period', + 'signal-robot.basic.timeframe15m': '15 Minutes', + 'signal-robot.basic.timeframe30m': '30 Minutes', + 'signal-robot.basic.timeframe1h': '1 Hour', + 'signal-robot.basic.timeframe4h': '4 Hours', + 'signal-robot.basic.timeframe1d': '1 Day', + 'signal-robot.basic.autoNameSuggestion': '{symbol} Signal Robot', + 'signal-robot.entry.alert': 'Trigger entry signal when all conditions below are met', + 'signal-robot.entry.condition': 'Condition {index}', + 'signal-robot.entry.indicator': 'Indicator', + 'signal-robot.entry.indicatorSupertrend': 'SuperTrend', + 'signal-robot.entry.indicatorEma': 'EMA (Exponential Moving Average)', + 'signal-robot.entry.indicatorRsi': 'RSI (Relative Strength Index)', + 'signal-robot.entry.indicatorMacd': 'MACD', + 'signal-robot.entry.indicatorBollinger': 'Bollinger Bands', + 'signal-robot.entry.atrPeriod': 'ATR Period', + 'signal-robot.entry.multiplier': 'Multiplier', + 'signal-robot.entry.triggerSignal': 'Trigger Signal', + 'signal-robot.entry.signalTrendBullish': 'Trend Bullish', + 'signal-robot.entry.signalTrendBearish': 'Trend Bearish', + 'signal-robot.entry.signalIsUptrend': 'Is Uptrend', + 'signal-robot.entry.signalIsDowntrend': 'Is Downtrend', + 'signal-robot.entry.period': 'Period', + 'signal-robot.entry.compareCondition': 'Compare Condition', + 'signal-robot.entry.priceAbove': 'Price > EMA (Price Above)', + 'signal-robot.entry.priceBelow': 'Price < EMA (Price Below)', + 'signal-robot.entry.crossUp': 'Price Crosses Above EMA (Golden Cross)', + 'signal-robot.entry.crossDown': 'Price Crosses Below EMA (Death Cross)', + 'signal-robot.entry.compare': 'Compare', + 'signal-robot.entry.greaterThan': 'Greater Than', + 'signal-robot.entry.lessThan': 'Less Than', + 'signal-robot.entry.crossUpShort': 'Cross Up', + 'signal-robot.entry.crossDownShort': 'Cross Down', + 'signal-robot.entry.threshold': 'Threshold', + 'signal-robot.entry.selectIndicator': 'Please select an indicator to configure parameters', + 'signal-robot.entry.addCondition': 'Add Condition', + 'signal-robot.position.alert': 'Configure initial position size, leverage and pyramiding rules', + 'signal-robot.position.basicTitle': 'Basic Position Management', + 'signal-robot.position.initialSize': 'Initial Size (Capital %)', + 'signal-robot.position.initialSizeHint': 'Recommended 5% - 20%', + 'signal-robot.position.leverage': 'Leverage', + 'signal-robot.position.leverageHint': 'Please ensure your exchange account supports this leverage', + 'signal-robot.position.maxPyramiding': 'Max Pyramiding Times', + 'signal-robot.position.maxPyramidingHint': '0 means no pyramiding', + 'signal-robot.position.pyramidingTitle': 'Pyramiding Rules', + 'signal-robot.position.pyramidingEnabled': 'Enabled', + 'signal-robot.position.pyramidingDisabled': 'Disabled', + 'signal-robot.position.pyramidingCondition': 'Pyramiding Trigger Condition', + 'signal-robot.position.priceRisePct': 'Price Rises (Long) / Falls (Short)', + 'signal-robot.position.profitPct': 'Profit Reaches', + 'signal-robot.position.triggerThreshold': 'Trigger Threshold (%)', + 'signal-robot.position.triggerThresholdHint': 'e.g. 3%: add position once every 3% rise', + 'signal-robot.position.addSize': 'Single Add Size (Capital %)', + 'signal-robot.position.pyramidingDisabledHint': 'Pyramiding disabled, only initial position will be executed', + 'signal-robot.risk.alert': 'Configure take profit, stop loss and exit rules', + 'signal-robot.risk.stopLossTitle': 'Stop Loss', + 'signal-robot.risk.stopLossEnabled': 'Enabled', + 'signal-robot.risk.stopLossDisabled': 'Disabled', + 'signal-robot.risk.stopLossType': 'Stop Loss Type', + 'signal-robot.risk.stopLossFixedPct': 'Fixed Percentage', + 'signal-robot.risk.stopLossAtrMultiplier': 'ATR Multiplier', + 'signal-robot.risk.stopLossValue': 'Value', + 'signal-robot.risk.stopLossFixedHint': 'e.g. 2%: stop loss when loss reaches 2%', + 'signal-robot.risk.stopLossAtrHint': 'e.g. 2.0: stop loss when price touches ATR*2.0', + 'signal-robot.risk.stopLossDisabledHint': 'Stop loss disabled (high risk)', + 'signal-robot.risk.trailingStopTitle': 'Trailing Stop', + 'signal-robot.risk.activationProfit': 'Activation Profit (%)', + 'signal-robot.risk.activationProfitHint': 'Start trailing when profit reaches this level', + 'signal-robot.risk.callbackPct': 'Callback (%)', + 'signal-robot.risk.callbackPctHint': 'Trigger take profit when price falls from peak by this percentage', + 'signal-robot.risk.trailingStopDisabledHint': 'Trailing stop disabled', + 'signal-robot.risk.otherExitTitle': 'Other Exit Rules', + 'signal-robot.risk.signalExit': 'Allow Signal Exit', + 'signal-robot.risk.signalExitHint': 'Close position immediately when reverse signal appears (e.g. close long when short signal appears)', + 'signal-robot.notify.alert': 'Configure notification methods when signal is triggered', + 'signal-robot.notify.channelTitle': 'Notification Channels', + 'signal-robot.notify.discordWebhook': 'Discord Webhook', + 'signal-robot.notify.genericWebhook': 'Generic Webhook', + 'signal-robot.notify.webhookUrl': 'Webhook URL', + 'signal-robot.notify.webhookUrlPlaceholder': 'https://example.com/webhook', + 'signal-robot.notify.discordUrl': 'Discord URL', + 'signal-robot.notify.discordUrlPlaceholder': 'https://discord.com/api/webhooks/...', + 'signal-robot.operator.greaterThan': 'Greater Than', + 'signal-robot.operator.lessThan': 'Less Than', + 'signal-robot.operator.equal': 'Equal', + 'signal-robot.operator.goldenCross': 'Golden Cross', + 'signal-robot.operator.deathCross': 'Death Cross', + 'signal-robot.operator.increaseBy': 'Increase By', + 'signal-robot.operator.decreaseBy': 'Decrease By', + 'signal-robot.indicator.price': 'Price', + 'signal-robot.indicator.rsi': 'RSI', + 'signal-robot.indicator.kdjK': 'KDJ(K)', + 'signal-robot.indicator.kdjJ': 'KDJ(J)', + 'signal-robot.indicator.macd': 'MACD', + 'signal-robot.indicator.macdHist': 'MACD Histogram', + 'signal-robot.indicator.bollingerUp': 'Bollinger Upper', + 'signal-robot.indicator.bollingerLow': 'Bollinger Lower', + 'signal-robot.indicator.volume': 'Volume', + 'signal-robot.indicator.changePct1h': '1H Change %', + 'signal-robot.indicator.changePct24h': '24H Change %', + 'signal-robot.indicator.signalLine': 'Signal Line' +} + +export default { + ...components, + ...locale +} diff --git a/quantdinger_vue/src/locales/lang/ko-KR.js b/quantdinger_vue/src/locales/lang/ko-KR.js new file mode 100644 index 0000000..9ba5dd2 --- /dev/null +++ b/quantdinger_vue/src/locales/lang/ko-KR.js @@ -0,0 +1,1767 @@ +import antdKoKR from 'ant-design-vue/es/locale-provider/ko_KR' +import momentKO from 'moment/locale/ko' + +const components = { + antLocale: antdKoKR, + momentName: 'ko', + momentLocale: momentKO +} +const locale = { + 'submit': '제출', + 'save': '저장', + 'submit.ok': '제출 성공', + 'save.ok': '성공적으로 저장되었습니다', + 'menu.welcome': '환영합니다', + 'menu.home': '홈페이지', + 'menu.dashboard': '대시보드', + 'menu.dashboard.indicator': '지표분석', + 'menu.dashboard.community': '지표 커뮤니티', + 'menu.dashboard.analysis': 'AI 분석', + 'menu.dashboard.tradingAssistant': '트레이딩 어시스턴트', + 'menu.dashboard.aiTradingAssistant': 'AI 트레이딩 도우미', + 'menu.dashboard.signalRobot': '시그널 로봇', + 'menu.dashboard.monitor': '모니터링 페이지', + 'menu.dashboard.workplace': '작업대', + 'menu.form': '양식 페이지', + 'menu.form.basic-form': '기본 형태', + 'menu.form.step-form': '단계별 양식', + 'menu.form.step-form.info': '단계별 양식(이체 정보 입력)', + 'menu.form.step-form.confirm': '단계별 양식(이체정보 확인)', + 'menu.form.step-form.result': '단계별 양식(완료)', + 'menu.form.advanced-form': '고급 양식', + 'menu.list': '목록 페이지', + 'menu.list.table-list': '문의 양식', + 'menu.list.basic-list': '표준 목록', + 'menu.list.card-list': '카드 목록', + 'menu.list.search-list': '검색 목록', + 'menu.list.search-list.articles': '검색 목록(기사)', + 'menu.list.search-list.projects': '검색 목록(프로젝트)', + 'menu.list.search-list.applications': '검색 목록(앱)', + 'menu.profile': '세부정보 페이지', + 'menu.profile.basic': '기본 세부정보 페이지', + 'menu.profile.advanced': '고급 세부정보 페이지', + 'menu.result': '결과 페이지', + 'menu.result.success': '성공 페이지', + 'menu.result.fail': '실패 페이지', + 'menu.exception': '예외 페이지', + 'menu.exception.not-permission': '403', + 'menu.exception.not-find': '404', + 'menu.exception.server-error': '500', + 'menu.exception.trigger': '트리거 오류', + 'menu.account': '개인 페이지', + 'menu.account.center': '개인 센터', + 'menu.account.settings': '개인 설정', + 'menu.account.trigger': '트리거 오류', + 'menu.account.logout': '로그아웃', + 'menu.wallet': '내 지갑', + 'menu.docs': '문서 센터', + 'menu.docs.detail': '문서 세부정보', + 'menu.header.refreshPage': '페이지 새로 고침', + 'menu.invite.friends': '친구 초대', + 'app.setting.pagestyle': '전반적인 스타일 설정', + 'app.setting.pagestyle.light': '밝은 메뉴 스타일', + 'app.setting.pagestyle.dark': '어두운 메뉴 스타일', + 'app.setting.pagestyle.realdark': '다크 모드', + 'app.setting.themecolor': '테마 색상', + 'app.setting.navigationmode': '탐색 모드', + 'app.setting.sidemenu.nav': '사이드바 탐색', + 'app.setting.topmenu.nav': '상단 표시줄 탐색', + 'app.setting.content-width': '콘텐츠 영역 너비', + 'app.setting.content-width.tooltip': '이 설정은 [상단 표시줄 탐색]인 경우에만 유효합니다.', + 'app.setting.content-width.fixed': '고정', + 'app.setting.content-width.fluid': '스트리밍', + 'app.setting.fixedheader': '고정 헤더', + 'app.setting.fixedheader.tooltip': '헤더 고정 시 구성 가능', + 'app.setting.autoHideHeader': '스크롤할 때 헤더 숨기기', + 'app.setting.fixedsidebar': '사이드 메뉴 고정', + 'app.setting.sidemenu': '사이드 메뉴 레이아웃', + 'app.setting.topmenu': '상단 메뉴 레이아웃', + 'app.setting.othersettings': '기타 설정', + 'app.setting.weakmode': '색약 모드', + 'app.setting.multitab': '멀티탭 모드', + 'app.setting.copy': '설정 복사', + 'app.setting.loading': '테마 로드 중', + 'app.setting.copyinfo': '설정을 성공적으로 복사했습니다. src/config/defaultSettings.js', + 'app.setting.copy.success': '복사 완료', + 'app.setting.copy.fail': '복사 실패', + 'app.setting.theme.switching': '테마 변경!', + 'app.setting.production.hint': '구성 표시줄은 개발 환경에서 미리 보기에만 사용되며 프로덕션 환경에서는 표시되지 않습니다. 구성 파일을 수동으로 복사하고 수정하십시오.', + 'app.setting.themecolor.daybreak': '새벽 파란색(기본값)', + 'app.setting.themecolor.dust': '황혼', + 'app.setting.themecolor.volcano': '화산', + 'app.setting.themecolor.sunset': '일몰', + 'app.setting.themecolor.cyan': '밍칭', + 'app.setting.themecolor.green': '오로라 그린', + 'app.setting.themecolor.geekblue': '긱 블루', + 'app.setting.themecolor.purple': '장쯔(Jiang Zi)', + 'app.setting.tooltip': '페이지 설정', + 'user.login.userName': '사용자 이름', + 'user.login.password': '비밀번호', + 'user.login.username.placeholder': '계정: 관리자', + 'user.login.password.placeholder': '비밀번호: admin 또는 ant.design', + 'user.login.message-invalid-credentials': '로그인에 실패했습니다. 이메일과 인증 코드를 확인하세요.', + 'user.login.message-invalid-verification-code': '인증코드 오류', + 'user.login.tab-login-credentials': '계정 비밀번호 로그인', + 'user.login.tab-login-email': '이메일 로그인', + 'user.login.tab-login-mobile': '휴대폰번호 로그인', + 'user.login.captcha.placeholder': '그래픽 인증 코드를 입력하세요.', + 'user.login.mobile.placeholder': '휴대전화번호', + 'user.login.mobile.verification-code.placeholder': '인증코드', + 'user.login.email.placeholder': '이메일 주소를 입력해주세요', + 'user.login.email.verification-code.placeholder': '인증번호를 입력해주세요', + 'user.login.email.sending': '인증코드가 전송되는 중입니다...', + 'user.login.email.send-success-title': '팁', + 'user.login.email.send-success': '인증코드가 성공적으로 전송되었습니다. 이메일을 확인해 주세요.', + 'user.login.sms.send-success': '인증번호가 성공적으로 전송되었습니다. 문자 메시지를 확인하세요.', + 'user.login.remember-me': '자동 로그인', + 'user.login.forgot-password': '비밀번호를 잊으셨나요?', + 'user.login.sign-in-with': '기타 로그인 방법', + 'user.login.signup': '계정 등록', + 'user.login.login': '로그인', + 'user.register.register': '등록', + 'user.register.email.placeholder': '이메일', + 'user.register.password.placeholder': '6자 이상 입력해주세요. 쉽게 추측할 수 있는 비밀번호는 사용하지 마세요.', + 'user.register.password.popover-message': '6자 이상 입력해주세요. 쉽게 추측할 수 있는 비밀번호는 사용하지 마세요.', + 'user.register.confirm-password.placeholder': '비밀번호 확인', + 'user.register.get-verification-code': '인증 코드 받기', + 'user.register.sign-in': '기존 계정을 사용하여 로그인', + 'user.register-result.msg': '귀하의 계정: {email} 등록이 완료되었습니다.', + 'user.register-result.activation-email': '활성화 이메일이 귀하의 사서함으로 전송되었으며 24시간 동안 유효합니다. 귀하의 이메일에 즉시 로그인하고 이메일에 있는 링크를 클릭하여 계정을 활성화하십시오.', + 'user.register-result.back-home': '홈페이지로 돌아가기', + 'user.register-result.view-mailbox': '우편함을 확인하세요', + 'user.email.required': '이메일 주소를 입력해주세요!', + 'user.email.wrong-format': '이메일 주소 형식이 잘못되었습니다!', + 'user.userName.required': '계정 이름이나 이메일 주소를 입력하세요', + 'user.password.required': '비밀번호를 입력해주세요!', + 'user.password.twice.msg': '두 번 입력한 비밀번호가 일치하지 않습니다!', + 'user.password.strength.msg': '비밀번호가 충분히 강력하지 않습니다.', + 'user.password.strength.strong': '힘: 강한', + 'user.password.strength.medium': '강도: 중간', + 'user.password.strength.low': '강도: 낮음', + 'user.password.strength.short': '강도: 너무 짧음', + 'user.confirm-password.required': '비밀번호를 확인해 주세요!', + 'user.phone-number.required': '정확한 휴대폰번호를 입력해주세요', + 'user.phone-number.wrong-format': '휴대폰 번호 형식이 잘못되었습니다!', + 'user.verification-code.required': '인증번호를 입력해주세요!', + 'user.captcha.required': '그래픽 인증코드를 입력해주세요!', + 'user.login.infos': 'QuantDinger는 AI 멀티에이전트 주식 분석 보조 도구로 증권 투자 컨설팅 자격이 없습니다. 플랫폼 내 모든 분석 결과, 점수, 참고 의견은 과거 데이터를 기반으로 AI에 의해 자동으로 생성되며 학습, 연구 및 기술 교류에만 사용되며 투자 조언이나 의사 결정 기반을 구성하지 않습니다. 주식투자에는 시장리스크, 유동성리스크, 정책리스크 등 다양한 리스크가 수반되며, 이로 인해 원금 손실이 발생할 수 있습니다. 사용자는 자신의 위험 허용 범위에 따라 독립적인 결정을 내려야 하며, 이 도구의 사용으로 인해 발생하는 모든 투자 행위 및 결과는 사용자가 부담해야 합니다. 시장은 위험하므로 투자에 신중해야 합니다.', + 'user.login.tab-login-web3': 'Web3 로그인', + 'user.login.web3.tip': '지갑을 이용한 서명 로그인', + 'user.login.web3.connect': '지갑을 연결하고 로그인하세요', + 'user.login.web3.no-wallet': '지갑이 감지되지 않음', + 'user.login.web3.no-address': '지갑 주소를 가져오지 못했습니다.', + 'user.login.web3.nonce-failed': '난수를 가져오지 못했습니다.', + 'user.login.web3.verify-failed': '서명 확인 실패', + 'user.login.web3.success': '로그인 성공', + 'user.login.web3.failed': '지갑 로그인 실패', + 'nav.no_wallet': '지갑이 감지되지 않음', + 'nav.copy': '복사', + 'nav.copy_success': '성공적으로 복사되었습니다', + 'user.login.oauth.google': 'Google로 로그인', + 'user.login.oauth.github': 'GitHub로 로그인', + 'user.login.oauth.loading': '승인 페이지로 리디렉션 중...', + 'user.login.oauth.failed': 'OAuth 로그인 실패', + 'user.login.oauth.get-url-failed': '승인 링크를 얻지 못했습니다.', + 'user.login.subtitle': '글로벌 시장에 대한 정량적 통찰력 확보', + 'user.login.legal.title': '법적 고지 사항', + 'user.login.legal.view': '법적 고지사항 보기', + 'user.login.legal.collapse': '법적 면책조항 접기', + 'user.login.legal.agree': '법적 고지 사항을 읽었으며 이에 동의합니다.', + 'user.login.legal.required': '먼저 법적 고지 사항을 읽고 확인하십시오.', + 'user.login.legal.content': 'QuantDinger는 AI 멀티에이전트 주식 분석 보조 도구로 증권 투자 컨설팅 자격이 없습니다. 플랫폼 내 모든 분석 결과, 등급, 참고 의견은 과거 데이터를 기반으로 AI에 의해 자동으로 생성되며 학습, 연구 및 기술 교류에만 사용되며 투자 조언이나 의사 결정 기반을 구성하지 않습니다. 주식투자에는 시장리스크, 유동성리스크, 정책리스크 등 다양한 리스크가 수반되며, 이로 인해 원금 손실이 발생할 수 있습니다. 사용자는 자신의 위험 허용 범위에 따라 독립적인 결정을 내려야 하며, 이 도구의 사용으로 인해 발생하는 모든 투자 행위 및 결과는 사용자가 부담해야 합니다. 시장은 위험하므로 투자에 신중해야 합니다.', + 'user.login.privacy.title': '사용자 개인 정보 보호 정책', + 'user.login.privacy.view': '사용자 개인 정보 보호 정책 보기', + 'user.login.privacy.collapse': '사용자 개인정보 보호 약관 닫기', + 'user.login.privacy.content': '우리는 귀하의 개인 정보 보호와 데이터 보호를 중요하게 생각합니다. 1) 수집 범위 : 기능 구현에 꼭 필요한 정보(이메일, 휴대전화번호, 지역번호, Web3 지갑 주소 등)와 필수 로그, 기기정보만 수집합니다. 2) 이용 목적: 계정 로그인 및 보안 확인, 서비스 기능 제공, 문제 해결 및 규정 준수 요구 사항. 3) 저장 및 보안: 데이터는 암호화되어 저장되며, 무단 접근, 공개 또는 손실을 방지하기 위해 필요한 권한 및 접근 제어 조치가 취해집니다. 4) 제3자와의 공유: 법률 및 규정에 의해 요구되거나 서비스 수행에 필요한 경우를 제외하고, 귀하의 개인정보는 제3자와 공유되지 않습니다. 제3자 서비스(예: 지갑, SMS 서비스 제공업체)가 관련된 경우 해당 기능을 구현하는 데 필요한 최소한의 범위에서만 처리됩니다. 5) 쿠키/로컬 저장소: 로그인 상태 및 필요한 세션 유지 관리(예: 토큰, PHPSESSID)에 사용되며 브라우저에서 이를 삭제하거나 제한할 수 있습니다. 6) 개인권리 : 귀하는 법령에 따른 조회, 정정, 삭제, 동의철회 등의 권리를 행사할 수 있습니다. 7) 변경 및 공지: 본 약관이 업데이트되면 페이지에 눈에 띄게 표시됩니다. 본 서비스를 계속 이용하시면 업데이트된 내용을 읽고 동의하신 것으로 간주됩니다. 본 약관 또는 그에 대한 업데이트에 동의하지 않는 경우 서비스 사용을 중단하고 당사에 문의하십시오.', + 'account.basicInfo': '기본정보', + 'account.id': '사용자 ID', + 'account.username': '사용자 이름', + 'account.nickname': '닉네임', + 'account.email': '이메일', + 'account.mobile': '휴대전화번호', + 'account.web3address': '지갑 주소', + 'account.pid': '추천인 ID', + 'account.level': '사용자 수준', + 'account.money': '균형', + 'account.qdtBalance': 'QDT 잔액', + 'account.score': '포인트', + 'account.createtime': '등록 시간', + 'account.inviteLink': '초대링크', + 'account.recharge': '재충전', + 'account.rechargeTip': '충전하려면 WeChat 또는 Alipay를 사용하여 아래 QR 코드를 스캔하세요.', + 'account.qrCodePlaceholder': 'QR 코드 자리 표시자(에뮬레이션)', + 'account.rechargeAmount': '충전금액', + 'account.enterAmount': '충전금액을 입력해주세요', + 'account.rechargeHint': '최소 입금액: 1 QDT', + 'account.confirmRecharge': '충전 확인', + 'account.enterValidAmount': '유효한 충전 금액을 입력하세요.', + 'account.rechargeSuccess': '재충전 성공! {amount} QDT 입금', + 'account.settings.menuMap.basic': '기본 설정', + 'account.settings.menuMap.security': '보안 설정', + 'account.settings.menuMap.notification': '새 메시지 알림', + 'account.settings.menuMap.moneyLog': '기금 세부정보', + 'account.moneyLog.empty': '아직 펀드 세부정보가 없습니다.', + 'account.moneyLog.total': '총 {total}개 기록', + 'account.moneyLog.type.purchase': '매수 지표', + 'account.moneyLog.type.recharge': '재충전', + 'account.moneyLog.type.refund': '환불', + 'account.moneyLog.type.reward': '보상', + 'account.moneyLog.type.income': '지표소득', + 'account.moneyLog.type.commission': '플랫폼 취급 수수료', + 'wallet.balance': 'QDT 잔액', + 'wallet.recharge': '재충전', + 'wallet.withdraw': '현금 인출', + 'wallet.filter': '필터', + 'wallet.reset': '재설정', + 'wallet.totalRecharge': '누적 충전', + 'wallet.totalWithdraw': '누적 출금', + 'wallet.totalIncome': '누적 수입', + 'wallet.records': '거래기록 및 자금내역', + 'wallet.tradingRecords': '거래 내역', + 'wallet.moneyLog': '기금 세부정보', + 'wallet.rechargeTip': '충전하려면 WeChat 또는 Alipay를 사용하여 아래 QR 코드를 스캔하세요.', + 'wallet.qrCodePlaceholder': 'QR 코드 자리 표시자(에뮬레이션)', + 'wallet.rechargeAmount': '충전금액', + 'wallet.enterAmount': '충전금액을 입력해주세요', + 'wallet.rechargeHint': '최소 입금액: 1 QDT', + 'wallet.confirmRecharge': '충전 확인', + 'wallet.enterValidAmount': '유효한 충전 금액을 입력하세요.', + 'wallet.rechargeSuccess': '재충전 성공! {amount} QDT 입금', + 'wallet.rechargeFailed': '재충전 실패', + 'wallet.withdrawTip': '출금금액과 출금주소를 입력해주세요', + 'wallet.withdrawAmount': '출금금액', + 'wallet.enterWithdrawAmount': '출금금액을 입력해주세요', + 'wallet.withdrawHint': '최소 출금 금액: 1 QDT', + 'wallet.withdrawAddress': '출금주소(선택)', + 'wallet.enterWithdrawAddress': '출금주소를 입력해주세요', + 'wallet.confirmWithdraw': '출금 확인', + 'wallet.enterValidWithdrawAmount': '유효한 인출 금액을 입력하세요.', + 'wallet.insufficientBalance': '잔액 부족', + 'wallet.withdrawSuccess': '출금 성공! 인출된 {금액} QDT', + 'wallet.withdrawFailed': '출금 실패', + 'wallet.noTradingRecords': '아직 거래기록이 없습니다', + 'wallet.noMoneyLog': '아직 펀드 세부정보가 없습니다.', + 'wallet.loadTradingRecordsFailed': '거래 기록을 로드하지 못했습니다.', + 'wallet.loadMoneyLogFailed': '자금 세부정보를 로드하지 못했습니다.', + 'wallet.moneyLogTotal': '총 {total}개 기록', + 'wallet.moneyLogTypeTitle': '유형', + 'wallet.moneyLogType.all': '모든 유형', + 'wallet.table.time': '시간', + 'wallet.table.type': '유형', + 'wallet.table.price': '가격', + 'wallet.table.amount': '수량', + 'wallet.table.money': '금액', + 'wallet.table.balance': '균형', + 'wallet.table.memo': '비고', + 'wallet.table.value': '가치', + 'wallet.table.profit': '이익과 손실', + 'wallet.table.commission': '취급 수수료', + 'wallet.table.total': '총 {total}개 기록', + 'wallet.tradeType.buy': '사다', + 'wallet.tradeType.sell': '팔다', + 'wallet.tradeType.liquidation': '강제청산', + 'wallet.tradeType.openLong': '길게 열다', + 'wallet.tradeType.addLong': '가돗', + 'wallet.tradeType.closeLong': '핀듀오', + 'wallet.tradeType.closeLongStop': '손실을 멈추고 오랫동안', + 'wallet.tradeType.closeLongProfit': '이익을 얻고 더 많은 레벨을 올리세요', + 'wallet.tradeType.openShort': '오픈 쇼트', + 'wallet.tradeType.addShort': '짧게 추가하다', + 'wallet.tradeType.closeShort': '비어 있음', + 'wallet.tradeType.closeShortStop': '손실 중지', + 'wallet.tradeType.closeShortProfit': '이익을 얻고 공매도 마감', + 'wallet.moneyLogType.purchase': '매수 지표', + 'wallet.moneyLogType.recharge': '재충전', + 'wallet.moneyLogType.withdraw': '현금 인출', + 'wallet.moneyLogType.refund': '환불', + 'wallet.moneyLogType.reward': '보상', + 'wallet.moneyLogType.income': '소득', + 'wallet.moneyLogType.commission': '취급 수수료', + 'wallet.web3Address.required': '먼저 Web3 지갑 주소를 입력해주세요', + 'wallet.web3Address.requiredDescription': '충전하기 전에 Web3 지갑 주소를 바인딩해야 충전을 받을 수 있습니다.', + 'wallet.web3Address.placeholder': 'Web3 지갑 주소(0x로 시작)를 입력하세요.', + 'wallet.web3Address.save': '지갑 주소 저장', + 'wallet.web3Address.saveSuccess': '지갑 주소가 성공적으로 저장되었습니다', + 'wallet.web3Address.saveFailed': '지갑 주소 저장 실패', + 'wallet.web3Address.invalidFormat': '유효한 Web3 지갑 주소를 입력하세요. (이더리움 형식: 0x로 시작하는 42자 또는 TRC20 형식: T로 시작하는 34자)', + 'wallet.selectCoin': '통화 선택', + 'wallet.selectChain': '선택 체인', + 'wallet.chain.eth': 'ETH(ERC20)', + 'wallet.chain.trc20': 'TRC20', + 'wallet.chain.bsc': 'BSC', + 'wallet.targetQdtAmount': '상환하려는 QDT 금액(선택 사항)', + 'wallet.targetQdtAmount.placeholder': '상환하려는 QDT 수량, 현재 QDT 가격을 입력하세요: {price} USDT', + 'wallet.targetQdtAmount.requiredUsdt': '재충전 필요: {amount} USDT', + 'wallet.rechargeAddress': '충전 주소', + 'wallet.copyAddress': '복사', + 'wallet.copySuccess': '성공적으로 복사되었습니다!', + 'wallet.copyFailed': '복사하지 못했습니다. 수동으로 복사하세요.', + 'wallet.rechargeAddressHint': '이 주소에 {coin}을 입금하려면 반드시 {chain}을 사용하세요.', + 'wallet.qdtPrice.loading': '로드 중...', + 'wallet.rechargeTip.new': '통화와 체인을 선택한 후 아래 QR 코드를 스캔하여 충전하세요.', + 'wallet.exchangeRate': '1 QDT ≒ {rate} USDT', + 'wallet.withdrawableAmount': '사용 가능한 현금 금액', + 'wallet.totalBalance': '총 잔액', + 'wallet.insufficientWithdrawable': '출금 가능한 현금 금액이 부족합니다. 현재 인출할 수 있는 금액은 다음과 같습니다: {amount} QDT', + 'wallet.withdrawAddressRequired': '출금주소를 입력해주세요', + 'wallet.withdrawAddressHint': '주소가 올바른지 확인하십시오. 탈퇴 후에는 취소가 불가능합니다.', + 'wallet.withdrawSubmitSuccess': '철회 신청서가 성공적으로 제출되었습니다. 검토를 기다려 주세요.', + 'menu.footer.contactUs': '문의하기', + 'menu.footer.getSupport': '지원 받기', + 'menu.footer.socialAccounts': '소셜 계정', + 'menu.footer.userAgreement': '사용자 계약', + 'menu.footer.privacyPolicy': '개인 정보 보호 정책', + 'menu.footer.support': '지원', + 'menu.footer.featureRequest': '기능 요청', + 'menu.footer.email': '이메일', + 'menu.footer.liveChat': '연중무휴 라이브 채팅', + 'dashboard.analysis.title': '다차원 분석', + 'dashboard.analysis.subtitle': 'AI 기반 종합재무분석 플랫폼', + 'dashboard.analysis.selectSymbol': '기본 코드 선택 또는 입력', + 'dashboard.analysis.selectModel': '모델 선택', + 'dashboard.analysis.startAnalysis': '분석 시작', + 'dashboard.analysis.history': '역사', + 'dashboard.analysis.tab.overview': '종합적인 분석', + 'dashboard.analysis.tab.fundamental': '기초', + 'dashboard.analysis.tab.technical': '기술', + 'dashboard.analysis.tab.news': '뉴스', + 'dashboard.analysis.tab.sentiment': '감정', + 'dashboard.analysis.tab.risk': '위험', + 'dashboard.analysis.tab.debate': '길고 짧은 논쟁', + 'dashboard.analysis.tab.decision': '최종 결정', + 'dashboard.analysis.empty.selectSymbol': '분석을 시작하려면 대상을 선택하세요.', + 'dashboard.analysis.empty.selectSymbolDesc': '자체 선택한 주식 목록에서 선택하거나 기본 코드를 입력하여 다차원 AI 분석 보고서를 얻습니다.', + 'dashboard.analysis.empty.startAnalysis': '다차원 분석을 수행하려면 "분석 시작" 버튼을 클릭하세요.', + 'dashboard.analysis.empty.startAnalysisDesc': '펀더멘털, 기술, 뉴스, 정서, 리스크 등 다차원에서 종합적인 분석을 제공해드립니다.', + 'dashboard.analysis.empty.noData': '현재 {type} 분석 데이터가 없습니다. 먼저 종합적인 분석을 수행해 주세요.', + 'dashboard.analysis.empty.noWatchlist': '현재 자체선택한 종목이 없습니다.', + 'dashboard.analysis.empty.noHistory': '아직 기록이 없습니다.', + 'dashboard.analysis.empty.watchlistHint': '현재 자체 선택한 주식이 없습니다. 먼저 문의해 주세요.', + 'dashboard.analysis.empty.noDebateData': '아직 토론 데이터가 없습니다.', + 'dashboard.analysis.empty.noDecisionData': '아직 결정 데이터가 없습니다.', + 'dashboard.analysis.empty.selectAgent': '분석 결과를 보려면 에이전트를 선택하세요.', + 'dashboard.analysis.loading.analyzing': '분석 중입니다. 잠시 기다려 주세요...', + 'dashboard.analysis.loading.fundamental': '기본 데이터 분석 중...', + 'dashboard.analysis.loading.technical': '기술 지표 분석 중...', + 'dashboard.analysis.loading.news': '뉴스 데이터 분석 중...', + 'dashboard.analysis.loading.sentiment': '시장 심리 분석 중…', + 'dashboard.analysis.loading.risk': '위험 평가 중...', + 'dashboard.analysis.loading.debate': '롱숏 논쟁이 진행중인데..', + 'dashboard.analysis.loading.decision': '최종 결정을 내리는 중...', + 'dashboard.analysis.score.overall': '종합평가', + 'dashboard.analysis.score.recommendation': '투자 조언', + 'dashboard.analysis.score.confidence': '자신감', + 'dashboard.analysis.dimension.fundamental': '기초', + 'dashboard.analysis.dimension.technical': '기술', + 'dashboard.analysis.dimension.news': '뉴스', + 'dashboard.analysis.dimension.sentiment': '감정', + 'dashboard.analysis.dimension.risk': '위험', + 'dashboard.analysis.card.dimensionScores': '각 차원에 대한 평가', + 'dashboard.analysis.card.overviewReport': '종합분석보고서', + 'dashboard.analysis.card.financialMetrics': '재무 지표', + 'dashboard.analysis.card.fundamentalReport': '기초분석 보고서', + 'dashboard.analysis.card.technicalIndicators': '기술 지표', + 'dashboard.analysis.card.technicalReport': '기술적 분석 보고서', + 'dashboard.analysis.card.newsList': '관련 뉴스', + 'dashboard.analysis.card.newsReport': '뉴스 분석 보고서', + 'dashboard.analysis.card.sentimentIndicators': '감정 지표', + 'dashboard.analysis.card.sentimentReport': '감정 분석 보고서', + 'dashboard.analysis.card.riskMetrics': '위험 지표', + 'dashboard.analysis.card.riskReport': '위험 평가 보고서', + 'dashboard.analysis.card.bullView': '강세 전망(강세)', + 'dashboard.analysis.card.bearView': '약세 전망 (곰)', + 'dashboard.analysis.card.researchConclusion': '연구원의 결론', + 'dashboard.analysis.card.traderPlan': '상인 계획', + 'dashboard.analysis.card.riskDebate': '위험위원회 토론', + 'dashboard.analysis.card.finalDecision': '최종 결정', + 'dashboard.analysis.card.tradePlanDetail': '거래 계획 세부정보', + 'dashboard.analysis.tradingPlan.entry_price': '입장료', + 'dashboard.analysis.tradingPlan.position_size': '위치 크기', + 'dashboard.analysis.tradingPlan.stop_loss': '손실을 막다', + 'dashboard.analysis.tradingPlan.take_profit': '이익을 얻으세요', + 'dashboard.analysis.label.confidence': '자신감', + 'dashboard.analysis.label.keyPoints': '핵심 포인트', + 'dashboard.analysis.label.riskWarning': '위험 경고', + 'dashboard.analysis.risk.risky': '위험하다', + 'dashboard.analysis.risk.neutral': '중립적 관점 (Neutral)', + 'dashboard.analysis.risk.safe': '보수적 관점(안전)', + 'dashboard.analysis.risk.conclusion': '결론', + 'dashboard.analysis.feature.fundamental': '기본적 분석', + 'dashboard.analysis.feature.technical': '기술적 분석', + 'dashboard.analysis.feature.news': '뉴스 분석', + 'dashboard.analysis.feature.sentiment': '감정 분석', + 'dashboard.analysis.feature.risk': '위험 평가', + 'dashboard.analysis.watchlist.title': '나의 주식 선택', + 'dashboard.analysis.watchlist.add': '추가하다', + 'dashboard.analysis.watchlist.addStock': '재고 추가', + 'dashboard.analysis.modal.addStock.title': '옵션 재고 추가', + 'dashboard.analysis.modal.addStock.confirm': '알았어', + 'dashboard.analysis.modal.addStock.cancel': '취소', + 'dashboard.analysis.modal.addStock.market': '시장 유형', + 'dashboard.analysis.modal.addStock.marketPlaceholder': '시장을 선택해주세요', + 'dashboard.analysis.modal.addStock.marketRequired': '시장 유형을 선택하세요.', + 'dashboard.analysis.modal.addStock.symbol': '주식 코드', + 'dashboard.analysis.modal.addStock.symbolPlaceholder': '예: AAPL, TSLA, GOOGL, 000001, BTC', + 'dashboard.analysis.modal.addStock.symbolRequired': '주식코드를 입력해주세요', + 'dashboard.analysis.modal.addStock.searchPlaceholder': '대상 코드 또는 이름 검색', + 'dashboard.analysis.modal.addStock.searchOrInputPlaceholder': '기본 코드를 검색하거나 입력하세요(예: AAPL, BTC/USDT, EUR/USD)', + 'dashboard.analysis.modal.addStock.searchOrInputHint': '데이터베이스에서 객체 검색 또는 코드 직접 입력 지원(시스템이 자동으로 이름을 얻음)', + 'dashboard.analysis.modal.addStock.search': '검색', + 'dashboard.analysis.modal.addStock.searchResults': '검색결과', + 'dashboard.analysis.modal.addStock.hotSymbols': '인기 있는 타겟', + 'dashboard.analysis.modal.addStock.noHotSymbols': '아직 인기 있는 타겟이 없습니다.', + 'dashboard.analysis.modal.addStock.selectedSymbol': '선택됨', + 'dashboard.analysis.modal.addStock.pleaseSelectSymbol': '먼저 대상을 선택하세요.', + 'dashboard.analysis.modal.addStock.pleaseSelectOrEnterSymbol': '대상을 선택하거나 대상 코드를 먼저 입력하세요', + 'dashboard.analysis.modal.addStock.pleaseEnterSymbol': '타겟코드를 입력해주세요', + 'dashboard.analysis.modal.addStock.pleaseSelectMarket': '먼저 시장 유형을 선택하세요.', + 'dashboard.analysis.modal.addStock.searchFailed': '검색에 실패했습니다. 나중에 다시 시도해 주세요.', + 'dashboard.analysis.modal.addStock.noSearchResults': '일치하는 대상이 없습니다.', + 'dashboard.analysis.modal.addStock.willAutoFetchName': '시스템에서 자동으로 이름을 가져옵니다.', + 'dashboard.analysis.modal.addStock.addDirectly': '직접 추가', + 'dashboard.analysis.modal.addStock.nameWillBeFetched': '추가되면 이름이 자동으로 선택됩니다.', + 'dashboard.analysis.market.AShare': 'A주', + 'dashboard.analysis.market.USStock': '미국 주식', + 'dashboard.analysis.market.HShare': '홍콩 주식', + 'dashboard.analysis.market.Crypto': '암호화폐', + 'dashboard.analysis.market.Forex': '외환', + 'dashboard.analysis.market.Futures': '선물', + 'dashboard.analysis.modal.history.title': '역사적 분석 기록', + 'dashboard.analysis.modal.history.viewResult': '결과 보기', + 'dashboard.analysis.modal.history.completeTime': '완료 시간', + 'dashboard.analysis.modal.history.error': '오류', + 'dashboard.analysis.status.pending': '보류 중', + 'dashboard.analysis.status.processing': '처리', + 'dashboard.analysis.status.completed': '완료됨', + 'dashboard.analysis.status.failed': '실패했다', + 'dashboard.analysis.message.selectSymbol': '대상을 먼저 선택해주세요', + 'dashboard.analysis.message.taskCreated': '분석 작업이 생성되어 백그라운드에서 실행 중입니다...', + 'dashboard.analysis.message.analysisComplete': '분석 완료', + 'dashboard.analysis.message.analysisCompleteCache': '분석 완료(캐시된 데이터 활용)', + 'dashboard.analysis.message.analysisFailed': '분석에 실패했습니다. 나중에 다시 시도해 주세요.', + 'dashboard.analysis.message.addStockSuccess': '성공적으로 추가되었습니다', + 'dashboard.analysis.message.addStockFailed': '추가 실패', + 'dashboard.analysis.message.removeStockSuccess': '성공적으로 제거되었습니다', + 'dashboard.analysis.message.removeStockFailed': '제거 실패', + 'dashboard.analysis.test': '상점 번호 {no}, Gongzhuan Road', + 'dashboard.analysis.introduce': '표시기 설명', + 'dashboard.analysis.total-sales': '총매출', + 'dashboard.analysis.day-sales': '일평균 매출엔엔', + 'dashboard.analysis.visits': '방문', + 'dashboard.analysis.visits-trend': '트래픽 동향', + 'dashboard.analysis.visits-ranking': '매장 방문 순위', + 'dashboard.analysis.day-visits': '일일 방문', + 'dashboard.analysis.week': '매주 전년 대비', + 'dashboard.analysis.day': '전년 대비', + 'dashboard.analysis.payments': '결제 횟수', + 'dashboard.analysis.conversion-rate': '전환율', + 'dashboard.analysis.operational-effect': '운영활동 효과', + 'dashboard.analysis.sales-trend': '판매 동향', + 'dashboard.analysis.sales-ranking': '매장판매순위', + 'dashboard.analysis.all-year': '일년 내내', + 'dashboard.analysis.all-month': '이번 달', + 'dashboard.analysis.all-week': '이번 주', + 'dashboard.analysis.all-day': '오늘', + 'dashboard.analysis.search-users': '검색 사용자 수', + 'dashboard.analysis.per-capita-search': '1인당 검색량', + 'dashboard.analysis.online-top-search': '온라인 인기 검색어', + 'dashboard.analysis.the-proportion-of-sales': '판매 카테고리 비율', + 'dashboard.analysis.dropdown-option-one': '첫 번째 작전', + 'dashboard.analysis.dropdown-option-two': '작전 2', + 'dashboard.analysis.channel.all': '모든 채널', + 'dashboard.analysis.channel.online': '온라인', + 'dashboard.analysis.channel.stores': '저장', + 'dashboard.analysis.sales': '판매', + 'dashboard.analysis.traffic': '승객 흐름', + 'dashboard.analysis.table.rank': '순위', + 'dashboard.analysis.table.search-keyword': '키워드 검색', + 'dashboard.analysis.table.users': '사용자 수', + 'dashboard.analysis.table.weekly-range': '주간 증가', + 'dashboard.indicator.selectSymbol': '기본 코드 선택 또는 입력', + 'dashboard.indicator.emptyWatchlistHint': '현재 자체 선택한 주식이 없습니다. 먼저 추가해 주세요.', + 'dashboard.indicator.hint.selectSymbol': '분석을 시작하려면 대상을 선택하세요.', + 'dashboard.indicator.hint.selectSymbolDesc': 'K-라인 차트 및 기술 지표를 보려면 위 검색창에서 주식 코드를 선택하거나 입력하세요.', + 'dashboard.indicator.retry': '다시 시도하세요', + 'dashboard.indicator.panel.title': '기술 지표', + 'dashboard.indicator.panel.realtimeOn': '실시간 업데이트 끄기', + 'dashboard.indicator.panel.realtimeOff': '실시간 업데이트 활성화', + 'dashboard.indicator.panel.themeLight': '어두운 테마로 전환', + 'dashboard.indicator.panel.themeDark': '밝은 테마로 전환', + 'dashboard.indicator.section.enabled': '활성화됨', + 'dashboard.indicator.section.added': '내가 추가한 지표', + 'dashboard.indicator.section.custom': '직접 만든 지표', + 'dashboard.indicator.section.bought': '내가 구매한 지표', + 'dashboard.indicator.section.myCreated': '내가 만든 지표', + 'dashboard.indicator.empty': '아직 표시기가 없습니다. 먼저 표시기를 추가하거나 생성하세요.', + 'dashboard.indicator.buy': '매수 지표', + 'dashboard.indicator.action.start': '시작', + 'dashboard.indicator.action.stop': '닫기', + 'dashboard.indicator.action.edit': '편집', + 'dashboard.indicator.action.delete': '삭제', + 'dashboard.indicator.action.backtest': '백테스트', + 'dashboard.indicator.status.normal': '정상', + 'dashboard.indicator.status.normalPermanent': '일반(영구적으로 유효)', + 'dashboard.indicator.status.expired': '만료됨', + 'dashboard.indicator.expiry.permanent': '영구적으로 유효함', + 'dashboard.indicator.expiry.noExpiry': '만료 시간 없음', + 'dashboard.indicator.expiry.expired': '만료됨: {date}', + 'dashboard.indicator.expiry.expiresOn': '만료 시간: {date}', + 'dashboard.indicator.delete.confirmTitle': '삭제 확인', + 'dashboard.indicator.delete.confirmContent': '"{name}" 표시기를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', + 'dashboard.indicator.delete.confirmOk': '삭제', + 'dashboard.indicator.delete.confirmCancel': '취소', + 'dashboard.indicator.delete.success': '성공적으로 삭제되었습니다', + 'dashboard.indicator.delete.failed': '삭제 실패', + 'dashboard.indicator.save.success': '성공적으로 저장되었습니다', + 'dashboard.indicator.save.failed': '저장 실패', + 'dashboard.indicator.error.loadWatchlistFailed': '옵션 재고를 로드하지 못했습니다.', + 'dashboard.indicator.error.chartNotReady': '차트 구성 요소가 초기화되지 않았습니다. 먼저 대상을 선택하고 차트가 로드될 때까지 기다려 주세요.', + 'dashboard.indicator.error.chartMethodNotReady': '차트 구성 요소 메서드가 준비되지 않았습니다. 나중에 다시 시도해 주세요.', + 'dashboard.indicator.error.chartExecuteNotReady': '차트 구성요소 실행 방법이 준비되지 않았습니다. 나중에 다시 시도해 주세요.', + 'dashboard.indicator.error.parseFailed': 'Python 코드를 구문 분석할 수 없습니다.', + 'dashboard.indicator.error.parseFailedCheck': 'Python 코드를 구문 분석할 수 없습니다. 코드 형식을 확인하세요.', + 'dashboard.indicator.error.addIndicatorFailed': '표시기를 추가하지 못했습니다.', + 'dashboard.indicator.error.runIndicatorFailed': '실행 표시기 실패', + 'dashboard.indicator.error.pleaseLogin': '먼저 로그인해주세요', + 'dashboard.indicator.error.loadDataFailed': '데이터 로딩 실패', + 'dashboard.indicator.error.loadDataFailedDesc': '네트워크 연결을 확인해주세요', + 'dashboard.indicator.error.pythonEngineFailed': 'Python 엔진을 로드하지 못했고 표시기 기능을 사용하지 못할 수 있습니다.', + 'dashboard.indicator.error.chartInitFailed': '차트 초기화 실패', + 'dashboard.indicator.warning.enterCode': '표시기 코드를 먼저 입력하세요.', + 'dashboard.indicator.warning.pyodideLoadFailed': 'Python 엔진을 로드하지 못했습니다.', + 'dashboard.indicator.warning.pyodideLoadFailedDesc': '현재 지역 또는 네트워크 환경에서는 이 기능을 사용할 수 없습니다.', + 'dashboard.indicator.warning.chartNotInitialized': '차트가 초기화되지 않아 선 그리기 도구를 사용할 수 없습니다.', + 'dashboard.indicator.success.runIndicator': '표시기가 성공적으로 실행됩니다.', + 'dashboard.indicator.success.clearDrawings': '모든 선화 삭제됨', + 'dashboard.indicator.sma': 'SMA(이동 평균 조합)', + 'dashboard.indicator.ema': 'EMA(지수 이동 평균 조합)', + 'dashboard.indicator.rsi': 'RSI(상대강도)', + 'dashboard.indicator.macd': 'MACD', + 'dashboard.indicator.bb': '볼린저 밴드', + 'dashboard.indicator.atr': 'ATR(평균 실제 범위)', + 'dashboard.indicator.cci': 'CCI(상품 채널 지수)', + 'dashboard.indicator.williams': 'Williams %R(윌리엄스 지표)', + 'dashboard.indicator.mfi': 'MFI(자금 흐름 지수)', + 'dashboard.indicator.adx': 'ADX(평균 추세 지수)', + 'dashboard.indicator.obv': 'OBV(에너지파)', + 'dashboard.indicator.adosc': 'ADOSC(누적/디스패치 오실레이터)', + 'dashboard.indicator.ad': 'AD(집적/분배선)', + 'dashboard.indicator.kdj': 'KDJ(확률적 지표)', + 'dashboard.indicator.signal.buy': '구매', + 'dashboard.indicator.signal.sell': '판매', + 'dashboard.indicator.signal.supertrendBuy': '슈퍼트렌드 구매', + 'dashboard.indicator.signal.supertrendSell': '슈퍼트렌드 매도', + 'dashboard.indicator.chart.kline': 'K라인', + 'dashboard.indicator.chart.volume': '볼륨', + 'dashboard.indicator.chart.uptrend': '상승 추세', + 'dashboard.indicator.chart.downtrend': '하락 추세', + 'dashboard.indicator.tooltip.time': '시간', + 'dashboard.indicator.tooltip.open': '열다', + 'dashboard.indicator.tooltip.close': '받다', + 'dashboard.indicator.tooltip.high': '높다', + 'dashboard.indicator.tooltip.low': '낮음', + 'dashboard.indicator.tooltip.volume': '볼륨', + 'dashboard.indicator.drawing.line': '선분', + 'dashboard.indicator.drawing.horizontalLine': '수평선', + 'dashboard.indicator.drawing.verticalLine': '수직선', + 'dashboard.indicator.drawing.ray': '레이', + 'dashboard.indicator.drawing.straightLine': '직선', + 'dashboard.indicator.drawing.parallelLine': '평행선', + 'dashboard.indicator.drawing.priceLine': '가격선', + 'dashboard.indicator.drawing.priceChannel': '가격 채널', + 'dashboard.indicator.drawing.fibonacciLine': '피보나치 라인', + 'dashboard.indicator.drawing.clearAll': '모든 선화 지우기', + 'dashboard.indicator.market.AShare': 'A주', + 'dashboard.indicator.market.USStock': '미국 주식', + 'dashboard.indicator.market.HShare': '홍콩 주식', + 'dashboard.indicator.market.Crypto': '암호화폐', + 'dashboard.indicator.market.Forex': '외환', + 'dashboard.indicator.market.Futures': '선물', + 'dashboard.indicator.create': '지표 생성', + 'dashboard.indicator.editor.title': '지표 생성/편집', + 'dashboard.indicator.editor.name': '지표 이름', + 'dashboard.indicator.editor.nameRequired': '지표 이름을 입력하세요.', + 'dashboard.indicator.editor.namePlaceholder': '지표 이름을 입력하세요.', + 'dashboard.indicator.editor.description': '표시기 설명', + 'dashboard.indicator.editor.descriptionPlaceholder': '표시기에 대한 설명을 입력하세요(선택 사항).', + 'dashboard.indicator.editor.code': '파이썬 코드', + 'dashboard.indicator.editor.codeRequired': '표시 코드를 입력하세요', + 'dashboard.indicator.editor.codePlaceholder': 'Python 코드를 입력하세요.', + 'dashboard.indicator.editor.run': '달리다', + 'dashboard.indicator.editor.runHint': '실행 버튼을 클릭하면 K-라인 차트의 지표 효과를 미리 볼 수 있습니다.', + 'dashboard.indicator.editor.guide': '개발 가이드', + 'dashboard.indicator.editor.guideTitle': 'Python 지표 개발 가이드', + 'dashboard.indicator.editor.save': '저장', + 'dashboard.indicator.editor.cancel': '취소', + 'dashboard.indicator.editor.unnamed': '이름 없는 표시기', + 'dashboard.indicator.editor.publishToCommunity': '커뮤니티에 게시', + 'dashboard.indicator.editor.publishToCommunityHint': '게시되면 다른 사용자가 커뮤니티에서 귀하의 지표를 보고 사용할 수 있습니다.', + 'dashboard.indicator.editor.indicatorType': '표시기 유형', + 'dashboard.indicator.editor.indicatorTypeRequired': '지표 유형을 선택하세요.', + 'dashboard.indicator.editor.indicatorTypePlaceholder': '지표 유형을 선택하세요.', + 'dashboard.indicator.editor.previewImage': '미리보기', + 'dashboard.indicator.editor.uploadImage': '사진 업로드', + 'dashboard.indicator.editor.previewImageHint': '권장 크기: 800x400, 2MB 이하', + 'dashboard.indicator.editor.pricing': '판매가(QDT)', + 'dashboard.indicator.editor.pricingType.free': '무료', + 'dashboard.indicator.editor.pricingType.permanent': '영구', + 'dashboard.indicator.editor.pricingType.monthly': '월별 결제', + 'dashboard.indicator.editor.price': '가격', + 'dashboard.indicator.editor.priceRequired': '가격을 입력해주세요', + 'dashboard.indicator.editor.pricePlaceholder': '가격을 입력해주세요', + 'dashboard.indicator.editor.pricingHint': '무료 지표의 가격은 0이지만 플랫폼은 지표 사용량과 칭찬률에 따라 보상(QDT)을 제공합니다.', + 'dashboard.indicator.editor.aiGenerate': '지능형 세대', + 'dashboard.indicator.editor.aiPromptPlaceholder': '당신의 아이디어를 알려주시면 Python 표시기 코드를 생성해 드리겠습니다.', + 'dashboard.indicator.editor.aiGenerateBtn': 'AI 생성 코드', + 'dashboard.indicator.editor.aiPromptRequired': '당신의 생각을 입력해주세요', + 'dashboard.indicator.editor.aiGenerateSuccess': '코드 생성 성공', + 'dashboard.indicator.editor.aiGenerateError': '코드 생성에 실패했습니다. 나중에 다시 시도해 주세요.', + 'dashboard.indicator.backtest.title': '지표 백테스트', + 'dashboard.indicator.backtest.config': '백테스트 매개변수', + 'dashboard.indicator.backtest.startDate': '시작일', + 'dashboard.indicator.backtest.endDate': '종료일', + 'dashboard.indicator.backtest.selectStartDate': '시작일 선택', + 'dashboard.indicator.backtest.selectEndDate': '종료일 선택', + 'dashboard.indicator.backtest.startDateRequired': '시작일을 선택하세요.', + 'dashboard.indicator.backtest.endDateRequired': '종료일을 선택하세요.', + 'dashboard.indicator.backtest.initialCapital': '초기자본금', + 'dashboard.indicator.backtest.initialCapitalRequired': '초기자금을 입력해주세요', + 'dashboard.indicator.backtest.commission': '수수료', + 'dashboard.indicator.backtest.leverage': '레버리지 비율', + 'dashboard.indicator.backtest.tradeDirection': '거래 방향', + 'dashboard.indicator.backtest.longOnly': '오래 가세요', + 'dashboard.indicator.backtest.shortOnly': '짧은', + 'dashboard.indicator.backtest.both': '양방향', + 'dashboard.indicator.backtest.run': '백테스팅 시작', + 'dashboard.indicator.backtest.rerun': '다시 백테스트', + 'dashboard.indicator.backtest.close': '닫기', + 'dashboard.indicator.backtest.running': '지표 백테스트 진행 중...', + 'dashboard.indicator.backtest.results': '백테스트 결과', + 'dashboard.indicator.backtest.totalReturn': '총 수익', + 'dashboard.indicator.backtest.annualReturn': '연간 소득', + 'dashboard.indicator.backtest.maxDrawdown': '최대 감소', + 'dashboard.indicator.backtest.sharpeRatio': '샤프비율', + 'dashboard.indicator.backtest.winRate': '승률', + 'dashboard.indicator.backtest.profitFactor': '손익 비율', + 'dashboard.indicator.backtest.totalTrades': '거래수', + 'dashboard.indicator.totalReturn': '총 수익', + 'dashboard.indicator.annualReturn': '연간 수익률', + 'dashboard.indicator.maxDrawdown': '최대 감소', + 'dashboard.indicator.sharpeRatio': '샤프비율', + 'dashboard.indicator.winRate': '승률', + 'dashboard.indicator.profitFactor': '손익 비율', + 'dashboard.indicator.totalTrades': '총 거래 수', + 'dashboard.indicator.backtest.totalCommission': '총 취급 수수료', + 'dashboard.indicator.backtest.equityCurve': '수익률 곡선', + 'dashboard.indicator.backtest.strategy': '지표소득', + 'dashboard.indicator.backtest.benchmark': '벤치마크 수익률', + 'dashboard.indicator.backtest.tradeHistory': '거래 내역', + 'dashboard.indicator.backtest.tradeTime': '시간', + 'dashboard.indicator.backtest.tradeType': '유형', + 'dashboard.indicator.backtest.buy': '사다', + 'dashboard.indicator.backtest.sell': '팔다', + 'dashboard.indicator.backtest.liquidation': '청산', + 'dashboard.indicator.backtest.openLong': '길게 열다', + 'dashboard.indicator.backtest.closeLong': '핀듀오', + 'dashboard.indicator.backtest.closeLongStop': '매수에 가까움(손절매)', + 'dashboard.indicator.backtest.closeLongProfit': '더 닫기 (이익 실현)', + 'dashboard.indicator.backtest.addLong': '롱 포지션 추가', + 'dashboard.indicator.backtest.openShort': '오픈 쇼트', + 'dashboard.indicator.backtest.closeShort': '비어 있음', + 'dashboard.indicator.backtest.closeShortStop': '청산(손절매)', + 'dashboard.indicator.backtest.closeShortProfit': '마감(이익실현)', + 'dashboard.indicator.backtest.addShort': '숏 포지션 추가', + 'dashboard.indicator.backtest.price': '가격', + 'dashboard.indicator.backtest.amount': '수량', + 'dashboard.indicator.backtest.balance': '계좌잔고', + 'dashboard.indicator.backtest.profit': '이익과 손실', + 'dashboard.indicator.backtest.success': '백테스트 완료', + 'dashboard.indicator.backtest.failed': '백테스트에 실패했습니다. 나중에 다시 시도해 주세요.', + 'dashboard.indicator.backtest.noIndicatorCode': '이 지표에 대한 백테스트 가능한 코드가 없습니다.', + 'dashboard.indicator.backtest.noSymbol': '거래대상을 먼저 선택해주세요', + 'dashboard.indicator.backtest.dateRangeExceeded': '백테스트 시간 범위가 한도를 초과합니다. {timeframe} 기간은 최대 {maxRange}까지 백테스트할 수 있습니다.', + 'dashboard.indicator.backtest.dateRangeExceededDays': 'Backtest range exceeds limit: {timeframe} max {maxRange} ({maxDays} days)', + 'dashboard.indicator.backtest.metaLine': 'Symbol: {symbol} | Market: {market} | Timeframe: {timeframe}', + 'dashboard.indicator.backtest.savedRunId': 'Backtest saved. Run ID: {id}', + 'dashboard.indicator.backtest.prev': 'Previous', + 'dashboard.indicator.backtest.next': 'Next', + 'dashboard.indicator.backtest.hint.entryPctMax': 'Max entry: {maxPct}% (reserve budget for future scale-ins)', + 'dashboard.indicator.backtest.steps.strategy.title': 'Strategy', + 'dashboard.indicator.backtest.steps.strategy.desc': 'Position sizing & risk controls', + 'dashboard.indicator.backtest.steps.trading.title': 'Trading settings', + 'dashboard.indicator.backtest.steps.trading.desc': 'Time range, capital, fees, leverage', + 'dashboard.indicator.backtest.steps.results.title': 'Results', + 'dashboard.indicator.backtest.steps.results.desc': 'Backtest output', + 'dashboard.indicator.backtest.panel.risk': 'Risk management (SL/TP/Trailing)', + 'dashboard.indicator.backtest.panel.scale': 'Scale in / DCA (trend & mean-reversion)', + 'dashboard.indicator.backtest.panel.reduce': 'Reduce position (trend & adverse)', + 'dashboard.indicator.backtest.panel.position': 'Position sizing', + 'dashboard.indicator.backtest.field.stopLossPct': 'Stop Loss (%)', + 'dashboard.indicator.backtest.field.takeProfitPct': 'Take Profit (%)', + 'dashboard.indicator.backtest.field.trailingEnabled': 'Trailing stop / take profit', + 'dashboard.indicator.backtest.field.trailingStopPct': 'Trailing drawdown (%)', + 'dashboard.indicator.backtest.field.trailingActivationPct': 'Trailing activation (%)', + 'dashboard.indicator.backtest.field.slippage': 'Slippage (%)', + 'dashboard.indicator.backtest.field.trendAddEnabled': 'Trend-following scale-in', + 'dashboard.indicator.backtest.field.dcaAddEnabled': 'Mean-reversion scale-in', + 'dashboard.indicator.backtest.field.trendAddStepPct': 'Scale-in trigger (%)', + 'dashboard.indicator.backtest.field.dcaAddStepPct': 'Scale-in trigger (%)', + 'dashboard.indicator.backtest.field.trendAddSizePct': 'Scale-in size (% of capital)', + 'dashboard.indicator.backtest.field.dcaAddSizePct': 'Scale-in size (% of capital)', + 'dashboard.indicator.backtest.field.trendAddMaxTimes': 'Max scale-in times', + 'dashboard.indicator.backtest.field.dcaAddMaxTimes': 'Max scale-in times', + 'dashboard.indicator.backtest.field.trendReduceEnabled': 'Trend reduce', + 'dashboard.indicator.backtest.field.adverseReduceEnabled': 'Adverse reduce', + 'dashboard.indicator.backtest.field.trendReduceStepPct': 'Trend trigger (%)', + 'dashboard.indicator.backtest.field.adverseReduceStepPct': 'Adverse trigger (%)', + 'dashboard.indicator.backtest.field.trendReduceSizePct': 'Reduce size (% of position)', + 'dashboard.indicator.backtest.field.adverseReduceSizePct': 'Adverse reduce size (% of position)', + 'dashboard.indicator.backtest.field.trendReduceMaxTimes': 'Max trend reduce times', + 'dashboard.indicator.backtest.field.adverseReduceMaxTimes': 'Max adverse reduce times', + 'dashboard.indicator.backtest.field.entryPct': 'Entry size (% of capital)', + 'dashboard.indicator.backtest.field.minOrderPct': 'Min order size (% of capital) (optional)', + 'dashboard.indicator.backtest.closeLongTrailing': 'Close Long (Trailing)', + 'dashboard.indicator.backtest.reduceLong': 'Reduce Long', + 'dashboard.indicator.backtest.closeShortTrailing': 'Close Short (Trailing)', + 'dashboard.indicator.backtest.reduceShort': 'Reduce Short', + 'dashboard.docs.title': '문서 센터', + 'dashboard.docs.search.placeholder': '문서 검색...', + 'dashboard.docs.category.all': '모두', + 'dashboard.docs.category.guide': '개발 가이드', + 'dashboard.docs.category.api': 'API 문서', + 'dashboard.docs.category.tutorial': '튜토리얼', + 'dashboard.docs.category.faq': 'FAQ', + 'dashboard.docs.featured.title': '권장 문서', + 'dashboard.docs.featured.tag': '추천', + 'dashboard.docs.list.views': '찾아보기', + 'dashboard.docs.list.author': '작성자', + 'dashboard.docs.list.empty': '아직 문서가 없습니다.', + 'dashboard.docs.list.backToAll': '모든 문서로 돌아가기', + 'dashboard.docs.list.total': '총 {count}개의 문서', + 'dashboard.docs.detail.back': '문서 목록으로 돌아가기', + 'dashboard.docs.detail.updatedAt': '업데이트 날짜', + 'dashboard.docs.detail.related': '관련 문서', + 'dashboard.docs.detail.notFound': '문서가 존재하지 않습니다', + 'dashboard.docs.detail.error': '문서가 존재하지 않거나 삭제되었습니다.', + 'dashboard.docs.search.result': '검색결과', + 'dashboard.docs.search.keyword': '키워드', + 'community.filter.indicatorType': '표시기 유형', + 'community.filter.all': '모두', + 'community.filter.other': '기타 옵션', + 'community.filter.pricing': '가격 유형', + 'community.filter.allPricing': '모든 가격', + 'community.filter.sortBy': '정렬 기준', + 'community.filter.search': '검색', + 'community.filter.searchPlaceholder': '측정항목 이름 검색', + 'community.indicatorType.trend': '트렌드 유형', + 'community.indicatorType.momentum': '모멘텀형', + 'community.indicatorType.volatility': '변동성', + 'community.indicatorType.volume': '볼륨', + 'community.indicatorType.custom': '사용자 정의', + 'community.pricing.free': '무료', + 'community.pricing.paid': '지불', + 'community.sort.downloads': '다운로드', + 'community.sort.rating': '등급', + 'community.sort.newest': '최신 릴리스', + 'community.pagination.total': '총 {total} 지표', + 'community.noDescription': '아직 설명이 없습니다', + 'community.detail.type': '유형', + 'community.detail.pricing': '가격', + 'community.detail.rating': '등급', + 'community.detail.downloads': '다운로드', + 'community.detail.author': '작성자', + 'community.detail.description': '소개', + 'community.detail.detailContent': '자세한 설명', + 'community.detail.backtestStats': '백테스트 통계', + 'community.action.purchase': '매수 지표', + 'community.action.addToMyIndicators': '내 지표에 추가', + 'community.action.favorite': '컬렉션', + 'community.action.unfavorite': '즐겨찾기 취소', + 'community.action.buyNow': '지금 구매', + 'community.action.renew': '갱신', + 'community.purchase.price': '가격', + 'community.purchase.permanent': '영구', + 'community.purchase.monthly': '달', + 'community.purchase.confirmBuy': '이 지표({price} QDT) 구매를 확인하시겠습니까?', + 'community.purchase.confirmRenew': '이 지표({price} QDT/월)를 갱신하시겠습니까?', + 'community.purchase.confirmFree': '이 무료 표시기를 추가하시겠습니까?', + 'community.purchase.confirmTitle': '조치 확인', + 'community.purchase.owned': '이 표시기를 구입하셨습니다(영구적으로 유효함).', + 'community.purchase.ownIndicator': '귀하가 게시한 측정항목입니다.', + 'community.purchase.expired': '구독이 만료되었습니다', + 'community.purchase.expiresOn': '만료 시간: {date}', + 'community.tabs.detail': '지표 세부정보', + 'community.tabs.backtest': '백테스트 데이터', + 'community.tabs.ratings': '사용자 리뷰', + 'community.rating.myRating': '내 평가', + 'community.rating.stars': '별 {count}개', + 'community.rating.commentPlaceholder': '당신의 경험을 공유하세요...', + 'community.rating.submit': '평가 제출', + 'community.rating.modify': '수정', + 'community.rating.saveModify': '변경사항 저장', + 'community.rating.cancel': '취소', + 'community.rating.selectRating': '등급을 선택해 주세요', + 'community.rating.success': '평가 성공', + 'community.rating.modifySuccess': '리뷰가 수정되었습니다.', + 'community.rating.failed': '평가 실패', + 'community.rating.noRatings': '아직 댓글이 없습니다', + 'community.backtest.note': '위 데이터는 과거 백테스트 결과이며 참고용일 뿐 미래 수익을 나타내지는 않습니다.', + 'community.backtest.noData': '아직 백테스트 데이터가 없습니다.', + 'community.backtest.uploadHint': '작성자는 아직 백테스트 데이터를 업로드하지 않았습니다.', + 'community.message.loadFailed': '표시기 목록을 로드하지 못했습니다.', + 'community.message.purchaseProcessing': '구매 요청 처리 중...', + 'community.message.downloadSuccess': '내 측정항목 목록에 측정항목이 추가되었습니다.', + 'community.message.favoriteSuccess': '수집 성공', + 'community.message.unfavoriteSuccess': '수집을 취소했습니다.', + 'community.message.operationSuccess': '작업 성공', + 'community.message.operationFailed': '작업 실패', + 'dashboard.totalEquity': '총자본', + 'dashboard.totalPnL': '총손익', + 'dashboard.aiStrategies': 'AI 전략', + 'dashboard.indicatorStrategies': '지표 전략', + 'dashboard.running': '달리기', + 'dashboard.pnlHistory': '역사적 손익', + 'dashboard.strategyPerformance': '전략 손익비율', + 'dashboard.recentTrades': '최근 거래', + 'dashboard.currentPositions': '현재 위치', + 'dashboard.table.time': '시간', + 'dashboard.table.strategy': '정책 이름', + 'dashboard.table.symbol': '대상', + 'dashboard.table.type': '유형', + 'dashboard.table.side': '방향', + 'dashboard.table.size': '미결제약정', + 'dashboard.table.entryPrice': '평균 개장가', + 'dashboard.table.price': '가격', + 'dashboard.table.amount': '수량', + 'dashboard.table.profit': '이익과 손실', + 'dashboard.pendingOrders': '주문 실행 기록', + 'dashboard.totalOrders': '총 {total}건', + 'dashboard.viewError': '오류 보기', + 'dashboard.filled': '체결됨', + 'dashboard.orderTable.time': '생성 시간', + 'dashboard.orderTable.strategy': '전략', + 'dashboard.orderTable.symbol': '거래 쌍', + 'dashboard.orderTable.signalType': '신호 유형', + 'dashboard.orderTable.amount': '수량', + 'dashboard.orderTable.price': '체결 가격', + 'dashboard.orderTable.status': '상태', + 'dashboard.orderTable.executedAt': '실행 시간', + 'dashboard.signalType.openLong': '롱 오픈', + 'dashboard.signalType.openShort': '숏 오픈', + 'dashboard.signalType.closeLong': '롱 청산', + 'dashboard.signalType.closeShort': '숏 청산', + 'dashboard.signalType.addLong': '롱 추가', + 'dashboard.signalType.addShort': '숏 추가', + 'dashboard.status.pending': '대기 중', + 'dashboard.status.processing': '처리 중', + 'dashboard.status.completed': '완료', + 'dashboard.status.failed': '실패', + 'dashboard.status.cancelled': '취소됨', + 'dashboard.table.pnl': '미실현 손익', + 'form.basic-form.basic.title': '기본 형태', + 'form.basic-form.basic.description': '양식 페이지는 사용자로부터 정보를 수집하거나 확인하는 데 사용됩니다. 기본 양식은 데이터 항목이 거의 없는 양식 시나리오에서 일반적으로 사용됩니다.', + 'form.basic-form.title.label': '제목', + 'form.basic-form.title.placeholder': '목표에 이름을 지어주세요', + 'form.basic-form.title.required': '제목을 입력하세요', + 'form.basic-form.date.label': '시작일 및 종료일', + 'form.basic-form.placeholder.start': '시작일', + 'form.basic-form.placeholder.end': '종료일', + 'form.basic-form.date.required': '시작일과 종료일을 선택하세요.', + 'form.basic-form.goal.label': '목표 설명', + 'form.basic-form.goal.placeholder': '단계별 업무 목표를 입력하세요.', + 'form.basic-form.goal.required': '목표 설명을 입력하세요.', + 'form.basic-form.standard.label': '측정하다', + 'form.basic-form.standard.placeholder': '측정항목을 입력하세요.', + 'form.basic-form.standard.required': '측정항목을 입력하세요.', + 'form.basic-form.client.label': '고객', + 'form.basic-form.client.required': '귀하가 서비스를 제공하는 고객에 대해 설명해주세요.', + 'form.basic-form.label.tooltip': '대상 서비스 대상자', + 'form.basic-form.client.placeholder': '귀하가 서비스를 제공하는 고객, 내부 고객을 직접 @이름/직위 번호로 설명하십시오.', + 'form.basic-form.invites.label': '리뷰어 초대', + 'form.basic-form.invites.placeholder': '@이름/사원번호로 직접 보내주세요. 최대 5명까지 초대 가능합니다.', + 'form.basic-form.weight.label': '무게', + 'form.basic-form.weight.placeholder': '입력해주세요', + 'form.basic-form.public.label': '대중을 대상으로', + 'form.basic-form.label.help': '고객과 검토자는 기본적으로 공유됩니다.', + 'form.basic-form.radio.public': '대중', + 'form.basic-form.radio.partially-public': '부분적으로 공개', + 'form.basic-form.radio.private': '비공개', + 'form.basic-form.publicUsers.placeholder': '열려 있다', + 'form.basic-form.option.A': '동료 1', + 'form.basic-form.option.B': '동료 2', + 'form.basic-form.option.C': '동료 3', + 'form.basic-form.email.required': '이메일 주소를 입력해주세요!', + 'form.basic-form.email.wrong-format': '이메일 주소 형식이 잘못되었습니다!', + 'form.basic-form.userName.required': '사용자 이름을 입력해주세요!', + 'form.basic-form.password.required': '비밀번호를 입력해주세요!', + 'form.basic-form.password.twice': '두 번 입력한 비밀번호가 일치하지 않습니다!', + 'form.basic-form.strength.msg': '6자 이상 입력해주세요. 쉽게 추측할 수 있는 비밀번호는 사용하지 마세요.', + 'form.basic-form.strength.strong': '힘: 강한', + 'form.basic-form.strength.medium': '강도: 중간', + 'form.basic-form.strength.short': '강도: 너무 짧음', + 'form.basic-form.confirm-password.required': '비밀번호를 확인해 주세요!', + 'form.basic-form.phone-number.required': '휴대폰번호를 입력해주세요!', + 'form.basic-form.phone-number.wrong-format': '휴대폰 번호 형식이 잘못되었습니다!', + 'form.basic-form.verification-code.required': '인증번호를 입력해주세요!', + 'form.basic-form.form.get-captcha': '인증 코드 받기', + 'form.basic-form.captcha.second': '초', + 'form.basic-form.form.optional': '(선택사항)', + 'form.basic-form.form.submit': '제출', + 'form.basic-form.form.save': '저장', + 'form.basic-form.email.placeholder': '이메일', + 'form.basic-form.password.placeholder': '비밀번호는 6자 이상, 대소문자 구분', + 'form.basic-form.confirm-password.placeholder': '비밀번호 확인', + 'form.basic-form.phone-number.placeholder': '휴대전화번호', + 'form.basic-form.verification-code.placeholder': '인증코드', + 'result.success.title': '제출 성공', + 'result.success.description': '제출 결과 페이지는 일련의 작업 작업의 처리 결과를 피드백하는 데 사용됩니다. 간단한 작업인 경우 메시지 전역 프롬프트 피드백을 사용하세요. 이 텍스트 영역에는 간단한 보충 지침이 표시될 수 있습니다. "문서"를 표시해야 하는 경우 아래 회색 영역에 더 복잡한 내용이 표시될 수 있습니다.', + 'result.success.operate-title': '프로젝트 이름', + 'result.success.operate-id': '프로젝트 ID', + 'result.success.principal': '담당자', + 'result.success.operate-time': '유효시간', + 'result.success.step1-title': '프로젝트 생성', + 'result.success.step1-operator': '쿠 릴리', + 'result.success.step2-title': '학과 사전 검토', + 'result.success.step2-operator': '저우 마오마오', + 'result.success.step2-extra': '긴급', + 'result.success.step3-title': '재무 검토', + 'result.success.step4-title': '완료', + 'result.success.btn-return': '목록으로 돌아가기', + 'result.success.btn-project': '항목 보기', + 'result.success.btn-print': '인쇄', + 'result.fail.error.title': '제출 실패', + 'result.fail.error.description': '다시 제출하기 전에 다음 정보를 확인하고 수정하시기 바랍니다.', + 'result.fail.error.hint-title': '제출한 콘텐츠에 다음 오류가 포함되어 있습니다.', + 'result.fail.error.hint-text1': '귀하의 계정이 동결되었습니다', + 'result.fail.error.hint-btn1': '즉시 해동', + 'result.fail.error.hint-text2': '귀하의 계정은 아직 신청할 수 없습니다', + 'result.fail.error.hint-btn2': '지금 업그레이드', + 'result.fail.error.btn-text': '수정으로 돌아가기', + 'account.settings.menuMap.custom': '개인화', + 'account.settings.menuMap.binding': '계정 바인딩', + 'account.settings.basic.avatar': '아바타', + 'account.settings.basic.change-avatar': '아바타 변경', + 'account.settings.basic.email': '이메일', + 'account.settings.basic.email-message': '이메일을 입력해주세요!', + 'account.settings.basic.nickname': '닉네임', + 'account.settings.basic.nickname-message': '닉네임을 입력해주세요!', + 'account.settings.basic.profile': '프로필', + 'account.settings.basic.profile-message': '개인 프로필을 입력해주세요!', + 'account.settings.basic.profile-placeholder': '프로필', + 'account.settings.basic.country': '국가/지역', + 'account.settings.basic.country-message': '국가나 지역을 입력해주세요!', + 'account.settings.basic.geographic': '지방 및 도시', + 'account.settings.basic.geographic-message': '귀하의 시/도를 입력해주세요!', + 'account.settings.basic.address': '거리 주소', + 'account.settings.basic.address-message': '주소를 입력해주세요!', + 'account.settings.basic.phone': '연락처', + 'account.settings.basic.phone-message': '연락처를 입력해주세요!', + 'account.settings.basic.update': '기본정보 업데이트', + 'account.settings.basic.update.success': '기본 정보를 성공적으로 업데이트했습니다.', + 'account.settings.security.strong': '강한', + 'account.settings.security.medium': '안으로', + 'account.settings.security.weak': '약한', + 'account.settings.security.password': '계정 비밀번호', + 'account.settings.security.password-description': '현재 비밀번호 강도:', + 'account.settings.security.phone': '보안 휴대폰', + 'account.settings.security.phone-description': '이미 바인딩된 휴대폰:', + 'account.settings.security.question': '보안 문제', + 'account.settings.security.question-description': '보안 질문이 설정되지 않아 계정 보안을 효과적으로 보호할 수 있습니다.', + 'account.settings.security.email': '이메일 바인딩', + 'account.settings.security.email-description': '이미 바인딩된 이메일 주소:', + 'account.settings.security.mfa': 'MFA 장치', + 'account.settings.security.mfa-description': 'MFA 디바이스가 바인딩되지 않았습니다. 바인딩 후 다시 확인할 수 있습니다.', + 'account.settings.security.modify': '수정', + 'account.settings.security.set': '설정', + 'account.settings.security.bind': '바인딩', + 'account.settings.binding.taobao': '타오바오 바인딩', + 'account.settings.binding.taobao-description': '현재 타오바오 계정이 연결되어 있지 않습니다', + 'account.settings.binding.alipay': '알리페이 바인딩', + 'account.settings.binding.alipay-description': 'Alipay 계정이 현재 연결되어 있지 않습니다.', + 'account.settings.binding.dingding': '바인딩 딩톡', + 'account.settings.binding.dingding-description': '현재 바인딩된 DingTalk 계정이 없습니다.', + 'account.settings.binding.bind': '바인딩', + 'account.settings.notification.password': '계정 비밀번호', + 'account.settings.notification.password-description': '다른 사용자의 메시지는 사이트 메시지 형식으로 통보됩니다.', + 'account.settings.notification.messages': '시스템 메시지', + 'account.settings.notification.messages-description': '시스템 메시지는 사이트 메시지 형식으로 통보됩니다.', + 'account.settings.notification.todo': '해야 할 일', + 'account.settings.notification.todo-description': '할일은 사이트 내 메시지로 알려드립니다.', + 'account.settings.settings.open': '열다', + 'account.settings.settings.close': '닫기', + 'trading-assistant.title': '트레이딩 어시스턴트', + 'trading-assistant.strategyList': '전략 목록', + 'trading-assistant.createStrategy': '정책 만들기', + 'trading-assistant.noStrategy': '아직 전략이 없습니다.', + 'trading-assistant.selectStrategy': '세부정보를 보려면 왼쪽에서 전략을 선택하세요.', + 'trading-assistant.startStrategy': '출시 전략', + 'trading-assistant.stopStrategy': '중지 전략', + 'trading-assistant.editStrategy': '편집 전략', + 'trading-assistant.deleteStrategy': '정책 삭제', + 'trading-assistant.status.running': '달리기', + 'trading-assistant.status.stopped': '중지됨', + 'trading-assistant.status.error': '오류', + 'trading-assistant.strategyType.IndicatorStrategy': '기술 지표 전략', + 'trading-assistant.strategyType.PromptBasedStrategy': '단서 전략', + 'trading-assistant.strategyType.GridStrategy': '그리드 전략', + 'trading-assistant.tabs.tradingRecords': '거래 내역', + 'trading-assistant.tabs.positions': '직위기록', + 'trading-assistant.tabs.equityCurve': '주식곡선', + 'trading-assistant.form.step1': '지표 선택', + 'trading-assistant.form.step2': '교환 구성', + 'trading-assistant.form.step3': '전략 매개변수', + 'trading-assistant.form.indicator': '지표 선택', + 'trading-assistant.form.indicatorHint': '자신이 구매했거나 생성한 기술 지표만 선택할 수 있습니다.', + 'trading-assistant.form.qdtCostHints': '전략을 사용하면 QDT가 소모됩니다. 계정에 QDT 잔액이 충분한지 확인하세요.', + 'trading-assistant.form.indicatorDescription': '표시기 설명', + 'trading-assistant.form.noDescription': '아직 설명이 없습니다', + 'trading-assistant.form.exchange': '교환 선택', + 'trading-assistant.form.apiKey': 'API 키', + 'trading-assistant.form.secretKey': '비밀키', + 'trading-assistant.form.passphrase': '암호', + 'trading-assistant.form.testConnection': '연결 테스트', + 'trading-assistant.form.strategyName': '정책 이름', + 'trading-assistant.form.symbol': '거래 쌍', + 'trading-assistant.form.symbolHint': '현재는 암호화폐 거래쌍만 지원됩니다', + 'trading-assistant.form.initialCapital': '투자금액', + 'trading-assistant.form.marketType': '시장 유형', + 'trading-assistant.form.marketTypeFutures': '계약', + 'trading-assistant.form.marketTypeSpot': '스팟', + 'trading-assistant.form.marketTypeHint': '계약은 양방향 거래와 레버리지를 지원하는 반면 현물은 롱 포지션만 지원하고 레버리지는 1x로 고정됩니다.', + 'trading-assistant.form.leverage': '여러 가지 활용', + 'trading-assistant.form.leverageHint': '계약 : 1~125회, 현물 : 고정 1회', + 'trading-assistant.form.spotLeverageFixed': '현물 거래 레버리지는 1x로 고정됩니다.', + 'trading-assistant.form.spotOnlyLongHint': '현물 거래는 매수 포지션만 지원합니다', + 'trading-assistant.form.tradeDirection': '거래 방향', + 'trading-assistant.form.tradeDirectionLong': '오직 길다', + 'trading-assistant.form.tradeDirectionShort': '단지 짧다', + 'trading-assistant.form.tradeDirectionBoth': '양방향 거래', + 'trading-assistant.form.timeframe': '기간', + 'trading-assistant.form.klinePeriod': 'K선 기간', + 'trading-assistant.form.timeframe1m': '1분', + 'trading-assistant.form.timeframe5m': '5분', + 'trading-assistant.form.timeframe15m': '15분', + 'trading-assistant.form.timeframe30m': '30분', + 'trading-assistant.form.timeframe1H': '1시간', + 'trading-assistant.form.timeframe4H': '4시간', + 'trading-assistant.form.timeframe1D': '1일', + 'trading-assistant.form.selectStrategyType': '전략 유형 선택', + 'trading-assistant.form.indicatorStrategy': '지표 전략', + 'trading-assistant.form.indicatorStrategyDesc': '기술 지표 기반 자동 거래 전략', + 'trading-assistant.form.aiStrategy': 'AI 전략', + 'trading-assistant.form.aiStrategyDesc': 'AI 지능형 의사결정 기반 자동 거래 전략', + 'trading-assistant.form.enableAiFilter': 'AI 지능형 의사결정 필터 활성화', + 'trading-assistant.form.enableAiFilterHint': '활성화하면 지표 신호가 AI에 의해 필터링되어 거래 품질이 향상됩니다', + 'trading-assistant.form.aiFilterPrompt': '사용자 정의 프롬프트', + 'trading-assistant.form.aiFilterPromptHint': 'AI 필터링을 위한 사용자 정의 지침을 제공하고, 비워두면 시스템 기본값을 사용합니다', + 'trading-assistant.validation.strategyTypeRequired': '전략 유형을 선택하세요', + 'trading-assistant.form.advancedSettings': '고급 설정', + 'trading-assistant.form.orderMode': '주문 모드', + 'trading-assistant.form.orderModeMaker': '메이커', + 'trading-assistant.form.orderModeTaker': '시장가(테이커)', + 'trading-assistant.form.orderModeHint': '지정가 주문 모드는 지정가 주문을 사용하며 처리 수수료가 더 낮습니다. 시장 가격 모드는 즉시 실행되며 처리 수수료가 더 높습니다.', + 'trading-assistant.form.makerWaitSec': '지정가 주문 대기 시간(초)', + 'trading-assistant.form.makerWaitSecHint': '주문 후 거래가 완료될 때까지 기다리는 시간입니다. 취소하고 시간 초과 후 다시 시도하세요.', + 'trading-assistant.form.makerRetries': '보류 중인 주문에 대한 재시도 횟수', + 'trading-assistant.form.makerRetriesHint': '보류 중인 주문이 실행되지 않을 때 최대 재시도 횟수', + 'trading-assistant.form.fallbackToMarket': '지정가 주문이 실패하고 시장 가격이 하락합니다.', + 'trading-assistant.form.fallbackToMarketHint': '개시/청산 보류 주문이 완료되지 않은 경우 거래가 완료되었는지 확인하기 위해 시장가 주문으로 다운그레이드해야 하는지 여부.', + 'trading-assistant.form.marginMode': '마진 모드', + 'trading-assistant.form.marginModeCross': '전체 창고', + 'trading-assistant.form.marginModeIsolated': '고립된 위치', + 'trading-assistant.form.stopLossPct': '정지손해율(%)', + 'trading-assistant.form.stopLossPctHint': '정지 손실 비율을 설정합니다. 0은 정지 손실이 활성화되지 않았음을 의미합니다.', + 'trading-assistant.form.takeProfitPct': '이익 실현 비율(%)', + 'trading-assistant.form.takeProfitPctHint': '이익실현 비율을 설정합니다. 0은 이익실현을 비활성화함을 의미합니다.', + 'trading-assistant.form.signalMode': '신호 모드', + 'trading-assistant.form.signalModeConfirmed': '모드 확인', + 'trading-assistant.form.signalModeAggressive': '공격적 모드', + 'trading-assistant.form.signalModeHint': '확인 모드: 완료된 K 라인만 확인합니다. 공격적 모드: K 라인 형성도 확인합니다.', + 'trading-assistant.form.cancel': '취소', + 'trading-assistant.form.prev': '이전 단계', + 'trading-assistant.form.next': '다음 단계', + 'trading-assistant.form.confirmCreate': '생성 확인', + 'trading-assistant.form.confirmEdit': '변경사항 확인', + 'trading-assistant.messages.createSuccess': '전략이 성공적으로 생성되었습니다.', + 'trading-assistant.messages.createFailed': '정책을 생성하지 못했습니다.', + 'trading-assistant.messages.updateSuccess': '정책 업데이트 성공', + 'trading-assistant.messages.updateFailed': '정책 업데이트 실패', + 'trading-assistant.messages.deleteSuccess': '정책 삭제 성공', + 'trading-assistant.messages.deleteFailed': '정책 삭제 실패', + 'trading-assistant.messages.startSuccess': '전략이 성공적으로 시작되었습니다', + 'trading-assistant.messages.startFailed': '정책을 시작하지 못했습니다.', + 'trading-assistant.messages.stopSuccess': '전략이 성공적으로 중지되었습니다.', + 'trading-assistant.messages.stopFailed': '중지 전략이 실패했습니다.', + 'trading-assistant.messages.loadFailed': '정책 목록을 가져오지 못했습니다.', + 'trading-assistant.messages.runningWarning': '전략이 실행되고 있습니다. 전략을 수정하기 전에 전략을 중지하세요.', + 'trading-assistant.messages.deleteConfirmWithName': '"{name}" 정책을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', + 'trading-assistant.messages.deleteConfirm': '이 정책을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', + 'trading-assistant.messages.loadTradesFailed': '거래 기록을 가져오지 못했습니다.', + 'trading-assistant.messages.loadPositionsFailed': '위치 기록을 가져오지 못했습니다.', + 'trading-assistant.messages.loadEquityFailed': '자기자본곡선 획득 실패', + 'trading-assistant.messages.loadIndicatorsFailed': '표시기 목록을 로드하지 못했습니다. 나중에 다시 시도해 주세요.', + 'trading-assistant.messages.spotLimitations': '현물 거래는 자동으로 매수, 1배 레버리지로 설정되었습니다.', + 'trading-assistant.messages.autoFillApiConfig': '거래소의 과거 API 구성이 자동으로 채워졌습니다.', + 'trading-assistant.placeholders.selectIndicator': '지표를 선택하세요', + 'trading-assistant.placeholders.selectExchange': '교환을 선택해주세요', + 'trading-assistant.placeholders.inputApiKey': 'API 키를 입력하세요', + 'trading-assistant.placeholders.inputSecretKey': '비밀키를 입력해주세요', + 'trading-assistant.placeholders.inputPassphrase': '암호를 입력하세요', + 'trading-assistant.placeholders.inputStrategyName': '정책 이름을 입력하세요.', + 'trading-assistant.placeholders.selectSymbol': '거래쌍을 선택해주세요', + 'trading-assistant.placeholders.selectTimeframe': '기간을 선택하세요.', + 'trading-assistant.placeholders.selectKlinePeriod': 'K선 기간을 선택하세요', + 'trading-assistant.placeholders.inputAiFilterPrompt': '사용자 정의 프롬프트를 입력하세요(선택사항)', + 'trading-assistant.validation.indicatorRequired': '지표를 선택하세요', + 'trading-assistant.validation.exchangeRequired': '교환을 선택해주세요', + 'trading-assistant.validation.apiKeyRequired': 'API 키를 입력하세요', + 'trading-assistant.validation.secretKeyRequired': '비밀키를 입력해주세요', + 'trading-assistant.validation.passphraseRequired': '암호를 입력하세요', + 'trading-assistant.validation.exchangeConfigIncomplete': '전체 교환 구성 정보를 입력하세요.', + 'trading-assistant.validation.testConnectionRequired': '먼저 "연결 테스트" 버튼을 클릭하고 연결이 성공했는지 확인하세요', + 'trading-assistant.validation.testConnectionFailed': '연결 테스트에 실패했습니다. 구성을 확인하고 다시 테스트하세요', + 'trading-assistant.validation.strategyNameRequired': '정책 이름을 입력하세요.', + 'trading-assistant.validation.symbolRequired': '거래쌍을 선택해주세요', + 'trading-assistant.validation.initialCapitalRequired': '투자금액을 입력해주세요.', + 'trading-assistant.validation.leverageRequired': '레버리지 비율을 입력하세요.', + 'trading-assistant.table.time': '시간', + 'trading-assistant.table.type': '유형', + 'trading-assistant.table.price': '가격', + 'trading-assistant.table.amount': '수량', + 'trading-assistant.table.value': '금액', + 'trading-assistant.table.commission': '취급 수수료', + 'trading-assistant.table.symbol': '거래쌍', + 'trading-assistant.table.side': '방향', + 'trading-assistant.table.size': '포지션 수량', + 'trading-assistant.table.entryPrice': '개장 가격', + 'trading-assistant.table.currentPrice': '현재 가격', + 'trading-assistant.table.unrealizedPnl': '미실현 손익', + 'trading-assistant.table.pnlPercent': '손익비율', + 'trading-assistant.table.buy': '사다', + 'trading-assistant.table.sell': '팔다', + 'trading-assistant.table.long': '오래 가세요', + 'trading-assistant.table.short': '짧은', + 'trading-assistant.table.noPositions': '아직 직책이 없습니다.', + 'trading-assistant.detail.title': '전략 세부정보', + 'trading-assistant.detail.strategyName': '정책 이름', + 'trading-assistant.detail.strategyType': '전략 유형', + 'trading-assistant.detail.status': '상태', + 'trading-assistant.detail.tradingMode': '거래 모델', + 'trading-assistant.detail.exchange': '교환', + 'trading-assistant.detail.initialCapital': '초기자본금', + 'trading-assistant.detail.totalInvestment': '총투자금액', + 'trading-assistant.detail.currentEquity': '현재 순자산', + 'trading-assistant.detail.totalPnl': '총손익', + 'trading-assistant.detail.indicatorName': '지표 이름', + 'trading-assistant.detail.maxLeverage': '최대 레버리지', + 'trading-assistant.detail.decideInterval': '결정 간격', + 'trading-assistant.detail.symbols': '거래개체', + 'trading-assistant.detail.createdAt': '생성 시간', + 'trading-assistant.detail.updatedAt': '업데이트 시간', + 'trading-assistant.detail.llmConfig': 'AI 모델 구성', + 'trading-assistant.detail.exchangeConfig': '교환 구성', + 'trading-assistant.detail.provider': '공급자', + 'trading-assistant.detail.modelId': '모델 ID', + 'trading-assistant.detail.close': '닫기', + 'trading-assistant.detail.loadFailed': '정책 세부정보를 가져오지 못했습니다.', + 'trading-assistant.equity.noData': '아직 순자산 데이터가 없습니다.', + 'trading-assistant.equity.equity': '순자산', + 'trading-assistant.exchange.tradingMode': '거래 모델', + 'trading-assistant.exchange.virtual': '모의거래', + 'trading-assistant.exchange.live': '실제 거래', + 'trading-assistant.exchange.selectExchange': '교환 선택', + 'trading-assistant.exchange.walletAddress': '지갑 주소', + 'trading-assistant.exchange.walletAddressPlaceholder': '지갑 주소(0x로 시작)를 입력하세요.', + 'trading-assistant.exchange.privateKey': '개인 키', + 'trading-assistant.exchange.privateKeyPlaceholder': '개인키(64자)를 입력해주세요.', + 'trading-assistant.exchange.testConnection': '연결 테스트', + 'trading-assistant.exchange.connectionSuccess': '연결 성공', + 'trading-assistant.exchange.connectionFailed': '연결 실패', + 'trading-assistant.exchange.testFailed': '연결 테스트 실패', + 'trading-assistant.exchange.fillComplete': '전체 교환 구성 정보를 입력하세요.', + 'trading-assistant.strategyTypeOptions.ai': 'AI 중심 전략', + 'trading-assistant.strategyTypeOptions.indicator': '기술 지표 전략', + 'trading-assistant.strategyTypeOptions.aiDeveloping': 'AI 기반 전략 기능은 개발 중이므로 계속 지켜봐 주시기 바랍니다', + 'trading-assistant.strategyTypeOptions.aiDevelopingWarning': 'AI 기반 전략 기능 개발 중', + 'trading-assistant.indicatorType.trend': '추세', + 'trading-assistant.indicatorType.momentum': '추진력', + 'trading-assistant.indicatorType.volatility': '변동', + 'trading-assistant.indicatorType.volume': '볼륨', + 'trading-assistant.indicatorType.custom': '사용자 정의', + 'trading-assistant.exchangeNames': { + 'okx': 'OKX', + 'binance': '바이낸스', + 'hyperliquid': '초액체', + 'blockchaincom': '블록체인닷컴', + 'coinbaseexchange': '코인베이스', + 'gate': 'Gate.io', + 'mexc': '멕시코', + 'kraken': '크라켄', + 'bitfinex': '비트파이넥스', + 'bybit': '바이비트', + 'kucoin': '쿠코인', + 'huobi': '후오비', + 'bitget': '비트겟', + 'bitmex': '비트멕스', + 'deribit': '데리비트', + 'phemex': '페멕스', + 'bitmart': '비트마트', + 'bitstamp': '비트스탬프', + 'bittrex': '비트렉스', + 'poloniex': '폴로닉스', + 'gemini': '쌍둥이자리', + 'cryptocom': '크립토닷컴', + 'bitflyer': '비트플라이어', + 'upbit': '업비트', + 'bithumb': '빗썸', + 'coinone': '코인원', + 'zb': 'ZB', + 'lbank': 'L은행', + 'bibox': '비박스', + 'bigone': '빅원', + 'bitrue': '바이트루', + 'coinex': '코인엑스', + 'digifinex': '디지파이넥스', + 'ftx': 'FTX', + 'ftxus': 'FTX 미국', + 'binanceus': '바이낸스 미국', + 'binancecoinm': '바이낸스 코인-M', + 'binanceusdm': '바이낸스 USDⓈ-M' + }, + 'ai-trading-assistant.title': 'AI 트레이딩 도우미', + 'ai-trading-assistant.strategyList': '전략 목록', + 'ai-trading-assistant.createStrategy': '정책 만들기', + 'ai-trading-assistant.noStrategy': '아직 전략이 없습니다.', + 'ai-trading-assistant.selectStrategy': '세부정보를 보려면 왼쪽에서 전략을 선택하세요.', + 'ai-trading-assistant.startStrategy': '출시 전략', + 'ai-trading-assistant.stopStrategy': '중지 전략', + 'ai-trading-assistant.editStrategy': '편집 전략', + 'ai-trading-assistant.deleteStrategy': '정책 삭제', + 'ai-trading-assistant.status.running': '달리기', + 'ai-trading-assistant.status.stopped': '중지됨', + 'ai-trading-assistant.status.error': '오류', + 'ai-trading-assistant.tabs.tradingRecords': '거래 내역', + 'ai-trading-assistant.tabs.positions': '직위기록', + 'ai-trading-assistant.tabs.aiDecisions': 'AI 의사결정 기록', + 'ai-trading-assistant.tabs.equityCurve': '주식곡선', + 'ai-trading-assistant.form.createTitle': 'AI 거래 전략 수립', + 'ai-trading-assistant.form.editTitle': 'AI 거래 전략 편집', + 'ai-trading-assistant.form.strategyName': '정책 이름', + 'ai-trading-assistant.form.modelId': 'AI 모델', + 'ai-trading-assistant.form.modelIdHint': 'API 키를 구성하지 않고 시스템 OpenRouter 서비스 사용', + 'ai-trading-assistant.form.decideInterval': '결정 간격', + 'ai-trading-assistant.form.decideInterval5m': '5분', + 'ai-trading-assistant.form.decideInterval10m': '10분', + 'ai-trading-assistant.form.decideInterval30m': '30분', + 'ai-trading-assistant.form.decideInterval1h': '1시간', + 'ai-trading-assistant.form.decideInterval4h': '4시간', + 'ai-trading-assistant.form.decideInterval1d': '1일', + 'ai-trading-assistant.form.decideInterval1w': '1주', + 'ai-trading-assistant.form.decideIntervalHint': 'AI가 결정을 내리는 시간 간격', + 'ai-trading-assistant.form.runPeriod': '실행 주기', + 'ai-trading-assistant.form.runPeriodHint': '전략 실행의 시작 시간과 종료 시간', + 'ai-trading-assistant.form.startDate': '시작일', + 'ai-trading-assistant.form.endDate': '종료일', + 'ai-trading-assistant.form.qdtCostTitle': 'QDT 공제 지침', + 'ai-trading-assistant.form.qdtCostHint': '각 AI 결정에 대해 {cost} QDT가 차감됩니다. 귀하의 계정에 충분한 QDT 잔액이 있는지 확인하십시오. 전략을 실행하는 동안 각 결정에 대해 수수료가 실시간으로 차감됩니다.', + 'ai-trading-assistant.form.apiKey': 'API 키', + 'ai-trading-assistant.form.exchange': '교환 선택', + 'ai-trading-assistant.form.secretKey': '비밀키', + 'ai-trading-assistant.form.passphrase': '암호', + 'ai-trading-assistant.form.testConnection': '연결 테스트', + 'ai-trading-assistant.form.symbol': '거래 쌍', + 'ai-trading-assistant.form.symbolHint': '거래할 거래쌍을 선택하세요', + 'ai-trading-assistant.form.initialCapital': '투자금액(마진)', + 'ai-trading-assistant.form.leverage': '여러 가지 활용', + 'ai-trading-assistant.form.timeframe': '기간', + 'ai-trading-assistant.form.timeframe1m': '1분', + 'ai-trading-assistant.form.timeframe5m': '5분', + 'ai-trading-assistant.form.timeframe15m': '15분', + 'ai-trading-assistant.form.timeframe30m': '30분', + 'ai-trading-assistant.form.timeframe1H': '1시간', + 'ai-trading-assistant.form.timeframe4H': '4시간', + 'ai-trading-assistant.form.timeframe1D': '1일', + 'ai-trading-assistant.form.marketType': '시장 유형', + 'ai-trading-assistant.form.marketTypeFutures': '계약', + 'ai-trading-assistant.form.marketTypeSpot': '스팟', + 'ai-trading-assistant.form.totalPnl': '총손익', + 'ai-trading-assistant.form.customPrompt': '맞춤 프롬프트 단어', + 'ai-trading-assistant.form.customPromptHint': '선택 사항, AI 거래 전략 및 의사 결정 논리를 사용자 정의하는 데 사용됩니다.', + 'ai-trading-assistant.form.cancel': '취소', + 'ai-trading-assistant.form.prev': '이전 단계', + 'ai-trading-assistant.form.next': '다음 단계', + 'ai-trading-assistant.form.confirmCreate': '생성 확인', + 'ai-trading-assistant.form.confirmEdit': '변경사항 확인', + 'ai-trading-assistant.messages.createSuccess': '전략이 성공적으로 생성되었습니다.', + 'ai-trading-assistant.messages.createFailed': '정책을 생성하지 못했습니다.', + 'ai-trading-assistant.messages.updateSuccess': '정책 업데이트 성공', + 'ai-trading-assistant.messages.updateFailed': '정책 업데이트 실패', + 'ai-trading-assistant.messages.deleteSuccess': '정책 삭제 성공', + 'ai-trading-assistant.messages.deleteFailed': '정책 삭제 실패', + 'ai-trading-assistant.messages.startSuccess': '전략이 성공적으로 시작되었습니다', + 'ai-trading-assistant.messages.startFailed': '정책을 시작하지 못했습니다.', + 'ai-trading-assistant.messages.stopSuccess': '전략이 성공적으로 중지되었습니다.', + 'ai-trading-assistant.messages.stopFailed': '중지 전략이 실패했습니다.', + 'ai-trading-assistant.messages.loadFailed': '정책 목록을 가져오지 못했습니다.', + 'ai-trading-assistant.messages.loadDecisionsFailed': 'AI 결정 기록을 가져오지 못했습니다.', + 'ai-trading-assistant.messages.deleteConfirm': '이 정책을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', + 'ai-trading-assistant.placeholders.inputStrategyName': '정책 이름을 입력하세요.', + 'ai-trading-assistant.placeholders.selectModelId': 'AI 모델을 선택해 주세요', + 'ai-trading-assistant.placeholders.selectDecideInterval': '결정 간격을 선택하세요.', + 'ai-trading-assistant.placeholders.startTime': '시작 시간', + 'ai-trading-assistant.placeholders.endTime': '종료 시간', + 'ai-trading-assistant.placeholders.inputApiKey': 'API 키를 입력하세요', + 'ai-trading-assistant.placeholders.selectExchange': '교환을 선택해주세요', + 'ai-trading-assistant.placeholders.inputSecretKey': '비밀키를 입력해주세요', + 'ai-trading-assistant.placeholders.inputPassphrase': '암호를 입력하세요', + 'ai-trading-assistant.placeholders.selectSymbol': '다음과 같은 거래쌍을 선택하세요: BTC/USDT', + 'ai-trading-assistant.placeholders.selectTimeframe': '기간을 선택하세요.', + 'ai-trading-assistant.placeholders.inputCustomPrompt': '맞춤 프롬프트 단어를 입력하세요(선택사항).', + 'ai-trading-assistant.validation.strategyNameRequired': '정책 이름을 입력하세요.', + 'ai-trading-assistant.validation.modelIdRequired': 'AI 모델을 선택해 주세요', + 'ai-trading-assistant.validation.runPeriodRequired': '실행 주기를 선택하세요.', + 'ai-trading-assistant.validation.apiKeyRequired': 'API 키를 입력하세요', + 'ai-trading-assistant.validation.exchangeRequired': '교환을 선택해주세요', + 'ai-trading-assistant.validation.secretKeyRequired': '비밀키를 입력해주세요', + 'ai-trading-assistant.validation.symbolRequired': '거래쌍을 선택해주세요', + 'ai-trading-assistant.validation.initialCapitalRequired': '투자금액을 입력해주세요.', + 'ai-trading-assistant.table.time': '시간', + 'ai-trading-assistant.table.type': '유형', + 'ai-trading-assistant.table.price': '가격', + 'ai-trading-assistant.table.amount': '수량', + 'ai-trading-assistant.table.value': '금액', + 'ai-trading-assistant.table.symbol': '거래 쌍', + 'ai-trading-assistant.table.side': '방향', + 'ai-trading-assistant.table.size': '포지션 수량', + 'ai-trading-assistant.table.entryPrice': '개장 가격', + 'ai-trading-assistant.table.currentPrice': '현재 가격', + 'ai-trading-assistant.table.unrealizedPnl': '미실현 손익', + 'ai-trading-assistant.table.profit': '이익과 손실', + 'ai-trading-assistant.table.openLong': '길게 열다', + 'ai-trading-assistant.table.closeLong': '핀듀오', + 'ai-trading-assistant.table.openShort': '오픈 쇼트', + 'ai-trading-assistant.table.closeShort': '비어 있음', + 'ai-trading-assistant.table.addLong': '가돗', + 'ai-trading-assistant.table.addShort': '짧게 추가하다', + 'ai-trading-assistant.table.closeShortProfit': '숏 포지션에서 이익을 얻으세요', + 'ai-trading-assistant.table.closeShortStop': '손실 중지', + 'ai-trading-assistant.table.closeLongProfit': '오래 멈추고 이익을 얻으십시오', + 'ai-trading-assistant.table.closeLongStop': '손실 중지', + 'ai-trading-assistant.table.buy': '사다', + 'ai-trading-assistant.table.sell': '팔다', + 'ai-trading-assistant.table.long': '오래 가세요', + 'ai-trading-assistant.table.short': '짧은', + 'ai-trading-assistant.table.hold': '보류', + 'ai-trading-assistant.table.reasoning': '분석 이유', + 'ai-trading-assistant.table.decisions': '의사결정', + 'ai-trading-assistant.table.riskAssessment': '위험 평가', + 'ai-trading-assistant.table.confidence': '자신감', + 'ai-trading-assistant.table.totalRecords': '총 {total}개 기록', + 'ai-trading-assistant.table.noPositions': '아직 직책이 없습니다.', + 'ai-trading-assistant.detail.title': '전략 세부정보', + 'ai-trading-assistant.equity.noData': '아직 순자산 데이터가 없습니다.', + 'ai-trading-assistant.equity.equity': '순자산', + 'ai-trading-assistant.exchange.testFailed': '연결 테스트 실패', + 'ai-trading-assistant.exchange.connectionSuccess': '연결 성공', + 'ai-trading-assistant.exchange.connectionFailed': '연결 실패', + 'ai-trading-assistant.form.advancedSettings': '고급 설정', + 'ai-trading-assistant.form.orderMode': '주문 모드', + 'ai-trading-assistant.form.orderModeMaker': '메이커', + 'ai-trading-assistant.form.orderModeTaker': '시장가(테이커)', + 'ai-trading-assistant.form.orderModeHint': '지정가 주문 모드는 지정가 주문을 사용하며 처리 수수료가 더 낮습니다. 시장 가격 모드는 즉시 실행되며 처리 수수료가 더 높습니다.', + 'ai-trading-assistant.form.makerWaitSec': '지정가 주문 대기 시간(초)', + 'ai-trading-assistant.form.makerWaitSecHint': '주문 후 거래가 완료될 때까지 기다리는 시간입니다. 취소하고 시간 초과 후 다시 시도하세요.', + 'ai-trading-assistant.form.makerRetries': '보류 중인 주문에 대한 재시도 횟수', + 'ai-trading-assistant.form.makerRetriesHint': '보류 중인 주문이 실행되지 않을 때 최대 재시도 횟수', + 'ai-trading-assistant.form.fallbackToMarket': '지정가 주문이 실패하고 시장 가격이 하락합니다.', + 'ai-trading-assistant.form.fallbackToMarketHint': '개시/청산 보류 주문이 완료되지 않은 경우 거래가 완료되었는지 확인하기 위해 시장가 주문으로 다운그레이드해야 하는지 여부.', + 'ai-trading-assistant.form.marginMode': '마진 모드', + 'ai-trading-assistant.form.marginModeCross': '전체 창고', + 'ai-trading-assistant.form.marginModeIsolated': '고립된 위치', + 'ai-analysis.title': '퀀트 트레이딩 엔진', + 'ai-analysis.system.online': '온라인', + 'ai-analysis.system.agents': '대리인', + 'ai-analysis.system.active': '활성', + 'ai-analysis.system.stage': '무대', + 'ai-analysis.panel.roster': '에이전트 라인업', + 'ai-analysis.panel.thinking': '생각하다...', + 'ai-analysis.panel.done': '완료', + 'ai-analysis.panel.standby': '대기', + 'ai-analysis.input.title': '퀀트 트레이딩 엔진', + 'ai-analysis.input.placeholder': '대상 자산 선택(예: BTC/USDT)', + 'ai-analysis.input.watchlist': '옵션 주식', + 'ai-analysis.input.start': '분석 시작', + 'ai-analysis.input.recent': '최근 작업:', + 'ai-analysis.vis.stage': '무대', + 'ai-analysis.vis.processing': '처리', + 'ai-analysis.result.complete': '분석 완료', + 'ai-analysis.result.signal': '최종 신호', + 'ai-analysis.result.confidence': '자신감:', + 'ai-analysis.result.new': '새로운 분석', + 'ai-analysis.result.full': '전체 보고서 보기', + 'ai-analysis.logs.title': '시스템 로그', + 'ai-analysis.modal.title': '기밀 보고서', + 'ai-analysis.modal.fundamental': '기본적 분석', + 'ai-analysis.modal.technical': '기술적 분석', + 'ai-analysis.modal.sentiment': '감정 분석', + 'ai-analysis.modal.risk': '위험 평가', + 'ai-analysis.stage.idle': '대기', + 'ai-analysis.stage.1': '1단계: 다차원 분석', + 'ai-analysis.stage.2': '2단계: 장단기 토론', + 'ai-analysis.stage.3': '3단계: 전략적 계획', + 'ai-analysis.stage.4': '네 번째 단계: 위험 통제 검토', + 'ai-analysis.stage.complete': '완료', + 'ai-analysis.agent.investment_director': '투자 이사', + 'ai-analysis.agent.role.investment_director': '종합 분석 및 최종 결론', + 'ai-analysis.agent.market': '시장 분석가', + 'ai-analysis.agent.role.market': '기술 및 시장 데이터', + 'ai-analysis.agent.fundamental': '기본 분석가', + 'ai-analysis.agent.role.fundamental': '재무 및 평가', + 'ai-analysis.agent.technical': '기술 분석가', + 'ai-analysis.agent.role.technical': '기술 지표 및 차트', + 'ai-analysis.agent.news': '뉴스 분석가', + 'ai-analysis.agent.role.news': '글로벌 뉴스 필터', + 'ai-analysis.agent.sentiment': '감정 분석가', + 'ai-analysis.agent.role.sentiment': '사회적 및 정서적', + 'ai-analysis.agent.risk': '위험 분석가', + 'ai-analysis.agent.role.risk': '기본 리스크 점검', + 'ai-analysis.agent.bull': '낙관적인 연구원', + 'ai-analysis.agent.role.bull': '성장촉매 채굴', + 'ai-analysis.agent.bear': '약세 연구원', + 'ai-analysis.agent.role.bear': '위험 및 결함 조사', + 'ai-analysis.agent.manager': '연구 관리자', + 'ai-analysis.agent.role.manager': '토론 진행자', + 'ai-analysis.agent.trader': '상인', + 'ai-analysis.agent.role.trader': '경영 전략가', + 'ai-analysis.agent.risky': '활동가 분석가', + 'ai-analysis.agent.role.risky': '공격적인 전략', + 'ai-analysis.agent.neutral': '균형 분석가', + 'ai-analysis.agent.role.neutral': '균형 전략', + 'ai-analysis.agent.safe': '보수적인 분석가', + 'ai-analysis.agent.role.safe': '보수적 전략', + 'ai-analysis.agent.cro': '위험 통제 관리자(CRO)', + 'ai-analysis.agent.role.cro': '최종 의사결정 권한', + 'ai-analysis.script.market': '주요 거래소에서 OHLCV 데이터를 가져오는 중...', + 'ai-analysis.script.fundamental': '분기별 재무 보고서를 검색하는 중...', + 'ai-analysis.script.technical': '기술지표 및 차트 패턴 분석 중...', + 'ai-analysis.script.news': '글로벌 금융 뉴스를 스캔하는 중...', + 'ai-analysis.script.sentiment': '소셜미디어 트렌드 분석…', + 'ai-analysis.script.risk': '역사적 변동성을 계산하는 중...', + 'invite.inviteLink': '초대링크', + 'invite.copy': '링크 복사', + 'invite.copySuccess': '성공적으로 복사되었습니다!', + 'invite.copyFailed': '복사하지 못했습니다. 수동으로 복사하세요.', + 'invite.noInviteLink': '초대 링크가 생성되지 않았습니다.', + 'invite.totalInvites': '누적 초대', + 'invite.totalReward': '누적 보상', + 'invite.rules': '초대 규칙', + 'invite.rule1': '친구를 등록하도록 성공적으로 초대할 때마다 보상을 받게 됩니다.', + 'invite.rule2': '초대한 친구가 첫 번째 거래를 완료하면 추가 보상을 받을 수 있습니다.', + 'invite.rule3': '초대 보상은 귀하의 계정으로 직접 전송됩니다', + 'invite.inviteList': '초대 목록', + 'invite.tasks': '선교 센터', + 'invite.inviteeName': '초대받은 사람', + 'invite.inviteTime': '초대 시간', + 'invite.status': '상태', + 'invite.reward': '보상', + 'invite.active': '활성', + 'invite.inactive': '활성화되지 않음', + 'invite.completed': '완료됨', + 'invite.claimed': '접수됨', + 'invite.pending': '완료 예정', + 'invite.goToTask': '완료하다', + 'invite.claimReward': '보상 청구', + 'invite.verify': '확인 완료', + 'invite.verifySuccess': '확인에 성공했습니다. 작업 완료', + 'invite.verifyNotCompleted': '작업이 아직 완료되지 않았습니다. 먼저 작업을 완료하세요.', + 'invite.verifyFailed': '확인에 실패했습니다. 나중에 다시 시도해 주세요.', + 'invite.claimSuccess': '{reward} QDT를 성공적으로 받았습니다!', + 'invite.claimFailed': '수집하지 못했습니다. 나중에 다시 시도해 주세요.', + 'invite.totalRecords': '총 {total}개 기록', + 'invite.task.twitter.title': 'X(트위터)로 리트윗', + 'invite.task.twitter.desc': '공식 트윗을 X(트위터) 계정에 공유하세요', + 'invite.task.youtube.title': 'YouTube 채널을 팔로우하세요', + 'invite.task.youtube.desc': '공식 YouTube 채널을 구독하고 팔로우하세요.', + 'invite.task.telegram.title': '텔레그램 그룹에 가입하세요', + 'invite.task.telegram.desc': '공식 텔레그램 커뮤니티 그룹에 가입하세요', + 'invite.task.discord.title': 'Discord 서버에 가입하세요', + 'invite.task.discord.desc': 'Discord 커뮤니티 서버에 가입하세요', + 'message': '-', + 'layouts.usermenu.dialog.title': '정보', + 'layouts.usermenu.dialog.content': '정말로 로그아웃하시겠습니까?', + 'layouts.userLayout.title': '불확실성 속에서 진실을 찾아라', + // Auto-filled missing keys from en-US (fallback to English) + 'dashboard.indicator.boundary.message': 'Note: The indicator script only computes plots + buy/sell signals. Position sizing, risk, scaling, fees/slippage belong to execution config.', + 'dashboard.indicator.boundary.indicatorRule': "Keep the script to buy/sell only (and set df['buy']/df['sell']). Do NOT implement position management, TP/SL, scaling in the script.", + 'dashboard.indicator.boundary.backtestRule': 'Rule: If a bar has a main signal (buy/sell → open/close/reverse), scaling in/out is skipped on the same bar.', + 'dashboard.indicator.guide.title': 'Python Indicator & Strategy Development Guide', + 'dashboard.indicator.guide.intro': 'This platform supports writing custom technical indicators and trading signals using Python. The system includes built-in pandas and numpy data analysis libraries. You can use standard DataFrame operations to process K-line data and draw charts or mark buy/sell signals.', + 'dashboard.indicator.guide.section1.title': '1. Runtime Environment & Predefined Variables', + 'dashboard.indicator.guide.section1.env': 'Your code runs in a browser-based Python environment (Pyodide).', + 'dashboard.indicator.guide.section1.libs': 'Pre-installed Libraries', + 'dashboard.indicator.guide.section1.libsDesc': 'The following libraries are imported by default, no need to import again:', + 'dashboard.indicator.guide.section1.libsList': 'pandas (pd), numpy (np), math, json', + 'dashboard.indicator.guide.section1.data': 'Input Data: df', + 'dashboard.indicator.guide.section1.dataDesc': 'The system automatically converts current K-line data into a Pandas DataFrame object, variable name is df.', + 'dashboard.indicator.guide.section1.dataColumns': 'df contains the following columns (all float type):', + 'dashboard.indicator.guide.section1.dataColumnsList': 'time (timestamp, seconds), open, high, low, close, volume', + 'dashboard.indicator.guide.section1.dataNote': 'Data index is arranged from old to new (0 is the earliest data, last row is the latest data).', + 'dashboard.indicator.guide.section1.dataIndex': 'Important: df index is timestamp (DatetimeIndex), please preserve the index, avoid using .values which loses the index.', + 'dashboard.indicator.guide.section2.title': '2. Parameter Definition', + 'dashboard.indicator.guide.section2.desc': 'You can define variables as parameters at the beginning of your code. The system will try to parse statements like variable = value as adjustable parameters.', + 'dashboard.indicator.guide.section2.example': 'Parameter Example', + 'dashboard.indicator.guide.section2.exampleCode': 'span = 20 or multiplier = 2.0', + 'dashboard.indicator.guide.section3.title': '3. Backtest Signal Setup (Important!)', + 'dashboard.indicator.guide.section3.desc': 'If you want your indicator to support backtesting, you must set the following two columns in your code:', + 'dashboard.indicator.guide.section3.backtestRequired': 'Required for Backtesting:', + 'dashboard.indicator.guide.section3.backtestBuySignal': "df['buy_signal'] - Boolean Series, True indicates buy signal", + 'dashboard.indicator.guide.section3.backtestSellSignal': "df['sell_signal'] - Boolean Series, True indicates sell signal", + 'dashboard.indicator.guide.section3.backtestExample': 'Example:', + 'dashboard.indicator.guide.section3.backtestExampleCode': "df['buy_signal'] = condition_buy
    df['sell_signal'] = condition_sell", + 'dashboard.indicator.guide.section3.backtestNote': 'Note: Even if you only set output.signals for chart display, you must set these two columns to enable backtesting.', + 'dashboard.indicator.guide.section4.title': '4. Output Requirements: output', + 'dashboard.indicator.guide.section4.desc': 'At the end of code execution, you must define a dictionary named output to tell the chart how to display results.', + 'dashboard.indicator.guide.section4.fields': 'The output dictionary contains the following fields:', + 'dashboard.indicator.guide.section4.plots': 'plots - For drawing lines (e.g., moving averages, RSI)', + 'dashboard.indicator.guide.section4.signals': 'signals - For marking buy/sell signals (e.g., arrows, labels)', + 'dashboard.indicator.guide.section4.name': 'name - Indicator name (optional)', + 'dashboard.indicator.guide.section4.calculatedVars': 'calculatedVars - Calculated variables (optional, for displaying additional information)', + 'dashboard.indicator.guide.section5.title': '5. Plot Configuration (Plots)', + 'dashboard.indicator.guide.section5.desc': 'Each object in the plots list represents a line.', + 'dashboard.indicator.guide.section5.table.field': 'Field', + 'dashboard.indicator.guide.section5.table.type': 'Type', + 'dashboard.indicator.guide.section5.table.required': 'Required', + 'dashboard.indicator.guide.section5.table.requiredYes': 'Yes', + 'dashboard.indicator.guide.section5.table.requiredNo': 'No', + 'dashboard.indicator.guide.section5.table.desc': 'Description', + 'dashboard.indicator.guide.section5.table.name': 'The name of the indicator line, displayed in the legend', + 'dashboard.indicator.guide.section5.table.data': 'Data list, length must match the number of rows in df. Recommended to use series.tolist()', + 'dashboard.indicator.guide.section5.table.color': "Line color (Hex format, e.g. '#ff0000'). If not filled, the system will automatically assign", + 'dashboard.indicator.guide.section5.table.typeDesc': "Chart type, default is 'line'", + 'dashboard.indicator.guide.section5.table.overlay': 'Important. True means display on main chart (with K-line), False means display on sub-chart (independent area)', + 'dashboard.indicator.guide.section5.example': 'Example: Draw Simple Moving Average (SMA)', + 'dashboard.indicator.guide.section5.exampleCalc': 'Calculate SMA', + 'dashboard.indicator.guide.section5.exampleOutput': 'Construct Output', + 'dashboard.indicator.guide.section5.exampleOverlay': 'Display on main chart', + 'dashboard.indicator.guide.section6.title': '6. Signal Markers (Signals)', + 'dashboard.indicator.guide.section6.desc': "signals are used to mark buy/sell points on specific K-lines through icons or text. Note: This is only for chart display. The backtest system uses df['buy_signal'] and df['sell_signal'].", + 'dashboard.indicator.guide.section6.table.type': "Signal type: 'buy' (buy, marked below K-line) or 'sell' (sell, marked above K-line)", + 'dashboard.indicator.guide.section6.table.data': 'Signal data list. Fill price value at signal positions, fill None (or np.nan) at non-signal positions', + 'dashboard.indicator.guide.section6.table.text': 'Text displayed on the label (e.g. "B", "S", "Buy", "Sell")', + 'dashboard.indicator.guide.section6.table.color': 'Marker color', + 'dashboard.indicator.guide.section6.hint': 'The system automatically handles positioning.', + 'dashboard.indicator.guide.section6.buy': 'Markers will be automatically positioned below the Low of the current K-line', + 'dashboard.indicator.guide.section6.sell': 'Markers will be automatically positioned above the High of the current K-line', + 'dashboard.indicator.guide.section6.example': 'Example: Draw Buy/Sell Signals', + 'dashboard.indicator.guide.section6.exampleInit': 'Initialize signal list, fill all with None', + 'dashboard.indicator.guide.section6.exampleLogic': 'Simple logic: mark buy when close price is greater than open price', + 'dashboard.indicator.guide.section6.exampleNote': 'In actual logic, please use loops or vectorized operations', + 'dashboard.indicator.guide.section6.examplePrice': 'Record price', + 'dashboard.indicator.guide.section6.exampleText': 'Buy', + 'dashboard.indicator.guide.section7.title': '7. Complete Code Examples', + 'dashboard.indicator.guide.section7.exampleA': 'Example A: Dual EMA Strategy (Supports Backtesting)', + 'dashboard.indicator.guide.section7.exampleACode': "# 1. Calculate indicators
    df['ema_fast'] = df['close'].ewm(span=5, adjust=False).mean()
    df['ema_slow'] = df['close'].ewm(span=13, adjust=False).mean()

    # 2. Calculate crossover signals
    cross_up = (df['ema_fast'] > df['ema_slow']) & (df['ema_fast'].shift(1) <= df['ema_slow'].shift(1))
    cross_down = (df['ema_fast'] < df['ema_slow']) & (df['ema_fast'].shift(1) >= df['ema_slow'].shift(1))

    # 3. 【Important】Set NEW backtest signal columns (bool)
    df['open_long'] = cross_up
    df['close_long'] = cross_down
    df['open_short'] = cross_down
    df['close_short'] = cross_up

    # 4. Generate signal data for chart display
    buy_signals = [df['low'].iloc[i] * 0.995 if cross_up.iloc[i] else None for i in range(len(df))]
    sell_signals = [df['high'].iloc[i] * 1.005 if cross_down.iloc[i] else None for i in range(len(df))]

    # 5. Build output
    output = {
    \"name\": \"Dual EMA Strategy\",
    \"plots\": [
    {\"name\": \"EMA 5\", \"data\": df['ema_fast'].tolist(), \"color\": \"#FF6B6B\", \"overlay\": True},
    {\"name\": \"EMA 13\", \"data\": df['ema_slow'].tolist(), \"color\": \"#4ECDC4\", \"overlay\": True}
    ],
    \"signals\": [
    {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},
    {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}
    ]
    }", + 'dashboard.indicator.guide.section7.exampleB': 'Example B: Bollinger Bands - Chart Display Only', + 'dashboard.indicator.guide.section7.exampleBCode': "# Calculate Bollinger Bands (chart display only, no backtesting)
    length = 20
    mult = 2.0
    df['sma'] = df['close'].rolling(length).mean()
    df['std'] = df['close'].rolling(length).std()
    df['upper'] = df['sma'] + mult * df['std']
    df['lower'] = df['sma'] - mult * df['std']

    output = {
    \"name\": \"Bollinger Bands\",
    \"plots\": [
    {\"name\": \"Upper\", \"data\": df['upper'].tolist(), \"color\": \"#FF5252\", \"overlay\": True},
    {\"name\": \"Middle\", \"data\": df['sma'].tolist(), \"color\": \"#2196F3\", \"overlay\": True},
    {\"name\": \"Lower\", \"data\": df['lower'].tolist(), \"color\": \"#00E676\", \"overlay\": True}
    ]
    }", + 'dashboard.indicator.guide.section7.exampleC': 'Example C: RSI Strategy (Supports Backtesting)', + 'dashboard.indicator.guide.section7.exampleCCode': "# Calculate RSI
    delta = df['close'].diff()
    up = delta.clip(lower=0)
    down = -1 * delta.clip(upper=0)
    ema_up = up.ewm(com=13, adjust=False).mean()
    ema_down = down.ewm(com=13, adjust=False).mean()
    rs = ema_up / ema_down
    df['rsi'] = 100 - (100 / (1 + rs))

    # Generate trading signals
    buy_condition = df['rsi'] < 30 # Oversold buy
    sell_condition = df['rsi'] > 70 # Overbought sell

    # 【Important】Set NEW backtest signal columns (bool)
    df['open_long'] = buy_condition
    df['close_long'] = sell_condition
    df['open_short'] = sell_condition
    df['close_short'] = buy_condition

    # Generate chart signals
    buy_signals = [df['low'].iloc[i] * 0.995 if buy_condition.iloc[i] else None for i in range(len(df))]
    sell_signals = [df['high'].iloc[i] * 1.005 if sell_condition.iloc[i] else None for i in range(len(df))]

    output = {
    \"name\": \"RSI Strategy\",
    \"plots\": [
    {\"name\": \"RSI\", \"data\": df['rsi'].tolist(), \"color\": \"#9C27B0\", \"overlay\": False},
    {\"name\": \"Overbought\", \"data\": [70] * len(df), \"color\": \"#FF5252\", \"style\": \"dashed\", \"overlay\": False},
    {\"name\": \"Oversold\", \"data\": [30] * len(df), \"color\": \"#00E676\", \"style\": \"dashed\", \"overlay\": False}
    ],
    \"signals\": [
    {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},
    {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}
    ]
    }", + 'dashboard.indicator.guide.section6.param': 'Parameters', + 'dashboard.indicator.guide.section6.calc': 'Calculate', + 'dashboard.indicator.guide.section6.output': 'Output', + 'dashboard.indicator.guide.section6.generate': 'Generate signal data (initialize with None)', + 'dashboard.indicator.guide.section6.subchart': 'Display on sub-chart', + 'dashboard.indicator.guide.section8.title': '8. Frequently Asked Questions (FAQ)', + 'dashboard.indicator.guide.section8.q1': "Q: Why can't my indicator be backtested?", + 'dashboard.indicator.guide.section8.a1': "A: The backtest system requires NEW signal columns: df['open_long'], df['close_long'], df['open_short'], df['close_short'] (boolean). Even if you set output.signals for chart display, you must set these columns to enable backtesting.", + 'dashboard.indicator.guide.section8.q2': 'Q: Data length mismatch?', + 'dashboard.indicator.guide.section8.a2': "A: The data list in plots and signals must have exactly the same length as the K-line data. If you use df['xxx'].tolist(), there is usually no problem.", + 'dashboard.indicator.guide.section8.q3': 'Q: How to handle NaN (empty values)?', + 'dashboard.indicator.guide.section8.a3': "A: Pandas calculations (such as rolling mean) will produce NaN at the beginning. The system will automatically convert NaN to null for chart rendering. You don't need to manually clean it, but if you need specific logic, you can use df.fillna(0) or other methods.", + 'dashboard.indicator.guide.section8.q4': 'Q: Index mismatch causing all signals to be NaN?', + 'dashboard.indicator.guide.section8.a4': "A: Please ensure you preserve the original df index (timestamp). Avoid using .values to create new Series. Use columns directly like df['close'] for operations.", + 'dashboard.indicator.guide.section8.q5': 'Q: Can I use third-party network libraries?', + 'dashboard.indicator.guide.section8.a5': 'A: No. The runtime environment is a browser sandbox and cannot use libraries like requests to request external APIs, nor can it install additional pip packages. Limited to built-in libraries such as pandas, numpy, math, json.', + 'dashboard.indicator.guide.section8.q6': 'Q: How to debug code?', + 'dashboard.indicator.guide.section8.a6': 'A: If the chart does not display indicators, it is usually because the Python code has an error. Please check for syntax errors or array index out of bounds. It is recommended to debug the algorithm logic in a local Python environment first, then copy it in.', + 'dashboard.indicator.guide.section8.q7': 'Q: Backtest shows 0 signals?', + 'dashboard.indicator.guide.section8.a7': "A: Please check the data type of df['open_long']/df['close_long']/df['open_short']/df['close_short']. They should be boolean (True/False), not numeric. Use condition.fillna(False).astype(bool) to ensure correct type.", + 'dashboard.indicator.backtest.commissionHint': 'Charged on notional value (incl. leverage) per trade; both entry & exit are deducted from balance.', + 'dashboard.indicator.backtest.historyTitle': 'Backtest History', + 'dashboard.indicator.backtest.historyRefresh': 'Refresh', + 'dashboard.indicator.backtest.historyView': 'View', + 'dashboard.indicator.backtest.historyNoData': 'No backtest history', + 'dashboard.indicator.backtest.historyUseCurrent': 'Only current symbol/timeframe', + 'dashboard.indicator.backtest.historyFilterSymbol': 'Symbol', + 'dashboard.indicator.backtest.historyFilterTimeframe': 'Timeframe', + 'dashboard.indicator.backtest.historyApply': 'Apply filters', + 'dashboard.indicator.backtest.historyAIAnalyze': 'AI Analyze', + 'dashboard.indicator.backtest.historyAIAnalyzeTitle': 'AI Suggestions', + 'dashboard.indicator.backtest.historyNoAIResult': 'No AI analysis result', + 'dashboard.indicator.backtest.historyRunId': 'Run ID', + 'dashboard.indicator.backtest.historyCreatedAt': 'Time', + 'dashboard.indicator.backtest.historyRange': 'Range', + 'dashboard.indicator.backtest.historyStatus': 'Status', + 'dashboard.indicator.backtest.historyStatusSuccess': 'Success', + 'dashboard.indicator.backtest.historyStatusFailed': 'Failed', + 'dashboard.indicator.backtest.historyActions': 'Actions', + 'trading-assistant.exchange.ipWhitelistTip': 'Please add the following IPs to the whitelist in your exchange API settings:', + 'signal-robot.title': 'Signal Robot Console', + 'signal-robot.createBot': 'Create New Bot', + 'signal-robot.search.nameOrSymbol': 'Name/Symbol', + 'signal-robot.search.placeholder': 'Search bot name or symbol', + 'signal-robot.search.status': 'Status', + 'signal-robot.search.statusAll': 'All', + 'signal-robot.search.statusRunning': 'Running', + 'signal-robot.search.statusPaused': 'Paused', + 'signal-robot.search.query': 'Query', + 'signal-robot.search.reset': 'Reset', + 'signal-robot.table.botName': 'Bot Name', + 'signal-robot.table.symbolTimeframe': 'Symbol/Timeframe', + 'signal-robot.table.triggerStrategy': 'Trigger Strategy', + 'signal-robot.table.notificationChannels': 'Notification Channels', + 'signal-robot.table.status': 'Status', + 'signal-robot.table.action': 'Action', + 'signal-robot.table.triggerConditions': '{count} trigger conditions', + 'signal-robot.table.monitoring': 'Monitoring', + 'signal-robot.table.paused': 'Paused', + 'signal-robot.table.edit': 'Edit', + 'signal-robot.table.pause': 'Pause', + 'signal-robot.table.start': 'Start', + 'signal-robot.table.delete': 'Delete', + 'signal-robot.table.deleteConfirm': 'Are you sure you want to delete this bot?', + 'signal-robot.table.deleteSuccess': 'Deleted successfully', + 'signal-robot.table.startSuccess': 'Bot started', + 'signal-robot.table.pauseSuccess': 'Bot paused', + 'signal-robot.channel.telegram': 'Telegram', + 'signal-robot.channel.discord': 'Discord', + 'signal-robot.channel.email': 'Email', + 'signal-robot.channel.webhook': 'Webhook', + 'signal-robot.channel.browser': 'Browser Push', + 'signal-robot.modal.createTitle': 'Create New Signal Robot', + 'signal-robot.modal.editTitle': 'Edit Signal Robot', + 'signal-robot.modal.step.basic': 'Basic Settings', + 'signal-robot.modal.step.entry': 'Entry Strategy', + 'signal-robot.modal.step.position': 'Position Management', + 'signal-robot.modal.step.risk': 'Risk Control', + 'signal-robot.modal.step.notify': 'Notification Config', + 'signal-robot.modal.prev': 'Previous', + 'signal-robot.modal.next': 'Next', + 'signal-robot.modal.preview': 'Preview Backtest', + 'signal-robot.modal.complete': 'Complete & Run', + 'signal-robot.modal.saveSuccess': 'Saved successfully', + 'signal-robot.modal.previewDeveloping': 'Preview feature is under development...', + 'signal-robot.modal.basicInfoRequired': 'Please complete basic information', + 'signal-robot.basic.alert': 'Set basic information for the bot', + 'signal-robot.basic.title': 'Basic Information', + 'signal-robot.basic.botName': 'Bot Name', + 'signal-robot.basic.botNamePlaceholder': 'e.g.: BTC SuperTrend Strategy', + 'signal-robot.basic.symbol': 'Trading Pair', + 'signal-robot.basic.symbolPlaceholder': 'Select from watchlist', + 'signal-robot.basic.noWatchlist': 'No watchlist items, please add in market page first', + 'signal-robot.basic.timeframe': 'K-Line Period', + 'signal-robot.basic.timeframe15m': '15 Minutes', + 'signal-robot.basic.timeframe30m': '30 Minutes', + 'signal-robot.basic.timeframe1h': '1 Hour', + 'signal-robot.basic.timeframe4h': '4 Hours', + 'signal-robot.basic.timeframe1d': '1 Day', + 'signal-robot.basic.autoNameSuggestion': '{symbol} Signal Robot', + 'signal-robot.entry.alert': 'Trigger entry signal when all conditions below are met', + 'signal-robot.entry.condition': 'Condition {index}', + 'signal-robot.entry.indicator': 'Indicator', + 'signal-robot.entry.indicatorSupertrend': 'SuperTrend', + 'signal-robot.entry.indicatorEma': 'EMA (Exponential Moving Average)', + 'signal-robot.entry.indicatorRsi': 'RSI (Relative Strength Index)', + 'signal-robot.entry.indicatorMacd': 'MACD', + 'signal-robot.entry.indicatorBollinger': 'Bollinger Bands', + 'signal-robot.entry.atrPeriod': 'ATR Period', + 'signal-robot.entry.multiplier': 'Multiplier', + 'signal-robot.entry.triggerSignal': 'Trigger Signal', + 'signal-robot.entry.signalTrendBullish': 'Trend Bullish', + 'signal-robot.entry.signalTrendBearish': 'Trend Bearish', + 'signal-robot.entry.signalIsUptrend': 'Is Uptrend', + 'signal-robot.entry.signalIsDowntrend': 'Is Downtrend', + 'signal-robot.entry.period': 'Period', + 'signal-robot.entry.compareCondition': 'Compare Condition', + 'signal-robot.entry.priceAbove': 'Price > EMA (Price Above)', + 'signal-robot.entry.priceBelow': 'Price < EMA (Price Below)', + 'signal-robot.entry.crossUp': 'Price Crosses Above EMA (Golden Cross)', + 'signal-robot.entry.crossDown': 'Price Crosses Below EMA (Death Cross)', + 'signal-robot.entry.compare': 'Compare', + 'signal-robot.entry.greaterThan': 'Greater Than', + 'signal-robot.entry.lessThan': 'Less Than', + 'signal-robot.entry.crossUpShort': 'Cross Up', + 'signal-robot.entry.crossDownShort': 'Cross Down', + 'signal-robot.entry.threshold': 'Threshold', + 'signal-robot.entry.selectIndicator': 'Please select an indicator to configure parameters', + 'signal-robot.entry.addCondition': 'Add Condition', + 'signal-robot.position.alert': 'Configure initial position size, leverage and pyramiding rules', + 'signal-robot.position.basicTitle': 'Basic Position Management', + 'signal-robot.position.initialSize': 'Initial Size (Capital %)', + 'signal-robot.position.initialSizeHint': 'Recommended 5% - 20%', + 'signal-robot.position.leverage': 'Leverage', + 'signal-robot.position.leverageHint': 'Please ensure your exchange account supports this leverage', + 'signal-robot.position.maxPyramiding': 'Max Pyramiding Times', + 'signal-robot.position.maxPyramidingHint': '0 means no pyramiding', + 'signal-robot.position.pyramidingTitle': 'Pyramiding Rules', + 'signal-robot.position.pyramidingEnabled': 'Enabled', + 'signal-robot.position.pyramidingDisabled': 'Disabled', + 'signal-robot.position.pyramidingCondition': 'Pyramiding Trigger Condition', + 'signal-robot.position.priceRisePct': 'Price Rises (Long) / Falls (Short)', + 'signal-robot.position.profitPct': 'Profit Reaches', + 'signal-robot.position.triggerThreshold': 'Trigger Threshold (%)', + 'signal-robot.position.triggerThresholdHint': 'e.g. 3%: add position once every 3% rise', + 'signal-robot.position.addSize': 'Single Add Size (Capital %)', + 'signal-robot.position.pyramidingDisabledHint': 'Pyramiding disabled, only initial position will be executed', + 'signal-robot.risk.alert': 'Configure take profit, stop loss and exit rules', + 'signal-robot.risk.stopLossTitle': 'Stop Loss', + 'signal-robot.risk.stopLossEnabled': 'Enabled', + 'signal-robot.risk.stopLossDisabled': 'Disabled', + 'signal-robot.risk.stopLossType': 'Stop Loss Type', + 'signal-robot.risk.stopLossFixedPct': 'Fixed Percentage', + 'signal-robot.risk.stopLossAtrMultiplier': 'ATR Multiplier', + 'signal-robot.risk.stopLossValue': 'Value', + 'signal-robot.risk.stopLossFixedHint': 'e.g. 2%: stop loss when loss reaches 2%', + 'signal-robot.risk.stopLossAtrHint': 'e.g. 2.0: stop loss when price touches ATR*2.0', + 'signal-robot.risk.stopLossDisabledHint': 'Stop loss disabled (high risk)', + 'signal-robot.risk.trailingStopTitle': 'Trailing Stop', + 'signal-robot.risk.activationProfit': 'Activation Profit (%)', + 'signal-robot.risk.activationProfitHint': 'Start trailing when profit reaches this level', + 'signal-robot.risk.callbackPct': 'Callback (%)', + 'signal-robot.risk.callbackPctHint': 'Trigger take profit when price falls from peak by this percentage', + 'signal-robot.risk.trailingStopDisabledHint': 'Trailing stop disabled', + 'signal-robot.risk.otherExitTitle': 'Other Exit Rules', + 'signal-robot.risk.signalExit': 'Allow Signal Exit', + 'signal-robot.risk.signalExitHint': 'Close position immediately when reverse signal appears (e.g. close long when short signal appears)', + 'signal-robot.notify.alert': 'Configure notification methods when signal is triggered', + 'signal-robot.notify.channelTitle': 'Notification Channels', + 'signal-robot.notify.discordWebhook': 'Discord Webhook', + 'signal-robot.notify.genericWebhook': 'Generic Webhook', + 'signal-robot.notify.webhookUrl': 'Webhook URL', + 'signal-robot.notify.webhookUrlPlaceholder': 'https://example.com/webhook', + 'signal-robot.notify.discordUrl': 'Discord URL', + 'signal-robot.notify.discordUrlPlaceholder': 'https://discord.com/api/webhooks/...', + 'signal-robot.operator.greaterThan': 'Greater Than', + 'signal-robot.operator.lessThan': 'Less Than', + 'signal-robot.operator.equal': 'Equal', + 'signal-robot.operator.goldenCross': 'Golden Cross', + 'signal-robot.operator.deathCross': 'Death Cross', + 'signal-robot.operator.increaseBy': 'Increase By', + 'signal-robot.operator.decreaseBy': 'Decrease By', + 'signal-robot.indicator.price': 'Price', + 'signal-robot.indicator.rsi': 'RSI', + 'signal-robot.indicator.kdjK': 'KDJ(K)', + 'signal-robot.indicator.kdjJ': 'KDJ(J)', + 'signal-robot.indicator.macd': 'MACD', + 'signal-robot.indicator.macdHist': 'MACD Histogram', + 'signal-robot.indicator.bollingerUp': 'Bollinger Upper', + 'signal-robot.indicator.bollingerLow': 'Bollinger Lower', + 'signal-robot.indicator.volume': 'Volume', + 'signal-robot.indicator.changePct1h': '1H Change %', + 'signal-robot.indicator.changePct24h': '24H Change %', + 'signal-robot.indicator.signalLine': 'Signal Line' +} + +export default { + ...components, + ...locale +} diff --git a/quantdinger_vue/src/locales/lang/th-TH.js b/quantdinger_vue/src/locales/lang/th-TH.js new file mode 100644 index 0000000..0baced1 --- /dev/null +++ b/quantdinger_vue/src/locales/lang/th-TH.js @@ -0,0 +1,1768 @@ +import antdThTH from 'ant-design-vue/es/locale-provider/th_TH' +import momentTH from 'moment/locale/th' + +const components = { + antLocale: antdThTH, + momentName: 'th', + momentLocale: momentTH +} + +const locale = { + 'submit': 'ส่ง', + 'save': 'บันทึก', + 'submit.ok': 'ส่งสำเร็จ', + 'save.ok': 'บันทึกเรียบร้อยแล้ว', + 'menu.welcome': 'ยินดีต้อนรับ', + 'menu.home': 'หน้าแรก', + 'menu.dashboard': 'แดชบอร์ด', + 'menu.dashboard.indicator': 'การวิเคราะห์ตัวบ่งชี้', + 'menu.dashboard.community': 'ชุมชนตัวบ่งชี้', + 'menu.dashboard.analysis': 'การวิเคราะห์เอไอ', + 'menu.dashboard.tradingAssistant': 'ผู้ช่วยการซื้อขาย', + 'menu.dashboard.aiTradingAssistant': 'ผู้ช่วยการซื้อขาย AI', + 'menu.dashboard.signalRobot': 'หุ่นยนต์สัญญาณ', + 'menu.dashboard.monitor': 'หน้าติดตาม', + 'menu.dashboard.workplace': 'โต๊ะทำงาน', + 'menu.form': 'หน้าแบบฟอร์ม', + 'menu.form.basic-form': 'แบบฟอร์มพื้นฐาน', + 'menu.form.step-form': 'แบบฟอร์มทีละขั้นตอน', + 'menu.form.step-form.info': 'แบบฟอร์มทีละขั้นตอน (กรอกข้อมูลการโอน)', + 'menu.form.step-form.confirm': 'แบบฟอร์มทีละขั้นตอน (ยืนยันข้อมูลการโอน)', + 'menu.form.step-form.result': 'แบบฟอร์มทีละขั้นตอน (สมบูรณ์)', + 'menu.form.advanced-form': 'แบบฟอร์มขั้นสูง', + 'menu.list': 'หน้ารายการ', + 'menu.list.table-list': 'แบบฟอร์มสอบถาม', + 'menu.list.basic-list': 'รายการมาตรฐาน', + 'menu.list.card-list': 'รายการการ์ด', + 'menu.list.search-list': 'รายการค้นหา', + 'menu.list.search-list.articles': 'รายการค้นหา (บทความ)', + 'menu.list.search-list.projects': 'รายการค้นหา (โครงการ)', + 'menu.list.search-list.applications': 'รายการค้นหา (แอป)', + 'menu.profile': 'หน้ารายละเอียด', + 'menu.profile.basic': 'หน้ารายละเอียดเบื้องต้น', + 'menu.profile.advanced': 'หน้ารายละเอียดขั้นสูง', + 'menu.result': 'หน้าผลลัพธ์', + 'menu.result.success': 'หน้าความสำเร็จ', + 'menu.result.fail': 'หน้าความล้มเหลว', + 'menu.exception': 'หน้าข้อยกเว้น', + 'menu.exception.not-permission': '403', + 'menu.exception.not-find': '404', + 'menu.exception.server-error': '500', + 'menu.exception.trigger': 'ข้อผิดพลาดของทริกเกอร์', + 'menu.account': 'หน้าส่วนตัว', + 'menu.account.center': 'ศูนย์ส่วนบุคคล', + 'menu.account.settings': 'การตั้งค่าส่วนบุคคล', + 'menu.account.trigger': 'ข้อผิดพลาดของทริกเกอร์', + 'menu.account.logout': 'ออกจากระบบ', + 'menu.wallet': 'กระเป๋าเงินของฉัน', + 'menu.docs': 'ศูนย์เอกสาร', + 'menu.docs.detail': 'รายละเอียดเอกสาร', + 'menu.header.refreshPage': 'รีเฟรชหน้า', + 'menu.invite.friends': 'ชวนเพื่อน', + 'app.setting.pagestyle': 'การตั้งค่าสไตล์โดยรวม', + 'app.setting.pagestyle.light': 'เมนูสไตล์สดใส', + 'app.setting.pagestyle.dark': 'สไตล์เมนูสีเข้ม', + 'app.setting.pagestyle.realdark': 'โหมดมืด', + 'app.setting.themecolor': 'สีของธีม', + 'app.setting.navigationmode': 'โหมดการนำทาง', + 'app.setting.sidemenu.nav': 'การนำทางแถบด้านข้าง', + 'app.setting.topmenu.nav': 'การนำทางแถบด้านบน', + 'app.setting.content-width': 'ความกว้างของพื้นที่เนื้อหา', + 'app.setting.content-width.tooltip': 'การตั้งค่านี้จะมีผลเฉพาะเมื่อ [การนำทางแถบด้านบน]', + 'app.setting.content-width.fixed': 'ที่ตายตัว', + 'app.setting.content-width.fluid': 'สตรีมมิ่ง', + 'app.setting.fixedheader': 'ส่วนหัวคงที่', + 'app.setting.fixedheader.tooltip': 'กำหนดค่าได้เมื่อแก้ไขส่วนหัว', + 'app.setting.autoHideHeader': 'ซ่อนส่วนหัวเมื่อเลื่อน', + 'app.setting.fixedsidebar': 'เมนูด้านข้างคงที่', + 'app.setting.sidemenu': 'เค้าโครงเมนูด้านข้าง', + 'app.setting.topmenu': 'เค้าโครงเมนูด้านบน', + 'app.setting.othersettings': 'การตั้งค่าอื่นๆ', + 'app.setting.weakmode': 'โหมดความอ่อนแอของสี', + 'app.setting.multitab': 'โหมดหลายแท็บ', + 'app.setting.copy': 'การตั้งค่าการทำสำเนา', + 'app.setting.loading': 'กำลังโหลดธีม', + 'app.setting.copyinfo': 'คัดลอกการตั้งค่าสำเร็จ src/config/defaultSettings.js', + 'app.setting.copy.success': 'คัดลอกเสร็จแล้ว', + 'app.setting.copy.fail': 'การคัดลอกล้มเหลว', + 'app.setting.theme.switching': 'เปลี่ยนธีม!', + 'app.setting.production.hint': 'แถบการกำหนดค่าใช้สำหรับการดูตัวอย่างในสภาพแวดล้อมการพัฒนาเท่านั้น และจะไม่แสดงในสภาพแวดล้อมการใช้งานจริง โปรดคัดลอกและแก้ไขไฟล์การกำหนดค่าด้วยตนเอง', + 'app.setting.themecolor.daybreak': 'รุ่งอรุณสีฟ้า (ค่าเริ่มต้น)', + 'app.setting.themecolor.dust': 'พลบค่ำ', + 'app.setting.themecolor.volcano': 'ภูเขาไฟ', + 'app.setting.themecolor.sunset': 'พระอาทิตย์ตก', + 'app.setting.themecolor.cyan': 'หมิงชิง', + 'app.setting.themecolor.green': 'ออโรร่าสีเขียว', + 'app.setting.themecolor.geekblue': 'เกินบรรยายสีฟ้า', + 'app.setting.themecolor.purple': 'เจียงซี', + 'app.setting.tooltip': 'การตั้งค่าหน้า', + 'user.login.userName': 'ชื่อผู้ใช้', + 'user.login.password': 'รหัสผ่าน', + 'user.login.username.placeholder': 'บัญชี: ผู้ดูแลระบบ', + 'user.login.password.placeholder': 'รหัสผ่าน: admin หรือ ant.design', + 'user.login.message-invalid-credentials': 'การเข้าสู่ระบบล้มเหลว โปรดตรวจสอบอีเมลและรหัสยืนยันของคุณ', + 'user.login.message-invalid-verification-code': 'รหัสยืนยันผิดพลาด', + 'user.login.tab-login-credentials': 'เข้าสู่ระบบรหัสผ่านบัญชี', + 'user.login.tab-login-email': 'เข้าสู่ระบบอีเมล', + 'user.login.tab-login-mobile': 'เข้าสู่ระบบหมายเลขโทรศัพท์มือถือ', + 'user.login.captcha.placeholder': 'กรุณากรอกรหัสยืนยันกราฟิก', + 'user.login.mobile.placeholder': 'หมายเลขโทรศัพท์', + 'user.login.mobile.verification-code.placeholder': 'รหัสยืนยัน', + 'user.login.email.placeholder': 'กรุณากรอกที่อยู่อีเมลของคุณ', + 'user.login.email.verification-code.placeholder': 'กรุณากรอกรหัสยืนยัน', + 'user.login.email.sending': 'กำลังส่งรหัสยืนยัน...', + 'user.login.email.send-success-title': 'คำใบ้', + 'user.login.email.send-success': 'ส่งรหัสยืนยันสำเร็จแล้ว โปรดตรวจสอบอีเมลของคุณ', + 'user.login.sms.send-success': 'ส่งรหัสยืนยันสำเร็จแล้ว โปรดตรวจสอบข้อความ', + 'user.login.remember-me': 'เข้าสู่ระบบอัตโนมัติ', + 'user.login.forgot-password': 'ลืมรหัสผ่าน', + 'user.login.sign-in-with': 'วิธีการเข้าสู่ระบบอื่น ๆ', + 'user.login.signup': 'ลงทะเบียนบัญชี', + 'user.login.login': 'เข้าสู่ระบบ', + 'user.register.register': 'ลงทะเบียน', + 'user.register.email.placeholder': 'จดหมาย', + 'user.register.password.placeholder': 'กรุณากรอกอย่างน้อย 6 ตัวอักษร กรุณาอย่าใช้รหัสผ่านที่คาดเดาได้ง่าย', + 'user.register.password.popover-message': 'กรุณากรอกอย่างน้อย 6 ตัวอักษร กรุณาอย่าใช้รหัสผ่านที่คาดเดาได้ง่าย', + 'user.register.confirm-password.placeholder': 'ยืนยันรหัสผ่าน', + 'user.register.get-verification-code': 'รับรหัสยืนยัน', + 'user.register.sign-in': 'เข้าสู่ระบบโดยใช้บัญชีที่มีอยู่', + 'user.register-result.msg': 'บัญชีของคุณ: {email} การลงทะเบียนสำเร็จ', + 'user.register-result.activation-email': 'อีเมลเปิดใช้งานได้ถูกส่งไปยังกล่องจดหมายของคุณแล้ว และมีอายุ 24 ชั่วโมง โปรดเข้าสู่ระบบอีเมลของคุณทันทีและคลิกลิงก์ในอีเมลเพื่อเปิดใช้งานบัญชีของคุณ', + 'user.register-result.back-home': 'กลับไปที่หน้าแรก', + 'user.register-result.view-mailbox': 'ตรวจสอบกล่องจดหมายของคุณ', + 'user.email.required': 'กรุณากรอกที่อยู่อีเมลของคุณ!', + 'user.email.wrong-format': 'รูปแบบที่อยู่อีเมลไม่ถูกต้อง!', + 'user.userName.required': 'กรุณากรอกชื่อบัญชีหรือที่อยู่อีเมลของคุณ', + 'user.password.required': 'กรุณากรอกรหัสผ่านของคุณ!', + 'user.password.twice.msg': 'รหัสผ่านที่ป้อนสองครั้งไม่ตรงกัน!', + 'user.password.strength.msg': 'รหัสผ่านไม่แข็งแกร่งพอ', + 'user.password.strength.strong': 'ความแข็งแกร่ง: แข็งแกร่ง', + 'user.password.strength.medium': 'ความแข็งแกร่ง: ปานกลาง', + 'user.password.strength.low': 'ความแรง: ต่ำ', + 'user.password.strength.short': 'ความแรง: สั้นเกินไป', + 'user.confirm-password.required': 'กรุณายืนยันรหัสผ่านของคุณ!', + 'user.phone-number.required': 'กรุณากรอกหมายเลขโทรศัพท์มือถือที่ถูกต้อง', + 'user.phone-number.wrong-format': 'รูปแบบหมายเลขโทรศัพท์มือถือผิด!', + 'user.verification-code.required': 'กรุณากรอกรหัสยืนยัน!', + 'user.captcha.required': 'กรุณากรอกรหัสยืนยันกราฟิก!', + 'user.login.infos': 'QuantDinger เป็นเครื่องมือเสริมการวิเคราะห์หุ้นแบบหลายตัวแทนด้วย AI และไม่มีคุณสมบัติในการให้คำปรึกษาด้านการลงทุนในหลักทรัพย์ผลการวิเคราะห์ คะแนน และความคิดเห็นอ้างอิงทั้งหมดในแพลตฟอร์มถูกสร้างขึ้นโดยอัตโนมัติโดย AI ตามข้อมูลในอดีต และใช้เพื่อการเรียนรู้ การวิจัย และการแลกเปลี่ยนทางเทคนิคเท่านั้น และไม่ถือเป็นคำแนะนำในการลงทุนหรือพื้นฐานการตัดสินใจใดๆการลงทุนในหุ้นเกี่ยวข้องกับความเสี่ยงต่างๆ เช่น ความเสี่ยงด้านตลาด ความเสี่ยงด้านสภาพคล่อง ความเสี่ยงด้านนโยบาย ฯลฯ ซึ่งอาจนำไปสู่การสูญเสียเงินต้นได้ผู้ใช้ควรตัดสินใจอย่างอิสระโดยพิจารณาจากการยอมรับความเสี่ยงของตนเอง และพฤติกรรมการลงทุนและผลที่ตามมาที่เกิดจากการใช้เครื่องมือนี้จะต้องตกเป็นภาระของผู้ใช้ตลาดมีความเสี่ยงและการลงทุนต้องระมัดระวัง', + 'user.login.tab-login-web3': 'เข้าสู่ระบบ Web3', + 'user.login.web3.tip': 'เข้าสู่ระบบโดยใช้กระเป๋าเงิน', + 'user.login.web3.connect': 'เชื่อมต่อกระเป๋าเงินและเข้าสู่ระบบ', + 'user.login.web3.no-wallet': 'ตรวจไม่พบกระเป๋าเงิน', + 'user.login.web3.no-address': 'ไม่ได้รับที่อยู่กระเป๋าเงิน', + 'user.login.web3.nonce-failed': 'ไม่สามารถรับหมายเลขสุ่มได้', + 'user.login.web3.verify-failed': 'การตรวจสอบลายเซ็นล้มเหลว', + 'user.login.web3.success': 'เข้าสู่ระบบสำเร็จ', + 'user.login.web3.failed': 'การเข้าสู่ระบบ Wallet ล้มเหลว', + 'nav.no_wallet': 'ตรวจไม่พบกระเป๋าเงิน', + 'nav.copy': 'สำเนา', + 'nav.copy_success': 'คัดลอกเรียบร้อยแล้ว', + 'user.login.oauth.google': 'ลงชื่อเข้าใช้งานด้วย Google', + 'user.login.oauth.github': 'ลงชื่อเข้าใช้ด้วย GitHub', + 'user.login.oauth.loading': 'กำลังเปลี่ยนเส้นทางไปยังหน้าการอนุญาต...', + 'user.login.oauth.failed': 'การเข้าสู่ระบบ OAuth ล้มเหลว', + 'user.login.oauth.get-url-failed': 'ไม่สามารถรับลิงก์การอนุญาตได้', + 'user.login.subtitle': 'ขับเคลื่อนข้อมูลเชิงลึกเชิงปริมาณสู่ตลาดโลก', + 'user.login.legal.title': 'ข้อสงวนสิทธิ์ทางกฎหมาย', + 'user.login.legal.view': 'ดูข้อจำกัดความรับผิดชอบทางกฎหมาย', + 'user.login.legal.collapse': 'ยุบข้อจำกัดความรับผิดชอบทางกฎหมาย', + 'user.login.legal.agree': 'ฉันได้อ่านและยอมรับข้อจำกัดความรับผิดชอบทางกฎหมาย', + 'user.login.legal.required': 'โปรดอ่านและตรวจสอบข้อจำกัดความรับผิดชอบทางกฎหมายก่อน', + 'user.login.legal.content': 'QuantDinger เป็นเครื่องมือเสริมการวิเคราะห์หุ้นแบบหลายตัวแทนด้วย AI และไม่มีคุณสมบัติในการให้คำปรึกษาด้านการลงทุนในหลักทรัพย์ผลการวิเคราะห์ การให้คะแนน และความคิดเห็นอ้างอิงทั้งหมดในแพลตฟอร์มถูกสร้างขึ้นโดยอัตโนมัติโดย AI ตามข้อมูลในอดีต และใช้สำหรับการเรียนรู้ การวิจัย และการแลกเปลี่ยนทางเทคนิคเท่านั้น และไม่ถือเป็นคำแนะนำในการลงทุนหรือพื้นฐานการตัดสินใจใดๆการลงทุนในหุ้นเกี่ยวข้องกับความเสี่ยงต่างๆ เช่น ความเสี่ยงด้านตลาด ความเสี่ยงด้านสภาพคล่อง ความเสี่ยงด้านนโยบาย ฯลฯ ซึ่งอาจนำไปสู่การสูญเสียเงินต้นได้ผู้ใช้ควรตัดสินใจอย่างอิสระโดยพิจารณาจากการยอมรับความเสี่ยงของตนเอง และพฤติกรรมการลงทุนและผลที่ตามมาที่เกิดจากการใช้เครื่องมือนี้จะต้องตกเป็นภาระของผู้ใช้ตลาดมีความเสี่ยงและการลงทุนต้องระมัดระวัง', + 'user.login.privacy.title': 'นโยบายความเป็นส่วนตัวของผู้ใช้', + 'user.login.privacy.view': 'ดูนโยบายความเป็นส่วนตัวของผู้ใช้', + 'user.login.privacy.collapse': 'ปิดข้อกำหนดความเป็นส่วนตัวของผู้ใช้', + 'user.login.privacy.content': 'เราให้ความสำคัญกับความเป็นส่วนตัวและการปกป้องข้อมูลของคุณ 1) ขอบเขตของการรวบรวม: เฉพาะข้อมูลที่จำเป็นในการใช้งานฟังก์ชัน (เช่น อีเมล หมายเลขโทรศัพท์มือถือ รหัสพื้นที่ ที่อยู่กระเป๋าสตางค์ของ Web3) และบันทึกและข้อมูลอุปกรณ์ที่จำเป็นเท่านั้นที่จะถูกรวบรวม 2) วัตถุประสงค์การใช้งาน: สำหรับการเข้าสู่ระบบบัญชีและการตรวจสอบความปลอดภัยการให้บริการฟังก์ชั่น การแก้ไขปัญหาและข้อกำหนดการปฏิบัติตาม 3) การจัดเก็บและความปลอดภัย: ข้อมูลได้รับการเข้ารหัสและจัดเก็บและมีการใช้สิทธิ์ที่จำเป็นและมาตรการควบคุมการเข้าถึงเพื่อป้องกันการเข้าถึง การเปิดเผย หรือการสูญหายโดยไม่ได้รับอนุญาต 4) การแบ่งปันกับบุคคลที่สาม: เว้นแต่กฎหมายและข้อบังคับกำหนดไว้หรือจำเป็นในการให้บริการ ข้อมูลส่วนบุคคลของคุณจะไม่ถูกแบ่งปันกับบุคคลที่สามหากเกี่ยวข้องกับบริการของบุคคลที่สาม (เช่น กระเป๋าเงิน ผู้ให้บริการ SMS) จะถูกประมวลผลในขอบเขตขั้นต่ำที่จำเป็นเพื่อใช้งานฟังก์ชันนี้เท่านั้น 5) คุกกี้/ที่เก็บข้อมูลในเครื่อง: ใช้สำหรับสถานะการเข้าสู่ระบบและการบำรุงรักษาเซสชันที่จำเป็น (เช่น โทเค็น PHPSESSID) คุณสามารถล้างหรือจำกัดได้ในเบราว์เซอร์ 6) สิทธิ์ส่วนบุคคล: คุณสามารถใช้สิทธิ์ของคุณในการสอบถาม แก้ไข ลบ เพิกถอนความยินยอม ฯลฯ ตามกฎหมายและข้อบังคับ 7) การเปลี่ยนแปลงและประกาศ: เมื่อมีการอัปเดตข้อกำหนดเหล่านี้จะแสดงอย่างเด่นชัดบนหน้าเว็บการใช้บริการนี้ต่อไปจะถือว่าคุณได้อ่านและยอมรับเนื้อหาที่อัปเดตแล้วหากคุณไม่ยอมรับข้อกำหนดเหล่านี้หรือการปรับปรุงใด ๆ โปรดหยุดใช้บริการและติดต่อเรา', + 'account.basicInfo': 'ข้อมูลพื้นฐาน', + 'account.id': 'รหัสผู้ใช้', + 'account.username': 'ชื่อผู้ใช้', + 'account.nickname': 'ชื่อนิค', + 'account.email': 'จดหมาย', + 'account.mobile': 'หมายเลขโทรศัพท์', + 'account.web3address': 'ที่อยู่กระเป๋าเงิน', + 'account.pid': 'รหัสผู้อ้างอิง', + 'account.level': 'ระดับผู้ใช้', + 'account.money': 'สมดุล', + 'account.qdtBalance': 'ยอดคงเหลือ QDT', + 'account.score': 'บูรณาการ', + 'account.createtime': 'เวลาลงทะเบียน', + 'account.inviteLink': 'ลิงค์คำเชิญ', + 'account.recharge': 'เติมเงิน', + 'account.rechargeTip': 'โปรดใช้ WeChat หรือ Alipay เพื่อสแกนโค้ด QR ด้านล่างเพื่อเติมเงิน', + 'account.qrCodePlaceholder': 'ตัวยึดรหัส QR (การจำลอง)', + 'account.rechargeAmount': 'จำนวนการเติมเงิน', + 'account.enterAmount': 'กรุณากรอกจำนวนเงินที่เติม', + 'account.rechargeHint': 'จำนวนเงินฝากขั้นต่ำ: 1 QDT', + 'account.confirmRecharge': 'ยืนยันการเติมเงิน', + 'account.enterValidAmount': 'กรุณากรอกจำนวนเงินที่เติมให้ถูกต้อง', + 'account.rechargeSuccess': 'เติมเงินสำเร็จ!ฝากแล้ว {amount} QDT', + 'account.settings.menuMap.basic': 'การตั้งค่าพื้นฐาน', + 'account.settings.menuMap.security': 'การตั้งค่าความปลอดภัย', + 'account.settings.menuMap.notification': 'การแจ้งเตือนข้อความใหม่', + 'account.settings.menuMap.moneyLog': 'รายละเอียดกองทุน', + 'account.moneyLog.empty': 'ยังไม่มีรายละเอียดกองทุน', + 'account.moneyLog.total': 'รวม {total} บันทึก', + 'account.moneyLog.type.purchase': 'ตัวบ่งชี้การซื้อ', + 'account.moneyLog.type.recharge': 'เติมเงิน', + 'account.moneyLog.type.refund': 'คืนเงิน', + 'account.moneyLog.type.reward': 'รางวัล', + 'account.moneyLog.type.income': 'ตัวชี้วัดรายได้', + 'account.moneyLog.type.commission': 'ค่าธรรมเนียมการจัดการแพลตฟอร์ม', + 'wallet.balance': 'ยอดคงเหลือ QDT', + 'wallet.recharge': 'เติมเงิน', + 'wallet.withdraw': 'ถอนเงินสด', + 'wallet.filter': 'กรอง', + 'wallet.reset': 'รีเซ็ต', + 'wallet.totalRecharge': 'การเติมเงินสะสม', + 'wallet.totalWithdraw': 'การถอนเงินสะสม', + 'wallet.totalIncome': 'รายได้สะสม', + 'wallet.records': 'บันทึกการทำธุรกรรมและรายละเอียดกองทุน', + 'wallet.tradingRecords': 'ประวัติการทำธุรกรรม', + 'wallet.moneyLog': 'รายละเอียดกองทุน', + 'wallet.rechargeTip': 'โปรดใช้ WeChat หรือ Alipay เพื่อสแกนโค้ด QR ด้านล่างเพื่อเติมเงิน', + 'wallet.qrCodePlaceholder': 'ตัวยึดรหัส QR (การจำลอง)', + 'wallet.rechargeAmount': 'จำนวนการเติมเงิน', + 'wallet.enterAmount': 'กรุณากรอกจำนวนเงินที่เติม', + 'wallet.rechargeHint': 'จำนวนเงินฝากขั้นต่ำ: 1 QDT', + 'wallet.confirmRecharge': 'ยืนยันการเติมเงิน', + 'wallet.enterValidAmount': 'กรุณากรอกจำนวนเงินที่เติมให้ถูกต้อง', + 'wallet.rechargeSuccess': 'เติมเงินสำเร็จ!ฝากแล้ว {amount} QDT', + 'wallet.rechargeFailed': 'การเติมเงินล้มเหลว', + 'wallet.withdrawTip': 'กรุณากรอกจำนวนเงินที่ถอนและที่อยู่การถอน', + 'wallet.withdrawAmount': 'ถอนจำนวนเงิน', + 'wallet.enterWithdrawAmount': 'กรุณากรอกจำนวนเงินที่ถอน', + 'wallet.withdrawHint': 'จำนวนถอนขั้นต่ำ: 1 QDT', + 'wallet.withdrawAddress': 'ที่อยู่การถอนเงิน (ไม่บังคับ)', + 'wallet.enterWithdrawAddress': 'กรุณากรอกที่อยู่การถอนเงิน', + 'wallet.confirmWithdraw': 'ยืนยันการถอนเงิน', + 'wallet.enterValidWithdrawAmount': 'กรุณากรอกจำนวนเงินที่ถอนให้ถูกต้อง', + 'wallet.insufficientBalance': 'ยอดคงเหลือไม่เพียงพอ', + 'wallet.withdrawSuccess': 'การถอนเงินสำเร็จ!ถอนออก {amount} QDT', + 'wallet.withdrawFailed': 'การถอนเงินล้มเหลว', + 'wallet.noTradingRecords': 'ยังไม่มีบันทึกการทำธุรกรรม', + 'wallet.noMoneyLog': 'ยังไม่มีรายละเอียดกองทุน', + 'wallet.loadTradingRecordsFailed': 'โหลดบันทึกธุรกรรมไม่สำเร็จ', + 'wallet.loadMoneyLogFailed': 'โหลดรายละเอียดกองทุนไม่สำเร็จ', + 'wallet.moneyLogTotal': 'รวม {total} บันทึก', + 'wallet.moneyLogTypeTitle': 'พิมพ์', + 'wallet.moneyLogType.all': 'ทุกประเภท', + 'wallet.table.time': 'เวลา', + 'wallet.table.type': 'พิมพ์', + 'wallet.table.price': 'ราคา', + 'wallet.table.amount': 'ปริมาณ', + 'wallet.table.money': 'จำนวน', + 'wallet.table.balance': 'สมดุล', + 'wallet.table.memo': 'หมายเหตุ', + 'wallet.table.value': 'ค่า', + 'wallet.table.profit': 'กำไรและขาดทุน', + 'wallet.table.commission': 'ค่าธรรมเนียมการจัดการ', + 'wallet.table.total': 'รวม {total} บันทึก', + 'wallet.tradeType.buy': 'ซื้อ', + 'wallet.tradeType.sell': 'ขาย', + 'wallet.tradeType.liquidation': 'บังคับให้เลิกกิจการ', + 'wallet.tradeType.openLong': 'เปิดยาวๆ', + 'wallet.tradeType.addLong': 'กาดอต', + 'wallet.tradeType.closeLong': 'ปินตัว', + 'wallet.tradeType.closeLongStop': 'หยุดขาดทุนและยาว', + 'wallet.tradeType.closeLongProfit': 'ขายทำกำไรและระดับมากขึ้น', + 'wallet.tradeType.openShort': 'เปิดสั้น', + 'wallet.tradeType.addShort': 'เพิ่มสั้น', + 'wallet.tradeType.closeShort': 'ว่างเปล่า', + 'wallet.tradeType.closeShortStop': 'หยุดการสูญเสีย', + 'wallet.tradeType.closeShortProfit': 'ขายทำกำไรและปิดการขาย', + 'wallet.moneyLogType.purchase': 'ตัวบ่งชี้การซื้อ', + 'wallet.moneyLogType.recharge': 'เติมเงิน', + 'wallet.moneyLogType.withdraw': 'ถอนเงินสด', + 'wallet.moneyLogType.refund': 'คืนเงิน', + 'wallet.moneyLogType.reward': 'รางวัล', + 'wallet.moneyLogType.income': 'รายได้', + 'wallet.moneyLogType.commission': 'ค่าธรรมเนียมการจัดการ', + 'wallet.web3Address.required': 'กรุณากรอกที่อยู่กระเป๋าเงิน Web3 ก่อน', + 'wallet.web3Address.requiredDescription': 'ก่อนที่จะเติมเงิน คุณต้องผูกที่อยู่กระเป๋าเงิน Web3 ของคุณเพื่อรับการเติมเงิน', + 'wallet.web3Address.placeholder': 'โปรดป้อนที่อยู่กระเป๋าเงิน Web3 ของคุณ (เริ่มต้นด้วย 0x)', + 'wallet.web3Address.save': 'บันทึกที่อยู่กระเป๋าเงิน', + 'wallet.web3Address.saveSuccess': 'บันทึกที่อยู่ Wallet เรียบร้อยแล้ว', + 'wallet.web3Address.saveFailed': 'ไม่สามารถบันทึกที่อยู่กระเป๋าสตางค์ได้', + 'wallet.web3Address.invalidFormat': 'โปรดป้อนที่อยู่กระเป๋าเงิน Web3 ที่ถูกต้อง (รูปแบบ Ethereum: 42 ตัวอักษรเริ่มต้นด้วย 0x หรือรูปแบบ TRC20: 34 ตัวอักษรเริ่มต้นด้วย T)', + 'wallet.selectCoin': 'เลือกสกุลเงิน', + 'wallet.selectChain': 'ห่วงโซ่การคัดเลือก', + 'wallet.chain.eth': 'ผลประโยชน์ทับซ้อน (ERC20)', + 'wallet.chain.trc20': 'TRC20', + 'wallet.chain.bsc': 'บีเอสซี', + 'wallet.targetQdtAmount': 'จำนวน QDT ที่คุณต้องการแลก (ไม่บังคับ)', + 'wallet.targetQdtAmount.placeholder': 'กรุณากรอกจำนวน QDT ที่คุณต้องการแลกเปลี่ยน ราคา QDT ปัจจุบันคือ: {price} USDT', + 'wallet.targetQdtAmount.requiredUsdt': 'ต้องเติมเงิน: {amount} USDT', + 'wallet.rechargeAddress': 'ที่อยู่เติมเงิน', + 'wallet.copyAddress': 'สำเนา', + 'wallet.copySuccess': 'คัดลอกสำเร็จ!', + 'wallet.copyFailed': 'การคัดลอกล้มเหลว โปรดคัดลอกด้วยตนเอง', + 'wallet.rechargeAddressHint': 'โปรดตรวจสอบให้แน่ใจว่าได้ใช้เครือข่าย {chain} เพื่อฝาก {coin} ไปยังที่อยู่นี้', + 'wallet.qdtPrice.loading': 'กำลังโหลด...', + 'wallet.rechargeTip.new': 'โปรดเลือกสกุลเงินและห่วงโซ่ จากนั้นสแกนโค้ด QR ด้านล่างเพื่อเติมเงิน', + 'wallet.exchangeRate': '1 คิวดีที กลับไปยัง {rate} ดอลลาร์สหรัฐ', + 'wallet.withdrawableAmount': 'จำนวนเงินสดที่มีอยู่', + 'wallet.totalBalance': 'ยอดรวม', + 'wallet.insufficientWithdrawable': 'จำนวนเงินสดที่สามารถถอนออกได้ไม่เพียงพอ เงินสดที่มีอยู่ในปัจจุบันคือ: {amount} QDT', + 'wallet.withdrawAddressRequired': 'กรุณากรอกที่อยู่การถอนเงิน', + 'wallet.withdrawAddressHint': 'โปรดตรวจสอบให้แน่ใจว่าที่อยู่ถูกต้อง หลังจากถอนแล้วไม่สามารถเพิกถอนได้', + 'wallet.withdrawSubmitSuccess': 'ส่งใบสมัครถอนเรียบร้อยแล้ว โปรดรอการตรวจสอบ', + 'menu.footer.contactUs': 'ติดต่อเรา', + 'menu.footer.getSupport': 'รับการสนับสนุน', + 'menu.footer.socialAccounts': 'บัญชีโซเชียล', + 'menu.footer.userAgreement': 'ข้อตกลงผู้ใช้', + 'menu.footer.privacyPolicy': 'นโยบายความเป็นส่วนตัว', + 'menu.footer.support': 'สนับสนุน', + 'menu.footer.featureRequest': 'คำขอคุณสมบัติ', + 'menu.footer.email': 'อีเมล', + 'menu.footer.liveChat': 'แชทสดทุกวันตลอด 24 ชั่วโมง', + 'dashboard.analysis.title': 'การวิเคราะห์หลายมิติ', + 'dashboard.analysis.subtitle': 'แพลตฟอร์มการวิเคราะห์ทางการเงินที่ครอบคลุมที่ขับเคลื่อนด้วย AI', + 'dashboard.analysis.selectSymbol': 'เลือกหรือป้อนรหัสพื้นฐาน', + 'dashboard.analysis.selectModel': 'เลือกรุ่น', + 'dashboard.analysis.startAnalysis': 'เริ่มการวิเคราะห์', + 'dashboard.analysis.history': 'ประวัติศาสตร์', + 'dashboard.analysis.tab.overview': 'การวิเคราะห์ที่ครอบคลุม', + 'dashboard.analysis.tab.fundamental': 'ความรู้พื้นฐาน', + 'dashboard.analysis.tab.technical': 'เทคโนโลยี', + 'dashboard.analysis.tab.news': 'ข่าว', + 'dashboard.analysis.tab.sentiment': 'อารมณ์', + 'dashboard.analysis.tab.risk': 'เสี่ยง', + 'dashboard.analysis.tab.debate': 'การอภิปรายระยะสั้นยาว', + 'dashboard.analysis.tab.decision': 'การตัดสินใจขั้นสุดท้าย', + 'dashboard.analysis.empty.selectSymbol': 'เลือกเป้าหมายเพื่อเริ่มการวิเคราะห์', + 'dashboard.analysis.empty.selectSymbolDesc': 'เลือกจากรายการหุ้นที่เลือกเองหรือป้อนรหัสที่เกี่ยวข้องเพื่อรับรายงานการวิเคราะห์ AI หลายมิติ', + 'dashboard.analysis.empty.startAnalysis': 'คลิกปุ่ม "เริ่มการวิเคราะห์" เพื่อทำการวิเคราะห์หลายมิติ', + 'dashboard.analysis.empty.startAnalysisDesc': 'เราจะให้การวิเคราะห์ที่ครอบคลุมจากหลายมิติ เช่น ปัจจัยพื้นฐาน เทคโนโลยี ข่าวสาร ความรู้สึก และความเสี่ยง', + 'dashboard.analysis.empty.noData': 'ขณะนี้ไม่มีข้อมูลการวิเคราะห์ {type} โปรดดำเนินการวิเคราะห์ที่ครอบคลุมก่อน', + 'dashboard.analysis.empty.noWatchlist': 'ขณะนี้ไม่มีหุ้นที่เลือกเอง', + 'dashboard.analysis.empty.noHistory': 'ยังไม่มีประวัติ', + 'dashboard.analysis.empty.watchlistHint': 'ขณะนี้ไม่มีหุ้นที่เลือกเองกรุณาก่อน', + 'dashboard.analysis.empty.noDebateData': 'ยังไม่มีข้อมูลการอภิปราย', + 'dashboard.analysis.empty.noDecisionData': 'ยังไม่มีข้อมูลการตัดสินใจ', + 'dashboard.analysis.empty.selectAgent': 'โปรดเลือกตัวแทนเพื่อดูผลการวิเคราะห์', + 'dashboard.analysis.loading.analyzing': 'กำลังวิเคราะห์ กรุณารอสักครู่...', + 'dashboard.analysis.loading.fundamental': 'กำลังวิเคราะห์ข้อมูลพื้นฐาน...', + 'dashboard.analysis.loading.technical': 'กำลังวิเคราะห์ตัวชี้วัดทางเทคนิค...', + 'dashboard.analysis.loading.news': 'กำลังวิเคราะห์ข้อมูลข่าว...', + 'dashboard.analysis.loading.sentiment': 'วิเคราะห์อารมณ์ตลาด...', + 'dashboard.analysis.loading.risk': 'กำลังประเมินความเสี่ยง...', + 'dashboard.analysis.loading.debate': 'การอภิปรายระยะสั้นระยะสั้นกำลังดำเนินอยู่...', + 'dashboard.analysis.loading.decision': 'กำลังสร้างการตัดสินใจขั้นสุดท้าย...', + 'dashboard.analysis.score.overall': 'คะแนนโดยรวม', + 'dashboard.analysis.score.recommendation': 'คำแนะนำการลงทุน', + 'dashboard.analysis.score.confidence': 'ความมั่นใจ', + 'dashboard.analysis.dimension.fundamental': 'ความรู้พื้นฐาน', + 'dashboard.analysis.dimension.technical': 'เทคโนโลยี', + 'dashboard.analysis.dimension.news': 'ข่าว', + 'dashboard.analysis.dimension.sentiment': 'อารมณ์', + 'dashboard.analysis.dimension.risk': 'เสี่ยง', + 'dashboard.analysis.card.dimensionScores': 'การให้คะแนนสำหรับแต่ละมิติ', + 'dashboard.analysis.card.overviewReport': 'รายงานการวิเคราะห์ที่ครอบคลุม', + 'dashboard.analysis.card.financialMetrics': 'ตัวชี้วัดทางการเงิน', + 'dashboard.analysis.card.fundamentalReport': 'รายงานการวิเคราะห์ปัจจัยพื้นฐาน', + 'dashboard.analysis.card.technicalIndicators': 'ตัวชี้วัดทางเทคนิค', + 'dashboard.analysis.card.technicalReport': 'รายงานการวิเคราะห์ทางเทคนิค', + 'dashboard.analysis.card.newsList': 'ข่าวที่เกี่ยวข้อง', + 'dashboard.analysis.card.newsReport': 'รายงานการวิเคราะห์ข่าว', + 'dashboard.analysis.card.sentimentIndicators': 'ตัวบ่งชี้ความรู้สึก', + 'dashboard.analysis.card.sentimentReport': 'รายงานการวิเคราะห์ความรู้สึก', + 'dashboard.analysis.card.riskMetrics': 'ตัวชี้วัดความเสี่ยง', + 'dashboard.analysis.card.riskReport': 'รายงานการประเมินความเสี่ยง', + 'dashboard.analysis.card.bullView': 'มุมมองรั้น (กระทิง)', + 'dashboard.analysis.card.bearView': 'มุมมองหยาบคาย (หมี)', + 'dashboard.analysis.card.researchConclusion': 'ข้อสรุปของผู้วิจัย', + 'dashboard.analysis.card.traderPlan': 'แผนผู้ค้า', + 'dashboard.analysis.card.riskDebate': 'การอภิปรายคณะกรรมการความเสี่ยง', + 'dashboard.analysis.card.finalDecision': 'การตัดสินใจครั้งสุดท้าย', + 'dashboard.analysis.card.tradePlanDetail': 'รายละเอียดแผนการเทรด', + 'dashboard.analysis.tradingPlan.entry_price': 'ราคาค่าเข้าชม', + 'dashboard.analysis.tradingPlan.position_size': 'ขนาดตำแหน่ง', + 'dashboard.analysis.tradingPlan.stop_loss': 'หยุดการสูญเสีย', + 'dashboard.analysis.tradingPlan.take_profit': 'ขายทำกำไร', + 'dashboard.analysis.label.confidence': 'ความมั่นใจ', + 'dashboard.analysis.label.keyPoints': 'จุดหลัก', + 'dashboard.analysis.label.riskWarning': 'คำเตือนความเสี่ยง', + 'dashboard.analysis.risk.risky': 'เสี่ยง', + 'dashboard.analysis.risk.neutral': 'มุมมองที่เป็นกลาง (เป็นกลาง)', + 'dashboard.analysis.risk.safe': 'มุมมองแบบอนุรักษ์นิยม (ปลอดภัย)', + 'dashboard.analysis.risk.conclusion': 'สรุปแล้ว', + 'dashboard.analysis.feature.fundamental': 'การวิเคราะห์พื้นฐาน', + 'dashboard.analysis.feature.technical': 'การวิเคราะห์ทางเทคนิค', + 'dashboard.analysis.feature.news': 'การวิเคราะห์ข่าว', + 'dashboard.analysis.feature.sentiment': 'การวิเคราะห์ความรู้สึก', + 'dashboard.analysis.feature.risk': 'การประเมินความเสี่ยง', + 'dashboard.analysis.watchlist.title': 'การเลือกหุ้นของฉัน', + 'dashboard.analysis.watchlist.add': 'เพิ่มไปที่', + 'dashboard.analysis.watchlist.addStock': 'เพิ่มสต็อก', + 'dashboard.analysis.modal.addStock.title': 'เพิ่มหุ้นเสริม', + 'dashboard.analysis.modal.addStock.confirm': 'แน่นอน', + 'dashboard.analysis.modal.addStock.cancel': 'ยกเลิก', + 'dashboard.analysis.modal.addStock.market': 'ประเภทตลาด', + 'dashboard.analysis.modal.addStock.marketPlaceholder': 'กรุณาเลือกตลาด', + 'dashboard.analysis.modal.addStock.marketRequired': 'กรุณาเลือกประเภทตลาด', + 'dashboard.analysis.modal.addStock.symbol': 'รหัสสต๊อกสินค้า', + 'dashboard.analysis.modal.addStock.symbolPlaceholder': 'ตัวอย่างเช่น: AAPL, TSLA, GOOGL, 000001, BTC', + 'dashboard.analysis.modal.addStock.symbolRequired': 'กรุณากรอกรหัสสต๊อกสินค้า', + 'dashboard.analysis.modal.addStock.searchPlaceholder': 'ค้นหารหัสหรือชื่อเป้าหมาย', + 'dashboard.analysis.modal.addStock.searchOrInputPlaceholder': 'ค้นหาหรือป้อนรหัสอ้างอิง (เช่น AAPL, BTC/USDT, EUR/USD)', + 'dashboard.analysis.modal.addStock.searchOrInputHint': 'รองรับการค้นหาวัตถุในฐานข้อมูลหรือป้อนรหัสโดยตรง (ระบบจะรับชื่อโดยอัตโนมัติ)', + 'dashboard.analysis.modal.addStock.search': 'ค้นหา', + 'dashboard.analysis.modal.addStock.searchResults': 'ผลการค้นหา', + 'dashboard.analysis.modal.addStock.hotSymbols': 'เป้าหมายยอดนิยม', + 'dashboard.analysis.modal.addStock.noHotSymbols': 'ยังไม่มีเป้าหมายยอดนิยม', + 'dashboard.analysis.modal.addStock.selectedSymbol': 'เลือกแล้ว', + 'dashboard.analysis.modal.addStock.pleaseSelectSymbol': 'โปรดเลือกเป้าหมายก่อน', + 'dashboard.analysis.modal.addStock.pleaseSelectOrEnterSymbol': 'โปรดเลือกเป้าหมายหรือป้อนรหัสเป้าหมายก่อน', + 'dashboard.analysis.modal.addStock.pleaseEnterSymbol': 'กรุณากรอกรหัสเป้าหมาย', + 'dashboard.analysis.modal.addStock.pleaseSelectMarket': 'โปรดเลือกประเภทตลาดก่อน', + 'dashboard.analysis.modal.addStock.searchFailed': 'การค้นหาล้มเหลว โปรดลองอีกครั้งในภายหลัง', + 'dashboard.analysis.modal.addStock.noSearchResults': 'ไม่พบเป้าหมายที่ตรงกัน', + 'dashboard.analysis.modal.addStock.willAutoFetchName': 'ระบบจะรับชื่อให้อัตโนมัติ', + 'dashboard.analysis.modal.addStock.addDirectly': 'เพิ่มโดยตรง', + 'dashboard.analysis.modal.addStock.nameWillBeFetched': 'ชื่อจะถูกหยิบขึ้นมาโดยอัตโนมัติเมื่อเพิ่ม', + 'dashboard.analysis.market.AShare': 'มีหุ้น', + 'dashboard.analysis.market.USStock': 'หุ้นสหรัฐ', + 'dashboard.analysis.market.HShare': 'หุ้นฮ่องกง', + 'dashboard.analysis.market.Crypto': 'สกุลเงินดิจิทัล', + 'dashboard.analysis.market.Forex': 'ฟอเร็กซ์', + 'dashboard.analysis.market.Futures': 'ฟิวเจอร์ส', + 'dashboard.analysis.modal.history.title': 'บันทึกการวิเคราะห์ทางประวัติศาสตร์', + 'dashboard.analysis.modal.history.viewResult': 'ดูผลลัพธ์', + 'dashboard.analysis.modal.history.completeTime': 'เวลาเสร็จสิ้น', + 'dashboard.analysis.modal.history.error': 'ความผิดพลาด', + 'dashboard.analysis.status.pending': 'รอดำเนินการ', + 'dashboard.analysis.status.processing': 'กำลังประมวลผล', + 'dashboard.analysis.status.completed': 'สมบูรณ์', + 'dashboard.analysis.status.failed': 'ล้มเหลว', + 'dashboard.analysis.message.selectSymbol': 'โปรดเลือกเป้าหมายก่อน', + 'dashboard.analysis.message.taskCreated': 'งานการวิเคราะห์ได้ถูกสร้างขึ้นแล้วและกำลังดำเนินการอยู่เบื้องหลัง...', + 'dashboard.analysis.message.analysisComplete': 'การวิเคราะห์เสร็จสมบูรณ์', + 'dashboard.analysis.message.analysisCompleteCache': 'การวิเคราะห์เสร็จสมบูรณ์ (ใช้ข้อมูลที่แคชไว้)', + 'dashboard.analysis.message.analysisFailed': 'การวิเคราะห์ล้มเหลว โปรดลองอีกครั้งในภายหลัง', + 'dashboard.analysis.message.addStockSuccess': 'เพิ่มเรียบร้อยแล้ว', + 'dashboard.analysis.message.addStockFailed': 'เพิ่มล้มเหลว', + 'dashboard.analysis.message.removeStockSuccess': 'ลบสำเร็จแล้ว', + 'dashboard.analysis.message.removeStockFailed': 'การลบล้มเหลว', + 'dashboard.analysis.test': 'ร้านค้าเลขที่ {no} ถนน Gongzhan', + 'dashboard.analysis.introduce': 'คำอธิบายตัวบ่งชี้', + 'dashboard.analysis.total-sales': 'ยอดขายรวม', + 'dashboard.analysis.day-sales': 'ยอดขายเฉลี่ยต่อวัน¥', + 'dashboard.analysis.visits': 'การเข้าชม', + 'dashboard.analysis.visits-trend': 'แนวโน้มการเข้าชม', + 'dashboard.analysis.visits-ranking': 'อันดับการเข้าชมร้านค้า', + 'dashboard.analysis.day-visits': 'การเข้าชมรายวัน', + 'dashboard.analysis.week': 'รายสัปดาห์ปีต่อปี', + 'dashboard.analysis.day': 'ปีต่อปี', + 'dashboard.analysis.payments': 'จำนวนการชำระเงิน', + 'dashboard.analysis.conversion-rate': 'อัตราการแปลง', + 'dashboard.analysis.operational-effect': 'ผลกระทบจากกิจกรรมการดำเนินงาน', + 'dashboard.analysis.sales-trend': 'แนวโน้มการขาย', + 'dashboard.analysis.sales-ranking': 'อันดับยอดขายของร้าน', + 'dashboard.analysis.all-year': 'ประจำปี', + 'dashboard.analysis.all-month': 'เดือนนี้', + 'dashboard.analysis.all-week': 'สัปดาห์นี้', + 'dashboard.analysis.all-day': 'วันนี้', + 'dashboard.analysis.search-users': 'จำนวนผู้ใช้การค้นหา', + 'dashboard.analysis.per-capita-search': 'การค้นหาต่อหัว', + 'dashboard.analysis.online-top-search': 'การค้นหายอดนิยมออนไลน์', + 'dashboard.analysis.the-proportion-of-sales': 'สัดส่วนประเภทการขาย', + 'dashboard.analysis.dropdown-option-one': 'ปฏิบัติการที่หนึ่ง', + 'dashboard.analysis.dropdown-option-two': 'ปฏิบัติการ 2', + 'dashboard.analysis.channel.all': 'ทุกช่อง', + 'dashboard.analysis.channel.online': 'ออนไลน์', + 'dashboard.analysis.channel.stores': 'เก็บ', + 'dashboard.analysis.sales': 'ฝ่ายขาย', + 'dashboard.analysis.traffic': 'การไหลของผู้โดยสาร', + 'dashboard.analysis.table.rank': 'การจัดอันดับ', + 'dashboard.analysis.table.search-keyword': 'ค้นหาคำสำคัญ', + 'dashboard.analysis.table.users': 'จำนวนผู้ใช้', + 'dashboard.analysis.table.weekly-range': 'เพิ่มขึ้นรายสัปดาห์', + 'dashboard.indicator.selectSymbol': 'เลือกหรือป้อนรหัสพื้นฐาน', + 'dashboard.indicator.emptyWatchlistHint': 'ขณะนี้ไม่มีหุ้นที่เลือกเอง กรุณาเพิ่มก่อน', + 'dashboard.indicator.hint.selectSymbol': 'โปรดเลือกเป้าหมายเพื่อเริ่มการวิเคราะห์', + 'dashboard.indicator.hint.selectSymbolDesc': 'เลือกหรือป้อนรหัสหุ้นในช่องค้นหาด้านบนเพื่อดูกราฟ K-line และตัวชี้วัดทางเทคนิค', + 'dashboard.indicator.retry': 'ลองอีกครั้ง', + 'dashboard.indicator.panel.title': 'ตัวชี้วัดทางเทคนิค', + 'dashboard.indicator.panel.realtimeOn': 'ปิดการอัพเดตแบบเรียลไทม์', + 'dashboard.indicator.panel.realtimeOff': 'เปิดใช้งานการอัปเดตแบบเรียลไทม์', + 'dashboard.indicator.panel.themeLight': 'เปลี่ยนเป็นธีมสีเข้ม', + 'dashboard.indicator.panel.themeDark': 'เปลี่ยนเป็นธีมสว่าง', + 'dashboard.indicator.section.enabled': 'เปิดใช้งานแล้ว', + 'dashboard.indicator.section.added': 'ตัวบ่งชี้ที่ฉันเพิ่ม', + 'dashboard.indicator.section.custom': 'ตัวชี้วัดที่สร้างขึ้นด้วยตัวเอง', + 'dashboard.indicator.section.bought': 'ตัวบ่งชี้ที่ฉันซื้อ', + 'dashboard.indicator.section.myCreated': 'ตัวบ่งชี้ที่ฉันสร้างขึ้น', + 'dashboard.indicator.empty': 'ยังไม่มีตัวบ่งชี้ โปรดเพิ่มหรือสร้างตัวบ่งชี้ก่อน', + 'dashboard.indicator.buy': 'ตัวบ่งชี้การซื้อ', + 'dashboard.indicator.action.start': 'เริ่มต้นขึ้น', + 'dashboard.indicator.action.stop': 'ปิด', + 'dashboard.indicator.action.edit': 'แก้ไข', + 'dashboard.indicator.action.delete': 'ลบ', + 'dashboard.indicator.action.backtest': 'การทดสอบย้อนหลัง', + 'dashboard.indicator.status.normal': 'ปกติ', + 'dashboard.indicator.status.normalPermanent': 'ปกติ (ใช้ได้ถาวร)', + 'dashboard.indicator.status.expired': 'หมดอายุแล้ว', + 'dashboard.indicator.expiry.permanent': 'มีผลถาวร', + 'dashboard.indicator.expiry.noExpiry': 'ไม่มีเวลาหมดอายุ', + 'dashboard.indicator.expiry.expired': 'หมดอายุ: {date}', + 'dashboard.indicator.expiry.expiresOn': 'เวลาหมดอายุ: {date}', + 'dashboard.indicator.delete.confirmTitle': 'ยืนยันการลบ', + 'dashboard.indicator.delete.confirmContent': 'คุณแน่ใจหรือไม่ว่าต้องการลบตัวบ่งชี้ "{name}"?การดำเนินการนี้ไม่สามารถย้อนกลับได้', + 'dashboard.indicator.delete.confirmOk': 'ลบ', + 'dashboard.indicator.delete.confirmCancel': 'ยกเลิก', + 'dashboard.indicator.delete.success': 'ลบสำเร็จ', + 'dashboard.indicator.delete.failed': 'ลบไม่สำเร็จ', + 'dashboard.indicator.save.success': 'บันทึกเรียบร้อยแล้ว', + 'dashboard.indicator.save.failed': 'บันทึกล้มเหลว', + 'dashboard.indicator.error.loadWatchlistFailed': 'ไม่สามารถโหลดหุ้นเสริมได้', + 'dashboard.indicator.error.chartNotReady': 'ส่วนประกอบแผนภูมิไม่ได้เตรียมใช้งาน โปรดเลือกเป้าหมายก่อนแล้วรอให้แผนภูมิโหลด', + 'dashboard.indicator.error.chartMethodNotReady': 'วิธีส่วนประกอบแผนภูมิไม่พร้อม โปรดลองอีกครั้งในภายหลัง', + 'dashboard.indicator.error.chartExecuteNotReady': 'วิธีดำเนินการส่วนประกอบแผนภูมิไม่พร้อม โปรดลองอีกครั้งในภายหลัง', + 'dashboard.indicator.error.parseFailed': 'ไม่สามารถแยกวิเคราะห์โค้ด Python', + 'dashboard.indicator.error.parseFailedCheck': 'ไม่สามารถแยกวิเคราะห์โค้ด Python ได้ โปรดตรวจสอบรูปแบบโค้ด', + 'dashboard.indicator.error.addIndicatorFailed': 'ไม่สามารถเพิ่มตัวบ่งชี้ได้', + 'dashboard.indicator.error.runIndicatorFailed': 'ตัวบ่งชี้การทำงานล้มเหลว', + 'dashboard.indicator.error.pleaseLogin': 'กรุณาเข้าสู่ระบบก่อน', + 'dashboard.indicator.error.loadDataFailed': 'การโหลดข้อมูลล้มเหลว', + 'dashboard.indicator.error.loadDataFailedDesc': 'โปรดตรวจสอบการเชื่อมต่อเครือข่าย', + 'dashboard.indicator.error.pythonEngineFailed': 'โหลดกลไก Python ล้มเหลวและฟังก์ชันตัวบ่งชี้อาจไม่พร้อมใช้งาน', + 'dashboard.indicator.error.chartInitFailed': 'การเริ่มต้นแผนภูมิล้มเหลว', + 'dashboard.indicator.warning.enterCode': 'กรุณากรอกรหัสตัวบ่งชี้ก่อน', + 'dashboard.indicator.warning.pyodideLoadFailed': 'โปรแกรม Python ไม่สามารถโหลดได้', + 'dashboard.indicator.warning.pyodideLoadFailedDesc': 'คุณสมบัตินี้ไม่สามารถใช้ได้ในภูมิภาคหรือสภาพแวดล้อมเครือข่ายปัจจุบันของคุณ', + 'dashboard.indicator.warning.chartNotInitialized': 'แผนภูมิไม่ได้เตรียมใช้งานและไม่สามารถใช้เครื่องมือวาดเส้นได้', + 'dashboard.indicator.success.runIndicator': 'ตัวบ่งชี้ทำงานสำเร็จ', + 'dashboard.indicator.success.clearDrawings': 'ล้างภาพวาดเส้นทั้งหมดแล้ว', + 'dashboard.indicator.sma': 'SMA (ชุดค่าผสมค่าเฉลี่ยเคลื่อนที่)', + 'dashboard.indicator.ema': 'EMA (ชุดค่าผสมค่าเฉลี่ยเคลื่อนที่เอ็กซ์โปเนนเชียล)', + 'dashboard.indicator.rsi': 'RSI (ความแรงสัมพัทธ์)', + 'dashboard.indicator.macd': 'MACD', + 'dashboard.indicator.bb': 'โบลินเจอร์ แบนด์', + 'dashboard.indicator.atr': 'ATR (ช่วงที่แท้จริงเฉลี่ย)', + 'dashboard.indicator.cci': 'CCI (ดัชนีช่องสินค้าโภคภัณฑ์)', + 'dashboard.indicator.williams': 'วิลเลียมส์ %R (ตัวบ่งชี้วิลเลียมส์)', + 'dashboard.indicator.mfi': 'MFI (ดัชนีการไหลของเงิน)', + 'dashboard.indicator.adx': 'ADX (ดัชนีแนวโน้มเฉลี่ย)', + 'dashboard.indicator.obv': 'OBV (คลื่นพลังงาน)', + 'dashboard.indicator.adosc': 'ADOSC (ออสซิลเลเตอร์สะสม/ส่ง)', + 'dashboard.indicator.ad': 'AD (เส้นการสะสม/การกระจาย)', + 'dashboard.indicator.kdj': 'KDJ (ตัวบ่งชี้สุ่ม)', + 'dashboard.indicator.signal.buy': 'ซื้อ', + 'dashboard.indicator.signal.sell': 'ขาย', + 'dashboard.indicator.signal.supertrendBuy': 'ซื้อ SuperTrend', + 'dashboard.indicator.signal.supertrendSell': 'ขาย SuperTrend', + 'dashboard.indicator.chart.kline': 'เคไลน์', + 'dashboard.indicator.chart.volume': 'ปริมาณ', + 'dashboard.indicator.chart.uptrend': 'อัพเทรนด์', + 'dashboard.indicator.chart.downtrend': 'เทรนด์ขาลง', + 'dashboard.indicator.tooltip.time': 'เวลา', + 'dashboard.indicator.tooltip.open': 'เปิด', + 'dashboard.indicator.tooltip.close': 'รับ', + 'dashboard.indicator.tooltip.high': 'สูง', + 'dashboard.indicator.tooltip.low': 'ต่ำ', + 'dashboard.indicator.tooltip.volume': 'ปริมาณ', + 'dashboard.indicator.drawing.line': 'ส่วนของเส้น', + 'dashboard.indicator.drawing.horizontalLine': 'เส้นแนวนอน', + 'dashboard.indicator.drawing.verticalLine': 'เส้นแนวตั้ง', + 'dashboard.indicator.drawing.ray': 'รังสี', + 'dashboard.indicator.drawing.straightLine': 'เส้นตรง', + 'dashboard.indicator.drawing.parallelLine': 'เส้นขนาน', + 'dashboard.indicator.drawing.priceLine': 'เส้นราคา', + 'dashboard.indicator.drawing.priceChannel': 'ช่องราคา', + 'dashboard.indicator.drawing.fibonacciLine': 'เส้นฟีโบนักชี', + 'dashboard.indicator.drawing.clearAll': 'ล้างภาพวาดเส้นทั้งหมด', + 'dashboard.indicator.market.AShare': 'มีหุ้น', + 'dashboard.indicator.market.USStock': 'หุ้นสหรัฐ', + 'dashboard.indicator.market.HShare': 'หุ้นฮ่องกง', + 'dashboard.indicator.market.Crypto': 'สกุลเงินดิจิทัล', + 'dashboard.indicator.market.Forex': 'ฟอเร็กซ์', + 'dashboard.indicator.market.Futures': 'ฟิวเจอร์ส', + 'dashboard.indicator.create': 'สร้างตัวบ่งชี้', + 'dashboard.indicator.editor.title': 'สร้าง/แก้ไขตัวบ่งชี้', + 'dashboard.indicator.editor.name': 'ชื่อตัวบ่งชี้', + 'dashboard.indicator.editor.nameRequired': 'กรุณากรอกชื่อตัวบ่งชี้', + 'dashboard.indicator.editor.namePlaceholder': 'กรุณากรอกชื่อตัวบ่งชี้', + 'dashboard.indicator.editor.description': 'คำอธิบายตัวบ่งชี้', + 'dashboard.indicator.editor.descriptionPlaceholder': 'โปรดป้อนคำอธิบายของตัวบ่งชี้ (ตัวเลือก)', + 'dashboard.indicator.editor.code': 'รหัสหลาม', + 'dashboard.indicator.editor.codeRequired': 'กรุณากรอกรหัสตัวบ่งชี้', + 'dashboard.indicator.editor.codePlaceholder': 'กรุณากรอกรหัสไพธอน', + 'dashboard.indicator.editor.run': 'วิ่ง', + 'dashboard.indicator.editor.runHint': 'คลิกปุ่มเรียกใช้เพื่อดูตัวอย่างเอฟเฟกต์ตัวบ่งชี้บนแผนภูมิเส้น K', + 'dashboard.indicator.editor.guide': 'คู่มือการพัฒนา', + 'dashboard.indicator.editor.guideTitle': 'คู่มือการพัฒนาตัวบ่งชี้ Python', + 'dashboard.indicator.editor.save': 'บันทึก', + 'dashboard.indicator.editor.cancel': 'ยกเลิก', + 'dashboard.indicator.editor.unnamed': 'ตัวบ่งชี้ที่ไม่มีชื่อ', + 'dashboard.indicator.editor.publishToCommunity': 'โพสต์ไปที่ชุมชน', + 'dashboard.indicator.editor.publishToCommunityHint': 'เมื่อเผยแพร่แล้ว ผู้ใช้รายอื่นจะสามารถดูและใช้ตัวบ่งชี้ของคุณในชุมชนได้', + 'dashboard.indicator.editor.indicatorType': 'ประเภทตัวบ่งชี้', + 'dashboard.indicator.editor.indicatorTypeRequired': 'โปรดเลือกประเภทตัวบ่งชี้', + 'dashboard.indicator.editor.indicatorTypePlaceholder': 'โปรดเลือกประเภทตัวบ่งชี้', + 'dashboard.indicator.editor.previewImage': 'ดูตัวอย่าง', + 'dashboard.indicator.editor.uploadImage': 'อัพโหลดรูปภาพ', + 'dashboard.indicator.editor.previewImageHint': 'ขนาดที่แนะนำ: 800x400 ไม่เกิน 2MB', + 'dashboard.indicator.editor.pricing': 'ราคาขาย (QDT)', + 'dashboard.indicator.editor.pricingType.free': 'ฟรี', + 'dashboard.indicator.editor.pricingType.permanent': 'ถาวร', + 'dashboard.indicator.editor.pricingType.monthly': 'การชำระเงินรายเดือน', + 'dashboard.indicator.editor.price': 'ราคา', + 'dashboard.indicator.editor.priceRequired': 'กรุณากรอกราคา', + 'dashboard.indicator.editor.pricePlaceholder': 'กรุณากรอกราคา', + 'dashboard.indicator.editor.pricingHint': 'แม้ว่าราคาของตัวบ่งชี้ฟรีจะเป็น 0 แต่แพลตฟอร์มจะให้รางวัลแก่คุณ (QDT) ตามการใช้ตัวบ่งชี้และอัตราการชมเชยของคุณ', + 'dashboard.indicator.editor.aiGenerate': 'รุ่นอัจฉริยะ', + 'dashboard.indicator.editor.aiPromptPlaceholder': 'บอกความคิดของคุณมาให้ฉัน แล้วฉันจะสร้างโค้ดตัวบ่งชี้ Python ให้กับคุณ', + 'dashboard.indicator.editor.aiGenerateBtn': 'AI สร้างโค้ด', + 'dashboard.indicator.editor.aiPromptRequired': 'กรุณากรอกความคิดของคุณ', + 'dashboard.indicator.editor.aiGenerateSuccess': 'การสร้างรหัสสำเร็จ', + 'dashboard.indicator.editor.aiGenerateError': 'การสร้างโค้ดล้มเหลว โปรดลองอีกครั้งในภายหลัง', + 'dashboard.indicator.backtest.title': 'ตัวบ่งชี้ย้อนกลับ', + 'dashboard.indicator.backtest.config': 'พารามิเตอร์การทดสอบย้อนกลับ', + 'dashboard.indicator.backtest.startDate': 'วันที่เริ่มต้น', + 'dashboard.indicator.backtest.endDate': 'วันที่สิ้นสุด', + 'dashboard.indicator.backtest.selectStartDate': 'เลือกวันที่เริ่มต้น', + 'dashboard.indicator.backtest.selectEndDate': 'เลือกวันที่สิ้นสุด', + 'dashboard.indicator.backtest.startDateRequired': 'โปรดเลือกวันที่เริ่มต้น', + 'dashboard.indicator.backtest.endDateRequired': 'โปรดเลือกวันที่สิ้นสุด', + 'dashboard.indicator.backtest.initialCapital': 'ทุนเริ่มต้น', + 'dashboard.indicator.backtest.initialCapitalRequired': 'กรุณากรอกเงินทุนเริ่มต้น', + 'dashboard.indicator.backtest.commission': 'ค่าธรรมเนียมการจัดการ', + 'dashboard.indicator.backtest.leverage': 'อัตราส่วนเลเวอเรจ', + 'dashboard.indicator.backtest.tradeDirection': 'ทิศทางการซื้อขาย', + 'dashboard.indicator.backtest.longOnly': 'ยาวไป', + 'dashboard.indicator.backtest.shortOnly': 'สั้น', + 'dashboard.indicator.backtest.both': 'สองทาง', + 'dashboard.indicator.backtest.run': 'เริ่มการทดสอบย้อนหลัง', + 'dashboard.indicator.backtest.rerun': 'ทดสอบย้อนหลังอีกครั้ง', + 'dashboard.indicator.backtest.close': 'ปิด', + 'dashboard.indicator.backtest.running': 'อยู่ระหว่างการทดสอบตัวบ่งชี้ย้อนกลับ...', + 'dashboard.indicator.backtest.results': 'ผลการทดสอบย้อนหลัง', + 'dashboard.indicator.backtest.totalReturn': 'ผลตอบแทนรวม', + 'dashboard.indicator.backtest.annualReturn': 'รายได้ต่อปี', + 'dashboard.indicator.backtest.maxDrawdown': 'การเบิกถอนสูงสุด', + 'dashboard.indicator.backtest.sharpeRatio': 'อัตราส่วนความคมชัด', + 'dashboard.indicator.backtest.winRate': 'อัตราการชนะ', + 'dashboard.indicator.backtest.profitFactor': 'อัตราส่วนกำไร-ขาดทุน', + 'dashboard.indicator.backtest.totalTrades': 'จำนวนธุรกรรม', + 'dashboard.indicator.totalReturn': 'ผลตอบแทนรวม', + 'dashboard.indicator.annualReturn': 'อัตราผลตอบแทนต่อปี', + 'dashboard.indicator.maxDrawdown': 'การเบิกถอนสูงสุด', + 'dashboard.indicator.sharpeRatio': 'อัตราส่วนความคมชัด', + 'dashboard.indicator.winRate': 'อัตราการชนะ', + 'dashboard.indicator.profitFactor': 'อัตราส่วนกำไร-ขาดทุน', + 'dashboard.indicator.totalTrades': 'จำนวนธุรกรรมทั้งหมด', + 'dashboard.indicator.backtest.totalCommission': 'ค่าธรรมเนียมการจัดการทั้งหมด', + 'dashboard.indicator.backtest.equityCurve': 'เส้นอัตราผลตอบแทน', + 'dashboard.indicator.backtest.strategy': 'ตัวชี้วัดรายได้', + 'dashboard.indicator.backtest.benchmark': 'ผลตอบแทนมาตรฐาน', + 'dashboard.indicator.backtest.tradeHistory': 'ประวัติการทำธุรกรรม', + 'dashboard.indicator.backtest.tradeTime': 'เวลา', + 'dashboard.indicator.backtest.tradeType': 'พิมพ์', + 'dashboard.indicator.backtest.buy': 'ซื้อ', + 'dashboard.indicator.backtest.sell': 'ขาย', + 'dashboard.indicator.backtest.liquidation': 'การชำระบัญชี', + 'dashboard.indicator.backtest.openLong': 'เปิดยาวๆ', + 'dashboard.indicator.backtest.closeLong': 'ปินตัว', + 'dashboard.indicator.backtest.closeLongStop': 'ใกล้ถึงระยะยาว (หยุดการสูญเสีย)', + 'dashboard.indicator.backtest.closeLongProfit': 'ปิดมากขึ้น (ทำกำไร)', + 'dashboard.indicator.backtest.addLong': 'เพิ่มตำแหน่งยาว', + 'dashboard.indicator.backtest.openShort': 'เปิดสั้น', + 'dashboard.indicator.backtest.closeShort': 'ว่างเปล่า', + 'dashboard.indicator.backtest.closeShortStop': 'ปิด (หยุดการสูญเสีย)', + 'dashboard.indicator.backtest.closeShortProfit': 'ปิด (ทำกำไร)', + 'dashboard.indicator.backtest.addShort': 'เพิ่มตำแหน่งสั้น', + 'dashboard.indicator.backtest.price': 'ราคา', + 'dashboard.indicator.backtest.amount': 'ปริมาณ', + 'dashboard.indicator.backtest.balance': 'ยอดเงินในบัญชี', + 'dashboard.indicator.backtest.profit': 'กำไรและขาดทุน', + 'dashboard.indicator.backtest.success': 'การทดสอบย้อนกลับเสร็จสมบูรณ์', + 'dashboard.indicator.backtest.failed': 'การทดสอบย้อนกลับล้มเหลว โปรดลองอีกครั้งในภายหลัง', + 'dashboard.indicator.backtest.noIndicatorCode': 'ไม่มีรหัสที่ทดสอบย้อนกลับได้สำหรับตัวบ่งชี้นี้', + 'dashboard.indicator.backtest.noSymbol': 'โปรดเลือกเป้าหมายการทำธุรกรรมก่อน', + 'dashboard.indicator.backtest.dateRangeExceeded': 'ช่วงเวลาการทดสอบย้อนหลังเกินขีดจำกัด: {timeframe} รอบสามารถทดสอบย้อนหลังได้สูงสุด {maxRange}', + 'dashboard.indicator.backtest.dateRangeExceededDays': 'Backtest range exceeds limit: {timeframe} max {maxRange} ({maxDays} days)', + 'dashboard.indicator.backtest.metaLine': 'Symbol: {symbol} | Market: {market} | Timeframe: {timeframe}', + 'dashboard.indicator.backtest.savedRunId': 'Backtest saved. Run ID: {id}', + 'dashboard.indicator.backtest.prev': 'Previous', + 'dashboard.indicator.backtest.next': 'Next', + 'dashboard.indicator.backtest.hint.entryPctMax': 'Max entry: {maxPct}% (reserve budget for future scale-ins)', + 'dashboard.indicator.backtest.steps.strategy.title': 'Strategy', + 'dashboard.indicator.backtest.steps.strategy.desc': 'Position sizing & risk controls', + 'dashboard.indicator.backtest.steps.trading.title': 'Trading settings', + 'dashboard.indicator.backtest.steps.trading.desc': 'Time range, capital, fees, leverage', + 'dashboard.indicator.backtest.steps.results.title': 'Results', + 'dashboard.indicator.backtest.steps.results.desc': 'Backtest output', + 'dashboard.indicator.backtest.panel.risk': 'Risk management (SL/TP/Trailing)', + 'dashboard.indicator.backtest.panel.scale': 'Scale in / DCA (trend & mean-reversion)', + 'dashboard.indicator.backtest.panel.reduce': 'Reduce position (trend & adverse)', + 'dashboard.indicator.backtest.panel.position': 'Position sizing', + 'dashboard.indicator.backtest.field.stopLossPct': 'Stop Loss (%)', + 'dashboard.indicator.backtest.field.takeProfitPct': 'Take Profit (%)', + 'dashboard.indicator.backtest.field.trailingEnabled': 'Trailing stop / take profit', + 'dashboard.indicator.backtest.field.trailingStopPct': 'Trailing drawdown (%)', + 'dashboard.indicator.backtest.field.trailingActivationPct': 'Trailing activation (%)', + 'dashboard.indicator.backtest.field.slippage': 'Slippage (%)', + 'dashboard.indicator.backtest.field.trendAddEnabled': 'Trend-following scale-in', + 'dashboard.indicator.backtest.field.dcaAddEnabled': 'Mean-reversion scale-in', + 'dashboard.indicator.backtest.field.trendAddStepPct': 'Scale-in trigger (%)', + 'dashboard.indicator.backtest.field.dcaAddStepPct': 'Scale-in trigger (%)', + 'dashboard.indicator.backtest.field.trendAddSizePct': 'Scale-in size (% of capital)', + 'dashboard.indicator.backtest.field.dcaAddSizePct': 'Scale-in size (% of capital)', + 'dashboard.indicator.backtest.field.trendAddMaxTimes': 'Max scale-in times', + 'dashboard.indicator.backtest.field.dcaAddMaxTimes': 'Max scale-in times', + 'dashboard.indicator.backtest.field.trendReduceEnabled': 'Trend reduce', + 'dashboard.indicator.backtest.field.adverseReduceEnabled': 'Adverse reduce', + 'dashboard.indicator.backtest.field.trendReduceStepPct': 'Trend trigger (%)', + 'dashboard.indicator.backtest.field.adverseReduceStepPct': 'Adverse trigger (%)', + 'dashboard.indicator.backtest.field.trendReduceSizePct': 'Reduce size (% of position)', + 'dashboard.indicator.backtest.field.adverseReduceSizePct': 'Adverse reduce size (% of position)', + 'dashboard.indicator.backtest.field.trendReduceMaxTimes': 'Max trend reduce times', + 'dashboard.indicator.backtest.field.adverseReduceMaxTimes': 'Max adverse reduce times', + 'dashboard.indicator.backtest.field.entryPct': 'Entry size (% of capital)', + 'dashboard.indicator.backtest.field.minOrderPct': 'Min order size (% of capital) (optional)', + 'dashboard.indicator.backtest.closeLongTrailing': 'Close Long (Trailing)', + 'dashboard.indicator.backtest.reduceLong': 'Reduce Long', + 'dashboard.indicator.backtest.closeShortTrailing': 'Close Short (Trailing)', + 'dashboard.indicator.backtest.reduceShort': 'Reduce Short', + 'dashboard.docs.title': 'ศูนย์เอกสาร', + 'dashboard.docs.search.placeholder': 'ค้นหาเอกสาร...', + 'dashboard.docs.category.all': 'ทั้งหมด', + 'dashboard.docs.category.guide': 'คู่มือการพัฒนา', + 'dashboard.docs.category.api': 'เอกสารประกอบ API', + 'dashboard.docs.category.tutorial': 'บทช่วยสอน', + 'dashboard.docs.category.faq': 'คำถามที่พบบ่อย', + 'dashboard.docs.featured.title': 'เอกสารแนะนำ', + 'dashboard.docs.featured.tag': 'แนะนำ', + 'dashboard.docs.list.views': 'เรียกดู', + 'dashboard.docs.list.author': 'ผู้เขียน', + 'dashboard.docs.list.empty': 'ยังไม่มีเอกสาร', + 'dashboard.docs.list.backToAll': 'กลับไปที่เอกสารทั้งหมด', + 'dashboard.docs.list.total': 'เอกสารทั้งหมด {count}', + 'dashboard.docs.detail.back': 'กลับไปยังรายการเอกสาร', + 'dashboard.docs.detail.updatedAt': 'อัปเดตเมื่อ', + 'dashboard.docs.detail.related': 'เอกสารที่เกี่ยวข้อง', + 'dashboard.docs.detail.notFound': 'ไม่มีเอกสารอยู่', + 'dashboard.docs.detail.error': 'เอกสารไม่มีอยู่หรือถูกลบไปแล้ว', + 'dashboard.docs.search.result': 'ผลการค้นหา', + 'dashboard.docs.search.keyword': 'คำหลัก', + 'community.filter.indicatorType': 'ประเภทตัวบ่งชี้', + 'community.filter.all': 'ทั้งหมด', + 'community.filter.other': 'ตัวเลือกอื่นๆ', + 'community.filter.pricing': 'ประเภทการกำหนดราคา', + 'community.filter.allPricing': 'ราคาทั้งหมด', + 'community.filter.sortBy': 'จัดเรียงตาม', + 'community.filter.search': 'ค้นหา', + 'community.filter.searchPlaceholder': 'ค้นหาชื่อเมตริก', + 'community.indicatorType.trend': 'ประเภทเทรนด์', + 'community.indicatorType.momentum': 'ประเภทโมเมนตัม', + 'community.indicatorType.volatility': 'ความผันผวน', + 'community.indicatorType.volume': 'ปริมาณ', + 'community.indicatorType.custom': 'ปรับแต่ง', + 'community.pricing.free': 'ฟรี', + 'community.pricing.paid': 'จ่าย', + 'community.sort.downloads': 'ดาวน์โหลด', + 'community.sort.rating': 'คะแนน', + 'community.sort.newest': 'รุ่นล่าสุด', + 'community.pagination.total': 'ตัวชี้วัดทั้งหมด {total}', + 'community.noDescription': 'ยังไม่มีคำอธิบาย', + 'community.detail.type': 'พิมพ์', + 'community.detail.pricing': 'ราคา', + 'community.detail.rating': 'คะแนน', + 'community.detail.downloads': 'ดาวน์โหลด', + 'community.detail.author': 'ผู้เขียน', + 'community.detail.description': 'การแนะนำ', + 'community.detail.detailContent': 'คำอธิบายโดยละเอียด', + 'community.detail.backtestStats': 'สถิติการทดสอบย้อนหลัง', + 'community.action.purchase': 'ตัวบ่งชี้การซื้อ', + 'community.action.addToMyIndicators': 'เพิ่มไปยังตัวบ่งชี้ของฉัน', + 'community.action.favorite': 'เก็บรวบรวม', + 'community.action.unfavorite': 'ยกเลิกรายการโปรด', + 'community.action.buyNow': 'ซื้อเลย', + 'community.action.renew': 'ต่ออายุ', + 'community.purchase.price': 'ราคา', + 'community.purchase.permanent': 'ถาวร', + 'community.purchase.monthly': 'ดวงจันทร์', + 'community.purchase.confirmBuy': 'ยืนยันที่จะซื้อตัวบ่งชี้นี้ ({price} QDT)?', + 'community.purchase.confirmRenew': 'ยืนยันที่จะต่ออายุตัวบ่งชี้นี้ ({price} QDT/เดือน) หรือไม่', + 'community.purchase.confirmFree': 'ยืนยันเพื่อเพิ่มตัวบ่งชี้ฟรีนี้หรือไม่', + 'community.purchase.confirmTitle': 'ยืนยันการดำเนินการ', + 'community.purchase.owned': 'คุณได้ซื้อตัวบ่งชี้นี้ (ใช้ได้ถาวร)', + 'community.purchase.ownIndicator': 'นี่คือเมตริกที่คุณเผยแพร่', + 'community.purchase.expired': 'การสมัครของคุณหมดอายุแล้ว', + 'community.purchase.expiresOn': 'เวลาหมดอายุ: {date}', + 'community.tabs.detail': 'รายละเอียดตัวบ่งชี้', + 'community.tabs.backtest': 'ข้อมูลการทดสอบย้อนหลัง', + 'community.tabs.ratings': 'ความคิดเห็นของผู้ใช้', + 'community.rating.myRating': 'คะแนนของฉัน', + 'community.rating.stars': '{count} ดาว', + 'community.rating.commentPlaceholder': 'แบ่งปันประสบการณ์ของคุณ...', + 'community.rating.submit': 'ส่งเรตติ้ง.', + 'community.rating.modify': 'ปรับปรุงใหม่', + 'community.rating.saveModify': 'บันทึกการเปลี่ยนแปลง', + 'community.rating.cancel': 'ยกเลิก', + 'community.rating.selectRating': 'โปรดเลือกการให้คะแนน', + 'community.rating.success': 'เรตติ้งสำเร็จ', + 'community.rating.modifySuccess': 'แก้ไขการตรวจสอบเรียบร้อยแล้ว', + 'community.rating.failed': 'การให้คะแนนล้มเหลว', + 'community.rating.noRatings': 'ยังไม่มีความคิดเห็น', + 'community.backtest.note': 'ข้อมูลข้างต้นเป็นผลการทดสอบย้อนกลับในอดีตและมีไว้เพื่อการอ้างอิงเท่านั้น และไม่ได้แสดงถึงรายได้ในอนาคต', + 'community.backtest.noData': 'ยังไม่มีข้อมูลย้อนหลัง', + 'community.backtest.uploadHint': 'ผู้เขียนยังไม่ได้อัพโหลดข้อมูล backtest', + 'community.message.loadFailed': 'ไม่สามารถโหลดรายการตัวบ่งชี้', + 'community.message.purchaseProcessing': 'กำลังประมวลผลคำขอซื้อ...', + 'community.message.downloadSuccess': 'เพิ่มเมตริกลงในรายการเมตริกของฉันแล้ว', + 'community.message.favoriteSuccess': 'รวบรวมสำเร็จ', + 'community.message.unfavoriteSuccess': 'ยกเลิกการรวบรวมเรียบร้อยแล้ว', + 'community.message.operationSuccess': 'การดำเนินงานประสบความสำเร็จ', + 'community.message.operationFailed': 'การดำเนินการล้มเหลว', + 'dashboard.totalEquity': 'ส่วนของผู้ถือหุ้นทั้งหมด', + 'dashboard.totalPnL': 'กำไรและขาดทุนทั้งหมด', + 'dashboard.aiStrategies': 'กลยุทธ์เอไอ', + 'dashboard.indicatorStrategies': 'กลยุทธ์ตัวบ่งชี้', + 'dashboard.running': 'วิ่ง', + 'dashboard.pnlHistory': 'กำไรและขาดทุนในอดีต', + 'dashboard.strategyPerformance': 'อัตราส่วนกำไรและขาดทุนของกลยุทธ์', + 'dashboard.recentTrades': 'การทำธุรกรรมล่าสุด', + 'dashboard.currentPositions': 'ตำแหน่งปัจจุบัน', + 'dashboard.table.time': 'เวลา', + 'dashboard.table.strategy': 'ชื่อกรมธรรม์', + 'dashboard.table.symbol': 'เป้า', + 'dashboard.table.type': 'พิมพ์', + 'dashboard.table.side': 'ทิศทาง', + 'dashboard.table.size': 'เปิดดอกเบี้ย', + 'dashboard.table.entryPrice': 'ราคาเปิดเฉลี่ย', + 'dashboard.table.price': 'ราคา', + 'dashboard.table.amount': 'ปริมาณ', + 'dashboard.table.profit': 'กำไรและขาดทุน', + 'dashboard.pendingOrders': 'บันทึกการดำเนินการคำสั่งซื้อ', + 'dashboard.totalOrders': 'รวม {total} รายการ', + 'dashboard.viewError': 'ดูข้อผิดพลาด', + 'dashboard.filled': 'ดำเนินการแล้ว', + 'dashboard.orderTable.time': 'เวลาที่สร้าง', + 'dashboard.orderTable.strategy': 'กลยุทธ์', + 'dashboard.orderTable.symbol': 'คู่เทรด', + 'dashboard.orderTable.signalType': 'ประเภทสัญญาณ', + 'dashboard.orderTable.amount': 'จำนวน', + 'dashboard.orderTable.price': 'ราคาที่ดำเนินการ', + 'dashboard.orderTable.status': 'สถานะ', + 'dashboard.orderTable.executedAt': 'เวลาที่ดำเนินการ', + 'dashboard.signalType.openLong': 'เปิด Long', + 'dashboard.signalType.openShort': 'เปิด Short', + 'dashboard.signalType.closeLong': 'ปิด Long', + 'dashboard.signalType.closeShort': 'ปิด Short', + 'dashboard.signalType.addLong': 'เพิ่ม Long', + 'dashboard.signalType.addShort': 'เพิ่ม Short', + 'dashboard.status.pending': 'รอดำเนินการ', + 'dashboard.status.processing': 'กำลังดำเนินการ', + 'dashboard.status.completed': 'เสร็จสิ้น', + 'dashboard.status.failed': 'ล้มเหลว', + 'dashboard.status.cancelled': 'ยกเลิกแล้ว', + 'dashboard.table.pnl': 'กำไรหรือขาดทุนที่ยังไม่เกิดขึ้นจริง', + 'form.basic-form.basic.title': 'แบบฟอร์มพื้นฐาน', + 'form.basic-form.basic.description': 'หน้าแบบฟอร์มใช้เพื่อรวบรวมหรือตรวจสอบข้อมูลจากผู้ใช้ โดยทั่วไปจะใช้แบบฟอร์มพื้นฐานในสถานการณ์จำลองที่มีรายการข้อมูลน้อย', + 'form.basic-form.title.label': 'ชื่อ', + 'form.basic-form.title.placeholder': 'ตั้งชื่อให้เป้าหมาย', + 'form.basic-form.title.required': 'กรุณากรอกชื่อ', + 'form.basic-form.date.label': 'วันที่เริ่มต้นและสิ้นสุด', + 'form.basic-form.placeholder.start': 'วันที่เริ่มต้น', + 'form.basic-form.placeholder.end': 'วันที่สิ้นสุด', + 'form.basic-form.date.required': 'กรุณาเลือกวันที่เริ่มต้นและสิ้นสุด', + 'form.basic-form.goal.label': 'คำอธิบายเป้าหมาย', + 'form.basic-form.goal.placeholder': 'กรุณาป้อนเป้าหมายการทำงานแบบเป็นขั้นตอนของคุณ', + 'form.basic-form.goal.required': 'กรุณากรอกคำอธิบายเป้าหมาย', + 'form.basic-form.standard.label': 'วัด', + 'form.basic-form.standard.placeholder': 'กรุณากรอกตัวชี้วัด', + 'form.basic-form.standard.required': 'กรุณากรอกตัวชี้วัด', + 'form.basic-form.client.label': 'ลูกค้า', + 'form.basic-form.client.required': 'โปรดอธิบายลูกค้าที่คุณให้บริการ', + 'form.basic-form.label.tooltip': 'ผู้รับบริการเป้าหมาย', + 'form.basic-form.client.placeholder': 'โปรดอธิบายลูกค้าที่คุณให้บริการ ลูกค้าภายในโดยตรง @ชื่อ/หมายเลขงาน', + 'form.basic-form.invites.label': 'เชิญผู้วิจารณ์', + 'form.basic-form.invites.placeholder': 'กรุณา @name/หมายเลขพนักงาน โดยตรง คุณสามารถเชิญได้สูงสุด 5 คน', + 'form.basic-form.weight.label': 'น้ำหนัก', + 'form.basic-form.weight.placeholder': 'กรุณาเข้า', + 'form.basic-form.public.label': 'กำหนดเป้าหมายสาธารณะ', + 'form.basic-form.label.help': 'ลูกค้าและผู้ตรวจสอบจะถูกแชร์โดยค่าเริ่มต้น', + 'form.basic-form.radio.public': 'สาธารณะ', + 'form.basic-form.radio.partially-public': 'เปิดเผยต่อสาธารณะบางส่วน', + 'form.basic-form.radio.private': 'ส่วนตัว', + 'form.basic-form.publicUsers.placeholder': 'เปิดให้', + 'form.basic-form.option.A': 'เพื่อนร่วมงาน 1', + 'form.basic-form.option.B': 'เพื่อนร่วมงาน 2', + 'form.basic-form.option.C': 'เพื่อนร่วมงานสามคน', + 'form.basic-form.email.required': 'กรุณากรอกที่อยู่อีเมลของคุณ!', + 'form.basic-form.email.wrong-format': 'รูปแบบที่อยู่อีเมลไม่ถูกต้อง!', + 'form.basic-form.userName.required': 'กรุณากรอกชื่อผู้ใช้!', + 'form.basic-form.password.required': 'กรุณากรอกรหัสผ่านของคุณ!', + 'form.basic-form.password.twice': 'รหัสผ่านที่ป้อนสองครั้งไม่ตรงกัน!', + 'form.basic-form.strength.msg': 'กรุณากรอกอย่างน้อย 6 ตัวอักษร กรุณาอย่าใช้รหัสผ่านที่คาดเดาได้ง่าย', + 'form.basic-form.strength.strong': 'ความแข็งแกร่ง: แข็งแกร่ง', + 'form.basic-form.strength.medium': 'ความแข็งแกร่ง: ปานกลาง', + 'form.basic-form.strength.short': 'ความแรง: สั้นเกินไป', + 'form.basic-form.confirm-password.required': 'กรุณายืนยันรหัสผ่านของคุณ!', + 'form.basic-form.phone-number.required': 'กรุณากรอกหมายเลขโทรศัพท์มือถือของคุณ!', + 'form.basic-form.phone-number.wrong-format': 'รูปแบบหมายเลขโทรศัพท์มือถือผิด!', + 'form.basic-form.verification-code.required': 'กรุณากรอกรหัสยืนยัน!', + 'form.basic-form.form.get-captcha': 'รับรหัสยืนยัน', + 'form.basic-form.captcha.second': 'ที่สอง', + 'form.basic-form.form.optional': '(ไม่จำเป็น)', + 'form.basic-form.form.submit': 'ส่ง', + 'form.basic-form.form.save': 'บันทึก', + 'form.basic-form.email.placeholder': 'จดหมาย', + 'form.basic-form.password.placeholder': 'รหัสผ่านอย่างน้อย 6 ตัวอักษร คำนึงถึงตัวพิมพ์เล็กและตัวพิมพ์ใหญ่', + 'form.basic-form.confirm-password.placeholder': 'ยืนยันรหัสผ่าน', + 'form.basic-form.phone-number.placeholder': 'หมายเลขโทรศัพท์', + 'form.basic-form.verification-code.placeholder': 'รหัสยืนยัน', + 'result.success.title': 'ส่งสำเร็จ', + 'result.success.description': 'หน้าผลลัพธ์การส่งใช้เพื่อตอบกลับผลการประมวลผลของชุดงานการดำเนินงาน หากเป็นเพียงการดำเนินการง่ายๆ ให้ใช้ข้อความตอบรับพร้อมท์ทั่วโลกพื้นที่ข้อความนี้สามารถแสดงคำแนะนำเสริมง่ายๆ หากจำเป็นต้องแสดง "เอกสาร" พื้นที่สีเทาด้านล่างสามารถแสดงเนื้อหาที่ซับซ้อนมากขึ้นได้', + 'result.success.operate-title': 'ชื่อโครงการ', + 'result.success.operate-id': 'รหัสโครงการ', + 'result.success.principal': 'บุคคลที่รับผิดชอบ', + 'result.success.operate-time': 'เวลาที่มีประสิทธิภาพ', + 'result.success.step1-title': 'สร้างโครงการ', + 'result.success.step1-operator': 'คู ลิลี่', + 'result.success.step2-title': 'การทบทวนเบื้องต้นของแผนก', + 'result.success.step2-operator': 'โจว เหมาเหมา', + 'result.success.step2-extra': 'ด่วน', + 'result.success.step3-title': 'การตรวจสอบทางการเงิน', + 'result.success.step4-title': 'เสร็จ', + 'result.success.btn-return': 'กลับไปที่รายการ', + 'result.success.btn-project': 'ดูรายการ', + 'result.success.btn-print': 'พิมพ์', + 'result.fail.error.title': 'การส่งล้มเหลว', + 'result.fail.error.description': 'โปรดตรวจสอบและแก้ไขข้อมูลต่อไปนี้ก่อนที่จะส่งอีกครั้ง', + 'result.fail.error.hint-title': 'เนื้อหาที่คุณส่งมีข้อผิดพลาดต่อไปนี้:', + 'result.fail.error.hint-text1': 'บัญชีของคุณถูกระงับ', + 'result.fail.error.hint-btn1': 'ละลายทันที', + 'result.fail.error.hint-text2': 'บัญชีของคุณยังไม่มีสิทธิ์สมัคร', + 'result.fail.error.hint-btn2': 'อัพเกรดตอนนี้', + 'result.fail.error.btn-text': 'กลับไปแก้ไข', + 'account.settings.menuMap.custom': 'การปรับเปลี่ยนในแบบของคุณ', + 'account.settings.menuMap.binding': 'การผูกบัญชี', + 'account.settings.basic.avatar': 'อวตาร', + 'account.settings.basic.change-avatar': 'เปลี่ยนอวตาร', + 'account.settings.basic.email': 'จดหมาย', + 'account.settings.basic.email-message': 'กรุณากรอกอีเมล์ของคุณ!', + 'account.settings.basic.nickname': 'ชื่อนิค', + 'account.settings.basic.nickname-message': 'กรุณากรอกชื่อเล่นของคุณ!', + 'account.settings.basic.profile': 'ประวัติโดยย่อ', + 'account.settings.basic.profile-message': 'กรุณากรอกรายละเอียดส่วนตัวของคุณ!', + 'account.settings.basic.profile-placeholder': 'ประวัติโดยย่อ', + 'account.settings.basic.country': 'ประเทศ/ภูมิภาค', + 'account.settings.basic.country-message': 'กรุณากรอกประเทศหรือภูมิภาคของคุณ!', + 'account.settings.basic.geographic': 'จังหวัดและเมือง', + 'account.settings.basic.geographic-message': 'กรุณากรอกจังหวัดและเมืองของคุณ!', + 'account.settings.basic.address': 'ที่อยู่ถนน', + 'account.settings.basic.address-message': 'กรุณากรอกที่อยู่ของคุณ!', + 'account.settings.basic.phone': 'เบอร์ติดต่อ', + 'account.settings.basic.phone-message': 'กรุณากรอกหมายเลขติดต่อของคุณ!', + 'account.settings.basic.update': 'อัพเดตข้อมูลพื้นฐาน', + 'account.settings.basic.update.success': 'อัปเดตข้อมูลพื้นฐานสำเร็จ', + 'account.settings.security.strong': 'ทรงพลัง', + 'account.settings.security.medium': 'กลาง', + 'account.settings.security.weak': 'อ่อนแอ', + 'account.settings.security.password': 'รหัสผ่านบัญชี', + 'account.settings.security.password-description': 'ความยากของรหัสผ่านปัจจุบัน:', + 'account.settings.security.phone': 'การรักษาความปลอดภัยโทรศัพท์มือถือ', + 'account.settings.security.phone-description': 'โทรศัพท์มือถือที่ผูกไว้แล้ว:', + 'account.settings.security.question': 'ปัญหาด้านความปลอดภัย', + 'account.settings.security.question-description': 'ไม่มีการตั้งค่าคำถามเพื่อความปลอดภัย ซึ่งสามารถปกป้องความปลอดภัยของบัญชีได้อย่างมีประสิทธิภาพ', + 'account.settings.security.email': 'ผูกอีเมล', + 'account.settings.security.email-description': 'ที่อยู่อีเมลที่ถูกผูกไว้แล้ว:', + 'account.settings.security.mfa': 'อุปกรณ์เอ็มเอฟเอ', + 'account.settings.security.mfa-description': 'อุปกรณ์ MFA ไม่ได้ถูกผูกไว้ หลังจากผูกแล้วสามารถยืนยันอีกครั้งได้', + 'account.settings.security.modify': 'ปรับปรุงใหม่', + 'account.settings.security.set': 'ตั้งค่า', + 'account.settings.security.bind': 'ผูกพัน', + 'account.settings.binding.taobao': 'ผูก Taobao', + 'account.settings.binding.taobao-description': 'บัญชี Taobao ไม่ได้ถูกผูกไว้ในขณะนี้', + 'account.settings.binding.alipay': 'ผูกอาลีเพย์', + 'account.settings.binding.alipay-description': 'บัญชี Alipay ไม่ได้ถูกผูกไว้ในขณะนี้', + 'account.settings.binding.dingding': 'การผูกมัด DingTalk', + 'account.settings.binding.dingding-description': 'ขณะนี้ไม่มีบัญชี DingTalk ถูกผูกไว้', + 'account.settings.binding.bind': 'ผูกพัน', + 'account.settings.notification.password': 'รหัสผ่านบัญชี', + 'account.settings.notification.password-description': 'ข้อความจากผู้ใช้รายอื่นจะได้รับแจ้งในรูปแบบของข้อความไซต์', + 'account.settings.notification.messages': 'ข้อความของระบบ', + 'account.settings.notification.messages-description': 'ข้อความของระบบจะได้รับแจ้งในรูปแบบของข้อความไซต์', + 'account.settings.notification.todo': 'งานที่ต้องทำ', + 'account.settings.notification.todo-description': 'งานที่ต้องทำจะได้รับแจ้งในรูปแบบของข้อความในไซต์', + 'account.settings.settings.open': 'เปิด', + 'account.settings.settings.close': 'ปิด', + 'trading-assistant.title': 'ผู้ช่วยการซื้อขาย', + 'trading-assistant.strategyList': 'รายการกลยุทธ์', + 'trading-assistant.createStrategy': 'สร้างนโยบาย', + 'trading-assistant.noStrategy': 'ยังไม่มีกลยุทธ์', + 'trading-assistant.selectStrategy': 'โปรดเลือกกลยุทธ์จากด้านซ้ายเพื่อดูรายละเอียด', + 'trading-assistant.startStrategy': 'กลยุทธ์การเปิดตัว', + 'trading-assistant.stopStrategy': 'กลยุทธ์การหยุด', + 'trading-assistant.editStrategy': 'กลยุทธ์ด้านบรรณาธิการ', + 'trading-assistant.deleteStrategy': 'ลบนโยบาย', + 'trading-assistant.status.running': 'วิ่ง', + 'trading-assistant.status.stopped': 'หยุดแล้ว', + 'trading-assistant.status.error': 'ความผิดพลาด', + 'trading-assistant.strategyType.IndicatorStrategy': 'กลยุทธ์ตัวบ่งชี้ทางเทคนิค', + 'trading-assistant.strategyType.PromptBasedStrategy': 'กลยุทธ์คำคิว', + 'trading-assistant.strategyType.GridStrategy': 'กลยุทธ์กริด', + 'trading-assistant.tabs.tradingRecords': 'ประวัติการทำธุรกรรม', + 'trading-assistant.tabs.positions': 'บันทึกตำแหน่ง', + 'trading-assistant.tabs.equityCurve': 'เส้นส่วนของผู้ถือหุ้น', + 'trading-assistant.form.step1': 'เลือกตัวบ่งชี้', + 'trading-assistant.form.step2': 'การกำหนดค่าการแลกเปลี่ยน', + 'trading-assistant.form.step3': 'พารามิเตอร์กลยุทธ์', + 'trading-assistant.form.indicator': 'เลือกตัวบ่งชี้', + 'trading-assistant.form.indicatorHint': 'คุณสามารถเลือกได้เฉพาะตัวบ่งชี้ทางเทคนิคที่คุณซื้อหรือสร้างขึ้นเท่านั้น', + 'trading-assistant.form.qdtCostHints': 'การใช้กลยุทธ์จะใช้ QDT โปรดตรวจสอบให้แน่ใจว่าบัญชีมียอดคงเหลือ QDT เพียงพอ', + 'trading-assistant.form.indicatorDescription': 'คำอธิบายตัวบ่งชี้', + 'trading-assistant.form.noDescription': 'ยังไม่มีคำอธิบาย', + 'trading-assistant.form.exchange': 'เลือกการแลกเปลี่ยน', + 'trading-assistant.form.apiKey': 'คีย์ API', + 'trading-assistant.form.secretKey': 'รหัสลับ', + 'trading-assistant.form.passphrase': 'ข้อความรหัสผ่าน', + 'trading-assistant.form.testConnection': 'ทดสอบการเชื่อมต่อ', + 'trading-assistant.form.strategyName': 'ชื่อกรมธรรม์', + 'trading-assistant.form.symbol': 'คู่การซื้อขาย', + 'trading-assistant.form.symbolHint': 'ขณะนี้รองรับเฉพาะคู่การซื้อขายสกุลเงินดิจิทัลเท่านั้น', + 'trading-assistant.form.initialCapital': 'จำนวนเงินลงทุน', + 'trading-assistant.form.marketType': 'ประเภทตลาด', + 'trading-assistant.form.marketTypeFutures': 'สัญญา', + 'trading-assistant.form.marketTypeSpot': 'จุดสินค้า', + 'trading-assistant.form.marketTypeHint': 'สัญญารองรับการซื้อขายสองทางและเลเวอเรจ ในขณะที่สปอตรองรับเฉพาะตำแหน่งซื้อและเลเวอเรจคงที่ที่ 1x', + 'trading-assistant.form.leverage': 'ใช้ประโยชน์หลายอย่าง', + 'trading-assistant.form.leverageHint': 'สัญญา: 1-125 ครั้ง, จุด: แก้ไข 1 ครั้ง', + 'trading-assistant.form.spotLeverageFixed': 'เลเวอเรจการซื้อขายสปอตคงที่ที่ 1x', + 'trading-assistant.form.spotOnlyLongHint': 'การซื้อขายแบบสปอตรองรับเฉพาะตำแหน่งซื้อเท่านั้น', + 'trading-assistant.form.tradeDirection': 'ทิศทางการซื้อขาย', + 'trading-assistant.form.tradeDirectionLong': 'ยาวเท่านั้น', + 'trading-assistant.form.tradeDirectionShort': 'สั้นเท่านั้น', + 'trading-assistant.form.tradeDirectionBoth': 'ธุรกรรมสองทาง', + 'trading-assistant.form.timeframe': 'ช่วงเวลา', + 'trading-assistant.form.klinePeriod': 'ช่วงเวลา K-Line', + 'trading-assistant.form.timeframe1m': '1 นาที', + 'trading-assistant.form.timeframe5m': '5 นาที', + 'trading-assistant.form.timeframe15m': '15 นาที', + 'trading-assistant.form.timeframe30m': '30 นาที', + 'trading-assistant.form.timeframe1H': '1 ชั่วโมง', + 'trading-assistant.form.timeframe4H': '4 ชั่วโมง', + 'trading-assistant.form.timeframe1D': '1 วัน', + 'trading-assistant.form.selectStrategyType': 'เลือกประเภทกลยุทธ์', + 'trading-assistant.form.indicatorStrategy': 'กลยุทธ์ตัวบ่งชี้', + 'trading-assistant.form.indicatorStrategyDesc': 'กลยุทธ์การซื้อขายอัตโนมัติตามตัวบ่งชี้ทางเทคนิค', + 'trading-assistant.form.aiStrategy': 'กลยุทธ์ AI', + 'trading-assistant.form.aiStrategyDesc': 'กลยุทธ์การซื้อขายอัตโนมัติตามการตัดสินใจอัจฉริยะของ AI', + 'trading-assistant.form.enableAiFilter': 'เปิดใช้งานตัวกรองการตัดสินใจอัจฉริยะ AI', + 'trading-assistant.form.enableAiFilterHint': 'เมื่อเปิดใช้งาน สัญญาณตัวบ่งชี้จะถูกกรองโดย AI เพื่อปรับปรุงคุณภาพการซื้อขาย', + 'trading-assistant.form.aiFilterPrompt': 'ข้อความแจ้งแบบกำหนดเอง', + 'trading-assistant.form.aiFilterPromptHint': 'ให้คำแนะนำแบบกำหนดเองสำหรับการกรอง AI เว้นว่างไว้เพื่อใช้ค่าเริ่มต้นของระบบ', + 'trading-assistant.validation.strategyTypeRequired': 'กรุณาเลือกประเภทกลยุทธ์', + 'trading-assistant.form.advancedSettings': 'การตั้งค่าขั้นสูง', + 'trading-assistant.form.orderMode': 'โหมดการสั่งซื้อ', + 'trading-assistant.form.orderModeMaker': 'ผู้สร้าง', + 'trading-assistant.form.orderModeTaker': 'ราคาตลาด (เทคเกอร์)', + 'trading-assistant.form.orderModeHint': 'โหมดคำสั่งซื้อที่รอดำเนินการใช้คำสั่งซื้อแบบจำกัดและค่าธรรมเนียมการจัดการต่ำกว่า โหมดราคาตลาดจะดำเนินการทันทีและค่าธรรมเนียมการจัดการจะสูงขึ้น', + 'trading-assistant.form.makerWaitSec': 'เวลารอสำหรับคำสั่งซื้อที่รอดำเนินการ (วินาที)', + 'trading-assistant.form.makerWaitSecHint': 'เวลาที่ต้องรอการทำธุรกรรมหลังจากทำการสั่งซื้อ ยกเลิกและลองอีกครั้งหลังจากหมดเวลา', + 'trading-assistant.form.makerRetries': 'จำนวนการลองใหม่สำหรับคำสั่งซื้อที่รอดำเนินการ', + 'trading-assistant.form.makerRetriesHint': 'จำนวนครั้งสูงสุดในการลองใหม่เมื่อไม่ได้ดำเนินการคำสั่งซื้อที่รอดำเนินการ', + 'trading-assistant.form.fallbackToMarket': 'คำสั่งซื้อที่รอดำเนินการล้มเหลวและราคาตลาดถูกลดระดับลง', + 'trading-assistant.form.fallbackToMarketHint': 'เมื่อการเปิด/ปิดคำสั่งซื้อที่รอดำเนินการไม่เสร็จสมบูรณ์ ควรดาวน์เกรดเป็นคำสั่งซื้อในตลาดเพื่อให้แน่ใจว่าธุรกรรมจะเสร็จสมบูรณ์หรือไม่', + 'trading-assistant.form.marginMode': 'โหมดระยะขอบ', + 'trading-assistant.form.marginModeCross': 'โกดังเต็ม', + 'trading-assistant.form.marginModeIsolated': 'ตำแหน่งที่โดดเดี่ยว', + 'trading-assistant.form.stopLossPct': 'อัตราการสูญเสียหยุด (%)', + 'trading-assistant.form.stopLossPctHint': 'ตั้งค่าเปอร์เซ็นต์การหยุดการขาดทุน 0 หมายถึงไม่ได้เปิดใช้งานการหยุดการขาดทุน', + 'trading-assistant.form.takeProfitPct': 'อัตราส่วนการทำกำไร (%)', + 'trading-assistant.form.takeProfitPctHint': 'ตั้งค่าเปอร์เซ็นต์การทำกำไร 0 หมายถึงปิดการใช้งานการทำกำไร', + 'trading-assistant.form.signalMode': 'โหมดสัญญาณ', + 'trading-assistant.form.signalModeConfirmed': 'ยืนยันโหมด', + 'trading-assistant.form.signalModeAggressive': 'โหมดก้าวร้าว', + 'trading-assistant.form.signalModeHint': 'โหมดการยืนยัน: ตรวจสอบเฉพาะเส้น K ที่เสร็จสมบูรณ์เท่านั้น โหมดก้าวร้าว: ตรวจสอบการสร้างเส้น K ด้วย', + 'trading-assistant.form.cancel': 'ยกเลิก', + 'trading-assistant.form.prev': 'ขั้นตอนก่อนหน้า', + 'trading-assistant.form.next': 'ขั้นตอนต่อไป', + 'trading-assistant.form.confirmCreate': 'ยืนยันการสร้าง', + 'trading-assistant.form.confirmEdit': 'ยืนยันการเปลี่ยนแปลง', + 'trading-assistant.messages.createSuccess': 'สร้างกลยุทธ์สำเร็จแล้ว', + 'trading-assistant.messages.createFailed': 'ไม่สามารถสร้างนโยบายได้', + 'trading-assistant.messages.updateSuccess': 'อัปเดตนโยบายสำเร็จแล้ว', + 'trading-assistant.messages.updateFailed': 'อัปเดตนโยบายไม่สำเร็จ', + 'trading-assistant.messages.deleteSuccess': 'ลบนโยบายสำเร็จแล้ว', + 'trading-assistant.messages.deleteFailed': 'ลบนโยบายล้มเหลว', + 'trading-assistant.messages.startSuccess': 'กลยุทธ์เริ่มต้นได้สำเร็จ', + 'trading-assistant.messages.startFailed': 'ไม่สามารถเริ่มนโยบายได้', + 'trading-assistant.messages.stopSuccess': 'กลยุทธ์หยุดสำเร็จ', + 'trading-assistant.messages.stopFailed': 'กลยุทธ์หยุดล้มเหลว', + 'trading-assistant.messages.loadFailed': 'ไม่สามารถรับรายการนโยบาย', + 'trading-assistant.messages.runningWarning': 'กลยุทธ์กำลังทำงานอยู่ โปรดหยุดกลยุทธ์ก่อนที่จะแก้ไข', + 'trading-assistant.messages.deleteConfirmWithName': 'คุณแน่ใจหรือไม่ว่าต้องการลบนโยบาย "{name}" การดำเนินการนี้ไม่สามารถย้อนกลับได้', + 'trading-assistant.messages.deleteConfirm': 'คุณแน่ใจหรือไม่ว่าต้องการลบนโยบายนี้การดำเนินการนี้ไม่สามารถย้อนกลับได้', + 'trading-assistant.messages.loadTradesFailed': 'ไม่สามารถรับบันทึกธุรกรรมได้', + 'trading-assistant.messages.loadPositionsFailed': 'ไม่สามารถรับบันทึกตำแหน่งได้', + 'trading-assistant.messages.loadEquityFailed': 'ไม่สามารถรับเส้นโค้งส่วนของผู้ถือหุ้นได้', + 'trading-assistant.messages.loadIndicatorsFailed': 'ไม่สามารถโหลดรายการตัวบ่งชี้ โปรดลองอีกครั้งในภายหลัง', + 'trading-assistant.messages.spotLimitations': 'การซื้อขายแบบสปอตได้รับการตั้งค่าโดยอัตโนมัติเป็นระยะยาวเท่านั้น เลเวอเรจ 1 เท่า', + 'trading-assistant.messages.autoFillApiConfig': 'การกำหนดค่า API ในอดีตของการแลกเปลี่ยนได้รับการเติมข้อมูลโดยอัตโนมัติ', + 'trading-assistant.placeholders.selectIndicator': 'โปรดเลือกตัวบ่งชี้', + 'trading-assistant.placeholders.selectExchange': 'กรุณาเลือกการแลกเปลี่ยน', + 'trading-assistant.placeholders.inputApiKey': 'กรุณากรอกคีย์ API', + 'trading-assistant.placeholders.inputSecretKey': 'กรุณากรอกรหัสลับ', + 'trading-assistant.placeholders.inputPassphrase': 'กรุณากรอกรหัสผ่าน', + 'trading-assistant.placeholders.inputStrategyName': 'โปรดป้อนชื่อนโยบาย', + 'trading-assistant.placeholders.selectSymbol': 'กรุณาเลือกคู่การซื้อขาย', + 'trading-assistant.placeholders.selectTimeframe': 'โปรดเลือกช่วงเวลา', + 'trading-assistant.placeholders.selectKlinePeriod': 'โปรดเลือกช่วงเวลา K-Line', + 'trading-assistant.placeholders.inputAiFilterPrompt': 'กรุณาใส่ข้อความแจ้งแบบกำหนดเอง (ไม่บังคับ)', + 'trading-assistant.validation.indicatorRequired': 'โปรดเลือกตัวบ่งชี้', + 'trading-assistant.validation.exchangeRequired': 'กรุณาเลือกการแลกเปลี่ยน', + 'trading-assistant.validation.apiKeyRequired': 'กรุณากรอกคีย์ API', + 'trading-assistant.validation.secretKeyRequired': 'กรุณากรอกรหัสลับ', + 'trading-assistant.validation.passphraseRequired': 'กรุณากรอกรหัสผ่าน', + 'trading-assistant.validation.exchangeConfigIncomplete': 'กรุณากรอกข้อมูลการกำหนดค่าการแลกเปลี่ยนให้ครบถ้วน', + 'trading-assistant.validation.testConnectionRequired': 'กรุณาคลิกปุ่ม "ทดสอบการเชื่อมต่อ" และตรวจสอบให้แน่ใจว่าการเชื่อมต่อสำเร็จ', + 'trading-assistant.validation.testConnectionFailed': 'การทดสอบการเชื่อมต่อล้มเหลว กรุณาตรวจสอบการกำหนดค่าและทดสอบอีกครั้ง', + 'trading-assistant.validation.strategyNameRequired': 'โปรดป้อนชื่อนโยบาย', + 'trading-assistant.validation.symbolRequired': 'กรุณาเลือกคู่การซื้อขาย', + 'trading-assistant.validation.initialCapitalRequired': 'กรุณากรอกจำนวนเงินลงทุน', + 'trading-assistant.validation.leverageRequired': 'กรุณากรอกอัตราส่วนเลเวอเรจ', + 'trading-assistant.table.time': 'เวลา', + 'trading-assistant.table.type': 'พิมพ์', + 'trading-assistant.table.price': 'ราคา', + 'trading-assistant.table.amount': 'ปริมาณ', + 'trading-assistant.table.value': 'จำนวน', + 'trading-assistant.table.commission': 'ค่าธรรมเนียมการจัดการ', + 'trading-assistant.table.symbol': 'คู่การซื้อขาย', + 'trading-assistant.table.side': 'ทิศทาง', + 'trading-assistant.table.size': 'ปริมาณตำแหน่ง', + 'trading-assistant.table.entryPrice': 'ราคาเปิด', + 'trading-assistant.table.currentPrice': 'ราคาปัจจุบัน', + 'trading-assistant.table.unrealizedPnl': 'กำไรหรือขาดทุนที่ยังไม่เกิดขึ้นจริง', + 'trading-assistant.table.pnlPercent': 'อัตราส่วนกำไรและขาดทุน', + 'trading-assistant.table.buy': 'ซื้อ', + 'trading-assistant.table.sell': 'ขาย', + 'trading-assistant.table.long': 'ยาวไป', + 'trading-assistant.table.short': 'สั้น', + 'trading-assistant.table.noPositions': 'ยังไม่มีตำแหน่ง', + 'trading-assistant.detail.title': 'รายละเอียดกลยุทธ์', + 'trading-assistant.detail.strategyName': 'ชื่อกรมธรรม์', + 'trading-assistant.detail.strategyType': 'ประเภทกลยุทธ์', + 'trading-assistant.detail.status': 'สถานะ', + 'trading-assistant.detail.tradingMode': 'รูปแบบการซื้อขาย', + 'trading-assistant.detail.exchange': 'แลกเปลี่ยน', + 'trading-assistant.detail.initialCapital': 'ทุนเริ่มต้น', + 'trading-assistant.detail.totalInvestment': 'จำนวนเงินลงทุนทั้งหมด', + 'trading-assistant.detail.currentEquity': 'มูลค่าสุทธิในปัจจุบัน', + 'trading-assistant.detail.totalPnl': 'กำไรและขาดทุนทั้งหมด', + 'trading-assistant.detail.indicatorName': 'ชื่อตัวบ่งชี้', + 'trading-assistant.detail.maxLeverage': 'เลเวอเรจสูงสุด', + 'trading-assistant.detail.decideInterval': 'ช่วงการตัดสินใจ', + 'trading-assistant.detail.symbols': 'วัตถุธุรกรรม', + 'trading-assistant.detail.createdAt': 'เวลาสร้าง', + 'trading-assistant.detail.updatedAt': 'เวลาอัปเดต', + 'trading-assistant.detail.llmConfig': 'การกำหนดค่าโมเดล AI', + 'trading-assistant.detail.exchangeConfig': 'การกำหนดค่าการแลกเปลี่ยน', + 'trading-assistant.detail.provider': 'ผู้ให้บริการ', + 'trading-assistant.detail.modelId': 'รหัสรุ่น', + 'trading-assistant.detail.close': 'ปิด', + 'trading-assistant.detail.loadFailed': 'ไม่สามารถรับรายละเอียดนโยบายได้', + 'trading-assistant.equity.noData': 'ยังไม่มีข้อมูลมูลค่าสุทธิ', + 'trading-assistant.equity.equity': 'มูลค่าสุทธิ', + 'trading-assistant.exchange.tradingMode': 'รูปแบบการซื้อขาย', + 'trading-assistant.exchange.virtual': 'การซื้อขายจำลอง', + 'trading-assistant.exchange.live': 'การทำธุรกรรมจริง', + 'trading-assistant.exchange.selectExchange': 'เลือกการแลกเปลี่ยน', + 'trading-assistant.exchange.walletAddress': 'ที่อยู่กระเป๋าเงิน', + 'trading-assistant.exchange.walletAddressPlaceholder': 'กรุณากรอกที่อยู่กระเป๋าเงินของคุณ (เริ่มต้นด้วย 0x)', + 'trading-assistant.exchange.privateKey': 'รหัสส่วนตัว', + 'trading-assistant.exchange.privateKeyPlaceholder': 'กรุณากรอกรหัสส่วนตัว (64 ตัวอักษร)', + 'trading-assistant.exchange.testConnection': 'ทดสอบการเชื่อมต่อ', + 'trading-assistant.exchange.connectionSuccess': 'การเชื่อมต่อสำเร็จ', + 'trading-assistant.exchange.connectionFailed': 'การเชื่อมต่อล้มเหลว', + 'trading-assistant.exchange.testFailed': 'การทดสอบการเชื่อมต่อล้มเหลว', + 'trading-assistant.exchange.fillComplete': 'กรุณากรอกข้อมูลการกำหนดค่าการแลกเปลี่ยนให้ครบถ้วน', + 'trading-assistant.strategyTypeOptions.ai': 'กลยุทธ์ที่ขับเคลื่อนด้วย AI', + 'trading-assistant.strategyTypeOptions.indicator': 'กลยุทธ์ตัวบ่งชี้ทางเทคนิค', + 'trading-assistant.strategyTypeOptions.aiDeveloping': 'ฟังก์ชันกลยุทธ์ที่ขับเคลื่อนด้วย AI อยู่ระหว่างการพัฒนา ดังนั้นโปรดคอยติดตาม', + 'trading-assistant.strategyTypeOptions.aiDevelopingWarning': 'คุณสมบัติกลยุทธ์ที่ขับเคลื่อนด้วย AI กำลังอยู่ในการพัฒนา', + 'trading-assistant.indicatorType.trend': 'แนวโน้ม', + 'trading-assistant.indicatorType.momentum': 'โมเมนตัม', + 'trading-assistant.indicatorType.volatility': 'ความผันผวน', + 'trading-assistant.indicatorType.volume': 'ปริมาณ', + 'trading-assistant.indicatorType.custom': 'ปรับแต่ง', + 'trading-assistant.exchangeNames': { + 'okx': 'โอเคเอ็กซ์', + 'binance': 'ไบแนนซ์', + 'hyperliquid': 'ไฮเปอร์ลิควิด', + 'blockchaincom': 'Blockchain.com', + 'coinbaseexchange': 'คอยน์เบส', + 'gate': 'Gate.io', + 'mexc': 'เม็กซิโก', + 'kraken': 'คราเคน', + 'bitfinex': 'Bitfinex', + 'bybit': 'บายบิต', + 'kucoin': 'คูคอยน์', + 'huobi': 'หั่วปี้', + 'bitget': 'บิทเก็ต', + 'bitmex': 'BitMEX', + 'deribit': 'อนุมาน', + 'phemex': 'ฟีเม็กซ์', + 'bitmart': 'บิตมาร์ท', + 'bitstamp': 'บิตสแตมป์', + 'bittrex': 'บิทเทร็กซ์', + 'poloniex': 'โปโลนีกซ์', + 'gemini': 'ราศีเมถุน', + 'cryptocom': 'Crypto.com', + 'bitflyer': 'บิตฟลายเออร์', + 'upbit': 'อัพบิต', + 'bithumb': 'บิธัมบ์', + 'coinone': 'โคโนเน่', + 'zb': 'ซีบี', + 'lbank': 'แอลแบงค์', + 'bibox': 'ไบบ็อกซ์', + 'bigone': 'บิ๊กวัน', + 'bitrue': 'บิตทรู', + 'coinex': 'คอยน์เอ็กซ์', + 'digifinex': 'DigiFinex', + 'ftx': 'เอฟทีเอ็กซ์', + 'ftxus': 'FTX สหรัฐอเมริกา', + 'binanceus': 'Binance สหรัฐอเมริกา', + 'binancecoinm': 'Binance COIN-M', + 'binanceusdm': 'Binance USDⓈ-M' + }, + 'ai-trading-assistant.title': 'ผู้ช่วยการซื้อขาย AI', + 'ai-trading-assistant.strategyList': 'รายการกลยุทธ์', + 'ai-trading-assistant.createStrategy': 'สร้างนโยบาย', + 'ai-trading-assistant.noStrategy': 'ยังไม่มีกลยุทธ์', + 'ai-trading-assistant.selectStrategy': 'โปรดเลือกกลยุทธ์จากด้านซ้ายเพื่อดูรายละเอียด', + 'ai-trading-assistant.startStrategy': 'กลยุทธ์การเปิดตัว', + 'ai-trading-assistant.stopStrategy': 'กลยุทธ์การหยุด', + 'ai-trading-assistant.editStrategy': 'กลยุทธ์ด้านบรรณาธิการ', + 'ai-trading-assistant.deleteStrategy': 'ลบนโยบาย', + 'ai-trading-assistant.status.running': 'วิ่ง', + 'ai-trading-assistant.status.stopped': 'หยุดแล้ว', + 'ai-trading-assistant.status.error': 'ความผิดพลาด', + 'ai-trading-assistant.tabs.tradingRecords': 'ประวัติการทำธุรกรรม', + 'ai-trading-assistant.tabs.positions': 'บันทึกตำแหน่ง', + 'ai-trading-assistant.tabs.aiDecisions': 'บันทึกการตัดสินใจของ AI', + 'ai-trading-assistant.tabs.equityCurve': 'เส้นส่วนของผู้ถือหุ้น', + 'ai-trading-assistant.form.createTitle': 'สร้างกลยุทธ์การซื้อขายด้วย AI', + 'ai-trading-assistant.form.editTitle': 'แก้ไขกลยุทธ์การซื้อขาย AI', + 'ai-trading-assistant.form.strategyName': 'ชื่อกรมธรรม์', + 'ai-trading-assistant.form.modelId': 'โมเดลเอไอ', + 'ai-trading-assistant.form.modelIdHint': 'ใช้บริการระบบ OpenRouter โดยไม่ต้องกำหนดค่า API Key', + 'ai-trading-assistant.form.decideInterval': 'ช่วงการตัดสินใจ', + 'ai-trading-assistant.form.decideInterval5m': '5 นาที', + 'ai-trading-assistant.form.decideInterval10m': '10 นาที', + 'ai-trading-assistant.form.decideInterval30m': '30 นาที', + 'ai-trading-assistant.form.decideInterval1h': '1 ชั่วโมง', + 'ai-trading-assistant.form.decideInterval4h': '4 ชั่วโมง', + 'ai-trading-assistant.form.decideInterval1d': '1 วัน', + 'ai-trading-assistant.form.decideInterval1w': '1 สัปดาห์', + 'ai-trading-assistant.form.decideIntervalHint': 'ช่วงเวลาที่ AI ในการตัดสินใจ', + 'ai-trading-assistant.form.runPeriod': 'วิ่งรอบ', + 'ai-trading-assistant.form.runPeriodHint': 'เวลาเริ่มต้นและเวลาสิ้นสุดของการดำเนินกลยุทธ์', + 'ai-trading-assistant.form.startDate': 'วันที่เริ่มต้น', + 'ai-trading-assistant.form.endDate': 'วันที่สิ้นสุด', + 'ai-trading-assistant.form.qdtCostTitle': 'คำแนะนำการหักเงินของ QDT', + 'ai-trading-assistant.form.qdtCostHint': 'การตัดสินใจของ AI แต่ละครั้งจะถูกหักออก {cost} QDT โปรดตรวจสอบให้แน่ใจว่าบัญชีของคุณมียอดคงเหลือ QDT เพียงพอ ในระหว่างการดำเนินการตามกลยุทธ์ค่าธรรมเนียมจะถูกหักแบบเรียลไทม์สำหรับการตัดสินใจแต่ละครั้ง', + 'ai-trading-assistant.form.apiKey': 'คีย์ API', + 'ai-trading-assistant.form.exchange': 'เลือกการแลกเปลี่ยน', + 'ai-trading-assistant.form.secretKey': 'รหัสลับ', + 'ai-trading-assistant.form.passphrase': 'ข้อความรหัสผ่าน', + 'ai-trading-assistant.form.testConnection': 'ทดสอบการเชื่อมต่อ', + 'ai-trading-assistant.form.symbol': 'คู่การซื้อขาย', + 'ai-trading-assistant.form.symbolHint': 'เลือกคู่การซื้อขายที่จะซื้อขาย', + 'ai-trading-assistant.form.initialCapital': 'จำนวนเงินที่ลงทุน (มาร์จิ้น)', + 'ai-trading-assistant.form.leverage': 'ใช้ประโยชน์หลายอย่าง', + 'ai-trading-assistant.form.timeframe': 'ช่วงเวลา', + 'ai-trading-assistant.form.timeframe1m': '1 นาที', + 'ai-trading-assistant.form.timeframe5m': '5 นาที', + 'ai-trading-assistant.form.timeframe15m': '15 นาที', + 'ai-trading-assistant.form.timeframe30m': '30 นาที', + 'ai-trading-assistant.form.timeframe1H': '1 ชั่วโมง', + 'ai-trading-assistant.form.timeframe4H': '4 ชั่วโมง', + 'ai-trading-assistant.form.timeframe1D': '1 วัน', + 'ai-trading-assistant.form.marketType': 'ประเภทตลาด', + 'ai-trading-assistant.form.marketTypeFutures': 'สัญญา', + 'ai-trading-assistant.form.marketTypeSpot': 'จุดสินค้า', + 'ai-trading-assistant.form.totalPnl': 'กำไรและขาดทุนทั้งหมด', + 'ai-trading-assistant.form.customPrompt': 'คำแจ้งที่กำหนดเอง', + 'ai-trading-assistant.form.customPromptHint': 'ตัวเลือกเสริม ใช้เพื่อปรับแต่งกลยุทธ์การซื้อขาย AI และตรรกะในการตัดสินใจ', + 'ai-trading-assistant.form.cancel': 'ยกเลิก', + 'ai-trading-assistant.form.prev': 'ขั้นตอนก่อนหน้า', + 'ai-trading-assistant.form.next': 'ขั้นตอนต่อไป', + 'ai-trading-assistant.form.confirmCreate': 'ยืนยันการสร้าง', + 'ai-trading-assistant.form.confirmEdit': 'ยืนยันการเปลี่ยนแปลง', + 'ai-trading-assistant.messages.createSuccess': 'สร้างกลยุทธ์สำเร็จแล้ว', + 'ai-trading-assistant.messages.createFailed': 'ไม่สามารถสร้างนโยบายได้', + 'ai-trading-assistant.messages.updateSuccess': 'อัปเดตนโยบายสำเร็จแล้ว', + 'ai-trading-assistant.messages.updateFailed': 'อัปเดตนโยบายไม่สำเร็จ', + 'ai-trading-assistant.messages.deleteSuccess': 'ลบนโยบายสำเร็จแล้ว', + 'ai-trading-assistant.messages.deleteFailed': 'ลบนโยบายล้มเหลว', + 'ai-trading-assistant.messages.startSuccess': 'กลยุทธ์เริ่มต้นได้สำเร็จ', + 'ai-trading-assistant.messages.startFailed': 'ไม่สามารถเริ่มนโยบายได้', + 'ai-trading-assistant.messages.stopSuccess': 'กลยุทธ์หยุดสำเร็จ', + 'ai-trading-assistant.messages.stopFailed': 'กลยุทธ์หยุดล้มเหลว', + 'ai-trading-assistant.messages.loadFailed': 'ไม่สามารถรับรายการนโยบาย', + 'ai-trading-assistant.messages.loadDecisionsFailed': 'ไม่สามารถรับบันทึกการตัดสินใจของ AI', + 'ai-trading-assistant.messages.deleteConfirm': 'คุณแน่ใจหรือไม่ว่าต้องการลบนโยบายนี้การดำเนินการนี้ไม่สามารถย้อนกลับได้', + 'ai-trading-assistant.placeholders.inputStrategyName': 'โปรดป้อนชื่อนโยบาย', + 'ai-trading-assistant.placeholders.selectModelId': 'โปรดเลือกโมเดล AI', + 'ai-trading-assistant.placeholders.selectDecideInterval': 'โปรดเลือกช่วงการตัดสินใจ', + 'ai-trading-assistant.placeholders.startTime': 'เวลาเริ่มต้น', + 'ai-trading-assistant.placeholders.endTime': 'เวลาสิ้นสุด', + 'ai-trading-assistant.placeholders.inputApiKey': 'กรุณากรอกคีย์ API', + 'ai-trading-assistant.placeholders.selectExchange': 'กรุณาเลือกการแลกเปลี่ยน', + 'ai-trading-assistant.placeholders.inputSecretKey': 'กรุณากรอกรหัสลับ', + 'ai-trading-assistant.placeholders.inputPassphrase': 'กรุณากรอกรหัสผ่าน', + 'ai-trading-assistant.placeholders.selectSymbol': 'โปรดเลือกคู่การซื้อขาย เช่น BTC/USDT', + 'ai-trading-assistant.placeholders.selectTimeframe': 'โปรดเลือกช่วงเวลา', + 'ai-trading-assistant.placeholders.inputCustomPrompt': 'โปรดป้อนคำแจ้งที่กำหนดเอง (ไม่บังคับ)', + 'ai-trading-assistant.validation.strategyNameRequired': 'โปรดป้อนชื่อนโยบาย', + 'ai-trading-assistant.validation.modelIdRequired': 'โปรดเลือกโมเดล AI', + 'ai-trading-assistant.validation.runPeriodRequired': 'กรุณาเลือกรอบการทำงาน', + 'ai-trading-assistant.validation.apiKeyRequired': 'กรุณากรอกคีย์ API', + 'ai-trading-assistant.validation.exchangeRequired': 'กรุณาเลือกการแลกเปลี่ยน', + 'ai-trading-assistant.validation.secretKeyRequired': 'กรุณากรอกรหัสลับ', + 'ai-trading-assistant.validation.symbolRequired': 'กรุณาเลือกคู่การซื้อขาย', + 'ai-trading-assistant.validation.initialCapitalRequired': 'กรุณากรอกจำนวนเงินลงทุน', + 'ai-trading-assistant.table.time': 'เวลา', + 'ai-trading-assistant.table.type': 'พิมพ์', + 'ai-trading-assistant.table.price': 'ราคา', + 'ai-trading-assistant.table.amount': 'ปริมาณ', + 'ai-trading-assistant.table.value': 'จำนวน', + 'ai-trading-assistant.table.symbol': 'คู่การซื้อขาย', + 'ai-trading-assistant.table.side': 'ทิศทาง', + 'ai-trading-assistant.table.size': 'ปริมาณตำแหน่ง', + 'ai-trading-assistant.table.entryPrice': 'ราคาเปิด', + 'ai-trading-assistant.table.currentPrice': 'ราคาปัจจุบัน', + 'ai-trading-assistant.table.unrealizedPnl': 'กำไรหรือขาดทุนที่ยังไม่เกิดขึ้นจริง', + 'ai-trading-assistant.table.profit': 'กำไรและขาดทุน', + 'ai-trading-assistant.table.openLong': 'เปิดยาวๆ', + 'ai-trading-assistant.table.closeLong': 'ปินตัว', + 'ai-trading-assistant.table.openShort': 'เปิดสั้น', + 'ai-trading-assistant.table.closeShort': 'ว่างเปล่า', + 'ai-trading-assistant.table.addLong': 'กาดอต', + 'ai-trading-assistant.table.addShort': 'เพิ่มสั้น', + 'ai-trading-assistant.table.closeShortProfit': 'ขายทำกำไรจากสถานะขาย', + 'ai-trading-assistant.table.closeShortStop': 'หยุดการสูญเสีย', + 'ai-trading-assistant.table.closeLongProfit': 'หยุดยาวและทำกำไร', + 'ai-trading-assistant.table.closeLongStop': 'หยุดการสูญเสีย', + 'ai-trading-assistant.table.buy': 'ซื้อ', + 'ai-trading-assistant.table.sell': 'ขาย', + 'ai-trading-assistant.table.long': 'ยาวไป', + 'ai-trading-assistant.table.short': 'สั้น', + 'ai-trading-assistant.table.hold': 'ถือ', + 'ai-trading-assistant.table.reasoning': 'เหตุผลในการวิเคราะห์', + 'ai-trading-assistant.table.decisions': 'การตัดสินใจ', + 'ai-trading-assistant.table.riskAssessment': 'การประเมินความเสี่ยง', + 'ai-trading-assistant.table.confidence': 'ความมั่นใจ', + 'ai-trading-assistant.table.totalRecords': 'รวม {total} บันทึก', + 'ai-trading-assistant.table.noPositions': 'ยังไม่มีตำแหน่ง', + 'ai-trading-assistant.detail.title': 'รายละเอียดกลยุทธ์', + 'ai-trading-assistant.equity.noData': 'ยังไม่มีข้อมูลมูลค่าสุทธิ', + 'ai-trading-assistant.equity.equity': 'มูลค่าสุทธิ', + 'ai-trading-assistant.exchange.testFailed': 'การทดสอบการเชื่อมต่อล้มเหลว', + 'ai-trading-assistant.exchange.connectionSuccess': 'การเชื่อมต่อสำเร็จ', + 'ai-trading-assistant.exchange.connectionFailed': 'การเชื่อมต่อล้มเหลว', + 'ai-trading-assistant.form.advancedSettings': 'การตั้งค่าขั้นสูง', + 'ai-trading-assistant.form.orderMode': 'โหมดการสั่งซื้อ', + 'ai-trading-assistant.form.orderModeMaker': 'ผู้สร้าง', + 'ai-trading-assistant.form.orderModeTaker': 'ราคาตลาด (เทคเกอร์)', + 'ai-trading-assistant.form.orderModeHint': 'โหมดคำสั่งซื้อที่รอดำเนินการใช้คำสั่งซื้อแบบจำกัดและค่าธรรมเนียมการจัดการต่ำกว่า โหมดราคาตลาดจะดำเนินการทันทีและค่าธรรมเนียมการจัดการจะสูงขึ้น', + 'ai-trading-assistant.form.makerWaitSec': 'เวลารอสำหรับคำสั่งซื้อที่รอดำเนินการ (วินาที)', + 'ai-trading-assistant.form.makerWaitSecHint': 'เวลาที่ต้องรอการทำธุรกรรมหลังจากทำการสั่งซื้อ ยกเลิกและลองอีกครั้งหลังจากหมดเวลา', + 'ai-trading-assistant.form.makerRetries': 'จำนวนการลองใหม่สำหรับคำสั่งซื้อที่รอดำเนินการ', + 'ai-trading-assistant.form.makerRetriesHint': 'จำนวนครั้งสูงสุดในการลองใหม่เมื่อไม่ได้ดำเนินการคำสั่งซื้อที่รอดำเนินการ', + 'ai-trading-assistant.form.fallbackToMarket': 'คำสั่งซื้อที่รอดำเนินการล้มเหลวและราคาตลาดถูกลดระดับลง', + 'ai-trading-assistant.form.fallbackToMarketHint': 'เมื่อการเปิด/ปิดคำสั่งซื้อที่รอดำเนินการไม่เสร็จสมบูรณ์ ควรดาวน์เกรดเป็นคำสั่งซื้อในตลาดเพื่อให้แน่ใจว่าธุรกรรมจะเสร็จสมบูรณ์หรือไม่', + 'ai-trading-assistant.form.marginMode': 'โหมดระยะขอบ', + 'ai-trading-assistant.form.marginModeCross': 'โกดังเต็ม', + 'ai-trading-assistant.form.marginModeIsolated': 'ตำแหน่งที่โดดเดี่ยว', + 'ai-analysis.title': 'เครื่องมือการซื้อขายควอนตัม', + 'ai-analysis.system.online': 'ออนไลน์', + 'ai-analysis.system.agents': 'ตัวแทน', + 'ai-analysis.system.active': 'คล่องแคล่ว', + 'ai-analysis.system.stage': 'เวที', + 'ai-analysis.panel.roster': 'รายชื่อตัวแทน', + 'ai-analysis.panel.thinking': 'กำลังคิด...', + 'ai-analysis.panel.done': 'เสร็จ', + 'ai-analysis.panel.standby': 'สแตนด์บาย', + 'ai-analysis.input.title': 'เครื่องมือการซื้อขายควอนตัม', + 'ai-analysis.input.placeholder': 'เลือกสินทรัพย์เป้าหมาย (เช่น BTC/USDT)', + 'ai-analysis.input.watchlist': 'หุ้นทางเลือก', + 'ai-analysis.input.start': 'เริ่มการวิเคราะห์', + 'ai-analysis.input.recent': 'งานล่าสุด:', + 'ai-analysis.vis.stage': 'เวที', + 'ai-analysis.vis.processing': 'กำลังประมวลผล', + 'ai-analysis.result.complete': 'การวิเคราะห์เสร็จสมบูรณ์', + 'ai-analysis.result.signal': 'สัญญาณสุดท้าย', + 'ai-analysis.result.confidence': 'ความมั่นใจ:', + 'ai-analysis.result.new': 'การวิเคราะห์ใหม่', + 'ai-analysis.result.full': 'ดูรายงานฉบับเต็ม', + 'ai-analysis.logs.title': 'บันทึกของระบบ', + 'ai-analysis.modal.title': 'รายงานที่เป็นความลับ', + 'ai-analysis.modal.fundamental': 'การวิเคราะห์พื้นฐาน', + 'ai-analysis.modal.technical': 'การวิเคราะห์ทางเทคนิค', + 'ai-analysis.modal.sentiment': 'การวิเคราะห์อารมณ์', + 'ai-analysis.modal.risk': 'การประเมินความเสี่ยง', + 'ai-analysis.stage.idle': 'สแตนด์บาย', + 'ai-analysis.stage.1': 'ระยะที่หนึ่ง: การวิเคราะห์หลายมิติ', + 'ai-analysis.stage.2': 'ระยะที่สอง: การอภิปรายระยะยาว-ระยะสั้น', + 'ai-analysis.stage.3': 'ระยะที่สาม: การวางแผนเชิงกลยุทธ์', + 'ai-analysis.stage.4': 'ขั้นตอนที่สี่: การทบทวนการควบคุมความเสี่ยง', + 'ai-analysis.stage.complete': 'เสร็จ', + 'ai-analysis.agent.investment_director': 'ผู้อำนวยการฝ่ายการลงทุน', + 'ai-analysis.agent.role.investment_director': 'การวิเคราะห์ที่ครอบคลุมและข้อสรุปขั้นสุดท้าย', + 'ai-analysis.agent.market': 'นักวิเคราะห์ตลาด', + 'ai-analysis.agent.role.market': 'เทคโนโลยีและข้อมูลการตลาด', + 'ai-analysis.agent.fundamental': 'นักวิเคราะห์พื้นฐาน', + 'ai-analysis.agent.role.fundamental': 'การเงินและการประเมินค่า', + 'ai-analysis.agent.technical': 'นักวิเคราะห์ทางเทคนิค', + 'ai-analysis.agent.role.technical': 'ตัวชี้วัดและแผนภูมิทางเทคนิค', + 'ai-analysis.agent.news': 'นักวิเคราะห์ข่าว', + 'ai-analysis.agent.role.news': 'ตัวกรองข่าวทั่วโลก', + 'ai-analysis.agent.sentiment': 'นักวิเคราะห์ความรู้สึก', + 'ai-analysis.agent.role.sentiment': 'สังคมและอารมณ์', + 'ai-analysis.agent.risk': 'นักวิเคราะห์ความเสี่ยง', + 'ai-analysis.agent.role.risk': 'การตรวจสอบความเสี่ยงเบื้องต้น', + 'ai-analysis.agent.bull': 'นักวิจัยรั้น', + 'ai-analysis.agent.role.bull': 'การขุดตัวเร่งปฏิกิริยาการเติบโต', + 'ai-analysis.agent.bear': 'นักวิจัยขาลง', + 'ai-analysis.agent.role.bear': 'การขุดความเสี่ยงและข้อบกพร่อง', + 'ai-analysis.agent.manager': 'ผู้จัดการฝ่ายวิจัย', + 'ai-analysis.agent.role.manager': 'ผู้ดูแลการอภิปราย', + 'ai-analysis.agent.trader': 'พ่อค้า', + 'ai-analysis.agent.role.trader': 'นักยุทธศาสตร์บริหาร', + 'ai-analysis.agent.risky': 'นักวิเคราะห์กิจกรรม', + 'ai-analysis.agent.role.risky': 'กลยุทธ์เชิงรุก', + 'ai-analysis.agent.neutral': 'นักวิเคราะห์ความสมดุล', + 'ai-analysis.agent.role.neutral': 'กลยุทธ์การสร้างสมดุล', + 'ai-analysis.agent.safe': 'นักวิเคราะห์อนุรักษ์นิยม', + 'ai-analysis.agent.role.safe': 'กลยุทธ์อนุรักษ์นิยม', + 'ai-analysis.agent.cro': 'ผู้จัดการฝ่ายควบคุมความเสี่ยง (CRO)', + 'ai-analysis.agent.role.cro': 'อำนาจในการตัดสินใจขั้นสุดท้าย', + 'ai-analysis.script.market': 'กำลังดึงข้อมูล OHLCV จากการแลกเปลี่ยนหลัก...', + 'ai-analysis.script.fundamental': 'กำลังดึงรายงานทางการเงินรายไตรมาส...', + 'ai-analysis.script.technical': 'กำลังวิเคราะห์ตัวชี้วัดทางเทคนิคและรูปแบบกราฟ...', + 'ai-analysis.script.news': 'กำลังสแกนข่าวการเงินทั่วโลก...', + 'ai-analysis.script.sentiment': 'วิเคราะห์เทรนด์โซเชียลมีเดีย...', + 'ai-analysis.script.risk': 'กำลังคำนวณความผันผวนในอดีต...', + 'invite.inviteLink': 'ลิงค์คำเชิญ', + 'invite.copy': 'คัดลอกลิงก์', + 'invite.copySuccess': 'คัดลอกสำเร็จ!', + 'invite.copyFailed': 'การคัดลอกล้มเหลว โปรดคัดลอกด้วยตนเอง', + 'invite.noInviteLink': 'ไม่ได้สร้างลิงก์คำเชิญ', + 'invite.totalInvites': 'คำเชิญสะสม', + 'invite.totalReward': 'รางวัลสะสม', + 'invite.rules': 'กฎการเชิญชวน', + 'invite.rule1': 'ทุกครั้งที่คุณเชิญเพื่อนมาลงทะเบียนสำเร็จ คุณจะได้รับรางวัล', + 'invite.rule2': 'หากเพื่อนที่ได้รับเชิญทำธุรกรรมครั้งแรกสำเร็จ คุณจะได้รับรางวัลเพิ่มเติม', + 'invite.rule3': 'รางวัลคำเชิญจะถูกส่งไปยังบัญชีของคุณโดยตรง', + 'invite.inviteList': 'รายการเชิญ', + 'invite.tasks': 'ศูนย์ภารกิจ', + 'invite.inviteeName': 'ผู้ได้รับเชิญ', + 'invite.inviteTime': 'เวลาเชิญ', + 'invite.status': 'สถานะ', + 'invite.reward': 'รางวัล', + 'invite.active': 'คล่องแคล่ว', + 'invite.inactive': 'ไม่ได้เปิดใช้งาน', + 'invite.completed': 'สมบูรณ์', + 'invite.claimed': 'ได้รับ', + 'invite.pending': 'ให้แล้วเสร็จ', + 'invite.goToTask': 'เพื่อให้เสร็จสมบูรณ์', + 'invite.claimReward': 'รับรางวัล', + 'invite.verify': 'การยืนยันเสร็จสมบูรณ์', + 'invite.verifySuccess': 'การยืนยันสำเร็จ!งานเสร็จสมบูรณ์', + 'invite.verifyNotCompleted': 'งานยังไม่เสร็จสมบูรณ์ กรุณาทำงานให้เสร็จก่อน', + 'invite.verifyFailed': 'การยืนยันล้มเหลว โปรดลองอีกครั้งในภายหลัง', + 'invite.claimSuccess': 'รับ {reward} QDT สำเร็จแล้ว!', + 'invite.claimFailed': 'ไม่สามารถรวบรวมได้ โปรดลองอีกครั้งในภายหลัง', + 'invite.totalRecords': 'รวม {total} บันทึก', + 'invite.task.twitter.title': 'รีทวีตไปที่ X (Twitter)', + 'invite.task.twitter.desc': 'แบ่งปันทวีตอย่างเป็นทางการของเราไปยังบัญชี X (Twitter) ของคุณ', + 'invite.task.youtube.title': 'ติดตามช่อง YouTube ของเรา', + 'invite.task.youtube.desc': 'สมัครสมาชิกและติดตามช่อง YouTube อย่างเป็นทางการของเรา', + 'invite.task.telegram.title': 'เข้าร่วมกลุ่มโทรเลข', + 'invite.task.telegram.desc': 'เข้าร่วมกลุ่มชุมชนโทรเลขอย่างเป็นทางการของเรา', + 'invite.task.discord.title': 'เข้าร่วมเซิร์ฟเวอร์ Discord', + 'invite.task.discord.desc': 'เข้าร่วมเซิร์ฟเวอร์ชุมชน Discord ของเรา', + 'message': '-', + 'layouts.usermenu.dialog.title': 'ข้อมูล', + 'layouts.usermenu.dialog.content': 'คุณแน่ใจหรือไม่ว่าต้องการออกจากระบบ?', + 'layouts.userLayout.title': 'ค้นหาความจริงในความไม่แน่นอน', + // Auto-filled missing keys from en-US (fallback to English) + 'dashboard.indicator.boundary.message': 'Note: The indicator script only computes plots + buy/sell signals. Position sizing, risk, scaling, fees/slippage belong to execution config.', + 'dashboard.indicator.boundary.indicatorRule': "Keep the script to buy/sell only (and set df['buy']/df['sell']). Do NOT implement position management, TP/SL, scaling in the script.", + 'dashboard.indicator.boundary.backtestRule': 'Rule: If a bar has a main signal (buy/sell → open/close/reverse), scaling in/out is skipped on the same bar.', + 'dashboard.indicator.guide.title': 'Python Indicator & Strategy Development Guide', + 'dashboard.indicator.guide.intro': 'This platform supports writing custom technical indicators and trading signals using Python. The system includes built-in pandas and numpy data analysis libraries. You can use standard DataFrame operations to process K-line data and draw charts or mark buy/sell signals.', + 'dashboard.indicator.guide.section1.title': '1. Runtime Environment & Predefined Variables', + 'dashboard.indicator.guide.section1.env': 'Your code runs in a browser-based Python environment (Pyodide).', + 'dashboard.indicator.guide.section1.libs': 'Pre-installed Libraries', + 'dashboard.indicator.guide.section1.libsDesc': 'The following libraries are imported by default, no need to import again:', + 'dashboard.indicator.guide.section1.libsList': 'pandas (pd), numpy (np), math, json', + 'dashboard.indicator.guide.section1.data': 'Input Data: df', + 'dashboard.indicator.guide.section1.dataDesc': 'The system automatically converts current K-line data into a Pandas DataFrame object, variable name is df.', + 'dashboard.indicator.guide.section1.dataColumns': 'df contains the following columns (all float type):', + 'dashboard.indicator.guide.section1.dataColumnsList': 'time (timestamp, seconds), open, high, low, close, volume', + 'dashboard.indicator.guide.section1.dataNote': 'Data index is arranged from old to new (0 is the earliest data, last row is the latest data).', + 'dashboard.indicator.guide.section1.dataIndex': 'Important: df index is timestamp (DatetimeIndex), please preserve the index, avoid using .values which loses the index.', + 'dashboard.indicator.guide.section2.title': '2. Parameter Definition', + 'dashboard.indicator.guide.section2.desc': 'You can define variables as parameters at the beginning of your code. The system will try to parse statements like variable = value as adjustable parameters.', + 'dashboard.indicator.guide.section2.example': 'Parameter Example', + 'dashboard.indicator.guide.section2.exampleCode': 'span = 20 or multiplier = 2.0', + 'dashboard.indicator.guide.section3.title': '3. Backtest Signal Setup (Important!)', + 'dashboard.indicator.guide.section3.desc': 'If you want your indicator to support backtesting, you must set the following two columns in your code:', + 'dashboard.indicator.guide.section3.backtestRequired': 'Required for Backtesting:', + 'dashboard.indicator.guide.section3.backtestBuySignal': "df['buy_signal'] - Boolean Series, True indicates buy signal", + 'dashboard.indicator.guide.section3.backtestSellSignal': "df['sell_signal'] - Boolean Series, True indicates sell signal", + 'dashboard.indicator.guide.section3.backtestExample': 'Example:', + 'dashboard.indicator.guide.section3.backtestExampleCode': "df['buy_signal'] = condition_buy
    df['sell_signal'] = condition_sell", + 'dashboard.indicator.guide.section3.backtestNote': 'Note: Even if you only set output.signals for chart display, you must set these two columns to enable backtesting.', + 'dashboard.indicator.guide.section4.title': '4. Output Requirements: output', + 'dashboard.indicator.guide.section4.desc': 'At the end of code execution, you must define a dictionary named output to tell the chart how to display results.', + 'dashboard.indicator.guide.section4.fields': 'The output dictionary contains the following fields:', + 'dashboard.indicator.guide.section4.plots': 'plots - For drawing lines (e.g., moving averages, RSI)', + 'dashboard.indicator.guide.section4.signals': 'signals - For marking buy/sell signals (e.g., arrows, labels)', + 'dashboard.indicator.guide.section4.name': 'name - Indicator name (optional)', + 'dashboard.indicator.guide.section4.calculatedVars': 'calculatedVars - Calculated variables (optional, for displaying additional information)', + 'dashboard.indicator.guide.section5.title': '5. Plot Configuration (Plots)', + 'dashboard.indicator.guide.section5.desc': 'Each object in the plots list represents a line.', + 'dashboard.indicator.guide.section5.table.field': 'Field', + 'dashboard.indicator.guide.section5.table.type': 'Type', + 'dashboard.indicator.guide.section5.table.required': 'Required', + 'dashboard.indicator.guide.section5.table.requiredYes': 'Yes', + 'dashboard.indicator.guide.section5.table.requiredNo': 'No', + 'dashboard.indicator.guide.section5.table.desc': 'Description', + 'dashboard.indicator.guide.section5.table.name': 'The name of the indicator line, displayed in the legend', + 'dashboard.indicator.guide.section5.table.data': 'Data list, length must match the number of rows in df. Recommended to use series.tolist()', + 'dashboard.indicator.guide.section5.table.color': "Line color (Hex format, e.g. '#ff0000'). If not filled, the system will automatically assign", + 'dashboard.indicator.guide.section5.table.typeDesc': "Chart type, default is 'line'", + 'dashboard.indicator.guide.section5.table.overlay': 'Important. True means display on main chart (with K-line), False means display on sub-chart (independent area)', + 'dashboard.indicator.guide.section5.example': 'Example: Draw Simple Moving Average (SMA)', + 'dashboard.indicator.guide.section5.exampleCalc': 'Calculate SMA', + 'dashboard.indicator.guide.section5.exampleOutput': 'Construct Output', + 'dashboard.indicator.guide.section5.exampleOverlay': 'Display on main chart', + 'dashboard.indicator.guide.section6.title': '6. Signal Markers (Signals)', + 'dashboard.indicator.guide.section6.desc': "signals are used to mark buy/sell points on specific K-lines through icons or text. Note: This is only for chart display. The backtest system uses df['buy_signal'] and df['sell_signal'].", + 'dashboard.indicator.guide.section6.table.type': "Signal type: 'buy' (buy, marked below K-line) or 'sell' (sell, marked above K-line)", + 'dashboard.indicator.guide.section6.table.data': 'Signal data list. Fill price value at signal positions, fill None (or np.nan) at non-signal positions', + 'dashboard.indicator.guide.section6.table.text': 'Text displayed on the label (e.g. "B", "S", "Buy", "Sell")', + 'dashboard.indicator.guide.section6.table.color': 'Marker color', + 'dashboard.indicator.guide.section6.hint': 'The system automatically handles positioning.', + 'dashboard.indicator.guide.section6.buy': 'Markers will be automatically positioned below the Low of the current K-line', + 'dashboard.indicator.guide.section6.sell': 'Markers will be automatically positioned above the High of the current K-line', + 'dashboard.indicator.guide.section6.example': 'Example: Draw Buy/Sell Signals', + 'dashboard.indicator.guide.section6.exampleInit': 'Initialize signal list, fill all with None', + 'dashboard.indicator.guide.section6.exampleLogic': 'Simple logic: mark buy when close price is greater than open price', + 'dashboard.indicator.guide.section6.exampleNote': 'In actual logic, please use loops or vectorized operations', + 'dashboard.indicator.guide.section6.examplePrice': 'Record price', + 'dashboard.indicator.guide.section6.exampleText': 'Buy', + 'dashboard.indicator.guide.section7.title': '7. Complete Code Examples', + 'dashboard.indicator.guide.section7.exampleA': 'Example A: Dual EMA Strategy (Supports Backtesting)', + 'dashboard.indicator.guide.section7.exampleACode': "# 1. Calculate indicators
    df['ema_fast'] = df['close'].ewm(span=5, adjust=False).mean()
    df['ema_slow'] = df['close'].ewm(span=13, adjust=False).mean()

    # 2. Calculate crossover signals
    cross_up = (df['ema_fast'] > df['ema_slow']) & (df['ema_fast'].shift(1) <= df['ema_slow'].shift(1))
    cross_down = (df['ema_fast'] < df['ema_slow']) & (df['ema_fast'].shift(1) >= df['ema_slow'].shift(1))

    # 3. 【Important】Set NEW backtest signal columns (bool)
    df['open_long'] = cross_up
    df['close_long'] = cross_down
    df['open_short'] = cross_down
    df['close_short'] = cross_up

    # 4. Generate signal data for chart display
    buy_signals = [df['low'].iloc[i] * 0.995 if cross_up.iloc[i] else None for i in range(len(df))]
    sell_signals = [df['high'].iloc[i] * 1.005 if cross_down.iloc[i] else None for i in range(len(df))]

    # 5. Build output
    output = {
    \"name\": \"Dual EMA Strategy\",
    \"plots\": [
    {\"name\": \"EMA 5\", \"data\": df['ema_fast'].tolist(), \"color\": \"#FF6B6B\", \"overlay\": True},
    {\"name\": \"EMA 13\", \"data\": df['ema_slow'].tolist(), \"color\": \"#4ECDC4\", \"overlay\": True}
    ],
    \"signals\": [
    {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},
    {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}
    ]
    }", + 'dashboard.indicator.guide.section7.exampleB': 'Example B: Bollinger Bands - Chart Display Only', + 'dashboard.indicator.guide.section7.exampleBCode': "# Calculate Bollinger Bands (chart display only, no backtesting)
    length = 20
    mult = 2.0
    df['sma'] = df['close'].rolling(length).mean()
    df['std'] = df['close'].rolling(length).std()
    df['upper'] = df['sma'] + mult * df['std']
    df['lower'] = df['sma'] - mult * df['std']

    output = {
    \"name\": \"Bollinger Bands\",
    \"plots\": [
    {\"name\": \"Upper\", \"data\": df['upper'].tolist(), \"color\": \"#FF5252\", \"overlay\": True},
    {\"name\": \"Middle\", \"data\": df['sma'].tolist(), \"color\": \"#2196F3\", \"overlay\": True},
    {\"name\": \"Lower\", \"data\": df['lower'].tolist(), \"color\": \"#00E676\", \"overlay\": True}
    ]
    }", + 'dashboard.indicator.guide.section7.exampleC': 'Example C: RSI Strategy (Supports Backtesting)', + 'dashboard.indicator.guide.section7.exampleCCode': "# Calculate RSI
    delta = df['close'].diff()
    up = delta.clip(lower=0)
    down = -1 * delta.clip(upper=0)
    ema_up = up.ewm(com=13, adjust=False).mean()
    ema_down = down.ewm(com=13, adjust=False).mean()
    rs = ema_up / ema_down
    df['rsi'] = 100 - (100 / (1 + rs))

    # Generate trading signals
    buy_condition = df['rsi'] < 30 # Oversold buy
    sell_condition = df['rsi'] > 70 # Overbought sell

    # 【Important】Set NEW backtest signal columns (bool)
    df['open_long'] = buy_condition
    df['close_long'] = sell_condition
    df['open_short'] = sell_condition
    df['close_short'] = buy_condition

    # Generate chart signals
    buy_signals = [df['low'].iloc[i] * 0.995 if buy_condition.iloc[i] else None for i in range(len(df))]
    sell_signals = [df['high'].iloc[i] * 1.005 if sell_condition.iloc[i] else None for i in range(len(df))]

    output = {
    \"name\": \"RSI Strategy\",
    \"plots\": [
    {\"name\": \"RSI\", \"data\": df['rsi'].tolist(), \"color\": \"#9C27B0\", \"overlay\": False},
    {\"name\": \"Overbought\", \"data\": [70] * len(df), \"color\": \"#FF5252\", \"style\": \"dashed\", \"overlay\": False},
    {\"name\": \"Oversold\", \"data\": [30] * len(df), \"color\": \"#00E676\", \"style\": \"dashed\", \"overlay\": False}
    ],
    \"signals\": [
    {\"type\": \"buy\", \"text\": \"BUY\", \"data\": buy_signals, \"color\": \"#00E676\"},
    {\"type\": \"sell\", \"text\": \"SELL\", \"data\": sell_signals, \"color\": \"#FF5252\"}
    ]
    }", + 'dashboard.indicator.guide.section6.param': 'Parameters', + 'dashboard.indicator.guide.section6.calc': 'Calculate', + 'dashboard.indicator.guide.section6.output': 'Output', + 'dashboard.indicator.guide.section6.generate': 'Generate signal data (initialize with None)', + 'dashboard.indicator.guide.section6.subchart': 'Display on sub-chart', + 'dashboard.indicator.guide.section8.title': '8. Frequently Asked Questions (FAQ)', + 'dashboard.indicator.guide.section8.q1': "Q: Why can't my indicator be backtested?", + 'dashboard.indicator.guide.section8.a1': "A: The backtest system requires NEW signal columns: df['open_long'], df['close_long'], df['open_short'], df['close_short'] (boolean). Even if you set output.signals for chart display, you must set these columns to enable backtesting.", + 'dashboard.indicator.guide.section8.q2': 'Q: Data length mismatch?', + 'dashboard.indicator.guide.section8.a2': "A: The data list in plots and signals must have exactly the same length as the K-line data. If you use df['xxx'].tolist(), there is usually no problem.", + 'dashboard.indicator.guide.section8.q3': 'Q: How to handle NaN (empty values)?', + 'dashboard.indicator.guide.section8.a3': "A: Pandas calculations (such as rolling mean) will produce NaN at the beginning. The system will automatically convert NaN to null for chart rendering. You don't need to manually clean it, but if you need specific logic, you can use df.fillna(0) or other methods.", + 'dashboard.indicator.guide.section8.q4': 'Q: Index mismatch causing all signals to be NaN?', + 'dashboard.indicator.guide.section8.a4': "A: Please ensure you preserve the original df index (timestamp). Avoid using .values to create new Series. Use columns directly like df['close'] for operations.", + 'dashboard.indicator.guide.section8.q5': 'Q: Can I use third-party network libraries?', + 'dashboard.indicator.guide.section8.a5': 'A: No. The runtime environment is a browser sandbox and cannot use libraries like requests to request external APIs, nor can it install additional pip packages. Limited to built-in libraries such as pandas, numpy, math, json.', + 'dashboard.indicator.guide.section8.q6': 'Q: How to debug code?', + 'dashboard.indicator.guide.section8.a6': 'A: If the chart does not display indicators, it is usually because the Python code has an error. Please check for syntax errors or array index out of bounds. It is recommended to debug the algorithm logic in a local Python environment first, then copy it in.', + 'dashboard.indicator.guide.section8.q7': 'Q: Backtest shows 0 signals?', + 'dashboard.indicator.guide.section8.a7': "A: Please check the data type of df['open_long']/df['close_long']/df['open_short']/df['close_short']. They should be boolean (True/False), not numeric. Use condition.fillna(False).astype(bool) to ensure correct type.", + 'dashboard.indicator.backtest.commissionHint': 'Charged on notional value (incl. leverage) per trade; both entry & exit are deducted from balance.', + 'dashboard.indicator.backtest.historyTitle': 'Backtest History', + 'dashboard.indicator.backtest.historyRefresh': 'Refresh', + 'dashboard.indicator.backtest.historyView': 'View', + 'dashboard.indicator.backtest.historyNoData': 'No backtest history', + 'dashboard.indicator.backtest.historyUseCurrent': 'Only current symbol/timeframe', + 'dashboard.indicator.backtest.historyFilterSymbol': 'Symbol', + 'dashboard.indicator.backtest.historyFilterTimeframe': 'Timeframe', + 'dashboard.indicator.backtest.historyApply': 'Apply filters', + 'dashboard.indicator.backtest.historyAIAnalyze': 'AI Analyze', + 'dashboard.indicator.backtest.historyAIAnalyzeTitle': 'AI Suggestions', + 'dashboard.indicator.backtest.historyNoAIResult': 'No AI analysis result', + 'dashboard.indicator.backtest.historyRunId': 'Run ID', + 'dashboard.indicator.backtest.historyCreatedAt': 'Time', + 'dashboard.indicator.backtest.historyRange': 'Range', + 'dashboard.indicator.backtest.historyStatus': 'Status', + 'dashboard.indicator.backtest.historyStatusSuccess': 'Success', + 'dashboard.indicator.backtest.historyStatusFailed': 'Failed', + 'dashboard.indicator.backtest.historyActions': 'Actions', + 'trading-assistant.exchange.ipWhitelistTip': 'Please add the following IPs to the whitelist in your exchange API settings:', + 'signal-robot.title': 'Signal Robot Console', + 'signal-robot.createBot': 'Create New Bot', + 'signal-robot.search.nameOrSymbol': 'Name/Symbol', + 'signal-robot.search.placeholder': 'Search bot name or symbol', + 'signal-robot.search.status': 'Status', + 'signal-robot.search.statusAll': 'All', + 'signal-robot.search.statusRunning': 'Running', + 'signal-robot.search.statusPaused': 'Paused', + 'signal-robot.search.query': 'Query', + 'signal-robot.search.reset': 'Reset', + 'signal-robot.table.botName': 'Bot Name', + 'signal-robot.table.symbolTimeframe': 'Symbol/Timeframe', + 'signal-robot.table.triggerStrategy': 'Trigger Strategy', + 'signal-robot.table.notificationChannels': 'Notification Channels', + 'signal-robot.table.status': 'Status', + 'signal-robot.table.action': 'Action', + 'signal-robot.table.triggerConditions': '{count} trigger conditions', + 'signal-robot.table.monitoring': 'Monitoring', + 'signal-robot.table.paused': 'Paused', + 'signal-robot.table.edit': 'Edit', + 'signal-robot.table.pause': 'Pause', + 'signal-robot.table.start': 'Start', + 'signal-robot.table.delete': 'Delete', + 'signal-robot.table.deleteConfirm': 'Are you sure you want to delete this bot?', + 'signal-robot.table.deleteSuccess': 'Deleted successfully', + 'signal-robot.table.startSuccess': 'Bot started', + 'signal-robot.table.pauseSuccess': 'Bot paused', + 'signal-robot.channel.telegram': 'Telegram', + 'signal-robot.channel.discord': 'Discord', + 'signal-robot.channel.email': 'Email', + 'signal-robot.channel.webhook': 'Webhook', + 'signal-robot.channel.browser': 'Browser Push', + 'signal-robot.modal.createTitle': 'Create New Signal Robot', + 'signal-robot.modal.editTitle': 'Edit Signal Robot', + 'signal-robot.modal.step.basic': 'Basic Settings', + 'signal-robot.modal.step.entry': 'Entry Strategy', + 'signal-robot.modal.step.position': 'Position Management', + 'signal-robot.modal.step.risk': 'Risk Control', + 'signal-robot.modal.step.notify': 'Notification Config', + 'signal-robot.modal.prev': 'Previous', + 'signal-robot.modal.next': 'Next', + 'signal-robot.modal.preview': 'Preview Backtest', + 'signal-robot.modal.complete': 'Complete & Run', + 'signal-robot.modal.saveSuccess': 'Saved successfully', + 'signal-robot.modal.previewDeveloping': 'Preview feature is under development...', + 'signal-robot.modal.basicInfoRequired': 'Please complete basic information', + 'signal-robot.basic.alert': 'Set basic information for the bot', + 'signal-robot.basic.title': 'Basic Information', + 'signal-robot.basic.botName': 'Bot Name', + 'signal-robot.basic.botNamePlaceholder': 'e.g.: BTC SuperTrend Strategy', + 'signal-robot.basic.symbol': 'Trading Pair', + 'signal-robot.basic.symbolPlaceholder': 'Select from watchlist', + 'signal-robot.basic.noWatchlist': 'No watchlist items, please add in market page first', + 'signal-robot.basic.timeframe': 'K-Line Period', + 'signal-robot.basic.timeframe15m': '15 Minutes', + 'signal-robot.basic.timeframe30m': '30 Minutes', + 'signal-robot.basic.timeframe1h': '1 Hour', + 'signal-robot.basic.timeframe4h': '4 Hours', + 'signal-robot.basic.timeframe1d': '1 Day', + 'signal-robot.basic.autoNameSuggestion': '{symbol} Signal Robot', + 'signal-robot.entry.alert': 'Trigger entry signal when all conditions below are met', + 'signal-robot.entry.condition': 'Condition {index}', + 'signal-robot.entry.indicator': 'Indicator', + 'signal-robot.entry.indicatorSupertrend': 'SuperTrend', + 'signal-robot.entry.indicatorEma': 'EMA (Exponential Moving Average)', + 'signal-robot.entry.indicatorRsi': 'RSI (Relative Strength Index)', + 'signal-robot.entry.indicatorMacd': 'MACD', + 'signal-robot.entry.indicatorBollinger': 'Bollinger Bands', + 'signal-robot.entry.atrPeriod': 'ATR Period', + 'signal-robot.entry.multiplier': 'Multiplier', + 'signal-robot.entry.triggerSignal': 'Trigger Signal', + 'signal-robot.entry.signalTrendBullish': 'Trend Bullish', + 'signal-robot.entry.signalTrendBearish': 'Trend Bearish', + 'signal-robot.entry.signalIsUptrend': 'Is Uptrend', + 'signal-robot.entry.signalIsDowntrend': 'Is Downtrend', + 'signal-robot.entry.period': 'Period', + 'signal-robot.entry.compareCondition': 'Compare Condition', + 'signal-robot.entry.priceAbove': 'Price > EMA (Price Above)', + 'signal-robot.entry.priceBelow': 'Price < EMA (Price Below)', + 'signal-robot.entry.crossUp': 'Price Crosses Above EMA (Golden Cross)', + 'signal-robot.entry.crossDown': 'Price Crosses Below EMA (Death Cross)', + 'signal-robot.entry.compare': 'Compare', + 'signal-robot.entry.greaterThan': 'Greater Than', + 'signal-robot.entry.lessThan': 'Less Than', + 'signal-robot.entry.crossUpShort': 'Cross Up', + 'signal-robot.entry.crossDownShort': 'Cross Down', + 'signal-robot.entry.threshold': 'Threshold', + 'signal-robot.entry.selectIndicator': 'Please select an indicator to configure parameters', + 'signal-robot.entry.addCondition': 'Add Condition', + 'signal-robot.position.alert': 'Configure initial position size, leverage and pyramiding rules', + 'signal-robot.position.basicTitle': 'Basic Position Management', + 'signal-robot.position.initialSize': 'Initial Size (Capital %)', + 'signal-robot.position.initialSizeHint': 'Recommended 5% - 20%', + 'signal-robot.position.leverage': 'Leverage', + 'signal-robot.position.leverageHint': 'Please ensure your exchange account supports this leverage', + 'signal-robot.position.maxPyramiding': 'Max Pyramiding Times', + 'signal-robot.position.maxPyramidingHint': '0 means no pyramiding', + 'signal-robot.position.pyramidingTitle': 'Pyramiding Rules', + 'signal-robot.position.pyramidingEnabled': 'Enabled', + 'signal-robot.position.pyramidingDisabled': 'Disabled', + 'signal-robot.position.pyramidingCondition': 'Pyramiding Trigger Condition', + 'signal-robot.position.priceRisePct': 'Price Rises (Long) / Falls (Short)', + 'signal-robot.position.profitPct': 'Profit Reaches', + 'signal-robot.position.triggerThreshold': 'Trigger Threshold (%)', + 'signal-robot.position.triggerThresholdHint': 'e.g. 3%: add position once every 3% rise', + 'signal-robot.position.addSize': 'Single Add Size (Capital %)', + 'signal-robot.position.pyramidingDisabledHint': 'Pyramiding disabled, only initial position will be executed', + 'signal-robot.risk.alert': 'Configure take profit, stop loss and exit rules', + 'signal-robot.risk.stopLossTitle': 'Stop Loss', + 'signal-robot.risk.stopLossEnabled': 'Enabled', + 'signal-robot.risk.stopLossDisabled': 'Disabled', + 'signal-robot.risk.stopLossType': 'Stop Loss Type', + 'signal-robot.risk.stopLossFixedPct': 'Fixed Percentage', + 'signal-robot.risk.stopLossAtrMultiplier': 'ATR Multiplier', + 'signal-robot.risk.stopLossValue': 'Value', + 'signal-robot.risk.stopLossFixedHint': 'e.g. 2%: stop loss when loss reaches 2%', + 'signal-robot.risk.stopLossAtrHint': 'e.g. 2.0: stop loss when price touches ATR*2.0', + 'signal-robot.risk.stopLossDisabledHint': 'Stop loss disabled (high risk)', + 'signal-robot.risk.trailingStopTitle': 'Trailing Stop', + 'signal-robot.risk.activationProfit': 'Activation Profit (%)', + 'signal-robot.risk.activationProfitHint': 'Start trailing when profit reaches this level', + 'signal-robot.risk.callbackPct': 'Callback (%)', + 'signal-robot.risk.callbackPctHint': 'Trigger take profit when price falls from peak by this percentage', + 'signal-robot.risk.trailingStopDisabledHint': 'Trailing stop disabled', + 'signal-robot.risk.otherExitTitle': 'Other Exit Rules', + 'signal-robot.risk.signalExit': 'Allow Signal Exit', + 'signal-robot.risk.signalExitHint': 'Close position immediately when reverse signal appears (e.g. close long when short signal appears)', + 'signal-robot.notify.alert': 'Configure notification methods when signal is triggered', + 'signal-robot.notify.channelTitle': 'Notification Channels', + 'signal-robot.notify.discordWebhook': 'Discord Webhook', + 'signal-robot.notify.genericWebhook': 'Generic Webhook', + 'signal-robot.notify.webhookUrl': 'Webhook URL', + 'signal-robot.notify.webhookUrlPlaceholder': 'https://example.com/webhook', + 'signal-robot.notify.discordUrl': 'Discord URL', + 'signal-robot.notify.discordUrlPlaceholder': 'https://discord.com/api/webhooks/...', + 'signal-robot.operator.greaterThan': 'Greater Than', + 'signal-robot.operator.lessThan': 'Less Than', + 'signal-robot.operator.equal': 'Equal', + 'signal-robot.operator.goldenCross': 'Golden Cross', + 'signal-robot.operator.deathCross': 'Death Cross', + 'signal-robot.operator.increaseBy': 'Increase By', + 'signal-robot.operator.decreaseBy': 'Decrease By', + 'signal-robot.indicator.price': 'Price', + 'signal-robot.indicator.rsi': 'RSI', + 'signal-robot.indicator.kdjK': 'KDJ(K)', + 'signal-robot.indicator.kdjJ': 'KDJ(J)', + 'signal-robot.indicator.macd': 'MACD', + 'signal-robot.indicator.macdHist': 'MACD Histogram', + 'signal-robot.indicator.bollingerUp': 'Bollinger Upper', + 'signal-robot.indicator.bollingerLow': 'Bollinger Lower', + 'signal-robot.indicator.volume': 'Volume', + 'signal-robot.indicator.changePct1h': '1H Change %', + 'signal-robot.indicator.changePct24h': '24H Change %', + 'signal-robot.indicator.signalLine': 'Signal Line' +} + +export default { + ...components, + ...locale +} diff --git a/quantdinger_vue/src/locales/lang/vi-VN.js b/quantdinger_vue/src/locales/lang/vi-VN.js new file mode 100644 index 0000000..f0a37f6 --- /dev/null +++ b/quantdinger_vue/src/locales/lang/vi-VN.js @@ -0,0 +1,2896 @@ +import antdViVN from 'ant-design-vue/es/locale-provider/vi_VN' +import momentVI from 'moment/locale/vi' + +const components = { + antLocale: antdViVN, + momentName: 'vi', + momentLocale: momentVI +} + +const locale = { + + 'submit': 'Gửi', + + 'save': 'Lưu', + + 'submit.ok': 'Gửi thành công', + + 'save.ok': 'Lưu thành công', + + 'menu.welcome': 'Chào mừng', + + 'menu.home': 'Trang chủ', + + 'menu.dashboard': 'Bảng điều khiển', + + 'menu.dashboard.indicator': 'Phân tích chỉ báo', + + 'menu.dashboard.community': 'Cộng đồng chỉ báo', + + 'menu.dashboard.analysis': 'Phân tích AI', + + 'menu.dashboard.tradingAssistant': 'Trợ lý giao dịch', + + 'menu.dashboard.aiTradingAssistant': 'Trợ lý giao dịch AI', + 'menu.dashboard.signalRobot': 'Robot tín hiệu', + 'menu.dashboard.monitor': 'Giám sát Trang', + + 'menu.dashboard.workplace': 'Nơi làm việc', + + 'menu.form': 'Trang biểu mẫu', + + 'menu.form.basic-form': 'Biểu mẫu cơ bản', + + 'menu.form.step-form': 'Biểu mẫu từng bước', + + 'menu.form.step-form.info': 'Biểu mẫu từng bước (Điền thông tin chuyển khoản)', + + 'menu.form.step-form.confirm': 'Biểu mẫu từng bước (Xác nhận thông tin chuyển khoản)', + + 'menu.form.step-form.result': 'Biểu mẫu từng bước (Hoàn tất)', + + 'menu.form.advanced-form': 'Biểu mẫu nâng cao', + + 'menu.list': 'Trang danh sách', + + 'menu.list.table-list': 'Bảng truy vấn', + + 'menu.list.basic-list': 'Tiêu chuẩn Menu.list', + + 'menu.list.card-list': 'Danh sách thẻ', + + 'menu.list.search-list': 'Tìm kiếm danh sách', + + 'menu.list.search-list.articles': 'Tìm kiếm danh sách (Bài viết)', + + 'menu.list.search-list.projects': 'Tìm kiếm danh sách (Dự án)', + + 'menu.list.search-list.applications': 'Tìm kiếm danh sách (Ứng dụng)', + + 'menu.profile': 'Trang chi tiết', + + 'menu.profile.basic': 'Trang chi tiết cơ bản', + + 'menu.profile.advanced': 'Trang chi tiết nâng cao', + + 'menu.result': 'Trang kết quả', + + 'menu.result.success': 'Trang thành công', + + 'menu.result.fail': 'Trang thất bại', + + 'menu.exception': 'Ngoại lệ Trang', + + 'menu.exception.not-permission': '403', + + 'menu.exception.not-find': '404', + + 'menu.exception.server-error': '500', + + 'menu.exception.trigger': 'Đang gây ra lỗi', + + 'menu.account': 'Trang cá nhân', + + 'menu.account.center': 'Trung tâm cá nhân', + + 'menu.account.settings': 'Cài đặt cá nhân', + + 'menu.account.trigger': 'Đang gây ra lỗi', + + 'menu.account.logout': 'Đăng xuất', + + 'menu.wallet': 'Ví của tôi', + + 'menu.docs': 'Trung tâm tài liệu', + + 'menu.docs.detail': 'Chi tiết tài liệu', + + 'menu.header.refreshPage': 'Làm mới Trang', + + 'menu.invite.friends': 'Mời bạn bè', + + 'app.setting.pagestyle': 'Cài đặt kiểu tổng thể', + + 'app.setting.pagestyle.light': 'Kiểu menu sáng', + + 'app.setting.pagestyle.dark': 'Kiểu menu tối', + + 'app.setting.pagestyle.realdark': 'Chế độ tối', + + 'app.setting.themecolor': 'Màu chủ đề', + + 'app.setting.navigationmode': 'Chế độ điều hướng', + + 'app.setting.sidemenu.nav': 'Điều hướng thanh bên', + + 'app.setting.topmenu.nav': 'Điều hướng trên cùng', + + 'app.setting.content-width': 'Chiều rộng vùng nội dung', + + 'app.setting.content-width.tooltip': 'Cài đặt này chỉ có hiệu lực khi [Điều hướng trên cùng] được bật', + + 'app.setting.content-width.fixed': 'Cố định', + + 'app.setting.content-width.fluid': 'Linh hoạt', + + 'app.setting.fixedheader': 'Tiêu đề cố định', + + 'app.setting.fixedheader.tooltip': 'Có thể cấu hình khi tiêu đề cố định', + + 'app.setting.autoHideHeader': 'Ẩn tiêu đề khi vuốt xuống', + + 'app.setting.fixedsidebar': 'Menu thanh bên cố định', + + 'app.setting.sidemenu': 'Bố cục menu thanh bên', + + 'app.setting.topmenu': 'Bố cục menu trên cùng', + + 'app.setting.othersettings': 'Các cài đặt khác', + + 'app.setting.weakmode': 'Chế độ màu yếu', + + 'app.setting.multitab': 'Chế độ nhiều tab', + + 'app.setting.copy': 'Sao chép cài đặt', + + 'app.setting.loading': 'Đang tải theme', + + 'app.setting.copyinfo': 'Đã sao chép cài đặt thành công src/config/defaultSettings.js', + + 'app.setting.copy.success': 'Sao chép hoàn tất', + + 'app.setting.copy.fail': 'Sao chép thất bại', + + 'app.setting.theme.switching': 'Đang chuyển đổi giao diện!', + + 'app.setting.production.hint': 'Thanh cấu hình chỉ dùng để xem trước trong môi trường phát triển và sẽ không hiển thị trong môi trường sản xuất. Vui lòng sao chép và chỉnh sửa thủ công tệp cấu hình.', + + 'app.setting.themecolor.daybreak': 'Xanh dương bình minh (mặc định)', + + 'app.setting.themecolor.dust': 'Hoàng hôn', + + 'app.setting.themecolor.volcano': 'Núi lửa', + + 'app.setting.themecolor.sunset': 'Hoàng hôn', + + 'app.setting.themecolor.cyan': 'Xanh lam sáng', + + 'app.setting.themecolor.green': 'Xanh lục cực quang', + + 'app.setting.themecolor.geekblue': 'Xanh dương công nghệ', + + 'app.setting.themecolor.purple': 'Tím', + + 'app.setting.tooltip': 'Cài đặt trang', + + 'user.login.userName': 'Tên người dùng', + + 'user.login.password': 'Mật khẩu', + + 'user.login.username.placeholder': 'Tài khoản: admin', + + 'user.login.password.placeholder': 'Mật khẩu: admin hoặc ant.design', + + 'user.login.message-invalid-credentials': 'Đăng nhập thất bại, vui lòng kiểm tra email và mã xác minh của bạn', + + 'user.login.message-invalid-verification-code': 'Mã xác minh không chính xác', + + 'user.login.tab-login-credentials': 'Đăng nhập bằng tài khoản và mật khẩu', + + 'user.login.tab-login-email': 'Đăng nhập bằng email', + + 'user.login.tab-login-mobile': 'Đăng nhập bằng số điện thoại', + + 'user.login.captcha.placeholder': 'Vui lòng nhập mã xác minh hình ảnh', + + 'user.login.mobile.placeholder': 'Số điện thoại Số điện thoại', + + 'user.login.mobile.verification-code.placeholder': 'Mã xác thực', + + 'user.login.email.placeholder': 'Vui lòng nhập địa chỉ email', + + 'user.login.email.verification-code.placeholder': 'Vui lòng nhập mã xác thực', + + 'user.login.email.sending': 'Đang gửi mã xác thực...', + + 'user.login.email.send-success-title': 'Thông báo', + + 'user.login.email.send-success': 'Mã xác thực đã được gửi thành công, vui lòng kiểm tra email của bạn', + + 'user.login.sms.send-success': 'Mã xác thực đã được gửi thành công, vui lòng kiểm tra tin nhắn SMS của bạn', + + 'user.login.remember-me': 'Tự động đăng nhập', + + 'user.login.forgot-password': 'Quên mật khẩu Mật khẩu', + + 'user.login.sign-in-with': 'Các phương thức đăng nhập khác', + + 'user.login.signup': 'Đăng ký tài khoản', + + 'user.login.login': 'Đăng nhập', + + 'user.register.register': 'Đăng ký', + + 'user.register.email.placeholder': 'Email', + + 'user.register.password.placeholder': 'Vui lòng nhập ít nhất 6 ký tự. Vui lòng không sử dụng mật khẩu dễ đoán.', + + 'user.register.password.popover-message': 'Vui lòng nhập ít nhất 6 ký tự. Vui lòng không sử dụng mật khẩu dễ đoán.', + + 'user.register.confirm-password.placeholder': 'Xác nhận mật khẩu', + + 'user.register.get-verification-code': 'Lấy mã xác minh', + + 'user.register.sign-in': 'Đăng nhập bằng tài khoản hiện có', + + 'user.register-result.msg': 'Tài khoản của bạn: {email} đã được đăng ký thành công', + + 'user.register-result.activation-email': 'Email kích hoạt đã được gửi đến hộp thư đến của bạn. Email có hiệu lực trong 24 giờ. Vui lòng đăng nhập vào email của bạn và nhấp vào liên kết trong email để kích hoạt tài khoản.', + + 'user.register-result.back-home': 'Quay lại trang chủ', + + 'user.register-result.view-mailbox': 'Xem email', + + 'user.email.required': 'Vui lòng nhập địa chỉ email của bạn!', + + 'user.email.wrong-format': 'Định dạng địa chỉ email không chính xác!', + + 'user.userName.required': 'Vui lòng nhập tên người dùng hoặc địa chỉ email của bạn', + +'user.password.required': 'Vui lòng nhập mật khẩu của bạn!', + +'user.password.twice.msg': 'Hai mật khẩu không khớp!', + +'user.password.strength.msg': 'Mật khẩu không đủ mạnh', + +'user.password.strength.strong': 'Độ mạnh: Mạnh', + +'user.password.strength.medium': 'Độ mạnh: Trung bình', + +'user.password.strength.low': 'Độ mạnh: Yếu', + +'user.password.strength.short': 'Độ mạnh: Quá ngắn', + +'user.confirm-password.required': 'Vui lòng xác nhận mật khẩu của bạn!', + +'user.phone-number.required': 'Vui lòng nhập số điện thoại hợp lệ', + +'user.phone-number.wrong-format': 'Định dạng số điện thoại không chính xác!', + +'user.verification-code.required': 'Vui lòng nhập mã xác minh!', + +'user.captcha.required': 'Vui lòng nhập mã xác minh hình ảnh!', + +'user.login.infos': 'QuantDinger là công cụ phân tích cổ phiếu đa tác nhân AI và không có chứng chỉ tư vấn đầu tư chứng khoán. Tất cả kết quả phân tích, xếp hạng và ý kiến ​​tham khảo trên nền tảng đều được AI tự động tạo ra dựa trên dữ liệu lịch sử và chỉ dành cho mục đích học tập, nghiên cứu và trao đổi kỹ thuật. Chúng không cấu thành bất kỳ lời khuyên đầu tư hoặc cơ sở ra quyết định nào. Đầu tư cổ phiếu tiềm ẩn nhiều rủi ro như rủi ro thị trường, rủi ro thanh khoản và rủi ro chính sách, có thể dẫn đến mất vốn gốc. Người dùng nên đưa ra quyết định độc lập dựa trên khả năng chấp nhận rủi ro của mình, và mọi hành vi đầu tư và hậu quả phát sinh từ việc sử dụng công cụ này đều do người dùng chịu trách nhiệm. Thị trường đầy rủi ro, và đầu tư cần thận trọng.', + +'user.login.tab-login-web3': 'Đăng nhập Web3', + +'user.login.web3.tip': 'Đăng nhập bằng ví để xác thực chữ ký', + +'user.login.web3.connect': 'Kết nối ví và đăng nhập', + +'user.login.web3.no-wallet': 'Không phát hiện ví', + +'user.login.web3.no-address': 'Không lấy được địa chỉ ví', + +'user.login.web3.nonce-failed': 'Không thể lấy số ngẫu nhiên', + +'user.login.web3.verify-failed': 'Xác thực chữ ký thất bại', + +'user.login.web3.success': 'Đăng nhập thành công', + +'user.login.web3.failed': 'Đăng nhập ví thất bại', + +'nav.no_wallet': 'Ví không được tìm thấy đã phát hiện', + +'nav.copy': 'Sao chép', + +'nav.copy_success': 'Sao chép thành công', + +'user.login.oauth.google': 'Đăng nhập bằng Google', + +'user.login.oauth.github': 'Đăng nhập bằng GitHub', + +'user.login.oauth.loading': 'Đang chuyển hướng đến trang xác thực...', + +'user.login.oauth.failed': 'Đăng nhập OAuth thất bại', + +'user.login.oauth.get-url-failed': 'Không thể lấy liên kết xác thực', + +'user.login.subtitle': 'Thông tin chi tiết định lượng về thị trường toàn cầu', + +'user.login.legal.title': 'Tuyên bố pháp lý', + +'user.login.legal.view': 'Xem tuyên bố pháp lý', + +'user.login.legal.collapse': 'Thu gọn Tuyên bố pháp lý', + +'user.login.legal.agree': 'Tôi đã đọc và đồng ý với tuyên bố pháp lý', + +'user.login.legal.required': 'Vui lòng đọc và kiểm tra tuyên bố pháp lý trước', + +'user.login.legal.content': 'QuantDinger là công cụ phân tích cổ phiếu đa tác nhân AI và không có bằng cấp tư vấn đầu tư chứng khoán. Tất cả kết quả phân tích, xếp hạng và ý kiến ​​tham khảo trên nền tảng đều được AI tự động tạo ra dựa trên dữ liệu lịch sử và chỉ dành cho mục đích học tập, nghiên cứu và trao đổi kỹ thuật, và không cấu thành bất kỳ lời khuyên đầu tư hoặc cơ sở ra quyết định nào. Đầu tư cổ phiếu tiềm ẩn nhiều rủi ro như rủi ro thị trường, rủi ro thanh khoản và rủi ro chính sách, có thể dẫn đến mất vốn gốc. Người dùng nên đưa ra quyết định độc lập dựa trên khả năng chấp nhận rủi ro của mình, và mọi hành vi đầu tư và hậu quả phát sinh từ việc sử dụng công cụ này sẽ do người dùng chịu trách nhiệm. Thị trường đầy rủi ro, và đầu tư cần thận trọng.', + +'user.login.privacy.title': 'Điều khoản Quyền riêng tư Người dùng', + +'user.login.privacy.view': 'Xem Điều khoản Quyền riêng tư Người dùng', + +'user.login.privacy.collapse': 'Thu gọn Điều khoản Quyền riêng tư Người dùng', + +'user.login.privacy.content': 'Chúng tôi coi trọng quyền riêng tư và việc bảo vệ dữ liệu của bạn. 1) Phạm vi Thu thập: Chúng tôi chỉ thu thập thông tin cần thiết để thực hiện các chức năng (chẳng hạn như email, số điện thoại di động, mã vùng, địa chỉ ví Web3) và nhật ký cần thiết cũng như thông tin thiết bị. 2) Mục đích Sử dụng: Để đăng nhập tài khoản và xác minh bảo mật, cung cấp chức năng dịch vụ, khắc phục sự cố và tuân thủ các yêu cầu. 3) Lưu trữ và Bảo mật: Dữ liệu được lưu trữ mã hóa và các biện pháp kiểm soát quyền và truy cập cần thiết được thực hiện để ngăn chặn truy cập trái phép, tiết lộ hoặc mất mát. 4) Chia sẻ và Bên thứ ba: Ngoại trừ trường hợp được yêu cầu bởi luật và quy định hoặc cần thiết để thực hiện dịch vụ, chúng tôi sẽ không chia sẻ thông tin cá nhân của bạn với bên thứ ba; Nếu có sự tham gia của các dịch vụ bên thứ ba (chẳng hạn như ví điện tử, nhà cung cấp dịch vụ SMS), dữ liệu sẽ chỉ được xử lý ở mức tối thiểu cần thiết để thực hiện các chức năng. 5) Cookie/Bộ nhớ cục bộ: Được sử dụng để xác thực trạng thái đăng nhập và duy trì phiên làm việc cần thiết (chẳng hạn như mã thông báo, PHPSESSID). Bạn có thể xóa hoặc hạn chế chúng trong trình duyệt của mình. 6) Quyền cá nhân: Bạn có thể thực hiện các quyền của mình để yêu cầu, sửa đổi, xóa và rút lại sự đồng ý theo luật và quy định hiện hành. 7) Thay đổi và Thông báo: Các bản cập nhật cho các điều khoản này sẽ được hiển thị rõ ràng trên trang. Việc tiếp tục sử dụng dịch vụ này cho thấy bạn đã đọc và đồng ý với nội dung được cập nhật. Nếu bạn không đồng ý với các điều khoản này hoặc bất kỳ bản cập nhật nào trong đó, vui lòng ngừng sử dụng dịch vụ này và liên hệ với chúng tôi. ', + +'account.basicInfo': 'Thông tin cơ bản', + +'account.id': 'ID người dùng', + +'account.username': 'Tên người dùng', + +'account.nickname': 'Biệt danh', + +'account.email': 'Email', + +'account.mobile': 'Số điện thoại', + +'account.web3address': 'Địa chỉ ví', + +'account.pid': 'ID người giới thiệu', + +'account.level': 'Cấp độ người dùng', + +'account.money': 'Số dư', + +'account.qdtBalance': 'Số dư QDT', + +'account.score': 'Điểm', + +'account.createtime': 'Thời gian đăng ký', + +'account.inviteLink': 'Liên kết mời', + +'account.recharge': 'Nạp tiền', + +'account.rechargeTip': 'Mã QR bên dưới để nạp tiền bằng WeChat hoặc Alipay', + +'account.qrCodePlaceholder': 'Mã QR giả lập', + +'account.rechargeAmount': 'Số tiền cần nạp', + +'account.enterAmount': 'Vui lòng nhập số tiền cần nạp', + +'account.rechargeHint': 'Số tiền nạp tối thiểu: 1 QDT', + +'account.confirmRecharge': 'Xác nhận nạp tiền', + +'account.enterValidAmount': 'Vui lòng nhập số tiền hợp lệ', + +'account.rechargeSuccess': 'Nạp tiền thành công! Số tiền {amount} QDT đã được nạp', + +'account.settings.menuMap.basic': 'Cài đặt cơ bản', + +'account.settings.menuMap.security': 'Cài đặt bảo mật', + +'account.settings.menuMap.notification': 'Thông báo tin nhắn mới', + +'account.settings.menuMap.moneyLog': 'Chi tiết quỹ', + +'account.moneyLog.empty': 'Không có chi tiết quỹ', + +'account.moneyLog.total': 'Tổng số {total} giao dịch', + +'account.moneyLog.type.purchase': 'Hạn mức mua hàng', + +'account.moneyLog.type.recharge': 'Nạp tiền', + +'account.moneyLog.type.refund': 'Hoàn tiền', + +'account.moneyLog.type.reward': 'Thưởng', + +'account.moneyLog.type.income': 'Thu nhập Các chỉ báo', + +'account.moneyLog.type.commission': 'Phí nền tảng', + +'wallet.balance': 'Số dư QDT', + +'wallet.recharge': 'Nạp tiền', + +'wallet.withdraw': 'Rút tiền', + +'wallet.filter': 'Lọc', + +'wallet.reset': 'Đặt lại', + +'wallet.totalRecharge': 'Tổng số tiền nạp', + +'wallet.totalWithdraw': 'Tổng số tiền rút', + +'wallet.totalIncome': 'Tổng số tiền thu nhập', + +'wallet.records': 'Lịch sử giao dịch và chi tiết quỹ', + +'wallet.tradingRecords': 'Lịch sử giao dịch', + +'wallet.moneyLog': 'Chi tiết quỹ', + +'wallet.rechargeTip': 'Vui lòng quét mã QR bên dưới bằng WeChat hoặc Alipay để nạp tiền', + +'wallet.qrCodePlaceholder': 'Mã QR (mô phỏng)', + +'wallet.rechargeAmount': 'Số tiền cần nạp', + +'wallet.enterAmount': 'Vui lòng nhập số tiền cần nạp', + +'wallet.rechargeHint': 'Số tiền nạp tối thiểu: 1 QDT', + +'wallet.confirmRecharge': 'Xác nhận nạp tiền', + +'wallet.enterValidAmount': 'Vui lòng nhập số tiền hợp lệ', + +'wallet.rechargeSuccess': 'Nạp tiền thành công! Đã thanh toán {amount} QDT', + +'wallet.rechargeFailed': 'Giao dịch thất bại', + +'wallet.withdrawTip': 'Vui lòng nhập số tiền rút và địa chỉ rút tiền', + +'wallet.withdrawAmount': 'Số tiền rút', + +'wallet.enterWithdrawAmount': 'Vui lòng nhập số tiền rút', + +'wallet.withdrawHint': 'Số tiền rút tối thiểu: 1 QDT', + +'wallet.withdrawAddress': 'Địa chỉ rút tiền (tùy chọn)', + +'wallet.enterWithdrawAddress': 'Vui lòng nhập địa chỉ rút tiền', + +'wallet.confirmWithdraw': 'Xác nhận rút tiền', + +'wallet.enterValidWithdrawAmount': 'Vui lòng nhập số tiền rút hợp lệ', + +'wallet.insufficientBalance': 'Số dư không đủ', + +'wallet.withdrawSuccess': 'Rút tiền thành công! Số tiền {amount} QDT đã rút', + +'wallet.withdrawFailed': 'Giao dịch rút tiền thất bại', + +'wallet.noTradingRecords': 'Không có bản ghi giao dịch nào', + +'wallet.noMoneyLog': 'Không có chi tiết giao dịch', + +'wallet.loadTradingRecordsFailed': 'Không thể tải bản ghi giao dịch', + +'wallet.loadMoneyLogFailed': 'Không thể tải chi tiết giao dịch', + +'wallet.moneyLogTotal': 'Tổng số {total} bản ghi', + +'wallet.moneyLogTypeTitle': 'Loại', + +'wallet.moneyLogType.all': 'Tất cả các loại', + +'wallet.table.time': 'Thời gian', + +'wallet.table.type': 'Loại', + +'wallet.table.price': 'Giá', + +'wallet.table.amount': 'Số lượng', + +'wallet.table.money': 'Số tiền', + +'wallet.table.balance': 'Số dư', + +'wallet.table.memo': 'Ghi chú', + +'wallet.table.value': 'Giá trị', + +'wallet.table.profit': 'Lợi nhuận/Thua lỗ', + +'wallet.table.commission': 'Hoa hồng', + +'wallet.table.total': 'Tổng số {total} bản ghi', + +'wallet.tradeType.buy': 'Mua', + +'wallet.tradeType.sell': 'Bán', + +'wallet.tradeType.liquidation': 'Thanh lý', + +'wallet.tradeType.openLong': 'Mở lệnh mua', + +'wallet.tradeType.addLong': 'Thêm lệnh mua', + +'wallet.tradeType.closeLong': 'Đóng lệnh mua', + +'wallet.tradeType.closeLongStop': 'Đóng lệnh mua và cắt lỗ', + +'wallet.tradeType.closeLongProfit': 'Chốt lời và đóng lệnh mua', +'wallet.tradeType.openShort': 'Mở vị thế bán khống', + +'wallet.tradeType.addShort': 'Thêm vị thế bán khống', + +'wallet.tradeType.closeShort': 'Đóng vị thế bán khống', + +'wallet.tradeType.closeShortStop': 'Đóng vị thế bán khống với lệnh dừng lỗ', + +'wallet.tradeType.closeShortProfit': 'Chốt lời và đóng vị thế bán khống', + +'wallet.moneyLogType.purchase': 'Chỉ báo mua', + +'wallet.moneyLogType.recharge': 'Nạp tiền', + +'wallet.moneyLogType.withdraw': 'Rút tiền mặt', + +'wallet.moneyLogType.refund': 'Hoàn tiền', + +'wallet.moneyLogType.reward': 'Thưởng', + +'wallet.moneyLogType.income': 'Thu nhập', + +'wallet.moneyLogType.commission': 'Phí giao dịch', + +'wallet.web3Address.required': 'Vui lòng nhập địa chỉ ví Web3 của bạn trước', + +'wallet.web3Address.requiredDescription': 'Bạn cần liên kết địa chỉ ví Web3 của mình trước khi gửi tiền để nhận tiền gửi', + +'wallet.web3Address.placeholder': 'Vui lòng nhập địa chỉ ví Web3 của bạn (bắt đầu bằng 0x)', + +'wallet.web3Address.save': 'Lưu địa chỉ ví', + +'wallet.web3Address.saveSuccess': 'Địa chỉ ví đã được lưu thành công', + +'wallet.web3Address.saveFailed': 'Không thể lưu địa chỉ ví', + +'wallet.web3Address.invalidFormat': 'Vui lòng nhập địa chỉ ví Web3 hợp lệ (định dạng Ethereum: 42 ký tự bắt đầu bằng 0x, hoặc TRC20) Định dạng: 34 ký tự bắt đầu bằng T)', + +'wallet.selectCoin': 'Chọn loại tiền điện tử', + +'wallet.selectChain': 'Chọn chuỗi khối', + +'wallet.chain.eth': 'ETH (ERC20)', + +'wallet.chain.trc20': 'TRC20', + +'wallet.chain.bsc': 'BSC', + +'wallet.targetQdtAmount': 'Số lượng QDT bạn muốn đổi (tùy chọn)', + +'wallet.targetQdtAmount.placeholder': 'Vui lòng nhập số lượng QDT bạn muốn đổi, giá QDT hiện tại: {price} USDT', + +'wallet.targetQdtAmount.requiredUsdt': 'Số tiền cần nạp: {amount} USDT', + +'wallet.rechargeAddress': 'Địa chỉ nạp tiền', + +'wallet.copyAddress': 'Sao chép', + +'wallet.copySuccess': 'Sao chép thành công!', + +'wallet.copyFailed': 'Sao chép thất bại, vui lòng sao chép thủ công', + +'wallet.rechargeAddressHint': 'Vui lòng đảm bảo bạn sử dụng chuỗi {chain} để gửi {coin} vào địa chỉ này', + +'wallet.qdtPrice.loading': 'Đang tải...', + +'wallet.rechargeTip.new': 'Vui lòng chọn loại tiền tệ và chuỗi, sau đó quét mã QR bên dưới để gửi tiền', + +'wallet.exchangeRate': '1 QDT ≈ {rate} USDT', + +'wallet.withdrawableAmount': 'Số tiền có thể rút', + +'wallet.totalBalance': 'Tổng số dư', + +'wallet.insufficientWithdrawable': 'Số tiền có thể rút không đủ, hiện tại chỉ có thể rút: {amount} QDT', + +'wallet.withdrawAddressRequired': 'Vui lòng nhập địa chỉ rút tiền địa chỉ', +'wallet.withdrawAddressHint': 'Vui lòng đảm bảo địa chỉ chính xác; không thể hủy bỏ giao dịch rút tiền sau khi đã gửi', + +'wallet.withdrawSubmitSuccess': 'Yêu cầu rút tiền đã được gửi thành công; Vui lòng chờ xem xét', + +'menu.footer.contactUs': 'Liên hệ với chúng tôi', + +'menu.footer.getSupport': 'Nhận hỗ trợ', + +'menu.footer.socialAccounts': 'Tài khoản mạng xã hội', + +'menu.footer.userAgreement': 'Thỏa thuận người dùng', + +'menu.footer.privacyPolicy': 'Chính sách bảo mật', + +'menu.footer.support': 'Hỗ trợ', + +'menu.footer.featureRequest': 'Yêu cầu tính năng', + +'menu.footer.email': 'Email', + +'menu.footer.liveChat': 'Trò chuyện trực tuyến 24/7', + +'dashboard.analysis.title': 'Phân tích đa chiều', + +'dashboard.analysis.subtitle': 'Phân tích tài chính toàn diện dựa trên AI Nền tảng', + +'dashboard.analysis.selectSymbol': 'Chọn hoặc nhập mã chứng khoán', + +'dashboard.analysis.selectModel': 'Chọn mô hình', + +'dashboard.analysis.startAnalysis': 'Bắt ​​đầu phân tích', + +'dashboard.analysis.history': 'Lịch sử', + +'dashboard.analysis.tab.overview': 'Phân tích toàn diện', + +'dashboard.analysis.tab.fundamental': 'Các yếu tố cơ bản', + +'dashboard.analysis.tab.technical': 'Phân tích kỹ thuật', + +'dashboard.analysis.tab.news': 'Tin tức', + +'dashboard.analysis.tab.sentiment': 'Tâm lý thị trường', + +'dashboard.analysis.tab.risk': 'Rủi ro', + +'dashboard.analysis.tab.debate': 'Tăng/Giảm giá', +'dashboard.analysis.tab.decision': 'Quyết định cuối cùng', + +'dashboard.analysis.empty.selectSymbol': 'Chọn mục tiêu để bắt đầu phân tích', + +'dashboard.analysis.empty.selectSymbolDesc': 'Chọn mục tiêu từ danh sách theo dõi của bạn hoặc nhập mã của nó để nhận báo cáo phân tích AI đa chiều', + +'dashboard.analysis.empty.startAnalysis': 'Nhấp vào nút "Bắt đầu phân tích" để phân tích đa chiều', + +'dashboard.analysis.empty.startAnalysisDesc': 'Chúng tôi sẽ cung cấp cho bạn phân tích toàn diện từ nhiều khía cạnh bao gồm cơ bản, kỹ thuật, tin tức, tâm lý và rủi ro', + +'dashboard.analysis.empty.noData': 'Không có dữ liệu phân tích cho {type}, vui lòng thực hiện phân tích toàn diện trước', + +'dashboard.analysis.empty.noWatchlist': 'Không có danh sách theo dõi có sẵn', + +'dashboard.analysis.empty.noHistory': 'Không có dữ liệu lịch sử', + +'dashboard.analysis.empty.watchlistHint': 'Không có danh sách theo dõi, vui lòng thực hiện phân tích toàn diện trước', + +'dashboard.analysis.empty.noDebateData': 'Không có dữ liệu tranh luận', + +'dashboard.analysis.empty.noDecisionData': 'Không có dữ liệu quyết định', + +'dashboard.analysis.empty.selectAgent': 'Vui lòng chọn một Đại lý để xem kết quả phân tích', + +'dashboard.analysis.loading.analyzing': 'Đang phân tích, vui lòng chờ...', + +'dashboard.analysis.loading.fundamental': 'Đang phân tích dữ liệu cơ bản...', + +'dashboard.analysis.loading.technical': 'Đang phân tích các chỉ báo kỹ thuật...', + +'dashboard.analysis.loading.news': 'Đang phân tích dữ liệu tin tức...', + +'dashboard.analysis.loading.sentiment': 'Phân tích tâm lý thị trường...', + +'dashboard.analysis.loading.risk': 'Đánh giá rủi ro...', + +'dashboard.analysis.loading.debate': 'Cuộc tranh luận đang diễn ra giữa phe tăng giá và phe giảm giá...', + +'dashboard.analysis.loading.decision': 'Đưa ra quyết định cuối cùng...', + +'dashboard.analysis.score.overall': 'Điểm tổng thể', + +'dashboard.analysis.score.recommendation': 'Lời khuyên đầu tư', + +'dashboard.analysis.score.confidence': 'Mức độ tin cậy', + +'dashboard.analysis.dimension.fundamental': 'các yếu tố cơ bản', + +'dashboard.analysis.dimension.technical': 'các yếu tố kỹ thuật', + +'dashboard.analysis.dimension.news': 'tin tức', + +'dashboard.analysis.dimension.sentiment': 'tâm lý', + +'dashboard.analysis.dimension.risk': 'rủi ro', + +'dashboard.analysis.card.dimensionScores': 'điểm số cho từng yếu tố', + +'dashboard.analysis.card.overviewReport': 'báo cáo tổng quan', + +'dashboard.analysis.card.financialMetrics': 'các chỉ số tài chính', + +'dashboard.analysis.card.fundamentalReport': 'báo cáo phân tích cơ bản', + +'dashboard.analysis.card.technicalIndicators': 'Các chỉ số kỹ thuật Các chỉ báo', + +'dashboard.analysis.card.technicalReport': 'Báo cáo phân tích kỹ thuật', + +'dashboard.analysis.card.newsList': 'Tin tức liên quan', + +'dashboard.analysis.card.newsReport': 'Báo cáo phân tích tin tức', + +'dashboard.analysis.card.sentimentIndicators': 'Các chỉ báo tâm lý', + +'dashboard.analysis.card.sentimentReport': 'Báo cáo phân tích tâm lý', + +'dashboard.analysis.card.riskMetrics': 'Các chỉ báo rủi ro', + +'dashboard.analysis.card.riskReport': 'Báo cáo đánh giá rủi ro', + +'dashboard.analysis.card.bullView': 'Quan điểm lạc quan', + +'dashboard.analysis.card.bearView': 'Quan điểm bi quan', + +'dashboard.analysis.card.researchConclusion': 'Kết luận của nhà nghiên cứu', + +'dashboard.analysis.card.traderPlan': 'Kế hoạch giao dịch', + +'dashboard.analysis.card.riskDebate': 'Thảo luận của Ủy ban Rủi ro', + +'dashboard.analysis.card.finalDecision': 'Quyết định cuối cùng', + +'dashboard.analysis.card.tradePlanDetail': 'Chi tiết kế hoạch giao dịch', + +'dashboard.analysis.tradingPlan.entry_price': 'Giá vào lệnh', + +'dashboard.analysis.tradingPlan.position_size': 'Quy mô vị thế', + +'dashboard.analysis.tradingPlan.stop_loss': 'Cắt lỗ', + +'dashboard.analysis.tradingPlan.take_profit': 'Chốt lời', + +'dashboard.analysis.label.confidence': 'Mức độ tự tin Mức độ', + +'dashboard.analysis.label.keyPoints': 'Điểm chính', + +'dashboard.analysis.label.riskWarning': 'Cảnh báo rủi ro', + +'dashboard.analysis.risk.risky': 'Quan điểm rủi ro', + +'dashboard.analysis.risk.neutral': 'Quan điểm trung lập', + +'dashboard.analysis.risk.safe': 'Quan điểm an toàn', + +'dashboard.analysis.risk.conclusion': 'Kết luận', + +'dashboard.analysis.feature.fundamental': 'Phân tích cơ bản', + +'dashboard.analysis.feature.technical': 'Phân tích kỹ thuật', + +'dashboard.analysis.feature.news': 'Phân tích tin tức', + +'dashboard.analysis.feature.sentiment': 'Phân tích cảm xúc', + +'dashboard.analysis.feature.risk': 'Rủi ro Đánh giá', + +'dashboard.analysis.watchlist.title': 'Danh sách theo dõi của tôi', + +'dashboard.analysis.watchlist.add': 'Thêm', + +'dashboard.analysis.watchlist.addStock': 'Thêm cổ phiếu', + +'dashboard.analysis.modal.addStock.title': 'Thêm vào danh sách theo dõi', + +'dashboard.analysis.modal.addStock.confirm': 'Xác nhận', + +'dashboard.analysis.modal.addStock.cancel': 'Hủy', + +'dashboard.analysis.modal.addStock.market': 'Loại thị trường', + +'dashboard.analysis.modal.addStock.marketPlaceholder': 'Vui lòng chọn thị trường', + +'dashboard.analysis.modal.addStock.marketRequired': 'Vui lòng chọn loại thị trường', + +'dashboard.analysis.modal.addStock.symbol': 'Mã chứng khoán', + +'dashboard.analysis.modal.addStock.symbolPlaceholder': 'Ví dụ: AAPL, TSLA, GOOGL, 000001, BTC', + +'dashboard.analysis.modal.addStock.symbolRequired': 'Vui lòng nhập mã chứng khoán', + +'dashboard.analysis.modal.addStock.searchPlaceholder': 'Tìm kiếm mã chứng khoán hoặc tên cổ phiếu', + +'dashboard.analysis.modal.addStock.searchOrInputPlaceholder': 'Tìm kiếm hoặc nhập mã chứng khoán (ví dụ: AAPL, BTC/USDT, EUR/USD)', + +'dashboard.analysis.modal.addStock.searchOrInputHint': 'Hỗ trợ tìm kiếm cổ phiếu trong cơ sở dữ liệu hoặc nhập trực tiếp mã (hệ thống sẽ tự động truy xuất tên)', + +'dashboard.analysis.modal.addStock.search': 'Tìm kiếm', + +'dashboard.analysis.modal.addStock.searchResults': 'Kết quả tìm kiếm', + +'dashboard.analysis.modal.addStock.hotSymbols': 'Cổ phiếu nóng', + +'dashboard.analysis.modal.addStock.noHotSymbols': 'Không có cổ phiếu nóng', + +'dashboard.analysis.modal.addStock.selectedSymbol': 'Cổ phiếu đã chọn', + +'dashboard.analysis.modal.addStock.pleaseSelectSymbol': 'Vui lòng chọn cổ phiếu trước', + +'dashboard.analysis.modal.addStock.pleaseSelectOrEnterSymbol': 'Vui lòng chọn cổ phiếu hoặc nhập mã cổ phiếu', + +'dashboard.analysis.modal.addStock.pleaseEnterSymbol': 'Vui lòng nhập mã cổ phiếu', + +'dashboard.analysis.modal.addStock.pleaseSelectMarket': 'Vui lòng chọn loại thị trường', + +'dashboard.analysis.modal.addStock.searchFailed': 'Tìm kiếm thất bại, vui lòng thử lại sau', + +'dashboard.analysis.modal.addStock.noSearchResults': 'Không tìm thấy cổ phiếu phù hợp', + +'dashboard.analysis.modal.addStock.willAutoFetchName': 'Hệ thống sẽ tự động lấy tên', + +'dashboard.analysis.modal.addStock.addDirectly': 'Thêm trực tiếp', + +'dashboard.analysis.modal.addStock.nameWillBeFetched': 'Tên sẽ được tự động lấy khi thêm', + +'dashboard.analysis.market.AShare': 'Cổ phiếu loại A', + +'dashboard.analysis.market.USStock': 'Cổ phiếu Mỹ', + +'dashboard.analysis.market.HShare': 'Cổ phiếu Hồng Kông cổ phiếu', + +'dashboard.analysis.market.Crypto': 'Tiền điện tử', + +'dashboard.analysis.market.Forex': 'Ngoại hối', + +'dashboard.analysis.market.Futures': 'Hợp đồng tương lai', + +'dashboard.analysis.modal.history.title': 'Lịch sử phân tích', + +'dashboard.analysis.modal.history.viewResult': 'Xem kết quả', + +'dashboard.analysis.modal.history.completeTime': 'Thời gian hoàn thành', + +'dashboard.analysis.modal.history.error': 'Lỗi', + +'dashboard.analysis.status.pending': 'Đang chờ xử lý', + +'dashboard.analysis.status.processing': 'Đang xử lý', + +'dashboard.analysis.status.completed': 'Hoàn thành', + +'dashboard.analysis.status.failed': 'Thất bại', + +'dashboard.analysis.message.selectSymbol': 'Vui lòng chọn mục tiêu trước', + +'dashboard.analysis.message.taskCreated': 'Tác vụ phân tích đã được tạo, đang thực thi trong nền...', + +'dashboard.analysis.message.analysisComplete': 'Phân tích đã hoàn tất', + +'dashboard.analysis.message.analysisCompleteCache': 'Phân tích đã hoàn tất (sử dụng dữ liệu được lưu trong bộ nhớ cache)', + +'dashboard.analysis.message.analysisFailed': 'Phân tích thất bại, vui lòng thử lại sau', + +'dashboard.analysis.message.addStockSuccess': 'Đã thêm thành công', + +'dashboard.analysis.message.addStockFailed': 'Thêm thất bại', + +'dashboard.analysis.message.removeStockSuccess': 'Đã xóa thành công', + +'dashboard.analysis.message.removeStockFailed': 'Xóa thất bại', + +'dashboard.analysis.test': 'Cửa hàng đường Gongzhuan {không}', + +'dashboard.analysis.introduce': 'Mô tả chỉ báo', + +'dashboard.analysis.total-sales': 'Tổng doanh thu', + +'dashboard.analysis.day-sales': 'Doanh thu trung bình hàng ngày', + +'dashboard.analysis.visits': 'Lượt truy cập', + +'dashboard.analysis.visits-trend': 'Lượt truy cập xu hướng', + +'dashboard.analysis.visits-ranking': 'Xếp hạng lượt truy cập cửa hàng', + +'dashboard.analysis.day-visits': 'Lượt truy cập hàng ngày', + +'dashboard.analysis.week': 'Lượt truy cập hàng tuần so với cùng kỳ năm trước', + +'dashboard.analysis.day': 'Lượt truy cập hàng ngày so với cùng kỳ năm trước', + +'dashboard.analysis.payments': 'Số lượng thanh toán', + +'dashboard.analysis.conversion-rate': 'Tỷ lệ chuyển đổi', + +'dashboard.analysis.operational-effect': 'Hiệu quả hoạt động', + +'dashboard.analysis.sales-trend': 'Xu hướng doanh số', + +'dashboard.analysis.sales-ranking': 'Xếp hạng doanh số cửa hàng', + +'dashboard.analysis.all-year': 'Quanh năm', + +'dashboard.analysis.all-month': 'Tháng này', + +'dashboard.analysis.all-week': 'Tuần này', + +'dashboard.analysis.all-day': 'Hôm nay', + +'dashboard.analysis.search-users': 'Số lượng người dùng tìm kiếm', + +'dashboard.analysis.per-capita-search': 'Số lượt tìm kiếm trung bình trên đầu người', + +'dashboard.analysis.online-top-search': 'Các từ khóa tìm kiếm trực tuyến phổ biến', + +'dashboard.analysis.the-proportion-of-sales': 'Doanh số theo danh mục', + +'dashboard.analysis.dropdown-option-one': 'Phương án một', + +'dashboard.analysis.dropdown-option-two': 'Phương án hai', + +'dashboard.analysis.channel.all': 'Tất cả Kênh', + +'dashboard.analysis.channel.online': 'Trực tuyến', + +'dashboard.analysis.channel.stores': 'Cửa hàng', + +'dashboard.analysis.sales': 'Doanh thu bán hàng', + +'dashboard.analysis.traffic': 'Lưu lượng truy cập', + +'dashboard.analysis.table.rank': 'Xếp hạng', + +'dashboard.analysis.table.search-keyword': 'Từ khóa tìm kiếm', + +'dashboard.analysis.table.users': 'Số lượng người dùng', + +'dashboard.analysis.table.weekly-range': 'Tăng trưởng hàng tuần', + +'dashboard.indicator.selectSymbol': 'Chọn hoặc nhập mã cổ phiếu', + +'dashboard.indicator.emptyWatchlistHint': 'Hiện không có cổ phiếu nào trong danh sách theo dõi, vui lòng thêm vào, Đầu tiên', + +'dashboard.indicator.hint.selectSymbol': 'Vui lòng chọn cổ phiếu để bắt đầu phân tích', + +'dashboard.indicator.hint.selectSymbolDesc': 'Chọn hoặc nhập mã cổ phiếu vào ô tìm kiếm phía trên để xem biểu đồ nến và các chỉ báo kỹ thuật', + +'dashboard.indicator.retry': 'Thử lại', + +'dashboard.indicator.panel.title': 'Các chỉ báo kỹ thuật', + +'dashboard.indicator.panel.realtimeOn': 'Tắt cập nhật thời gian thực', + +'dashboard.indicator.panel.realtimeOff': 'Bật cập nhật thời gian thực', + +'dashboard.indicator.panel.themeLight': 'Chuyển sang chủ đề tối', + +'dashboard.indicator.panel.themeDark': 'Chuyển sang chủ đề sáng theme', + +'dashboard.indicator.section.enabled': 'Đã bật', + +'dashboard.indicator.section.added': 'Các chỉ báo tôi đã thêm', + +'dashboard.indicator.section.custom': 'Chỉ báo do tôi tự tạo', + +'dashboard.indicator.section.bought': 'Các chỉ báo tôi đã mua', + +'dashboard.indicator.section.myCreated': 'Các chỉ báo tôi đã tạo', + +'dashboard.indicator.empty': 'Chưa có chỉ báo nào, vui lòng thêm hoặc tạo chỉ báo trước', + +'dashboard.indicator.buy': 'Mua chỉ báo', + +'dashboard.indicator.action.start': 'Bắt ​​đầu', + +'dashboard.indicator.action.stop': 'Dừng', + +'dashboard.indicator.action.edit': 'Chỉnh sửa', + +'dashboard.indicator.action.delete': 'Xóa', + +'dashboard.indicator.action.backtest': 'Kiểm tra lại', + +'dashboard.indicator.status.normal': 'Bình thường', + +'dashboard.indicator.status.normalPermanent': 'Bình thường (Có hiệu lực vĩnh viễn)', + +'dashboard.indicator.status.expired': 'Đã hết hạn', + +'dashboard.indicator.expiry.permanent': 'Có hiệu lực vĩnh viễn', + +'dashboard.indicator.expiry.noExpiry': 'Không có ngày hết hạn', + +'dashboard.indicator.expiry.expired': 'Đã hết hạn: {date}', + +'dashboard.indicator.expiry.expiresOn': 'Ngày hết hạn: {date}', + +'dashboard.indicator.delete.confirmTitle': 'Xác nhận xóa', + +'dashboard.indicator.delete.confirmContent': 'Bạn có chắc chắn muốn xóa chỉ báo "{name}" không? Thao tác này không thể đảo ngược.', + +'dashboard.indicator.delete.confirmOk': 'Xóa', + +'dashboard.indicator.delete.confirmCancel': 'Hủy', + +'dashboard.indicator.delete.success': 'Xóa thành công', + +'dashboard.indicator.delete.failed': 'Xóa thất bại', + +'dashboard.indicator.save.success': 'Lưu thành công', + +'dashboard.indicator.save.failed': 'Lưu thất bại', + +'dashboard.indicator.error.loadWatchlistFailed': 'Không thể tải danh sách theo dõi', + +'dashboard.indicator.error.chartNotReady': 'Thành phần biểu đồ chưa được khởi tạo, vui lòng chọn mục tiêu và đợi biểu đồ tải xong , load', +'dashboard.indicator.error.chartMethodNotReady': 'Phương thức của thành phần biểu đồ chưa sẵn sàng, vui lòng thử lại sau', + +'dashboard.indicator.error.chartExecuteNotReady': 'Phương thức thực thi của thành phần biểu đồ chưa sẵn sàng, vui lòng thử lại sau', + +'dashboard.indicator.error.parseFailed': 'Không thể phân tích cú pháp mã Python', + +'dashboard.indicator.error.parseFailedCheck': 'Không thể phân tích cú pháp mã Python, vui lòng kiểm tra định dạng mã', + +'dashboard.indicator.error.addIndicatorFailed': 'Không thể thêm chỉ báo', + +'dashboard.indicator.error.runIndicatorFailed': 'Không thể chạy chỉ báo', + +'dashboard.indicator.error.pleaseLogin': 'Vui lòng đăng nhập trước', + +'dashboard.indicator.error.loadDataFailed': 'Tải dữ liệu thất bại', + +'dashboard.indicator.error.loadDataFailedDesc': 'Vui lòng kiểm tra kết nối mạng của bạn', + +'dashboard.indicator.error.pythonEngineFailed': 'Không thể tải công cụ Python; chức năng chỉ báo có thể không khả dụng', + +'dashboard.indicator.error.chartInitFailed': 'Khởi tạo biểu đồ thất bại', + +'dashboard.indicator.warning.enterCode': 'Vui lòng nhập mã chỉ báo trước', + +'dashboard.indicator.warning.pyodideLoadFailed': 'Không thể tải công cụ Python', + +'dashboard.indicator.warning.pyodideLoadFailedDesc': 'Tính năng này không khả dụng trong khu vực hoặc môi trường mạng hiện tại của bạn', + +'dashboard.indicator.warning.chartNotInitialized': 'Biểu đồ chưa được khởi tạo; Công cụ vẽ không khả dụng', + +'dashboard.indicator.success.runIndicator': 'Chỉ báo đã chạy thành công', + +'dashboard.indicator.success.clearDrawings': 'Đã xóa tất cả các đường', + +'dashboard.indicator.sma': 'SMA (Trung bình động ngắn hạn)', + +'dashboard.indicator.ema': 'EMA (Trung bình động hàm mũ)', + +'dashboard.indicator.rsi': 'RSI (Chỉ số sức mạnh tương đối)', + +'dashboard.indicator.macd': 'MACD', + +'dashboard.indicator.bb': 'Dải Bollinger (...Dải Bollinger)', + +'dashboard.indicator.atr': 'ATR (Chỉ số phạm vi trung bình thực)', + +'dashboard.indicator.cci': 'CCI (Chỉ số kênh hàng hóa)', + +'dashboard.indicator.williams': 'Williams %R (Chỉ số Williams %R)', + +'dashboard.indicator.mfi': 'MFI (Chỉ số dòng tiền)', + +'dashboard.indicator.adx': 'ADX (Chỉ số hướng trung bình)', + +'dashboard.indicator.obv': 'OBV (Chỉ số khối lượng cân bằng)', + +'dashboard.indicator.adosc': 'ADOSC (Chỉ báo dao động tích lũy/phân phối)', + +'dashboard.indicator.ad': 'AD (Chỉ báo đường tích lũy/phân phối)', + +'dashboard.indicator.kdj': 'KDJ (Chỉ báo ngẫu nhiên)', +'dashboard.indicator.signal.buy': 'MUA', + +'dashboard.indicator.signal.sell': 'BÁN', + +'dashboard.indicator.signal.supertrendBuy': 'Mua theo xu hướng siêu mạnh', + +'dashboard.indicator.signal.supertrendSell': 'Bán theo xu hướng siêu mạnh', + +'dashboard.indicator.chart.kline': 'Đường K', + +'dashboard.indicator.chart.volume': 'Khối lượng', + +'dashboard.indicator.chart.uptrend': 'Xu hướng tăng', + +'dashboard.indicator.chart.downtrend': 'Xu hướng giảm', + +'dashboard.indicator.tooltip.time': 'Thời gian', +'dashboard.indicator.tooltip.open': 'Mở', + +'dashboard.indicator.tooltip.close': 'Đóng', + +'dashboard.indicator.tooltip.high': 'Cao', + +'dashboard.indicator.tooltip.low': 'Thấp', + +'dashboard.indicator.tooltip.volume': 'Khối lượng', + +'dashboard.indicator.drawing.line': 'Đoạn thẳng', + +'dashboard.indicator.drawing.horizontalLine': 'Đường ngang', + +'dashboard.indicator.drawing.verticalLine': 'Đường thẳng đứng', + +'dashboard.indicator.drawing.ray': 'Tia', + +'dashboard.indicator.drawing.straightLine': 'Đường thẳng', + +'dashboard.indicator.drawing.parallelLine': 'Đường song song', + +'dashboard.indicator.drawing.priceLine': 'Giá', +'dashboard.indicator.drawing.priceChannel': 'kênh giá', + +'dashboard.indicator.drawing.fibonacciLine': 'đường Fibonacci', + +'dashboard.indicator.drawing.clearAll': 'xóa tất cả các đường đã vẽ', + +'dashboard.indicator.market.AShare': 'cổ phiếu A', + +'dashboard.indicator.market.USStock': 'cổ phiếu Mỹ', + +'dashboard.indicator.market.HShare': 'cổ phiếu Hồng Kông', + +'dashboard.indicator.market.Crypto': 'tiền điện tử', + +'dashboard.indicator.market.Forex': 'ngoại hối', + +'dashboard.indicator.market.Futures': 'hợp đồng tương lai', + +'dashboard.indicator.create': 'Tạo Indicator', + +'dashboard.indicator.editor.title': 'Tạo/Chỉnh sửa chỉ báo', + +'dashboard.indicator.editor.name': 'Tên chỉ báo', + +'dashboard.indicator.editor.nameRequired': 'Vui lòng nhập tên chỉ báo', + +'dashboard.indicator.editor.namePlaceholder': 'Vui lòng nhập tên chỉ báo', + +'dashboard.indicator.editor.description': 'Mô tả chỉ báo', + +'dashboard.indicator.editor.descriptionPlaceholder': 'Vui lòng nhập mô tả chỉ báo (tùy chọn)', + +'dashboard.indicator.editor.code': 'Mã Python', + +'dashboard.indicator.editor.codeRequired': 'Vui lòng nhập mã chỉ báo', + +'dashboard.indicator.editor.codePlaceholder': 'Vui lòng nhập mã Python code', + +'dashboard.indicator.editor.run': 'Chạy', + +'dashboard.indicator.editor.runHint': 'Nhấp vào nút chạy để xem trước hiệu ứng của chỉ báo trên biểu đồ nến', + +'dashboard.indicator.editor.guide': 'Hướng dẫn phát triển', + +'dashboard.indicator.editor.guideTitle': 'Hướng dẫn phát triển chỉ báo Python', + +'dashboard.indicator.editor.save': 'Lưu', + +'dashboard.indicator.editor.cancel': 'Hủy', + +'dashboard.indicator.editor.unnamed': 'Chỉ báo chưa được đặt tên', + +'dashboard.indicator.editor.publishToCommunity': 'Chia sẻ lên cộng đồng', + +'dashboard.indicator.editor.publishToCommunityHint': 'Sau khi chia sẻ, người dùng khác có thể xem và sử dụng các chỉ số của bạn, trong cộng đồng', + +'dashboard.indicator.editor.indicatorType': 'Loại chỉ báo', + +'dashboard.indicator.editor.indicatorTypeRequired': 'Vui lòng chọn loại chỉ báo', + +'dashboard.indicator.editor.indicatorTypePlaceholder': 'Vui lòng chọn loại chỉ báo', + +'dashboard.indicator.editor.previewImage': 'Hình ảnh xem trước', + +'dashboard.indicator.editor.uploadImage': 'Tải lên hình ảnh', + +'dashboard.indicator.editor.previewImageHint': 'Kích thước đề xuất: 800x400, kích thước không vượt quá 2MB', + +'dashboard.indicator.editor.pricing': 'Giá (QDT)', + +'dashboard.indicator.editor.pricingType.free': 'Miễn phí', + +'dashboard.indicator.editor.pricingType.permanent': 'Vĩnh viễn', + +'dashboard.indicator.editor.pricingType.monthly': 'Hàng tháng', + +'dashboard.indicator.editor.price': 'Giá', + +'dashboard.indicator.editor.priceRequired': 'Vui lòng nhập giá', + +'dashboard.indicator.editor.pricePlaceholder': 'Vui lòng nhập giá', + +'dashboard.indicator.editor.pricingHint': 'Mặc dù chỉ báo miễn phí có giá 0, nền tảng sẽ thưởng cho bạn dựa trên việc sử dụng chỉ báo và tỷ lệ đánh giá tích cực (QDT)', + +'dashboard.indicator.editor.aiGenerate': 'Tạo thông minh', + +'dashboard.indicator.editor.aiPromptPlaceholder': 'Hãy cho tôi biết ý tưởng của bạn, và Tôi sẽ tạo mã chỉ báo Python cho bạn', + +'dashboard.indicator.editor.aiGenerateBtn': 'Mã được tạo bởi AI', + +'dashboard.indicator.editor.aiPromptRequired': 'Vui lòng nhập ý tưởng của bạn', + +'dashboard.indicator.editor.aiGenerateSuccess': 'Tạo mã thành công', + +'dashboard.indicator.editor.aiGenerateError': 'Tạo mã thất bại, vui lòng thử lại sau', + +'dashboard.indicator.backtest.title': 'Kiểm thử ngược chỉ báo', + +'dashboard.indicator.backtest.config': 'Tham số kiểm thử ngược', + +'dashboard.indicator.backtest.startDate': 'Ngày bắt đầu', + +'dashboard.indicator.backtest.endDate': 'Ngày kết thúc Ngày', + +'dashboard.indicator.backtest.selectStartDate': 'Chọn ngày bắt đầu', + +'dashboard.indicator.backtest.selectEndDate': 'Chọn ngày kết thúc', + +'dashboard.indicator.backtest.startDateRequired': 'Vui lòng chọn ngày bắt đầu', + +'dashboard.indicator.backtest.endDateRequired': 'Vui lòng chọn ngày kết thúc', + +'dashboard.indicator.backtest.initialCapital': 'Vốn ban đầu', + +'dashboard.indicator.backtest.initialCapitalRequired': 'Vui lòng nhập vốn ban đầu', + +'dashboard.indicator.backtest.commission': 'Tỷ lệ hoa hồng', + +'dashboard.indicator.backtest.leverage': 'Tỷ lệ đòn bẩy', + +'dashboard.indicator.backtest.tradeDirection': 'Hướng giao dịch Hướng', + +'dashboard.indicator.backtest.longOnly': 'Mua', + +'dashboard.indicator.backtest.shortOnly': 'Bán', + +'dashboard.indicator.backtest.both': 'Cả hai chiều', +'dashboard.indicator.backtest.run': 'Bắt ​​đầu kiểm thử ngược', + +'dashboard.indicator.backtest.rerun': 'Chạy lại kiểm thử ngược', + +'dashboard.indicator.backtest.close': 'Đóng', + +'dashboard.indicator.backtest.running': 'Đang tiến hành kiểm thử ngược...', + +'dashboard.indicator.backtest.results': 'Kết quả kiểm thử ngược', + +'dashboard.indicator.backtest.totalReturn': 'Tổng lợi nhuận', + +'dashboard.indicator.backtest.annualReturn': 'Lợi nhuận hàng năm', + +'dashboard.indicator.backtest.maxDrawdown': 'Mức giảm tối đa', + +'dashboard.indicator.backtest.sharpeRatio': 'Tỷ lệ Sharpe ratio', + +'dashboard.indicator.backtest.winRate': 'Tỷ lệ thắng', + +'dashboard.indicator.backtest.profitFactor': 'Tỷ lệ lãi/lỗ', + +'dashboard.indicator.backtest.totalTrades': 'Số lượng giao dịch', + +'dashboard.indicator.totalReturn': 'Tổng lợi nhuận', + +'dashboard.indicator.annualReturn': 'Lợi nhuận hàng năm', + +'dashboard.indicator.maxDrawdown': 'Mức giảm tối đa', + +'dashboard.indicator.sharpeRatio': 'Tỷ lệ Sharpe', + +'dashboard.indicator.winRate': 'Tỷ lệ thắng', + +'dashboard.indicator.profitFactor': 'Tỷ lệ lãi/lỗ Tỷ lệ', + +'dashboard.indicator.totalTrades': 'Tổng số giao dịch', + +'dashboard.indicator.backtest.totalCommission': 'Tổng phí hoa hồng', + +'dashboard.indicator.backtest.equityCurve': 'Đường cong vốn chủ sở hữu', + +'dashboard.indicator.backtest.strategy': 'Lợi nhuận của chỉ báo', + +'dashboard.indicator.backtest.benchmark': 'Lợi nhuận chuẩn', + +'dashboard.indicator.backtest.tradeHistory': 'Lịch sử giao dịch', + +'dashboard.indicator.backtest.tradeTime': 'Thời gian', + +'dashboard.indicator.backtest.tradeType': 'Loại giao dịch', + +'dashboard.indicator.backtest.buy': 'Mua', + +'dashboard.indicator.backtest.sell': 'Bán', + +'dashboard.indicator.backtest.liquidation': 'Tài khoản đã bị thanh lý', + +'dashboard.indicator.backtest.openLong': 'Mở lệnh mua', + +'dashboard.indicator.backtest.closeLong': 'Đóng lệnh mua', + +'dashboard.indicator.backtest.closeLongStop': 'Đóng lệnh mua (Cắt lỗ)', + +'dashboard.indicator.backtest.closeLongProfit': 'Đóng lệnh mua (Chốt lời)', + +'dashboard.indicator.backtest.addLong': 'Thêm lệnh mua', + +'dashboard.indicator.backtest.openShort': 'Mở lệnh bán', + +'dashboard.indicator.backtest.closeShort': 'Đóng lệnh bán', + +'dashboard.indicator.backtest.closeShortStop': 'Đóng lệnh bán (Dừng lỗ) Lỗ', + +'dashboard.indicator.backtest.closeShortProfit': 'Đóng vị thế bán khống (chốt lời)', + +'dashboard.indicator.backtest.addShort': 'Thêm vị thế bán khống', + +'dashboard.indicator.backtest.price': 'Giá', + +'dashboard.indicator.backtest.amount': 'Số lượng', + +'dashboard.indicator.backtest.balance': 'Số dư tài khoản', + +'dashboard.indicator.backtest.profit': 'Lãi/Lỗ', + +'dashboard.indicator.backtest.success': 'Kiểm thử ngược thành công', + +'dashboard.indicator.backtest.failed': 'Kiểm thử ngược thất bại, vui lòng thử lại sau', + +'dashboard.indicator.backtest.noIndicatorCode': 'Không có mã nào để kiểm thử ngược này', +'dashboard.indicator.backtest.noSymbol': 'Vui lòng chọn công cụ giao dịch trước', + +'dashboard.indicator.backtest.dateRangeExceeded': 'Thời gian kiểm thử ngược vượt quá giới hạn: chỉ có thể kiểm thử ngược trong khoảng thời gian {timeframe} là {maxRange}', +'dashboard.indicator.backtest.dateRangeExceededDays': 'Backtest range exceeds limit: {timeframe} max {maxRange} ({maxDays} days)', +'dashboard.indicator.backtest.hint.entryPctMax': 'Max entry: {maxPct}% (reserve budget for future scale-ins)', +'dashboard.indicator.backtest.metaLine': 'Symbol: {symbol} | Market: {market} | Timeframe: {timeframe}', +'dashboard.indicator.backtest.savedRunId': 'Backtest saved. Run ID: {id}', +'dashboard.indicator.backtest.prev': 'Previous', +'dashboard.indicator.backtest.next': 'Next', +'dashboard.indicator.backtest.steps.strategy.title': 'Strategy', +'dashboard.indicator.backtest.steps.strategy.desc': 'Position sizing & risk controls', +'dashboard.indicator.backtest.steps.trading.title': 'Trading settings', +'dashboard.indicator.backtest.steps.trading.desc': 'Time range, capital, fees, leverage', +'dashboard.indicator.backtest.steps.results.title': 'Results', +'dashboard.indicator.backtest.steps.results.desc': 'Backtest output', +'dashboard.indicator.backtest.panel.risk': 'Risk management (SL/TP/Trailing)', +'dashboard.indicator.backtest.panel.scale': 'Scale in / DCA (trend & mean-reversion)', +'dashboard.indicator.backtest.panel.reduce': 'Reduce position (trend & adverse)', +'dashboard.indicator.backtest.panel.position': 'Position sizing', +'dashboard.indicator.backtest.field.stopLossPct': 'Stop Loss (%)', +'dashboard.indicator.backtest.field.takeProfitPct': 'Take Profit (%)', +'dashboard.indicator.backtest.field.trailingEnabled': 'Trailing stop / take profit', +'dashboard.indicator.backtest.field.trailingStopPct': 'Trailing drawdown (%)', +'dashboard.indicator.backtest.field.trailingActivationPct': 'Trailing activation (%)', +'dashboard.indicator.backtest.field.slippage': 'Slippage (%)', +'dashboard.indicator.backtest.field.trendAddEnabled': 'Trend-following scale-in', +'dashboard.indicator.backtest.field.dcaAddEnabled': 'Mean-reversion DCA', +'dashboard.indicator.backtest.field.trendAddStepPct': 'Scale-in trigger (%)', +'dashboard.indicator.backtest.field.dcaAddStepPct': 'DCA trigger (%)', +'dashboard.indicator.backtest.field.trendAddSizePct': 'Scale-in size (% of capital)', +'dashboard.indicator.backtest.field.dcaAddSizePct': 'DCA size (% of capital)', +'dashboard.indicator.backtest.field.trendAddMaxTimes': 'Max scale-in times', +'dashboard.indicator.backtest.field.dcaAddMaxTimes': 'Max DCA times', +'dashboard.indicator.backtest.field.trendReduceEnabled': 'Trend reduce', +'dashboard.indicator.backtest.field.adverseReduceEnabled': 'Adverse reduce', +'dashboard.indicator.backtest.field.trendReduceStepPct': 'Trend trigger (%)', +'dashboard.indicator.backtest.field.adverseReduceStepPct': 'Adverse trigger (%)', +'dashboard.indicator.backtest.field.trendReduceSizePct': 'Reduce size (% of position)', +'dashboard.indicator.backtest.field.adverseReduceSizePct': 'Adverse reduce size (% of position)', +'dashboard.indicator.backtest.field.trendReduceMaxTimes': 'Max trend reduce times', +'dashboard.indicator.backtest.field.adverseReduceMaxTimes': 'Max adverse reduce times', +'dashboard.indicator.backtest.field.entryPct': 'Entry size (% of capital)', +'dashboard.indicator.backtest.field.minOrderPct': 'Min order size (% of capital) (optional)', +'dashboard.indicator.backtest.closeLongTrailing': 'Close Long (Trailing)', +'dashboard.indicator.backtest.reduceLong': 'Reduce Long', +'dashboard.indicator.backtest.closeShortTrailing': 'Close Short (Trailing)', +'dashboard.indicator.backtest.reduceShort': 'Reduce Short', + +'dashboard.docs.title': 'Trung tâm Tài liệu', + +'dashboard.docs.search.placeholder': 'Tìm kiếm Tài liệu...', + +'dashboard.docs.category.all': 'Tất cả', + +'dashboard.docs.category.guide': 'Hướng dẫn dành cho nhà phát triển', + +'dashboard.docs.category.api': 'Tài liệu API', + +'dashboard.docs.category.tutorial': 'Hướng dẫn', + +'dashboard.docs.category.faq': 'Câu hỏi thường gặp Câu hỏi', + +'dashboard.docs.featured.title': 'Tài liệu được đề xuất', + +'dashboard.docs.featured.tag': 'Được đề xuất', + +'dashboard.docs.list.views': 'Lượt xem', + +'dashboard.docs.list.author': 'Tác giả', + +'dashboard.docs.list.empty': 'Không có tài liệu nào', + +'dashboard.docs.list.backToAll': 'Trả về tất cả tài liệu', + +'dashboard.docs.list.total': 'Tổng số {count} tài liệu', + +'dashboard.docs.detail.back': 'Trả về danh sách tài liệu', + +'dashboard.docs.detail.updatedAt': 'Cập nhật lúc', + +'dashboard.docs.detail.related': 'Tài liệu liên quan', + +'dashboard.docs.detail.notFound': 'Không tìm thấy tài liệu tồn tại', + +'dashboard.docs.detail.error': 'Tài liệu không tồn tại hoặc đã bị xóa', + +'dashboard.docs.search.result': 'Kết quả tìm kiếm', + +'dashboard.docs.search.keyword': 'Từ khóa', + +'community.filter.indicatorType': 'Loại chỉ báo', + +'community.filter.all': 'Tất cả', + +'community.filter.other': 'Các tùy chọn khác', + +'community.filter.pricing': 'Loại giá', + +'community.filter.allPricing': 'Tất cả giá', + +'community.filter.sortBy': 'Phương thức sắp xếp', + +'community.filter.search': 'Tìm kiếm', + +'community.filter.searchPlaceholder': 'Tên chỉ báo tìm kiếm', + +'community.indicatorType.trend': 'Định hướng theo xu hướng', + +'community.indicatorType.momentum': 'Động lượng', + +'community.indicatorType.volatility': 'Biến động', + +'community.indicatorType.volume': 'Khối lượng', + +'community.indicatorType.custom': 'Tùy chỉnh', + +'community.pricing.free': 'Miễn phí', + +'community.pricing.paid': 'Trả phí', + +'community.sort.downloads': 'Lượt tải xuống', + +'community.sort.rating': 'Xếp hạng', + +'community.sort.newest': 'Phiên bản mới nhất', + +'community.pagination.total': 'Tổng số {total} chỉ báo', + +'community.noDescription': 'Không có mô tả', + +'community.detail.type': 'Loại', + +'community.detail.pricing': 'Giá cả', + +'community.detail.rating': 'Xếp hạng', + +'community.detail.downloads': 'Lượt tải xuống', + +'community.detail.author': 'Tác giả', + +'community.detail.description': 'Mô tả', + +'community.detail.detailContent': 'Mô tả chi tiết', + +'community.detail.backtestStats': 'Thống kê kiểm thử ngược', + +'community.action.purchase': 'Số liệu mua hàng', + +'community.action.addToMyIndicators': 'Thêm vào chỉ báo của tôi', + +'community.action.favorite': 'Yêu thích', + +'community.action.unfavorite': 'Bỏ yêu thích', + +'community.action.buyNow': 'Mua ngay Ngay bây giờ', + +'community.action.renew': 'Gia hạn', + +'community.purchase.price': 'Giá', + +'community.purchase.permanent': 'Vĩnh viễn', + +'community.purchase.monthly': 'Hàng tháng', + +'community.purchase.confirmBuy': 'Xác nhận mua chỉ số này ({price} QDT)?', + +'community.purchase.confirmRenew': 'Xác nhận gia hạn chỉ số này ({price} QDT/tháng)?', + +'community.purchase.confirmFree': 'Xác nhận thêm chỉ số miễn phí này?', +'community.purchase.confirmTitle': 'Xác nhận thao tác', + +'community.purchase.owned': 'Bạn đã mua chỉ số này (có hiệu lực vĩnh viễn)', + +'community.purchase.ownIndicator': 'Đây là chỉ số bạn đã công bố', + +'community.purchase.expired': 'Gói đăng ký của bạn đã hết hạn', + +'community.purchase.expiresOn': 'Ngày hết hạn: {date}', + +'community.tabs.detail': 'Chi tiết chỉ số', + +'community.tabs.backtest': 'Dữ liệu kiểm thử ngược', + +'community.tabs.ratings': 'Xếp hạng người dùng', + +'community.rating.myRating': 'Xếp hạng của tôi', + +'community.rating.stars': '{count} Stars', + +'community.rating.commentPlaceholder': 'Chia sẻ trải nghiệm người dùng của bạn...', + +'community.rating.submit': 'Gửi đánh giá', + +'community.rating.modify': 'Sửa đổi', + +'community.rating.saveModify': 'Lưu thay đổi', + +'community.rating.cancel': 'Hủy', + +'community.rating.selectRating': 'Vui lòng chọn đánh giá', + +'community.rating.success': 'Đánh giá thành công', + +'community.rating.modifySuccess': 'Đánh giá được sửa đổi thành công', + +'community.rating.failed': 'Đánh giá thất bại', + +'community.rating.noRatings': 'Chưa có đánh giá nào', + +'community.backtest.note': 'Dữ liệu trên là kết quả kiểm thử ngược trong quá khứ, chỉ để tham khảo và không đại diện cho hiệu suất trong tương lai', +'community.backtest.noData': 'Không có dữ liệu kiểm thử ngược', + +'community.backtest.uploadHint': 'Tác giả chưa tải lên dữ liệu kiểm thử ngược', + +'community.message.loadFailed': 'Không thể tải danh sách chỉ báo', + +'community.message.purchaseProcessing': 'Đang xử lý yêu cầu mua hàng...', + +'community.message.downloadSuccess': 'Chỉ báo đã được thêm vào danh sách chỉ báo của tôi', + +'community.message.favoriteSuccess': 'Đã thêm vào mục yêu thích thành công', + +'community.message.unfavoriteSuccess': 'Đã xóa khỏi mục yêu thích thành công', + +'community.message.operationSuccess': 'Thao tác thành công', + +'community.message.operationFailed': 'Thao tác thất bại thất bại', + +'dashboard.totalEquity': 'Tổng vốn chủ sở hữu', + +'dashboard.totalPnL': 'Tổng lợi nhuận/thua lỗ', + +'dashboard.aiStrategies': 'Chiến lược AI', + +'dashboard.indicatorStrategies': 'Chiến lược chỉ báo', + +'dashboard.running': 'Đang chạy', + +'dashboard.pnlHistory': 'Lịch sử lợi nhuận/thua lỗ', + +'dashboard.strategyPerformance': 'Tỷ lệ lợi nhuận/thua lỗ của chiến lược', + +'dashboard.recentTrades': 'Các giao dịch gần đây', + +'dashboard.currentPositions': 'Vị thế hiện tại', + +'dashboard.table.time': 'Thời gian', + +'dashboard.table.strategy': 'Chiến lược Name', + +'dashboard.table.symbol': 'Tài sản cơ sở', + +'dashboard.table.type': 'Loại', + +'dashboard.table.side': 'Hướng', + +'dashboard.table.size': 'Số lượng', + +'dashboard.table.entryPrice': 'Giá mở cửa trung bình', + +'dashboard.table.price': 'Giá', + +'dashboard.table.amount': 'Số lượng', + +'dashboard.table.profit': 'Lợi nhuận/Thua lỗ', + +'dashboard.pendingOrders': 'Lịch sử thực hiện lệnh', + +'dashboard.totalOrders': 'Tổng {total} lệnh', + +'dashboard.viewError': 'Xem lỗi', + +'dashboard.filled': 'Đã khớp', + +'dashboard.orderTable.time': 'Thời gian tạo', + +'dashboard.orderTable.strategy': 'Chiến lược', + +'dashboard.orderTable.symbol': 'Cặp giao dịch', + +'dashboard.orderTable.signalType': 'Loại tín hiệu', + +'dashboard.orderTable.amount': 'Số lượng', + +'dashboard.orderTable.price': 'Giá khớp', + +'dashboard.orderTable.status': 'Trạng thái', + +'dashboard.orderTable.executedAt': 'Thời gian thực hiện', + +'dashboard.signalType.openLong': 'Mở Long', + +'dashboard.signalType.openShort': 'Mở Short', + +'dashboard.signalType.closeLong': 'Đóng Long', + +'dashboard.signalType.closeShort': 'Đóng Short', + +'dashboard.signalType.addLong': 'Thêm Long', + +'dashboard.signalType.addShort': 'Thêm Short', + +'dashboard.status.pending': 'Đang chờ', + +'dashboard.status.processing': 'Đang xử lý', + +'dashboard.status.completed': 'Hoàn thành', + +'dashboard.status.failed': 'Thất bại', + +'dashboard.status.cancelled': 'Đã hủy', + +'dashboard.table.pnl': 'Lợi nhuận/Thua lỗ chưa thực hiện', + +'form.basic-form.basic.title': 'Biểu mẫu cơ bản', + +'form.basic-form.basic.description': 'Các trang biểu mẫu được sử dụng để thu thập hoặc xác minh thông tin từ người dùng. Biểu mẫu cơ bản thường phổ biến trong các trường hợp có ít mục dữ liệu.', + +'form.basic-form.title.label': 'Tiêu đề', + +'form.basic-form.title.placeholder': 'Đặt tên cho mục tiêu', + +'form.basic-form.title.required': 'Vui lòng nhập tiêu đề', + +'form.basic-form.date.label': 'Ngày bắt đầu và ngày kết thúc', + +'form.basic-form.placeholder.start': 'Ngày bắt đầu', + +'form.basic-form.placeholder.end': 'Ngày kết thúc', + +'form.basic-form.date.required': 'Vui lòng chọn ngày bắt đầu và ngày kết thúc', + +'form.basic-form.goal.label': 'Mô tả mục tiêu', + +'form.basic-form.goal.placeholder': 'Vui lòng nhập mục tiêu công việc theo từng giai đoạn', + +'form.basic-form.goal.required': 'Vui lòng nhập mục tiêu Mô tả', +'form.basic-form.standard.label': 'Số liệu', + +'form.basic-form.standard.placeholder': 'Vui lòng nhập số liệu', + +'form.basic-form.standard.required': 'Vui lòng nhập số liệu', + +'form.basic-form.client.label': 'Khách hàng', + +'form.basic-form.client.required': 'Vui lòng mô tả khách hàng bạn đang phục vụ', + +'form.basic-form.label.tooltip': 'Đối tượng mục tiêu', + +'form.basic-form.client.placeholder': 'Vui lòng mô tả khách hàng bạn đang phục vụ; khách hàng nội bộ có thể trực tiếp là @tên/ID nhân viên', + +'form.basic-form.invites.label': 'Người mời', + +'form.basic-form.invites.placeholder': 'Vui lòng trực tiếp là @tên/ID nhân viên; Có thể mời tối đa 5 người', +'form.basic-form.weight.label': 'Trọng lượng', + +'form.basic-form.weight.placeholder': 'Vui lòng nhập', + +'form.basic-form.public.label': 'Mục tiêu công khai', + +'form.basic-form.label.help': 'Khách hàng và người đánh giá được chia sẻ theo mặc định', + +'form.basic-form.radio.public': 'Công khai', + +'form.basic-form.radio.partially-public': 'Công khai một phần', + +'form.basic-form.radio.private': 'Không công khai', + +'form.basic-form.publicUsers.placeholder': 'Công khai cho', + +'form.basic-form.option.A': 'Đồng nghiệp 1', + +'form.basic-form.option.B': 'Đồng nghiệp 2', + +'form.basic-form.option.C': 'Đồng nghiệp số Ba', + +'form.basic-form.email.required': 'Vui lòng nhập địa chỉ email của bạn!', + +'form.basic-form.email.wrong-format': 'Định dạng địa chỉ email không chính xác!', + +'form.basic-form.userName.required': 'Vui lòng nhập tên người dùng của bạn!', + +'form.basic-form.password.required': 'Vui lòng nhập mật khẩu của bạn!', + +'form.basic-form.password.twice': 'Hai mật khẩu không khớp!', + +'form.basic-form.strength.msg': 'Vui lòng nhập ít nhất 6 ký tự. Vui lòng không sử dụng mật khẩu dễ đoán.', + +'form.basic-form.strength.strong': 'Độ mạnh: Mạnh', + +'form.basic-form.strength.medium': 'Độ mạnh: Trung bình', + +'form.basic-form.strength.short': 'Độ mạnh: Quá ngắn', + +'form.basic-form.confirm-password.required': 'Vui lòng xác nhận mật khẩu!', + +'form.basic-form.phone-number.required': 'Vui lòng nhập số điện thoại của bạn!', + +'form.basic-form.phone-number.wrong-format': 'Định dạng số điện thoại không chính xác!', + +'form.basic-form.verification-code.required': 'Vui lòng nhập mã xác minh!', + +'form.basic-form.form.get-captcha': 'Lấy mã xác minh mã', + +'form.basic-form.captcha.second': 'giây', + +'form.basic-form.form.optional': '(Tùy chọn)', + +'form.basic-form.form.submit': 'Gửi', + +'form.basic-form.form.save': 'Lưu', + +'form.basic-form.email.placeholder': 'Email', + +'form.basic-form.password.placeholder': 'Mật khẩu phải có ít nhất 6 ký tự, phân biệt chữ hoa chữ thường', + +'form.basic-form.confirm-password.placeholder': 'Xác nhận mật khẩu', + +'form.basic-form.phone-number.placeholder': 'Số điện thoại', + +'form.basic-form.verification-code.placeholder': 'Mã xác minh', + +'result.success.title': 'Đã gửi Thành công', + +'result.success.description': 'Trang kết quả nộp bài được sử dụng để cung cấp phản hồi về kết quả xử lý của một loạt các tác vụ vận hành. Nếu chỉ là một thao tác đơn giản, thông báo Tin nhắn toàn cục là đủ. Vùng văn bản này có thể hiển thị các giải thích bổ sung đơn giản. Nếu cần hiển thị nội dung như một "tài liệu", vùng màu xám bên dưới có thể hiển thị nội dung phức tạp hơn.', + +'result.success.operate-title': 'Tên dự án', + +'result.success.operate-id': 'ID dự án', + +'result.success.principal': 'Người phụ trách', + +'result.success.operate-time': 'Thời gian hiệu quả', + +'result.success.step1-title': 'Tạo dự án', + +'result.success.step1-operator': 'Qu Lili', + +'result.success.step2-title': 'Đánh giá ban đầu của phòng ban', + +'result.success.step2-operator': 'Zhou Maomao', + +'result.success.step2-extra': 'Theo dõi', + +'result.success.step3-title': 'Đánh giá tài chính', + +'result.success.step4-title': 'Hoàn thành', + +'result.success.btn-return': 'Quay lại danh sách', + +'result.success.btn-project': 'Xem dự án', + +'result.success.btn-print': 'In', + +'result.fail.error.title': 'Gửi bài không thành công', + +'result.fail.error.description': 'Vui lòng kiểm tra và chỉnh sửa thông tin sau trước khi gửi lại.', + +'result.fail.error.hint-title': 'Nội dung bạn đã gửi có các lỗi sau:', + +'result.fail.error.hint-text1': 'Tài khoản của bạn đã bị đóng băng', + +'result.fail.error.hint-btn1': 'Mở khóa ngay lập tức', + +'result.fail.error.hint-text2': 'Tài khoản của bạn chưa đủ điều kiện để đăng ký', + +'result.fail.error.hint-btn2': 'Nâng cấp ngay lập tức', + +'result.fail.error.btn-text': 'Quay lại chỉnh sửa', + +'account.settings.menuMap.custom': 'Cá nhân hóa', + +'account.settings.menuMap.binding': 'Liên kết tài khoản', + +'account.settings.basic.avatar': 'Avatar', + +'account.settings.basic.change-avatar': 'Thay đổi ảnh đại diện', + +'account.settings.basic.email': 'Địa chỉ email', + +'account.settings.basic.email-message': 'Vui lòng nhập địa chỉ email của bạn!', + +'account.settings.basic.nickname': 'Biệt danh', + +'account.settings.basic.nickname-message': 'Vui lòng nhập biệt danh của bạn!', + +'account.settings.basic.profile': 'Hồ sơ', + +'account.settings.basic.profile-message': 'Vui lòng nhập thông tin hồ sơ của bạn!', + +'account.settings.basic.profile-placeholder': 'Hồ sơ', + +'account.settings.basic.country': 'Quốc gia/Vùng', + +'account.settings.basic.country-message': 'Vui lòng nhập quốc gia của bạn hoặc khu vực!', + +'account.settings.basic.geographic': 'Tỉnh và Thành phố', + +'account.settings.basic.geographic-message': 'Vui lòng nhập tỉnh và thành phố của bạn!', + +'account.settings.basic.address': 'Địa chỉ đường phố', + +'account.settings.basic.address-message': 'Vui lòng nhập địa chỉ đường phố của bạn!', + +'account.settings.basic.phone': 'Số điện thoại', + +'account.settings.basic.phone-message': 'Vui lòng nhập số điện thoại của bạn!', + +'account.settings.basic.update': 'Cập nhật thông tin cơ bản', + +'account.settings.basic.update.success': 'Đã cập nhật thông tin cơ bản thành công', + +'account.settings.security.strong': 'Mạnh', + +'account.settings.security.medium': 'Trung bình', + +'account.settings.security.weak': 'Yếu', + +'account.settings.security.password': 'Mật khẩu tài khoản', + +'account.settings.security.password-description': 'Độ mạnh mật khẩu hiện tại:', + +'account.settings.security.phone': 'Số điện thoại bảo mật', + +'account.settings.security.phone-description': 'Số điện thoại liên kết:', + +'account.settings.security.question': 'Câu hỏi bảo mật', + +'account.settings.security.question-description': 'Chưa thiết lập câu hỏi bảo mật nào. Câu hỏi bảo mật có thể bảo vệ hiệu quả tính bảo mật của tài khoản', + +'account.settings.security.email': 'Email liên kết', + +'account.settings.security.email-description': 'Số email liên kết:', + +'account.settings.security.mfa': 'Thiết bị MFA', + +'account.settings.security.mfa-description': 'Thiết bị MFA chưa liên kết. Việc liên kết sẽ cho phép xác nhận thứ cấp', + +'account.settings.security.modify': 'Sửa đổi', + +'account.settings.security.set': 'Cài đặt', + +'account.settings.security.bind': 'Liên kết', + +'account.settings.binding.taobao': 'Liên kết Taobao', + +'account.settings.binding.taobao-description': 'Hiện chưa được liên kết với tài khoản Taobao', + +'account.settings.binding.alipay': 'Liên kết Alipay', + +'account.settings.binding.alipay-description': 'Hiện chưa được liên kết với tài khoản Alipay', + +'account.settings.binding.dingding': 'Liên kết DingTalk', + +'account.settings.binding.dingding-description': 'Hiện chưa được liên kết với tài khoản DingTalk', + +'account.settings.binding.bind': 'Liên kết', + +'account.settings.notification.password': 'Mật khẩu tài khoản', + +'account.settings.notification.password-description': 'Tin nhắn từ người dùng khác sẽ được gửi qua thông báo trong ứng dụng', + +'account.settings.notification.messages': 'Tin nhắn hệ thống', + +'account.settings.notification.messages-description': 'Tin nhắn hệ thống sẽ được gửi qua thông báo trong ứng dụng', + +'account.settings.notification.todo': 'Việc cần làm', + +'account.settings.notification.todo-description': 'Việc cần làm sẽ được gửi qua thông báo trong ứng dụng', + +'account.settings.settings.open': 'Bật', + +'account.settings.settings.close': 'Tắt', + +'trading-assistant.title': 'Trợ lý giao dịch', + +'trading-assistant.strategyList': 'Chiến lược Danh sách', + +'trading-assistant.createStrategy': 'Tạo chiến lược', + +'trading-assistant.noStrategy': 'Không có chiến lược nào', + +'trading-assistant.selectStrategy': 'Chọn một chiến lược từ bên trái để xem chi tiết', + +'trading-assistant.startStrategy': 'Bắt ​​đầu chiến lược', + +'trading-assistant.stopStrategy': 'Dừng chiến lược', + +'trading-assistant.editStrategy': 'Chỉnh sửa chiến lược', + +'trading-assistant.deleteStrategy': 'Xóa chiến lược', + +'trading-assistant.status.running': 'Đang chạy', + +'trading-assistant.status.stopped': 'Đã dừng', + +'trading-assistant.status.error': 'Lỗi', + +'trading-assistant.strategyType.IndicatorStrategy': 'Chỉ báo kỹ thuật Chiến lược', + +'trading-assistant.strategyType.PromptBasedStrategy': 'Chiến lược dựa trên lời nhắc', + +'trading-assistant.strategyType.GridStrategy': 'Chiến lược lưới', + +'trading-assistant.tabs.tradingRecords': 'Hồ sơ giao dịch', + +'trading-assistant.tabs.positions': 'Vị thế', + +'trading-assistant.tabs.equityCurve': 'Đường cong vốn chủ sở hữu', + +'trading-assistant.form.step1': 'Chọn chỉ báo', + +'trading-assistant.form.step2': 'Cấu hình sàn giao dịch', + +'trading-assistant.form.step3': 'Tham số chiến lược', + +'trading-assistant.form.indicator': 'Chọn chỉ báo', + +'trading-assistant.form.indicatorHint': 'Chỉ chọn các chỉ báo kỹ thuật bạn muốn đã mua hoặc tạo', + +'trading-assistant.form.qdtCostHints': 'Việc sử dụng chiến lược này sẽ tiêu tốn QDT; vui lòng đảm bảo tài khoản của bạn có đủ số dư QDT', + +'trading-assistant.form.indicatorDescription': 'Mô tả chỉ báo', + +'trading-assistant.form.noDescription': 'Không có mô tả', + +'trading-assistant.form.exchange': 'Chọn sàn giao dịch', + +'trading-assistant.form.apiKey': 'Khóa API', + +'trading-assistant.form.secretKey': 'Khóa bí mật', + +'trading-assistant.form.passphrase': 'Mật khẩu', + +'trading-assistant.form.testConnection': 'Kiểm tra kết nối', + +'trading-assistant.form.strategyName': 'Tên chiến lược', + +'trading-assistant.form.symbol': 'Cặp giao dịch', + +'trading-assistant.form.symbolHint': 'Hiện tại chỉ hỗ trợ các cặp giao dịch tiền điện tử', + +'trading-assistant.form.initialCapital': 'Vốn ban đầu', + +'trading-assistant.form.marketType': 'Loại thị trường', + +'trading-assistant.form.marketTypeFutures': 'Hợp đồng tương lai', + +'trading-assistant.form.marketTypeSpot': 'Giao dịch giao ngay', + +'trading-assistant.form.marketTypeHint': 'Hợp đồng hỗ trợ giao dịch hai chiều và đòn bẩy; Giao dịch giao ngay chỉ hỗ trợ vị thế mua với đòn bẩy cố định là 1x', + +'trading-assistant.form.leverage': 'Tỷ lệ đòn bẩy', + +'trading-assistant.form.leverageHint': 'Hợp đồng: 1-125x, Giao ngay: Cố định 1x', + +'trading-assistant.form.spotLeverageFixed': 'Đòn bẩy giao dịch giao ngay được cố định ở mức 1x', + +'trading-assistant.form.spotOnlyLongHint': 'Giao dịch giao ngay chỉ hỗ trợ vị thế mua', + +'trading-assistant.form.tradeDirection': 'Hướng giao dịch', + +'trading-assistant.form.tradeDirectionLong': 'Chỉ mua', + +'trading-assistant.form.tradeDirectionShort': 'Chỉ bán', + +'trading-assistant.form.tradeDirectionBoth': 'Hai chiều giao dịch', + +'trading-assistant.form.timeframe': 'Khung thời gian', + +'trading-assistant.form.klinePeriod': 'Chu kỳ K-Line', + +'trading-assistant.form.timeframe1m': '1 phút', + +'trading-assistant.form.timeframe5m': '5 phút', + +'trading-assistant.form.timeframe15m': '15 phút', + +'trading-assistant.form.timeframe30m': '30 phút', + +'trading-assistant.form.timeframe1H': '1 giờ', + +'trading-assistant.form.timeframe4H': '4 giờ', + +'trading-assistant.form.timeframe1D': '1 ngày', + +'trading-assistant.form.selectStrategyType': 'Chọn loại chiến lược', + +'trading-assistant.form.indicatorStrategy': 'Chiến lược chỉ báo', + +'trading-assistant.form.indicatorStrategyDesc': 'Chiến lược giao dịch tự động dựa trên chỉ báo kỹ thuật', + +'trading-assistant.form.aiStrategy': 'Chiến lược AI', + +'trading-assistant.form.aiStrategyDesc': 'Chiến lược giao dịch tự động dựa trên quyết định thông minh AI', + +'trading-assistant.form.enableAiFilter': 'Bật bộ lọc quyết định thông minh AI', + +'trading-assistant.form.enableAiFilterHint': 'Khi bật, tín hiệu chỉ báo sẽ được lọc bởi AI để cải thiện chất lượng giao dịch', + +'trading-assistant.form.aiFilterPrompt': 'Lời nhắc tùy chỉnh', + +'trading-assistant.form.aiFilterPromptHint': 'Cung cấp hướng dẫn tùy chỉnh cho bộ lọc AI, để trống để sử dụng mặc định hệ thống', + +'trading-assistant.validation.strategyTypeRequired': 'Vui lòng chọn loại chiến lược', + +'trading-assistant.form.advancedSettings': 'Cài đặt nâng cao', + +'trading-assistant.form.orderMode': 'Chế độ đặt lệnh', + +'trading-assistant.form.orderModeMaker': 'Maker', + +'trading-assistant.form.orderModeTaker': 'Giá thị trường', + +'trading-assistant.form.orderModeHint': 'Chế độ Maker sử dụng lệnh giới hạn, phí thấp hơn; chế độ Market thực hiện ngay lập tức, phí cao hơn', + +'trading-assistant.form.makerWaitSec': 'Thời gian chờ của Maker (giây)', + +'trading-assistant.form.makerWaitSecHint': 'Thời gian chờ sau khi đặt lệnh; ', +'trading-assistant.form.makerRetries': 'Số lần thử lại cho lệnh maker', + +'trading-assistant.form.makerRetriesHint': 'Số lần thử lại tối đa nếu lệnh maker không được khớp', + +'trading-assistant.form.fallbackToMarket': 'Hạ cấp lệnh maker thất bại xuống giá thị trường', + +'trading-assistant.form.fallbackToMarketHint': 'Có nên hạ cấp các lệnh mở/đóng đang chờ xử lý thành lệnh thị trường để đảm bảo thực hiện khi chúng không được khớp hay không', + +'trading-assistant.form.marginMode': 'Chế độ ký quỹ', + +'trading-assistant.form.marginModeCross': 'Ký quỹ chéo', + +'trading-assistant.form.marginModeIsolated': 'Ký quỹ riêng biệt Ký quỹ', + +'trading-assistant.form.stopLossPct': 'Tỷ lệ cắt lỗ (%)', + +'trading-assistant.form.stopLossPctHint': 'Thiết lập tỷ lệ cắt lỗ; 0 cho biết lệnh dừng lỗ không được bật', + +'trading-assistant.form.takeProfitPct': 'Tỷ lệ chốt lời (%)', + +'trading-assistant.form.takeProfitPctHint': 'Thiết lập tỷ lệ chốt lời, 0 cho biết lệnh chốt lời không được bật', + +'trading-assistant.form.signalMode': 'Chế độ tín hiệu', + +'trading-assistant.form.signalModeConfirmed': 'Chế độ xác nhận', + +'trading-assistant.form.signalModeAggressive': 'Chế độ giao dịch tích cực', + +'trading-assistant.form.signalModeHint': 'Chế độ xác nhận: Chỉ kiểm tra các nến đã hoàn thành; Chế độ giao dịch tích cực: Kiểm tra cả việc hình thành nến', + +'trading-assistant.form.cancel': 'Hủy', + +'trading-assistant.form.prev': 'Bước trước', + +'trading-assistant.form.next': 'Bước tiếp theo', + +'trading-assistant.form.confirmCreate': 'Xác nhận tạo', + +'trading-assistant.form.confirmEdit': 'Xác nhận chỉnh sửa', + +'trading-assistant.messages.createSuccess': 'Tạo chiến lược thành công', + +'trading-assistant.messages.createFailed': 'Tạo chiến lược thất bại', + +'trading-assistant.messages.updateSuccess': 'Cập nhật chiến lược thành công', + +'trading-assistant.messages.updateFailed': 'Cập nhật chiến lược thất bại', +'trading-assistant.messages.deleteSuccess': 'Xóa chiến lược thành công', + +'trading-assistant.messages.deleteFailed': 'Xóa chiến lược thất bại', + +'trading-assistant.messages.startSuccess': 'Khởi động chiến lược thành công', + +'trading-assistant.messages.startFailed': 'Khởi động chiến lược thất bại', + +'trading-assistant.messages.stopSuccess': 'Chính sách đã dừng thành công', + +'trading-assistant.messages.stopFailed': 'Dừng chính sách thất bại', + +'trading-assistant.messages.loadFailed': 'Không thể tải danh sách chính sách', + +'trading-assistant.messages.runningWarning': 'Chính sách đang chạy, vui lòng dừng chính sách trước khi sửa đổi', +'trading-assistant.messages.deleteConfirmWithName': 'Bạn có chắc chắn muốn xóa chính sách "{name}" không? Thao tác này không thể đảo ngược.', + +'trading-assistant.messages.deleteConfirm': 'Bạn có chắc chắn muốn xóa chính sách này không? Thao tác này không thể đảo ngược.', + +'trading-assistant.messages.loadTradesFailed': 'Không thể truy xuất hồ sơ giao dịch', + +'trading-assistant.messages.loadPositionsFailed': 'Không thể truy xuất hồ sơ vị thế', + +'trading-assistant.messages.loadEquityFailed': 'Không thể truy xuất đường cong vốn chủ sở hữu', + +'trading-assistant.messages.loadIndicatorsFailed': 'Không thể tải danh sách chỉ báo, vui lòng thử lại sau', + +'trading-assistant.messages.spotLimitations': 'Giao dịch giao ngay đã được tự động thiết lập chỉ mua, đòn bẩy 1x', + +'trading-assistant.messages.autoFillApiConfig': 'Cấu hình API lịch sử cho sàn giao dịch này đã được tự động điền', + +'trading-assistant.placeholders.selectIndicator': 'Vui lòng chọn một chỉ báo indicator', +'trading-assistant.placeholders.selectExchange': 'Vui lòng chọn một sàn giao dịch', + +'trading-assistant.placeholders.inputApiKey': 'Vui lòng nhập Khóa API', + +'trading-assistant.placeholders.inputSecretKey': 'Vui lòng nhập Khóa bí mật', + +'trading-assistant.placeholders.inputPassphrase': 'Vui lòng nhập Mật khẩu', + +'trading-assistant.placeholders.inputStrategyName': 'Vui lòng nhập tên chiến lược', + +'trading-assistant.placeholders.selectSymbol': 'Vui lòng chọn một cặp giao dịch', + +'trading-assistant.placeholders.selectTimeframe': 'Vui lòng chọn một khung thời gian', + +'trading-assistant.placeholders.selectKlinePeriod': 'Vui lòng chọn chu kỳ K-Line', + +'trading-assistant.placeholders.inputAiFilterPrompt': 'Vui lòng nhập lời nhắc tùy chỉnh (tùy chọn)', + +'trading-assistant.validation.indicatorRequired': 'Vui lòng chọn một chỉ báo', + +'trading-assistant.validation.exchangeRequired': 'Vui lòng chọn một sàn giao dịch', +'trading-assistant.validation.apiKeyRequired': 'Vui lòng nhập Khóa API', + +'trading-assistant.validation.secretKeyRequired': 'Vui lòng nhập Khóa bí mật', + +'trading-assistant.validation.passphraseRequired': 'Vui lòng nhập Mật khẩu', + +'trading-assistant.validation.exchangeConfigIncomplete': 'Vui lòng điền đầy đủ thông tin cấu hình sàn giao dịch', +'trading-assistant.validation.testConnectionRequired': 'Vui lòng nhấp vào nút "Kiểm tra kết nối" và đảm bảo kết nối thành công', +'trading-assistant.validation.testConnectionFailed': 'Kiểm tra kết nối thất bại, vui lòng kiểm tra cấu hình và thử lại', + +'trading-assistant.validation.strategyNameRequired': 'Vui lòng nhập tên chiến lược', + +'trading-assistant.validation.symbolRequired': 'Vui lòng chọn một cặp giao dịch ...apiKeyRequired', + +'trading-assistant.apiKeyRequired': 'Vui lòng Nhập khóa API', + +'trading-assistant.validation.initialCapitalRequired': 'Vui lòng nhập vốn ban đầu', + +'trading-assistant.validation.leverageRequired': 'Vui lòng nhập tỷ lệ đòn bẩy', + +'trading-assistant.table.time': 'Thời gian', + +'trading-assistant.table.type': 'Loại', + +'trading-assistant.table.price': 'Giá', + +'trading-assistant.table.amount': 'Số tiền', + +'trading-assistant.table.value': 'Số tiền', + +'trading-assistant.table.commission': 'Phí hoa hồng', + +'trading-assistant.table.symbol': 'Cặp giao dịch', + +'trading-assistant.table.side': 'Hướng', + +'trading-assistant.table.size': 'Vị thế kích thước', + +'trading-assistant.table.entryPrice': 'Giá vào lệnh', + +'trading-assistant.table.currentPrice': 'Giá hiện tại', + +'trading-assistant.table.unrealizedPnl': 'Lợi nhuận/Thua lỗ chưa thực hiện', + +'trading-assistant.table.pnlPercent': 'Tỷ lệ lợi nhuận/thua lỗ', + +'trading-assistant.table.buy': 'Mua', + +'trading-assistant.table.sell': 'Bán', + +'trading-assistant.table.long': 'Mua', + +'trading-assistant.table.short': 'Bán', + +'trading-assistant.table.noPositions': 'Không có vị thế', + +'trading-assistant.detail.title': 'Chi tiết chiến lược', + +'trading-assistant.detail.strategyName': 'Chiến lược Tên', + +'trading-assistant.detail.strategyType': 'Loại chiến lược', + +'trading-assistant.detail.status': 'Trạng thái', + +'trading-assistant.detail.tradingMode': 'Chế độ giao dịch', + +'trading-assistant.detail.exchange': 'Sàn giao dịch', + +'trading-assistant.detail.initialCapital': 'Vốn ban đầu', + +'trading-assistant.detail.totalInvestment': 'Tổng vốn đầu tư', + +'trading-assistant.detail.currentEquity': 'Vốn chủ sở hữu hiện tại', + +'trading-assistant.detail.totalPnl': 'Tổng lợi nhuận/thua lỗ', + +'trading-assistant.detail.indicatorName': 'Tên chỉ báo', + +'trading-assistant.detail.maxLeverage': 'Đòn bẩy tối đa Đòn bẩy', + +'trading-assistant.detail.decideInterval': 'Khoảng thời gian quyết định', + +'trading-assistant.detail.symbols': 'Công cụ giao dịch', + +'trading-assistant.detail.createdAt': 'Thời gian tạo', + +'trading-assistant.detail.updatedAt': 'Thời gian cập nhật', + +'trading-assistant.detail.llmConfig': 'Cấu hình mô hình AI', + +'trading-assistant.detail.exchangeConfig': 'Cấu hình sàn giao dịch', + +'trading-assistant.detail.provider': 'Nhà cung cấp', + +'trading-assistant.detail.modelId': 'ID mô hình', + +'trading-assistant.detail.close': 'Đóng', + +'trading-assistant.detail.loadFailed': 'Không thể truy xuất chiến lược chi tiết', + +'trading-assistant.equity.noData': 'Không có dữ liệu giá trị tài sản ròng', + +'trading-assistant.equity.equity': 'Giá trị tài sản ròng', + +'trading-assistant.exchange.tradingMode': 'Chế độ giao dịch', + +'trading-assistant.exchange.virtual': 'Giao dịch thử nghiệm', + +'trading-assistant.exchange.live': 'Giao dịch trực tiếp', + +'trading-assistant.exchange.selectExchange': 'Chọn sàn giao dịch', + +'trading-assistant.exchange.walletAddress': 'Địa chỉ ví', + +'trading-assistant.exchange.walletAddressPlaceholder': 'Vui lòng nhập địa chỉ ví của bạn (bắt đầu bằng 0x)', + +'trading-assistant.exchange.privateKey': 'Khóa riêng tư', + +'trading-assistant.exchange.privateKeyPlaceholder': 'Vui lòng nhập khóa riêng tư của bạn (64 ký tự)', + +'trading-assistant.exchange.testConnection': 'Kiểm tra kết nối', + +'trading-assistant.exchange.connectionSuccess': 'Kết nối thành công', + +'trading-assistant.exchange.connectionFailed': 'Kết nối thất bại', + +'trading-assistant.exchange.testFailed': 'Kiểm tra kết nối thất bại', + +'trading-assistant.exchange.fillComplete': 'Vui lòng điền đầy đủ thông tin cấu hình sàn giao dịch', + +'trading-assistant.strategyTypeOptions.ai': 'Chiến lược dựa trên AI', + +'trading-assistant.strategyTypeOptions.indicator': 'Chiến lược chỉ báo kỹ thuật', + +'trading-assistant.strategyTypeOptions.aiDeveloping': 'Chức năng chiến lược dựa trên AI đang được phát triển, hãy theo dõi', + +'trading-assistant.strategyTypeOptions.aiDevelopingWarning': 'Chức năng chiến lược dựa trên AI đang được phát triển', + +'trading-assistant.indicatorType.trend': 'Xu hướng', + +'trading-assistant.indicatorType.momentum': 'Động lượng', + +'trading-assistant.indicatorType.volatility': 'Biến động', + +'trading-assistant.indicatorType.volume': 'Khối lượng', + +'trading-assistant.indicatorType.custom': 'Tùy chỉnh', + +'trading-assistant.exchangeNames': { + +'okx': 'OKX', + +'binance': 'Binance', + +'hyperliquid': 'Hyperliquid', + +'blockchaincom': 'Blockchain.com', + +'coinbaseexchange': 'Coinbase', + +'gate': 'Gate.io', + +'mexc': 'MEXC', + +'kraken': 'Kraken', +'bitfinex': 'Bitfinex', +'bybit': 'Bybit', +'kucoin': 'KuCoin', +'huobi': 'Huobi', +'bitget': 'Bitget', +'bitmex': 'BitMEX', +'gạch từ': 'Dựa trên', +'phemex': 'Phemex', +'bitmart': 'BitMart', +'bitstamp': 'Bitstamp', +'bittrex': 'Bittrex', +'poloniex': 'Poloniex', +'song tử': 'song tử', +'tiền điện tử': 'Crypto.com', +'bitflyer': 'bitFlyer', +'upbit': 'Upbit', +'bithumb': 'Bithumb', +'coinone': 'Coinone', +'zb': 'ZB', +'ngân hàng': 'LBank', + +'bibox': 'Bibox', + +'bigone': 'BigONE', + +'bitrue': 'Bitrue', + +'coinex': 'CoinEx', + +'digifinex': 'DigiFinex', + +'ftx': 'FTX', + +'ftxus': 'FTX US', + +'binanceus': 'Binance US', + +'binancecoinm': 'Binance COIN-M', + +'binanceusdm': 'Binance USDⓈ-M' + +}, + +'ai-trading-assistant.title': 'Trợ lý giao dịch AI', + +'ai-trading-assistant.strategyList': 'Danh sách chiến lược', + +'ai-trading-assistant.createStrategy': 'Tạo chiến lược', + +'ai-trading-assistant.noStrategy': 'Không có chiến lược Có sẵn', + +'ai-trading-assistant.selectStrategy': 'Chọn chiến lược từ bên trái để xem chi tiết', + +'ai-trading-assistant.startStrategy': 'Bắt ​​đầu chiến lược', + +'ai-trading-assistant.stopStrategy': 'Dừng chiến lược', + +'ai-trading-assistant.editStrategy': 'Chỉnh sửa chiến lược', + +'ai-trading-assistant.deleteStrategy': 'Xóa chiến lược', + +'ai-trading-assistant.status.running': 'Đang chạy', + +'ai-trading-assistant.status.stopped': 'Đã dừng', + +'ai-trading-assistant.status.error': 'Lỗi', + +'ai-trading-assistant.tabs.tradingRecords': 'Hồ sơ giao dịch', + +'ai-trading-assistant.tabs.positions': 'Vị thế', + +'ai-trading-assistant.tabs.aiDecisions': 'Bản ghi quyết định của AI', + +'ai-trading-assistant.tabs.equityCurve': 'Đường cong vốn chủ sở hữu', + +'ai-trading-assistant.form.createTitle': 'Tạo chiến lược giao dịch AI', + +'ai-trading-assistant.form.editTitle': 'Chỉnh sửa chiến lược giao dịch AI', + +'ai-trading-assistant.form.strategyName': 'Tên chiến lược', + +'ai-trading-assistant.form.modelId': 'Mô hình AI', + +'ai-trading-assistant.form.modelIdHint': 'Sử dụng dịch vụ OpenRouter của hệ thống; Không cần cấu hình khóa API', + +'ai-trading-assistant.form.decideInterval': 'Khoảng thời gian quyết định', + +'ai-trading-assistant.form.decideInterval5m': '5 phút', + +'ai-trading-assistant.form.decideInterval10m': '10 phút', + +'ai-trading-assistant.form.decideInterval30m': '30 phút', + +'ai-trading-assistant.form.decideInterval1h': '1 giờ', + +'ai-trading-assistant.form.decideInterval4h': '4 giờ', + +'ai-trading-assistant.form.decideInterval1d': '1 ngày', + +'ai-trading-assistant.form.decideInterval1w': '1 tuần', + +'ai-trading-assistant.form.decideIntervalHint': 'Khoảng thời gian cho AI để đưa ra quyết định', + +'ai-trading-assistant.form.runPeriod': 'Thời gian chạy', + +'ai-trading-assistant.form.runPeriodHint': 'Thời gian bắt đầu và kết thúc của chiến lược', + +'ai-trading-assistant.form.startDate': 'Ngày bắt đầu', + +'ai-trading-assistant.form.endDate': 'Ngày kết thúc', + +'ai-trading-assistant.form.qdtCostTitle': 'Mô tả chi phí QDT', + +'ai-trading-assistant.form.qdtCostHint': 'Mỗi quyết định của AI sẽ có giá {cost} QDT. Vui lòng đảm bảo tài khoản của bạn có đủ số dư QDT. Chi phí sẽ được trừ theo thời gian thực cho mỗi quyết định trong quá trình chạy chiến lược.', + +'ai-trading-assistant.form.apiKey': 'Khóa API', + +'ai-trading-assistant.form.exchange': 'Chọn sàn giao dịch', + +'ai-trading-assistant.form.secretKey': 'Khóa bí mật', + +'ai-trading-assistant.form.passphrase': 'Mật khẩu', + +'ai-trading-assistant.form.testConnection': 'Kiểm tra kết nối', + +'ai-trading-assistant.form.symbol': 'Cặp giao dịch', + +'ai-trading-assistant.form.symbolHint': 'Chọn cặp giao dịch', + +'ai-trading-assistant.form.initialCapital': 'Số vốn đầu tư (Ký quỹ)', + +'ai-trading-assistant.form.leverage': 'Tỷ lệ đòn bẩy', + +'ai-trading-assistant.form.timeframe': 'Khung thời gian', + +'ai-trading-assistant.form.timeframe1m': '1 phút', + +'ai-trading-assistant.form.timeframe5m': '5 phút', + +'ai-trading-assistant.form.timeframe15m': '15 phút', + +'ai-trading-assistant.form.timeframe30m': '30 phút', + +'ai-trading-assistant.form.timeframe1H': '1 giờ', + +'ai-trading-assistant.form.timeframe4H': '4 giờ', + +'ai-trading-assistant.form.timeframe1D': '1 ngày', + +'ai-trading-assistant.form.marketType': 'Loại thị trường', + +'ai-trading-assistant.form.marketTypeFutures': 'Hợp đồng', + +'ai-trading-assistant.form.marketTypeSpot': 'Giao dịch tức thời', + +'ai-trading-assistant.form.totalPnl': 'Tổng lợi nhuận/thua lỗ', + +'ai-trading-assistant.form.customPrompt': 'Lời nhắc tùy chỉnh', + +'ai-trading-assistant.form.customPromptHint': 'Tùy chọn, để tùy chỉnh chiến lược giao dịch và logic quyết định của AI', + +'ai-trading-assistant.form.cancel': 'Hủy', + +'ai-trading-assistant.form.prev': 'Bước trước', + +'ai-trading-assistant.form.next': 'Bước tiếp theo', + +'ai-trading-assistant.form.confirmCreate': 'Xác nhận tạo', + +'ai-trading-assistant.form.confirmEdit': 'Xác nhận chỉnh sửa', + +'ai-trading-assistant.messages.createSuccess': 'Tạo chiến lược thành công', + +'ai-trading-assistant.messages.createFailed': 'Tạo chiến lược thất bại', + +'ai-trading-assistant.messages.updateSuccess': 'Cập nhật chiến lược thành công', + +'ai-trading-assistant.messages.updateFailed': 'Cập nhật chiến lược thất bại', + +'ai-trading-assistant.messages.deleteSuccess': 'Xóa chiến lược thành công', + +'ai-trading-assistant.messages.deleteFailed': 'Xóa chiến lược thất bại', + +'ai-trading-assistant.messages.startSuccess': 'Khởi động chiến lược thành công', + +'ai-trading-assistant.messages.startFailed': 'Không thể khởi động chính sách', + +'ai-trading-assistant.messages.stopSuccess': 'Đã dừng thành công chính sách', + +'ai-trading-assistant.messages.stopFailed': 'Không thể dừng chính sách', + +'ai-trading-assistant.messages.loadFailed': 'Không thể truy xuất danh sách chính sách', + +'ai-trading-assistant.messages.loadDecisionsFailed': 'Không thể truy xuất bản ghi quyết định của AI', + +'ai-trading-assistant.messages.deleteConfirm': 'Bạn có chắc chắn muốn xóa chính sách này không? Thao tác này không thể đảo ngược.', + +'ai-trading-assistant.placeholders.inputStrategyName': 'Vui lòng nhập tên chiến lược', + +'ai-trading-assistant.placeholders.selectModelId': 'Vui lòng chọn mô hình AI', + +'ai-trading-assistant.placeholders.selectDecideInterval': 'Vui lòng chọn khoảng thời gian quyết định', + +'ai-trading-assistant.placeholders.startTime': 'Thời gian bắt đầu', + +'ai-trading-assistant.placeholders.endTime': 'Thời gian kết thúc', + +'ai-trading-assistant.placeholders.inputApiKey': 'Vui lòng nhập khóa API', + +'ai-trading-assistant.placeholders.selectExchange': 'Vui lòng chọn sàn giao dịch', + +'ai-trading-assistant.placeholders.inputSecretKey': 'Vui lòng nhập mã bí mật Key', + +'ai-trading-assistant.placeholders.inputPassphrase': 'Vui lòng nhập mật khẩu của bạn', + +'ai-trading-assistant.placeholders.selectSymbol': 'Vui lòng chọn một cặp giao dịch, ví dụ như BTC/USDT', + +'ai-trading-assistant.placeholders.selectTimeframe': 'Vui lòng chọn khung thời gian', + +'ai-trading-assistant.placeholders.inputCustomPrompt': 'Vui lòng nhập lời nhắc tùy chỉnh (tùy chọn)', + +'ai-trading-assistant.validation.strategyNameRequired': 'Vui lòng nhập tên chiến lược', + +'ai-trading-assistant.validation.modelIdRequired': 'Vui lòng chọn một mô hình AI', + +'ai-trading-assistant.validation.runPeriodRequired': 'Vui lòng chọn khoảng thời gian chạy', + +'ai-trading-assistant.validation.apiKeyRequired': 'Vui lòng nhập API Key', + +'ai-trading-assistant.validation.exchangeRequired': 'Vui lòng chọn sàn giao dịch', + +'ai-trading-assistant.validation.secretKeyRequired': 'Vui lòng nhập Khóa bí mật', + +'ai-trading-assistant.validation.symbolRequired': 'Vui lòng chọn cặp giao dịch', + +'ai-trading-assistant.validation.initialCapitalRequired': 'Vui lòng nhập số vốn đầu tư', + +'ai-trading-assistant.table.time': 'Thời gian', + +'ai-trading-assistant.table.type': 'Loại', + +'ai-trading-assistant.table.price': 'Giá', + +'ai-trading-assistant.table.amount': 'Số lượng', + +'ai-trading-assistant.table.value': 'Giá trị', + +'ai-trading-assistant.table.symbol': 'Cặp giao dịch', + +'ai-trading-assistant.table.side': 'Hướng', + +'ai-trading-assistant.table.size': 'Số lượng vị thế', + +'ai-trading-assistant.table.entryPrice': 'Giá vào lệnh', + +'ai-trading-assistant.table.currentPrice': 'Giá hiện tại', + +'ai-trading-assistant.table.unrealizedPnl': 'Lợi nhuận/Thua lỗ chưa thực hiện', + +'ai-trading-assistant.table.profit': 'Lợi nhuận/Thua lỗ', + +'ai-trading-assistant.table.openLong': 'Mở lệnh mua', + +'ai-trading-assistant.table.closeLong': 'Đóng lệnh Long', + +'ai-trading-assistant.table.openShort': 'Mở lệnh bán', + +'ai-trading-assistant.table.closeShort': 'Đóng lệnh bán', + +'ai-trading-assistant.table.addLong': 'Thêm lệnh mua', + +'ai-trading-assistant.table.addShort': 'Thêm lệnh bán', + +'ai-trading-assistant.table.closeShortProfit': 'Đóng lệnh bán có lãi', + +'ai-trading-assistant.table.closeShortStop': 'Đóng lệnh cắt lỗ lệnh bán', + +'ai-trading-assistant.table.closeLongProfit': 'Đóng lệnh mua có lãi', + +'ai-trading-assistant.table.closeLongStop': 'Đóng lệnh cắt lỗ lệnh mua', + +'ai-trading-assistant.table.buy': 'Mua', + +'ai-trading-assistant.table.sell': '售', + +'ai-trading-assistant.table.long': '开长', + +'ai-trading-assistant.table.short': '开短', + +'ai-trading-assistant.table.hold': '控股', + +'ai-trading-assistant.table.reasoning': '分析理理', + +'ai-trading-assistant.table.decisions': '决定', + +'ai-trading-assistant.table.riskAssessment': '风险考核', + +'ai-trading-assistant.table.trust': '信信度', + +'ai-trading-assistant.table.totalRecords': '{total} Không có bản ghi', +'ai-trading-assistant.table.noPositions': 'Không có vị thế', + +'ai-trading-assistant.detail.title': 'Chi tiết chiến lược', + +'ai-trading-assistant.equity.noData': 'Không có dữ liệu giá trị tài sản ròng', + +'ai-trading-assistant.equity.equity': 'Giá trị tài sản ròng', + +'ai-trading-assistant.exchange.testFailed': 'Kiểm tra kết nối thất bại', + +'ai-trading-assistant.exchange.connectionSuccess': 'Kết nối thành công', + +'ai-trading-assistant.exchange.connectionFailed': 'Kết nối thất bại', + +'ai-trading-assistant.form.advancedSettings': 'Cài đặt nâng cao', + +'ai-trading-assistant.form.orderMode': 'Lệnh Mode', + +'ai-trading-assistant.form.orderModeMaker': 'Lệnh chờ (Người tạo lệnh)', + +'ai-trading-assistant.form.orderModeTaker': 'Lệnh thị trường (Người nhận lệnh)', + +'ai-trading-assistant.form.orderModeHint': 'Chế độ lệnh chờ sử dụng lệnh giới hạn, phí thấp hơn; chế độ lệnh thị trường thực hiện ngay lập tức, phí cao hơn', + +'ai-trading-assistant.form.makerWaitSec': 'Thời gian chờ lệnh (giây)', + +'ai-trading-assistant.form.makerWaitSecHint': 'Thời gian chờ sau khi đặt lệnh;', +'ai-trading-assistant.form.makerRetries': 'Số lần thử lại cho lệnh đang chờ', + +'ai-trading-assistant.form.makerRetriesHint': 'Số lần thử lại tối đa khi lệnh đang chờ không được khớp', + +'ai-trading-assistant.form.fallbackToMarket': 'Hạ cấp lệnh đang chờ thất bại thành lệnh thị trường', + +'ai-trading-assistant.form.fallbackToMarketHint': 'Có nên hạ cấp lệnh đang chờ mở/đóng thành lệnh thị trường để đảm bảo thực hiện khi chúng không được khớp hay không', + +'ai-trading-assistant.form.marginMode': 'Chế độ ký quỹ', + +'ai-trading-assistant.form.marginModeCross': 'Ký quỹ chéo', + +'ai-trading-assistant.form.marginModeIsolated': 'Ký quỹ riêng biệt Margin', + +'ai-analysis.title': 'Hệ thống giao dịch lượng tử', + +'ai-analysis.system.online': 'Trực tuyến', + +'ai-analysis.system.agents': 'Nhân viên', + +'ai-analysis.system.active': 'Hoạt động', + +'ai-analysis.system.stage': 'Giai đoạn', + +'ai-analysis.panel.roster': 'Danh sách nhân viên', + +'ai-analysis.panel.thinking': 'Đang suy nghĩ...', + +'ai-analysis.panel.done': 'Hoàn tất', + +'ai-analysis.panel.standby': 'Chờ', + +'ai-analysis.input.title': 'Hệ thống giao dịch lượng tử', + +'ai-analysis.input.placeholder': 'Chọn tài sản mục tiêu (ví dụ: BTC/USDT)', + +'ai-analysis.input.watchlist': 'Danh sách theo dõi', + +'ai-analysis.input.start': 'Bắt ​​đầu phân tích', + +'ai-analysis.input.recent': 'Các tác vụ gần đây:', + +'ai-analysis.vis.stage': 'Giai đoạn', + +'ai-analysis.vis.processing': 'Đang xử lý', + +'ai-analysis.result.complete': 'Phân tích hoàn tất', + +'ai-analysis.result.signal': 'Tín hiệu cuối cùng', + +'ai-analysis.result.confidence': 'Mức độ tin cậy:', + +'ai-analysis.result.new': 'Phân tích mới', + +'ai-analysis.result.full': 'Xem báo cáo đầy đủ', + +'ai-analysis.logs.title': 'Nhật ký hệ thống', + +'ai-analysis.modal.title': 'Báo cáo bảo mật', + +'ai-analysis.modal.fundamental': 'Phân tích cơ bản', + +'ai-analysis.modal.technical': 'Phân tích kỹ thuật', + +'ai-analysis.modal.sentiment': 'Phân tích tâm lý', + +'ai-analysis.modal.risk': 'Đánh giá rủi ro', + +'ai-analysis.stage.idle': 'Chế độ chờ', + +'ai-analysis.stage.1': 'Giai đoạn 1: Phân tích đa chiều', + +'ai-analysis.stage.2': 'Giai đoạn 2: Thảo luận về xu hướng tăng/giảm', + +'ai-analysis.stage.3': 'Giai đoạn 3: Lập kế hoạch chiến lược', + +'ai-analysis.stage.4': 'Giai đoạn 4: Xem xét kiểm soát rủi ro', + +'ai-analysis.stage.complete': 'Đã hoàn thành', + +'ai-analysis.agent.investment_director': 'Giám đốc đầu tư Giám đốc', + +'ai-analysis.agent.role.investment_director': 'Phân tích toàn diện & Kết luận cuối cùng', + +'ai-analysis.agent.market': 'Nhà phân tích thị trường', + +'ai-analysis.agent.role.market': 'Dữ liệu kỹ thuật & thị trường', + +'ai-analysis.agent.fundamental': 'Nhà phân tích cơ bản', + +'ai-analysis.agent.role.fundamental': 'Tài chính & Định giá', + +'ai-analysis.agent.technical': 'Nhà phân tích kỹ thuật', + +'ai-analysis.agent.role.technical': 'Các chỉ báo & biểu đồ kỹ thuật', + +'ai-analysis.agent.news': 'Nhà phân tích tin tức', + +'ai-analysis.agent.role.news': 'Bộ lọc tin tức toàn cầu', + +'ai-analysis.agent.sentiment': 'Tâm lý thị trường Nhà phân tích', + +'ai-analysis.agent.role.sentiment': 'Phân tích xã hội & cảm xúc', + +'ai-analysis.agent.risk': 'Nhà phân tích rủi ro', + +'ai-analysis.agent.role.risk': 'Kiểm tra rủi ro cơ bản', + +'ai-analysis.agent.bull': 'Nhà phân tích lạc quan', + +'ai-analysis.agent.role.bull': 'Khám phá động lực tăng trưởng', + +'ai-analysis.agent.bear': 'Nhà phân tích bi quan', + +'ai-analysis.agent.role.bear': 'Khám phá rủi ro & điểm yếu', + +'ai-analysis.agent.manager': 'Quản lý nghiên cứu', + +'ai-analysis.agent.role.manager': 'Người điều phối tranh luận', + +'ai-analysis.agent.trader': 'Nhà giao dịch', + +'ai-analysis.agent.role.trader': 'Chiến lược gia điều hành', + +'ai-analysis.agent.risky': 'Nhà phân tích năng động', + +'ai-analysis.agent.role.risky': 'Chiến lược năng động', + +'ai-analysis.agent.neutral': 'Nhà phân tích cân bằng', + +'ai-analysis.agent.role.neutral': 'Chiến lược cân bằng', + +'ai-analysis.agent.safe': 'Nhà phân tích thận trọng', + +'ai-analysis.agent.role.safe': 'Chiến lược thận trọng', + +'ai-analysis.agent.cro': 'Cân nhắc Quản lý rủi ro (CRO)', + +'ai-analysis.agent.role.cro': 'Người có thẩm quyền quyết định cuối cùng', + +'ai-analysis.script.market': 'Đang truy xuất dữ liệu OHLCV từ các sàn giao dịch lớn...', + +'ai-analysis.script.fundamental': 'Đang truy xuất báo cáo tài chính hàng quý...', + +'ai-analysis.script.technical': 'Phân tích các chỉ báo kỹ thuật và mô hình biểu đồ...', + +'ai-analysis.script.news': 'Đang quét tin tức tài chính toàn cầu...', + +'ai-analysis.script.sentiment': 'Phân tích xu hướng mạng xã hội...', + +'ai-analysis.script.risk': 'Đang tính toán biến động lịch sử...', + +'invite.inviteLink': 'Liên kết mời', + +'invite.copy': 'Sao chép liên kết', + +'invite.copySuccess': 'Sao chép thành công!', + +'invite.copyFailed': 'Sao chép thất bại, vui lòng sao chép thủ công', + +'invite.noInviteLink': 'Không tạo được liên kết mời', + +'invite.totalInvites': 'Tổng số lời mời', + +'invite.totalReward': 'Tổng số phần thưởng', + +'invite.rules': 'Quy tắc mời', + +'invite.rule1': 'Bạn sẽ nhận được phần thưởng cho mỗi người bạn mời đăng ký thành công', + +'invite.rule2': 'Bạn sẽ nhận được phần thưởng bổ sung khi người bạn được mời hoàn tất giao dịch đầu tiên', + +'invite.rule3': 'Phần thưởng mời sẽ được cộng trực tiếp vào tài khoản của bạn', + +'invite.inviteList': 'Danh sách người được mời', + +'invite.tasks': 'Trung tâm nhiệm vụ', + +'invite.inviteeName': 'Người được mời', + +'invite.inviteTime': 'Thời gian mời', + +'invite.status': 'Trạng thái', + +'invite.reward': 'Phần thưởng', + +'invite.active': 'Đang hoạt động', + +'invite.inactive': 'Không hoạt động', + +'invite.completed': 'Đã hoàn thành', + +'invite.claimed': 'Đã nhận', + +'invite.pending': 'Đang chờ hoàn thành', + +'invite.goToTask': 'Chuyển đến hoàn thành', + +'invite.claimReward': 'Nhận phần thưởng', + +'invite.verify': 'Xác minh hoàn tất', + +'invite.verifySuccess': 'Xác minh thành công Nhiệm vụ hoàn thành', + +'invite.verifyNotCompleted': 'Nhiệm vụ chưa hoàn thành, vui lòng hoàn thành nhiệm vụ trước', + +'invite.verifyFailed': 'Xác minh thất bại, vui lòng thử lại sau', + +'invite.claimSuccess': 'Đã nhận thành công {reward} QDT!', + +'invite.claimFailed': 'Không thể xác nhận, vui lòng thử lại sau', + +'invite.totalRecords': 'Tổng số {total} bản ghi', + +'invite.task.twitter.title': 'Chia sẻ lại bài đăng trên Twitter của chúng tôi lên X (Twitter)', + +'invite.task.twitter.desc': 'Chia sẻ bài đăng chính thức của chúng tôi lên tài khoản Twitter của bạn (X)', + +'invite.task.youtube.title': 'Đăng ký kênh YouTube của chúng tôi', + +'invite.task.youtube.desc': 'Đăng ký và theo dõi kênh YouTube chính thức của chúng tôi', + +'invite.task.telegram.title': 'Tham gia nhóm Telegram của chúng tôi', + +'invite.task.telegram.desc': 'Tham gia nhóm cộng đồng Telegram chính thức của chúng tôi', + +'invite.task.discord.title': 'Tham gia Discord Máy chủ', + +'invite.task.discord.desc': 'Tham gia máy chủ cộng đồng Discord của chúng tôi', + +'message': '-', + +'layouts.usermenu.dialog.title': 'Thông tin', + +'layouts.usermenu.dialog.content': 'Bạn có chắc chắn muốn đăng xuất không?', + +'layouts.userLayout.title': 'Tìm kiếm sự thật trong sự không chắc chắn' + +} +export default { + ...components, + ...locale +} diff --git a/quantdinger_vue/src/locales/lang/zh-CN.js b/quantdinger_vue/src/locales/lang/zh-CN.js new file mode 100644 index 0000000..16a0b91 --- /dev/null +++ b/quantdinger_vue/src/locales/lang/zh-CN.js @@ -0,0 +1,1749 @@ +import antd from 'ant-design-vue/es/locale-provider/zh_CN' +import momentCN from 'moment/locale/zh-cn' + +const components = { + antLocale: antd, + momentName: 'zh-cn', + momentLocale: momentCN +} + +const locale = { + 'submit': '提交', + 'save': '保存', + 'submit.ok': '提交成功', + 'save.ok': '保存成功', + 'menu.welcome': '欢迎', + 'menu.home': '主页', + 'menu.dashboard': '仪表盘', + 'menu.dashboard.indicator': '指标分析', + 'menu.dashboard.community': '指标社区', + 'menu.dashboard.analysis': 'AI 分析', + 'menu.dashboard.tradingAssistant': '交易助手', + 'menu.dashboard.aiTradingAssistant': 'AI交易助手', + 'menu.dashboard.signalRobot': '信号机器人', + 'menu.dashboard.monitor': '监控页', + 'menu.dashboard.workplace': '工作台', + 'menu.form': '表单页', + 'menu.form.basic-form': '基础表单', + 'menu.form.step-form': '分步表单', + 'menu.form.step-form.info': '分步表单(填写转账信息)', + 'menu.form.step-form.confirm': '分步表单(确认转账信息)', + 'menu.form.step-form.result': '分步表单(完成)', + 'menu.form.advanced-form': '高级表单', + 'menu.list': '列表页', + 'menu.list.table-list': '查询表格', + 'menu.list.basic-list': '标准列表', + 'menu.list.card-list': '卡片列表', + 'menu.list.search-list': '搜索列表', + 'menu.list.search-list.articles': '搜索列表(文章)', + 'menu.list.search-list.projects': '搜索列表(项目)', + 'menu.list.search-list.applications': '搜索列表(应用)', + 'menu.profile': '详情页', + 'menu.profile.basic': '基础详情页', + 'menu.profile.advanced': '高级详情页', + 'menu.result': '结果页', + 'menu.result.success': '成功页', + 'menu.result.fail': '失败页', + 'menu.exception': '异常页', + 'menu.exception.not-permission': '403', + 'menu.exception.not-find': '404', + 'menu.exception.server-error': '500', + 'menu.exception.trigger': '触发错误', + 'menu.account': '个人页', + 'menu.account.center': '个人中心', + 'menu.account.settings': '个人设置', + 'menu.account.trigger': '触发报错', + 'menu.account.logout': '退出登录', + 'menu.wallet': '我的钱包', + 'menu.docs': '文档中心', + 'menu.docs.detail': '文档详情', + 'menu.header.refreshPage': '刷新页面', + 'menu.invite.friends': '邀请好友', + 'app.setting.pagestyle': '整体风格设置', + 'app.setting.pagestyle.light': '亮色菜单风格', + 'app.setting.pagestyle.dark': '暗色菜单风格', + 'app.setting.pagestyle.realdark': '暗黑模式', + 'app.setting.themecolor': '主题色', + 'app.setting.navigationmode': '导航模式', + 'app.setting.sidemenu.nav': '侧边栏导航', + 'app.setting.topmenu.nav': '顶部栏导航', + 'app.setting.content-width': '内容区域宽度', + 'app.setting.content-width.tooltip': '该设定仅 [顶部栏导航] 时有效', + 'app.setting.content-width.fixed': '固定', + 'app.setting.content-width.fluid': '流式', + 'app.setting.fixedheader': '固定 Header', + 'app.setting.fixedheader.tooltip': '固定 Header 时可配置', + 'app.setting.autoHideHeader': '下滑时隐藏 Header', + 'app.setting.fixedsidebar': '固定侧边菜单', + 'app.setting.sidemenu': '侧边菜单布局', + 'app.setting.topmenu': '顶部菜单布局', + 'app.setting.othersettings': '其他设置', + 'app.setting.weakmode': '色弱模式', + 'app.setting.multitab': '多页签模式', + 'app.setting.copy': '拷贝设置', + 'app.setting.loading': '加载主题中', + 'app.setting.copyinfo': '拷贝设置成功 src/config/defaultSettings.js', + 'app.setting.copy.success': '复制完毕', + 'app.setting.copy.fail': '复制失败', + 'app.setting.theme.switching': '正在切换主题!', + 'app.setting.production.hint': '配置栏只在开发环境用于预览,生产环境不会展现,请拷贝后手动修改配置文件', + 'app.setting.themecolor.daybreak': '拂晓蓝(默认)', + 'app.setting.themecolor.dust': '薄暮', + 'app.setting.themecolor.volcano': '火山', + 'app.setting.themecolor.sunset': '日暮', + 'app.setting.themecolor.cyan': '明青', + 'app.setting.themecolor.green': '极光绿', + 'app.setting.themecolor.geekblue': '极客蓝', + 'app.setting.themecolor.purple': '酱紫', + 'app.setting.tooltip': '页面设置', + 'user.login.userName': '用户名', + 'user.login.password': '密码', + 'user.login.username.placeholder': '账户: admin', + 'user.login.password.placeholder': '密码: admin or ant.design', + 'user.login.message-invalid-credentials': '登录失败,请检查邮箱和验证码', + 'user.login.message-invalid-verification-code': '验证码错误', + 'user.login.tab-login-credentials': '账户密码登录', + 'user.login.tab-login-email': '邮箱登录', + 'user.login.tab-login-mobile': '手机号登录', + 'user.login.captcha.placeholder': '请输入图形验证码', + 'user.login.mobile.placeholder': '手机号', + 'user.login.mobile.verification-code.placeholder': '验证码', + 'user.login.email.placeholder': '请输入邮箱地址', + 'user.login.email.verification-code.placeholder': '请输入验证码', + 'user.login.email.sending': '验证码发送中...', + 'user.login.email.send-success-title': '提示', + 'user.login.email.send-success': '验证码发送成功,请查收邮件', + 'user.login.sms.send-success': '验证码发送成功,请查收短信', + 'user.login.remember-me': '自动登录', + 'user.login.forgot-password': '忘记密码', + 'user.login.sign-in-with': '其他登录方式', + 'user.login.signup': '注册账户', + 'user.login.login': '登录', + 'user.register.register': '注册', + 'user.register.email.placeholder': '邮箱', + 'user.register.password.placeholder': '请至少输入 6 个字符。请不要使用容易被猜到的密码。', + 'user.register.password.popover-message': '请至少输入 6 个字符。请不要使用容易被猜到的密码。', + 'user.register.confirm-password.placeholder': '确认密码', + 'user.register.get-verification-code': '获取验证码', + 'user.register.sign-in': '使用已有账户登录', + 'user.register-result.msg': '你的账户:{email} 注册成功', + 'user.register-result.activation-email': '激活邮件已发送到你的邮箱中,邮件有效期为24小时。请及时登录邮箱,点击邮件中的链接激活帐户。', + 'user.register-result.back-home': '返回首页', + 'user.register-result.view-mailbox': '查看邮箱', + 'user.email.required': '请输入邮箱地址!', + 'user.email.wrong-format': '邮箱地址格式错误!', + 'user.userName.required': '请输入帐户名或邮箱地址', + 'user.password.required': '请输入密码!', + 'user.password.twice.msg': '两次输入的密码不匹配!', + 'user.password.strength.msg': '密码强度不够 ', + 'user.password.strength.strong': '强度:强', + 'user.password.strength.medium': '强度:中', + 'user.password.strength.low': '强度:低', + 'user.password.strength.short': '强度:太短', + 'user.confirm-password.required': '请确认密码!', + 'user.phone-number.required': '请输入正确的手机号', + 'user.phone-number.wrong-format': '手机号格式错误!', + 'user.verification-code.required': '请输入验证码!', + 'user.captcha.required': '请输入图形验证码!', + 'user.login.infos': 'QuantDinger 是一个 AI 多智能体股票分析辅助工具,不具备证券投资咨询资质。平台中的所有分析结果、评分、参考意见均由 AI 基于历史数据自动生成,仅供学习、研究与技术交流使用,不构成任何投资建议或决策依据。股票投资存在市场风险、流动性风险、政策风险等多种风险,可能导致本金损失。用户应基于自身风险承受能力独立决策,使用本工具产生的任何投资行为及其后果由用户自行承担。市场有风险,投资需谨慎。', + 'user.login.tab-login-web3': 'Web3 登录', + 'user.login.web3.tip': '使用钱包进行签名登录', + 'user.login.web3.connect': '连接钱包并登录', + 'user.login.web3.no-wallet': '未检测到钱包', + 'user.login.web3.no-address': '未获取到钱包地址', + 'user.login.web3.nonce-failed': '获取随机数失败', + 'user.login.web3.verify-failed': '签名验证失败', + 'user.login.web3.success': '登录成功', + 'user.login.web3.failed': '钱包登录失败', + 'nav.no_wallet': '未检测到钱包', + 'nav.copy': '复制', + 'nav.copy_success': '复制成功', + 'user.login.oauth.google': '使用 Google 登录', + 'user.login.oauth.github': '使用 GitHub 登录', + 'user.login.oauth.loading': '正在跳转到授权页面...', + 'user.login.oauth.failed': 'OAuth 登录失败', + 'user.login.oauth.get-url-failed': '获取授权链接失败', + 'user.login.subtitle': '驱动的全球市场量化洞察', + 'user.login.legal.title': '法律免责声明', + 'user.login.legal.view': '查看法律免责声明', + 'user.login.legal.collapse': '收起法律免责声明', + 'user.login.legal.agree': '我已阅读并同意法律免责声明', + 'user.login.legal.required': '请先阅读并勾选法律免责声明', + 'user.login.legal.content': 'QuantDinger 是一个 AI 多智能体股票分析辅助工具,不具备证券投资咨询资质。平台中的所有分析结果、评分、参考意见均由 AI 基于历史数据自动生成,仅供学习、研究与技术交流使用,不构成任何投资建议或决策依据。股票投资存在市场风险、流动性风险、政策风险等多种风险,可能导致本金损失。用户应基于自身风险承受能力独立决策,使用本工具产生的任何投资行为及其后果由用户自行承担。市场有风险,投资需谨慎。', + 'user.login.privacy.title': '用户隐私条款', + 'user.login.privacy.view': '查看用户隐私条款', + 'user.login.privacy.collapse': '收起用户隐私条款', + 'user.login.privacy.content': '我们重视您的隐私与数据保护。1) 收集范围:仅收集实现功能所需的信息(如邮箱、手机号、区号、Web3 钱包地址)以及必要的日志与设备信息。2) 使用目的:用于账户登录与安全校验、服务功能提供、问题排查与合规要求。3) 存储与安全:数据加密存储,并采取必要的权限与访问控制措施,尽力防止未经授权的访问、披露或丢失。4) 共享与第三方:除法律法规要求或履行服务所必需外,不会与第三方共享您的个人信息;若涉及第三方服务(如钱包、短信服务商),仅在实现功能所需的最小范围内处理。5) Cookies/本地存储:用于登录态与必要的会话维持(如令牌、PHPSESSID),您可在浏览器中进行清理或限制。6) 个人权利:您可根据法律法规行使查询、更正、删除、撤回同意等权利。7) 变更与通知:本条款更新后将在页面显著位置提示。继续使用本服务即表示您已阅读并同意更新内容。若您不同意本条款或其中任何更新,请停止使用本服务并联系我们。', + 'account.basicInfo': '基础信息', + 'account.id': '用户ID', + 'account.username': '用户名', + 'account.nickname': '昵称', + 'account.email': '邮箱', + 'account.mobile': '手机号', + 'account.web3address': '钱包地址', + 'account.pid': '推荐人ID', + 'account.level': '用户等级', + 'account.money': '余额', + 'account.qdtBalance': 'QDT 余额', + 'account.score': '积分', + 'account.createtime': '注册时间', + 'account.inviteLink': '邀请链接', + 'account.recharge': '充值', + 'account.rechargeTip': '请使用微信或支付宝扫描下方二维码进行充值', + 'account.qrCodePlaceholder': '二维码占位符(模拟)', + 'account.rechargeAmount': '充值金额', + 'account.enterAmount': '请输入充值金额', + 'account.rechargeHint': '最小充值金额:1 QDT', + 'account.confirmRecharge': '确认充值', + 'account.enterValidAmount': '请输入有效的充值金额', + 'account.rechargeSuccess': '充值成功!已充值 {amount} QDT', + 'account.settings.menuMap.basic': '基本设置', + 'account.settings.menuMap.security': '安全设置', + 'account.settings.menuMap.notification': '新消息通知', + 'account.settings.menuMap.moneyLog': '资金明细', + 'account.moneyLog.empty': '暂无资金明细', + 'account.moneyLog.total': '共 {total} 条记录', + 'account.moneyLog.type.purchase': '购买指标', + 'account.moneyLog.type.recharge': '充值', + 'account.moneyLog.type.refund': '退款', + 'account.moneyLog.type.reward': '奖励', + 'account.moneyLog.type.income': '指标收入', + 'account.moneyLog.type.commission': '平台手续费', + 'wallet.balance': 'QDT 余额', + 'wallet.recharge': '充值', + 'wallet.withdraw': '提现', + 'wallet.filter': '筛选', + 'wallet.reset': '重置', + 'wallet.totalRecharge': '累计充值', + 'wallet.totalWithdraw': '累计提现', + 'wallet.totalIncome': '累计收入', + 'wallet.records': '交易记录与资金明细', + 'wallet.tradingRecords': '交易记录', + 'wallet.moneyLog': '资金明细', + 'wallet.rechargeTip': '请使用微信或支付宝扫描下方二维码进行充值', + 'wallet.qrCodePlaceholder': '二维码占位符(模拟)', + 'wallet.rechargeAmount': '充值金额', + 'wallet.enterAmount': '请输入充值金额', + 'wallet.rechargeHint': '最小充值金额:1 QDT', + 'wallet.confirmRecharge': '确认充值', + 'wallet.enterValidAmount': '请输入有效的充值金额', + 'wallet.rechargeSuccess': '充值成功!已充值 {amount} QDT', + 'wallet.rechargeFailed': '充值失败', + 'wallet.withdrawTip': '请输入提现金额和提现地址', + 'wallet.withdrawAmount': '提现金额', + 'wallet.enterWithdrawAmount': '请输入提现金额', + 'wallet.withdrawHint': '最小提现金额:1 QDT', + 'wallet.withdrawAddress': '提现地址(可选)', + 'wallet.enterWithdrawAddress': '请输入提现地址', + 'wallet.confirmWithdraw': '确认提现', + 'wallet.enterValidWithdrawAmount': '请输入有效的提现金额', + 'wallet.insufficientBalance': '余额不足', + 'wallet.withdrawSuccess': '提现成功!已提现 {amount} QDT', + 'wallet.withdrawFailed': '提现失败', + 'wallet.noTradingRecords': '暂无交易记录', + 'wallet.noMoneyLog': '暂无资金明细', + 'wallet.loadTradingRecordsFailed': '加载交易记录失败', + 'wallet.loadMoneyLogFailed': '加载资金明细失败', + 'wallet.moneyLogTotal': '共 {total} 条记录', + 'wallet.moneyLogTypeTitle': '类型', + 'wallet.moneyLogType.all': '全部类型', + 'wallet.table.time': '时间', + 'wallet.table.type': '类型', + 'wallet.table.price': '价格', + 'wallet.table.amount': '数量', + 'wallet.table.money': '金额', + 'wallet.table.balance': '余额', + 'wallet.table.memo': '备注', + 'wallet.table.value': '价值', + 'wallet.table.profit': '盈亏', + 'wallet.table.commission': '手续费', + 'wallet.table.total': '共 {total} 条记录', + 'wallet.tradeType.buy': '买入', + 'wallet.tradeType.sell': '卖出', + 'wallet.tradeType.liquidation': '强平', + 'wallet.tradeType.openLong': '开多', + 'wallet.tradeType.addLong': '加多', + 'wallet.tradeType.closeLong': '平多', + 'wallet.tradeType.closeLongStop': '止损平多', + 'wallet.tradeType.closeLongProfit': '止盈平多', + 'wallet.tradeType.openShort': '开空', + 'wallet.tradeType.addShort': '加空', + 'wallet.tradeType.closeShort': '平空', + 'wallet.tradeType.closeShortStop': '止损平空', + 'wallet.tradeType.closeShortProfit': '止盈平空', + 'wallet.moneyLogType.purchase': '购买指标', + 'wallet.moneyLogType.recharge': '充值', + 'wallet.moneyLogType.withdraw': '提现', + 'wallet.moneyLogType.refund': '退款', + 'wallet.moneyLogType.reward': '奖励', + 'wallet.moneyLogType.income': '收入', + 'wallet.moneyLogType.commission': '手续费', + 'wallet.web3Address.required': '请先填写Web3钱包地址', + 'wallet.web3Address.requiredDescription': '充值前需要先绑定您的Web3钱包地址,用于接收充值', + 'wallet.web3Address.placeholder': '请输入您的Web3钱包地址(0x开头)', + 'wallet.web3Address.save': '保存钱包地址', + 'wallet.web3Address.saveSuccess': '钱包地址保存成功', + 'wallet.web3Address.saveFailed': '保存钱包地址失败', + 'wallet.web3Address.invalidFormat': '请输入有效的Web3钱包地址(以太坊格式:0x开头42位字符,或TRC20格式:T开头34位字符)', + 'wallet.selectCoin': '选择币种', + 'wallet.selectChain': '选择链', + 'wallet.chain.eth': 'ETH(ERC20)', + 'wallet.chain.trc20': 'TRC20', + 'wallet.chain.bsc': 'BSC', + 'wallet.targetQdtAmount': '想要兑换的QDT数量(可选)', + 'wallet.targetQdtAmount.placeholder': '请输入想要兑换的QDT数量,当前QDT价格:{price} USDT', + 'wallet.targetQdtAmount.requiredUsdt': '需要充值:{amount} USDT', + 'wallet.rechargeAddress': '充值地址', + 'wallet.copyAddress': '复制', + 'wallet.copySuccess': '复制成功!', + 'wallet.copyFailed': '复制失败,请手动复制', + 'wallet.rechargeAddressHint': '请确保使用{chain}链向此地址充值{coin}', + 'wallet.qdtPrice.loading': '加载中...', + 'wallet.rechargeTip.new': '请选择币种和链,然后扫描下方二维码进行充值', + 'wallet.exchangeRate': '1 QDT ≈ {rate} USDT', + 'wallet.withdrawableAmount': '可提现金额', + 'wallet.totalBalance': '总余额', + 'wallet.insufficientWithdrawable': '可提现金额不足,当前可提现:{amount} QDT', + 'wallet.withdrawAddressRequired': '请输入提现地址', + 'wallet.withdrawAddressHint': '请确保地址正确,提现后无法撤销', + 'wallet.withdrawSubmitSuccess': '提现申请提交成功,请等待审核', + 'menu.footer.contactUs': '联系我们', + 'menu.footer.getSupport': '获取支持', + 'menu.footer.socialAccounts': '社交账户', + 'menu.footer.userAgreement': '用户协议', + 'menu.footer.privacyPolicy': '隐私条例', + 'menu.footer.support': 'Support', + 'menu.footer.featureRequest': 'Feature request', + 'menu.footer.email': 'Email', + 'menu.footer.liveChat': '24/7 live chat', + 'dashboard.analysis.title': 'Multi-Dimensional Analysis', + 'dashboard.analysis.subtitle': 'AI驱动的综合金融分析平台', + 'dashboard.analysis.selectSymbol': '选择或输入标的代码', + 'dashboard.analysis.selectModel': '选择模型', + 'dashboard.analysis.startAnalysis': '开始分析', + 'dashboard.analysis.history': '历史记录', + 'dashboard.analysis.tab.overview': '综合分析', + 'dashboard.analysis.tab.fundamental': '基本面', + 'dashboard.analysis.tab.technical': '技术', + 'dashboard.analysis.tab.news': '新闻', + 'dashboard.analysis.tab.sentiment': '情绪', + 'dashboard.analysis.tab.risk': '风险', + 'dashboard.analysis.tab.debate': '多空辩论', + 'dashboard.analysis.tab.decision': '最终决策', + 'dashboard.analysis.empty.selectSymbol': '选择标的开始分析', + 'dashboard.analysis.empty.selectSymbolDesc': '从自选股列表中选择或输入标的代码,获取多维度AI分析报告', + 'dashboard.analysis.empty.startAnalysis': '点击"开始分析"按钮进行多维度分析', + 'dashboard.analysis.empty.startAnalysisDesc': '我们将从基本面、技术、新闻、情绪和风险等多个维度为您提供全面的分析', + 'dashboard.analysis.empty.noData': '暂无{type}分析数据,请先进行综合分析', + 'dashboard.analysis.empty.noWatchlist': '暂无自选股', + 'dashboard.analysis.empty.noHistory': '暂无历史记录', + 'dashboard.analysis.empty.watchlistHint': '暂无自选股,请先', + 'dashboard.analysis.empty.noDebateData': '暂无辩论数据', + 'dashboard.analysis.empty.noDecisionData': '暂无决策数据', + 'dashboard.analysis.empty.selectAgent': '请选择一个 Agent 查看分析结果', + 'dashboard.analysis.loading.analyzing': '正在分析中,请稍候...', + 'dashboard.analysis.loading.fundamental': '正在分析基本面数据...', + 'dashboard.analysis.loading.technical': '正在分析技术指标...', + 'dashboard.analysis.loading.news': '正在分析新闻数据...', + 'dashboard.analysis.loading.sentiment': '正在分析市场情绪...', + 'dashboard.analysis.loading.risk': '正在评估风险...', + 'dashboard.analysis.loading.debate': '正在进行多空辩论...', + 'dashboard.analysis.loading.decision': '正在生成最终决策...', + 'dashboard.analysis.score.overall': '综合评分', + 'dashboard.analysis.score.recommendation': '投资建议', + 'dashboard.analysis.score.confidence': '置信度', + 'dashboard.analysis.dimension.fundamental': '基本面', + 'dashboard.analysis.dimension.technical': '技术', + 'dashboard.analysis.dimension.news': '新闻', + 'dashboard.analysis.dimension.sentiment': '情绪', + 'dashboard.analysis.dimension.risk': '风险', + 'dashboard.analysis.card.dimensionScores': '各维度评分', + 'dashboard.analysis.card.overviewReport': '综合分析报告', + 'dashboard.analysis.card.financialMetrics': '财务指标', + 'dashboard.analysis.card.fundamentalReport': '基本面分析报告', + 'dashboard.analysis.card.technicalIndicators': '技术指标', + 'dashboard.analysis.card.technicalReport': '技术分析报告', + 'dashboard.analysis.card.newsList': '相关新闻', + 'dashboard.analysis.card.newsReport': '新闻分析报告', + 'dashboard.analysis.card.sentimentIndicators': '情绪指标', + 'dashboard.analysis.card.sentimentReport': '情绪分析报告', + 'dashboard.analysis.card.riskMetrics': '风险指标', + 'dashboard.analysis.card.riskReport': '风险评估报告', + 'dashboard.analysis.card.bullView': '看涨观点 (Bull)', + 'dashboard.analysis.card.bearView': '看跌观点 (Bear)', + 'dashboard.analysis.card.researchConclusion': '研究员结论', + 'dashboard.analysis.card.traderPlan': '交易员计划', + 'dashboard.analysis.card.riskDebate': '风险委员会辩论', + 'dashboard.analysis.card.finalDecision': '最终决策 (Final Decision)', + 'dashboard.analysis.card.tradePlanDetail': '交易计划详情', + 'dashboard.analysis.tradingPlan.entry_price': '入场价格', + 'dashboard.analysis.tradingPlan.position_size': '仓位大小', + 'dashboard.analysis.tradingPlan.stop_loss': '止损', + 'dashboard.analysis.tradingPlan.take_profit': '止盈', + 'dashboard.analysis.label.confidence': '置信度', + 'dashboard.analysis.label.keyPoints': '核心要点', + 'dashboard.analysis.label.riskWarning': '风险提示', + 'dashboard.analysis.risk.risky': '激进派观点 (Risky)', + 'dashboard.analysis.risk.neutral': '中立派观点 (Neutral)', + 'dashboard.analysis.risk.safe': '保守派观点 (Safe)', + 'dashboard.analysis.risk.conclusion': '结论', + 'dashboard.analysis.feature.fundamental': '基本面分析', + 'dashboard.analysis.feature.technical': '技术分析', + 'dashboard.analysis.feature.news': '新闻分析', + 'dashboard.analysis.feature.sentiment': '情绪分析', + 'dashboard.analysis.feature.risk': '风险评估', + 'dashboard.analysis.watchlist.title': '我的自选股', + 'dashboard.analysis.watchlist.add': '添加', + 'dashboard.analysis.watchlist.addStock': '添加股票', + 'dashboard.analysis.modal.addStock.title': '添加自选股', + 'dashboard.analysis.modal.addStock.confirm': '确定', + 'dashboard.analysis.modal.addStock.cancel': '取消', + 'dashboard.analysis.modal.addStock.market': '市场类型', + 'dashboard.analysis.modal.addStock.marketPlaceholder': '请选择市场', + 'dashboard.analysis.modal.addStock.marketRequired': '请选择市场类型', + 'dashboard.analysis.modal.addStock.symbol': '股票代码', + 'dashboard.analysis.modal.addStock.symbolPlaceholder': '例如: AAPL, TSLA, GOOGL, 000001, BTC', + 'dashboard.analysis.modal.addStock.symbolRequired': '请输入股票代码', + 'dashboard.analysis.modal.addStock.searchPlaceholder': '搜索标的代码或名称', + 'dashboard.analysis.modal.addStock.searchOrInputPlaceholder': '搜索或输入标的代码(如:AAPL、BTC/USDT、EUR/USD)', + 'dashboard.analysis.modal.addStock.searchOrInputHint': '支持搜索数据库中的标的,或直接输入代码(系统将自动获取名称)', + 'dashboard.analysis.modal.addStock.search': '搜索', + 'dashboard.analysis.modal.addStock.searchResults': '搜索结果', + 'dashboard.analysis.modal.addStock.hotSymbols': '热门标的', + 'dashboard.analysis.modal.addStock.noHotSymbols': '暂无热门标的', + 'dashboard.analysis.modal.addStock.selectedSymbol': '已选标的', + 'dashboard.analysis.modal.addStock.pleaseSelectSymbol': '请先选择一个标的', + 'dashboard.analysis.modal.addStock.pleaseSelectOrEnterSymbol': '请先选择一个标的或输入标的代码', + 'dashboard.analysis.modal.addStock.pleaseEnterSymbol': '请输入标的代码', + 'dashboard.analysis.modal.addStock.pleaseSelectMarket': '请先选择市场类型', + 'dashboard.analysis.modal.addStock.searchFailed': '搜索失败,请稍后重试', + 'dashboard.analysis.modal.addStock.noSearchResults': '未找到匹配的标的', + 'dashboard.analysis.modal.addStock.willAutoFetchName': '系统将自动获取名称', + 'dashboard.analysis.modal.addStock.addDirectly': '直接添加', + 'dashboard.analysis.modal.addStock.nameWillBeFetched': '名称将在添加时自动获取', + 'dashboard.analysis.market.AShare': 'A股', + 'dashboard.analysis.market.USStock': '美股', + 'dashboard.analysis.market.HShare': '港股', + 'dashboard.analysis.market.Crypto': '加密货币', + 'dashboard.analysis.market.Forex': '外汇', + 'dashboard.analysis.market.Futures': '期货', + 'dashboard.analysis.modal.history.title': '历史分析记录', + 'dashboard.analysis.modal.history.viewResult': '查看结果', + 'dashboard.analysis.modal.history.completeTime': '完成时间', + 'dashboard.analysis.modal.history.error': '错误', + 'dashboard.analysis.status.pending': '待处理', + 'dashboard.analysis.status.processing': '处理中', + 'dashboard.analysis.status.completed': '已完成', + 'dashboard.analysis.status.failed': '失败', + 'dashboard.analysis.message.selectSymbol': '请先选择标的', + 'dashboard.analysis.message.taskCreated': '分析任务已创建,正在后台执行...', + 'dashboard.analysis.message.analysisComplete': '分析完成', + 'dashboard.analysis.message.analysisCompleteCache': '分析完成(使用缓存数据)', + 'dashboard.analysis.message.analysisFailed': '分析失败,请稍后重试', + 'dashboard.analysis.message.addStockSuccess': '添加成功', + 'dashboard.analysis.message.addStockFailed': '添加失败', + 'dashboard.analysis.message.removeStockSuccess': '移除成功', + 'dashboard.analysis.message.removeStockFailed': '移除失败', + 'dashboard.analysis.test': '工专路 {no} 号店', + 'dashboard.analysis.introduce': '指标说明', + 'dashboard.analysis.total-sales': '总销售额', + 'dashboard.analysis.day-sales': '日均销售额¥', + 'dashboard.analysis.visits': '访问量', + 'dashboard.analysis.visits-trend': '访问量趋势', + 'dashboard.analysis.visits-ranking': '门店访问量排名', + 'dashboard.analysis.day-visits': '日访问量', + 'dashboard.analysis.week': '周同比', + 'dashboard.analysis.day': '日同比', + 'dashboard.analysis.payments': '支付笔数', + 'dashboard.analysis.conversion-rate': '转化率', + 'dashboard.analysis.operational-effect': '运营活动效果', + 'dashboard.analysis.sales-trend': '销售趋势', + 'dashboard.analysis.sales-ranking': '门店销售额排名', + 'dashboard.analysis.all-year': '全年', + 'dashboard.analysis.all-month': '本月', + 'dashboard.analysis.all-week': '本周', + 'dashboard.analysis.all-day': '今日', + 'dashboard.analysis.search-users': '搜索用户数', + 'dashboard.analysis.per-capita-search': '人均搜索次数', + 'dashboard.analysis.online-top-search': '线上热门搜索', + 'dashboard.analysis.the-proportion-of-sales': '销售额类别占比', + 'dashboard.analysis.dropdown-option-one': '操作一', + 'dashboard.analysis.dropdown-option-two': '操作二', + 'dashboard.analysis.channel.all': '全部渠道', + 'dashboard.analysis.channel.online': '线上', + 'dashboard.analysis.channel.stores': '门店', + 'dashboard.analysis.sales': '销售额', + 'dashboard.analysis.traffic': '客流量', + 'dashboard.analysis.table.rank': '排名', + 'dashboard.analysis.table.search-keyword': '搜索关键词', + 'dashboard.analysis.table.users': '用户数', + 'dashboard.analysis.table.weekly-range': '周涨幅', + 'dashboard.indicator.selectSymbol': '选择或输入标的代码', + 'dashboard.indicator.emptyWatchlistHint': '暂无自选股,请先添加', + 'dashboard.indicator.hint.selectSymbol': '请选择标的开始分析', + 'dashboard.indicator.hint.selectSymbolDesc': '在上方搜索框中选择或输入股票代码,查看K线图表和技术指标', + 'dashboard.indicator.retry': '重试', + 'dashboard.indicator.panel.title': '技术指标', + 'dashboard.indicator.panel.realtimeOn': '关闭实时更新', + 'dashboard.indicator.panel.realtimeOff': '开启实时更新', + 'dashboard.indicator.panel.themeLight': '切换到深色主题', + 'dashboard.indicator.panel.themeDark': '切换到浅色主题', + 'dashboard.indicator.section.enabled': '已启用', + 'dashboard.indicator.section.added': '我添加的指标', + 'dashboard.indicator.section.custom': '自己创建的指标', + 'dashboard.indicator.section.bought': '我选购的指标', + 'dashboard.indicator.section.myCreated': '我创建的指标', + 'dashboard.indicator.empty': '暂无指标,请先添加或创建指标', + 'dashboard.indicator.buy': '购买指标', + 'dashboard.indicator.action.start': '启动', + 'dashboard.indicator.action.stop': '关闭', + 'dashboard.indicator.action.edit': '编辑', + 'dashboard.indicator.action.delete': '删除', + 'dashboard.indicator.action.backtest': '回测', + 'dashboard.indicator.status.normal': '正常', + 'dashboard.indicator.status.normalPermanent': '正常(永久有效)', + 'dashboard.indicator.status.expired': '已过期', + 'dashboard.indicator.expiry.permanent': '永久有效', + 'dashboard.indicator.expiry.noExpiry': '无到期时间', + 'dashboard.indicator.expiry.expired': '已过期:{date}', + 'dashboard.indicator.expiry.expiresOn': '到期时间:{date}', + 'dashboard.indicator.delete.confirmTitle': '确认删除', + 'dashboard.indicator.delete.confirmContent': '确定要删除指标"{name}"吗?此操作不可恢复。', + 'dashboard.indicator.delete.confirmOk': '删除', + 'dashboard.indicator.delete.confirmCancel': '取消', + 'dashboard.indicator.delete.success': '删除成功', + 'dashboard.indicator.delete.failed': '删除失败', + 'dashboard.indicator.save.success': '保存成功', + 'dashboard.indicator.save.failed': '保存失败', + 'dashboard.indicator.error.loadWatchlistFailed': '加载自选股失败', + 'dashboard.indicator.error.chartNotReady': '图表组件未初始化,请先选择标的并等待图表加载完成', + 'dashboard.indicator.error.chartMethodNotReady': '图表组件方法未就绪,请稍后重试', + 'dashboard.indicator.error.chartExecuteNotReady': '图表组件执行方法未就绪,请稍后重试', + 'dashboard.indicator.error.parseFailed': '无法解析Python代码', + 'dashboard.indicator.error.parseFailedCheck': '无法解析Python代码,请检查代码格式', + 'dashboard.indicator.error.addIndicatorFailed': '添加指标失败', + 'dashboard.indicator.error.runIndicatorFailed': '运行指标失败', + 'dashboard.indicator.error.pleaseLogin': '请先登录', + 'dashboard.indicator.error.loadDataFailed': '数据加载失败', + 'dashboard.indicator.error.loadDataFailedDesc': '请检查网络连接', + 'dashboard.indicator.error.pythonEngineFailed': 'Python 引擎加载失败,指标功能可能无法使用', + 'dashboard.indicator.error.chartInitFailed': '图表初始化失败', + 'dashboard.indicator.warning.enterCode': '请先输入指标代码', + 'dashboard.indicator.warning.pyodideLoadFailed': 'Python 引擎加载失败', + 'dashboard.indicator.warning.pyodideLoadFailedDesc': '您当前所在区域或网络环境无法使用该功能', + 'dashboard.indicator.warning.chartNotInitialized': '图表未初始化,无法使用画线工具', + 'dashboard.indicator.success.runIndicator': '指标运行成功', + 'dashboard.indicator.success.clearDrawings': '已清除所有画线', + 'dashboard.indicator.sma': 'SMA (均线组合)', + 'dashboard.indicator.ema': 'EMA (指数均线组合)', + 'dashboard.indicator.rsi': 'RSI (相对强弱)', + 'dashboard.indicator.macd': 'MACD', + 'dashboard.indicator.bb': '布林带 (Bollinger Bands)', + 'dashboard.indicator.atr': 'ATR (平均真实波幅)', + 'dashboard.indicator.cci': 'CCI (商品通道指数)', + 'dashboard.indicator.williams': 'Williams %R (威廉指标)', + 'dashboard.indicator.mfi': 'MFI (资金流量指标)', + 'dashboard.indicator.adx': 'ADX (平均趋向指数)', + 'dashboard.indicator.obv': 'OBV (能量潮)', + 'dashboard.indicator.adosc': 'ADOSC (积累/派发振荡器)', + 'dashboard.indicator.ad': 'AD (积累/派发线)', + 'dashboard.indicator.kdj': 'KDJ (随机指标)', + 'dashboard.indicator.signal.buy': 'BUY', + 'dashboard.indicator.signal.sell': 'SELL', + 'dashboard.indicator.signal.supertrendBuy': 'SuperTrend Buy', + 'dashboard.indicator.signal.supertrendSell': 'SuperTrend Sell', + 'dashboard.indicator.chart.kline': 'K线', + 'dashboard.indicator.chart.volume': '成交量', + 'dashboard.indicator.chart.uptrend': 'Up Trend', + 'dashboard.indicator.chart.downtrend': 'Down Trend', + 'dashboard.indicator.tooltip.time': '时间', + 'dashboard.indicator.tooltip.open': '开', + 'dashboard.indicator.tooltip.close': '收', + 'dashboard.indicator.tooltip.high': '高', + 'dashboard.indicator.tooltip.low': '低', + 'dashboard.indicator.tooltip.volume': '成交量', + 'dashboard.indicator.drawing.line': '线段', + 'dashboard.indicator.drawing.horizontalLine': '水平线', + 'dashboard.indicator.drawing.verticalLine': '垂直线', + 'dashboard.indicator.drawing.ray': '射线', + 'dashboard.indicator.drawing.straightLine': '直线', + 'dashboard.indicator.drawing.parallelLine': '平行线', + 'dashboard.indicator.drawing.priceLine': '价格线', + 'dashboard.indicator.drawing.priceChannel': '价格通道', + 'dashboard.indicator.drawing.fibonacciLine': '斐波那契线', + 'dashboard.indicator.drawing.clearAll': '清除所有画线', + 'dashboard.indicator.market.AShare': 'A股', + 'dashboard.indicator.market.USStock': '美股', + 'dashboard.indicator.market.HShare': '港股', + 'dashboard.indicator.market.Crypto': '加密货币', + 'dashboard.indicator.market.Forex': '外汇', + 'dashboard.indicator.market.Futures': '期货', + 'dashboard.indicator.create': '创建指标', + 'dashboard.indicator.editor.title': '创建/编辑指标', + 'dashboard.indicator.editor.name': '指标名称', + 'dashboard.indicator.editor.nameRequired': '请输入指标名称', + 'dashboard.indicator.editor.namePlaceholder': '请输入指标名称', + 'dashboard.indicator.editor.description': '指标描述', + 'dashboard.indicator.editor.descriptionPlaceholder': '请输入指标描述(可选)', + 'dashboard.indicator.editor.code': '指标脚本(Python)', + 'dashboard.indicator.editor.codeRequired': '请输入指标代码', + 'dashboard.indicator.editor.codePlaceholder': '请输入Python代码', + 'dashboard.indicator.editor.run': '运行', + 'dashboard.indicator.editor.runHint': '点击运行按钮可以在K线图上预览指标效果', + 'dashboard.indicator.editor.guide': '开发指南', + 'dashboard.indicator.editor.guideTitle': 'Python 指标开发指南', + 'dashboard.indicator.editor.save': '保存', + 'dashboard.indicator.editor.cancel': '取消', + 'dashboard.indicator.editor.unnamed': '未命名指标', + 'dashboard.indicator.editor.publishToCommunity': '发布到社区', + 'dashboard.indicator.editor.publishToCommunityHint': '发布后,其他用户可以在社区中查看和使用您的指标', + 'dashboard.indicator.editor.indicatorType': '指标类型', + 'dashboard.indicator.editor.indicatorTypeRequired': '请选择指标类型', + 'dashboard.indicator.editor.indicatorTypePlaceholder': '请选择指标类型', + 'dashboard.indicator.editor.previewImage': '预览图', + 'dashboard.indicator.editor.uploadImage': '上传图片', + 'dashboard.indicator.editor.previewImageHint': '建议尺寸:800x400,大小不超过2MB', + 'dashboard.indicator.editor.pricing': '售价(QDT)', + 'dashboard.indicator.editor.pricingType.free': '免费', + 'dashboard.indicator.editor.pricingType.permanent': '永久', + 'dashboard.indicator.editor.pricingType.monthly': '月付', + 'dashboard.indicator.editor.price': '价格', + 'dashboard.indicator.editor.priceRequired': '请输入价格', + 'dashboard.indicator.editor.pricePlaceholder': '请输入价格', + 'dashboard.indicator.editor.pricingHint': '免费指标虽然价格为0,但平台会根据您的指标使用量和好评率给予奖励(QDT)', + 'dashboard.indicator.editor.aiGenerate': '智能生成', + 'dashboard.indicator.editor.aiPromptPlaceholder': '描述清楚你的信号逻辑(只输出 buy/sell)和绘图(plots)。仓位/风控/加减仓在回测配置里设置。', + 'dashboard.indicator.editor.aiGenerateBtn': 'AI生成代码', + 'dashboard.indicator.editor.aiPromptRequired': '请输入您的想法', + 'dashboard.indicator.editor.aiGenerateSuccess': '代码生成成功', + 'dashboard.indicator.editor.aiGenerateError': '代码生成失败,请稍后重试', + 'dashboard.indicator.boundary.message': '提示:指标脚本只负责“计算 + 绘图 + buy/sell 信号”;仓位、风控、加减仓、手续费/滑点属于策略执行配置。', + 'dashboard.indicator.boundary.indicatorRule': "指标脚本请只输出 buy/sell(并设置 df['buy']/df['sell'])。不要在脚本里写仓位管理、止盈止损、加减仓。", + 'dashboard.indicator.boundary.backtestRule': '规则:同一根K线若出现主信号(buy/sell→开/平仓/反手),本K线将跳过所有加仓与减仓。', + 'dashboard.indicator.backtest.title': '指标回测', + 'dashboard.indicator.backtest.config': '回测参数', + 'dashboard.indicator.backtest.startDate': '开始日期', + 'dashboard.indicator.backtest.endDate': '结束日期', + 'dashboard.indicator.backtest.selectStartDate': '选择开始日期', + 'dashboard.indicator.backtest.selectEndDate': '选择结束日期', + 'dashboard.indicator.backtest.startDateRequired': '请选择开始日期', + 'dashboard.indicator.backtest.endDateRequired': '请选择结束日期', + 'dashboard.indicator.backtest.initialCapital': '初始资金', + 'dashboard.indicator.backtest.initialCapitalRequired': '请输入初始资金', + 'dashboard.indicator.backtest.commission': '手续费率', + 'dashboard.indicator.backtest.leverage': '杠杆倍率', + 'dashboard.indicator.backtest.tradeDirection': '交易方向', + 'dashboard.indicator.backtest.longOnly': '做多', + 'dashboard.indicator.backtest.shortOnly': '做空', + 'dashboard.indicator.backtest.both': '双向', + 'dashboard.indicator.backtest.run': '开始回测', + 'dashboard.indicator.backtest.rerun': '重新回测', + 'dashboard.indicator.backtest.close': '关闭', + 'dashboard.indicator.backtest.running': '正在进行指标回测...', + 'dashboard.indicator.backtest.results': '回测结果', + 'dashboard.indicator.backtest.totalReturn': '总收益率', + 'dashboard.indicator.backtest.annualReturn': '年化收益', + 'dashboard.indicator.backtest.maxDrawdown': '最大回撤', + 'dashboard.indicator.backtest.sharpeRatio': '夏普比率', + 'dashboard.indicator.backtest.winRate': '胜率', + 'dashboard.indicator.backtest.profitFactor': '盈亏比', + 'dashboard.indicator.backtest.totalTrades': '交易次数', + 'dashboard.indicator.totalReturn': '总收益率', + 'dashboard.indicator.annualReturn': '年化收益率', + 'dashboard.indicator.maxDrawdown': '最大回撤', + 'dashboard.indicator.sharpeRatio': '夏普比率', + 'dashboard.indicator.winRate': '胜率', + 'dashboard.indicator.profitFactor': '盈亏比', + 'dashboard.indicator.totalTrades': '总交易次数', + 'dashboard.indicator.backtest.totalCommission': '总手续费', + 'dashboard.indicator.backtest.equityCurve': '收益曲线', + 'dashboard.indicator.backtest.strategy': '指标收益', + 'dashboard.indicator.backtest.benchmark': '基准收益', + 'dashboard.indicator.backtest.tradeHistory': '交易记录', + 'dashboard.indicator.backtest.tradeTime': '时间', + 'dashboard.indicator.backtest.tradeType': '类型', + 'dashboard.indicator.backtest.buy': '买入', + 'dashboard.indicator.backtest.sell': '卖出', + 'dashboard.indicator.backtest.liquidation': '爆仓', + 'dashboard.indicator.backtest.openLong': '开多', + 'dashboard.indicator.backtest.closeLong': '平多', + 'dashboard.indicator.backtest.closeLongStop': '平多(止损)', + 'dashboard.indicator.backtest.closeLongProfit': '平多(止盈)', + 'dashboard.indicator.backtest.addLong': '加多仓', + 'dashboard.indicator.backtest.openShort': '开空', + 'dashboard.indicator.backtest.closeShort': '平空', + 'dashboard.indicator.backtest.closeShortStop': '平空(止损)', + 'dashboard.indicator.backtest.closeShortProfit': '平空(止盈)', + 'dashboard.indicator.backtest.addShort': '加空仓', + 'dashboard.indicator.backtest.closeLongTrailing': '平多(移动止损/止盈)', + 'dashboard.indicator.backtest.reduceLong': '多头减仓', + 'dashboard.indicator.backtest.closeShortTrailing': '平空(移动止损/止盈)', + 'dashboard.indicator.backtest.reduceShort': '空头减仓', + 'dashboard.indicator.backtest.price': '价格', + 'dashboard.indicator.backtest.amount': '数量', + 'dashboard.indicator.backtest.balance': '账户余额', + 'dashboard.indicator.backtest.profit': '盈亏', + 'dashboard.indicator.backtest.success': '回测完成', + 'dashboard.indicator.backtest.failed': '回测失败,请稍后重试', + 'dashboard.indicator.backtest.noIndicatorCode': '该指标没有可回测的代码', + 'dashboard.indicator.backtest.noSymbol': '请先选择交易标的', + 'dashboard.indicator.backtest.dateRangeExceeded': '回测时间范围超出限制:{timeframe}周期最多可回测{maxRange}', + 'dashboard.indicator.backtest.dateRangeExceededDays': '回测时间范围超出限制:{timeframe}周期最多可回测{maxRange}({maxDays}天)', + 'dashboard.indicator.backtest.metaLine': '标的:{symbol} | 市场:{market} | 周期:{timeframe}', + 'dashboard.indicator.backtest.savedRunId': '回测已保存,记录ID:{id}', + 'dashboard.indicator.backtest.historyTitle': '回测记录', + 'dashboard.indicator.backtest.historyRefresh': '刷新', + 'dashboard.indicator.backtest.historyView': '查看', + 'dashboard.indicator.backtest.historyNoData': '暂无回测记录', + 'dashboard.indicator.backtest.historyUseCurrent': '仅当前币种/周期', + 'dashboard.indicator.backtest.historyFilterSymbol': '币种(Symbol)', + 'dashboard.indicator.backtest.historyFilterTimeframe': '周期(Timeframe)', + 'dashboard.indicator.backtest.historyApply': '应用筛选', + 'dashboard.indicator.backtest.historyAIAnalyze': 'AI分析', + 'dashboard.indicator.backtest.historyAIAnalyzeTitle': 'AI 分析建议', + 'dashboard.indicator.backtest.historyNoAIResult': '暂无 AI 分析结果', + 'dashboard.indicator.backtest.historyRunId': '记录ID', + 'dashboard.indicator.backtest.historyCreatedAt': '时间', + 'dashboard.indicator.backtest.historyRange': '区间', + 'dashboard.indicator.backtest.historyStatus': '状态', + 'dashboard.indicator.backtest.historyStatusSuccess': '成功', + 'dashboard.indicator.backtest.historyStatusFailed': '失败', + 'dashboard.indicator.backtest.historyActions': '操作', + 'dashboard.indicator.backtest.prev': '上一步', + 'dashboard.indicator.backtest.next': '下一步', + 'dashboard.indicator.backtest.steps.strategy.title': '执行配置', + 'dashboard.indicator.backtest.steps.strategy.desc': '仓位/风控/加减仓(策略层)', + 'dashboard.indicator.backtest.steps.trading.title': '回测环境', + 'dashboard.indicator.backtest.steps.trading.desc': '时间/资金/费率/杠杆/滑点', + 'dashboard.indicator.backtest.steps.results.title': '结果', + 'dashboard.indicator.backtest.steps.results.desc': '统计与交易明细', + 'dashboard.indicator.backtest.panel.risk': '执行配置:风控(止损/移动止盈)', + 'dashboard.indicator.backtest.panel.scale': '执行配置:加仓(顺势/逆势)', + 'dashboard.indicator.backtest.panel.reduce': '执行配置:减仓(顺势/逆势)', + 'dashboard.indicator.backtest.panel.position': '执行配置:开仓仓位', + 'dashboard.indicator.backtest.field.stopLossPct': '止损(%)', + 'dashboard.indicator.backtest.field.takeProfitPct': '止盈(%)', + 'dashboard.indicator.backtest.field.trailingEnabled': '移动止盈', + 'dashboard.indicator.backtest.field.trailingStopPct': '移动回撤(%)', + 'dashboard.indicator.backtest.field.trailingActivationPct': '移动止盈激活(%)', + 'dashboard.indicator.backtest.field.slippage': '滑点(%)', + 'dashboard.indicator.backtest.field.trendAddEnabled': '顺势加仓', + 'dashboard.indicator.backtest.field.dcaAddEnabled': '逆势加仓', + 'dashboard.indicator.backtest.field.trendAddStepPct': '加仓触发(%)', + 'dashboard.indicator.backtest.field.dcaAddStepPct': '加仓触发(%)', + 'dashboard.indicator.backtest.field.trendAddSizePct': '每次加仓资金占比(%)', + 'dashboard.indicator.backtest.field.dcaAddSizePct': '每次加仓资金占比(%)', + 'dashboard.indicator.backtest.field.trendAddMaxTimes': '最多加仓次数', + 'dashboard.indicator.backtest.field.dcaAddMaxTimes': '最多加仓次数', + 'dashboard.indicator.backtest.field.trendReduceEnabled': '顺势减仓', + 'dashboard.indicator.backtest.field.adverseReduceEnabled': '逆势减仓', + 'dashboard.indicator.backtest.field.trendReduceStepPct': '顺势触发(%)', + 'dashboard.indicator.backtest.field.adverseReduceStepPct': '逆势触发(%)', + 'dashboard.indicator.backtest.field.trendReduceSizePct': '每次减仓比例(%)', + 'dashboard.indicator.backtest.field.adverseReduceSizePct': '每次逆势减仓比例(%)', + 'dashboard.indicator.backtest.field.trendReduceMaxTimes': '最多顺势减仓次数', + 'dashboard.indicator.backtest.field.adverseReduceMaxTimes': '最多逆势减仓次数', + 'dashboard.indicator.backtest.field.entryPct': '开仓资金占比(%)', + 'dashboard.indicator.backtest.field.minOrderPct': '单次最小资金占比(%)(可选)', + 'dashboard.indicator.backtest.hint.entryPctMax': '最大可开仓:{maxPct}%(为后续加仓预留资金)', + 'dashboard.docs.title': '文档中心', + 'dashboard.docs.search.placeholder': '搜索文档...', + 'dashboard.docs.category.all': '全部', + 'dashboard.docs.category.guide': '开发指南', + 'dashboard.docs.category.api': 'API文档', + 'dashboard.docs.category.tutorial': '教程', + 'dashboard.docs.category.faq': '常见问题', + 'dashboard.docs.featured.title': '推荐文档', + 'dashboard.docs.featured.tag': '推荐', + 'dashboard.docs.list.views': '浏览', + 'dashboard.docs.list.author': '作者', + 'dashboard.docs.list.empty': '暂无文档', + 'dashboard.docs.list.backToAll': '返回全部文档', + 'dashboard.docs.list.total': '共 {count} 篇文档', + 'dashboard.docs.detail.back': '返回文档列表', + 'dashboard.docs.detail.updatedAt': '更新于', + 'dashboard.docs.detail.related': '相关文档', + 'dashboard.docs.detail.notFound': '文档不存在', + 'dashboard.docs.detail.error': '文档不存在或已被删除', + 'dashboard.docs.search.result': '搜索结果', + 'dashboard.docs.search.keyword': '关键词', + 'community.filter.indicatorType': '指标类型', + 'community.filter.all': '全部', + 'community.filter.other': '其他选项', + 'community.filter.pricing': '定价类型', + 'community.filter.allPricing': '全部定价', + 'community.filter.sortBy': '排序方式', + 'community.filter.search': '搜索', + 'community.filter.searchPlaceholder': '搜索指标名称', + 'community.indicatorType.trend': '趋势型', + 'community.indicatorType.momentum': '动量型', + 'community.indicatorType.volatility': '波动率', + 'community.indicatorType.volume': '成交量', + 'community.indicatorType.custom': '自定义', + 'community.pricing.free': '免费', + 'community.pricing.paid': '付费', + 'community.sort.downloads': '下载量', + 'community.sort.rating': '评分', + 'community.sort.newest': '最新发布', + 'community.pagination.total': '共 {total} 个指标', + 'community.noDescription': '暂无描述', + 'community.detail.type': '类型', + 'community.detail.pricing': '定价', + 'community.detail.rating': '评分', + 'community.detail.downloads': '下载量', + 'community.detail.author': '作者', + 'community.detail.description': '简介', + 'community.detail.detailContent': '详细说明', + 'community.detail.backtestStats': '回测统计', + 'community.action.purchase': '购买指标', + 'community.action.addToMyIndicators': '添加到我的指标', + 'community.action.favorite': '收藏', + 'community.action.unfavorite': '取消收藏', + 'community.action.buyNow': '立即购买', + 'community.action.renew': '续费', + 'community.purchase.price': '价格', + 'community.purchase.permanent': '永久', + 'community.purchase.monthly': '月', + 'community.purchase.confirmBuy': '确认购买该指标({price} QDT)?', + 'community.purchase.confirmRenew': '确认续费该指标({price} QDT/月)?', + 'community.purchase.confirmFree': '确认添加该免费指标?', + 'community.purchase.confirmTitle': '确认操作', + 'community.purchase.owned': '您已购买该指标(永久有效)', + 'community.purchase.ownIndicator': '这是您发布的指标', + 'community.purchase.expired': '您的订阅已过期', + 'community.purchase.expiresOn': '到期时间:{date}', + 'community.tabs.detail': '指标详情', + 'community.tabs.backtest': '回测数据', + 'community.tabs.ratings': '用户评价', + 'community.rating.myRating': '我的评分', + 'community.rating.stars': '{count} 星', + 'community.rating.commentPlaceholder': '分享您的使用体验...', + 'community.rating.submit': '提交评分', + 'community.rating.modify': '修改', + 'community.rating.saveModify': '保存修改', + 'community.rating.cancel': '取消', + 'community.rating.selectRating': '请选择评分', + 'community.rating.success': '评分成功', + 'community.rating.modifySuccess': '修改评价成功', + 'community.rating.failed': '评分失败', + 'community.rating.noRatings': '暂无评价', + 'community.backtest.note': '以上数据为历史回测结果,仅供参考,不代表未来收益', + 'community.backtest.noData': '暂无回测数据', + 'community.backtest.uploadHint': '作者尚未上传回测数据', + 'community.message.loadFailed': '加载指标列表失败', + 'community.message.purchaseProcessing': '正在处理购买请求...', + 'community.message.downloadSuccess': '指标已添加到我的指标列表', + 'community.message.favoriteSuccess': '收藏成功', + 'community.message.unfavoriteSuccess': '取消收藏成功', + 'community.message.operationSuccess': '操作成功', + 'community.message.operationFailed': '操作失败', + 'dashboard.totalEquity': '总权益', + 'dashboard.totalPnL': '总盈亏', + 'dashboard.aiStrategies': 'AI 策略', + 'dashboard.indicatorStrategies': '指标策略', + 'dashboard.running': '运行中', + 'dashboard.pnlHistory': '历史盈亏', + 'dashboard.strategyPerformance': '策略盈亏占比', + 'dashboard.recentTrades': '最近交易', + 'dashboard.currentPositions': '当前持仓', + 'dashboard.table.time': '时间', + 'dashboard.table.strategy': '策略名称', + 'dashboard.table.symbol': '标的', + 'dashboard.table.type': '类型', + 'dashboard.table.side': '方向', + 'dashboard.table.size': '持仓量', + 'dashboard.table.entryPrice': '开仓均价', + 'dashboard.table.price': '价格', + 'dashboard.table.amount': '数量', + 'dashboard.table.profit': '盈亏', + 'dashboard.pendingOrders': '订单执行记录', + 'dashboard.totalOrders': '共 {total} 条', + 'dashboard.viewError': '查看错误', + 'dashboard.filled': '已成交', + 'dashboard.orderTable.time': '创建时间', + 'dashboard.orderTable.strategy': '策略', + 'dashboard.orderTable.symbol': '交易对', + 'dashboard.orderTable.signalType': '信号类型', + 'dashboard.orderTable.amount': '数量', + 'dashboard.orderTable.price': '成交价', + 'dashboard.orderTable.status': '状态', + 'dashboard.orderTable.executedAt': '执行时间', + 'dashboard.orderTable.exchange': '交易所', + 'dashboard.orderTable.notify': '通知方式', + 'dashboard.orderTable.actions': '操作', + 'dashboard.orderTable.delete': '删除', + 'dashboard.orderTable.deleteConfirm': '确认删除这条记录?', + 'dashboard.orderTable.deleteSuccess': '删除成功', + 'dashboard.orderTable.deleteFailed': '删除失败', + 'dashboard.signalType.openLong': '开多', + 'dashboard.signalType.openShort': '开空', + 'dashboard.signalType.closeLong': '平多', + 'dashboard.signalType.closeShort': '平空', + 'dashboard.signalType.addLong': '加多', + 'dashboard.signalType.addShort': '加空', + 'dashboard.status.pending': '待处理', + 'dashboard.status.processing': '处理中', + 'dashboard.status.completed': '已完成', + 'dashboard.status.failed': '失败', + 'dashboard.status.cancelled': '已取消', + 'dashboard.table.pnl': '未实现盈亏', + 'form.basic-form.basic.title': '基础表单', + 'form.basic-form.basic.description': '表单页用于向用户收集或验证信息,基础表单常见于数据项较少的表单场景。', + 'form.basic-form.title.label': '标题', + 'form.basic-form.title.placeholder': '给目标起个名字', + 'form.basic-form.title.required': '请输入标题', + 'form.basic-form.date.label': '起止日期', + 'form.basic-form.placeholder.start': '开始日期', + 'form.basic-form.placeholder.end': '结束日期', + 'form.basic-form.date.required': '请选择起止日期', + 'form.basic-form.goal.label': '目标描述', + 'form.basic-form.goal.placeholder': '请输入你的阶段性工作目标', + 'form.basic-form.goal.required': '请输入目标描述', + 'form.basic-form.standard.label': '衡量标准', + 'form.basic-form.standard.placeholder': '请输入衡量标准', + 'form.basic-form.standard.required': '请输入衡量标准', + 'form.basic-form.client.label': '客户', + 'form.basic-form.client.required': '请描述你服务的客户', + 'form.basic-form.label.tooltip': '目标的服务对象', + 'form.basic-form.client.placeholder': '请描述你服务的客户,内部客户直接 @姓名/工号', + 'form.basic-form.invites.label': '邀评人', + 'form.basic-form.invites.placeholder': '请直接 @姓名/工号,最多可邀请 5 人', + 'form.basic-form.weight.label': '权重', + 'form.basic-form.weight.placeholder': '请输入', + 'form.basic-form.public.label': '目标公开', + 'form.basic-form.label.help': '客户、邀评人默认被分享', + 'form.basic-form.radio.public': '公开', + 'form.basic-form.radio.partially-public': '部分公开', + 'form.basic-form.radio.private': '不公开', + 'form.basic-form.publicUsers.placeholder': '公开给', + 'form.basic-form.option.A': '同事一', + 'form.basic-form.option.B': '同事二', + 'form.basic-form.option.C': '同事三', + 'form.basic-form.email.required': '请输入邮箱地址!', + 'form.basic-form.email.wrong-format': '邮箱地址格式错误!', + 'form.basic-form.userName.required': '请输入用户名!', + 'form.basic-form.password.required': '请输入密码!', + 'form.basic-form.password.twice': '两次输入的密码不匹配!', + 'form.basic-form.strength.msg': '请至少输入 6 个字符。请不要使用容易被猜到的密码。', + 'form.basic-form.strength.strong': '强度:强', + 'form.basic-form.strength.medium': '强度:中', + 'form.basic-form.strength.short': '强度:太短', + 'form.basic-form.confirm-password.required': '请确认密码!', + 'form.basic-form.phone-number.required': '请输入手机号!', + 'form.basic-form.phone-number.wrong-format': '手机号格式错误!', + 'form.basic-form.verification-code.required': '请输入验证码!', + 'form.basic-form.form.get-captcha': '获取验证码', + 'form.basic-form.captcha.second': '秒', + 'form.basic-form.form.optional': '(选填)', + 'form.basic-form.form.submit': '提交', + 'form.basic-form.form.save': '保存', + 'form.basic-form.email.placeholder': '邮箱', + 'form.basic-form.password.placeholder': '至少6位密码,区分大小写', + 'form.basic-form.confirm-password.placeholder': '确认密码', + 'form.basic-form.phone-number.placeholder': '手机号', + 'form.basic-form.verification-code.placeholder': '验证码', + 'result.success.title': '提交成功', + 'result.success.description': '提交结果页用于反馈一系列操作任务的处理结果, 如果仅是简单操作,使用 Message 全局提示反馈即可。 本文字区域可以展示简单的补充说明,如果有类似展示 “单据”的需求,下面这个灰色区域可以呈现比较复杂的内容。', + 'result.success.operate-title': '项目名称', + 'result.success.operate-id': '项目 ID', + 'result.success.principal': '负责人', + 'result.success.operate-time': '生效时间', + 'result.success.step1-title': '创建项目', + 'result.success.step1-operator': '曲丽丽', + 'result.success.step2-title': '部门初审', + 'result.success.step2-operator': '周毛毛', + 'result.success.step2-extra': '催一下', + 'result.success.step3-title': '财务复核', + 'result.success.step4-title': '完成', + 'result.success.btn-return': '返回列表', + 'result.success.btn-project': '查看项目', + 'result.success.btn-print': '打印', + 'result.fail.error.title': '提交失败', + 'result.fail.error.description': '请核对并修改以下信息后,再重新提交。', + 'result.fail.error.hint-title': '您提交的内容有如下错误:', + 'result.fail.error.hint-text1': '您的账户已被冻结', + 'result.fail.error.hint-btn1': '立即解冻', + 'result.fail.error.hint-text2': '您的账户还不具备申请资格', + 'result.fail.error.hint-btn2': '立即升级', + 'result.fail.error.btn-text': '返回修改', + 'account.settings.menuMap.custom': '个性化', + 'account.settings.menuMap.binding': '账号绑定', + 'account.settings.basic.avatar': '头像', + 'account.settings.basic.change-avatar': '更换头像', + 'account.settings.basic.email': '邮箱', + 'account.settings.basic.email-message': '请输入您的邮箱!', + 'account.settings.basic.nickname': '昵称', + 'account.settings.basic.nickname-message': '请输入您的昵称!', + 'account.settings.basic.profile': '个人简介', + 'account.settings.basic.profile-message': '请输入个人简介!', + 'account.settings.basic.profile-placeholder': '个人简介', + 'account.settings.basic.country': '国家/地区', + 'account.settings.basic.country-message': '请输入您的国家或地区!', + 'account.settings.basic.geographic': '所在省市', + 'account.settings.basic.geographic-message': '请输入您的所在省市!', + 'account.settings.basic.address': '街道地址', + 'account.settings.basic.address-message': '请输入您的街道地址!', + 'account.settings.basic.phone': '联系电话', + 'account.settings.basic.phone-message': '请输入您的联系电话!', + 'account.settings.basic.update': '更新基本信息', + 'account.settings.basic.update.success': '更新基本信息成功', + 'account.settings.security.strong': '强', + 'account.settings.security.medium': '中', + 'account.settings.security.weak': '弱', + 'account.settings.security.password': '账户密码', + 'account.settings.security.password-description': '当前密码强度:', + 'account.settings.security.phone': '密保手机', + 'account.settings.security.phone-description': '已绑定手机:', + 'account.settings.security.question': '密保问题', + 'account.settings.security.question-description': '未设置密保问题,密保问题可有效保护账户安全', + 'account.settings.security.email': '绑定邮箱', + 'account.settings.security.email-description': '已绑定邮箱:', + 'account.settings.security.mfa': 'MFA 设备', + 'account.settings.security.mfa-description': '未绑定 MFA 设备,绑定后,可以进行二次确认', + 'account.settings.security.modify': '修改', + 'account.settings.security.set': '设置', + 'account.settings.security.bind': '绑定', + 'account.settings.binding.taobao': '绑定淘宝', + 'account.settings.binding.taobao-description': '当前未绑定淘宝账号', + 'account.settings.binding.alipay': '绑定支付宝', + 'account.settings.binding.alipay-description': '当前未绑定支付宝账号', + 'account.settings.binding.dingding': '绑定钉钉', + 'account.settings.binding.dingding-description': '当前未绑定钉钉账号', + 'account.settings.binding.bind': '绑定', + 'account.settings.notification.password': '账户密码', + 'account.settings.notification.password-description': '其他用户的消息将以站内信的形式通知', + 'account.settings.notification.messages': '系统消息', + 'account.settings.notification.messages-description': '系统消息将以站内信的形式通知', + 'account.settings.notification.todo': '待办任务', + 'account.settings.notification.todo-description': '待办任务将以站内信的形式通知', + 'account.settings.settings.open': '开', + 'account.settings.settings.close': '关', + 'trading-assistant.title': '交易助手', + 'trading-assistant.strategyList': '策略列表', + 'trading-assistant.createStrategy': '创建策略', + 'trading-assistant.noStrategy': '暂无策略', + 'trading-assistant.selectStrategy': '请从左侧选择一个策略查看详情', + 'trading-assistant.startStrategy': '启动策略', + 'trading-assistant.stopStrategy': '停止策略', + 'trading-assistant.editStrategy': '编辑策略', + 'trading-assistant.deleteStrategy': '删除策略', + 'trading-assistant.status.running': '运行中', + 'trading-assistant.status.stopped': '已停止', + 'trading-assistant.status.error': '错误', + 'trading-assistant.strategyType.IndicatorStrategy': '技术指标策略', + 'trading-assistant.strategyType.PromptBasedStrategy': '提示词策略', + 'trading-assistant.strategyType.GridStrategy': '网格策略', + 'trading-assistant.tabs.tradingRecords': '交易记录', + 'trading-assistant.tabs.positions': '持仓记录', + 'trading-assistant.tabs.equityCurve': '净值曲线', + 'trading-assistant.form.step1': '选择指标', + 'trading-assistant.form.step2': '交易所配置', + 'trading-assistant.form.step3': '策略参数', + 'trading-assistant.form.step2Params': '参数配置', + 'trading-assistant.form.step3Signal': '信号推送', + 'trading-assistant.form.marketCategory': '交易种类', + 'trading-assistant.form.marketCategoryHint': '先选择交易种类,用于判断是否支持实盘接入(目前仅加密可选实盘)。', + 'trading-assistant.market.AShare': 'A股', + 'trading-assistant.market.USStock': '美股', + 'trading-assistant.market.Crypto': '加密货币', + 'trading-assistant.market.Forex': '外汇', + 'trading-assistant.form.indicator': '选择指标', + 'trading-assistant.form.indicatorHint': '只能选择您已购买或创建的技术指标', + 'trading-assistant.form.qdtCostHints': '使用策略将消耗QDT,请确保账户有足够的QDT余额', + 'trading-assistant.form.indicatorDescription': '指标描述', + 'trading-assistant.form.noDescription': '暂无描述', + 'trading-assistant.form.exchange': '选择交易所', + 'trading-assistant.form.apiKey': 'API Key', + 'trading-assistant.form.secretKey': 'Secret Key', + 'trading-assistant.form.passphrase': 'Passphrase', + 'trading-assistant.form.testConnection': '测试连接', + 'trading-assistant.form.strategyName': '策略名称', + 'trading-assistant.form.symbol': '交易对', + 'trading-assistant.form.symbolHint': '标的格式取决于所选市场', + 'trading-assistant.form.symbolHintCrypto': '加密:例如 BTC/USDT', + 'trading-assistant.form.symbolHintGeneral': '请输入所选市场的标的代码(如 600519、AAPL、EURUSD)。', + 'trading-assistant.form.initialCapital': '投入金额', + 'trading-assistant.form.marketType': '市场类型', + 'trading-assistant.form.marketTypeFutures': '合约', + 'trading-assistant.form.marketTypeSpot': '现货', + 'trading-assistant.form.marketTypeHint': '合约支持双向交易和杠杆,现货仅支持做多且杠杆固定为1倍', + 'trading-assistant.form.leverage': '杠杆倍数', + 'trading-assistant.form.leverageHint': '合约:1-125倍,现货:固定1倍', + 'trading-assistant.form.spotLeverageFixed': '现货交易杠杆固定为1倍', + 'trading-assistant.form.spotOnlyLongHint': '现货交易仅支持做多', + 'trading-assistant.form.tradeDirection': '交易方向', + 'trading-assistant.form.tradeDirectionLong': '仅做多', + 'trading-assistant.form.tradeDirectionShort': '仅做空', + 'trading-assistant.form.tradeDirectionBoth': '双向交易', + 'trading-assistant.form.timeframe': '时间周期', + 'trading-assistant.form.klinePeriod': 'K线周期', + 'trading-assistant.form.timeframe1m': '1分钟', + 'trading-assistant.form.timeframe5m': '5分钟', + 'trading-assistant.form.timeframe15m': '15分钟', + 'trading-assistant.form.timeframe30m': '30分钟', + 'trading-assistant.form.timeframe1H': '1小时', + 'trading-assistant.form.timeframe4H': '4小时', + 'trading-assistant.form.timeframe1D': '1天', + 'trading-assistant.form.selectStrategyType': '选择策略类型', + 'trading-assistant.form.indicatorStrategy': '指标策略', + 'trading-assistant.form.indicatorStrategyDesc': '基于技术指标的自动化交易策略', + 'trading-assistant.form.aiStrategy': 'AI策略', + 'trading-assistant.form.aiStrategyDesc': '基于AI智能决策的自动化交易策略', + 'trading-assistant.form.enableAiFilter': '启用AI智能决策过滤', + 'trading-assistant.form.enableAiFilterHint': '启用后,指标信号将经过AI智能过滤,提高交易质量', + 'trading-assistant.form.aiFilterPrompt': '自定义提示词', + 'trading-assistant.form.aiFilterPromptHint': '为AI过滤提供自定义指令,留空则使用系统默认', + 'trading-assistant.validation.strategyTypeRequired': '请选择策略类型', + 'trading-assistant.validation.marketCategoryRequired': '请选择交易种类', + 'trading-assistant.form.advancedSettings': '高级设置', + 'trading-assistant.form.orderMode': '下单模式', + 'trading-assistant.form.orderModeMaker': '挂单(Maker)', + 'trading-assistant.form.orderModeTaker': '市价(Taker)', + 'trading-assistant.form.orderModeHint': '挂单模式使用限价单,手续费更低;市价模式立即成交,手续费较高', + 'trading-assistant.form.makerWaitSec': '挂单等待时间(秒)', + 'trading-assistant.form.makerWaitSecHint': '挂单后等待成交的时间,超时后取消并重试', + 'trading-assistant.form.makerRetries': '挂单重试次数', + 'trading-assistant.form.makerRetriesHint': '挂单未成交时的最大重试次数', + 'trading-assistant.form.fallbackToMarket': '挂单失败降级市价', + 'trading-assistant.form.fallbackToMarketHint': '开/平仓挂单未成交时,是否降级为市价单以确保成交', + 'trading-assistant.form.marginMode': '保证金模式', + 'trading-assistant.form.marginModeCross': '全仓', + 'trading-assistant.form.marginModeIsolated': '逐仓', + 'trading-assistant.form.stopLossPct': '止损比例(%)', + 'trading-assistant.form.stopLossPctHint': '设置止损百分比,0表示不启用止损', + 'trading-assistant.form.takeProfitPct': '止盈比例(%)', + 'trading-assistant.form.takeProfitPctHint': '设置止盈百分比,0表示不启用止盈', + 'trading-assistant.form.commission': '手续费(%)', + 'trading-assistant.form.commissionHint': '交易手续费百分比(可选)', + 'trading-assistant.form.slippage': '滑点(%)', + 'trading-assistant.form.slippageHint': '预估滑点百分比(可选)', + 'trading-assistant.form.executionMode': '执行方式', + 'trading-assistant.form.executionModeSignal': '仅信号推送', + 'trading-assistant.form.executionModeLive': '实盘交易(仅加密)', + 'trading-assistant.form.liveTradingCryptoOnlyHint': '目前只有加密货币支持选择实盘交易,其他市场仅支持信号推送。', + 'trading-assistant.form.notifyChannels': '信号推送方式', + 'trading-assistant.form.notifyChannelsHint': '选择买卖信号、止盈止损等事件的推送方式。', + 'trading-assistant.notify.browser': '浏览器通知', + 'trading-assistant.notify.email': '邮箱', + 'trading-assistant.notify.phone': '手机号', + 'trading-assistant.notify.telegram': 'Telegram', + 'trading-assistant.notify.discord': 'Discord', + 'trading-assistant.notify.webhook': 'Webhook', + 'trading-assistant.form.notifyEmail': '邮箱', + 'trading-assistant.form.notifyPhone': '手机号', + 'trading-assistant.form.notifyTelegram': 'Telegram(chat id / 用户名)', + 'trading-assistant.form.notifyDiscord': 'Discord(Webhook URL)', + 'trading-assistant.form.notifyWebhook': 'Webhook URL', + 'trading-assistant.form.liveTradingConfigTitle': '交易所信息', + 'trading-assistant.form.liveTradingConfigHint': '填写交易所 APIKey 等信息,可先测试连接再保存。', + 'trading-assistant.form.savedCredential': '已保存的交易所凭证', + 'trading-assistant.form.savedCredentialHint': '可选择已保存的凭证自动填充(可选)。', + 'trading-assistant.form.saveCredential': '保存本次交易所凭证(下次免重复输入)', + 'trading-assistant.form.credentialName': '凭证名称(可选)', + 'trading-assistant.form.signalMode': '信号模式', + 'trading-assistant.form.signalModeConfirmed': '确认模式', + 'trading-assistant.form.signalModeAggressive': '激进模式', + 'trading-assistant.form.signalModeHint': '确认模式:仅检查已完成的K线;激进模式:也检查正在形成的K线', + 'trading-assistant.form.cancel': '取消', + 'trading-assistant.form.prev': '上一步', + 'trading-assistant.form.next': '下一步', + 'trading-assistant.form.confirmCreate': '确认创建', + 'trading-assistant.form.confirmEdit': '确认修改', + 'trading-assistant.messages.createSuccess': '策略创建成功', + 'trading-assistant.messages.createFailed': '创建策略失败', + 'trading-assistant.messages.updateSuccess': '策略更新成功', + 'trading-assistant.messages.updateFailed': '更新策略失败', + 'trading-assistant.messages.deleteSuccess': '策略删除成功', + 'trading-assistant.messages.deleteFailed': '删除策略失败', + 'trading-assistant.messages.startSuccess': '策略启动成功', + 'trading-assistant.messages.startFailed': '启动策略失败', + 'trading-assistant.messages.stopSuccess': '策略停止成功', + 'trading-assistant.messages.stopFailed': '停止策略失败', + 'trading-assistant.messages.loadFailed': '获取策略列表失败', + 'trading-assistant.messages.runningWarning': '策略运行中,请先停止策略后再修改', + 'trading-assistant.messages.deleteConfirmWithName': '确定要删除策略“{name}”吗?此操作不可恢复。', + 'trading-assistant.messages.deleteConfirm': '确定要删除该策略吗?此操作不可恢复。', + 'trading-assistant.messages.loadTradesFailed': '获取交易记录失败', + 'trading-assistant.messages.loadPositionsFailed': '获取持仓记录失败', + 'trading-assistant.messages.loadEquityFailed': '获取净值曲线失败', + 'trading-assistant.messages.loadIndicatorsFailed': '加载指标列表失败,请稍后重试', + 'trading-assistant.messages.spotLimitations': '现货交易已自动设置为仅做多、1倍杠杆', + 'trading-assistant.messages.autoFillApiConfig': '已自动填充该交易所的历史API配置', + 'trading-assistant.placeholders.selectIndicator': '请选择指标', + 'trading-assistant.placeholders.selectExchange': '请选择交易所', + 'trading-assistant.placeholders.selectMarketCategory': '请选择交易种类', + 'trading-assistant.placeholders.inputApiKey': '请输入API Key', + 'trading-assistant.placeholders.inputSecretKey': '请输入Secret Key', + 'trading-assistant.placeholders.inputPassphrase': '请输入Passphrase', + 'trading-assistant.placeholders.inputStrategyName': '请输入策略名称', + 'trading-assistant.placeholders.selectSymbol': '请选择交易对', + 'trading-assistant.placeholders.inputSymbol': '请输入标的代码', + 'trading-assistant.placeholders.selectTimeframe': '请选择时间周期', + 'trading-assistant.placeholders.selectKlinePeriod': '请选择K线周期', + 'trading-assistant.placeholders.inputAiFilterPrompt': '请输入自定义提示词(可选)', + 'trading-assistant.placeholders.inputEmail': '请输入邮箱', + 'trading-assistant.placeholders.inputPhone': '请输入手机号', + 'trading-assistant.placeholders.inputTelegram': '请输入 Telegram chat id / 用户名', + 'trading-assistant.placeholders.inputDiscord': '请输入 Discord webhook url', + 'trading-assistant.placeholders.inputWebhook': '请输入 webhook url', + 'trading-assistant.placeholders.selectSavedCredential': '请选择已保存的凭证', + 'trading-assistant.placeholders.inputCredentialName': '例如:Binance 主账号', + 'trading-assistant.validation.indicatorRequired': '请选择指标', + 'trading-assistant.validation.exchangeRequired': '请选择交易所', + 'trading-assistant.validation.apiKeyRequired': '请输入API Key', + 'trading-assistant.validation.secretKeyRequired': '请输入Secret Key', + 'trading-assistant.validation.passphraseRequired': '请输入Passphrase', + 'trading-assistant.validation.exchangeConfigIncomplete': '请填写完整的交易所配置信息', + 'trading-assistant.validation.testConnectionRequired': '请先点击"测试连接"按钮并确保连接成功', + 'trading-assistant.validation.testConnectionFailed': '连接测试失败,请检查配置后重新测试', + 'trading-assistant.validation.strategyNameRequired': '请输入策略名称', + 'trading-assistant.validation.symbolRequired': '请选择交易对', + 'trading-assistant.validation.initialCapitalRequired': '请输入投入金额', + 'trading-assistant.validation.leverageRequired': '请输入杠杆倍数', + 'trading-assistant.validation.emailInvalid': '邮箱格式不正确', + 'trading-assistant.validation.notifyChannelRequired': '请至少选择一种信号推送方式', + 'trading-assistant.table.time': '时间', + 'trading-assistant.table.type': '类型', + 'trading-assistant.table.price': '价格', + 'trading-assistant.table.amount': '数量', + 'trading-assistant.table.value': '金额', + 'trading-assistant.table.commission': '手续费', + 'trading-assistant.table.symbol': '交易对', + 'trading-assistant.table.side': '方向', + 'trading-assistant.table.size': '持仓数量', + 'trading-assistant.table.entryPrice': '开仓价格', + 'trading-assistant.table.currentPrice': '当前价格', + 'trading-assistant.table.unrealizedPnl': '未实现盈亏', + 'trading-assistant.table.pnlPercent': '盈亏比例', + 'trading-assistant.table.buy': '买入', + 'trading-assistant.table.sell': '卖出', + 'trading-assistant.table.long': '做多', + 'trading-assistant.table.short': '做空', + 'trading-assistant.table.noPositions': '暂无持仓', + 'trading-assistant.detail.title': '策略详情', + 'trading-assistant.detail.strategyName': '策略名称', + 'trading-assistant.detail.strategyType': '策略类型', + 'trading-assistant.detail.status': '状态', + 'trading-assistant.detail.tradingMode': '交易模式', + 'trading-assistant.detail.exchange': '交易所', + 'trading-assistant.detail.initialCapital': '初始资金', + 'trading-assistant.detail.totalInvestment': '总投入金额', + 'trading-assistant.detail.currentEquity': '当前净值', + 'trading-assistant.detail.totalPnl': '总盈亏', + 'trading-assistant.detail.indicatorName': '指标名称', + 'trading-assistant.detail.maxLeverage': '最大杠杆', + 'trading-assistant.detail.decideInterval': '决策间隔', + 'trading-assistant.detail.symbols': '交易标的', + 'trading-assistant.detail.createdAt': '创建时间', + 'trading-assistant.detail.updatedAt': '更新时间', + 'trading-assistant.detail.llmConfig': 'AI模型配置', + 'trading-assistant.detail.exchangeConfig': '交易所配置', + 'trading-assistant.detail.provider': '提供商', + 'trading-assistant.detail.modelId': '模型ID', + 'trading-assistant.detail.close': '关闭', + 'trading-assistant.detail.loadFailed': '获取策略详情失败', + 'trading-assistant.equity.noData': '暂无净值数据', + 'trading-assistant.equity.equity': '净值', + 'trading-assistant.exchange.tradingMode': '交易模式', + 'trading-assistant.exchange.virtual': '模拟交易', + 'trading-assistant.exchange.live': '实盘交易', + 'trading-assistant.exchange.selectExchange': '选择交易所', + 'trading-assistant.exchange.walletAddress': '钱包地址', + 'trading-assistant.exchange.walletAddressPlaceholder': '请输入钱包地址(以0x开头)', + 'trading-assistant.exchange.privateKey': '私钥', + 'trading-assistant.exchange.privateKeyPlaceholder': '请输入私钥(64字符)', + 'trading-assistant.exchange.testConnection': '测试连接', + 'trading-assistant.exchange.connectionSuccess': '连接成功', + 'trading-assistant.exchange.connectionFailed': '连接失败', + 'trading-assistant.exchange.testFailed': '连接测试失败', + 'trading-assistant.exchange.fillComplete': '请填写完整的交易所配置信息', + 'trading-assistant.exchange.ipWhitelistTip': '请在交易所API设置中将以下IP添加到白名单:', + 'trading-assistant.strategyTypeOptions.ai': 'AI驱动策略', + 'trading-assistant.strategyTypeOptions.indicator': '技术指标策略', + 'trading-assistant.strategyTypeOptions.aiDeveloping': 'AI驱动策略功能开发中,敬请期待', + 'trading-assistant.strategyTypeOptions.aiDevelopingWarning': 'AI驱动策略功能正在开发中', + 'trading-assistant.indicatorType.trend': '趋势', + 'trading-assistant.indicatorType.momentum': '动量', + 'trading-assistant.indicatorType.volatility': '波动', + 'trading-assistant.indicatorType.volume': '成交量', + 'trading-assistant.indicatorType.custom': '自定义', + 'trading-assistant.exchangeNames': { + 'okx': 'OKX', + 'binance': '币安', + 'hyperliquid': 'Hyperliquid', + 'blockchaincom': 'Blockchain.com', + 'coinbaseexchange': 'Coinbase', + 'gate': 'Gate.io', + 'mexc': 'MEXC', + 'kraken': 'Kraken', + 'bitfinex': 'Bitfinex', + 'bybit': 'Bybit', + 'kucoin': 'KuCoin', + 'huobi': '火币', + 'bitget': 'Bitget', + 'bitmex': 'BitMEX', + 'deribit': 'Deribit', + 'phemex': 'Phemex', + 'bitmart': 'BitMart', + 'bitstamp': 'Bitstamp', + 'bittrex': 'Bittrex', + 'poloniex': 'Poloniex', + 'gemini': 'Gemini', + 'cryptocom': 'Crypto.com', + 'bitflyer': 'bitFlyer', + 'upbit': 'Upbit', + 'bithumb': 'Bithumb', + 'coinone': 'Coinone', + 'zb': 'ZB', + 'lbank': 'LBank', + 'bibox': 'Bibox', + 'bigone': 'BigONE', + 'bitrue': 'Bitrue', + 'coinex': 'CoinEx', + 'digifinex': 'DigiFinex', + 'ftx': 'FTX', + 'ftxus': 'FTX US', + 'binanceus': 'Binance US', + 'binancecoinm': 'Binance COIN-M', + 'binanceusdm': 'Binance USDⓈ-M' + }, + 'ai-trading-assistant.title': 'AI交易助手', + 'ai-trading-assistant.strategyList': '策略列表', + 'ai-trading-assistant.createStrategy': '创建策略', + 'ai-trading-assistant.noStrategy': '暂无策略', + 'ai-trading-assistant.selectStrategy': '请从左侧选择一个策略查看详情', + 'ai-trading-assistant.startStrategy': '启动策略', + 'ai-trading-assistant.stopStrategy': '停止策略', + 'ai-trading-assistant.editStrategy': '编辑策略', + 'ai-trading-assistant.deleteStrategy': '删除策略', + 'ai-trading-assistant.status.running': '运行中', + 'ai-trading-assistant.status.stopped': '已停止', + 'ai-trading-assistant.status.error': '错误', + 'ai-trading-assistant.tabs.tradingRecords': '交易记录', + 'ai-trading-assistant.tabs.positions': '持仓记录', + 'ai-trading-assistant.tabs.aiDecisions': 'AI决策记录', + 'ai-trading-assistant.tabs.equityCurve': '净值曲线', + 'ai-trading-assistant.form.createTitle': '创建AI交易策略', + 'ai-trading-assistant.form.editTitle': '编辑AI交易策略', + 'ai-trading-assistant.form.strategyName': '策略名称', + 'ai-trading-assistant.form.modelId': 'AI模型', + 'ai-trading-assistant.form.modelIdHint': '使用系统OpenRouter服务,无需配置API Key', + 'ai-trading-assistant.form.decideInterval': '决策间隔', + 'ai-trading-assistant.form.decideInterval5m': '5分钟', + 'ai-trading-assistant.form.decideInterval10m': '10分钟', + 'ai-trading-assistant.form.decideInterval30m': '30分钟', + 'ai-trading-assistant.form.decideInterval1h': '1小时', + 'ai-trading-assistant.form.decideInterval4h': '4小时', + 'ai-trading-assistant.form.decideInterval1d': '1天', + 'ai-trading-assistant.form.decideInterval1w': '1周', + 'ai-trading-assistant.form.decideIntervalHint': 'AI做决策的时间间隔', + 'ai-trading-assistant.form.runPeriod': '运行周期', + 'ai-trading-assistant.form.runPeriodHint': '策略运行的开始时间和结束时间', + 'ai-trading-assistant.form.startDate': '开始日期', + 'ai-trading-assistant.form.endDate': '结束日期', + 'ai-trading-assistant.form.qdtCostTitle': 'QDT扣费说明', + 'ai-trading-assistant.form.qdtCostHint': '每次AI决策将扣费 {cost} 个QDT,请确保账户有足够的QDT余额。策略运行期间,每次决策都会实时扣费。', + 'ai-trading-assistant.form.apiKey': 'API Key', + 'ai-trading-assistant.form.exchange': '选择交易所', + 'ai-trading-assistant.form.secretKey': 'Secret Key', + 'ai-trading-assistant.form.passphrase': 'Passphrase', + 'ai-trading-assistant.form.testConnection': '测试连接', + 'ai-trading-assistant.form.symbol': '交易对', + 'ai-trading-assistant.form.symbolHint': '选择要交易的交易对', + 'ai-trading-assistant.form.initialCapital': '投入金额(保证金)', + 'ai-trading-assistant.form.leverage': '杠杆倍数', + 'ai-trading-assistant.form.timeframe': '时间周期', + 'ai-trading-assistant.form.timeframe1m': '1分钟', + 'ai-trading-assistant.form.timeframe5m': '5分钟', + 'ai-trading-assistant.form.timeframe15m': '15分钟', + 'ai-trading-assistant.form.timeframe30m': '30分钟', + 'ai-trading-assistant.form.timeframe1H': '1小时', + 'ai-trading-assistant.form.timeframe4H': '4小时', + 'ai-trading-assistant.form.timeframe1D': '1天', + 'ai-trading-assistant.form.marketType': '市场类型', + 'ai-trading-assistant.form.marketTypeFutures': '合约', + 'ai-trading-assistant.form.marketTypeSpot': '现货', + 'ai-trading-assistant.form.totalPnl': '总盈亏', + 'ai-trading-assistant.form.customPrompt': '自定义提示词', + 'ai-trading-assistant.form.customPromptHint': '可选,用于自定义AI的交易策略和决策逻辑', + 'ai-trading-assistant.form.cancel': '取消', + 'ai-trading-assistant.form.prev': '上一步', + 'ai-trading-assistant.form.next': '下一步', + 'ai-trading-assistant.form.confirmCreate': '确认创建', + 'ai-trading-assistant.form.confirmEdit': '确认修改', + 'ai-trading-assistant.messages.createSuccess': '策略创建成功', + 'ai-trading-assistant.messages.createFailed': '创建策略失败', + 'ai-trading-assistant.messages.updateSuccess': '策略更新成功', + 'ai-trading-assistant.messages.updateFailed': '更新策略失败', + 'ai-trading-assistant.messages.deleteSuccess': '策略删除成功', + 'ai-trading-assistant.messages.deleteFailed': '删除策略失败', + 'ai-trading-assistant.messages.startSuccess': '策略启动成功', + 'ai-trading-assistant.messages.startFailed': '启动策略失败', + 'ai-trading-assistant.messages.stopSuccess': '策略停止成功', + 'ai-trading-assistant.messages.stopFailed': '停止策略失败', + 'ai-trading-assistant.messages.loadFailed': '获取策略列表失败', + 'ai-trading-assistant.messages.loadDecisionsFailed': '获取AI决策记录失败', + 'ai-trading-assistant.messages.deleteConfirm': '确定要删除该策略吗?此操作不可恢复。', + 'ai-trading-assistant.placeholders.inputStrategyName': '请输入策略名称', + 'ai-trading-assistant.placeholders.selectModelId': '请选择AI模型', + 'ai-trading-assistant.placeholders.selectDecideInterval': '请选择决策间隔', + 'ai-trading-assistant.placeholders.startTime': '开始时间', + 'ai-trading-assistant.placeholders.endTime': '结束时间', + 'ai-trading-assistant.placeholders.inputApiKey': '请输入API Key', + 'ai-trading-assistant.placeholders.selectExchange': '请选择交易所', + 'ai-trading-assistant.placeholders.inputSecretKey': '请输入Secret Key', + 'ai-trading-assistant.placeholders.inputPassphrase': '请输入Passphrase', + 'ai-trading-assistant.placeholders.selectSymbol': '请选择交易对,如:BTC/USDT', + 'ai-trading-assistant.placeholders.selectTimeframe': '请选择时间周期', + 'ai-trading-assistant.placeholders.inputCustomPrompt': '请输入自定义提示词(可选)', + 'ai-trading-assistant.validation.strategyNameRequired': '请输入策略名称', + 'ai-trading-assistant.validation.modelIdRequired': '请选择AI模型', + 'ai-trading-assistant.validation.runPeriodRequired': '请选择运行周期', + 'ai-trading-assistant.validation.apiKeyRequired': '请输入API Key', + 'ai-trading-assistant.validation.exchangeRequired': '请选择交易所', + 'ai-trading-assistant.validation.secretKeyRequired': '请输入Secret Key', + 'ai-trading-assistant.validation.symbolRequired': '请选择交易对', + 'ai-trading-assistant.validation.initialCapitalRequired': '请输入投入金额', + 'ai-trading-assistant.table.time': '时间', + 'ai-trading-assistant.table.type': '类型', + 'ai-trading-assistant.table.price': '价格', + 'ai-trading-assistant.table.amount': '数量', + 'ai-trading-assistant.table.value': '金额', + 'ai-trading-assistant.table.symbol': '交易对', + 'ai-trading-assistant.table.side': '方向', + 'ai-trading-assistant.table.size': '持仓数量', + 'ai-trading-assistant.table.entryPrice': '开仓价格', + 'ai-trading-assistant.table.currentPrice': '当前价格', + 'ai-trading-assistant.table.unrealizedPnl': '未实现盈亏', + 'ai-trading-assistant.table.profit': '盈亏', + 'ai-trading-assistant.table.openLong': '开多', + 'ai-trading-assistant.table.closeLong': '平多', + 'ai-trading-assistant.table.openShort': '开空', + 'ai-trading-assistant.table.closeShort': '平空', + 'ai-trading-assistant.table.addLong': '加多', + 'ai-trading-assistant.table.addShort': '加空', + 'ai-trading-assistant.table.closeShortProfit': '平空止盈', + 'ai-trading-assistant.table.closeShortStop': '平空止损', + 'ai-trading-assistant.table.closeLongProfit': '平多止盈', + 'ai-trading-assistant.table.closeLongStop': '平多止损', + 'ai-trading-assistant.table.buy': '买入', + 'ai-trading-assistant.table.sell': '卖出', + 'ai-trading-assistant.table.long': '做多', + 'ai-trading-assistant.table.short': '做空', + 'ai-trading-assistant.table.hold': '持有', + 'ai-trading-assistant.table.reasoning': '分析理由', + 'ai-trading-assistant.table.decisions': '决策', + 'ai-trading-assistant.table.riskAssessment': '风险评估', + 'ai-trading-assistant.table.confidence': '置信度', + 'ai-trading-assistant.table.totalRecords': '共 {total} 条记录', + 'ai-trading-assistant.table.noPositions': '暂无持仓', + 'ai-trading-assistant.detail.title': '策略详情', + 'ai-trading-assistant.equity.noData': '暂无净值数据', + 'ai-trading-assistant.equity.equity': '净值', + 'ai-trading-assistant.exchange.testFailed': '连接测试失败', + 'ai-trading-assistant.exchange.connectionSuccess': '连接成功', + 'ai-trading-assistant.exchange.connectionFailed': '连接失败', + 'ai-trading-assistant.form.advancedSettings': '高级设置', + 'ai-trading-assistant.form.orderMode': '下单模式', + 'ai-trading-assistant.form.orderModeMaker': '挂单(Maker)', + 'ai-trading-assistant.form.orderModeTaker': '市价(Taker)', + 'ai-trading-assistant.form.orderModeHint': '挂单模式使用限价单,手续费更低;市价模式立即成交,手续费较高', + 'ai-trading-assistant.form.makerWaitSec': '挂单等待时间(秒)', + 'ai-trading-assistant.form.makerWaitSecHint': '挂单后等待成交的时间,超时后取消并重试', + 'ai-trading-assistant.form.makerRetries': '挂单重试次数', + 'ai-trading-assistant.form.makerRetriesHint': '挂单未成交时的最大重试次数', + 'ai-trading-assistant.form.fallbackToMarket': '挂单失败降级市价', + 'ai-trading-assistant.form.fallbackToMarketHint': '开/平仓挂单未成交时,是否降级为市价单以确保成交', + 'ai-trading-assistant.form.marginMode': '保证金模式', + 'ai-trading-assistant.form.marginModeCross': '全仓', + 'ai-trading-assistant.form.marginModeIsolated': '逐仓', + 'ai-analysis.title': '量子交易引擎', + 'ai-analysis.system.online': '在线', + 'ai-analysis.system.agents': '智能体', + 'ai-analysis.system.active': '活跃', + 'ai-analysis.system.stage': '阶段', + 'ai-analysis.panel.roster': '智能体阵容', + 'ai-analysis.panel.thinking': '思考中...', + 'ai-analysis.panel.done': '完成', + 'ai-analysis.panel.standby': '待机', + 'ai-analysis.input.title': '量子交易引擎', + 'ai-analysis.input.placeholder': '选择目标资产 (如 BTC/USDT)', + 'ai-analysis.input.watchlist': '自选股', + 'ai-analysis.input.start': '开始分析', + 'ai-analysis.input.recent': '最近任务:', + 'ai-analysis.vis.stage': '阶段', + 'ai-analysis.vis.processing': '处理中', + 'ai-analysis.result.complete': '分析完成', + 'ai-analysis.result.signal': '最终信号', + 'ai-analysis.result.confidence': '置信度:', + 'ai-analysis.result.new': '新分析', + 'ai-analysis.result.full': '查看完整报告', + 'ai-analysis.logs.title': '系统日志', + 'ai-analysis.modal.title': '机密报告', + 'ai-analysis.modal.fundamental': '基本面分析', + 'ai-analysis.modal.technical': '技术面分析', + 'ai-analysis.modal.sentiment': '情绪面分析', + 'ai-analysis.modal.risk': '风险评估', + 'ai-analysis.stage.idle': '待机', + 'ai-analysis.stage.1': '第一阶段:多维分析', + 'ai-analysis.stage.2': '第二阶段:多空辩论', + 'ai-analysis.stage.3': '第三阶段:战略规划', + 'ai-analysis.stage.4': '第四阶段:风控审查', + 'ai-analysis.stage.complete': '完成', + 'ai-analysis.agent.investment_director': '投资总监', + 'ai-analysis.agent.role.investment_director': '综合分析 & 最终结论', + 'ai-analysis.agent.market': '市场分析师', + 'ai-analysis.agent.role.market': '技术 & 市场数据', + 'ai-analysis.agent.fundamental': '基本面分析师', + 'ai-analysis.agent.role.fundamental': '财务 & 估值', + 'ai-analysis.agent.technical': '技术分析师', + 'ai-analysis.agent.role.technical': '技术指标 & 图表', + 'ai-analysis.agent.news': '新闻分析师', + 'ai-analysis.agent.role.news': '全球新闻过滤', + 'ai-analysis.agent.sentiment': '情绪分析师', + 'ai-analysis.agent.role.sentiment': '社交 & 情绪', + 'ai-analysis.agent.risk': '风险分析师', + 'ai-analysis.agent.role.risk': '基础风险检查', + 'ai-analysis.agent.bull': '看涨研究员', + 'ai-analysis.agent.role.bull': '增长催化剂挖掘', + 'ai-analysis.agent.bear': '看跌研究员', + 'ai-analysis.agent.role.bear': '风险 & 缺陷挖掘', + 'ai-analysis.agent.manager': '研究经理', + 'ai-analysis.agent.role.manager': '辩论主持人', + 'ai-analysis.agent.trader': '交易员', + 'ai-analysis.agent.role.trader': '执行策略师', + 'ai-analysis.agent.risky': '激进分析师', + 'ai-analysis.agent.role.risky': '激进策略', + 'ai-analysis.agent.neutral': '平衡分析师', + 'ai-analysis.agent.role.neutral': '平衡策略', + 'ai-analysis.agent.safe': '保守分析师', + 'ai-analysis.agent.role.safe': '保守策略', + 'ai-analysis.agent.cro': '风控经理 (CRO)', + 'ai-analysis.agent.role.cro': '最终决策权威', + 'ai-analysis.script.market': '正在从主要交易所获取 OHLCV 数据...', + 'ai-analysis.script.fundamental': '正在检索季度财务报告...', + 'ai-analysis.script.technical': '正在分析技术指标和图表形态...', + 'ai-analysis.script.news': '正在扫描全球财经新闻...', + 'ai-analysis.script.sentiment': '正在分析社交媒体趋势...', + 'ai-analysis.script.risk': '正在计算历史波动率...', + 'invite.inviteLink': '邀请链接', + 'invite.copy': '复制链接', + 'invite.copySuccess': '复制成功!', + 'invite.copyFailed': '复制失败,请手动复制', + 'invite.noInviteLink': '邀请链接未生成', + 'invite.totalInvites': '累计邀请', + 'invite.totalReward': '累计奖励', + 'invite.rules': '邀请规则', + 'invite.rule1': '每成功邀请一位好友注册,您将获得奖励', + 'invite.rule2': '被邀请的好友完成首次交易,您将获得额外奖励', + 'invite.rule3': '邀请奖励将直接发放到您的账户', + 'invite.inviteList': '邀请列表', + 'invite.tasks': '任务中心', + 'invite.inviteeName': '被邀请人', + 'invite.inviteTime': '邀请时间', + 'invite.status': '状态', + 'invite.reward': '奖励', + 'invite.active': '活跃', + 'invite.inactive': '未激活', + 'invite.completed': '已完成', + 'invite.claimed': '已领取', + 'invite.pending': '待完成', + 'invite.goToTask': '去完成', + 'invite.claimReward': '领取奖励', + 'invite.verify': '验证完成', + 'invite.verifySuccess': '验证成功!任务已完成', + 'invite.verifyNotCompleted': '任务尚未完成,请先完成任务', + 'invite.verifyFailed': '验证失败,请稍后重试', + 'invite.claimSuccess': '成功领取 {reward} QDT!', + 'invite.claimFailed': '领取失败,请稍后重试', + 'invite.totalRecords': '共 {total} 条记录', + 'invite.task.twitter.title': '转发推文到 X (Twitter)', + 'invite.task.twitter.desc': '分享我们的官方推文到您的 X (Twitter) 账号', + 'invite.task.youtube.title': '关注我们的 YouTube 频道', + 'invite.task.youtube.desc': '订阅并关注我们的 YouTube 官方频道', + 'invite.task.telegram.title': '加入 Telegram 群组', + 'invite.task.telegram.desc': '加入我们的官方 Telegram 社区群组', + 'invite.task.discord.title': '加入 Discord 服务器', + 'invite.task.discord.desc': '加入我们的 Discord 社区服务器', + 'message': '-', + 'layouts.usermenu.dialog.title': '信息', + 'layouts.usermenu.dialog.content': '您确定要注销吗?', + 'layouts.userLayout.title': '于不确定中,寻见真理', + // Signal Robot + 'signal-robot.title': '信号机器人控制台', + 'signal-robot.createBot': '创建新机器人', + 'signal-robot.search.nameOrSymbol': '名称/币种', + 'signal-robot.search.placeholder': '搜索机器人名称或币种', + 'signal-robot.search.status': '状态', + 'signal-robot.search.statusAll': '全部', + 'signal-robot.search.statusRunning': '运行中', + 'signal-robot.search.statusPaused': '已暂停', + 'signal-robot.search.query': '查询', + 'signal-robot.search.reset': '重置', + 'signal-robot.table.botName': '机器人名称', + 'signal-robot.table.symbolTimeframe': '交易对/周期', + 'signal-robot.table.triggerStrategy': '触发策略', + 'signal-robot.table.notificationChannels': '通知方式', + 'signal-robot.table.status': '状态', + 'signal-robot.table.action': '操作', + 'signal-robot.table.triggerConditions': '{count} 个触发条件', + 'signal-robot.table.monitoring': '监控中', + 'signal-robot.table.paused': '已暂停', + 'signal-robot.table.edit': '编辑', + 'signal-robot.table.pause': '暂停', + 'signal-robot.table.start': '启动', + 'signal-robot.table.delete': '删除', + 'signal-robot.table.deleteConfirm': '确定要删除这个机器人吗?', + 'signal-robot.table.deleteSuccess': '删除成功', + 'signal-robot.table.startSuccess': '机器人已启动', + 'signal-robot.table.pauseSuccess': '机器人已暂停', + 'signal-robot.channel.telegram': 'Telegram', + 'signal-robot.channel.discord': 'Discord', + 'signal-robot.channel.email': 'Email', + 'signal-robot.channel.webhook': 'Webhook', + 'signal-robot.channel.browser': '浏览器推送', + 'signal-robot.modal.createTitle': '创建新信号机器人', + 'signal-robot.modal.editTitle': '编辑信号机器人', + 'signal-robot.modal.step.basic': '基础设置', + 'signal-robot.modal.step.entry': '入场策略', + 'signal-robot.modal.step.position': '资金管理', + 'signal-robot.modal.step.risk': '风控止损', + 'signal-robot.modal.step.notify': '通知配置', + 'signal-robot.modal.prev': '上一步', + 'signal-robot.modal.next': '下一步', + 'signal-robot.modal.preview': '预览回测', + 'signal-robot.modal.complete': '完成并运行', + 'signal-robot.modal.saveSuccess': '保存成功', + 'signal-robot.modal.previewDeveloping': '预览功能开发中...', + 'signal-robot.modal.basicInfoRequired': '请完善基础信息', + 'signal-robot.basic.alert': '设置机器人的基本信息', + 'signal-robot.basic.title': '基础信息', + 'signal-robot.basic.botName': '机器人名称', + 'signal-robot.basic.botNamePlaceholder': '例如:BTC SuperTrend 策略', + 'signal-robot.basic.symbol': '交易对', + 'signal-robot.basic.symbolPlaceholder': '选择自选股', + 'signal-robot.basic.noWatchlist': '暂无自选股,请先在行情页添加', + 'signal-robot.basic.timeframe': 'K线周期', + 'signal-robot.basic.timeframe15m': '15分钟', + 'signal-robot.basic.timeframe30m': '30分钟', + 'signal-robot.basic.timeframe1h': '1小时', + 'signal-robot.basic.timeframe4h': '4小时', + 'signal-robot.basic.timeframe1d': '1天', + 'signal-robot.basic.autoNameSuggestion': '{symbol} 信号机器人', + 'signal-robot.entry.alert': '当满足以下所有条件时,触发入场信号', + 'signal-robot.entry.condition': '条件 {index}', + 'signal-robot.entry.indicator': '指标', + 'signal-robot.entry.indicatorSupertrend': 'SuperTrend', + 'signal-robot.entry.indicatorEma': 'EMA (指数移动平均)', + 'signal-robot.entry.indicatorRsi': 'RSI (相对强弱)', + 'signal-robot.entry.indicatorMacd': 'MACD', + 'signal-robot.entry.indicatorBollinger': '布林带 (Bollinger)', + 'signal-robot.entry.indicatorKdj': 'KDJ', + 'signal-robot.entry.indicatorMa': 'MA (移动平均)', + 'signal-robot.entry.fastPeriod': '快线周期', + 'signal-robot.entry.slowPeriod': '慢线周期', + 'signal-robot.entry.signalPeriod': '信号周期', + 'signal-robot.entry.stdDev': '标准差', + 'signal-robot.entry.maType': 'MA类型', + 'signal-robot.entry.conditionDiffGtDea': '快线 > 慢线', + 'signal-robot.entry.conditionDiffLtDea': '快线 < 慢线', + 'signal-robot.entry.conditionGoldCross': '金叉 (快穿慢)', + 'signal-robot.entry.conditionDeathCross': '死叉 (快穿慢)', + 'signal-robot.entry.conditionPriceAboveUpper': '价格 > 上轨', + 'signal-robot.entry.conditionPriceBelowLower': '价格 < 下轨', + 'signal-robot.entry.conditionPriceAboveMid': '价格 > 中轨', + 'signal-robot.entry.conditionPriceBelowMid': '价格 < 中轨', + 'signal-robot.entry.conditionKGtD': 'K > D', + 'signal-robot.entry.conditionKLtD': 'K < D', + 'signal-robot.entry.atrPeriod': 'ATR 周期', + 'signal-robot.entry.multiplier': '乘数 (Multiplier)', + 'signal-robot.entry.triggerSignal': '触发信号', + 'signal-robot.entry.signalTrendBullish': '趋势转多 (Trend Bullish)', + 'signal-robot.entry.signalTrendBearish': '趋势转空 (Trend Bearish)', + 'signal-robot.entry.signalIsUptrend': '处于多头趋势中 (Is Uptrend)', + 'signal-robot.entry.signalIsDowntrend': '处于空头趋势中 (Is Downtrend)', + 'signal-robot.entry.period': '周期 (Period)', + 'signal-robot.entry.compareCondition': '比较条件', + 'signal-robot.entry.priceAbove': '价格 > EMA (Price Above)', + 'signal-robot.entry.priceBelow': '价格 < EMA (Price Below)', + 'signal-robot.entry.crossUp': '价格上穿 EMA (Golden Cross)', + 'signal-robot.entry.crossDown': '价格下穿 EMA (Death Cross)', + 'signal-robot.entry.compare': '比较', + 'signal-robot.entry.greaterThan': '大于', + 'signal-robot.entry.lessThan': '小于', + 'signal-robot.entry.crossUpShort': '上穿', + 'signal-robot.entry.crossDownShort': '下穿', + 'signal-robot.entry.threshold': '阈值', + 'signal-robot.entry.selectIndicator': '请选择指标以配置参数', + 'signal-robot.entry.addCondition': '添加判断条件', + 'signal-robot.position.alert': '配置首仓大小、杠杆及加仓规则', + 'signal-robot.position.basicTitle': '基础仓位管理', + 'signal-robot.position.initialSize': '首仓大小 (资金占比 %)', + 'signal-robot.position.initialSizeHint': '建议 5% - 20%', + 'signal-robot.position.leverage': '杠杆倍数 (Leverage)', + 'signal-robot.position.leverageHint': '请确保交易所账户支持该杠杆', + 'signal-robot.position.maxPyramiding': '最大加仓次数', + 'signal-robot.position.maxPyramidingHint': '0 表示不加仓', + 'signal-robot.position.pyramidingTitle': '加仓规则 (Pyramiding)', + 'signal-robot.position.pyramidingEnabled': '开启', + 'signal-robot.position.pyramidingDisabled': '关闭', + 'signal-robot.position.pyramidingCondition': '加仓触发条件', + 'signal-robot.position.priceRisePct': '价格每上涨 (多头) / 下跌 (空头)', + 'signal-robot.position.profitPct': '盈利达到', + 'signal-robot.position.triggerThreshold': '触发阈值 (%)', + 'signal-robot.position.triggerThresholdHint': '例如 3%:涨3%加仓一次', + 'signal-robot.position.addSize': '单次加仓大小 (资金占比 %)', + 'signal-robot.position.pyramidingDisabledHint': '加仓功能已关闭,仅执行首仓', + 'signal-robot.risk.alert': '配置止盈止损及平仓规则', + 'signal-robot.risk.stopLossTitle': '止损 (Stop Loss)', + 'signal-robot.risk.stopLossEnabled': '开启', + 'signal-robot.risk.stopLossDisabled': '关闭', + 'signal-robot.risk.stopLossType': '止损类型', + 'signal-robot.risk.stopLossFixedPct': '固定百分比 (Fixed %)', + 'signal-robot.risk.stopLossAtrMultiplier': 'ATR 倍数 (ATR Multiplier)', + 'signal-robot.risk.stopLossValue': '数值', + 'signal-robot.risk.stopLossFixedHint': '例如 2%:亏损 2% 止损', + 'signal-robot.risk.stopLossAtrHint': '例如 2.0:价格触及 ATR*2.0 止损', + 'signal-robot.risk.stopLossDisabledHint': '未启用止损(高风险)', + 'signal-robot.risk.trailingStopTitle': '移动止盈 (Trailing Stop)', + 'signal-robot.risk.activationProfit': '激活门槛 (Activation Profit %)', + 'signal-robot.risk.activationProfitHint': '盈利达到多少时开始追踪', + 'signal-robot.risk.callbackPct': '回撤比例 (Callback %)', + 'signal-robot.risk.callbackPctHint': '最高点回撤多少时触发止盈', + 'signal-robot.risk.trailingStopDisabledHint': '未启用移动止盈', + 'signal-robot.risk.otherExitTitle': '其他平仓规则', + 'signal-robot.risk.signalExit': '允许反向信号平仓 (Signal Exit)', + 'signal-robot.risk.signalExitHint': '当出现反向信号时(如做多时出现做空信号),立即平仓', + 'signal-robot.notify.alert': '配置信号触发后的通知方式', + 'signal-robot.notify.channelTitle': '通知渠道', + 'signal-robot.notify.discordWebhook': 'Discord Webhook', + 'signal-robot.notify.genericWebhook': 'Generic Webhook', + 'signal-robot.notify.webhookUrl': 'Webhook URL', + 'signal-robot.notify.webhookUrlPlaceholder': 'https://example.com/webhook', + 'signal-robot.notify.discordUrl': 'Discord URL', + 'signal-robot.notify.discordUrlPlaceholder': 'https://discord.com/api/webhooks/...', + 'signal-robot.operator.greaterThan': '大于', + 'signal-robot.operator.lessThan': '小于', + 'signal-robot.operator.equal': '等于', + 'signal-robot.operator.goldenCross': '金叉', + 'signal-robot.operator.deathCross': '死叉', + 'signal-robot.operator.increaseBy': '上涨超', + 'signal-robot.operator.decreaseBy': '下跌超', + 'signal-robot.indicator.price': '价格', + 'signal-robot.indicator.rsi': 'RSI', + 'signal-robot.indicator.kdjK': 'KDJ(K)', + 'signal-robot.indicator.kdjJ': 'KDJ(J)', + 'signal-robot.indicator.macd': 'MACD', + 'signal-robot.indicator.macdHist': 'MACD柱', + 'signal-robot.indicator.bollingerUp': '布林上轨', + 'signal-robot.indicator.bollingerLow': '布林下轨', + 'signal-robot.indicator.volume': '成交量', + 'signal-robot.indicator.changePct1h': '1H涨幅', + 'signal-robot.indicator.changePct24h': '24H涨幅', + 'signal-robot.indicator.signalLine': '信号线' +} + +export default { + ...components, + ...locale +} diff --git a/quantdinger_vue/src/locales/lang/zh-TW.js b/quantdinger_vue/src/locales/lang/zh-TW.js new file mode 100644 index 0000000..d537441 --- /dev/null +++ b/quantdinger_vue/src/locales/lang/zh-TW.js @@ -0,0 +1,1528 @@ +import antd from 'ant-design-vue/es/locale-provider/zh_TW' +import momentTW from 'moment/locale/zh-tw' + +const components = { + antLocale: antd, + momentName: 'zh-tw', + momentLocale: momentTW +} + +const locale = { + 'submit': '提交', + 'save': '保存', + 'submit.ok': '提交成功', + 'save.ok': '保存成功', + 'menu.welcome': '歡迎', + 'menu.home': '主頁', + 'menu.dashboard': '儀表盤', + 'menu.dashboard.indicator': '指標分析', + 'menu.dashboard.community': '指標社區', + 'menu.dashboard.analysis': 'AI 分析', + 'menu.dashboard.tradingAssistant': '交易助手', + 'menu.dashboard.aiTradingAssistant': 'AI交易助手', + 'menu.dashboard.signalRobot': '信號機器人', + 'menu.dashboard.monitor': '監控頁', + 'menu.dashboard.workplace': '工作臺', + 'menu.form': '表單頁', + 'menu.form.basic-form': '基礎表單', + 'menu.form.step-form': '分步表單', + 'menu.form.step-form.info': '分步表單(填寫轉賬信息)', + 'menu.form.step-form.confirm': '分步表單(確認轉賬信息)', + 'menu.form.step-form.result': '分步表單(完成)', + 'menu.form.advanced-form': '高級表單', + 'menu.list': '列表頁', + 'menu.list.table-list': '查詢表格', + 'menu.list.basic-list': '標準列表', + 'menu.list.card-list': '卡片列表', + 'menu.list.search-list': '搜索列表', + 'menu.list.search-list.articles': '搜索列表(文章)', + 'menu.list.search-list.projects': '搜索列表(項目)', + 'menu.list.search-list.applications': '搜索列表(應用)', + 'menu.profile': '詳情頁', + 'menu.profile.basic': '基礎詳情頁', + 'menu.profile.advanced': '高級詳情頁', + 'menu.result': '結果頁', + 'menu.result.success': '成功頁', + 'menu.result.fail': '失敗頁', + 'menu.exception': '異常頁', + 'menu.exception.not-permission': '403', + 'menu.exception.not-find': '404', + 'menu.exception.server-error': '500', + 'menu.exception.trigger': '觸發錯誤', + 'menu.account': '個人頁', + 'menu.account.center': '個人中心', + 'menu.account.settings': '個人設置', + 'menu.account.trigger': '觸發報錯', + 'menu.account.logout': '退出登錄', + 'menu.wallet': '我的錢包', + 'menu.docs': '文檔中心', + 'menu.docs.detail': '文檔詳情', + 'menu.header.refreshPage': '刷新頁面', + 'menu.invite.friends': '邀請好友', + 'app.setting.pagestyle': '整體風格設置', + 'app.setting.pagestyle.light': '亮色菜單風格', + 'app.setting.pagestyle.dark': '暗色菜單風格', + 'app.setting.pagestyle.realdark': '暗黑模式', + 'app.setting.themecolor': '主題色', + 'app.setting.navigationmode': '導航模式', + 'app.setting.sidemenu.nav': '側邊欄導航', + 'app.setting.topmenu.nav': '頂部欄導航', + 'app.setting.content-width': '內容區域寬度', + 'app.setting.content-width.tooltip': '該設定僅 [頂部欄導航] 時有效', + 'app.setting.content-width.fixed': '固定', + 'app.setting.content-width.fluid': '流式', + 'app.setting.fixedheader': '固定 Header', + 'app.setting.fixedheader.tooltip': '固定 Header 時可配置', + 'app.setting.autoHideHeader': '下滑時隱藏 Header', + 'app.setting.fixedsidebar': '固定側邊菜單', + 'app.setting.sidemenu': '側邊菜單布局', + 'app.setting.topmenu': '頂部菜單布局', + 'app.setting.othersettings': '其他設置', + 'app.setting.weakmode': '色弱模式', + 'app.setting.multitab': '多頁籤模式', + 'app.setting.copy': '拷貝設置', + 'app.setting.loading': '加載主題中', + 'app.setting.copyinfo': '拷貝設置成功 src/config/defaultSettings.js', + 'app.setting.copy.success': '復制完畢', + 'app.setting.copy.fail': '復制失敗', + 'app.setting.theme.switching': '正在切換主題!', + 'app.setting.production.hint': '配置欄只在開發環境用於預覽,生產環境不會展現,請拷貝後手動修改配置文件', + 'app.setting.themecolor.daybreak': '拂曉藍(默認)', + 'app.setting.themecolor.dust': '薄暮', + 'app.setting.themecolor.volcano': '火山', + 'app.setting.themecolor.sunset': '日暮', + 'app.setting.themecolor.cyan': '明青', + 'app.setting.themecolor.green': '極光綠', + 'app.setting.themecolor.geekblue': '極客藍', + 'app.setting.themecolor.purple': '醬紫', + 'app.setting.tooltip': '頁面設置', + 'user.login.userName': '用戶名', + 'user.login.password': '密碼', + 'user.login.username.placeholder': '賬戶: admin', + 'user.login.password.placeholder': '密碼: admin or ant.design', + 'user.login.message-invalid-credentials': '登錄失敗,請檢查郵箱和驗證碼', + 'user.login.message-invalid-verification-code': '驗證碼錯誤', + 'user.login.tab-login-credentials': '賬戶密碼登錄', + 'user.login.tab-login-email': '郵箱登錄', + 'user.login.tab-login-mobile': '手機號登錄', + 'user.login.captcha.placeholder': '請輸入圖形驗證碼', + 'user.login.mobile.placeholder': '手機號', + 'user.login.mobile.verification-code.placeholder': '驗證碼', + 'user.login.email.placeholder': '請輸入郵箱地址', + 'user.login.email.verification-code.placeholder': '請輸入驗證碼', + 'user.login.email.sending': '驗證碼發送中...', + 'user.login.email.send-success-title': '提示', + 'user.login.email.send-success': '驗證碼發送成功,請查收郵件', + 'user.login.sms.send-success': '驗證碼發送成功,請查收短信', + 'user.login.remember-me': '自動登錄', + 'user.login.forgot-password': '忘記密碼', + 'user.login.sign-in-with': '其他登錄方式', + 'user.login.signup': '注冊賬戶', + 'user.login.login': '登錄', + 'user.register.register': '注冊', + 'user.register.email.placeholder': '郵箱', + 'user.register.password.placeholder': '請至少輸入 6 個字符。請不要使用容易被猜到的密碼。', + 'user.register.password.popover-message': '請至少輸入 6 個字符。請不要使用容易被猜到的密碼。', + 'user.register.confirm-password.placeholder': '確認密碼', + 'user.register.get-verification-code': '獲取驗證碼', + 'user.register.sign-in': '使用已有賬戶登錄', + 'user.register-result.msg': '你的賬戶:{email} 注冊成功', + 'user.register-result.activation-email': '激活郵件已發送到你的郵箱中,郵件有效期爲24小時。請及時登錄郵箱,點擊郵件中的鏈接激活帳戶。', + 'user.register-result.back-home': '返回首頁', + 'user.register-result.view-mailbox': '查看郵箱', + 'user.email.required': '請輸入郵箱地址!', + 'user.email.wrong-format': '郵箱地址格式錯誤!', + 'user.userName.required': '請輸入帳戶名或郵箱地址', + 'user.password.required': '請輸入密碼!', + 'user.password.twice.msg': '兩次輸入的密碼不匹配!', + 'user.password.strength.msg': '密碼強度不夠 ', + 'user.password.strength.strong': '強度:強', + 'user.password.strength.medium': '強度:中', + 'user.password.strength.low': '強度:低', + 'user.password.strength.short': '強度:太短', + 'user.confirm-password.required': '請確認密碼!', + 'user.phone-number.required': '請輸入正確的手機號', + 'user.phone-number.wrong-format': '手機號格式錯誤!', + 'user.verification-code.required': '請輸入驗證碼!', + 'user.captcha.required': '請輸入圖形驗證碼!', + 'user.login.infos': 'QuantDinger 是一個 AI 多智能體股票分析輔助工具,不具備證券投資諮詢資質。平臺中的所有分析結果、評分、參考意見均由 AI 基於歷史數據自動生成,僅供學習、研究與技術交流使用,不構成任何投資建議或決策依據。股票投資存在市場風險、流動性風險、政策風險等多種風險,可能導致本金損失。用戶應基於自身風險承受能力獨立決策,使用本工具產生的任何投資行爲及其後果由用戶自行承擔。市場有風險,投資需謹慎。', + 'user.login.tab-login-web3': 'Web3 登錄', + 'user.login.web3.tip': '使用錢包進行籤名登錄', + 'user.login.web3.connect': '連接錢包並登錄', + 'user.login.web3.no-wallet': '未檢測到錢包', + 'user.login.web3.no-address': '未獲取到錢包地址', + 'user.login.web3.nonce-failed': '獲取隨機數失敗', + 'user.login.web3.verify-failed': '籤名驗證失敗', + 'user.login.web3.success': '登錄成功', + 'user.login.web3.failed': '錢包登錄失敗', + 'nav.no_wallet': '未檢測到錢包', + 'nav.copy': '復制', + 'nav.copy_success': '復制成功', + 'user.login.oauth.google': '使用 Google 登錄', + 'user.login.oauth.github': '使用 GitHub 登錄', + 'user.login.oauth.loading': '正在跳轉到授權頁面...', + 'user.login.oauth.failed': 'OAuth 登錄失敗', + 'user.login.oauth.get-url-failed': '獲取授權鏈接失敗', + 'user.login.subtitle': '驅動的全球市場量化洞察', + 'user.login.legal.title': '法律免責聲明', + 'user.login.legal.view': '查看法律免責聲明', + 'user.login.legal.collapse': '收起法律免責聲明', + 'user.login.legal.agree': '我已閱讀並同意法律免責聲明', + 'user.login.legal.required': '請先閱讀並勾選法律免責聲明', + 'user.login.legal.content': 'QuantDinger 是一個 AI 多智能體股票分析輔助工具,不具備證券投資諮詢資質。平臺中的所有分析結果、評分、參考意見均由 AI 基於歷史數據自動生成,僅供學習、研究與技術交流使用,不構成任何投資建議或決策依據。股票投資存在市場風險、流動性風險、政策風險等多種風險,可能導致本金損失。用戶應基於自身風險承受能力獨立決策,使用本工具產生的任何投資行爲及其後果由用戶自行承擔。市場有風險,投資需謹慎。', + 'user.login.privacy.title': '用戶隱私條款', + 'user.login.privacy.view': '查看用戶隱私條款', + 'user.login.privacy.collapse': '收起用戶隱私條款', + 'user.login.privacy.content': '我們重視您的隱私與數據保護。1) 收集範圍:僅收集實現功能所需的信息(如郵箱、手機號、區號、Web3 錢包地址)以及必要的日志與設備信息。2) 使用目的:用於賬戶登錄與安全校驗、服務功能提供、問題排查與合規要求。3) 存儲與安全:數據加密存儲,並採取必要的權限與訪問控制措施,盡力防止未經授權的訪問、披露或丟失。4) 共享與第三方:除法律法規要求或履行服務所必需外,不會與第三方共享您的個人信息;若涉及第三方服務(如錢包、短信服務商),僅在實現功能所需的最小範圍內處理。5) Cookies/本地存儲:用於登錄態與必要的會話維持(如令牌、PHPSESSID),您可在瀏覽器中進行清理或限制。6) 個人權利:您可根據法律法規行使查詢、更正、刪除、撤回同意等權利。7) 變更與通知:本條款更新後將在頁面顯著位置提示。繼續使用本服務即表示您已閱讀並同意更新內容。若您不同意本條款或其中任何更新,請停止使用本服務並聯系我們。', + 'account.basicInfo': '基礎信息', + 'account.id': '用戶ID', + 'account.username': '用戶名', + 'account.nickname': '暱稱', + 'account.email': '郵箱', + 'account.mobile': '手機號', + 'account.web3address': '錢包地址', + 'account.pid': '推薦人ID', + 'account.level': '用戶等級', + 'account.money': '餘額', + 'account.qdtBalance': 'QDT 餘額', + 'account.score': '積分', + 'account.createtime': '注冊時間', + 'account.inviteLink': '邀請鏈接', + 'account.recharge': '充值', + 'account.rechargeTip': '請使用微信或支付寶掃描下方二維碼進行充值', + 'account.qrCodePlaceholder': '二維碼佔位符(模擬)', + 'account.rechargeAmount': '充值金額', + 'account.enterAmount': '請輸入充值金額', + 'account.rechargeHint': '最小充值金額:1 QDT', + 'account.confirmRecharge': '確認充值', + 'account.enterValidAmount': '請輸入有效的充值金額', + 'account.rechargeSuccess': '充值成功!已充值 {amount} QDT', + 'account.settings.menuMap.basic': '基本設置', + 'account.settings.menuMap.security': '安全設置', + 'account.settings.menuMap.notification': '新消息通知', + 'account.settings.menuMap.moneyLog': '資金明細', + 'account.moneyLog.empty': '暫無資金明細', + 'account.moneyLog.total': '共 {total} 條記錄', + 'account.moneyLog.type.purchase': '購買指標', + 'account.moneyLog.type.recharge': '充值', + 'account.moneyLog.type.refund': '退款', + 'account.moneyLog.type.reward': '獎勵', + 'account.moneyLog.type.income': '指標收入', + 'account.moneyLog.type.commission': '平臺手續費', + 'wallet.balance': 'QDT 餘額', + 'wallet.recharge': '充值', + 'wallet.withdraw': '提現', + 'wallet.filter': '篩選', + 'wallet.reset': '重置', + 'wallet.totalRecharge': '累計充值', + 'wallet.totalWithdraw': '累計提現', + 'wallet.totalIncome': '累計收入', + 'wallet.records': '交易記錄與資金明細', + 'wallet.tradingRecords': '交易記錄', + 'wallet.moneyLog': '資金明細', + 'wallet.rechargeTip': '請使用微信或支付寶掃描下方二維碼進行充值', + 'wallet.qrCodePlaceholder': '二維碼佔位符(模擬)', + 'wallet.rechargeAmount': '充值金額', + 'wallet.enterAmount': '請輸入充值金額', + 'wallet.rechargeHint': '最小充值金額:1 QDT', + 'wallet.confirmRecharge': '確認充值', + 'wallet.enterValidAmount': '請輸入有效的充值金額', + 'wallet.rechargeSuccess': '充值成功!已充值 {amount} QDT', + 'wallet.rechargeFailed': '充值失敗', + 'wallet.withdrawTip': '請輸入提現金額和提現地址', + 'wallet.withdrawAmount': '提現金額', + 'wallet.enterWithdrawAmount': '請輸入提現金額', + 'wallet.withdrawHint': '最小提現金額:1 QDT', + 'wallet.withdrawAddress': '提現地址(可選)', + 'wallet.enterWithdrawAddress': '請輸入提現地址', + 'wallet.confirmWithdraw': '確認提現', + 'wallet.enterValidWithdrawAmount': '請輸入有效的提現金額', + 'wallet.insufficientBalance': '餘額不足', + 'wallet.withdrawSuccess': '提現成功!已提現 {amount} QDT', + 'wallet.withdrawFailed': '提現失敗', + 'wallet.noTradingRecords': '暫無交易記錄', + 'wallet.noMoneyLog': '暫無資金明細', + 'wallet.loadTradingRecordsFailed': '加載交易記錄失敗', + 'wallet.loadMoneyLogFailed': '加載資金明細失敗', + 'wallet.moneyLogTotal': '共 {total} 條記錄', + 'wallet.moneyLogTypeTitle': '類型', + 'wallet.moneyLogType.all': '全部類型', + 'wallet.table.time': '時間', + 'wallet.table.type': '類型', + 'wallet.table.price': '價格', + 'wallet.table.amount': '數量', + 'wallet.table.money': '金額', + 'wallet.table.balance': '餘額', + 'wallet.table.memo': '備注', + 'wallet.table.value': '價值', + 'wallet.table.profit': '盈虧', + 'wallet.table.commission': '手續費', + 'wallet.table.total': '共 {total} 條記錄', + 'wallet.tradeType.buy': '買入', + 'wallet.tradeType.sell': '賣出', + 'wallet.tradeType.liquidation': '強平', + 'wallet.tradeType.openLong': '開多', + 'wallet.tradeType.addLong': '加多', + 'wallet.tradeType.closeLong': '平多', + 'wallet.tradeType.closeLongStop': '止損平多', + 'wallet.tradeType.closeLongProfit': '止盈平多', + 'wallet.tradeType.openShort': '開空', + 'wallet.tradeType.addShort': '加空', + 'wallet.tradeType.closeShort': '平空', + 'wallet.tradeType.closeShortStop': '止損平空', + 'wallet.tradeType.closeShortProfit': '止盈平空', + 'wallet.moneyLogType.purchase': '購買指標', + 'wallet.moneyLogType.recharge': '充值', + 'wallet.moneyLogType.withdraw': '提現', + 'wallet.moneyLogType.refund': '退款', + 'wallet.moneyLogType.reward': '獎勵', + 'wallet.moneyLogType.income': '收入', + 'wallet.moneyLogType.commission': '手續費', + 'wallet.web3Address.required': '請先填寫Web3錢包地址', + 'wallet.web3Address.requiredDescription': '充值前需要先綁定您的Web3錢包地址,用於接收充值', + 'wallet.web3Address.placeholder': '請輸入您的Web3錢包地址(0x開頭)', + 'wallet.web3Address.save': '保存錢包地址', + 'wallet.web3Address.saveSuccess': '錢包地址保存成功', + 'wallet.web3Address.saveFailed': '保存錢包地址失敗', + 'wallet.web3Address.invalidFormat': '請輸入有效的Web3錢包地址(以太坊格式:0x開頭42位字符,或TRC20格式:T開頭34位字符)', + 'wallet.selectCoin': '選擇幣種', + 'wallet.selectChain': '選擇鏈', + 'wallet.chain.eth': 'ETH(ERC20)', + 'wallet.chain.trc20': 'TRC20', + 'wallet.chain.bsc': 'BSC', + 'wallet.targetQdtAmount': '想要兌換的QDT數量(可選)', + 'wallet.targetQdtAmount.placeholder': '請輸入想要兌換的QDT數量,當前QDT價格:{price} USDT', + 'wallet.targetQdtAmount.requiredUsdt': '需要充值:{amount} USDT', + 'wallet.rechargeAddress': '充值地址', + 'wallet.copyAddress': '復制', + 'wallet.copySuccess': '復制成功!', + 'wallet.copyFailed': '復制失敗,請手動復制', + 'wallet.rechargeAddressHint': '請確保使用{chain}鏈向此地址充值{coin}', + 'wallet.qdtPrice.loading': '加載中...', + 'wallet.rechargeTip.new': '請選擇幣種和鏈,然後掃描下方二維碼進行充值', + 'wallet.exchangeRate': '1 QDT ≈ {rate} USDT', + 'wallet.withdrawableAmount': '可提現金額', + 'wallet.totalBalance': '總餘額', + 'wallet.insufficientWithdrawable': '可提現金額不足,當前可提現:{amount} QDT', + 'wallet.withdrawAddressRequired': '請輸入提現地址', + 'wallet.withdrawAddressHint': '請確保地址正確,提現後無法撤銷', + 'wallet.withdrawSubmitSuccess': '提現申請提交成功,請等待審核', + 'menu.footer.contactUs': '聯系我們', + 'menu.footer.getSupport': '獲取支持', + 'menu.footer.socialAccounts': '社交賬戶', + 'menu.footer.userAgreement': '用戶協議', + 'menu.footer.privacyPolicy': '隱私條例', + 'menu.footer.support': 'Support', + 'menu.footer.featureRequest': 'Feature request', + 'menu.footer.email': 'Email', + 'menu.footer.liveChat': '24/7 live chat', + 'dashboard.analysis.title': 'Multi-Dimensional Analysis', + 'dashboard.analysis.subtitle': 'AI驅動的綜合金融分析平臺', + 'dashboard.analysis.selectSymbol': '選擇或輸入標的代碼', + 'dashboard.analysis.selectModel': '選擇模型', + 'dashboard.analysis.startAnalysis': '開始分析', + 'dashboard.analysis.history': '歷史記錄', + 'dashboard.analysis.tab.overview': '綜合分析', + 'dashboard.analysis.tab.fundamental': '基本面', + 'dashboard.analysis.tab.technical': '技術', + 'dashboard.analysis.tab.news': '新聞', + 'dashboard.analysis.tab.sentiment': '情緒', + 'dashboard.analysis.tab.risk': '風險', + 'dashboard.analysis.tab.debate': '多空辯論', + 'dashboard.analysis.tab.decision': '最終決策', + 'dashboard.analysis.empty.selectSymbol': '選擇標的開始分析', + 'dashboard.analysis.empty.selectSymbolDesc': '從自選股列表中選擇或輸入標的代碼,獲取多維度AI分析報告', + 'dashboard.analysis.empty.startAnalysis': '點擊"開始分析"按鈕進行多維度分析', + 'dashboard.analysis.empty.startAnalysisDesc': '我們將從基本面、技術、新聞、情緒和風險等多個維度爲您提供全面的分析', + 'dashboard.analysis.empty.noData': '暫無{type}分析數據,請先進行綜合分析', + 'dashboard.analysis.empty.noWatchlist': '暫無自選股', + 'dashboard.analysis.empty.noHistory': '暫無歷史記錄', + 'dashboard.analysis.empty.watchlistHint': '暫無自選股,請先', + 'dashboard.analysis.empty.noDebateData': '暫無辯論數據', + 'dashboard.analysis.empty.noDecisionData': '暫無決策數據', + 'dashboard.analysis.empty.selectAgent': '請選擇一個 Agent 查看分析結果', + 'dashboard.analysis.loading.analyzing': '正在分析中,請稍候...', + 'dashboard.analysis.loading.fundamental': '正在分析基本面數據...', + 'dashboard.analysis.loading.technical': '正在分析技術指標...', + 'dashboard.analysis.loading.news': '正在分析新聞數據...', + 'dashboard.analysis.loading.sentiment': '正在分析市場情緒...', + 'dashboard.analysis.loading.risk': '正在評估風險...', + 'dashboard.analysis.loading.debate': '正在進行多空辯論...', + 'dashboard.analysis.loading.decision': '正在生成最終決策...', + 'dashboard.analysis.score.overall': '綜合評分', + 'dashboard.analysis.score.recommendation': '投資建議', + 'dashboard.analysis.score.confidence': '置信度', + 'dashboard.analysis.dimension.fundamental': '基本面', + 'dashboard.analysis.dimension.technical': '技術', + 'dashboard.analysis.dimension.news': '新聞', + 'dashboard.analysis.dimension.sentiment': '情緒', + 'dashboard.analysis.dimension.risk': '風險', + 'dashboard.analysis.card.dimensionScores': '各維度評分', + 'dashboard.analysis.card.overviewReport': '綜合分析報告', + 'dashboard.analysis.card.financialMetrics': '財務指標', + 'dashboard.analysis.card.fundamentalReport': '基本面分析報告', + 'dashboard.analysis.card.technicalIndicators': '技術指標', + 'dashboard.analysis.card.technicalReport': '技術分析報告', + 'dashboard.analysis.card.newsList': '相關新聞', + 'dashboard.analysis.card.newsReport': '新聞分析報告', + 'dashboard.analysis.card.sentimentIndicators': '情緒指標', + 'dashboard.analysis.card.sentimentReport': '情緒分析報告', + 'dashboard.analysis.card.riskMetrics': '風險指標', + 'dashboard.analysis.card.riskReport': '風險評估報告', + 'dashboard.analysis.card.bullView': '看漲觀點 (Bull)', + 'dashboard.analysis.card.bearView': '看跌觀點 (Bear)', + 'dashboard.analysis.card.researchConclusion': '研究員結論', + 'dashboard.analysis.card.traderPlan': '交易員計劃', + 'dashboard.analysis.card.riskDebate': '風險委員會辯論', + 'dashboard.analysis.card.finalDecision': '最終決策 (Final Decision)', + 'dashboard.analysis.card.tradePlanDetail': '交易計劃詳情', + 'dashboard.analysis.tradingPlan.entry_price': '入場價格', + 'dashboard.analysis.tradingPlan.position_size': '倉位大小', + 'dashboard.analysis.tradingPlan.stop_loss': '止損', + 'dashboard.analysis.tradingPlan.take_profit': '止盈', + 'dashboard.analysis.label.confidence': '置信度', + 'dashboard.analysis.label.keyPoints': '核心要點', + 'dashboard.analysis.label.riskWarning': '風險提示', + 'dashboard.analysis.risk.risky': '激進派觀點 (Risky)', + 'dashboard.analysis.risk.neutral': '中立派觀點 (Neutral)', + 'dashboard.analysis.risk.safe': '保守派觀點 (Safe)', + 'dashboard.analysis.risk.conclusion': '結論', + 'dashboard.analysis.feature.fundamental': '基本面分析', + 'dashboard.analysis.feature.technical': '技術分析', + 'dashboard.analysis.feature.news': '新聞分析', + 'dashboard.analysis.feature.sentiment': '情緒分析', + 'dashboard.analysis.feature.risk': '風險評估', + 'dashboard.analysis.watchlist.title': '我的自選股', + 'dashboard.analysis.watchlist.add': '添加', + 'dashboard.analysis.watchlist.addStock': '添加股票', + 'dashboard.analysis.modal.addStock.title': '添加自選股', + 'dashboard.analysis.modal.addStock.confirm': '確定', + 'dashboard.analysis.modal.addStock.cancel': '取消', + 'dashboard.analysis.modal.addStock.market': '市場類型', + 'dashboard.analysis.modal.addStock.marketPlaceholder': '請選擇市場', + 'dashboard.analysis.modal.addStock.marketRequired': '請選擇市場類型', + 'dashboard.analysis.modal.addStock.symbol': '股票代碼', + 'dashboard.analysis.modal.addStock.symbolPlaceholder': '例如: AAPL, TSLA, GOOGL, 000001, BTC', + 'dashboard.analysis.modal.addStock.symbolRequired': '請輸入股票代碼', + 'dashboard.analysis.modal.addStock.searchPlaceholder': '搜索標的代碼或名稱', + 'dashboard.analysis.modal.addStock.searchOrInputPlaceholder': '搜索或輸入標的代碼(如:AAPL、BTC/USDT、EUR/USD)', + 'dashboard.analysis.modal.addStock.searchOrInputHint': '支持搜索數據庫中的標的,或直接輸入代碼(系統將自動獲取名稱)', + 'dashboard.analysis.modal.addStock.search': '搜索', + 'dashboard.analysis.modal.addStock.searchResults': '搜索結果', + 'dashboard.analysis.modal.addStock.hotSymbols': '熱門標的', + 'dashboard.analysis.modal.addStock.noHotSymbols': '暫無熱門標的', + 'dashboard.analysis.modal.addStock.selectedSymbol': '已選標的', + 'dashboard.analysis.modal.addStock.pleaseSelectSymbol': '請先選擇一個標的', + 'dashboard.analysis.modal.addStock.pleaseSelectOrEnterSymbol': '請先選擇一個標的或輸入標的代碼', + 'dashboard.analysis.modal.addStock.pleaseEnterSymbol': '請輸入標的代碼', + 'dashboard.analysis.modal.addStock.pleaseSelectMarket': '請先選擇市場類型', + 'dashboard.analysis.modal.addStock.searchFailed': '搜索失敗,請稍後重試', + 'dashboard.analysis.modal.addStock.noSearchResults': '未找到匹配的標的', + 'dashboard.analysis.modal.addStock.willAutoFetchName': '系統將自動獲取名稱', + 'dashboard.analysis.modal.addStock.addDirectly': '直接添加', + 'dashboard.analysis.modal.addStock.nameWillBeFetched': '名稱將在添加時自動獲取', + 'dashboard.analysis.market.AShare': 'A股', + 'dashboard.analysis.market.USStock': '美股', + 'dashboard.analysis.market.HShare': '港股', + 'dashboard.analysis.market.Crypto': '加密貨幣', + 'dashboard.analysis.market.Forex': '外匯', + 'dashboard.analysis.market.Futures': '期貨', + 'dashboard.analysis.modal.history.title': '歷史分析記錄', + 'dashboard.analysis.modal.history.viewResult': '查看結果', + 'dashboard.analysis.modal.history.completeTime': '完成時間', + 'dashboard.analysis.modal.history.error': '錯誤', + 'dashboard.analysis.status.pending': '待處理', + 'dashboard.analysis.status.processing': '處理中', + 'dashboard.analysis.status.completed': '已完成', + 'dashboard.analysis.status.failed': '失敗', + 'dashboard.analysis.message.selectSymbol': '請先選擇標的', + 'dashboard.analysis.message.taskCreated': '分析任務已創建,正在後臺執行...', + 'dashboard.analysis.message.analysisComplete': '分析完成', + 'dashboard.analysis.message.analysisCompleteCache': '分析完成(使用緩存數據)', + 'dashboard.analysis.message.analysisFailed': '分析失敗,請稍後重試', + 'dashboard.analysis.message.addStockSuccess': '添加成功', + 'dashboard.analysis.message.addStockFailed': '添加失敗', + 'dashboard.analysis.message.removeStockSuccess': '移除成功', + 'dashboard.analysis.message.removeStockFailed': '移除失敗', + 'dashboard.analysis.test': '工專路 {no} 號店', + 'dashboard.analysis.introduce': '指標說明', + 'dashboard.analysis.total-sales': '總銷售額', + 'dashboard.analysis.day-sales': '日均銷售額¥', + 'dashboard.analysis.visits': '訪問量', + 'dashboard.analysis.visits-trend': '訪問量趨勢', + 'dashboard.analysis.visits-ranking': '門店訪問量排名', + 'dashboard.analysis.day-visits': '日訪問量', + 'dashboard.analysis.week': '周同比', + 'dashboard.analysis.day': '日同比', + 'dashboard.analysis.payments': '支付筆數', + 'dashboard.analysis.conversion-rate': '轉化率', + 'dashboard.analysis.operational-effect': '運營活動效果', + 'dashboard.analysis.sales-trend': '銷售趨勢', + 'dashboard.analysis.sales-ranking': '門店銷售額排名', + 'dashboard.analysis.all-year': '全年', + 'dashboard.analysis.all-month': '本月', + 'dashboard.analysis.all-week': '本周', + 'dashboard.analysis.all-day': '今日', + 'dashboard.analysis.search-users': '搜索用戶數', + 'dashboard.analysis.per-capita-search': '人均搜索次數', + 'dashboard.analysis.online-top-search': '線上熱門搜索', + 'dashboard.analysis.the-proportion-of-sales': '銷售額類別佔比', + 'dashboard.analysis.dropdown-option-one': '操作一', + 'dashboard.analysis.dropdown-option-two': '操作二', + 'dashboard.analysis.channel.all': '全部渠道', + 'dashboard.analysis.channel.online': '線上', + 'dashboard.analysis.channel.stores': '門店', + 'dashboard.analysis.sales': '銷售額', + 'dashboard.analysis.traffic': '客流量', + 'dashboard.analysis.table.rank': '排名', + 'dashboard.analysis.table.search-keyword': '搜索關鍵詞', + 'dashboard.analysis.table.users': '用戶數', + 'dashboard.analysis.table.weekly-range': '周漲幅', + 'dashboard.indicator.selectSymbol': '選擇或輸入標的代碼', + 'dashboard.indicator.emptyWatchlistHint': '暫無自選股,請先添加', + 'dashboard.indicator.hint.selectSymbol': '請選擇標的開始分析', + 'dashboard.indicator.hint.selectSymbolDesc': '在上方搜索框中選擇或輸入股票代碼,查看K線圖表和技術指標', + 'dashboard.indicator.retry': '重試', + 'dashboard.indicator.panel.title': '技術指標', + 'dashboard.indicator.panel.realtimeOn': '關閉實時更新', + 'dashboard.indicator.panel.realtimeOff': '開啓實時更新', + 'dashboard.indicator.panel.themeLight': '切換到深色主題', + 'dashboard.indicator.panel.themeDark': '切換到淺色主題', + 'dashboard.indicator.section.enabled': '已啓用', + 'dashboard.indicator.section.added': '我添加的指標', + 'dashboard.indicator.section.custom': '自己創建的指標', + 'dashboard.indicator.section.bought': '我選購的指標', + 'dashboard.indicator.section.myCreated': '我創建的指標', + 'dashboard.indicator.empty': '暫無指標,請先添加或創建指標', + 'dashboard.indicator.buy': '購買指標', + 'dashboard.indicator.action.start': '啓動', + 'dashboard.indicator.action.stop': '關閉', + 'dashboard.indicator.action.edit': '編輯', + 'dashboard.indicator.action.delete': '刪除', + 'dashboard.indicator.action.backtest': '回測', + 'dashboard.indicator.status.normal': '正常', + 'dashboard.indicator.status.normalPermanent': '正常(永久有效)', + 'dashboard.indicator.status.expired': '已過期', + 'dashboard.indicator.expiry.permanent': '永久有效', + 'dashboard.indicator.expiry.noExpiry': '無到期時間', + 'dashboard.indicator.expiry.expired': '已過期:{date}', + 'dashboard.indicator.expiry.expiresOn': '到期時間:{date}', + 'dashboard.indicator.delete.confirmTitle': '確認刪除', + 'dashboard.indicator.delete.confirmContent': '確定要刪除指標"{name}"嗎?此操作不可恢復。', + 'dashboard.indicator.delete.confirmOk': '刪除', + 'dashboard.indicator.delete.confirmCancel': '取消', + 'dashboard.indicator.delete.success': '刪除成功', + 'dashboard.indicator.delete.failed': '刪除失敗', + 'dashboard.indicator.save.success': '保存成功', + 'dashboard.indicator.save.failed': '保存失敗', + 'dashboard.indicator.error.loadWatchlistFailed': '加載自選股失敗', + 'dashboard.indicator.error.chartNotReady': '圖表組件未初始化,請先選擇標的並等待圖表加載完成', + 'dashboard.indicator.error.chartMethodNotReady': '圖表組件方法未就緒,請稍後重試', + 'dashboard.indicator.error.chartExecuteNotReady': '圖表組件執行方法未就緒,請稍後重試', + 'dashboard.indicator.error.parseFailed': '無法解析Python代碼', + 'dashboard.indicator.error.parseFailedCheck': '無法解析Python代碼,請檢查代碼格式', + 'dashboard.indicator.error.addIndicatorFailed': '添加指標失敗', + 'dashboard.indicator.error.runIndicatorFailed': '運行指標失敗', + 'dashboard.indicator.error.pleaseLogin': '請先登錄', + 'dashboard.indicator.error.loadDataFailed': '數據加載失敗', + 'dashboard.indicator.error.loadDataFailedDesc': '請檢查網絡連接', + 'dashboard.indicator.error.pythonEngineFailed': 'Python 引擎加載失敗,指標功能可能無法使用', + 'dashboard.indicator.error.chartInitFailed': '圖表初始化失敗', + 'dashboard.indicator.warning.enterCode': '請先輸入指標代碼', + 'dashboard.indicator.warning.pyodideLoadFailed': 'Python 引擎加載失敗', + 'dashboard.indicator.warning.pyodideLoadFailedDesc': '您當前所在區域或網絡環境無法使用該功能', + 'dashboard.indicator.warning.chartNotInitialized': '圖表未初始化,無法使用畫線工具', + 'dashboard.indicator.success.runIndicator': '指標運行成功', + 'dashboard.indicator.success.clearDrawings': '已清除所有畫線', + 'dashboard.indicator.sma': 'SMA (均線組合)', + 'dashboard.indicator.ema': 'EMA (指數均線組合)', + 'dashboard.indicator.rsi': 'RSI (相對強弱)', + 'dashboard.indicator.macd': 'MACD', + 'dashboard.indicator.bb': '布林帶 (Bollinger Bands)', + 'dashboard.indicator.atr': 'ATR (平均真實波幅)', + 'dashboard.indicator.cci': 'CCI (商品通道指數)', + 'dashboard.indicator.williams': 'Williams %R (威廉指標)', + 'dashboard.indicator.mfi': 'MFI (資金流量指標)', + 'dashboard.indicator.adx': 'ADX (平均趨向指數)', + 'dashboard.indicator.obv': 'OBV (能量潮)', + 'dashboard.indicator.adosc': 'ADOSC (積累/派發振蕩器)', + 'dashboard.indicator.ad': 'AD (積累/派發線)', + 'dashboard.indicator.kdj': 'KDJ (隨機指標)', + 'dashboard.indicator.signal.buy': 'BUY', + 'dashboard.indicator.signal.sell': 'SELL', + 'dashboard.indicator.signal.supertrendBuy': 'SuperTrend Buy', + 'dashboard.indicator.signal.supertrendSell': 'SuperTrend Sell', + 'dashboard.indicator.chart.kline': 'K線', + 'dashboard.indicator.chart.volume': '成交量', + 'dashboard.indicator.chart.uptrend': 'Up Trend', + 'dashboard.indicator.chart.downtrend': 'Down Trend', + 'dashboard.indicator.tooltip.time': '時間', + 'dashboard.indicator.tooltip.open': '開', + 'dashboard.indicator.tooltip.close': '收', + 'dashboard.indicator.tooltip.high': '高', + 'dashboard.indicator.tooltip.low': '低', + 'dashboard.indicator.tooltip.volume': '成交量', + 'dashboard.indicator.drawing.line': '線段', + 'dashboard.indicator.drawing.horizontalLine': '水平線', + 'dashboard.indicator.drawing.verticalLine': '垂直線', + 'dashboard.indicator.drawing.ray': '射線', + 'dashboard.indicator.drawing.straightLine': '直線', + 'dashboard.indicator.drawing.parallelLine': '平行線', + 'dashboard.indicator.drawing.priceLine': '價格線', + 'dashboard.indicator.drawing.priceChannel': '價格通道', + 'dashboard.indicator.drawing.fibonacciLine': '斐波那契線', + 'dashboard.indicator.drawing.clearAll': '清除所有畫線', + 'dashboard.indicator.market.AShare': 'A股', + 'dashboard.indicator.market.USStock': '美股', + 'dashboard.indicator.market.HShare': '港股', + 'dashboard.indicator.market.Crypto': '加密貨幣', + 'dashboard.indicator.market.Forex': '外匯', + 'dashboard.indicator.market.Futures': '期貨', + 'dashboard.indicator.create': '創建指標', + 'dashboard.indicator.editor.title': '創建/編輯指標', + 'dashboard.indicator.editor.name': '指標名稱', + 'dashboard.indicator.editor.nameRequired': '請輸入指標名稱', + 'dashboard.indicator.editor.namePlaceholder': '請輸入指標名稱', + 'dashboard.indicator.editor.description': '指標描述', + 'dashboard.indicator.editor.descriptionPlaceholder': '請輸入指標描述(可選)', + 'dashboard.indicator.editor.code': '指標腳本(Python)', + 'dashboard.indicator.editor.codeRequired': '請輸入指標代碼', + 'dashboard.indicator.editor.codePlaceholder': '請輸入Python代碼', + 'dashboard.indicator.editor.run': '運行', + 'dashboard.indicator.editor.runHint': '點擊運行按鈕可以在K線圖上預覽指標效果', + 'dashboard.indicator.editor.guide': '開發指南', + 'dashboard.indicator.editor.guideTitle': 'Python 指標開發指南', + 'dashboard.indicator.editor.save': '保存', + 'dashboard.indicator.editor.cancel': '取消', + 'dashboard.indicator.editor.unnamed': '未命名指標', + 'dashboard.indicator.editor.publishToCommunity': '發布到社區', + 'dashboard.indicator.editor.publishToCommunityHint': '發布後,其他用戶可以在社區中查看和使用您的指標', + 'dashboard.indicator.editor.indicatorType': '指標類型', + 'dashboard.indicator.editor.indicatorTypeRequired': '請選擇指標類型', + 'dashboard.indicator.editor.indicatorTypePlaceholder': '請選擇指標類型', + 'dashboard.indicator.editor.previewImage': '預覽圖', + 'dashboard.indicator.editor.uploadImage': '上傳圖片', + 'dashboard.indicator.editor.previewImageHint': '建議尺寸:800x400,大小不超過2MB', + 'dashboard.indicator.editor.pricing': '售價(QDT)', + 'dashboard.indicator.editor.pricingType.free': '免費', + 'dashboard.indicator.editor.pricingType.permanent': '永久', + 'dashboard.indicator.editor.pricingType.monthly': '月付', + 'dashboard.indicator.editor.price': '價格', + 'dashboard.indicator.editor.priceRequired': '請輸入價格', + 'dashboard.indicator.editor.pricePlaceholder': '請輸入價格', + 'dashboard.indicator.editor.pricingHint': '免費指標雖然價格爲0,但平臺會根據您的指標使用量和好評率給予獎勵(QDT)', + 'dashboard.indicator.editor.aiGenerate': '智能生成', + 'dashboard.indicator.editor.aiPromptPlaceholder': '請描述你的信號邏輯(只輸出 buy/sell)與繪圖(plots)。倉位/風控/加減倉請在回測配置中設定。', + 'dashboard.indicator.editor.aiGenerateBtn': 'AI生成代碼', + 'dashboard.indicator.editor.aiPromptRequired': '請輸入您的想法', + 'dashboard.indicator.editor.aiGenerateSuccess': '代碼生成成功', + 'dashboard.indicator.editor.aiGenerateError': '代碼生成失敗,請稍後重試', + 'dashboard.indicator.boundary.message': '提示:指標腳本只負責「計算 + 繪圖 + buy/sell 信號」;倉位、風控、加減倉、手續費/滑點屬於策略執行配置。', + 'dashboard.indicator.boundary.indicatorRule': "指標腳本請只輸出 buy/sell(並設定 df['buy']/df['sell'])。不要在腳本內撰寫倉位管理、止盈止損、加減倉。", + 'dashboard.indicator.boundary.backtestRule': '規則:同一根K線若出現主信號(buy/sell→開/平倉/反手),本K線將跳過所有加倉與減倉。', + 'dashboard.indicator.backtest.title': '指標回測', + 'dashboard.indicator.backtest.config': '回測參數', + 'dashboard.indicator.backtest.startDate': '開始日期', + 'dashboard.indicator.backtest.endDate': '結束日期', + 'dashboard.indicator.backtest.selectStartDate': '選擇開始日期', + 'dashboard.indicator.backtest.selectEndDate': '選擇結束日期', + 'dashboard.indicator.backtest.startDateRequired': '請選擇開始日期', + 'dashboard.indicator.backtest.endDateRequired': '請選擇結束日期', + 'dashboard.indicator.backtest.initialCapital': '初始資金', + 'dashboard.indicator.backtest.initialCapitalRequired': '請輸入初始資金', + 'dashboard.indicator.backtest.commission': '手續費率(%)', + 'dashboard.indicator.backtest.commissionHint': '按名義價值(含槓桿)收取;每筆成交(開/平倉)都會從餘額扣除。', + 'dashboard.indicator.backtest.leverage': '槓杆倍率', + 'dashboard.indicator.backtest.tradeDirection': '交易方向', + 'dashboard.indicator.backtest.longOnly': '做多', + 'dashboard.indicator.backtest.shortOnly': '做空', + 'dashboard.indicator.backtest.both': '雙向', + 'dashboard.indicator.backtest.run': '開始回測', + 'dashboard.indicator.backtest.rerun': '重新回測', + 'dashboard.indicator.backtest.close': '關閉', + 'dashboard.indicator.backtest.running': '正在進行指標回測...', + 'dashboard.indicator.backtest.results': '回測結果', + 'dashboard.indicator.backtest.totalReturn': '總收益率', + 'dashboard.indicator.backtest.annualReturn': '年化收益', + 'dashboard.indicator.backtest.maxDrawdown': '最大回撤', + 'dashboard.indicator.backtest.sharpeRatio': '夏普比率', + 'dashboard.indicator.backtest.winRate': '勝率', + 'dashboard.indicator.backtest.profitFactor': '盈虧比', + 'dashboard.indicator.backtest.totalTrades': '交易次數', + 'dashboard.indicator.totalReturn': '總收益率', + 'dashboard.indicator.annualReturn': '年化收益率', + 'dashboard.indicator.maxDrawdown': '最大回撤', + 'dashboard.indicator.sharpeRatio': '夏普比率', + 'dashboard.indicator.winRate': '勝率', + 'dashboard.indicator.profitFactor': '盈虧比', + 'dashboard.indicator.totalTrades': '總交易次數', + 'dashboard.indicator.backtest.totalCommission': '總手續費', + 'dashboard.indicator.backtest.equityCurve': '收益曲線', + 'dashboard.indicator.backtest.strategy': '指標收益', + 'dashboard.indicator.backtest.benchmark': '基準收益', + 'dashboard.indicator.backtest.tradeHistory': '交易記錄', + 'dashboard.indicator.backtest.tradeTime': '時間', + 'dashboard.indicator.backtest.tradeType': '類型', + 'dashboard.indicator.backtest.buy': '買入', + 'dashboard.indicator.backtest.sell': '賣出', + 'dashboard.indicator.backtest.liquidation': '爆倉', + 'dashboard.indicator.backtest.openLong': '開多', + 'dashboard.indicator.backtest.closeLong': '平多', + 'dashboard.indicator.backtest.closeLongStop': '平多(止損)', + 'dashboard.indicator.backtest.closeLongProfit': '平多(止盈)', + 'dashboard.indicator.backtest.addLong': '加多倉', + 'dashboard.indicator.backtest.openShort': '開空', + 'dashboard.indicator.backtest.closeShort': '平空', + 'dashboard.indicator.backtest.closeShortStop': '平空(止損)', + 'dashboard.indicator.backtest.closeShortProfit': '平空(止盈)', + 'dashboard.indicator.backtest.addShort': '加空倉', + 'dashboard.indicator.backtest.price': '價格', + 'dashboard.indicator.backtest.amount': '數量', + 'dashboard.indicator.backtest.balance': '賬戶餘額', + 'dashboard.indicator.backtest.profit': '盈虧', + 'dashboard.indicator.backtest.success': '回測完成', + 'dashboard.indicator.backtest.failed': '回測失敗,請稍後重試', + 'dashboard.indicator.backtest.noIndicatorCode': '該指標沒有可回測的代碼', + 'dashboard.indicator.backtest.noSymbol': '請先選擇交易標的', + 'dashboard.indicator.backtest.dateRangeExceeded': '回測時間範圍超出限制:{timeframe}周期最多可回測{maxRange}', + 'dashboard.indicator.backtest.dateRangeExceededDays': '回測時間範圍超出限制:{timeframe}周期最多可回測{maxRange}({maxDays}天)', + 'dashboard.indicator.backtest.metaLine': '標的:{symbol} | 市場:{market} | 週期:{timeframe}', + 'dashboard.indicator.backtest.savedRunId': '回測已保存,記錄ID:{id}', + 'dashboard.indicator.backtest.historyTitle': '回測記錄', + 'dashboard.indicator.backtest.historyRefresh': '重新整理', + 'dashboard.indicator.backtest.historyView': '查看', + 'dashboard.indicator.backtest.historyNoData': '暫無回測記錄', + 'dashboard.indicator.backtest.historyUseCurrent': '僅目前幣種/週期', + 'dashboard.indicator.backtest.historyFilterSymbol': '幣種(Symbol)', + 'dashboard.indicator.backtest.historyFilterTimeframe': '週期(Timeframe)', + 'dashboard.indicator.backtest.historyApply': '套用篩選', + 'dashboard.indicator.backtest.historyAIAnalyze': 'AI分析', + 'dashboard.indicator.backtest.historyAIAnalyzeTitle': 'AI 分析建議', + 'dashboard.indicator.backtest.historyNoAIResult': '暫無 AI 分析結果', + 'dashboard.indicator.backtest.historyRunId': '記錄ID', + 'dashboard.indicator.backtest.historyCreatedAt': '時間', + 'dashboard.indicator.backtest.historyRange': '區間', + 'dashboard.indicator.backtest.historyStatus': '狀態', + 'dashboard.indicator.backtest.historyStatusSuccess': '成功', + 'dashboard.indicator.backtest.historyStatusFailed': '失敗', + 'dashboard.indicator.backtest.historyActions': '操作', + 'dashboard.indicator.backtest.prev': '上一步', + 'dashboard.indicator.backtest.next': '下一步', + 'dashboard.indicator.backtest.steps.strategy.title': '執行配置', + 'dashboard.indicator.backtest.steps.strategy.desc': '倉位/風控/加減倉(策略層)', + 'dashboard.indicator.backtest.steps.trading.title': '回測環境', + 'dashboard.indicator.backtest.steps.trading.desc': '時間/資金/費率/槓桿/滑點', + 'dashboard.indicator.backtest.steps.results.title': '結果', + 'dashboard.indicator.backtest.steps.results.desc': '統計與交易明細', + 'dashboard.indicator.backtest.panel.risk': '執行配置:風控(止損/移動止盈)', + 'dashboard.indicator.backtest.panel.scale': '執行配置:加倉(順勢/逆勢)', + 'dashboard.indicator.backtest.panel.reduce': '執行配置:減倉(順勢/逆勢)', + 'dashboard.indicator.backtest.panel.position': '執行配置:開倉倉位', + 'dashboard.indicator.backtest.field.stopLossPct': '止損(%)', + 'dashboard.indicator.backtest.field.takeProfitPct': '止盈(%)', + 'dashboard.indicator.backtest.field.trailingEnabled': '移動止盈', + 'dashboard.indicator.backtest.field.trailingStopPct': '移動回撤(%)', + 'dashboard.indicator.backtest.field.trailingActivationPct': '移動止盈激活(%)', + 'dashboard.indicator.backtest.field.slippage': '滑點(%)', + 'dashboard.indicator.backtest.field.trendAddEnabled': '順勢加倉', + 'dashboard.indicator.backtest.field.dcaAddEnabled': '逆勢加倉', + 'dashboard.indicator.backtest.field.trendAddStepPct': '加倉觸發(%)', + 'dashboard.indicator.backtest.field.dcaAddStepPct': '加倉觸發(%)', + 'dashboard.indicator.backtest.field.trendAddSizePct': '每次加倉資金占比(%)', + 'dashboard.indicator.backtest.field.dcaAddSizePct': '每次加倉資金占比(%)', + 'dashboard.indicator.backtest.field.trendAddMaxTimes': '最多加倉次數', + 'dashboard.indicator.backtest.field.dcaAddMaxTimes': '最多加倉次數', + 'dashboard.indicator.backtest.field.trendReduceEnabled': '順勢減倉', + 'dashboard.indicator.backtest.field.adverseReduceEnabled': '逆勢減倉', + 'dashboard.indicator.backtest.field.trendReduceStepPct': '順勢觸發(%)', + 'dashboard.indicator.backtest.field.adverseReduceStepPct': '逆勢觸發(%)', + 'dashboard.indicator.backtest.field.trendReduceSizePct': '每次減倉比例(%)', + 'dashboard.indicator.backtest.field.adverseReduceSizePct': '每次逆勢減倉比例(%)', + 'dashboard.indicator.backtest.field.trendReduceMaxTimes': '最多順勢減倉次數', + 'dashboard.indicator.backtest.field.adverseReduceMaxTimes': '最多逆勢減倉次數', + 'dashboard.indicator.backtest.field.entryPct': '開倉資金占比(%)', + 'dashboard.indicator.backtest.field.minOrderPct': '單次最小資金占比(%)(可選)', + 'dashboard.indicator.backtest.hint.entryPctMax': '最大可開倉:{maxPct}%(為後續加倉預留資金)', + 'dashboard.indicator.backtest.closeLongTrailing': '平多(移動止損/止盈)', + 'dashboard.indicator.backtest.reduceLong': '多頭減倉', + 'dashboard.indicator.backtest.closeShortTrailing': '平空(移動止損/止盈)', + 'dashboard.indicator.backtest.reduceShort': '空頭減倉', + 'dashboard.docs.title': '文檔中心', + 'dashboard.docs.search.placeholder': '搜索文檔...', + 'dashboard.docs.category.all': '全部', + 'dashboard.docs.category.guide': '開發指南', + 'dashboard.docs.category.api': 'API文檔', + 'dashboard.docs.category.tutorial': '教程', + 'dashboard.docs.category.faq': '常見問題', + 'dashboard.docs.featured.title': '推薦文檔', + 'dashboard.docs.featured.tag': '推薦', + 'dashboard.docs.list.views': '瀏覽', + 'dashboard.docs.list.author': '作者', + 'dashboard.docs.list.empty': '暫無文檔', + 'dashboard.docs.list.backToAll': '返回全部文檔', + 'dashboard.docs.list.total': '共 {count} 篇文檔', + 'dashboard.docs.detail.back': '返回文檔列表', + 'dashboard.docs.detail.updatedAt': '更新於', + 'dashboard.docs.detail.related': '相關文檔', + 'dashboard.docs.detail.notFound': '文檔不存在', + 'dashboard.docs.detail.error': '文檔不存在或已被刪除', + 'dashboard.docs.search.result': '搜索結果', + 'dashboard.docs.search.keyword': '關鍵詞', + 'community.filter.indicatorType': '指標類型', + 'community.filter.all': '全部', + 'community.filter.other': '其他選項', + 'community.filter.pricing': '定價類型', + 'community.filter.allPricing': '全部定價', + 'community.filter.sortBy': '排序方式', + 'community.filter.search': '搜索', + 'community.filter.searchPlaceholder': '搜索指標名稱', + 'community.indicatorType.trend': '趨勢型', + 'community.indicatorType.momentum': '動量型', + 'community.indicatorType.volatility': '波動率', + 'community.indicatorType.volume': '成交量', + 'community.indicatorType.custom': '自定義', + 'community.pricing.free': '免費', + 'community.pricing.paid': '付費', + 'community.sort.downloads': '下載量', + 'community.sort.rating': '評分', + 'community.sort.newest': '最新發布', + 'community.pagination.total': '共 {total} 個指標', + 'community.noDescription': '暫無描述', + 'community.detail.type': '類型', + 'community.detail.pricing': '定價', + 'community.detail.rating': '評分', + 'community.detail.downloads': '下載量', + 'community.detail.author': '作者', + 'community.detail.description': '簡介', + 'community.detail.detailContent': '詳細說明', + 'community.detail.backtestStats': '回測統計', + 'community.action.purchase': '購買指標', + 'community.action.addToMyIndicators': '添加到我的指標', + 'community.action.favorite': '收藏', + 'community.action.unfavorite': '取消收藏', + 'community.action.buyNow': '立即購買', + 'community.action.renew': '續費', + 'community.purchase.price': '價格', + 'community.purchase.permanent': '永久', + 'community.purchase.monthly': '月', + 'community.purchase.confirmBuy': '確認購買該指標({price} QDT)?', + 'community.purchase.confirmRenew': '確認續費該指標({price} QDT/月)?', + 'community.purchase.confirmFree': '確認添加該免費指標?', + 'community.purchase.confirmTitle': '確認操作', + 'community.purchase.owned': '您已購買該指標(永久有效)', + 'community.purchase.ownIndicator': '這是您發布的指標', + 'community.purchase.expired': '您的訂閱已過期', + 'community.purchase.expiresOn': '到期時間:{date}', + 'community.tabs.detail': '指標詳情', + 'community.tabs.backtest': '回測數據', + 'community.tabs.ratings': '用戶評價', + 'community.rating.myRating': '我的評分', + 'community.rating.stars': '{count} 星', + 'community.rating.commentPlaceholder': '分享您的使用體驗...', + 'community.rating.submit': '提交評分', + 'community.rating.modify': '修改', + 'community.rating.saveModify': '保存修改', + 'community.rating.cancel': '取消', + 'community.rating.selectRating': '請選擇評分', + 'community.rating.success': '評分成功', + 'community.rating.modifySuccess': '修改評價成功', + 'community.rating.failed': '評分失敗', + 'community.rating.noRatings': '暫無評價', + 'community.backtest.note': '以上數據爲歷史回測結果,僅供參考,不代表未來收益', + 'community.backtest.noData': '暫無回測數據', + 'community.backtest.uploadHint': '作者尚未上傳回測數據', + 'community.message.loadFailed': '加載指標列表失敗', + 'community.message.purchaseProcessing': '正在處理購買請求...', + 'community.message.downloadSuccess': '指標已添加到我的指標列表', + 'community.message.favoriteSuccess': '收藏成功', + 'community.message.unfavoriteSuccess': '取消收藏成功', + 'community.message.operationSuccess': '操作成功', + 'community.message.operationFailed': '操作失敗', + 'dashboard.totalEquity': '總權益', + 'dashboard.totalPnL': '總盈虧', + 'dashboard.aiStrategies': 'AI 策略', + 'dashboard.indicatorStrategies': '指標策略', + 'dashboard.running': '運行中', + 'dashboard.pnlHistory': '歷史盈虧', + 'dashboard.strategyPerformance': '策略盈虧佔比', + 'dashboard.recentTrades': '最近交易', + 'dashboard.currentPositions': '當前持倉', + 'dashboard.table.time': '時間', + 'dashboard.table.strategy': '策略名稱', + 'dashboard.table.symbol': '標的', + 'dashboard.table.type': '類型', + 'dashboard.table.side': '方向', + 'dashboard.table.size': '持倉量', + 'dashboard.table.entryPrice': '開倉均價', + 'dashboard.table.price': '價格', + 'dashboard.table.amount': '數量', + 'dashboard.table.profit': '盈虧', + 'dashboard.pendingOrders': '訂單執行記錄', + 'dashboard.totalOrders': '共 {total} 條', + 'dashboard.viewError': '查看錯誤', + 'dashboard.filled': '已成交', + 'dashboard.orderTable.time': '創建時間', + 'dashboard.orderTable.strategy': '策略', + 'dashboard.orderTable.symbol': '交易對', + 'dashboard.orderTable.signalType': '信號類型', + 'dashboard.orderTable.amount': '數量', + 'dashboard.orderTable.price': '成交價', + 'dashboard.orderTable.status': '狀態', + 'dashboard.orderTable.executedAt': '執行時間', + 'dashboard.orderTable.exchange': '交易所', + 'dashboard.orderTable.notify': '通知方式', + 'dashboard.orderTable.actions': '操作', + 'dashboard.orderTable.delete': '刪除', + 'dashboard.orderTable.deleteConfirm': '確認刪除這條記錄?', + 'dashboard.orderTable.deleteSuccess': '刪除成功', + 'dashboard.orderTable.deleteFailed': '刪除失敗', + 'dashboard.signalType.openLong': '開多', + 'dashboard.signalType.openShort': '開空', + 'dashboard.signalType.closeLong': '平多', + 'dashboard.signalType.closeShort': '平空', + 'dashboard.signalType.addLong': '加多', + 'dashboard.signalType.addShort': '加空', + 'dashboard.status.pending': '待處理', + 'dashboard.status.processing': '處理中', + 'dashboard.status.completed': '已完成', + 'dashboard.status.failed': '失敗', + 'dashboard.status.cancelled': '已取消', + 'dashboard.table.pnl': '未實現盈虧', + 'form.basic-form.basic.title': '基礎表單', + 'form.basic-form.basic.description': '表單頁用於向用戶收集或驗證信息,基礎表單常見於數據項較少的表單場景。', + 'form.basic-form.title.label': '標題', + 'form.basic-form.title.placeholder': '給目標起個名字', + 'form.basic-form.title.required': '請輸入標題', + 'form.basic-form.date.label': '起止日期', + 'form.basic-form.placeholder.start': '開始日期', + 'form.basic-form.placeholder.end': '結束日期', + 'form.basic-form.date.required': '請選擇起止日期', + 'form.basic-form.goal.label': '目標描述', + 'form.basic-form.goal.placeholder': '請輸入你的階段性工作目標', + 'form.basic-form.goal.required': '請輸入目標描述', + 'form.basic-form.standard.label': '衡量標準', + 'form.basic-form.standard.placeholder': '請輸入衡量標準', + 'form.basic-form.standard.required': '請輸入衡量標準', + 'form.basic-form.client.label': '客戶', + 'form.basic-form.client.required': '請描述你服務的客戶', + 'form.basic-form.label.tooltip': '目標的服務對象', + 'form.basic-form.client.placeholder': '請描述你服務的客戶,內部客戶直接 @姓名/工號', + 'form.basic-form.invites.label': '邀評人', + 'form.basic-form.invites.placeholder': '請直接 @姓名/工號,最多可邀請 5 人', + 'form.basic-form.weight.label': '權重', + 'form.basic-form.weight.placeholder': '請輸入', + 'form.basic-form.public.label': '目標公開', + 'form.basic-form.label.help': '客戶、邀評人默認被分享', + 'form.basic-form.radio.public': '公開', + 'form.basic-form.radio.partially-public': '部分公開', + 'form.basic-form.radio.private': '不公開', + 'form.basic-form.publicUsers.placeholder': '公開給', + 'form.basic-form.option.A': '同事一', + 'form.basic-form.option.B': '同事二', + 'form.basic-form.option.C': '同事三', + 'form.basic-form.email.required': '請輸入郵箱地址!', + 'form.basic-form.email.wrong-format': '郵箱地址格式錯誤!', + 'form.basic-form.userName.required': '請輸入用戶名!', + 'form.basic-form.password.required': '請輸入密碼!', + 'form.basic-form.password.twice': '兩次輸入的密碼不匹配!', + 'form.basic-form.strength.msg': '請至少輸入 6 個字符。請不要使用容易被猜到的密碼。', + 'form.basic-form.strength.strong': '強度:強', + 'form.basic-form.strength.medium': '強度:中', + 'form.basic-form.strength.short': '強度:太短', + 'form.basic-form.confirm-password.required': '請確認密碼!', + 'form.basic-form.phone-number.required': '請輸入手機號!', + 'form.basic-form.phone-number.wrong-format': '手機號格式錯誤!', + 'form.basic-form.verification-code.required': '請輸入驗證碼!', + 'form.basic-form.form.get-captcha': '獲取驗證碼', + 'form.basic-form.captcha.second': '秒', + 'form.basic-form.form.optional': '(選填)', + 'form.basic-form.form.submit': '提交', + 'form.basic-form.form.save': '保存', + 'form.basic-form.email.placeholder': '郵箱', + 'form.basic-form.password.placeholder': '至少6位密碼,區分大小寫', + 'form.basic-form.confirm-password.placeholder': '確認密碼', + 'form.basic-form.phone-number.placeholder': '手機號', + 'form.basic-form.verification-code.placeholder': '驗證碼', + 'result.success.title': '提交成功', + 'result.success.description': '提交結果頁用於反饋一系列操作任務的處理結果, 如果僅是簡單操作,使用 Message 全局提示反饋即可。 本文字區域可以展示簡單的補充說明,如果有類似展示 “單據”的需求,下面這個灰色區域可以呈現比較復雜的內容。', + 'result.success.operate-title': '項目名稱', + 'result.success.operate-id': '項目 ID', + 'result.success.principal': '負責人', + 'result.success.operate-time': '生效時間', + 'result.success.step1-title': '創建項目', + 'result.success.step1-operator': '曲麗麗', + 'result.success.step2-title': '部門初審', + 'result.success.step2-operator': '周毛毛', + 'result.success.step2-extra': '催一下', + 'result.success.step3-title': '財務復核', + 'result.success.step4-title': '完成', + 'result.success.btn-return': '返回列表', + 'result.success.btn-project': '查看項目', + 'result.success.btn-print': '打印', + 'result.fail.error.title': '提交失敗', + 'result.fail.error.description': '請核對並修改以下信息後,再重新提交。', + 'result.fail.error.hint-title': '您提交的內容有如下錯誤:', + 'result.fail.error.hint-text1': '您的賬戶已被凍結', + 'result.fail.error.hint-btn1': '立即解凍', + 'result.fail.error.hint-text2': '您的賬戶還不具備申請資格', + 'result.fail.error.hint-btn2': '立即升級', + 'result.fail.error.btn-text': '返回修改', + 'account.settings.menuMap.custom': '個性化', + 'account.settings.menuMap.binding': '賬號綁定', + 'account.settings.basic.avatar': '頭像', + 'account.settings.basic.change-avatar': '更換頭像', + 'account.settings.basic.email': '郵箱', + 'account.settings.basic.email-message': '請輸入您的郵箱!', + 'account.settings.basic.nickname': '暱稱', + 'account.settings.basic.nickname-message': '請輸入您的暱稱!', + 'account.settings.basic.profile': '個人簡介', + 'account.settings.basic.profile-message': '請輸入個人簡介!', + 'account.settings.basic.profile-placeholder': '個人簡介', + 'account.settings.basic.country': '國家/地區', + 'account.settings.basic.country-message': '請輸入您的國家或地區!', + 'account.settings.basic.geographic': '所在省市', + 'account.settings.basic.geographic-message': '請輸入您的所在省市!', + 'account.settings.basic.address': '街道地址', + 'account.settings.basic.address-message': '請輸入您的街道地址!', + 'account.settings.basic.phone': '聯系電話', + 'account.settings.basic.phone-message': '請輸入您的聯系電話!', + 'account.settings.basic.update': '更新基本信息', + 'account.settings.basic.update.success': '更新基本信息成功', + 'account.settings.security.strong': '強', + 'account.settings.security.medium': '中', + 'account.settings.security.weak': '弱', + 'account.settings.security.password': '賬戶密碼', + 'account.settings.security.password-description': '當前密碼強度:', + 'account.settings.security.phone': '密保手機', + 'account.settings.security.phone-description': '已綁定手機:', + 'account.settings.security.question': '密保問題', + 'account.settings.security.question-description': '未設置密保問題,密保問題可有效保護賬戶安全', + 'account.settings.security.email': '綁定郵箱', + 'account.settings.security.email-description': '已綁定郵箱:', + 'account.settings.security.mfa': 'MFA 設備', + 'account.settings.security.mfa-description': '未綁定 MFA 設備,綁定後,可以進行二次確認', + 'account.settings.security.modify': '修改', + 'account.settings.security.set': '設置', + 'account.settings.security.bind': '綁定', + 'account.settings.binding.taobao': '綁定淘寶', + 'account.settings.binding.taobao-description': '當前未綁定淘寶賬號', + 'account.settings.binding.alipay': '綁定支付寶', + 'account.settings.binding.alipay-description': '當前未綁定支付寶賬號', + 'account.settings.binding.dingding': '綁定釘釘', + 'account.settings.binding.dingding-description': '當前未綁定釘釘賬號', + 'account.settings.binding.bind': '綁定', + 'account.settings.notification.password': '賬戶密碼', + 'account.settings.notification.password-description': '其他用戶的消息將以站內信的形式通知', + 'account.settings.notification.messages': '系統消息', + 'account.settings.notification.messages-description': '系統消息將以站內信的形式通知', + 'account.settings.notification.todo': '待辦任務', + 'account.settings.notification.todo-description': '待辦任務將以站內信的形式通知', + 'account.settings.settings.open': '開', + 'account.settings.settings.close': '關', + 'trading-assistant.title': '交易助手', + 'trading-assistant.strategyList': '策略列表', + 'trading-assistant.createStrategy': '創建策略', + 'trading-assistant.noStrategy': '暫無策略', + 'trading-assistant.selectStrategy': '請從左側選擇一個策略查看詳情', + 'trading-assistant.startStrategy': '啓動策略', + 'trading-assistant.stopStrategy': '停止策略', + 'trading-assistant.editStrategy': '編輯策略', + 'trading-assistant.deleteStrategy': '刪除策略', + 'trading-assistant.status.running': '運行中', + 'trading-assistant.status.stopped': '已停止', + 'trading-assistant.status.error': '錯誤', + 'trading-assistant.strategyType.IndicatorStrategy': '技術指標策略', + 'trading-assistant.strategyType.PromptBasedStrategy': '提示詞策略', + 'trading-assistant.strategyType.GridStrategy': '網格策略', + 'trading-assistant.tabs.tradingRecords': '交易記錄', + 'trading-assistant.tabs.positions': '持倉記錄', + 'trading-assistant.tabs.equityCurve': '淨值曲線', + 'trading-assistant.form.step1': '選擇指標', + 'trading-assistant.form.step2': '交易所配置', + 'trading-assistant.form.step3': '策略參數', + 'trading-assistant.form.indicator': '選擇指標', + 'trading-assistant.form.indicatorHint': '只能選擇您已購買或創建的技術指標', + 'trading-assistant.form.qdtCostHints': '使用策略將消耗QDT,請確保賬戶有足夠的QDT餘額', + 'trading-assistant.form.indicatorDescription': '指標描述', + 'trading-assistant.form.noDescription': '暫無描述', + 'trading-assistant.form.exchange': '選擇交易所', + 'trading-assistant.form.apiKey': 'API Key', + 'trading-assistant.form.secretKey': 'Secret Key', + 'trading-assistant.form.passphrase': 'Passphrase', + 'trading-assistant.form.testConnection': '測試連接', + 'trading-assistant.form.strategyName': '策略名稱', + 'trading-assistant.form.symbol': '交易對', + 'trading-assistant.form.symbolHint': '目前僅支持加密貨幣交易對', + 'trading-assistant.form.initialCapital': '投入金額', + 'trading-assistant.form.marketType': '市場類型', + 'trading-assistant.form.marketTypeFutures': '合約', + 'trading-assistant.form.marketTypeSpot': '現貨', + 'trading-assistant.form.marketTypeHint': '合約支持雙向交易和槓杆,現貨僅支持做多且槓杆固定爲1倍', + 'trading-assistant.form.leverage': '槓杆倍數', + 'trading-assistant.form.leverageHint': '合約:1-125倍,現貨:固定1倍', + 'trading-assistant.form.spotLeverageFixed': '現貨交易槓杆固定爲1倍', + 'trading-assistant.form.spotOnlyLongHint': '現貨交易僅支持做多', + 'trading-assistant.form.tradeDirection': '交易方向', + 'trading-assistant.form.tradeDirectionLong': '僅做多', + 'trading-assistant.form.tradeDirectionShort': '僅做空', + 'trading-assistant.form.tradeDirectionBoth': '雙向交易', + 'trading-assistant.form.timeframe': '時間周期', + 'trading-assistant.form.klinePeriod': 'K線周期', + 'trading-assistant.form.timeframe1m': '1分鍾', + 'trading-assistant.form.timeframe5m': '5分鍾', + 'trading-assistant.form.timeframe15m': '15分鍾', + 'trading-assistant.form.timeframe30m': '30分鍾', + 'trading-assistant.form.timeframe1H': '1小時', + 'trading-assistant.form.timeframe4H': '4小時', + 'trading-assistant.form.timeframe1D': '1天', + 'trading-assistant.form.selectStrategyType': '選擇策略類型', + 'trading-assistant.form.indicatorStrategy': '指標策略', + 'trading-assistant.form.indicatorStrategyDesc': '基於技術指標的自動化交易策略', + 'trading-assistant.form.aiStrategy': 'AI策略', + 'trading-assistant.form.aiStrategyDesc': '基於AI智能決策的自動化交易策略', + 'trading-assistant.form.enableAiFilter': '啟用AI智能決策過濾', + 'trading-assistant.form.enableAiFilterHint': '啟用後,指標信號將經過AI智能過濾,提高交易質量', + 'trading-assistant.form.aiFilterPrompt': '自定義提示詞', + 'trading-assistant.form.aiFilterPromptHint': '為AI過濾提供自定義指令,留空則使用系統默認', + 'trading-assistant.validation.strategyTypeRequired': '請選擇策略類型', + 'trading-assistant.form.advancedSettings': '高級設置', + 'trading-assistant.form.orderMode': '下單模式', + 'trading-assistant.form.orderModeMaker': '掛單(Maker)', + 'trading-assistant.form.orderModeTaker': '市價(Taker)', + 'trading-assistant.form.orderModeHint': '掛單模式使用限價單,手續費更低;市價模式立即成交,手續費較高', + 'trading-assistant.form.makerWaitSec': '掛單等待時間(秒)', + 'trading-assistant.form.makerWaitSecHint': '掛單後等待成交的時間,超時後取消並重試', + 'trading-assistant.form.makerRetries': '掛單重試次數', + 'trading-assistant.form.makerRetriesHint': '掛單未成交時的最大重試次數', + 'trading-assistant.form.fallbackToMarket': '掛單失敗降級市價', + 'trading-assistant.form.fallbackToMarketHint': '開/平倉掛單未成交時,是否降級爲市價單以確保成交', + 'trading-assistant.form.marginMode': '保證金模式', + 'trading-assistant.form.marginModeCross': '全倉', + 'trading-assistant.form.marginModeIsolated': '逐倉', + 'trading-assistant.form.stopLossPct': '止損比例(%)', + 'trading-assistant.form.stopLossPctHint': '設置止損百分比,0表示不啓用止損', + 'trading-assistant.form.takeProfitPct': '止盈比例(%)', + 'trading-assistant.form.takeProfitPctHint': '設置止盈百分比,0表示不啓用止盈', + 'trading-assistant.form.signalMode': '信號模式', + 'trading-assistant.form.signalModeConfirmed': '確認模式', + 'trading-assistant.form.signalModeAggressive': '激進模式', + 'trading-assistant.form.signalModeHint': '確認模式:僅檢查已完成的K線;激進模式:也檢查正在形成的K線', + 'trading-assistant.form.cancel': '取消', + 'trading-assistant.form.prev': '上一步', + 'trading-assistant.form.next': '下一步', + 'trading-assistant.form.confirmCreate': '確認創建', + 'trading-assistant.form.confirmEdit': '確認修改', + 'trading-assistant.messages.createSuccess': '策略創建成功', + 'trading-assistant.messages.createFailed': '創建策略失敗', + 'trading-assistant.messages.updateSuccess': '策略更新成功', + 'trading-assistant.messages.updateFailed': '更新策略失敗', + 'trading-assistant.messages.deleteSuccess': '策略刪除成功', + 'trading-assistant.messages.deleteFailed': '刪除策略失敗', + 'trading-assistant.messages.startSuccess': '策略啓動成功', + 'trading-assistant.messages.startFailed': '啓動策略失敗', + 'trading-assistant.messages.stopSuccess': '策略停止成功', + 'trading-assistant.messages.stopFailed': '停止策略失敗', + 'trading-assistant.messages.loadFailed': '獲取策略列表失敗', + 'trading-assistant.messages.runningWarning': '策略運行中,請先停止策略後再修改', + 'trading-assistant.messages.deleteConfirmWithName': '確定要刪除策略“{name}”嗎?此操作不可恢復。', + 'trading-assistant.messages.deleteConfirm': '確定要刪除該策略嗎?此操作不可恢復。', + 'trading-assistant.messages.loadTradesFailed': '獲取交易記錄失敗', + 'trading-assistant.messages.loadPositionsFailed': '獲取持倉記錄失敗', + 'trading-assistant.messages.loadEquityFailed': '獲取淨值曲線失敗', + 'trading-assistant.messages.loadIndicatorsFailed': '加載指標列表失敗,請稍後重試', + 'trading-assistant.messages.spotLimitations': '現貨交易已自動設置爲僅做多、1倍槓杆', + 'trading-assistant.messages.autoFillApiConfig': '已自動填充該交易所的歷史API配置', + 'trading-assistant.placeholders.selectIndicator': '請選擇指標', + 'trading-assistant.placeholders.selectExchange': '請選擇交易所', + 'trading-assistant.placeholders.inputApiKey': '請輸入API Key', + 'trading-assistant.placeholders.inputSecretKey': '請輸入Secret Key', + 'trading-assistant.placeholders.inputPassphrase': '請輸入Passphrase', + 'trading-assistant.placeholders.inputStrategyName': '請輸入策略名稱', + 'trading-assistant.placeholders.selectSymbol': '請選擇交易對', + 'trading-assistant.placeholders.selectTimeframe': '請選擇時間周期', + 'trading-assistant.placeholders.selectKlinePeriod': '請選擇K線周期', + 'trading-assistant.placeholders.inputAiFilterPrompt': '請輸入自定義提示詞(可選)', + 'trading-assistant.validation.indicatorRequired': '請選擇指標', + 'trading-assistant.validation.exchangeRequired': '請選擇交易所', + 'trading-assistant.validation.apiKeyRequired': '請輸入API Key', + 'trading-assistant.validation.secretKeyRequired': '請輸入Secret Key', + 'trading-assistant.validation.passphraseRequired': '請輸入Passphrase', + 'trading-assistant.validation.exchangeConfigIncomplete': '請填寫完整的交易所配置信息', + 'trading-assistant.validation.testConnectionRequired': '請先點擊"測試連接"按鈕並確保連接成功', + 'trading-assistant.validation.testConnectionFailed': '連接測試失敗,請檢查配置後重新測試', + 'trading-assistant.validation.strategyNameRequired': '請輸入策略名稱', + 'trading-assistant.validation.symbolRequired': '請選擇交易對', + 'trading-assistant.validation.initialCapitalRequired': '請輸入投入金額', + 'trading-assistant.validation.leverageRequired': '請輸入槓杆倍數', + 'trading-assistant.table.time': '時間', + 'trading-assistant.table.type': '類型', + 'trading-assistant.table.price': '價格', + 'trading-assistant.table.amount': '數量', + 'trading-assistant.table.value': '金額', + 'trading-assistant.table.commission': '手續費', + 'trading-assistant.table.symbol': '交易對', + 'trading-assistant.table.side': '方向', + 'trading-assistant.table.size': '持倉數量', + 'trading-assistant.table.entryPrice': '開倉價格', + 'trading-assistant.table.currentPrice': '當前價格', + 'trading-assistant.table.unrealizedPnl': '未實現盈虧', + 'trading-assistant.table.pnlPercent': '盈虧比例', + 'trading-assistant.table.buy': '買入', + 'trading-assistant.table.sell': '賣出', + 'trading-assistant.table.long': '做多', + 'trading-assistant.table.short': '做空', + 'trading-assistant.table.noPositions': '暫無持倉', + 'trading-assistant.detail.title': '策略詳情', + 'trading-assistant.detail.strategyName': '策略名稱', + 'trading-assistant.detail.strategyType': '策略類型', + 'trading-assistant.detail.status': '狀態', + 'trading-assistant.detail.tradingMode': '交易模式', + 'trading-assistant.detail.exchange': '交易所', + 'trading-assistant.detail.initialCapital': '初始資金', + 'trading-assistant.detail.totalInvestment': '總投入金額', + 'trading-assistant.detail.currentEquity': '當前淨值', + 'trading-assistant.detail.totalPnl': '總盈虧', + 'trading-assistant.detail.indicatorName': '指標名稱', + 'trading-assistant.detail.maxLeverage': '最大槓杆', + 'trading-assistant.detail.decideInterval': '決策間隔', + 'trading-assistant.detail.symbols': '交易標的', + 'trading-assistant.detail.createdAt': '創建時間', + 'trading-assistant.detail.updatedAt': '更新時間', + 'trading-assistant.detail.llmConfig': 'AI模型配置', + 'trading-assistant.detail.exchangeConfig': '交易所配置', + 'trading-assistant.detail.provider': '提供商', + 'trading-assistant.detail.modelId': '模型ID', + 'trading-assistant.detail.close': '關閉', + 'trading-assistant.detail.loadFailed': '獲取策略詳情失敗', + 'trading-assistant.equity.noData': '暫無淨值數據', + 'trading-assistant.equity.equity': '淨值', + 'trading-assistant.exchange.tradingMode': '交易模式', + 'trading-assistant.exchange.virtual': '模擬交易', + 'trading-assistant.exchange.live': '實盤交易', + 'trading-assistant.exchange.selectExchange': '選擇交易所', + 'trading-assistant.exchange.walletAddress': '錢包地址', + 'trading-assistant.exchange.walletAddressPlaceholder': '請輸入錢包地址(以0x開頭)', + 'trading-assistant.exchange.privateKey': '私鑰', + 'trading-assistant.exchange.privateKeyPlaceholder': '請輸入私鑰(64字符)', + 'trading-assistant.exchange.testConnection': '測試連接', + 'trading-assistant.exchange.connectionSuccess': '連接成功', + 'trading-assistant.exchange.connectionFailed': '連接失敗', + 'trading-assistant.exchange.testFailed': '連接測試失敗', + 'trading-assistant.exchange.fillComplete': '請填寫完整的交易所配置信息', + 'trading-assistant.strategyTypeOptions.ai': 'AI驅動策略', + 'trading-assistant.strategyTypeOptions.indicator': '技術指標策略', + 'trading-assistant.strategyTypeOptions.aiDeveloping': 'AI驅動策略功能開發中,敬請期待', + 'trading-assistant.strategyTypeOptions.aiDevelopingWarning': 'AI驅動策略功能正在開發中', + 'trading-assistant.indicatorType.trend': '趨勢', + 'trading-assistant.indicatorType.momentum': '動量', + 'trading-assistant.indicatorType.volatility': '波動', + 'trading-assistant.indicatorType.volume': '成交量', + 'trading-assistant.indicatorType.custom': '自定義', + 'trading-assistant.exchangeNames': { + 'okx': 'OKX', + 'binance': '幣安', + 'hyperliquid': 'Hyperliquid', + 'blockchaincom': 'Blockchain.com', + 'coinbaseexchange': 'Coinbase', + 'gate': 'Gate.io', + 'mexc': 'MEXC', + 'kraken': 'Kraken', + 'bitfinex': 'Bitfinex', + 'bybit': 'Bybit', + 'kucoin': 'KuCoin', + 'huobi': '火幣', + 'bitget': 'Bitget', + 'bitmex': 'BitMEX', + 'deribit': 'Deribit', + 'phemex': 'Phemex', + 'bitmart': 'BitMart', + 'bitstamp': 'Bitstamp', + 'bittrex': 'Bittrex', + 'poloniex': 'Poloniex', + 'gemini': 'Gemini', + 'cryptocom': 'Crypto.com', + 'bitflyer': 'bitFlyer', + 'upbit': 'Upbit', + 'bithumb': 'Bithumb', + 'coinone': 'Coinone', + 'zb': 'ZB', + 'lbank': 'LBank', + 'bibox': 'Bibox', + 'bigone': 'BigONE', + 'bitrue': 'Bitrue', + 'coinex': 'CoinEx', + 'digifinex': 'DigiFinex', + 'ftx': 'FTX', + 'ftxus': 'FTX US', + 'binanceus': 'Binance US', + 'binancecoinm': 'Binance COIN-M', + 'binanceusdm': 'Binance USDⓈ-M' + }, + 'ai-trading-assistant.title': 'AI交易助手', + 'ai-trading-assistant.strategyList': '策略列表', + 'ai-trading-assistant.createStrategy': '創建策略', + 'ai-trading-assistant.noStrategy': '暫無策略', + 'ai-trading-assistant.selectStrategy': '請從左側選擇一個策略查看詳情', + 'ai-trading-assistant.startStrategy': '啓動策略', + 'ai-trading-assistant.stopStrategy': '停止策略', + 'ai-trading-assistant.editStrategy': '編輯策略', + 'ai-trading-assistant.deleteStrategy': '刪除策略', + 'ai-trading-assistant.status.running': '運行中', + 'ai-trading-assistant.status.stopped': '已停止', + 'ai-trading-assistant.status.error': '錯誤', + 'ai-trading-assistant.tabs.tradingRecords': '交易記錄', + 'ai-trading-assistant.tabs.positions': '持倉記錄', + 'ai-trading-assistant.tabs.aiDecisions': 'AI決策記錄', + 'ai-trading-assistant.tabs.equityCurve': '淨值曲線', + 'ai-trading-assistant.form.createTitle': '創建AI交易策略', + 'ai-trading-assistant.form.editTitle': '編輯AI交易策略', + 'ai-trading-assistant.form.strategyName': '策略名稱', + 'ai-trading-assistant.form.modelId': 'AI模型', + 'ai-trading-assistant.form.modelIdHint': '使用系統OpenRouter服務,無需配置API Key', + 'ai-trading-assistant.form.decideInterval': '決策間隔', + 'ai-trading-assistant.form.decideInterval5m': '5分鍾', + 'ai-trading-assistant.form.decideInterval10m': '10分鍾', + 'ai-trading-assistant.form.decideInterval30m': '30分鍾', + 'ai-trading-assistant.form.decideInterval1h': '1小時', + 'ai-trading-assistant.form.decideInterval4h': '4小時', + 'ai-trading-assistant.form.decideInterval1d': '1天', + 'ai-trading-assistant.form.decideInterval1w': '1周', + 'ai-trading-assistant.form.decideIntervalHint': 'AI做決策的時間間隔', + 'ai-trading-assistant.form.runPeriod': '運行周期', + 'ai-trading-assistant.form.runPeriodHint': '策略運行的開始時間和結束時間', + 'ai-trading-assistant.form.startDate': '開始日期', + 'ai-trading-assistant.form.endDate': '結束日期', + 'ai-trading-assistant.form.qdtCostTitle': 'QDT扣費說明', + 'ai-trading-assistant.form.qdtCostHint': '每次AI決策將扣費 {cost} 個QDT,請確保賬戶有足夠的QDT餘額。策略運行期間,每次決策都會實時扣費。', + 'ai-trading-assistant.form.apiKey': 'API Key', + 'ai-trading-assistant.form.exchange': '選擇交易所', + 'ai-trading-assistant.form.secretKey': 'Secret Key', + 'ai-trading-assistant.form.passphrase': 'Passphrase', + 'ai-trading-assistant.form.testConnection': '測試連接', + 'ai-trading-assistant.form.symbol': '交易對', + 'ai-trading-assistant.form.symbolHint': '選擇要交易的交易對', + 'ai-trading-assistant.form.initialCapital': '投入金額(保證金)', + 'ai-trading-assistant.form.leverage': '槓杆倍數', + 'ai-trading-assistant.form.timeframe': '時間周期', + 'ai-trading-assistant.form.timeframe1m': '1分鍾', + 'ai-trading-assistant.form.timeframe5m': '5分鍾', + 'ai-trading-assistant.form.timeframe15m': '15分鍾', + 'ai-trading-assistant.form.timeframe30m': '30分鍾', + 'ai-trading-assistant.form.timeframe1H': '1小時', + 'ai-trading-assistant.form.timeframe4H': '4小時', + 'ai-trading-assistant.form.timeframe1D': '1天', + 'ai-trading-assistant.form.marketType': '市場類型', + 'ai-trading-assistant.form.marketTypeFutures': '合約', + 'ai-trading-assistant.form.marketTypeSpot': '現貨', + 'ai-trading-assistant.form.totalPnl': '總盈虧', + 'ai-trading-assistant.form.customPrompt': '自定義提示詞', + 'ai-trading-assistant.form.customPromptHint': '可選,用於自定義AI的交易策略和決策邏輯', + 'ai-trading-assistant.form.cancel': '取消', + 'ai-trading-assistant.form.prev': '上一步', + 'ai-trading-assistant.form.next': '下一步', + 'ai-trading-assistant.form.confirmCreate': '確認創建', + 'ai-trading-assistant.form.confirmEdit': '確認修改', + 'ai-trading-assistant.messages.createSuccess': '策略創建成功', + 'ai-trading-assistant.messages.createFailed': '創建策略失敗', + 'ai-trading-assistant.messages.updateSuccess': '策略更新成功', + 'ai-trading-assistant.messages.updateFailed': '更新策略失敗', + 'ai-trading-assistant.messages.deleteSuccess': '策略刪除成功', + 'ai-trading-assistant.messages.deleteFailed': '刪除策略失敗', + 'ai-trading-assistant.messages.startSuccess': '策略啓動成功', + 'ai-trading-assistant.messages.startFailed': '啓動策略失敗', + 'ai-trading-assistant.messages.stopSuccess': '策略停止成功', + 'ai-trading-assistant.messages.stopFailed': '停止策略失敗', + 'ai-trading-assistant.messages.loadFailed': '獲取策略列表失敗', + 'ai-trading-assistant.messages.loadDecisionsFailed': '獲取AI決策記錄失敗', + 'ai-trading-assistant.messages.deleteConfirm': '確定要刪除該策略嗎?此操作不可恢復。', + 'ai-trading-assistant.placeholders.inputStrategyName': '請輸入策略名稱', + 'ai-trading-assistant.placeholders.selectModelId': '請選擇AI模型', + 'ai-trading-assistant.placeholders.selectDecideInterval': '請選擇決策間隔', + 'ai-trading-assistant.placeholders.startTime': '開始時間', + 'ai-trading-assistant.placeholders.endTime': '結束時間', + 'ai-trading-assistant.placeholders.inputApiKey': '請輸入API Key', + 'ai-trading-assistant.placeholders.selectExchange': '請選擇交易所', + 'ai-trading-assistant.placeholders.inputSecretKey': '請輸入Secret Key', + 'ai-trading-assistant.placeholders.inputPassphrase': '請輸入Passphrase', + 'ai-trading-assistant.placeholders.selectSymbol': '請選擇交易對,如:BTC/USDT', + 'ai-trading-assistant.placeholders.selectTimeframe': '請選擇時間周期', + 'ai-trading-assistant.placeholders.inputCustomPrompt': '請輸入自定義提示詞(可選)', + 'ai-trading-assistant.validation.strategyNameRequired': '請輸入策略名稱', + 'ai-trading-assistant.validation.modelIdRequired': '請選擇AI模型', + 'ai-trading-assistant.validation.runPeriodRequired': '請選擇運行周期', + 'ai-trading-assistant.validation.apiKeyRequired': '請輸入API Key', + 'ai-trading-assistant.validation.exchangeRequired': '請選擇交易所', + 'ai-trading-assistant.validation.secretKeyRequired': '請輸入Secret Key', + 'ai-trading-assistant.validation.symbolRequired': '請選擇交易對', + 'ai-trading-assistant.validation.initialCapitalRequired': '請輸入投入金額', + 'ai-trading-assistant.table.time': '時間', + 'ai-trading-assistant.table.type': '類型', + 'ai-trading-assistant.table.price': '價格', + 'ai-trading-assistant.table.amount': '數量', + 'ai-trading-assistant.table.value': '金額', + 'ai-trading-assistant.table.symbol': '交易對', + 'ai-trading-assistant.table.side': '方向', + 'ai-trading-assistant.table.size': '持倉數量', + 'ai-trading-assistant.table.entryPrice': '開倉價格', + 'ai-trading-assistant.table.currentPrice': '當前價格', + 'ai-trading-assistant.table.unrealizedPnl': '未實現盈虧', + 'ai-trading-assistant.table.profit': '盈虧', + 'ai-trading-assistant.table.openLong': '開多', + 'ai-trading-assistant.table.closeLong': '平多', + 'ai-trading-assistant.table.openShort': '開空', + 'ai-trading-assistant.table.closeShort': '平空', + 'ai-trading-assistant.table.addLong': '加多', + 'ai-trading-assistant.table.addShort': '加空', + 'ai-trading-assistant.table.closeShortProfit': '平空止盈', + 'ai-trading-assistant.table.closeShortStop': '平空止損', + 'ai-trading-assistant.table.closeLongProfit': '平多止盈', + 'ai-trading-assistant.table.closeLongStop': '平多止損', + 'ai-trading-assistant.table.buy': '買入', + 'ai-trading-assistant.table.sell': '賣出', + 'ai-trading-assistant.table.long': '做多', + 'ai-trading-assistant.table.short': '做空', + 'ai-trading-assistant.table.hold': '持有', + 'ai-trading-assistant.table.reasoning': '分析理由', + 'ai-trading-assistant.table.decisions': '決策', + 'ai-trading-assistant.table.riskAssessment': '風險評估', + 'ai-trading-assistant.table.confidence': '置信度', + 'ai-trading-assistant.table.totalRecords': '共 {total} 條記錄', + 'ai-trading-assistant.table.noPositions': '暫無持倉', + 'ai-trading-assistant.detail.title': '策略詳情', + 'ai-trading-assistant.equity.noData': '暫無淨值數據', + 'ai-trading-assistant.equity.equity': '淨值', + 'ai-trading-assistant.exchange.testFailed': '連接測試失敗', + 'ai-trading-assistant.exchange.connectionSuccess': '連接成功', + 'ai-trading-assistant.exchange.connectionFailed': '連接失敗', + 'ai-trading-assistant.form.advancedSettings': '高級設置', + 'ai-trading-assistant.form.orderMode': '下單模式', + 'ai-trading-assistant.form.orderModeMaker': '掛單(Maker)', + 'ai-trading-assistant.form.orderModeTaker': '市價(Taker)', + 'ai-trading-assistant.form.orderModeHint': '掛單模式使用限價單,手續費更低;市價模式立即成交,手續費較高', + 'ai-trading-assistant.form.makerWaitSec': '掛單等待時間(秒)', + 'ai-trading-assistant.form.makerWaitSecHint': '掛單後等待成交的時間,超時後取消並重試', + 'ai-trading-assistant.form.makerRetries': '掛單重試次數', + 'ai-trading-assistant.form.makerRetriesHint': '掛單未成交時的最大重試次數', + 'ai-trading-assistant.form.fallbackToMarket': '掛單失敗降級市價', + 'ai-trading-assistant.form.fallbackToMarketHint': '開/平倉掛單未成交時,是否降級爲市價單以確保成交', + 'ai-trading-assistant.form.marginMode': '保證金模式', + 'ai-trading-assistant.form.marginModeCross': '全倉', + 'ai-trading-assistant.form.marginModeIsolated': '逐倉', + 'ai-analysis.title': '量子交易引擎', + 'ai-analysis.system.online': '在線', + 'ai-analysis.system.agents': '智能體', + 'ai-analysis.system.active': '活躍', + 'ai-analysis.system.stage': '階段', + 'ai-analysis.panel.roster': '智能體陣容', + 'ai-analysis.panel.thinking': '思考中...', + 'ai-analysis.panel.done': '完成', + 'ai-analysis.panel.standby': '待機', + 'ai-analysis.input.title': '量子交易引擎', + 'ai-analysis.input.placeholder': '選擇目標資產 (如 BTC/USDT)', + 'ai-analysis.input.watchlist': '自選股', + 'ai-analysis.input.start': '開始分析', + 'ai-analysis.input.recent': '最近任務:', + 'ai-analysis.vis.stage': '階段', + 'ai-analysis.vis.processing': '處理中', + 'ai-analysis.result.complete': '分析完成', + 'ai-analysis.result.signal': '最終信號', + 'ai-analysis.result.confidence': '置信度:', + 'ai-analysis.result.new': '新分析', + 'ai-analysis.result.full': '查看完整報告', + 'ai-analysis.logs.title': '系統日志', + 'ai-analysis.modal.title': '機密報告', + 'ai-analysis.modal.fundamental': '基本面分析', + 'ai-analysis.modal.technical': '技術面分析', + 'ai-analysis.modal.sentiment': '情緒面分析', + 'ai-analysis.modal.risk': '風險評估', + 'ai-analysis.stage.idle': '待機', + 'ai-analysis.stage.1': '第一階段:多維分析', + 'ai-analysis.stage.2': '第二階段:多空辯論', + 'ai-analysis.stage.3': '第三階段:戰略規劃', + 'ai-analysis.stage.4': '第四階段:風控審查', + 'ai-analysis.stage.complete': '完成', + 'ai-analysis.agent.investment_director': '投資總監', + 'ai-analysis.agent.role.investment_director': '綜合分析 & 最終結論', + 'ai-analysis.agent.market': '市場分析師', + 'ai-analysis.agent.role.market': '技術 & 市場數據', + 'ai-analysis.agent.fundamental': '基本面分析師', + 'ai-analysis.agent.role.fundamental': '財務 & 估值', + 'ai-analysis.agent.technical': '技術分析師', + 'ai-analysis.agent.role.technical': '技術指標 & 圖表', + 'ai-analysis.agent.news': '新聞分析師', + 'ai-analysis.agent.role.news': '全球新聞過濾', + 'ai-analysis.agent.sentiment': '情緒分析師', + 'ai-analysis.agent.role.sentiment': '社交 & 情緒', + 'ai-analysis.agent.risk': '風險分析師', + 'ai-analysis.agent.role.risk': '基礎風險檢查', + 'ai-analysis.agent.bull': '看漲研究員', + 'ai-analysis.agent.role.bull': '增長催化劑挖掘', + 'ai-analysis.agent.bear': '看跌研究員', + 'ai-analysis.agent.role.bear': '風險 & 缺陷挖掘', + 'ai-analysis.agent.manager': '研究經理', + 'ai-analysis.agent.role.manager': '辯論主持人', + 'ai-analysis.agent.trader': '交易員', + 'ai-analysis.agent.role.trader': '執行策略師', + 'ai-analysis.agent.risky': '激進分析師', + 'ai-analysis.agent.role.risky': '激進策略', + 'ai-analysis.agent.neutral': '平衡分析師', + 'ai-analysis.agent.role.neutral': '平衡策略', + 'ai-analysis.agent.safe': '保守分析師', + 'ai-analysis.agent.role.safe': '保守策略', + 'ai-analysis.agent.cro': '風控經理 (CRO)', + 'ai-analysis.agent.role.cro': '最終決策權威', + 'ai-analysis.script.market': '正在從主要交易所獲取 OHLCV 數據...', + 'ai-analysis.script.fundamental': '正在檢索季度財務報告...', + 'ai-analysis.script.technical': '正在分析技術指標和圖表形態...', + 'ai-analysis.script.news': '正在掃描全球財經新聞...', + 'ai-analysis.script.sentiment': '正在分析社交媒體趨勢...', + 'ai-analysis.script.risk': '正在計算歷史波動率...', + 'invite.inviteLink': '邀請鏈接', + 'invite.copy': '復制鏈接', + 'invite.copySuccess': '復制成功!', + 'invite.copyFailed': '復制失敗,請手動復制', + 'invite.noInviteLink': '邀請鏈接未生成', + 'invite.totalInvites': '累計邀請', + 'invite.totalReward': '累計獎勵', + 'invite.rules': '邀請規則', + 'invite.rule1': '每成功邀請一位好友注冊,您將獲得獎勵', + 'invite.rule2': '被邀請的好友完成首次交易,您將獲得額外獎勵', + 'invite.rule3': '邀請獎勵將直接發放到您的賬戶', + 'invite.inviteList': '邀請列表', + 'invite.tasks': '任務中心', + 'invite.inviteeName': '被邀請人', + 'invite.inviteTime': '邀請時間', + 'invite.status': '狀態', + 'invite.reward': '獎勵', + 'invite.active': '活躍', + 'invite.inactive': '未激活', + 'invite.completed': '已完成', + 'invite.claimed': '已領取', + 'invite.pending': '待完成', + 'invite.goToTask': '去完成', + 'invite.claimReward': '領取獎勵', + 'invite.verify': '驗證完成', + 'invite.verifySuccess': '驗證成功!任務已完成', + 'invite.verifyNotCompleted': '任務尚未完成,請先完成任務', + 'invite.verifyFailed': '驗證失敗,請稍後重試', + 'invite.claimSuccess': '成功領取 {reward} QDT!', + 'invite.claimFailed': '領取失敗,請稍後重試', + 'invite.totalRecords': '共 {total} 條記錄', + 'invite.task.twitter.title': '轉發推文到 X (Twitter)', + 'invite.task.twitter.desc': '分享我們的官方推文到您的 X (Twitter) 賬號', + 'invite.task.youtube.title': '關注我們的 YouTube 頻道', + 'invite.task.youtube.desc': '訂閱並關注我們的 YouTube 官方頻道', + 'invite.task.telegram.title': '加入 Telegram 羣組', + 'invite.task.telegram.desc': '加入我們的官方 Telegram 社區羣組', + 'invite.task.discord.title': '加入 Discord 服務器', + 'invite.task.discord.desc': '加入我們的 Discord 社區服務器', + 'message': '-', + 'layouts.usermenu.dialog.title': '信息', + 'layouts.usermenu.dialog.content': '您確定要注銷嗎?', + 'layouts.userLayout.title': '於不確定中,尋見真理' + } + +export default { + ...components, + ...locale +} diff --git a/quantdinger_vue/src/main.js b/quantdinger_vue/src/main.js new file mode 100644 index 0000000..608dc04 --- /dev/null +++ b/quantdinger_vue/src/main.js @@ -0,0 +1,56 @@ +// with polyfills +import 'core-js/stable' +import 'regenerator-runtime/runtime' + +import Vue from 'vue' +import App from './App.vue' +import router from './router' +import store from './store/' +import i18n from './locales' +import { VueAxios } from './utils/request' +import ProLayout, { PageHeaderWrapper } from '@ant-design-vue/pro-layout' +import themePluginConfig from '../config/themePluginConfig' + +// mock +// WARNING: `mockjs` NOT SUPPORT `IE` PLEASE DO NOT USE IN `production` ENV. +import './mock' + +import bootstrap from './core/bootstrap' +import './core/lazy_use' // use lazy load components +import './permission' // permission control +import './utils/filter' // global filter +import './global.less' // global style + +Vue.config.productionTip = false + +// Suppress noisy ResizeObserver loop errors (harmless in most cases on responsive layouts) +if (typeof window !== 'undefined') { + const ignoreResizeObserverError = (e) => { + const msg = (e && (e.reason && e.reason.message || e.message)) || '' + if (msg.includes('ResizeObserver loop') || msg.includes('ResizeObserver loop limit exceeded')) { + e.preventDefault && e.preventDefault() + e.stopImmediatePropagation && e.stopImmediatePropagation() + return false + } + } + window.addEventListener('error', ignoreResizeObserverError) + window.addEventListener('unhandledrejection', ignoreResizeObserverError) +} + +// mount axios to `Vue.$http` and `this.$http` +Vue.use(VueAxios) +// use pro-layout components +Vue.component('pro-layout', ProLayout) +Vue.component('page-container', PageHeaderWrapper) +Vue.component('page-header-wrapper', PageHeaderWrapper) + +window.umi_plugin_ant_themeVar = themePluginConfig.theme + +new Vue({ + router, + store, + i18n, + // init localstorage, vuex, Logo message + created: bootstrap, + render: h => h(App) +}).$mount('#app') diff --git a/quantdinger_vue/src/mock/index.js b/quantdinger_vue/src/mock/index.js new file mode 100644 index 0000000..addbb95 --- /dev/null +++ b/quantdinger_vue/src/mock/index.js @@ -0,0 +1,20 @@ +import { isIE } from '@/utils/util' + +// 判断环境不是 prod 或者 preview 是 true 时,加载 mock 服务 +if (process.env.NODE_ENV !== 'production' || process.env.VUE_APP_PREVIEW === 'true') { + if (isIE()) { + } + // 使用同步加载依赖 + // 防止 vuex 中的 GetInfo 早于 mock 运行,导致无法 mock 请求返回结果 + const Mock = require('mockjs2') + require('./services/auth') + require('./services/user') + require('./services/manage') + require('./services/other') + require('./services/tagCloud') + require('./services/article') + + Mock.setup({ + timeout: 800 // setter delay time + }) +} diff --git a/quantdinger_vue/src/mock/services/article.js b/quantdinger_vue/src/mock/services/article.js new file mode 100644 index 0000000..88e1fa1 --- /dev/null +++ b/quantdinger_vue/src/mock/services/article.js @@ -0,0 +1,88 @@ +import Mock from 'mockjs2' +import { builder, getQueryParameters } from '../util' + +const titles = [ + 'Alipay', + 'Angular', + 'Ant Design', + 'Ant Design Pro', + 'Bootstrap', + 'React', + 'Vue', + 'Webpack' +] + +const avatar = ['https://gw.alipayobjects.com/zos/rmsportal/WdGqmHpayyMjiEhcKoVE.png', + 'https://gw.alipayobjects.com/zos/rmsportal/zOsKZmFRdUtvpqCImOVY.png', + 'https://gw.alipayobjects.com/zos/rmsportal/dURIMkkrRFpPgTuzkwnB.png', + 'https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png', + 'https://gw.alipayobjects.com/zos/rmsportal/siCrBXXhmvTQGWPNLBow.png' +] + +const covers = [ + 'https://gw.alipayobjects.com/zos/rmsportal/uMfMFlvUuceEyPpotzlq.png', + 'https://gw.alipayobjects.com/zos/rmsportal/iZBVOIhGJiAnhplqjvZW.png', + 'https://gw.alipayobjects.com/zos/rmsportal/iXjVmWVHbCJAyqvDxdtx.png', + 'https://gw.alipayobjects.com/zos/rmsportal/gLaIAoVWTtLbBWZNYEMg.png' +] + +const owner = [ + '付小小', + '吴加好', + '周星星', + '林东东', + '曲丽丽' +] + +const content = '段落示意:蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。' +const description = '在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,这些类似的组件会被抽离成一套标准规范。' +const href = 'https://ant.design' + +const article = (options) => { + const queryParameters = getQueryParameters(options) + if (queryParameters && !queryParameters.count) { + queryParameters.count = 5 + } + const data = [] + for (let i = 0; i < queryParameters.count; i++) { + const tmpKey = i + 1 + const num = parseInt(Math.random() * (4 + 1), 10) + data.push({ + id: tmpKey, + avatar: avatar[num], + owner: owner[num], + content: content, + star: Mock.mock('@integer(1, 999)'), + percent: Mock.mock('@integer(1, 999)'), + like: Mock.mock('@integer(1, 999)'), + message: Mock.mock('@integer(1, 999)'), + description: description, + href: href, + title: titles[ i % 8 ], + updatedAt: Mock.mock('@datetime'), + members: [ + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ZiESqWwCXBRQoaPONSJe.png', + name: '曲丽丽', + id: 'member1' + }, + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/tBOxZPlITHqwlGjsJWaF.png', + name: '王昭君', + id: 'member2' + }, + { + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/sBxjgqiuHMGRkIjqlQCd.png', + name: '董娜娜', + id: 'member3' + } + ], + activeUser: Math.ceil(Math.random() * 100000) + 100000, + newUser: Math.ceil(Math.random() * 1000) + 1000, + cover: parseInt(i / 4, 10) % 2 === 0 ? covers[i % 4] : covers[3 - (i % 4)] + }) + } + return builder(data) +} + +Mock.mock(/\/list\/article/, 'get', article) diff --git a/quantdinger_vue/src/mock/services/auth.js b/quantdinger_vue/src/mock/services/auth.js new file mode 100644 index 0000000..79fc155 --- /dev/null +++ b/quantdinger_vue/src/mock/services/auth.js @@ -0,0 +1,49 @@ +import Mock from 'mockjs2' +import { builder, getBody } from '../util' + +const username = ['admin', 'super'] +// 强硬要求 ant.design 相同密码 +// '21232f297a57a5a743894a0e4a801fc3', +const password = ['8914de686ab28dc22f30d3d8e107ff6c', '21232f297a57a5a743894a0e4a801fc3'] // admin, ant.design + +const login = (options) => { + const body = getBody(options) + if (!username.includes(body.username) || !password.includes(body.password)) { + return builder({ isLogin: true }, '账户或密码错误', 401) + } + + return builder({ + 'id': Mock.mock('@guid'), + 'name': Mock.mock('@name'), + 'username': 'admin', + 'password': '', + 'avatar': 'https://gw.alipayobjects.com/zos/rmsportal/jZUIxmJycoymBprLOUbT.png', + 'status': 1, + 'telephone': '', + 'lastLoginIp': '27.154.74.117', + 'lastLoginTime': 1534837621348, + 'creatorId': 'admin', + 'createTime': 1497160610259, + 'deleted': 0, + 'roleId': 'admin', + 'lang': 'zh-CN', + 'token': '4291d7da9005377ec9aec4a71ea837f' + }, '', 200, { 'Custom-Header': Mock.mock('@guid') }) +} + +const logout = () => { + return builder({}, '[测试接口] 注销成功') +} + +const smsCaptcha = () => { + return builder({ captcha: Mock.mock('@integer(10000, 99999)') }) +} + +const twofactor = () => { + return builder({ stepCode: Mock.mock('@integer(0, 1)') }) +} + +Mock.mock(/\/auth\/login/, 'post', login) +Mock.mock(/\/auth\/logout/, 'post', logout) +Mock.mock(/\/account\/sms/, 'post', smsCaptcha) +Mock.mock(/\/auth\/2step-code/, 'post', twofactor) diff --git a/quantdinger_vue/src/mock/services/manage.js b/quantdinger_vue/src/mock/services/manage.js new file mode 100644 index 0000000..10181ec --- /dev/null +++ b/quantdinger_vue/src/mock/services/manage.js @@ -0,0 +1,252 @@ +import Mock from 'mockjs2' +import { builder, getQueryParameters } from '../util' + +const totalCount = 5701 + +const serverList = (options) => { + const parameters = getQueryParameters(options) + + const result = [] + const pageNo = parseInt(parameters.pageNo) + const pageSize = parseInt(parameters.pageSize) + const totalPage = Math.ceil(totalCount / pageSize) + const key = (pageNo - 1) * pageSize + const next = (pageNo >= totalPage ? (totalCount % pageSize) : pageSize) + 1 + + for (let i = 1; i < next; i++) { + const tmpKey = key + i + result.push({ + key: tmpKey, + id: tmpKey, + no: 'No ' + tmpKey, + description: '这是一段描述', + callNo: Mock.mock('@integer(1, 999)'), + status: Mock.mock('@integer(0, 3)'), + updatedAt: Mock.mock('@datetime'), + editable: false + }) + } + + return builder({ + pageSize: pageSize, + pageNo: pageNo, + totalCount: totalCount, + totalPage: totalPage, + data: result + }) +} + +const projects = () => { + return builder({ + 'data': [{ + id: 1, + cover: 'https://gw.alipayobjects.com/zos/rmsportal/WdGqmHpayyMjiEhcKoVE.png', + title: 'Alipay', + description: '那是一种内在的东西, 他们到达不了,也无法触及的', + status: 1, + updatedAt: '2018-07-26 00:00:00' + }, + { + id: 2, + cover: 'https://gw.alipayobjects.com/zos/rmsportal/zOsKZmFRdUtvpqCImOVY.png', + title: 'Angular', + description: '希望是一个好东西,也许是最好的,好东西是不会消亡的', + status: 1, + updatedAt: '2018-07-26 00:00:00' + }, + { + id: 3, + cover: 'https://gw.alipayobjects.com/zos/rmsportal/dURIMkkrRFpPgTuzkwnB.png', + title: 'Ant Design', + description: '城镇中有那么多的酒馆,她却偏偏走进了我的酒馆', + status: 1, + updatedAt: '2018-07-26 00:00:00' + }, + { + id: 4, + cover: 'https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png', + title: 'Ant Design Pro', + description: '那时候我只会想自己想要什么,从不想自己拥有什么', + status: 1, + updatedAt: '2018-07-26 00:00:00' + }, + { + id: 5, + cover: 'https://gw.alipayobjects.com/zos/rmsportal/siCrBXXhmvTQGWPNLBow.png', + title: 'Bootstrap', + description: '凛冬将至', + status: 1, + updatedAt: '2018-07-26 00:00:00' + }, + { + id: 6, + cover: 'https://gw.alipayobjects.com/zos/rmsportal/ComBAopevLwENQdKWiIn.png', + title: 'Vue', + description: '生命就像一盒巧克力,结果往往出人意料', + status: 1, + updatedAt: '2018-07-26 00:00:00' + } + ], + 'pageSize': 10, + 'pageNo': 0, + 'totalPage': 6, + 'totalCount': 57 + }) +} + +const activity = () => { + return builder([{ + id: 1, + user: { + nickname: '@name', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png' + }, + project: { + name: '白鹭酱油开发组', + action: '更新', + event: '番组计划' + }, + time: '2018-08-23 14:47:00' + }, + { + id: 1, + user: { + nickname: '蓝莓酱', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/jZUIxmJycoymBprLOUbT.png' + }, + project: { + name: '白鹭酱油开发组', + action: '更新', + event: '番组计划' + }, + time: '2018-08-23 09:35:37' + }, + { + id: 1, + user: { + nickname: '@name', + avatar: '@image(64x64)' + }, + project: { + name: '白鹭酱油开发组', + action: '创建', + event: '番组计划' + }, + time: '2017-05-27 00:00:00' + }, + { + id: 1, + user: { + nickname: '曲丽丽', + avatar: '@image(64x64)' + }, + project: { + name: '高逼格设计天团', + action: '更新', + event: '六月迭代' + }, + time: '2018-08-23 14:47:00' + }, + { + id: 1, + user: { + nickname: '@name', + avatar: '@image(64x64)' + }, + project: { + name: '高逼格设计天团', + action: 'created', + event: '六月迭代' + }, + time: '2018-08-23 14:47:00' + }, + { + id: 1, + user: { + nickname: '曲丽丽', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png' + }, + project: { + name: '高逼格设计天团', + action: 'created', + event: '六月迭代' + }, + time: '2018-08-23 14:47:00' + } + ]) +} + +const teams = () => { + return builder([{ + id: 1, + name: '科学搬砖组', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png' + }, + { + id: 2, + name: '程序员日常', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/cnrhVkzwxjPwAaCfPbdc.png' + }, + { + id: 1, + name: '设计天团', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/gaOngJwsRYRaVAuXXcmB.png' + }, + { + id: 1, + name: '中二少女团', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ubnKSIfAJTxIgXOKlciN.png' + }, + { + id: 1, + name: '骗你学计算机', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/WhxKECPNujWoWEFNdnJE.png' + } + ]) +} + +const radar = () => { + return builder([{ + item: '引用', + '个人': 70, + '团队': 30, + '部门': 40 + }, + { + item: '口碑', + '个人': 60, + '团队': 70, + '部门': 40 + }, + { + item: '产量', + '个人': 50, + '团队': 60, + '部门': 40 + }, + { + item: '贡献', + '个人': 40, + '团队': 50, + '部门': 40 + }, + { + item: '热度', + '个人': 60, + '团队': 70, + '部门': 40 + }, + { + item: '引用', + '个人': 70, + '团队': 50, + '部门': 40 + } + ]) +} + +Mock.mock(/\/service/, 'get', serverList) +Mock.mock(/\/list\/search\/projects/, 'get', projects) +Mock.mock(/\/workplace\/activity/, 'get', activity) +Mock.mock(/\/workplace\/teams/, 'get', teams) +Mock.mock(/\/workplace\/radar/, 'get', radar) diff --git a/quantdinger_vue/src/mock/services/other.js b/quantdinger_vue/src/mock/services/other.js new file mode 100644 index 0000000..56e2dd9 --- /dev/null +++ b/quantdinger_vue/src/mock/services/other.js @@ -0,0 +1,973 @@ +import Mock from 'mockjs2' +import { builder } from '../util' + +const orgTree = () => { + return builder([{ + 'key': 'key-01', + 'title': '研发中心', + 'icon': 'mail', + 'children': [{ + 'key': 'key-01-01', + 'title': '后端组', + 'icon': null, + 'group': true, + children: [{ + 'key': 'key-01-01-01', + 'title': 'JAVA', + 'icon': null + }, + { + 'key': 'key-01-01-02', + 'title': 'PHP', + 'icon': null + }, + { + 'key': 'key-01-01-03', + 'title': 'Golang', + 'icon': null + } + ] + }, { + 'key': 'key-01-02', + 'title': '前端组', + 'icon': null, + 'group': true, + children: [{ + 'key': 'key-01-02-01', + 'title': 'React', + 'icon': null + }, + { + 'key': 'key-01-02-02', + 'title': 'Vue', + 'icon': null + }, + { + 'key': 'key-01-02-03', + 'title': 'Angular', + 'icon': null + } + ] + }] + }, { + 'key': 'key-02', + 'title': '财务部', + 'icon': 'dollar', + 'children': [{ + 'key': 'key-02-01', + 'title': '会计核算', + 'icon': null + }, { + 'key': 'key-02-02', + 'title': '成本控制', + 'icon': null + }, { + 'key': 'key-02-03', + 'title': '内部控制', + 'icon': null, + 'children': [{ + 'key': 'key-02-03-01', + 'title': '财务制度建设', + 'icon': null + }, + { + 'key': 'key-02-03-02', + 'title': '会计核算', + 'icon': null + } + ] + }] + }]) +} + +const role = () => { + return builder({ + 'data': [{ + 'id': 'admin', + 'name': '管理员', + 'describe': '拥有所有权限', + 'status': 1, + 'creatorId': 'system', + 'createTime': 1497160610259, + 'deleted': 0, + 'permissions': [{ + 'roleId': 'admin', + 'permissionId': 'comment', + 'permissionName': '评论管理', + 'actions': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'actionEntitySet': [{ + 'action': 'add', + 'describe': '新增', + 'defaultCheck': false + }, + { + 'action': 'query', + 'describe': '查询', + 'defaultCheck': false + }, + { + 'action': 'get', + 'describe': '详情', + 'defaultCheck': false + }, + { + 'action': 'edit', + 'describe': '修改', + 'defaultCheck': false + }, + { + 'action': 'delete', + 'describe': '删除', + 'defaultCheck': false + }], + 'actionList': ['delete', 'edit'], + 'dataAccess': null + }, + { + 'roleId': 'admin', + 'permissionId': 'member', + 'permissionName': '会员管理', + 'actions': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'actionEntitySet': [{ + 'action': 'add', + 'describe': '新增', + 'defaultCheck': false + }, + { + 'action': 'query', + 'describe': '查询', + 'defaultCheck': false + }, + { + 'action': 'get', + 'describe': '详情', + 'defaultCheck': false + }, + { + 'action': 'edit', + 'describe': '修改', + 'defaultCheck': false + }, + { + 'action': 'delete', + 'describe': '删除', + 'defaultCheck': false + } + ], + 'actionList': ['query', 'get', 'edit', 'delete'], + 'dataAccess': null + }, + { + 'roleId': 'admin', + 'permissionId': 'menu', + 'permissionName': '菜单管理', + 'actions': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"import","defaultCheck":false,"describe":"导入"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"edit","defaultCheck":false,"describe":"修改"}]', + 'actionEntitySet': [{ + 'action': 'add', + 'describe': '新增', + 'defaultCheck': false + }, + { + 'action': 'import', + 'describe': '导入', + 'defaultCheck': false + }, + { + 'action': 'get', + 'describe': '详情', + 'defaultCheck': false + }, + { + 'action': 'edit', + 'describe': '修改', + 'defaultCheck': false + } + ], + 'actionList': ['add', 'import'], + 'dataAccess': null + }, + { + 'roleId': 'admin', + 'permissionId': 'order', + 'permissionName': '订单管理', + 'actions': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'actionEntitySet': [{ + 'action': 'add', + 'describe': '新增', + 'defaultCheck': false + }, + { + 'action': 'query', + 'describe': '查询', + 'defaultCheck': false + }, + { + 'action': 'get', + 'describe': '详情', + 'defaultCheck': false + }, + { + 'action': 'edit', + 'describe': '修改', + 'defaultCheck': false + }, + { + 'action': 'delete', + 'describe': '删除', + 'defaultCheck': false + } + ], + 'actionList': ['query', 'add', 'get'], + 'dataAccess': null + }, + { + 'roleId': 'admin', + 'permissionId': 'permission', + 'permissionName': '权限管理', + 'actions': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'actionEntitySet': [{ + 'action': 'add', + 'describe': '新增', + 'defaultCheck': false + }, + { + 'action': 'get', + 'describe': '详情', + 'defaultCheck': false + }, + { + 'action': 'edit', + 'describe': '修改', + 'defaultCheck': false + }, + { + 'action': 'delete', + 'describe': '删除', + 'defaultCheck': false + } + ], + 'actionList': ['add', 'get', 'edit', 'delete'], + 'dataAccess': null + }, + { + 'roleId': 'admin', + 'permissionId': 'role', + 'permissionName': '角色管理', + 'actions': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'actionEntitySet': [{ + 'action': 'add', + 'describe': '新增', + 'defaultCheck': false + }, + { + 'action': 'get', + 'describe': '详情', + 'defaultCheck': false + }, + { + 'action': 'edit', + 'describe': '修改', + 'defaultCheck': false + }, + { + 'action': 'delete', + 'describe': '删除', + 'defaultCheck': false + } + ], + 'actionList': null, + 'dataAccess': null + }, + { + 'roleId': 'admin', + 'permissionId': 'test', + 'permissionName': '测试权限', + 'actions': '[]', + 'actionEntitySet': [], + 'actionList': null, + 'dataAccess': null + }, + { + 'roleId': 'admin', + 'permissionId': 'user', + 'permissionName': '用户管理', + 'actions': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"import","defaultCheck":false,"describe":"导入"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"},{"action":"export","defaultCheck":false,"describe":"导出"}]', + 'actionEntitySet': [{ + 'action': 'add', + 'describe': '新增', + 'defaultCheck': false + }, + { + 'action': 'import', + 'describe': '导入', + 'defaultCheck': false + }, + { + 'action': 'get', + 'describe': '详情', + 'defaultCheck': false + }, + { + 'action': 'edit', + 'describe': '修改', + 'defaultCheck': false + }, + { + 'action': 'delete', + 'describe': '删除', + 'defaultCheck': false + }, + { + 'action': 'export', + 'describe': '导出', + 'defaultCheck': false + } + ], + 'actionList': ['add', 'get'], + 'dataAccess': null + } + ] + }, + { + 'id': 'svip', + 'name': 'SVIP', + 'describe': '超级会员', + 'status': 1, + 'creatorId': 'system', + 'createTime': 1532417744846, + 'deleted': 0, + 'permissions': [{ + 'roleId': 'admin', + 'permissionId': 'comment', + 'permissionName': '评论管理', + 'actions': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'actionEntitySet': [{ + 'action': 'add', + 'describe': '新增', + 'defaultCheck': false + }, + { + 'action': 'query', + 'describe': '查询', + 'defaultCheck': false + }, + { + 'action': 'get', + 'describe': '详情', + 'defaultCheck': false + }, + { + 'action': 'edit', + 'describe': '修改', + 'defaultCheck': false + }, + { + 'action': 'delete', + 'describe': '删除', + 'defaultCheck': false + } + ], + 'actionList': ['add', 'get', 'delete'], + 'dataAccess': null + }, + { + 'roleId': 'admin', + 'permissionId': 'member', + 'permissionName': '会员管理', + 'actions': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'actionEntitySet': [{ + 'action': 'add', + 'describe': '新增', + 'defaultCheck': false + }, + { + 'action': 'query', + 'describe': '查询', + 'defaultCheck': false + }, + { + 'action': 'get', + 'describe': '详情', + 'defaultCheck': false + } + ], + 'actionList': ['add', 'query', 'get'], + 'dataAccess': null + }, + { + 'roleId': 'admin', + 'permissionId': 'menu', + 'permissionName': '菜单管理', + 'actions': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"import","defaultCheck":false,"describe":"导入"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"edit","defaultCheck":false,"describe":"修改"}]', + 'actionEntitySet': [{ + 'action': 'add', + 'describe': '新增', + 'defaultCheck': false + }, + { + 'action': 'import', + 'describe': '导入', + 'defaultCheck': false + }, + { + 'action': 'get', + 'describe': '详情', + 'defaultCheck': false + } + ], + 'actionList': ['add', 'get'], + 'dataAccess': null + }, + { + 'roleId': 'admin', + 'permissionId': 'order', + 'permissionName': '订单管理', + 'actions': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'actionEntitySet': [{ + 'action': 'add', + 'describe': '新增', + 'defaultCheck': false + }, + { + 'action': 'query', + 'describe': '查询', + 'defaultCheck': false + }, + { + 'action': 'get', + 'describe': '详情', + 'defaultCheck': false + }, + { + 'action': 'edit', + 'describe': '修改', + 'defaultCheck': false + } + ], + 'actionList': ['add', 'query'], + 'dataAccess': null + }, + { + 'roleId': 'admin', + 'permissionId': 'permission', + 'permissionName': '权限管理', + 'actions': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'actionEntitySet': [{ + 'action': 'add', + 'describe': '新增', + 'defaultCheck': false + }, + { + 'action': 'get', + 'describe': '详情', + 'defaultCheck': false + }, + { + 'action': 'edit', + 'describe': '修改', + 'defaultCheck': false + } + ], + 'actionList': ['add', 'get', 'edit'], + 'dataAccess': null + }, + { + 'roleId': 'admin', + 'permissionId': 'role', + 'permissionName': '角色管理', + 'actions': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'actionEntitySet': [{ + 'action': 'add', + 'describe': '新增', + 'defaultCheck': false + }, + { + 'action': 'edit', + 'describe': '修改', + 'defaultCheck': false + }, + { + 'action': 'delete', + 'describe': '删除', + 'defaultCheck': false + } + ], + 'actionList': null, + 'dataAccess': null + }, + { + 'roleId': 'admin', + 'permissionId': 'test', + 'permissionName': '测试权限', + 'actions': '[]', + 'actionEntitySet': [], + 'actionList': ['add', 'edit'], + 'dataAccess': null + }, + { + 'roleId': 'admin', + 'permissionId': 'user', + 'permissionName': '用户管理', + 'actions': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"import","defaultCheck":false,"describe":"导入"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"},{"action":"export","defaultCheck":false,"describe":"导出"}]', + 'actionEntitySet': [{ + 'action': 'add', + 'describe': '新增', + 'defaultCheck': false + }, + { + 'action': 'import', + 'describe': '导入', + 'defaultCheck': false + }, + { + 'action': 'get', + 'describe': '详情', + 'defaultCheck': false + }, + { + 'action': 'edit', + 'describe': '修改', + 'defaultCheck': false + } + ], + 'actionList': ['add'], + 'dataAccess': null + } + ] + }, + { + 'id': 'user', + 'name': '普通会员', + 'describe': '普通用户,只能查询', + 'status': 1, + 'creatorId': 'system', + 'createTime': 1497160610259, + 'deleted': 0, + 'permissions': [{ + 'roleId': 'user', + 'permissionId': 'comment', + 'permissionName': '评论管理', + 'actions': '[{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"}]', + 'actionEntitySet': [{ + 'action': 'query', + 'describe': '查询', + 'defaultCheck': false + }, + { + 'action': 'get', + 'describe': '详情', + 'defaultCheck': false + } + ], + 'actionList': ['query'], + 'dataAccess': null + }, + + { + 'roleId': 'user', + 'permissionId': 'marketing', + 'permissionName': '营销管理', + 'actions': '[]', + 'actionEntitySet': [], + 'actionList': null, + 'dataAccess': null + }, + { + 'roleId': 'user', + 'permissionId': 'member', + 'permissionName': '会员管理', + 'actions': '[{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"}]', + 'actionEntitySet': [{ + 'action': 'query', + 'describe': '查询', + 'defaultCheck': false + }, + { + 'action': 'get', + 'describe': '详情', + 'defaultCheck': false + } + ], + 'actionList': null, + 'dataAccess': null + }, + { + 'roleId': 'user', + 'permissionId': 'menu', + 'permissionName': '菜单管理', + 'actions': '[]', + 'actionEntitySet': [], + 'actionList': null, + 'dataAccess': null + }, + + { + 'roleId': 'user', + 'permissionId': 'order', + 'permissionName': '订单管理', + 'actions': '[{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"}]', + 'actionEntitySet': [{ + 'action': 'query', + 'describe': '查询', + 'defaultCheck': false + }, + { + 'action': 'get', + 'describe': '详情', + 'defaultCheck': false + } + ], + 'actionList': null, + 'dataAccess': null + }, + { + 'roleId': 'user', + 'permissionId': 'permission', + 'permissionName': '权限管理', + 'actions': '[]', + 'actionEntitySet': [], + 'actionList': null, + 'dataAccess': null + }, + { + 'roleId': 'user', + 'permissionId': 'role', + 'permissionName': '角色管理', + 'actions': '[]', + 'actionEntitySet': [], + 'actionList': null, + 'dataAccess': null + }, + + { + 'roleId': 'user', + 'permissionId': 'test', + 'permissionName': '测试权限', + 'actions': '[]', + 'actionEntitySet': [], + 'actionList': null, + 'dataAccess': null + }, + { + 'roleId': 'user', + 'permissionId': 'user', + 'permissionName': '用户管理', + 'actions': '[]', + 'actionEntitySet': [], + 'actionList': null, + 'dataAccess': null + } + ] + } + ], + 'pageSize': 10, + 'pageNo': 0, + 'totalPage': 1, + 'totalCount': 5 + }) +} + +const permissionNoPager = () => { + return builder([{ + 'id': 'marketing', + 'name': '营销管理', + 'describe': null, + 'status': 1, + 'actionData': '[{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'sptDaTypes': null, + 'optionalFields': null, + 'parents': null, + 'type': null, + 'deleted': 0, + 'actions': [ + 'add', + 'query', + 'get', + 'edit', + 'delete' + ] + }, + { + 'id': 'member', + 'name': '会员管理', + 'describe': null, + 'status': 1, + 'actionData': '[{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'sptDaTypes': null, + 'optionalFields': '[]', + 'parents': null, + 'type': 'default', + 'deleted': 0, + 'actions': [ + 'add', + 'query', + 'get', + 'edit', + 'delete' + ] + }, + { + 'id': 'menu', + 'name': '菜单管理', + 'describe': null, + 'status': 1, + 'actionData': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"import","defaultCheck":false,"describe":"导入"},{"action":"get","defaultCheck":false,"describe":"查询"},{"action":"edit","defaultCheck":false,"describe":"修改"}]', + 'sptDaTypes': null, + 'optionalFields': '[]', + 'parents': null, + 'type': 'default', + 'deleted': 0, + 'actions': [ + 'add', + 'import', + 'get', + 'edit' + ] + }, + { + 'id': 'order', + 'name': '订单管理', + 'describe': null, + 'status': 1, + 'actionData': '[{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'sptDaTypes': null, + 'optionalFields': '[]', + 'parents': null, + 'type': 'default', + 'deleted': 0, + 'actions': [ + 'add', + 'query', + 'get', + 'edit', + 'delete' + ] + }, + { + 'id': 'permission', + 'name': '权限管理', + 'describe': null, + 'status': 1, + 'actionData': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"get","defaultCheck":false,"describe":"查询"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'sptDaTypes': null, + 'optionalFields': '[]', + 'parents': null, + 'type': 'default', + 'deleted': 0, + 'actions': [ + 'add', + 'get', + 'edit', + 'delete' + ] + }, + { + 'id': 'role', + 'name': '角色管理', + 'describe': null, + 'status': 1, + 'actionData': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"get","defaultCheck":false,"describe":"查询"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'sptDaTypes': null, + 'optionalFields': '[]', + 'parents': null, + 'type': 'default', + 'deleted': 0, + 'actions': [ + 'add', + 'get', + 'edit', + 'delete' + ] + }, + { + 'id': 'test', + 'name': '测试权限', + 'describe': null, + 'status': 1, + 'actionData': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"get","defaultCheck":false,"describe":"详情"}]', + 'sptDaTypes': null, + 'optionalFields': '[]', + 'parents': null, + 'type': 'default', + 'deleted': 0, + 'actions': [ + 'add', + 'get' + ] + }, + { + 'id': 'user', + 'name': '用户管理', + 'describe': null, + 'status': 1, + 'actionData': '[{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"},{"action":"import","defaultCheck":false,"describe":"导入"},{"action":"export","defaultCheck":false,"describe":"导出"}]', + 'sptDaTypes': null, + 'optionalFields': '[]', + 'parents': null, + 'type': 'default', + 'deleted': 0, + 'actions': [ + 'add', + 'get' + ] + } + ]) +} + +const permissions = () => { + return builder({ + 'data': [{ + 'id': 'marketing', + 'name': '营销管理', + 'describe': null, + 'status': 1, + 'actionData': '[{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'sptDaTypes': null, + 'optionalFields': null, + 'parents': null, + 'type': null, + 'deleted': 0, + 'actions': [ + 'add', + 'query', + 'get', + 'edit', + 'delete' + ] + }, + { + 'id': 'member', + 'name': '会员管理', + 'describe': null, + 'status': 1, + 'actionData': '[{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'sptDaTypes': null, + 'optionalFields': '[]', + 'parents': null, + 'type': 'default', + 'deleted': 0, + 'actions': [ + 'add', + 'query', + 'get', + 'edit', + 'delete' + ] + }, + { + 'id': 'menu', + 'name': '菜单管理', + 'describe': null, + 'status': 1, + 'actionData': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"import","defaultCheck":false,"describe":"导入"},{"action":"get","defaultCheck":false,"describe":"查询"},{"action":"edit","defaultCheck":false,"describe":"修改"}]', + 'sptDaTypes': null, + 'optionalFields': '[]', + 'parents': null, + 'type': 'default', + 'deleted': 0, + 'actions': [ + 'add', + 'import', + 'get', + 'edit' + ] + }, + { + 'id': 'order', + 'name': '订单管理', + 'describe': null, + 'status': 1, + 'actionData': '[{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'sptDaTypes': null, + 'optionalFields': '[]', + 'parents': null, + 'type': 'default', + 'deleted': 0, + 'actions': [ + 'add', + 'query', + 'get', + 'edit', + 'delete' + ] + }, + { + 'id': 'permission', + 'name': '权限管理', + 'describe': null, + 'status': 1, + 'actionData': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"get","defaultCheck":false,"describe":"查询"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'sptDaTypes': null, + 'optionalFields': '[]', + 'parents': null, + 'type': 'default', + 'deleted': 0, + 'actions': [ + 'add', + 'get', + 'edit', + 'delete' + ] + }, + { + 'id': 'role', + 'name': '角色管理', + 'describe': null, + 'status': 1, + 'actionData': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"get","defaultCheck":false,"describe":"查询"},{"action":"edit","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + 'sptDaTypes': null, + 'optionalFields': '[]', + 'parents': null, + 'type': 'default', + 'deleted': 0, + 'actions': [ + 'add', + 'get', + 'edit', + 'delete' + ] + }, + { + 'id': 'test', + 'name': '测试权限', + 'describe': null, + 'status': 1, + 'actionData': '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"get","defaultCheck":false,"describe":"详情"}]', + 'sptDaTypes': null, + 'optionalFields': '[]', + 'parents': null, + 'type': 'default', + 'deleted': 0, + 'actions': [ + 'add', + 'get' + ] + }, + { + 'id': 'user', + 'name': '用户管理', + 'describe': null, + 'status': 1, + 'actionData': '[{"action":"add","describe":"新增","defaultCheck":false},{"action":"get","describe":"查询","defaultCheck":false}]', + 'sptDaTypes': null, + 'optionalFields': '[]', + 'parents': null, + 'type': 'default', + 'deleted': 0, + 'actions': [ + 'add', + 'get' + ] + } + ], + 'pageSize': 10, + 'pageNo': 0, + 'totalPage': 1, + 'totalCount': 5 + }) +} + +Mock.mock(/\/org\/tree/, 'get', orgTree) +Mock.mock(/\/role/, 'get', role) +Mock.mock(/\/permission\/no-pager/, 'get', permissionNoPager) +Mock.mock(/\/permission/, 'get', permissions) diff --git a/quantdinger_vue/src/mock/services/tagCloud.js b/quantdinger_vue/src/mock/services/tagCloud.js new file mode 100644 index 0000000..63a2e06 --- /dev/null +++ b/quantdinger_vue/src/mock/services/tagCloud.js @@ -0,0 +1,9 @@ +import Mock from 'mockjs2' +import { builder } from '../util' + +// +const tagCloudData = () => { + return builder([{ 'value': 9, 'name': 'AntV' }, { 'value': 8, 'name': 'F2' }, { 'value': 8, 'name': 'G2' }, { 'value': 8, 'name': 'G6' }, { 'value': 8, 'name': 'DataSet' }, { 'value': 8, 'name': '墨者学院' }, { 'value': 6, 'name': 'Analysis' }, { 'value': 6, 'name': 'Data Mining' }, { 'value': 6, 'name': 'Data Vis' }, { 'value': 6, 'name': 'Design' }, { 'value': 6, 'name': 'Grammar' }, { 'value': 6, 'name': 'Graphics' }, { 'value': 6, 'name': 'Graph' }, { 'value': 6, 'name': 'Hierarchy' }, { 'value': 6, 'name': 'Labeling' }, { 'value': 6, 'name': 'Layout' }, { 'value': 6, 'name': 'Quantitative' }, { 'value': 6, 'name': 'Relation' }, { 'value': 6, 'name': 'Statistics' }, { 'value': 6, 'name': '可视化' }, { 'value': 6, 'name': '数据' }, { 'value': 6, 'name': '数据可视化' }, { 'value': 4, 'name': 'Arc Diagram' }, { 'value': 4, 'name': 'Bar Chart' }, { 'value': 4, 'name': 'Canvas' }, { 'value': 4, 'name': 'Chart' }, { 'value': 4, 'name': 'DAG' }, { 'value': 4, 'name': 'DG' }, { 'value': 4, 'name': 'Facet' }, { 'value': 4, 'name': 'Geo' }, { 'value': 4, 'name': 'Line' }, { 'value': 4, 'name': 'MindMap' }, { 'value': 4, 'name': 'Pie' }, { 'value': 4, 'name': 'Pizza Chart' }, { 'value': 4, 'name': 'Punch Card' }, { 'value': 4, 'name': 'SVG' }, { 'value': 4, 'name': 'Sunburst' }, { 'value': 4, 'name': 'Tree' }, { 'value': 4, 'name': 'UML' }, { 'value': 3, 'name': 'Chart' }, { 'value': 3, 'name': 'View' }, { 'value': 3, 'name': 'Geom' }, { 'value': 3, 'name': 'Shape' }, { 'value': 3, 'name': 'Scale' }, { 'value': 3, 'name': 'Animate' }, { 'value': 3, 'name': 'Global' }, { 'value': 3, 'name': 'Slider' }, { 'value': 3, 'name': 'Connector' }, { 'value': 3, 'name': 'Transform' }, { 'value': 3, 'name': 'Util' }, { 'value': 3, 'name': 'DomUtil' }, { 'value': 3, 'name': 'MatrixUtil' }, { 'value': 3, 'name': 'PathUtil' }, { 'value': 3, 'name': 'G' }, { 'value': 3, 'name': '2D' }, { 'value': 3, 'name': '3D' }, { 'value': 3, 'name': 'Line' }, { 'value': 3, 'name': 'Area' }, { 'value': 3, 'name': 'Interval' }, { 'value': 3, 'name': 'Schema' }, { 'value': 3, 'name': 'Edge' }, { 'value': 3, 'name': 'Polygon' }, { 'value': 3, 'name': 'Heatmap' }, { 'value': 3, 'name': 'Render' }, { 'value': 3, 'name': 'Tooltip' }, { 'value': 3, 'name': 'Axis' }, { 'value': 3, 'name': 'Guide' }, { 'value': 3, 'name': 'Coord' }, { 'value': 3, 'name': 'Legend' }, { 'value': 3, 'name': 'Path' }, { 'value': 3, 'name': 'Helix' }, { 'value': 3, 'name': 'Theta' }, { 'value': 3, 'name': 'Rect' }, { 'value': 3, 'name': 'Polar' }, { 'value': 3, 'name': 'Dsv' }, { 'value': 3, 'name': 'Csv' }, { 'value': 3, 'name': 'Tsv' }, { 'value': 3, 'name': 'GeoJSON' }, { 'value': 3, 'name': 'TopoJSON' }, { 'value': 3, 'name': 'Filter' }, { 'value': 3, 'name': 'Map' }, { 'value': 3, 'name': 'Pick' }, { 'value': 3, 'name': 'Rename' }, { 'value': 3, 'name': 'Filter' }, { 'value': 3, 'name': 'Map' }, { 'value': 3, 'name': 'Pick' }, { 'value': 3, 'name': 'Rename' }, { 'value': 3, 'name': 'Reverse' }, { 'value': 3, 'name': 'sort' }, { 'value': 3, 'name': 'Subset' }, { 'value': 3, 'name': 'Partition' }, { 'value': 3, 'name': 'Imputation' }, { 'value': 3, 'name': 'Fold' }, { 'value': 3, 'name': 'Aggregate' }, { 'value': 3, 'name': 'Proportion' }, { 'value': 3, 'name': 'Histogram' }, { 'value': 3, 'name': 'Quantile' }, { 'value': 3, 'name': 'Treemap' }, { 'value': 3, 'name': 'Hexagon' }, { 'value': 3, 'name': 'Binning' }, { 'value': 3, 'name': 'kernel' }, { 'value': 3, 'name': 'Regression' }, { 'value': 3, 'name': 'Density' }, { 'value': 3, 'name': 'Sankey' }, { 'value': 3, 'name': 'Voronoi' }, { 'value': 3, 'name': 'Projection' }, { 'value': 3, 'name': 'Centroid' }, { 'value': 3, 'name': 'H5' }, { 'value': 3, 'name': 'Mobile' }, { 'value': 3, 'name': 'K线图' }, { 'value': 3, 'name': '关系图' }, { 'value': 3, 'name': '烛形图' }, { 'value': 3, 'name': '股票图' }, { 'value': 3, 'name': '直方图' }, { 'value': 3, 'name': '金字塔图' }, { 'value': 3, 'name': '分面' }, { 'value': 3, 'name': '南丁格尔玫瑰图' }, { 'value': 3, 'name': '饼图' }, { 'value': 3, 'name': '线图' }, { 'value': 3, 'name': '点图' }, { 'value': 3, 'name': '散点图' }, { 'value': 3, 'name': '子弹图' }, { 'value': 3, 'name': '柱状图' }, { 'value': 3, 'name': '仪表盘' }, { 'value': 3, 'name': '气泡图' }, { 'value': 3, 'name': '漏斗图' }, { 'value': 3, 'name': '热力图' }, { 'value': 3, 'name': '玉玦图' }, { 'value': 3, 'name': '直方图' }, { 'value': 3, 'name': '矩形树图' }, { 'value': 3, 'name': '箱形图' }, { 'value': 3, 'name': '色块图' }, { 'value': 3, 'name': '螺旋图' }, { 'value': 3, 'name': '词云' }, { 'value': 3, 'name': '词云图' }, { 'value': 3, 'name': '雷达图' }, { 'value': 3, 'name': '面积图' }, { 'value': 3, 'name': '马赛克图' }, { 'value': 3, 'name': '盒须图' }, { 'value': 3, 'name': '坐标轴' }, { 'value': 3, 'name': '' }, { 'value': 3, 'name': 'Jacques Bertin' }, { 'value': 3, 'name': 'Leland Wilkinson' }, { 'value': 3, 'name': 'William Playfair' }, { 'value': 3, 'name': '关联' }, { 'value': 3, 'name': '分布' }, { 'value': 3, 'name': '区间' }, { 'value': 3, 'name': '占比' }, { 'value': 3, 'name': '地图' }, { 'value': 3, 'name': '时间' }, { 'value': 3, 'name': '比较' }, { 'value': 3, 'name': '流程' }, { 'value': 3, 'name': '趋势' }, { 'value': 2, 'name': '亦叶' }, { 'value': 2, 'name': '再飞' }, { 'value': 2, 'name': '完白' }, { 'value': 2, 'name': '巴思' }, { 'value': 2, 'name': '张初尘' }, { 'value': 2, 'name': '御术' }, { 'value': 2, 'name': '有田' }, { 'value': 2, 'name': '沉鱼' }, { 'value': 2, 'name': '玉伯' }, { 'value': 2, 'name': '画康' }, { 'value': 2, 'name': '祯逸' }, { 'value': 2, 'name': '绝云' }, { 'value': 2, 'name': '罗宪' }, { 'value': 2, 'name': '萧庆' }, { 'value': 2, 'name': '董珊珊' }, { 'value': 2, 'name': '陆沉' }, { 'value': 2, 'name': '顾倾' }, { 'value': 2, 'name': 'Domo' }, { 'value': 2, 'name': 'GPL' }, { 'value': 2, 'name': 'PAI' }, { 'value': 2, 'name': 'SPSS' }, { 'value': 2, 'name': 'SYSTAT' }, { 'value': 2, 'name': 'Tableau' }, { 'value': 2, 'name': 'D3' }, { 'value': 2, 'name': 'Vega' }, { 'value': 2, 'name': '统计图表' }]) +} + +Mock.mock(/\/data\/antv\/tag-cloud/, 'get', tagCloudData) diff --git a/quantdinger_vue/src/mock/services/user.js b/quantdinger_vue/src/mock/services/user.js new file mode 100644 index 0000000..a104a31 --- /dev/null +++ b/quantdinger_vue/src/mock/services/user.js @@ -0,0 +1,577 @@ +import Mock from 'mockjs2' +import { builder } from '../util' + +const info = options => { + const userInfo = { + id: '4291d7da9005377ec9aec4a71ea837f', + name: '天野远子', + username: 'admin', + password: '', + avatar: '/avatar2.jpg', + status: 1, + telephone: '', + lastLoginIp: '27.154.74.117', + lastLoginTime: 1534837621348, + creatorId: 'admin', + createTime: 1497160610259, + merchantCode: 'TLif2btpzg079h15bk', + deleted: 0, + roleId: 'admin', + role: {} + } + // role + const roleObj = { + id: 'admin', + name: '管理员', + describe: '拥有所有权限', + status: 1, + creatorId: 'system', + createTime: 1497160610259, + deleted: 0, + permissions: [ + { + roleId: 'admin', + permissionId: 'dashboard', + permissionName: '仪表盘', + actions: + '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"update","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + actionEntitySet: [ + { + action: 'add', + describe: '新增', + defaultCheck: false + }, + { + action: 'query', + describe: '查询', + defaultCheck: false + }, + { + action: 'get', + describe: '详情', + defaultCheck: false + }, + { + action: 'update', + describe: '修改', + defaultCheck: false + }, + { + action: 'delete', + describe: '删除', + defaultCheck: false + } + ], + actionList: null, + dataAccess: null + }, + { + roleId: 'admin', + permissionId: 'exception', + permissionName: '异常页面权限', + actions: + '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"update","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + actionEntitySet: [ + { + action: 'add', + describe: '新增', + defaultCheck: false + }, + { + action: 'query', + describe: '查询', + defaultCheck: false + }, + { + action: 'get', + describe: '详情', + defaultCheck: false + }, + { + action: 'update', + describe: '修改', + defaultCheck: false + }, + { + action: 'delete', + describe: '删除', + defaultCheck: false + } + ], + actionList: null, + dataAccess: null + }, + { + roleId: 'admin', + permissionId: 'result', + permissionName: '结果权限', + actions: + '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"update","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + actionEntitySet: [ + { + action: 'add', + describe: '新增', + defaultCheck: false + }, + { + action: 'query', + describe: '查询', + defaultCheck: false + }, + { + action: 'get', + describe: '详情', + defaultCheck: false + }, + { + action: 'update', + describe: '修改', + defaultCheck: false + }, + { + action: 'delete', + describe: '删除', + defaultCheck: false + } + ], + actionList: null, + dataAccess: null + }, + { + roleId: 'admin', + permissionId: 'profile', + permissionName: '详细页权限', + actions: + '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"update","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + actionEntitySet: [ + { + action: 'add', + describe: '新增', + defaultCheck: false + }, + { + action: 'query', + describe: '查询', + defaultCheck: false + }, + { + action: 'get', + describe: '详情', + defaultCheck: false + }, + { + action: 'update', + describe: '修改', + defaultCheck: false + }, + { + action: 'delete', + describe: '删除', + defaultCheck: false + } + ], + actionList: null, + dataAccess: null + }, + { + roleId: 'admin', + permissionId: 'table', + permissionName: '表格权限', + actions: + '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"import","defaultCheck":false,"describe":"导入"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"update","defaultCheck":false,"describe":"修改"}]', + actionEntitySet: [ + { + action: 'add', + describe: '新增', + defaultCheck: false + }, + { + action: 'import', + describe: '导入', + defaultCheck: false + }, + { + action: 'get', + describe: '详情', + defaultCheck: false + }, + { + action: 'update', + describe: '修改', + defaultCheck: false + } + ], + actionList: null, + dataAccess: null + }, + { + roleId: 'admin', + permissionId: 'form', + permissionName: '表单权限', + actions: + '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"update","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + actionEntitySet: [ + { + action: 'add', + describe: '新增', + defaultCheck: false + }, + { + action: 'get', + describe: '详情', + defaultCheck: false + }, + { + action: 'query', + describe: '查询', + defaultCheck: false + }, + { + action: 'update', + describe: '修改', + defaultCheck: false + }, + { + action: 'delete', + describe: '删除', + defaultCheck: false + } + ], + actionList: null, + dataAccess: null + }, + { + roleId: 'admin', + permissionId: 'order', + permissionName: '订单管理', + actions: + '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"update","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + actionEntitySet: [ + { + action: 'add', + describe: '新增', + defaultCheck: false + }, + { + action: 'query', + describe: '查询', + defaultCheck: false + }, + { + action: 'get', + describe: '详情', + defaultCheck: false + }, + { + action: 'update', + describe: '修改', + defaultCheck: false + }, + { + action: 'delete', + describe: '删除', + defaultCheck: false + } + ], + actionList: null, + dataAccess: null + }, + { + roleId: 'admin', + permissionId: 'permission', + permissionName: '权限管理', + actions: + '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"update","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + actionEntitySet: [ + { + action: 'add', + describe: '新增', + defaultCheck: false + }, + { + action: 'get', + describe: '详情', + defaultCheck: false + }, + { + action: 'update', + describe: '修改', + defaultCheck: false + }, + { + action: 'delete', + describe: '删除', + defaultCheck: false + } + ], + actionList: null, + dataAccess: null + }, + { + roleId: 'admin', + permissionId: 'role', + permissionName: '角色管理', + actions: + '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"update","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + actionEntitySet: [ + { + action: 'add', + describe: '新增', + defaultCheck: false + }, + { + action: 'get', + describe: '详情', + defaultCheck: false + }, + { + action: 'update', + describe: '修改', + defaultCheck: false + }, + { + action: 'delete', + describe: '删除', + defaultCheck: false + } + ], + actionList: null, + dataAccess: null + }, + { + roleId: 'admin', + permissionId: 'table', + permissionName: '桌子管理', + actions: + '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"query","defaultCheck":false,"describe":"查询"},{"action":"update","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"}]', + actionEntitySet: [ + { + action: 'add', + describe: '新增', + defaultCheck: false + }, + { + action: 'get', + describe: '详情', + defaultCheck: false + }, + { + action: 'query', + describe: '查询', + defaultCheck: false + }, + { + action: 'update', + describe: '修改', + defaultCheck: false + }, + { + action: 'delete', + describe: '删除', + defaultCheck: false + } + ], + actionList: null, + dataAccess: null + }, + { + roleId: 'admin', + permissionId: 'user', + permissionName: '用户管理', + actions: + '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"import","defaultCheck":false,"describe":"导入"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"update","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"},{"action":"export","defaultCheck":false,"describe":"导出"}]', + actionEntitySet: [ + { + action: 'add', + describe: '新增', + defaultCheck: false + }, + { + action: 'import', + describe: '导入', + defaultCheck: false + }, + { + action: 'get', + describe: '详情', + defaultCheck: false + }, + { + action: 'update', + describe: '修改', + defaultCheck: false + }, + { + action: 'delete', + describe: '删除', + defaultCheck: false + }, + { + action: 'export', + describe: '导出', + defaultCheck: false + } + ], + actionList: null, + dataAccess: null + } + ] + } + + roleObj.permissions.push({ + roleId: 'admin', + permissionId: 'support', + permissionName: '超级模块', + actions: + '[{"action":"add","defaultCheck":false,"describe":"新增"},{"action":"import","defaultCheck":false,"describe":"导入"},{"action":"get","defaultCheck":false,"describe":"详情"},{"action":"update","defaultCheck":false,"describe":"修改"},{"action":"delete","defaultCheck":false,"describe":"删除"},{"action":"export","defaultCheck":false,"describe":"导出"}]', + actionEntitySet: [ + { + action: 'add', + describe: '新增', + defaultCheck: false + }, + { + action: 'import', + describe: '导入', + defaultCheck: false + }, + { + action: 'get', + describe: '详情', + defaultCheck: false + }, + { + action: 'update', + describe: '修改', + defaultCheck: false + }, + { + action: 'delete', + describe: '删除', + defaultCheck: false + }, + { + action: 'export', + describe: '导出', + defaultCheck: false + } + ], + actionList: null, + dataAccess: null + }) + + userInfo.role = roleObj + return builder(userInfo) +} + +/** + * 使用 用户登录的 token 获取用户有权限的菜单 + * 返回结构必须按照这个结构体形式处理,或根据 + * /src/router/generator-routers.js 文件的菜单结构处理函数对应即可 + * @param {*} options + * @returns + */ +const userNav = options => { + const nav = [ + // AI 分析 + { + name: 'Analysis', + parentId: 0, + id: 1, + meta: { + title: 'menu.dashboard.analysis', + icon: 'thunderbolt', + show: true + }, + component: 'Analysis', + path: '/ai-analysis' + }, + // 指标分析 + { + name: 'Indicator', + parentId: 0, + id: 2, + meta: { + title: 'menu.dashboard.indicator', + icon: 'line-chart', + show: true + }, + component: 'Indicator', + path: '/indicator-analysis' + }, + // 特殊三级菜单 + { + name: 'settings', + parentId: 10028, + id: 10030, + meta: { + title: 'menu.account.settings', + hideHeader: true, + hideChildren: true, + show: true + }, + redirect: '/account/settings/basic', + component: 'AccountSettings' + }, + { + name: 'BasicSettings', + path: '/account/settings/basic', + parentId: 10030, + id: 10031, + meta: { + title: 'account.settings.menuMap.basic', + show: false + }, + component: 'BasicSetting' + }, + { + name: 'SecuritySettings', + path: '/account/settings/security', + parentId: 10030, + id: 10032, + meta: { + title: 'account.settings.menuMap.security', + show: false + }, + component: 'SecuritySettings' + }, + { + name: 'CustomSettings', + path: '/account/settings/custom', + parentId: 10030, + id: 10033, + meta: { + title: 'account.settings.menuMap.custom', + show: false + }, + component: 'CustomSettings' + }, + { + name: 'BindingSettings', + path: '/account/settings/binding', + parentId: 10030, + id: 10034, + meta: { + title: 'account.settings.menuMap.binding', + show: false + }, + component: 'BindingSettings' + }, + { + name: 'NotificationSettings', + path: '/account/settings/notification', + parentId: 10030, + id: 10034, + meta: { + title: 'account.settings.menuMap.notification', + show: false + }, + component: 'NotificationSettings' + } + ] + const json = builder(nav) + return json +} + +Mock.mock(/\/api\/user\/info/, 'get', info) +Mock.mock(/\/api\/user\/nav/, 'get', userNav) diff --git a/quantdinger_vue/src/mock/util.js b/quantdinger_vue/src/mock/util.js new file mode 100644 index 0000000..a4be036 --- /dev/null +++ b/quantdinger_vue/src/mock/util.js @@ -0,0 +1,38 @@ +const responseBody = { + message: '', + timestamp: 0, + result: null, + code: 0 +} + +export const builder = (data, message, code = 0, headers = {}) => { + responseBody.result = data + if (message !== undefined && message !== null) { + responseBody.message = message + } + if (code !== undefined && code !== 0) { + responseBody.code = code + responseBody._status = code + } + if (headers !== null && typeof headers === 'object' && Object.keys(headers).length > 0) { + responseBody._headers = headers + } + responseBody.timestamp = new Date().getTime() + return responseBody +} + +export const getQueryParameters = (options) => { + const url = options.url + const search = url.split('?')[1] + if (!search) { + return {} + } + return JSON.parse('{"' + decodeURIComponent(search) + .replace(/"/g, '\\"') + .replace(/&/g, '","') + .replace(/=/g, '":"') + '"}') +} + +export const getBody = (options) => { + return options.body && JSON.parse(options.body) +} diff --git a/quantdinger_vue/src/permission.js b/quantdinger_vue/src/permission.js new file mode 100644 index 0000000..d4c4c2b --- /dev/null +++ b/quantdinger_vue/src/permission.js @@ -0,0 +1,113 @@ +import router, { + resetRouter +} from './router' +import store from './store' +import storage from 'store' +import NProgress from 'nprogress' // progress bar +import '@/components/NProgress/nprogress.less' // progress bar custom style +import { + setDocumentTitle, + domTitle +} from '@/utils/domUtil' +import { + ACCESS_TOKEN +} from '@/store/mutation-types' +import { + i18nRender +} from '@/locales' + +NProgress.configure({ + showSpinner: false +}) // NProgress Configuration + +const allowList = ['login'] // no redirect allowList +const loginRoutePath = '/user/login' +const defaultRoutePath = '/dashboard' + +router.beforeEach((to, from, next) => { + NProgress.start() // start progress bar + to.meta && typeof to.meta.title !== 'undefined' && setDocumentTitle(`${i18nRender(to.meta.title)} - ${domTitle}`) + + // Check whether we have a token (local-only auth). + const token = storage.get(ACCESS_TOKEN) + + if (token) { + // 有 token,允许访问所有页面 + // 如果访问登录页,跳转到默认页面 + if (to.path === loginRoutePath) { + next({ path: defaultRoutePath }) + NProgress.done() + } else { + // 检查用户信息是否已加载 + if (store.getters.roles.length === 0) { + store.dispatch('GetInfo') + .then(res => { + // 拉取用户信息成功 + // const roles = res && res.role + // 生成路由 + store.dispatch('GenerateRoutes', { token }).then(() => { + // 动态添加可访问路由表 + resetRouter() // 重置路由 + store.getters.addRouters.forEach(r => { + router.addRoute(r) + }) + // 请求带有 redirect 重定向时,登录自动重定向到该地址 + const redirect = decodeURIComponent(from.query.redirect || to.path) + if (to.path === redirect) { + // hack方法 确保addRoutes已完成 ,set the replace: true so the navigation will not leave a history record + next({ ...to, replace: true }) + } else { + // 跳转到目的路由 + next({ path: redirect }) + } + }) + }) + .catch(() => { + // Do NOT hard-logout on transient failures (backend down, proxy issue, etc). + // Instead, degrade gracefully with a default role and continue. + store.commit('SET_ROLES', [{ id: 'default', permissionList: [] }]) + store.dispatch('GenerateRoutes', { token }).then(() => { + resetRouter() + store.getters.addRouters.forEach(r => router.addRoute(r)) + next({ ...to, replace: true }) + }).catch(() => { + next() + }) + }) + } else { + // 检查路由是否已初始化 + const addRouters = store.getters.addRouters + // 如果路由未初始化,先初始化路由 + if (!addRouters || addRouters.length === 0) { + store.dispatch('GenerateRoutes', { token }).then(() => { + // 动态添加可访问路由表 + resetRouter() // 重置路由 防止退出重新登录或者 token 过期后页面未刷新,导致的路由重复添加 + store.getters.addRouters.forEach(r => { + router.addRoute(r) + }) + // 重新进入当前路由,避免首次刷新空白 + next({ ...to, replace: true }) + }).catch(() => { + next() + }) + } else { + next() + } + } + } + } else { + // 没有 token + if (allowList.includes(to.name)) { + // 在免登录名单,直接进入 + next() + } else { + // 跳转到登录页 + next({ path: loginRoutePath, query: { redirect: to.fullPath } }) + NProgress.done() // if current page is login will not trigger afterEach hook, so manually handle it + } + } +}) + +router.afterEach(() => { + NProgress.done() // finish progress bar +}) diff --git a/quantdinger_vue/src/router/README.md b/quantdinger_vue/src/router/README.md new file mode 100644 index 0000000..b736444 --- /dev/null +++ b/quantdinger_vue/src/router/README.md @@ -0,0 +1,134 @@ +路由/菜单说明 +==== + + +格式和说明 +---- + +```ecmascript 6 +const routerObject = { + redirect: noredirect, + name: 'router-name', + hidden: true, + meta: { + title: 'title', + icon: 'a-icon', + target: '_blank|_self|_top|_parent', + keepAlive: true, + hiddenHeaderContent: true, + } +} +``` + + + +`{ Route }` 对象 + +| 参数 | 说明 | 类型 | 默认值 | +| -------- | ----------------------------------------- | ------- | ------ | +| hidden | 控制路由是否显示在 sidebar | boolean | false | +| redirect | 重定向地址, 访问这个路由时,自定进行重定向 | string | - | +| name | 路由名称, 必须设置,且不能重名 | string | - | +| meta | 路由元信息(路由附带扩展信息) | object | {} | +| hideChildrenInMenu | 强制菜单显示为Item而不是SubItem(配合 meta.hidden) | boolean | - | + + +`{ Meta }` 路由元信息对象 + +| 参数 | 说明 | 类型 | 默认值 | +| ------------------- | ------------------------------------------------------------ | ------- | ------ | +| title | 路由标题, 用于显示面包屑, 页面标题 *推荐设置 | string | - | +| icon | 路由在 menu 上显示的图标 | [string,svg] | - | +| keepAlive | 缓存该路由 | boolean | false | +| target | 菜单链接跳转目标(参考 html a 标记) | string | - | +| hidden | 配合`hideChildrenInMenu`使用,用于隐藏菜单时,提供递归到父菜单显示 选中菜单项_(可参考 个人页 配置方式)_ | boolean | false | +| hiddenHeaderContent | *特殊 隐藏 [PageHeader](https://github.com/vueComponent/ant-design-vue-pro/blob/master/src/components/PageHeader/PageHeader.vue#L6) 组件中的页面带的 面包屑和页面标题栏 | boolean | false | +| permission | 与项目提供的权限拦截匹配的权限,如果不匹配,则会被禁止访问该路由页面 | array | [] | + +> 路由自定义 `Icon` 请引入自定义 `svg` Icon 文件,然后传递给路由的 `meta.icon` 参数即可 + +路由构建例子方案1 + +路由例子 +---- + +```ecmascript 6 +const asyncRouterMap = [ + { + path: '/', + name: 'index', + component: BasicLayout, + meta: { title: '首页' }, + redirect: '/ai-analysis', + children: [ + { + path: '/dashboard', + component: RouteView, + name: 'dashboard', + redirect: '/dashboard/workplace', + meta: {title: '仪表盘', icon: 'dashboard', permission: ['dashboard']}, + children: [ + { + path: '/ai-analysis', + name: 'Analysis', + component: () => import('@/views/dashboard/Analysis'), + meta: {title: '分析页', permission: ['dashboard']} + }, + { + path: '/dashboard/monitor', + name: 'Monitor', + hidden: true, + component: () => import('@/views/dashboard/Monitor'), + meta: {title: '监控页', permission: ['dashboard']} + }, + { + path: '/dashboard/workplace', + name: 'Workplace', + component: () => import('@/views/dashboard/Workplace'), + meta: {title: '工作台', permission: ['dashboard']} + } + ] + }, + + // result + { + path: '/result', + name: 'result', + component: PageView, + redirect: '/result/success', + meta: { title: '结果页', icon: 'check-circle-o', permission: [ 'result' ] }, + children: [ + { + path: '/result/success', + name: 'ResultSuccess', + component: () => import(/* webpackChunkName: "result" */ '@/views/result/Success'), + // 该页面隐藏面包屑和页面标题栏 + meta: { title: '成功', hiddenHeaderContent: true, permission: [ 'result' ] } + }, + { + path: '/result/fail', + name: 'ResultFail', + component: () => import(/* webpackChunkName: "result" */ '@/views/result/Error'), + // 该页面隐藏面包屑和页面标题栏 + meta: { title: '失败', hiddenHeaderContent: true, permission: [ 'result' ] } + } + ] + }, + ... + ] + }, +] +``` + +> 1. 请注意 `component: () => import('..') ` 方式引入路由的页面组件为 懒加载模式。具体可以看 [Vue 官方文档](https://router.vuejs.org/zh/guide/advanced/lazy-loading.html) +> 2. 增加新的路由应该增加在 '/' (index) 路由的 `children` 内 +> 3. 子路由的父级路由必须有 `router-view` 才能让子路由渲染出来,请仔细查阅 vue-router 文档 +> 4. `permission` 可以进行自定义修改,只需要对这个模块进行自定义修改即可 [src/store/modules/permission.js#L10](https://github.com/vueComponent/ant-design-vue-pro/blob/master/src/store/modules/permission.js#L10) + + +附权限路由结构: + +![权限结构](https://static-2.loacg.com/open/static/github/permissions.png) + + +第二种前端路由由后端动态生成的设计,可以前往官网文档 https://pro.antdv.com/docs/authority-management 参考 diff --git a/quantdinger_vue/src/router/generator-routers.js b/quantdinger_vue/src/router/generator-routers.js new file mode 100644 index 0000000..9e7cc05 --- /dev/null +++ b/quantdinger_vue/src/router/generator-routers.js @@ -0,0 +1,9 @@ +import { asyncRouterMap } from '@/config/router.config' + +/** + * Local-only mode: generate routes from frontend static config. + * This removes dependency on legacy PHP `/user/nav`. + */ +export const generatorDynamicRouter = token => { + return Promise.resolve(asyncRouterMap) +} diff --git a/quantdinger_vue/src/router/index.js b/quantdinger_vue/src/router/index.js new file mode 100644 index 0000000..5acaeff --- /dev/null +++ b/quantdinger_vue/src/router/index.js @@ -0,0 +1,28 @@ +import Vue from 'vue' +import Router from 'vue-router' +import { constantRouterMap } from '@/config/router.config' + +// hack router push callback +const originalPush = Router.prototype.push +Router.prototype.push = function push (location, onResolve, onReject) { + if (onResolve || onReject) return originalPush.call(this, location, onResolve, onReject) + return originalPush.call(this, location).catch(err => err) +} + +Vue.use(Router) + +const createRouter = () => + new Router({ + mode: 'hash', + routes: constantRouterMap + }) + +const router = createRouter() + +// 定义一个resetRouter 方法,在退出登录后或token过期后 需要重新登录时,调用即可 +export function resetRouter () { + const newRouter = createRouter() + router.matcher = newRouter.matcher +} + +export default router diff --git a/quantdinger_vue/src/store/app-mixin.js b/quantdinger_vue/src/store/app-mixin.js new file mode 100644 index 0000000..c910ae9 --- /dev/null +++ b/quantdinger_vue/src/store/app-mixin.js @@ -0,0 +1,32 @@ +import { mapState } from 'vuex' + +const baseMixin = { + computed: { + ...mapState({ + layout: state => state.app.layout, + navTheme: state => state.app.theme, + primaryColor: state => state.app.color, + colorWeak: state => state.app.weak, + fixedHeader: state => state.app.fixedHeader, + fixedSidebar: state => state.app.fixedSidebar, + contentWidth: state => state.app.contentWidth, + autoHideHeader: state => state.app.autoHideHeader, + + isMobile: state => state.app.isMobile, + sideCollapsed: state => state.app.sideCollapsed, + multiTab: state => state.app.multiTab + }), + isTopMenu () { + return this.layout === 'topmenu' + } + }, + methods: { + isSideMenu () { + return !this.isTopMenu + } + } +} + +export { + baseMixin +} diff --git a/quantdinger_vue/src/store/device-mixin.js b/quantdinger_vue/src/store/device-mixin.js new file mode 100644 index 0000000..2510707 --- /dev/null +++ b/quantdinger_vue/src/store/device-mixin.js @@ -0,0 +1,11 @@ +import { mapState } from 'vuex' + +const deviceMixin = { + computed: { + ...mapState({ + isMobile: state => state.app.isMobile + }) + } +} + +export { deviceMixin } diff --git a/quantdinger_vue/src/store/getters.js b/quantdinger_vue/src/store/getters.js new file mode 100644 index 0000000..5a5ad70 --- /dev/null +++ b/quantdinger_vue/src/store/getters.js @@ -0,0 +1,16 @@ +const getters = { + isMobile: state => state.app.isMobile, + lang: state => state.app.lang, + theme: state => state.app.theme, + color: state => state.app.color, + token: state => state.user.token, + avatar: state => state.user.avatar, + nickname: state => state.user.name, + welcome: state => state.user.welcome, + roles: state => state.user.roles, + userInfo: state => state.user.info, + addRouters: state => state.permission.addRouters, + multiTab: state => state.app.multiTab +} + +export default getters diff --git a/quantdinger_vue/src/store/i18n-mixin.js b/quantdinger_vue/src/store/i18n-mixin.js new file mode 100644 index 0000000..715b0c8 --- /dev/null +++ b/quantdinger_vue/src/store/i18n-mixin.js @@ -0,0 +1,16 @@ +import { mapState } from 'vuex' + +const i18nMixin = { + computed: { + ...mapState({ + currentLang: state => state.app.lang + }) + }, + methods: { + setLang (lang) { + this.$store.dispatch('setLang', lang) + } + } +} + +export default i18nMixin diff --git a/quantdinger_vue/src/store/index.js b/quantdinger_vue/src/store/index.js new file mode 100644 index 0000000..4dddc1a --- /dev/null +++ b/quantdinger_vue/src/store/index.js @@ -0,0 +1,29 @@ +import Vue from 'vue' +import Vuex from 'vuex' + +import app from './modules/app' +import user from './modules/user' + +// default router permission control +// 默认路由模式为静态路由 (router.config.js) +import permission from './modules/static-router' + +// dynamic router permission control (Experimental) +// 动态路由模式(api请求后端生成) +// import permission from './modules/async-router' + +import getters from './getters' + +Vue.use(Vuex) + +export default new Vuex.Store({ + modules: { + app, + user, + permission + }, + state: {}, + mutations: {}, + actions: {}, + getters +}) diff --git a/quantdinger_vue/src/store/modules/app.js b/quantdinger_vue/src/store/modules/app.js new file mode 100644 index 0000000..68f45a2 --- /dev/null +++ b/quantdinger_vue/src/store/modules/app.js @@ -0,0 +1,99 @@ +import storage from 'store' +import { + SIDEBAR_TYPE, + TOGGLE_MOBILE_TYPE, + TOGGLE_NAV_THEME, + TOGGLE_LAYOUT, + TOGGLE_FIXED_HEADER, + TOGGLE_FIXED_SIDEBAR, + TOGGLE_CONTENT_WIDTH, + TOGGLE_HIDE_HEADER, + TOGGLE_COLOR, + TOGGLE_WEAK, + TOGGLE_MULTI_TAB, + // i18n + APP_LANGUAGE +} from '@/store/mutation-types' +import { loadLanguageAsync } from '@/locales' + +const app = { + state: { + sideCollapsed: false, + isMobile: false, + theme: storage.get(TOGGLE_NAV_THEME, 'light'), // 从 localStorage 读取主题,默认 'light' + layout: '', + contentWidth: '', + fixedHeader: false, + fixedSidebar: false, + autoHideHeader: false, + color: storage.get(TOGGLE_COLOR, '#13C2C2'), // 从 localStorage 读取主题色,默认 '#13C2C2' + weak: false, + multiTab: true, + lang: 'en-US', + _antLocale: {} + }, + mutations: { + [SIDEBAR_TYPE]: (state, type) => { + state.sideCollapsed = type + storage.set(SIDEBAR_TYPE, type) + }, + [TOGGLE_MOBILE_TYPE]: (state, isMobile) => { + state.isMobile = isMobile + }, + [TOGGLE_NAV_THEME]: (state, theme) => { + state.theme = theme + storage.set(TOGGLE_NAV_THEME, theme) + }, + [TOGGLE_LAYOUT]: (state, mode) => { + state.layout = mode + storage.set(TOGGLE_LAYOUT, mode) + }, + [TOGGLE_FIXED_HEADER]: (state, mode) => { + state.fixedHeader = mode + storage.set(TOGGLE_FIXED_HEADER, mode) + }, + [TOGGLE_FIXED_SIDEBAR]: (state, mode) => { + state.fixedSidebar = mode + storage.set(TOGGLE_FIXED_SIDEBAR, mode) + }, + [TOGGLE_CONTENT_WIDTH]: (state, type) => { + state.contentWidth = type + storage.set(TOGGLE_CONTENT_WIDTH, type) + }, + [TOGGLE_HIDE_HEADER]: (state, type) => { + state.autoHideHeader = type + storage.set(TOGGLE_HIDE_HEADER, type) + }, + [TOGGLE_COLOR]: (state, color) => { + state.color = color + storage.set(TOGGLE_COLOR, color) + }, + [TOGGLE_WEAK]: (state, mode) => { + state.weak = mode + storage.set(TOGGLE_WEAK, mode) + }, + [APP_LANGUAGE]: (state, lang, antd = {}) => { + state.lang = lang + state._antLocale = antd + storage.set(APP_LANGUAGE, lang) + }, + [TOGGLE_MULTI_TAB]: (state, bool) => { + storage.set(TOGGLE_MULTI_TAB, bool) + state.multiTab = bool + } + }, + actions: { + setLang ({ commit }, lang) { + return new Promise((resolve, reject) => { + commit(APP_LANGUAGE, lang) + loadLanguageAsync(lang).then(() => { + resolve() + }).catch((e) => { + reject(e) + }) + }) + } + } +} + +export default app diff --git a/quantdinger_vue/src/store/modules/async-router.js b/quantdinger_vue/src/store/modules/async-router.js new file mode 100644 index 0000000..bd9e38f --- /dev/null +++ b/quantdinger_vue/src/store/modules/async-router.js @@ -0,0 +1,33 @@ +/** + * 向后端请求用户的菜单,动态生成路由 + */ +import { constantRouterMap } from '@/config/router.config' +import { generatorDynamicRouter } from '@/router/generator-routers' + +const permission = { + state: { + routers: constantRouterMap, + addRouters: [] + }, + mutations: { + SET_ROUTERS: (state, routers) => { + state.addRouters = routers + state.routers = constantRouterMap.concat(routers) + } + }, + actions: { + GenerateRoutes ({ commit }, data) { + return new Promise((resolve, reject) => { + const { token } = data + generatorDynamicRouter(token).then(routers => { + commit('SET_ROUTERS', routers) + resolve() + }).catch(e => { + reject(e) + }) + }) + } + } +} + +export default permission diff --git a/quantdinger_vue/src/store/modules/static-router.js b/quantdinger_vue/src/store/modules/static-router.js new file mode 100644 index 0000000..874e4fc --- /dev/null +++ b/quantdinger_vue/src/store/modules/static-router.js @@ -0,0 +1,54 @@ +import { asyncRouterMap, constantRouterMap } from '@/config/router.config' +import cloneDeep from 'lodash.clonedeep' + +/** + * 单账户多角色时,使用该方法可过滤角色不存在的菜单 + * + * @param roles + * @param route + * @returns {*} + */ +// eslint-disable-next-line +function hasRole(roles, route) { + if (route.meta && route.meta.roles) { + return route.meta.roles.includes(roles.id) + } else { + return true + } +} + +function filterAsyncRouter (routerMap, role) { + // 不进行权限过滤,直接返回所有路由(后端会验证token) + return routerMap.map(route => { + if (route.children && route.children.length) { + route.children = filterAsyncRouter(route.children, role) + } + return route + }) +} + +const permission = { + state: { + routers: constantRouterMap, + addRouters: [] + }, + mutations: { + SET_ROUTERS: (state, routers) => { + state.addRouters = routers + state.routers = constantRouterMap.concat(routers) + } + }, + actions: { + GenerateRoutes ({ commit }, data) { + return new Promise(resolve => { + const routerMap = cloneDeep(asyncRouterMap) + // 不进行权限过滤,直接返回所有路由(后端会验证token) + const accessedRouters = filterAsyncRouter(routerMap, null) + commit('SET_ROUTERS', accessedRouters) + resolve() + }) + } + } +} + +export default permission diff --git a/quantdinger_vue/src/store/modules/user.js b/quantdinger_vue/src/store/modules/user.js new file mode 100644 index 0000000..4d0ae41 --- /dev/null +++ b/quantdinger_vue/src/store/modules/user.js @@ -0,0 +1,410 @@ +import storage from 'store' +import expirePlugin from 'store/plugins/expire' +import { login, emailLogin, mobileLogin, logout, getUserInfo, apiLogout, updateUserInfo, oauthCallback } from '@/api/login' +import { ACCESS_TOKEN, USER_INFO, USER_ROLES } from '@/store/mutation-types' +import { welcome } from '@/utils/util' + +storage.addPlugin(expirePlugin) + +const DEFAULT_ROLE = { id: 'default', permissionList: [] } + +function normalizeRoles (roles) { + if (!roles) return [] + if (Array.isArray(roles)) return roles + return [roles] +} + +function getStoredInfo () { + const info = storage.get(USER_INFO) || {} + return (info && typeof info === 'object') ? info : {} +} + +function getStoredRoles () { + const roles = storage.get(USER_ROLES) || [] + return normalizeRoles(roles) +} + +function getStoredToken () { + const token = storage.get(ACCESS_TOKEN) + return typeof token === 'string' ? token : (token && token.token) ? token.token : token +} + +const initialInfo = getStoredInfo() +const initialRoles = getStoredRoles() +const initialToken = getStoredToken() || '' +const initialName = initialInfo.nickname || initialInfo.username || '' +const initialAvatar = initialInfo.avatar || '' +const initialWelcome = initialName ? welcome() : '' +const user = { + state: { + token: initialToken, + name: initialName, + welcome: initialWelcome, + avatar: initialAvatar, + roles: initialRoles, + info: initialInfo + }, + + mutations: { + SET_TOKEN: (state, token) => { + state.token = token + }, + SET_NAME: (state, { name, welcome }) => { + state.name = name + state.welcome = welcome + }, + SET_AVATAR: (state, avatar) => { + state.avatar = avatar + }, + SET_ROLES: (state, roles) => { + state.roles = roles + }, + SET_INFO: (state, info) => { + state.info = info + } + }, + + actions: { + // 登录 + Login ({ commit }, userInfo) { + return new Promise((resolve, reject) => { + login(userInfo).then(response => { + // 适配 Python 后端响应格式 + if (response && response.code === 1 && response.data) { + const result = response.data + const token = result.token + const info = result.userinfo || {} + + const expiresAt = new Date().getTime() + 7 * 24 * 60 * 60 * 1000 + storage.set(ACCESS_TOKEN, token, expiresAt) + commit('SET_TOKEN', token) + commit('SET_INFO', info) + storage.set(USER_INFO, info, expiresAt) + + // 设置基本信息 + const name = info.nickname || info.username || 'User' + commit('SET_NAME', { name: name, welcome: welcome() }) + commit('SET_AVATAR', info.avatar || '/avatar2.jpg') + + // 设置默认角色,防止路由鉴权失败 + const roles = [{ id: 'admin', permissionList: ['dashboard', 'exception', 'account'] }] + commit('SET_ROLES', roles) + storage.set(USER_ROLES, roles, expiresAt) + + resolve(response) + } else { + reject(new Error((response && response.msg) || 'Login failed')) + } + }).catch(error => { + reject(error) + }) + }) + }, + + // 邮箱验证码登录 + EmailLogin ({ commit }, userInfo) { + return new Promise((resolve, reject) => { + emailLogin(userInfo).then(response => { + // 新API响应格式: { code: 1, msg: "登录成功", data: { token, userInfo } } + if (response.code === 1 && response.data) { + const token = response.data.token + storage.set(ACCESS_TOKEN, token, new Date().getTime() + 7 * 24 * 60 * 60 * 1000) + commit('SET_TOKEN', token) + // 保存用户信息(从登录接口返回的 userInfo) + if (response.data.userInfo) { + const userInfoData = response.data.userInfo + commit('SET_INFO', userInfoData) + + // 设置用户名 + if (userInfoData.nickname) { + commit('SET_NAME', { name: userInfoData.nickname, welcome: welcome() }) + } else if (userInfoData.username) { + commit('SET_NAME', { name: userInfoData.username, welcome: welcome() }) + } + + // 设置头像 + if (userInfoData.avatar) { + commit('SET_AVATAR', userInfoData.avatar) + } + + // 设置角色(如果有) + if (userInfoData.role) { + commit('SET_ROLES', userInfoData.role) + } else if (userInfoData.roles) { + commit('SET_ROLES', userInfoData.roles) + } else { + // 如果没有角色信息,设置一个默认角色对象,避免路由守卫卡住 + // 设置一个标记,表示已经初始化过用户信息 + commit('SET_ROLES', [{ id: 'default', permissionList: [] }]) + } + } + resolve(response) + } else { + reject(new Error(response.msg || '登录失败')) + } + }).catch(error => { + reject(error) + }) + }) + }, + + // 手机号验证码登录 + MobileLogin ({ commit }, userInfo) { + return new Promise((resolve, reject) => { + mobileLogin(userInfo).then(response => { + // 新API响应格式: { code: 1, msg: "登录成功", data: { token, userInfo } } + if (response.code === 1 && response.data) { + const token = response.data.token + storage.set(ACCESS_TOKEN, token, new Date().getTime() + 7 * 24 * 60 * 60 * 1000) + commit('SET_TOKEN', token) + // 保存用户信息(从登录接口返回的 userInfo) + if (response.data.userInfo) { + const userInfoData = response.data.userInfo + commit('SET_INFO', userInfoData) + + // 设置用户名 + if (userInfoData.nickname) { + commit('SET_NAME', { name: userInfoData.nickname, welcome: welcome() }) + } else if (userInfoData.username) { + commit('SET_NAME', { name: userInfoData.username, welcome: welcome() }) + } + + // 设置头像 + if (userInfoData.avatar) { + commit('SET_AVATAR', userInfoData.avatar) + } + + // 设置角色(如果有) + if (userInfoData.role) { + commit('SET_ROLES', userInfoData.role) + } else if (userInfoData.roles) { + commit('SET_ROLES', userInfoData.roles) + } else { + // 如果没有角色信息,设置一个默认角色对象,避免路由守卫卡住 + // 设置一个标记,表示已经初始化过用户信息 + commit('SET_ROLES', [{ id: 'default', permissionList: [] }]) + } + } + resolve(response) + } else { + reject(new Error(response.msg || '登录失败')) + } + }).catch(error => { + reject(error) + }) + }) + }, + + // Web3 登录完成后的统一处理 + Web3LoginFinalize ({ commit }, payload) { + return new Promise((resolve, reject) => { + try { + const { token, userInfo } = payload + if (!token || !userInfo) { + reject(new Error('登录数据异常')) + return + } + storage.set(ACCESS_TOKEN, token, new Date().getTime() + 7 * 24 * 60 * 60 * 1000) + commit('SET_TOKEN', token) + commit('SET_INFO', userInfo) + + if (userInfo.nickname) { + commit('SET_NAME', { name: userInfo.nickname, welcome: welcome() }) + } else if (userInfo.username) { + commit('SET_NAME', { name: userInfo.username, welcome: welcome() }) + } + + if (userInfo.avatar) { + commit('SET_AVATAR', userInfo.avatar) + } + + if (userInfo.role) { + commit('SET_ROLES', userInfo.role) + } else if (userInfo.roles) { + commit('SET_ROLES', userInfo.roles) + } else { + commit('SET_ROLES', [{ id: 'default', permissionList: [] }]) + } + + resolve() + } catch (e) { + reject(e) + } + }) + }, + + // 刷新用户信息 + FetchUserInfo ({ commit }) { + return new Promise((resolve, reject) => { + getUserInfo().then(res => { + if (res && res.code === 1 && res.data) { + const info = res.data + commit('SET_INFO', info) + if (info.nickname) { + commit('SET_NAME', { name: info.nickname, welcome: welcome() }) + } else if (info.username) { + commit('SET_NAME', { name: info.username, welcome: welcome() }) + } + if (info.avatar) { + commit('SET_AVATAR', info.avatar) + } + resolve(info) + } else { + reject(new Error((res && res.msg) || '获取用户信息失败')) + } + }).catch(err => reject(err)) + }) + }, + + // 更新用户基本信息 + UpdateUserInfo ({ commit, state }, payload) { + return new Promise((resolve, reject) => { + updateUserInfo(payload).then(res => { + if (res && res.code === 1) { + // 合并本地 store 的 info + const newInfo = { ...state.info, ...payload } + commit('SET_INFO', newInfo) + if (newInfo.nickname) { + commit('SET_NAME', { name: newInfo.nickname, welcome: welcome() }) + } + resolve(res) + } else { + reject(new Error((res && res.msg) || '修改失败')) + } + }).catch(err => reject(err)) + }) + }, + + // 获取用户信息(从 store 中获取,不再请求接口) + GetInfo ({ commit, state }) { + return new Promise((resolve, reject) => { + // 用户信息已经在登录时保存到 store 中,直接返回 + if (state.info && Object.keys(state.info).length > 0) { + // 补全 Roles + const info = state.info + if (info.role) { + const roles = normalizeRoles(info.role) + commit('SET_ROLES', roles) + storage.set(USER_ROLES, roles, new Date().getTime() + 7 * 24 * 60 * 60 * 1000) + } else if (info.roles) { + const roles = normalizeRoles(info.roles) + commit('SET_ROLES', roles) + storage.set(USER_ROLES, roles, new Date().getTime() + 7 * 24 * 60 * 60 * 1000) + } else { + commit('SET_ROLES', [DEFAULT_ROLE]) + storage.set(USER_ROLES, [DEFAULT_ROLE], new Date().getTime() + 7 * 24 * 60 * 60 * 1000) + } + resolve(state.info) + } else { + // 尝试主动拉取一次 + getUserInfo().then(res => { + if (res && res.code === 1 && res.data) { + const info = res.data + commit('SET_INFO', info) + storage.set(USER_INFO, info, new Date().getTime() + 7 * 24 * 60 * 60 * 1000) + if (info.nickname) { + commit('SET_NAME', { name: info.nickname, welcome: welcome() }) + } else if (info.username) { + commit('SET_NAME', { name: info.username, welcome: welcome() }) + } + if (info.avatar) { + commit('SET_AVATAR', info.avatar) + } + // 关键修复:设置角色,防止路由守卫死循环 + if (info.role) { + const roles = normalizeRoles(info.role) + commit('SET_ROLES', roles) + storage.set(USER_ROLES, roles, new Date().getTime() + 7 * 24 * 60 * 60 * 1000) + } else if (info.roles) { + const roles = normalizeRoles(info.roles) + commit('SET_ROLES', roles) + storage.set(USER_ROLES, roles, new Date().getTime() + 7 * 24 * 60 * 60 * 1000) + } else { + commit('SET_ROLES', [DEFAULT_ROLE]) + storage.set(USER_ROLES, [DEFAULT_ROLE], new Date().getTime() + 7 * 24 * 60 * 60 * 1000) + } + resolve(info) + } else { + reject(new Error((res && res.msg) || '用户信息不存在')) + } + }).catch(err => reject(err)) + } + }) + }, + + // OAuth 登录(Google/GitHub) + OAuthLogin ({ commit }, userInfo) { + return new Promise((resolve, reject) => { + oauthCallback(userInfo).then(response => { + // 新API响应格式: { code: 1, msg: "登录成功", data: { token, userInfo } } + if (response.code === 1 && response.data) { + const token = response.data.token + storage.set(ACCESS_TOKEN, token, new Date().getTime() + 7 * 24 * 60 * 60 * 1000) + commit('SET_TOKEN', token) + // 保存用户信息(从登录接口返回的 userInfo) + if (response.data.userInfo) { + const userInfoData = response.data.userInfo + commit('SET_INFO', userInfoData) + + // 设置用户名 + if (userInfoData.nickname) { + commit('SET_NAME', { name: userInfoData.nickname, welcome: welcome() }) + } else if (userInfoData.username) { + commit('SET_NAME', { name: userInfoData.username, welcome: welcome() }) + } + + // 设置头像 + if (userInfoData.avatar) { + commit('SET_AVATAR', userInfoData.avatar) + } + + // 设置角色(如果有) + if (userInfoData.role) { + commit('SET_ROLES', userInfoData.role) + } else if (userInfoData.roles) { + commit('SET_ROLES', userInfoData.roles) + } else { + // 如果没有角色信息,设置一个默认角色对象,避免路由守卫卡住 + commit('SET_ROLES', [{ id: 'default', permissionList: [] }]) + } + } + resolve(response) + } else { + reject(new Error(response.msg || '登录失败')) + } + }).catch(error => { + reject(error) + }) + }) + }, + + // 登出 + Logout ({ commit, state }) { + return new Promise((resolve) => { + // 兼容旧登出与新后端登出 + const req = typeof apiLogout === 'function' ? apiLogout() : logout(state.token) + req.then(() => { + commit('SET_TOKEN', '') + commit('SET_ROLES', []) + commit('SET_INFO', {}) + commit('SET_NAME', { name: '', welcome: '' }) + commit('SET_AVATAR', '') + storage.remove(ACCESS_TOKEN) + storage.remove(USER_INFO) + storage.remove(USER_ROLES) + resolve() + }).catch(() => { + // 登出失败时也继续执行,确保清理本地状态 + storage.remove(ACCESS_TOKEN) + storage.remove(USER_INFO) + storage.remove(USER_ROLES) + resolve() + }).finally(() => { + }) + }) + } + + } +} + +export default user diff --git a/quantdinger_vue/src/store/mutation-types.js b/quantdinger_vue/src/store/mutation-types.js new file mode 100644 index 0000000..2ddfdfc --- /dev/null +++ b/quantdinger_vue/src/store/mutation-types.js @@ -0,0 +1,26 @@ +export const ACCESS_TOKEN = 'Access-Token' +export const USER_INFO = 'User-Info' +export const USER_ROLES = 'User-Roles' + +export const SIDEBAR_TYPE = 'sidebar_type' +export const TOGGLE_MOBILE_TYPE = 'is_mobile' +export const TOGGLE_NAV_THEME = 'nav_theme' +export const TOGGLE_LAYOUT = 'layout' +export const TOGGLE_FIXED_HEADER = 'fixed_header' +export const TOGGLE_FIXED_SIDEBAR = 'fixed_sidebar' +export const TOGGLE_CONTENT_WIDTH = 'content_width' +export const TOGGLE_HIDE_HEADER = 'auto_hide_header' +export const TOGGLE_COLOR = 'color' +export const TOGGLE_WEAK = 'weak' +export const TOGGLE_MULTI_TAB = 'multi_tab' +export const APP_LANGUAGE = 'app_language' + +export const CONTENT_WIDTH_TYPE = { + Fluid: 'Fluid', + Fixed: 'Fixed' +} + +export const NAV_THEME = { + LIGHT: 'light', + DARK: 'dark' +} diff --git a/quantdinger_vue/src/utils/axios.js b/quantdinger_vue/src/utils/axios.js new file mode 100644 index 0000000..ecccd85 --- /dev/null +++ b/quantdinger_vue/src/utils/axios.js @@ -0,0 +1,34 @@ +const VueAxios = { + vm: {}, + // eslint-disable-next-line no-unused-vars + install (Vue, instance) { + if (this.installed) { + return + } + this.installed = true + + if (!instance) { + // eslint-disable-next-line no-console + return + } + + Vue.axios = instance + + Object.defineProperties(Vue.prototype, { + axios: { + get: function get () { + return instance + } + }, + $http: { + get: function get () { + return instance + } + } + }) + } +} + +export { + VueAxios +} diff --git a/quantdinger_vue/src/utils/codeDecrypt.js b/quantdinger_vue/src/utils/codeDecrypt.js new file mode 100644 index 0000000..2faaab8 --- /dev/null +++ b/quantdinger_vue/src/utils/codeDecrypt.js @@ -0,0 +1,138 @@ +/** + * 指标代码解密工具 + * 用于解密用户购买的加密指标代码 + */ + +import CryptoJS from 'crypto-js' +import request from '@/utils/request' + +/** + * 解密指标代码 + * + * @param {string} encryptedCode - base64编码的加密代码 + * @param {number} userId - 用户ID + * @param {number} indicatorId - 指标ID + * @param {string} serverSecret - 服务器密钥(需要从后端获取或配置) + * @returns {string} - 解密后的代码 + */ +export function decryptCode (encryptedCode, userId, indicatorId, encryptedKey) { + if (!encryptedCode || !userId || !indicatorId || !encryptedKey) { + return encryptedCode + } + + try { + // 解码base64加密代码 + const combined = CryptoJS.enc.Base64.parse(encryptedCode) + + // 提取IV(前16字节)和加密数据 + const ivWords = CryptoJS.lib.WordArray.create(combined.words.slice(0, 4)) // 前16字节(4个word) + const encryptedWords = CryptoJS.lib.WordArray.create(combined.words.slice(4)) // 剩余部分 + + // 解密密钥(从后端获取的base64编码密钥) + // encryptedKey 是从后端获取的 base64 编码的密钥,直接解码使用 + const key = CryptoJS.enc.Base64.parse(encryptedKey) + + // 解密 + const decrypted = CryptoJS.AES.decrypt( + { ciphertext: encryptedWords }, + key, + { + iv: ivWords, + mode: CryptoJS.mode.CBC, + padding: CryptoJS.pad.Pkcs7 + } + ) + + // 转换为字符串 + const decryptedText = decrypted.toString(CryptoJS.enc.Utf8) + + if (!decryptedText) { + return encryptedCode + } + + return decryptedText + } catch (error) { + // 解密失败,返回原代码(向后兼容) + return encryptedCode + } +} + +/** + * 从后端获取解密密钥(动态密钥) + * + * @param {number} userId - 用户ID + * @param {number} indicatorId - 指标ID + * @returns {Promise} - 解密密钥(base64编码) + */ +export async function getDecryptKey (userId, indicatorId) { + if (!userId || !indicatorId) { + throw new Error('用户ID和指标ID不能为空') + } + + try { + // 动态请求方式:从后端API获取 + const response = await request({ + url: '/api/indicator/getDecryptKey', + method: 'post', + data: { + userid: userId, + indicatorId: indicatorId + } + }) + + if (response.code === 1 && response.data && response.data.key) { + // 返回base64编码的密钥 + return response.data.key + } else { + throw new Error(response.msg || '获取解密密钥失败') + } + } catch (error) { + // 如果后端接口失败,抛出错误,不使用备用密钥(更安全) + throw new Error('无法获取解密密钥,请检查网络连接或联系管理员: ' + (error.message || '未知错误')) + } +} + +/** + * 智能解密代码(自动获取密钥) + * + * @param {string} encryptedCode - 加密的代码 + * @param {number} userId - 用户ID + * @param {number} indicatorId - 指标ID + * @returns {Promise} - 解密后的代码 + */ +export async function decryptCodeAuto (encryptedCode, userId, indicatorId) { + // 从后端动态获取解密密钥(base64编码) + const encryptedKey = await getDecryptKey(userId, indicatorId) + // 使用获取的密钥解密 + return decryptCode(encryptedCode, userId, indicatorId, encryptedKey) +} + +/** + * 检查代码是否需要解密 + * + * @param {string} code - 代码 + * @param {number} isEncrypted - 是否加密标记 + * @returns {boolean} + */ +export function needsDecrypt (code, isEncrypted) { + // 如果明确标记为加密,或者代码长度很长且符合base64格式,可能需要解密 + if (isEncrypted === 1 || isEncrypted === true) { + return true + } + + // 简单检查:加密代码通常较长(base64编码会增大约33%) + if (code && code.length > 100) { + // 尝试base64解码检查 + try { + const decoded = atob(code) + // 如果解码后的长度合理,可能是加密的 + if (decoded.length > 50) { + return true + } + } catch (e) { + // 不是base64,不需要解密 + } + } + + return false +} diff --git a/quantdinger_vue/src/utils/domUtil.js b/quantdinger_vue/src/utils/domUtil.js new file mode 100644 index 0000000..be93027 --- /dev/null +++ b/quantdinger_vue/src/utils/domUtil.js @@ -0,0 +1,21 @@ +import config from '@/config/defaultSettings' + +export const setDocumentTitle = function (title) { + document.title = title + const ua = navigator.userAgent + // eslint-disable-next-line + const regex = /\bMicroMessenger\/([\d\.]+)/ + if (regex.test(ua) && /ip(hone|od|ad)/i.test(ua)) { + const i = document.createElement('iframe') + i.src = '/favicon.ico' + i.style.display = 'none' + i.onload = function () { + setTimeout(function () { + i.remove() + }, 9) + } + document.body.appendChild(i) + } +} + +export const domTitle = config.title diff --git a/quantdinger_vue/src/utils/filter.js b/quantdinger_vue/src/utils/filter.js new file mode 100644 index 0000000..45702c6 --- /dev/null +++ b/quantdinger_vue/src/utils/filter.js @@ -0,0 +1,20 @@ +import Vue from 'vue' +import moment from 'moment' +import 'moment/locale/zh-cn' +moment.locale('zh-cn') + +Vue.filter('NumberFormat', function (value) { + if (!value) { + return '0' + } + const intPartFormat = value.toString().replace(/(\d)(?=(?:\d{3})+$)/g, '$1,') // 将整数部分逢三一断 + return intPartFormat +}) + +Vue.filter('dayjs', function (dataStr, pattern = 'YYYY-MM-DD HH:mm:ss') { + return moment(dataStr).format(pattern) +}) + +Vue.filter('moment', function (dataStr, pattern = 'YYYY-MM-DD HH:mm:ss') { + return moment(dataStr).format(pattern) +}) diff --git a/quantdinger_vue/src/utils/request.js b/quantdinger_vue/src/utils/request.js new file mode 100644 index 0000000..264efac --- /dev/null +++ b/quantdinger_vue/src/utils/request.js @@ -0,0 +1,149 @@ +import axios from 'axios' +// import store from '@/store' +import storage from 'store' +import notification from 'ant-design-vue/es/notification' +import { VueAxios } from './axios' +import { ACCESS_TOKEN } from '@/store/mutation-types' + +// PHPSESSID 存储键名 +const PHPSESSID_KEY = 'PHPSESSID' +// Locale storage key used by vue-i18n (see src/locales/index.js) +const LOCALE_KEY = 'lang' + +// 创建 axios 实例 +const request = axios.create({ + // API 请求的默认前缀 + // 生产环境应由 Nginx 处理,开发环境由 devServer proxy 处理 + baseURL: '/', + timeout: 6000, // 请求超时时间 + withCredentials: true // 允许携带 cookies +}) + +// 异常拦截处理器 +const errorHandler = (error) => { + if (error.response) { + const data = error.response.data + if (error.response.status === 403) { + notification.error({ + message: 'Forbidden', + description: data.message + }) + } + if (error.response.status === 401 && !(data.result && data.result.isLogin)) { + notification.error({ + message: 'Unauthorized', + description: 'Authorization verification failed' + }) + // 不清理本地 token,避免刷新后丢失登录态;仅跳转到登录页 + const loginPath = '/user/login' + const cur = window.location.pathname + window.location.search + if (!cur.includes('/user/login')) { + const redirect = encodeURIComponent(cur) + window.location.assign(`${loginPath}?redirect=${redirect}`) + } + } + } + return Promise.reject(error) +} + +// request interceptor +request.interceptors.request.use(config => { + const token = storage.get(ACCESS_TOKEN) + const lang = storage.get(LOCALE_KEY) || 'en-US' + + // Tell backend which UI language user is using, so AI reports can match it. + // We keep both a custom header and the standard Accept-Language for compatibility. + config.headers['X-App-Lang'] = lang + config.headers['Accept-Language'] = lang + + // 如果 token 存在,将 token 添加到请求头 + if (token) { + // 使用 Authorization header,格式为 Bearer {token} + config.headers['Authorization'] = `Bearer ${token}` + // 同时保留原有的 Access-Token header(如果后端需要) + config.headers[ACCESS_TOKEN] = token + // 兼容后端要求的 token 头 + config.headers['token'] = token + } + + // 防止缓存导致的 304:为请求添加禁止缓存的头 + config.headers['Cache-Control'] = 'no-cache' + config.headers['Pragma'] = 'no-cache' + config.headers['If-Modified-Since'] = '0' + + // 为 GET 请求添加时间戳参数,避免缓存 + if ((config.method || 'get').toLowerCase() === 'get') { + const ts = Date.now() + config.params = Object.assign({}, config.params || {}, { _t: ts }) + } + + // 手动设置 PHPSESSID cookie,确保每次请求使用相同的 session + // 注意:浏览器不允许手动设置 Cookie 请求头,需要通过 document.cookie 设置 + // 但由于跨域限制,可能无法直接设置 cookie,主要依赖 withCredentials: true + const phpsessid = storage.get(PHPSESSID_KEY) + if (phpsessid && typeof document !== 'undefined') { + // 检查当前 document.cookie 中的 PHPSESSID + const currentCookies = document.cookie + const currentPhpsessidMatch = currentCookies.match(/PHPSESSID=([^;]+)/i) + const currentPhpsessid = currentPhpsessidMatch ? currentPhpsessidMatch[1].trim() : null + + // 如果当前 cookie 中的 PHPSESSID 与保存的不一致,尝试更新 + // 注意:跨域情况下可能无法设置 cookie,这取决于 CORS 配置 + if (!currentPhpsessid || currentPhpsessid !== phpsessid) { + // 尝试设置 cookie(可能因为跨域而失败,但不影响 withCredentials 的工作) + try { + // 尝试设置带 domain 的 cookie(仅当在相同域名下时有效) + if (window.location.hostname.includes('quantdinger.com')) { + document.cookie = `PHPSESSID=${phpsessid}; path=/; domain=.quantdinger.com; SameSite=None; Secure` + } else { + // 跨域情况下,只能依赖 withCredentials: true 和服务器设置 + // 这里尝试设置,但可能不会成功 + document.cookie = `PHPSESSID=${phpsessid}; path=/; SameSite=None; Secure` + } + } catch (e) { + // 设置失败是正常的(跨域限制),主要依赖 withCredentials + } + } + } + + return config +}, errorHandler) + +// response interceptor +request.interceptors.response.use((response) => { + // 从响应中提取 PHPSESSID 并保存 + // 由于浏览器安全限制,无法直接读取 set-cookie 头,需要通过 document.cookie 获取 + try { + if (typeof document !== 'undefined') { + // 从 document.cookie 获取 PHPSESSID(浏览器自动设置的) + const cookies = document.cookie + const phpsessidMatch = cookies.match(/PHPSESSID=([^;]+)/i) + if (phpsessidMatch && phpsessidMatch[1]) { + const phpsessid = phpsessidMatch[1].trim() + // 保存 PHPSESSID 到 storage,有效期 24 小时 + const savedPhpsessid = storage.get(PHPSESSID_KEY) + // 如果 PHPSESSID 发生变化,更新保存的值 + if (!savedPhpsessid || savedPhpsessid !== phpsessid) { + storage.set(PHPSESSID_KEY, phpsessid, new Date().getTime() + 24 * 60 * 60 * 1000) + } + } + } + } catch (e) { + } + + return response.data +}, errorHandler) + +const installer = { + vm: {}, + install (Vue) { + Vue.use(VueAxios, request) + } +} + +export default request + +export { + installer as VueAxios, + request as axios +} diff --git a/quantdinger_vue/src/utils/routeConvert.js b/quantdinger_vue/src/utils/routeConvert.js new file mode 100644 index 0000000..e88b0d6 --- /dev/null +++ b/quantdinger_vue/src/utils/routeConvert.js @@ -0,0 +1,30 @@ +import cloneDeep from 'lodash.clonedeep' + +export function convertRoutes (nodes) { + if (!nodes) return null + + nodes = cloneDeep(nodes) + + let queue = Array.isArray(nodes) ? nodes.concat() : [nodes] + + while (queue.length) { + const levelSize = queue.length + + for (let i = 0; i < levelSize; i++) { + const node = queue.shift() + + if (!node.children || !node.children.length) continue + + node.children.forEach(child => { + // 转化相对路径 + if (child.path[0] !== '/' && !child.path.startsWith('http')) { + child.path = node.path.replace(/(\w*)[/]*$/, `$1/${child.path}`) + } + }) + + queue = queue.concat(node.children) + } + } + + return nodes +} diff --git a/quantdinger_vue/src/utils/screenLog.js b/quantdinger_vue/src/utils/screenLog.js new file mode 100644 index 0000000..a0386ac --- /dev/null +++ b/quantdinger_vue/src/utils/screenLog.js @@ -0,0 +1,4 @@ +/* eslint-disable */ +export const printANSI = () => { + +} diff --git a/quantdinger_vue/src/utils/util.js b/quantdinger_vue/src/utils/util.js new file mode 100644 index 0000000..2b77158 --- /dev/null +++ b/quantdinger_vue/src/utils/util.js @@ -0,0 +1,95 @@ +export function timeFix () { + const time = new Date() + const hour = time.getHours() + return hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening' +} + +export function welcome () { + const arr = ['休息一会儿吧', '准备吃什么呢?', '要不要打一把 DOTA', '我猜你可能累了'] + const index = Math.floor(Math.random() * arr.length) + return arr[index] +} + +/** + * 触发 window.resize + */ +export function triggerWindowResizeEvent () { + const event = document.createEvent('HTMLEvents') + event.initEvent('resize', true, true) + event.eventType = 'message' + window.dispatchEvent(event) +} + +export function handleScrollHeader (callback) { + let timer = 0 + + let beforeScrollTop = window.pageYOffset + callback = callback || function () {} + window.addEventListener( + 'scroll', + event => { + clearTimeout(timer) + timer = setTimeout(() => { + let direction = 'up' + const afterScrollTop = window.pageYOffset + const delta = afterScrollTop - beforeScrollTop + if (delta === 0) { + return false + } + direction = delta > 0 ? 'down' : 'up' + callback(direction) + beforeScrollTop = afterScrollTop + }, 50) + }, + false + ) +} + +export function isIE () { + const bw = window.navigator.userAgent + const compare = (s) => bw.indexOf(s) >= 0 + const ie11 = (() => 'ActiveXObject' in window)() + return compare('MSIE') || ie11 +} + +/** + * Remove loading animate + * @param id parent element id or class + * @param timeout + */ +export function removeLoadingAnimate (id = '', timeout = 1500) { + if (id === '') { + return + } + setTimeout(() => { + document.body.removeChild(document.getElementById(id)) + }, timeout) +} +export function scorePassword (pass) { + let score = 0 + if (!pass) { + return score + } + // award every unique letter until 5 repetitions + const letters = {} + for (let i = 0; i < pass.length; i++) { + letters[pass[i]] = (letters[pass[i]] || 0) + 1 + score += 5.0 / letters[pass[i]] + } + + // bonus points for mixing it up + const variations = { + digits: /\d/.test(pass), + lower: /[a-z]/.test(pass), + upper: /[A-Z]/.test(pass), + nonWords: /\W/.test(pass) + } + + let variationCount = 0 + for (var check in variations) { + variationCount += (variations[check] === true) ? 1 : 0 + } + score += (variationCount - 1) * 10 + + return parseInt(score) +} diff --git a/quantdinger_vue/src/utils/utils.less b/quantdinger_vue/src/utils/utils.less new file mode 100644 index 0000000..23bc405 --- /dev/null +++ b/quantdinger_vue/src/utils/utils.less @@ -0,0 +1,54 @@ +.textOverflow() { + overflow: hidden; + text-overflow: ellipsis; + word-break: break-all; + white-space: nowrap; +} + +.textOverflowMulti(@line: 3, @bg: #fff) { + position: relative; + max-height: @line * 1.5em; + padding-right: 1em; + margin-right: -1em; + overflow: hidden; + line-height: 1.5em; + text-align: justify; + + &::before { + position: absolute; + right: 14px; + bottom: 0; + padding: 0 1px; + background: @bg; + content: '...'; + } + + &::after { + position: absolute; + right: 14px; + width: 1em; + height: 1em; + margin-top: .2em; + background: white; + content: ''; + } +} + +// mixins for clearfix +// ------------------------ +.clearfix() { + zoom: 1; + + &::before, + &::after { + display: table; + content: ' '; + } + + &::after { + height: 0; + clear: both; + font-size: 0; + visibility: hidden; + } +} diff --git a/quantdinger_vue/src/views/404.vue b/quantdinger_vue/src/views/404.vue new file mode 100644 index 0000000..8c1d8a1 --- /dev/null +++ b/quantdinger_vue/src/views/404.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/quantdinger_vue/src/views/ai-analysis/components/index.vue b/quantdinger_vue/src/views/ai-analysis/components/index.vue new file mode 100644 index 0000000..c7c633d --- /dev/null +++ b/quantdinger_vue/src/views/ai-analysis/components/index.vue @@ -0,0 +1,3734 @@ + + + + + diff --git a/quantdinger_vue/src/views/ai-analysis/index.vue b/quantdinger_vue/src/views/ai-analysis/index.vue new file mode 100644 index 0000000..1182ca5 --- /dev/null +++ b/quantdinger_vue/src/views/ai-analysis/index.vue @@ -0,0 +1,2462 @@ + + + + + diff --git a/quantdinger_vue/src/views/dashboard/index.vue b/quantdinger_vue/src/views/dashboard/index.vue new file mode 100644 index 0000000..2e38fad --- /dev/null +++ b/quantdinger_vue/src/views/dashboard/index.vue @@ -0,0 +1,1049 @@ + + + + + + diff --git a/quantdinger_vue/src/views/exception/403.vue b/quantdinger_vue/src/views/exception/403.vue new file mode 100644 index 0000000..fb1bf36 --- /dev/null +++ b/quantdinger_vue/src/views/exception/403.vue @@ -0,0 +1,20 @@ + + + diff --git a/quantdinger_vue/src/views/exception/404.vue b/quantdinger_vue/src/views/exception/404.vue new file mode 100644 index 0000000..3142e7b --- /dev/null +++ b/quantdinger_vue/src/views/exception/404.vue @@ -0,0 +1,20 @@ + + + diff --git a/quantdinger_vue/src/views/exception/500.vue b/quantdinger_vue/src/views/exception/500.vue new file mode 100644 index 0000000..2770b77 --- /dev/null +++ b/quantdinger_vue/src/views/exception/500.vue @@ -0,0 +1,20 @@ + + + diff --git a/quantdinger_vue/src/views/indicator-analysis/components/BacktestHistoryDrawer.vue b/quantdinger_vue/src/views/indicator-analysis/components/BacktestHistoryDrawer.vue new file mode 100644 index 0000000..305a86b --- /dev/null +++ b/quantdinger_vue/src/views/indicator-analysis/components/BacktestHistoryDrawer.vue @@ -0,0 +1,221 @@ + + + diff --git a/quantdinger_vue/src/views/indicator-analysis/components/BacktestModal.vue b/quantdinger_vue/src/views/indicator-analysis/components/BacktestModal.vue new file mode 100644 index 0000000..7abcb13 --- /dev/null +++ b/quantdinger_vue/src/views/indicator-analysis/components/BacktestModal.vue @@ -0,0 +1,1361 @@ + + + + + diff --git a/quantdinger_vue/src/views/indicator-analysis/components/BacktestRunViewer.vue b/quantdinger_vue/src/views/indicator-analysis/components/BacktestRunViewer.vue new file mode 100644 index 0000000..be20e4d --- /dev/null +++ b/quantdinger_vue/src/views/indicator-analysis/components/BacktestRunViewer.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/quantdinger_vue/src/views/indicator-analysis/components/IndicatorEditor.vue b/quantdinger_vue/src/views/indicator-analysis/components/IndicatorEditor.vue new file mode 100644 index 0000000..a06478b --- /dev/null +++ b/quantdinger_vue/src/views/indicator-analysis/components/IndicatorEditor.vue @@ -0,0 +1,1119 @@ + + + + + diff --git a/quantdinger_vue/src/views/indicator-analysis/components/KlineChart.vue b/quantdinger_vue/src/views/indicator-analysis/components/KlineChart.vue new file mode 100644 index 0000000..ce30942 --- /dev/null +++ b/quantdinger_vue/src/views/indicator-analysis/components/KlineChart.vue @@ -0,0 +1,3863 @@ + + + + + diff --git a/quantdinger_vue/src/views/indicator-analysis/index.vue b/quantdinger_vue/src/views/indicator-analysis/index.vue new file mode 100644 index 0000000..83943c9 --- /dev/null +++ b/quantdinger_vue/src/views/indicator-analysis/index.vue @@ -0,0 +1,2933 @@ + + + + + diff --git a/quantdinger_vue/src/views/indicator-community/index.vue b/quantdinger_vue/src/views/indicator-community/index.vue new file mode 100644 index 0000000..47c8c1f --- /dev/null +++ b/quantdinger_vue/src/views/indicator-community/index.vue @@ -0,0 +1,41 @@ +