Merge branch 'brokermr810:main' into main
This commit is contained in:
@@ -106,6 +106,14 @@ QuantDinger includes a built-in **LLM-based multi-agent research system** that g
|
||||
|
||||
## 📸 Visual Tour
|
||||
|
||||
<div align="center">
|
||||
<h3>🗺️ System Architecture Overview</h3>
|
||||
<p>A comprehensive view of QuantDinger's AI-powered research, backtesting, and automated trading capabilities.</p>
|
||||
<img src="docs/screenshots/tuopu.png" alt="QuantDinger System Topology" width="100%" style="border-radius: 10px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); max-width: 800px;">
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<h3>📊 Professional Quant Dashboard</h3>
|
||||
<p>Real-time monitoring of market dynamics, assets, and strategy status.</p>
|
||||
@@ -288,7 +296,29 @@ Config lives in `.env` (see `backend_api_python/env.example`): `ENABLE_AGENT_MEM
|
||||
- **Auto-Restore**: Resumes running strategies after system restarts
|
||||
- **Order Queue**: Background worker for order execution
|
||||
|
||||
### 7. Tech Stack
|
||||
### 7. Multi-LLM Provider Support
|
||||
|
||||
QuantDinger supports multiple AI providers with auto-detection:
|
||||
|
||||
| Provider | Features |
|
||||
|----------|----------|
|
||||
| **OpenRouter** | Multi-model gateway (default), 100+ models |
|
||||
| **OpenAI** | GPT-4o, GPT-4o-mini |
|
||||
| **Google Gemini** | Gemini 1.5 Flash/Pro |
|
||||
| **DeepSeek** | DeepSeek Chat (cost-effective) |
|
||||
| **xAI Grok** | Grok Beta |
|
||||
|
||||
Simply configure your preferred provider's API key in `.env`. The system auto-detects available providers.
|
||||
|
||||
### 8. User Management & Security
|
||||
|
||||
- **Multi-User Support**: PostgreSQL-backed user accounts with role-based permissions
|
||||
- **OAuth Login**: Google and GitHub OAuth integration
|
||||
- **Email Verification**: Registration and password reset via email codes
|
||||
- **Security Features**: Cloudflare Turnstile captcha, IP/account rate limiting
|
||||
- **Demo Mode**: Read-only mode for public demonstrations
|
||||
|
||||
### 9. Tech Stack
|
||||
|
||||
- **Backend**: Python (Flask) + PostgreSQL + Redis (optional)
|
||||
- **Frontend**: Vue 2 + Ant Design Vue + KlineCharts/ECharts
|
||||
@@ -708,10 +738,13 @@ 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**: `DATABASE_URL` (PostgreSQL connection string)
|
||||
- **AI / LLM**: `OPENROUTER_API_KEY`, `OPENROUTER_MODEL`, timeouts
|
||||
- **AI / LLM**: `LLM_PROVIDER` (openrouter/openai/google/deepseek/grok), provider-specific API keys
|
||||
- **OAuth**: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`
|
||||
- **Security**: `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY`, `ENABLE_REGISTRATION`
|
||||
- **Web search**: `SEARCH_PROVIDER`, `SEARCH_GOOGLE_*`, `SEARCH_BING_API_KEY`
|
||||
- **Order Execution**: `ORDER_MODE` (maker/market), `MAKER_WAIT_SEC`, `MAKER_OFFSET_BPS`
|
||||
- **Proxy (optional)**: `PROXY_PORT` or `PROXY_URL`
|
||||
- **Workers**: `ENABLE_PENDING_ORDER_WORKER`, `DISABLE_RESTORE_RUNNING_STRATEGIES`
|
||||
- **Workers**: `ENABLE_PENDING_ORDER_WORKER`, `ENABLE_PORTFOLIO_MONITOR`
|
||||
|
||||
---
|
||||
|
||||
@@ -823,6 +856,30 @@ Your contributions help us maintain and improve QuantDinger.
|
||||
|
||||
---
|
||||
|
||||
### 🎓 Supporting Partners
|
||||
|
||||
We are proud to be supported by academic institutions and organizations advancing quantitative finance education and research.
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<a href="https://beinvolved.indiana.edu/organization/quantfiniu" target="_blank">
|
||||
<img src="docs/screenshots/qfs_logo.png" alt="Indiana University Quantitative Finance Society" width="280" style="border-radius: 8px;">
|
||||
</a>
|
||||
<br/><br/>
|
||||
<strong>Quantitative Finance Society (QFS)</strong><br/>
|
||||
<small>Indiana University Bloomington</small><br/>
|
||||
<small>Fostering the next generation of quantitative finance professionals</small>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
> 💡 **Interested in becoming a supporting partner?** We welcome collaborations with universities, research institutions, and organizations. Contact us at [brokermr810@gmail.com](mailto:brokermr810@gmail.com) or via [Telegram](https://t.me/worldinbroker).
|
||||
|
||||
---
|
||||
|
||||
### Acknowledgements
|
||||
|
||||
QuantDinger stands on the shoulders of great open-source projects:
|
||||
|
||||
+61
-2
@@ -106,6 +106,14 @@ QuantDinger 包含一个内置的**基于 LLM 的多智能体研究系统**,
|
||||
|
||||
## 📸 功能预览
|
||||
|
||||
<div align="center">
|
||||
<h3>🗺️ 系统架构总览</h3>
|
||||
<p>QuantDinger AI 驱动的研究、回测和自动化交易功能全景图。</p>
|
||||
<img src="docs/screenshots/tuopu.png" alt="QuantDinger 系统拓扑图" width="100%" style="border-radius: 10px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); max-width: 800px;">
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<h3>📊 专业量化仪表盘</h3>
|
||||
<p>实时监控市场动态、资产状况和策略状态。</p>
|
||||
@@ -326,7 +334,29 @@ score = w_{sim}\cdot sim + w_{recency}\cdot recency + w_{returns}\cdot returns\_
|
||||
- **自动恢复**:系统重启后恢复运行中的策略
|
||||
- **订单队列**:后台工作线程用于订单执行
|
||||
|
||||
### 7. 技术栈
|
||||
### 7. 多LLM提供商支持
|
||||
|
||||
QuantDinger 支持多个 AI 提供商,具备自动检测功能:
|
||||
|
||||
| 提供商 | 特点 |
|
||||
|--------|------|
|
||||
| **OpenRouter** | 多模型网关(默认),100+ 模型 |
|
||||
| **OpenAI** | GPT-4o, GPT-4o-mini |
|
||||
| **Google Gemini** | Gemini 1.5 Flash/Pro |
|
||||
| **DeepSeek** | DeepSeek Chat(性价比高) |
|
||||
| **xAI Grok** | Grok Beta |
|
||||
|
||||
只需在 `.env` 中配置您首选提供商的 API 密钥,系统会自动检测可用提供商。
|
||||
|
||||
### 8. 用户管理与安全
|
||||
|
||||
- **多用户支持**:基于 PostgreSQL 的用户账户,支持基于角色的权限管理
|
||||
- **OAuth 登录**:Google 和 GitHub OAuth 集成
|
||||
- **邮箱验证**:通过邮箱验证码进行注册和密码重置
|
||||
- **安全功能**:Cloudflare Turnstile 人机验证、IP/账户速率限制
|
||||
- **演示模式**:用于公开演示的只读模式
|
||||
|
||||
### 9. 技术栈
|
||||
|
||||
- **后端**:Python (Flask) + PostgreSQL + Redis(可选)
|
||||
- **前端**:Vue 2 + Ant Design Vue + KlineCharts/ECharts
|
||||
@@ -535,9 +565,14 @@ npm run serve
|
||||
|
||||
- **认证**: `SECRET_KEY`, `ADMIN_USER`, `ADMIN_PASSWORD`
|
||||
- **服务器**: `PYTHON_API_HOST`, `PYTHON_API_PORT`, `PYTHON_API_DEBUG`
|
||||
- **AI / LLM**: `OPENROUTER_API_KEY`, `OPENROUTER_MODEL`
|
||||
- **数据库**: `DATABASE_URL` (PostgreSQL 连接字符串)
|
||||
- **AI / LLM**: `LLM_PROVIDER` (openrouter/openai/google/deepseek/grok), 各提供商 API 密钥
|
||||
- **OAuth**: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`
|
||||
- **安全**: `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY`, `ENABLE_REGISTRATION`
|
||||
- **网络搜索**: `SEARCH_PROVIDER`, `SEARCH_GOOGLE_*`, `SEARCH_BING_API_KEY`
|
||||
- **订单执行**: `ORDER_MODE` (maker/market), `MAKER_WAIT_SEC`, `MAKER_OFFSET_BPS`
|
||||
- **代理 (可选)**: `PROXY_PORT` 或 `PROXY_URL`
|
||||
- **后台服务**: `ENABLE_PENDING_ORDER_WORKER`, `ENABLE_PORTFOLIO_MONITOR`
|
||||
|
||||
---
|
||||
|
||||
@@ -632,6 +667,30 @@ QuantDinger 的代码使用 **Apache License 2.0** 授权。但需要注意:**
|
||||
|
||||
---
|
||||
|
||||
### 🎓 支持伙伴
|
||||
|
||||
我们很荣幸获得推动量化金融教育和研究的学术机构和组织的支持。
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<a href="https://beinvolved.indiana.edu/organization/quantfiniu" target="_blank">
|
||||
<img src="docs/screenshots/qfs_logo.png" alt="印第安纳大学量化金融学会" width="280" style="border-radius: 8px;">
|
||||
</a>
|
||||
<br/><br/>
|
||||
<strong>量化金融学会 (QFS)</strong><br/>
|
||||
<small>印第安纳大学布卢明顿分校</small><br/>
|
||||
<small>培养下一代量化金融专业人才</small>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
> 💡 **有兴趣成为支持伙伴吗?** 我们欢迎与大学、研究机构和组织合作。请通过 [brokermr810@gmail.com](mailto:brokermr810@gmail.com) 或 [Telegram](https://t.me/worldinbroker) 联系我们。
|
||||
|
||||
---
|
||||
|
||||
### 致谢
|
||||
|
||||
QuantDinger 站在这些伟大的开源项目肩膀之上:Flask, Pandas, CCXT, Vue.js, Ant Design Vue, KlineCharts 等。
|
||||
|
||||
+62
-3
@@ -106,6 +106,14 @@ QuantDingerには、ウェブから金融情報を収集し、ローカル市場
|
||||
|
||||
## 📸 ビジュアルツアー
|
||||
|
||||
<div align="center">
|
||||
<h3>🗺️ システムアーキテクチャ概要</h3>
|
||||
<p>QuantDinger の AI 駆動型リサーチ、バックテスト、自動取引機能の全体像。</p>
|
||||
<img src="docs/screenshots/tuopu.png" alt="QuantDinger システムトポロジー" width="100%" style="border-radius: 10px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); max-width: 800px;">
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<h3>📊 プロフェッショナル・クオンツダッシュボード</h3>
|
||||
<p>市場の動向、資産、戦略ステータスをリアルタイムで監視。</p>
|
||||
@@ -296,9 +304,31 @@ score = w_{sim}\cdot sim + w_{recency}\cdot recency + w_{returns}\cdot returns\_
|
||||
- **自動復元**: システム再起動後に実行中の戦略を再開
|
||||
- **注文キュー**: 注文実行のためのバックグラウンドワーカー
|
||||
|
||||
### 7. 技術スタック
|
||||
### 7. マルチLLMプロバイダーサポート
|
||||
|
||||
- **バックエンド**: Python (Flask) + SQLite + Redis(オプション)
|
||||
QuantDinger は複数の AI プロバイダーをサポートし、自動検出機能を備えています:
|
||||
|
||||
| プロバイダー | 特徴 |
|
||||
|------------|------|
|
||||
| **OpenRouter** | マルチモデルゲートウェイ(デフォルト)、100+ モデル |
|
||||
| **OpenAI** | GPT-4o, GPT-4o-mini |
|
||||
| **Google Gemini** | Gemini 1.5 Flash/Pro |
|
||||
| **DeepSeek** | DeepSeek Chat(コスパ良好) |
|
||||
| **xAI Grok** | Grok Beta |
|
||||
|
||||
`.env` でお好みのプロバイダーの API キーを設定するだけで、システムが利用可能なプロバイダーを自動検出します。
|
||||
|
||||
### 8. ユーザー管理とセキュリティ
|
||||
|
||||
- **マルチユーザーサポート**:PostgreSQL ベースのユーザーアカウント、ロールベースの権限管理
|
||||
- **OAuth ログイン**:Google および GitHub OAuth 統合
|
||||
- **メール認証**:メール認証コードによる登録とパスワードリセット
|
||||
- **セキュリティ機能**:Cloudflare Turnstile キャプチャ、IP/アカウントレート制限
|
||||
- **デモモード**:公開デモ用の読み取り専用モード
|
||||
|
||||
### 9. 技術スタック
|
||||
|
||||
- **バックエンド**: Python (Flask) + PostgreSQL + Redis(オプション)
|
||||
- **フロントエンド**: Vue 2 + Ant Design Vue + KlineCharts/ECharts
|
||||
- **デプロイ**: Docker Compose
|
||||
|
||||
@@ -491,9 +521,14 @@ npm run serve
|
||||
|
||||
- **認証**: `SECRET_KEY`, `ADMIN_USER`, `ADMIN_PASSWORD`
|
||||
- **サーバー**: `PYTHON_API_HOST`, `PYTHON_API_PORT`, `PYTHON_API_DEBUG`
|
||||
- **AI / LLM**: `OPENROUTER_API_KEY`, `OPENROUTER_MODEL`
|
||||
- **データベース**: `DATABASE_URL` (PostgreSQL 接続文字列)
|
||||
- **AI / LLM**: `LLM_PROVIDER` (openrouter/openai/google/deepseek/grok), 各プロバイダー API キー
|
||||
- **OAuth**: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`
|
||||
- **セキュリティ**: `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY`, `ENABLE_REGISTRATION`
|
||||
- **ウェブ検索**: `SEARCH_PROVIDER`, `SEARCH_GOOGLE_*`, `SEARCH_BING_API_KEY`
|
||||
- **注文執行**: `ORDER_MODE` (maker/market), `MAKER_WAIT_SEC`, `MAKER_OFFSET_BPS`
|
||||
- **プロキシ (オプション)**: `PROXY_PORT` または `PROXY_URL`
|
||||
- **バックグラウンドサービス**: `ENABLE_PENDING_ORDER_WORKER`, `ENABLE_PORTFOLIO_MONITOR`
|
||||
|
||||
---
|
||||
|
||||
@@ -588,6 +623,30 @@ QuantDinger のコードは **Apache License 2.0** で提供されています
|
||||
|
||||
---
|
||||
|
||||
### 🎓 サポートパートナー
|
||||
|
||||
量的金融の教育と研究を推進する学術機関や組織からのサポートを光栄に思います。
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<a href="https://beinvolved.indiana.edu/organization/quantfiniu" target="_blank">
|
||||
<img src="docs/screenshots/qfs_logo.png" alt="インディアナ大学量的金融学会" width="280" style="border-radius: 8px;">
|
||||
</a>
|
||||
<br/><br/>
|
||||
<strong>量的金融学会 (QFS)</strong><br/>
|
||||
<small>インディアナ大学ブルーミントン校</small><br/>
|
||||
<small>次世代の量的金融専門家を育成</small>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
> 💡 **サポートパートナーになりませんか?** 大学、研究機関、組織との協力を歓迎します。[brokermr810@gmail.com](mailto:brokermr810@gmail.com) または [Telegram](https://t.me/worldinbroker) でお問い合わせください。
|
||||
|
||||
---
|
||||
|
||||
### 謝辞
|
||||
|
||||
QuantDingerは、Flask, Pandas, CCXT, Vue.js, Ant Design Vue, KlineChartsなどの素晴らしいオープンソースプロジェクトの上に成り立っています。
|
||||
|
||||
+62
-3
@@ -106,6 +106,14 @@ QuantDinger는 웹에서 금융 정보를 수집하고, 로컬 시장 데이터
|
||||
|
||||
## 📸 비주얼 투어
|
||||
|
||||
<div align="center">
|
||||
<h3>🗺️ 시스템 아키텍처 개요</h3>
|
||||
<p>QuantDinger의 AI 기반 리서치, 백테스팅 및 자동화 거래 기능 전체 개요.</p>
|
||||
<img src="docs/screenshots/tuopu.png" alt="QuantDinger 시스템 토폴로지" width="100%" style="border-radius: 10px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); max-width: 800px;">
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<h3>📊 전문 퀀트 대시보드</h3>
|
||||
<p>시장 역학, 자산 및 전략 상태에 대한 실시간 모니터링.</p>
|
||||
@@ -296,9 +304,31 @@ score = w_{sim}\cdot sim + w_{recency}\cdot recency + w_{returns}\cdot returns\_
|
||||
- **자동 복구**: 시스템 재시작 후 실행 중인 전략 재개
|
||||
- **주문 대기열**: 주문 실행을 위한 백그라운드 워커
|
||||
|
||||
### 7. 기술 스택
|
||||
### 7. 멀티 LLM 제공자 지원
|
||||
|
||||
- **백엔드**: Python (Flask) + SQLite + Redis(옵션)
|
||||
QuantDinger는 자동 감지 기능을 갖춘 여러 AI 제공자를 지원합니다:
|
||||
|
||||
| 제공자 | 특징 |
|
||||
|--------|------|
|
||||
| **OpenRouter** | 멀티모델 게이트웨이(기본값), 100+ 모델 |
|
||||
| **OpenAI** | GPT-4o, GPT-4o-mini |
|
||||
| **Google Gemini** | Gemini 1.5 Flash/Pro |
|
||||
| **DeepSeek** | DeepSeek Chat(가성비 좋음) |
|
||||
| **xAI Grok** | Grok Beta |
|
||||
|
||||
`.env`에서 선호하는 제공자의 API 키만 설정하면 시스템이 사용 가능한 제공자를 자동 감지합니다.
|
||||
|
||||
### 8. 사용자 관리 및 보안
|
||||
|
||||
- **멀티유저 지원**: PostgreSQL 기반 사용자 계정, 역할 기반 권한 관리
|
||||
- **OAuth 로그인**: Google 및 GitHub OAuth 통합
|
||||
- **이메일 인증**: 이메일 인증 코드를 통한 등록 및 비밀번호 재설정
|
||||
- **보안 기능**: Cloudflare Turnstile 캡차, IP/계정 속도 제한
|
||||
- **데모 모드**: 공개 데모를 위한 읽기 전용 모드
|
||||
|
||||
### 9. 기술 스택
|
||||
|
||||
- **백엔드**: Python (Flask) + PostgreSQL + Redis(옵션)
|
||||
- **프론트엔드**: Vue 2 + Ant Design Vue + KlineCharts/ECharts
|
||||
- **배포**: Docker Compose
|
||||
|
||||
@@ -504,9 +534,14 @@ npm run serve
|
||||
|
||||
- **인증**: `SECRET_KEY`, `ADMIN_USER`, `ADMIN_PASSWORD`
|
||||
- **서버**: `PYTHON_API_HOST`, `PYTHON_API_PORT`, `PYTHON_API_DEBUG`
|
||||
- **AI / LLM**: `OPENROUTER_API_KEY`, `OPENROUTER_MODEL`
|
||||
- **데이터베이스**: `DATABASE_URL` (PostgreSQL 연결 문자열)
|
||||
- **AI / LLM**: `LLM_PROVIDER` (openrouter/openai/google/deepseek/grok), 각 제공자 API 키
|
||||
- **OAuth**: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`
|
||||
- **보안**: `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY`, `ENABLE_REGISTRATION`
|
||||
- **웹 검색**: `SEARCH_PROVIDER`, `SEARCH_GOOGLE_*`, `SEARCH_BING_API_KEY`
|
||||
- **주문 실행**: `ORDER_MODE` (maker/market), `MAKER_WAIT_SEC`, `MAKER_OFFSET_BPS`
|
||||
- **프록시 (옵션)**: `PROXY_PORT` 또는 `PROXY_URL`
|
||||
- **백그라운드 서비스**: `ENABLE_PENDING_ORDER_WORKER`, `ENABLE_PORTFOLIO_MONITOR`
|
||||
|
||||
---
|
||||
|
||||
@@ -601,6 +636,30 @@ QuantDinger의 코드는 **Apache License 2.0**으로 제공됩니다. 다만 **
|
||||
|
||||
---
|
||||
|
||||
### 🎓 지원 파트너
|
||||
|
||||
양적 금융 교육 및 연구를 발전시키는 학술 기관과 조직의 지원을 받게 되어 자랑스럽습니다.
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<a href="https://beinvolved.indiana.edu/organization/quantfiniu" target="_blank">
|
||||
<img src="docs/screenshots/qfs_logo.png" alt="인디애나 대학교 양적금융학회" width="280" style="border-radius: 8px;">
|
||||
</a>
|
||||
<br/><br/>
|
||||
<strong>양적금융학회 (QFS)</strong><br/>
|
||||
<small>인디애나 대학교 블루밍턴</small><br/>
|
||||
<small>차세대 양적 금융 전문가 양성</small>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
> 💡 **지원 파트너가 되는 데 관심이 있으신가요?** 대학, 연구 기관 및 조직과의 협력을 환영합니다. [brokermr810@gmail.com](mailto:brokermr810@gmail.com) 또는 [Telegram](https://t.me/worldinbroker)으로 연락주세요.
|
||||
|
||||
---
|
||||
|
||||
### 감사의 말
|
||||
|
||||
QuantDinger는 Flask, Pandas, CCXT, Vue.js, Ant Design Vue, KlineCharts 등과 같은 훌륭한 오픈 소스 프로젝트의 어깨 위에 서 있습니다.
|
||||
|
||||
+64
-5
@@ -106,6 +106,14 @@ QuantDinger 包含一個內置的**基於 LLM 的多智能體研究系統**,
|
||||
|
||||
## 📸 功能預覽
|
||||
|
||||
<div align="center">
|
||||
<h3>🗺️ 系統架構總覽</h3>
|
||||
<p>QuantDinger AI 驅動的研究、回測和自動化交易功能全景圖。</p>
|
||||
<img src="docs/screenshots/tuopu.png" alt="QuantDinger 系統拓撲圖" width="100%" style="border-radius: 10px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); max-width: 800px;">
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<h3>📊 專業量化儀表盤</h3>
|
||||
<p>實時監控市場動態、資產狀況和策略狀態。</p>
|
||||
@@ -296,9 +304,31 @@ score = w_{sim}\cdot sim + w_{recency}\cdot recency + w_{returns}\cdot returns\_
|
||||
- **自動恢復**:系統重啟後恢復運行中的策略
|
||||
- **訂單隊列**:後台工作線程用於訂單執行
|
||||
|
||||
### 7. 技術棧
|
||||
### 7. 多LLM提供商支援
|
||||
|
||||
- **後端**:Python (Flask) + SQLite + Redis(可選)
|
||||
QuantDinger 支援多個 AI 提供商,具備自動檢測功能:
|
||||
|
||||
| 提供商 | 特點 |
|
||||
|--------|------|
|
||||
| **OpenRouter** | 多模型閘道(預設),100+ 模型 |
|
||||
| **OpenAI** | GPT-4o, GPT-4o-mini |
|
||||
| **Google Gemini** | Gemini 1.5 Flash/Pro |
|
||||
| **DeepSeek** | DeepSeek Chat(高性價比) |
|
||||
| **xAI Grok** | Grok Beta |
|
||||
|
||||
只需在 `.env` 中配置您首選提供商的 API 金鑰,系統會自動檢測可用提供商。
|
||||
|
||||
### 8. 使用者管理與安全
|
||||
|
||||
- **多使用者支援**:基於 PostgreSQL 的使用者帳戶,支援基於角色的權限管理
|
||||
- **OAuth 登入**:Google 和 GitHub OAuth 整合
|
||||
- **郵箱驗證**:透過郵箱驗證碼進行註冊和密碼重設
|
||||
- **安全功能**:Cloudflare Turnstile 人機驗證、IP/帳戶速率限制
|
||||
- **示範模式**:用於公開示範的唯讀模式
|
||||
|
||||
### 9. 技術棧
|
||||
|
||||
- **後端**:Python (Flask) + PostgreSQL + Redis(可選)
|
||||
- **前端**:Vue 2 + Ant Design Vue + KlineCharts/ECharts
|
||||
- **部署**:Docker Compose
|
||||
|
||||
@@ -504,10 +534,15 @@ npm run serve
|
||||
使用 `backend_api_python/env.example` 作為模板。常用設置包括:
|
||||
|
||||
- **認證**: `SECRET_KEY`, `ADMIN_USER`, `ADMIN_PASSWORD`
|
||||
- **服務器**: `PYTHON_API_HOST`, `PYTHON_API_PORT`, `PYTHON_API_DEBUG`
|
||||
- **AI / LLM**: `OPENROUTER_API_KEY`, `OPENROUTER_MODEL`
|
||||
- **網絡搜索**: `SEARCH_PROVIDER`, `SEARCH_GOOGLE_*`, `SEARCH_BING_API_KEY`
|
||||
- **伺服器**: `PYTHON_API_HOST`, `PYTHON_API_PORT`, `PYTHON_API_DEBUG`
|
||||
- **資料庫**: `DATABASE_URL` (PostgreSQL 連接字串)
|
||||
- **AI / LLM**: `LLM_PROVIDER` (openrouter/openai/google/deepseek/grok), 各提供商 API 金鑰
|
||||
- **OAuth**: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`
|
||||
- **安全**: `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY`, `ENABLE_REGISTRATION`
|
||||
- **網路搜尋**: `SEARCH_PROVIDER`, `SEARCH_GOOGLE_*`, `SEARCH_BING_API_KEY`
|
||||
- **訂單執行**: `ORDER_MODE` (maker/market), `MAKER_WAIT_SEC`, `MAKER_OFFSET_BPS`
|
||||
- **代理 (可選)**: `PROXY_PORT` 或 `PROXY_URL`
|
||||
- **後台服務**: `ENABLE_PENDING_ORDER_WORKER`, `ENABLE_PORTFOLIO_MONITOR`
|
||||
|
||||
---
|
||||
|
||||
@@ -602,6 +637,30 @@ QuantDinger 的代碼使用 **Apache License 2.0** 授權。但請注意:**Apa
|
||||
|
||||
---
|
||||
|
||||
### 🎓 支持夥伴
|
||||
|
||||
我們很榮幸獲得推動量化金融教育和研究的學術機構和組織的支持。
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<a href="https://beinvolved.indiana.edu/organization/quantfiniu" target="_blank">
|
||||
<img src="docs/screenshots/qfs_logo.png" alt="印第安納大學量化金融學會" width="280" style="border-radius: 8px;">
|
||||
</a>
|
||||
<br/><br/>
|
||||
<strong>量化金融學會 (QFS)</strong><br/>
|
||||
<small>印第安納大學布盧明頓分校</small><br/>
|
||||
<small>培養下一代量化金融專業人才</small>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
> 💡 **有興趣成為支持夥伴嗎?** 我們歡迎與大學、研究機構和組織合作。請透過 [brokermr810@gmail.com](mailto:brokermr810@gmail.com) 或 [Telegram](https://t.me/worldinbroker) 聯繫我們。
|
||||
|
||||
---
|
||||
|
||||
### 致謝
|
||||
|
||||
QuantDinger 站在這些偉大的開源項目肩膀之上:Flask, Pandas, CCXT, Vue.js, Ant Design Vue, KlineCharts 等。
|
||||
|
||||
@@ -22,6 +22,7 @@ def register_routes(app: Flask):
|
||||
from app.routes.ibkr import ibkr_bp
|
||||
from app.routes.mt5 import mt5_bp
|
||||
from app.routes.user import user_bp
|
||||
from app.routes.global_market import global_market_bp
|
||||
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(auth_bp, url_prefix='/api/auth') # Auth routes
|
||||
@@ -39,3 +40,4 @@ def register_routes(app: Flask):
|
||||
app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio')
|
||||
app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr')
|
||||
app.register_blueprint(mt5_bp, url_prefix='/api/mt5')
|
||||
app.register_blueprint(global_market_bp, url_prefix='/api/global-market')
|
||||
@@ -379,9 +379,14 @@ def login_with_code():
|
||||
(user['id'],)
|
||||
)
|
||||
db.commit()
|
||||
affected = cur.rowcount
|
||||
cur.close()
|
||||
if affected == 0:
|
||||
logger.error(f"Failed to update last_login_at: no rows affected for user_id={user['id']}")
|
||||
else:
|
||||
logger.info(f"Updated last_login_at for user_id={user['id']}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update last_login_at: {e}")
|
||||
logger.error(f"Failed to update last_login_at for user_id={user.get('id')}: {e}")
|
||||
|
||||
# Log login
|
||||
security.log_security_event('login_via_code', user['id'], ip_address, user_agent)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,7 +66,7 @@ def get_public_config():
|
||||
'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',
|
||||
'openai/gpt-4.1-mini': 'OpenAI: GPT-4.1 Mini',
|
||||
'deepseek/deepseek-v3.2': 'DeepSeek: DeepSeek V3.2',
|
||||
'minimax/minimax-m2': 'MiniMax: MiniMax M2',
|
||||
'anthropic/claude-sonnet-4': 'Anthropic: Claude Sonnet 4',
|
||||
|
||||
@@ -816,9 +816,6 @@ def get_strategy_notifications():
|
||||
user_strategy_ids = [r.get('id') for r in rows if r.get('id')]
|
||||
cur.close()
|
||||
|
||||
if not user_strategy_ids:
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'items': []}})
|
||||
|
||||
where = []
|
||||
args = []
|
||||
|
||||
@@ -830,9 +827,15 @@ def get_strategy_notifications():
|
||||
else:
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': {'items': []}})
|
||||
else:
|
||||
placeholders = ",".join(["?"] * len(user_strategy_ids))
|
||||
where.append(f"strategy_id IN ({placeholders})")
|
||||
args.extend(user_strategy_ids)
|
||||
if user_strategy_ids:
|
||||
placeholders = ",".join(["?"] * len(user_strategy_ids))
|
||||
where.append(f"(strategy_id IN ({placeholders}) OR (strategy_id IS NULL AND user_id = ?))")
|
||||
args.extend(user_strategy_ids)
|
||||
args.append(user_id)
|
||||
else:
|
||||
# Only portfolio monitor notifications (strategy_id is NULL)
|
||||
where.append("strategy_id IS NULL AND user_id = ?")
|
||||
args.append(user_id)
|
||||
|
||||
if since_id:
|
||||
where.append("id > ?")
|
||||
@@ -889,15 +892,18 @@ def mark_notification_read():
|
||||
if not notification_id:
|
||||
return jsonify({'code': 0, 'msg': 'Missing id'}), 400
|
||||
|
||||
# Only update notifications for user's strategies
|
||||
# Update notifications for user's strategies OR portfolio monitor notifications
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_strategy_notifications SET is_read = 1
|
||||
WHERE id = ? AND strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?)
|
||||
WHERE id = ? AND (
|
||||
strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?)
|
||||
OR (strategy_id IS NULL AND user_id = ?)
|
||||
)
|
||||
""",
|
||||
(int(notification_id), user_id)
|
||||
(int(notification_id), user_id, user_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -920,8 +926,9 @@ def mark_all_notifications_read():
|
||||
"""
|
||||
UPDATE qd_strategy_notifications SET is_read = 1
|
||||
WHERE strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?)
|
||||
OR (strategy_id IS NULL AND user_id = ?)
|
||||
""",
|
||||
(user_id,)
|
||||
(user_id, user_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -944,8 +951,9 @@ def clear_notifications():
|
||||
"""
|
||||
DELETE FROM qd_strategy_notifications
|
||||
WHERE strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?)
|
||||
OR (strategy_id IS NULL AND user_id = ?)
|
||||
""",
|
||||
(user_id,)
|
||||
(user_id, user_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
@@ -387,8 +387,9 @@ class LLMService:
|
||||
logger.error(f"{p.value} API HTTP error ({current_model}): {error_detail}")
|
||||
last_error = str(e)
|
||||
|
||||
# Check for payment/quota errors
|
||||
if e.response and e.response.status_code in (402, 429):
|
||||
# Check for recoverable errors - try fallback model
|
||||
# 402: Payment required, 403: Forbidden (invalid key), 404: Model not found, 429: Rate limit
|
||||
if e.response and e.response.status_code in (402, 403, 404, 429):
|
||||
logger.warning(f"{p.value} returned {e.response.status_code} for model {current_model}; trying fallback...")
|
||||
continue
|
||||
|
||||
|
||||
@@ -768,8 +768,9 @@ def _send_monitor_notification(
|
||||
cur.close()
|
||||
elif ch == 'telegram':
|
||||
chat_id = targets.get('telegram', '')
|
||||
token_override = targets.get('telegram_bot_token', '')
|
||||
if chat_id:
|
||||
notifier._notify_telegram(chat_id=chat_id, text=f"<b>{error_title}</b>\n\n{error_msg}", parse_mode="HTML")
|
||||
notifier._notify_telegram(chat_id=chat_id, text=f"<b>{error_title}</b>\n\n{error_msg}", token_override=token_override, parse_mode="HTML")
|
||||
elif ch == 'email':
|
||||
to_email = targets.get('email', '')
|
||||
if to_email:
|
||||
@@ -815,11 +816,13 @@ def _send_monitor_notification(
|
||||
|
||||
elif ch == 'telegram':
|
||||
chat_id = targets.get('telegram', '')
|
||||
token_override = targets.get('telegram_bot_token', '')
|
||||
if chat_id:
|
||||
# Use Telegram-optimized format
|
||||
notifier._notify_telegram(
|
||||
chat_id=chat_id,
|
||||
text=telegram_report,
|
||||
token_override=token_override,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@@ -1099,8 +1102,9 @@ def _check_position_alerts():
|
||||
cur.close()
|
||||
elif ch == 'telegram':
|
||||
chat_id = targets.get('telegram', '')
|
||||
token_override = targets.get('telegram_bot_token', '')
|
||||
if chat_id:
|
||||
notifier._notify_telegram(chat_id=chat_id, text=alert_message, parse_mode="HTML")
|
||||
notifier._notify_telegram(chat_id=chat_id, text=alert_message, token_override=token_override, parse_mode="HTML")
|
||||
elif ch == 'email':
|
||||
to_email = targets.get('email', '')
|
||||
if to_email:
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
"""
|
||||
Search service.
|
||||
Integrates Google Custom Search (CSE) and Bing Search API.
|
||||
Integrates Google Custom Search (CSE), Bing Search API, and DuckDuckGo (free fallback).
|
||||
Configuration is provided via environment variables (see env.example) through config_loader.
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
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__)
|
||||
|
||||
# Track Google API quota status
|
||||
_google_quota_exhausted = False
|
||||
_google_quota_reset_time = 0
|
||||
|
||||
|
||||
class SearchService:
|
||||
"""Search service."""
|
||||
"""Search service with automatic fallback."""
|
||||
|
||||
def __init__(self):
|
||||
self._config = {}
|
||||
@@ -28,7 +33,7 @@ class SearchService:
|
||||
|
||||
def search(self, query: str, num_results: int = None, date_restrict: str = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute a web search.
|
||||
Execute a web search with automatic fallback.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
@@ -38,18 +43,45 @@ class SearchService:
|
||||
Returns:
|
||||
List of search results
|
||||
"""
|
||||
global _google_quota_exhausted, _google_quota_reset_time
|
||||
|
||||
# 重新加载配置以支持热更新
|
||||
self._load_config()
|
||||
|
||||
limit = num_results if num_results else self.max_results
|
||||
|
||||
# Check if Google quota has reset (after midnight UTC typically)
|
||||
if _google_quota_exhausted and time.time() > _google_quota_reset_time:
|
||||
_google_quota_exhausted = False
|
||||
logger.info("Google API quota reset, re-enabling Google search")
|
||||
|
||||
results = []
|
||||
|
||||
if self.provider == 'bing':
|
||||
return self._search_bing(query, limit)
|
||||
results = self._search_bing(query, limit)
|
||||
elif self.provider == 'duckduckgo':
|
||||
results = self._search_duckduckgo(query, limit)
|
||||
else:
|
||||
return self._search_google(query, limit, date_restrict)
|
||||
# Google with fallback
|
||||
if not _google_quota_exhausted:
|
||||
results = self._search_google(query, limit, date_restrict)
|
||||
|
||||
# If Google failed or returned empty, try fallbacks
|
||||
if not results:
|
||||
logger.info("Google search failed or empty, trying fallback search engines...")
|
||||
# Try Bing first if configured
|
||||
results = self._search_bing(query, limit)
|
||||
|
||||
# If Bing also failed, try DuckDuckGo (free, no API key needed)
|
||||
if not results:
|
||||
results = self._search_duckduckgo(query, limit)
|
||||
|
||||
return results
|
||||
|
||||
def _search_google(self, query: str, num_results: int, date_restrict: str = None) -> List[Dict[str, Any]]:
|
||||
"""Google Custom Search (CSE)."""
|
||||
global _google_quota_exhausted, _google_quota_reset_time
|
||||
|
||||
api_key = self._config.get('google', {}).get('api_key')
|
||||
cx = self._config.get('google', {}).get('cx')
|
||||
|
||||
@@ -71,17 +103,23 @@ class SearchService:
|
||||
params['dateRestrict'] = date_restrict
|
||||
|
||||
try:
|
||||
# logger.info(f"正在调用 Google Search API: q={query}, params={params}")
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
|
||||
# Check for quota exceeded (429)
|
||||
if response.status_code == 429:
|
||||
logger.warning("Google Search API quota exceeded (429). Switching to fallback search engines.")
|
||||
_google_quota_exhausted = True
|
||||
# Set reset time to next day midnight UTC
|
||||
import datetime
|
||||
tomorrow = datetime.datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1)
|
||||
_google_quota_reset_time = tomorrow.timestamp()
|
||||
return []
|
||||
|
||||
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({
|
||||
@@ -96,10 +134,20 @@ class SearchService:
|
||||
|
||||
return results
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if hasattr(e, 'response') and e.response is not None and e.response.status_code == 429:
|
||||
logger.warning("Google Search API quota exceeded. Switching to fallback.")
|
||||
_google_quota_exhausted = True
|
||||
import datetime
|
||||
tomorrow = datetime.datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1)
|
||||
_google_quota_reset_time = tomorrow.timestamp()
|
||||
else:
|
||||
logger.error(f"Google search failed: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
logger.error(f"Response: {e.response.text}")
|
||||
return []
|
||||
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]]:
|
||||
@@ -140,3 +188,120 @@ class SearchService:
|
||||
logger.error(f"Bing search failed: {e}")
|
||||
return []
|
||||
|
||||
def _search_duckduckgo(self, query: str, num_results: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
DuckDuckGo search (free, no API key required).
|
||||
Uses the DuckDuckGo HTML search endpoint.
|
||||
"""
|
||||
try:
|
||||
# Use DuckDuckGo Instant Answer API
|
||||
url = "https://api.duckduckgo.com/"
|
||||
params = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'no_html': 1,
|
||||
'skip_disambig': 1
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
results = []
|
||||
|
||||
# Get results from RelatedTopics
|
||||
related_topics = data.get('RelatedTopics', [])
|
||||
for topic in related_topics[:num_results]:
|
||||
if isinstance(topic, dict):
|
||||
if 'FirstURL' in topic:
|
||||
results.append({
|
||||
'title': topic.get('Text', '')[:100],
|
||||
'link': topic.get('FirstURL', ''),
|
||||
'snippet': topic.get('Text', ''),
|
||||
'source': 'DuckDuckGo',
|
||||
'published': ''
|
||||
})
|
||||
# Handle nested topics
|
||||
elif 'Topics' in topic:
|
||||
for sub_topic in topic['Topics']:
|
||||
if len(results) >= num_results:
|
||||
break
|
||||
if 'FirstURL' in sub_topic:
|
||||
results.append({
|
||||
'title': sub_topic.get('Text', '')[:100],
|
||||
'link': sub_topic.get('FirstURL', ''),
|
||||
'snippet': sub_topic.get('Text', ''),
|
||||
'source': 'DuckDuckGo',
|
||||
'published': ''
|
||||
})
|
||||
|
||||
# Also check AbstractURL and AbstractText
|
||||
if data.get('AbstractURL') and len(results) < num_results:
|
||||
results.insert(0, {
|
||||
'title': data.get('Heading', query),
|
||||
'link': data.get('AbstractURL', ''),
|
||||
'snippet': data.get('AbstractText', ''),
|
||||
'source': 'DuckDuckGo',
|
||||
'published': ''
|
||||
})
|
||||
|
||||
# If no results from Instant Answer, try HTML scraping as fallback
|
||||
if not results:
|
||||
results = self._search_duckduckgo_html(query, num_results)
|
||||
|
||||
if results:
|
||||
logger.info(f"DuckDuckGo search returned {len(results)} results")
|
||||
|
||||
return results[:num_results]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DuckDuckGo search failed: {e}")
|
||||
# Try HTML fallback
|
||||
return self._search_duckduckgo_html(query, num_results)
|
||||
|
||||
def _search_duckduckgo_html(self, query: str, num_results: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
DuckDuckGo HTML search fallback.
|
||||
Scrapes the lite HTML version for better results.
|
||||
"""
|
||||
try:
|
||||
url = "https://lite.duckduckgo.com/lite/"
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
data = {'q': query}
|
||||
|
||||
response = requests.post(url, headers=headers, data=data, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
results = []
|
||||
|
||||
# Simple HTML parsing without BeautifulSoup
|
||||
html = response.text
|
||||
|
||||
# Find all result links (they have class="result-link")
|
||||
import re
|
||||
|
||||
# Pattern to find result entries
|
||||
link_pattern = r'<a[^>]*class="result-link"[^>]*href="([^"]*)"[^>]*>([^<]*)</a>'
|
||||
snippet_pattern = r'<td[^>]*class="result-snippet"[^>]*>([^<]*)</td>'
|
||||
|
||||
links = re.findall(link_pattern, html)
|
||||
snippets = re.findall(snippet_pattern, html)
|
||||
|
||||
for i, (link, title) in enumerate(links[:num_results]):
|
||||
snippet = snippets[i] if i < len(snippets) else ''
|
||||
if link and title:
|
||||
results.append({
|
||||
'title': title.strip(),
|
||||
'link': link,
|
||||
'snippet': snippet.strip(),
|
||||
'source': 'DuckDuckGo',
|
||||
'published': ''
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DuckDuckGo HTML search failed: {e}")
|
||||
return []
|
||||
|
||||
@@ -52,29 +52,48 @@ class TradingExecutor:
|
||||
self._ensure_db_columns()
|
||||
|
||||
def _ensure_db_columns(self):
|
||||
"""确保必要的数据库字段存在"""
|
||||
"""确保必要的数据库字段存在(支持 SQLite 和 PostgreSQL)"""
|
||||
try:
|
||||
db_type = os.getenv('DB_TYPE', 'sqlite').lower()
|
||||
with get_db_connection() as db:
|
||||
cursor = db.cursor()
|
||||
col_names = set()
|
||||
|
||||
# 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 db_type == 'postgresql':
|
||||
# PostgreSQL: 使用 information_schema 查询列
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'qd_strategy_positions'
|
||||
""")
|
||||
cols = cursor.fetchall() or []
|
||||
col_names = {c.get('column_name') or c.get('COLUMN_NAME') for c in cols if isinstance(c, dict)}
|
||||
except Exception:
|
||||
col_names = set()
|
||||
else:
|
||||
# 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")
|
||||
logger.info(f"Adding highest_price column to qd_strategy_positions ({db_type})...")
|
||||
if db_type == 'postgresql':
|
||||
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN IF NOT EXISTS highest_price DOUBLE PRECISION DEFAULT 0")
|
||||
else:
|
||||
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")
|
||||
logger.info(f"Adding lowest_price column to qd_strategy_positions ({db_type})...")
|
||||
if db_type == 'postgresql':
|
||||
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN IF NOT EXISTS lowest_price DOUBLE PRECISION DEFAULT 0")
|
||||
else:
|
||||
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN lowest_price REAL DEFAULT 0")
|
||||
db.commit()
|
||||
logger.info("lowest_price column added")
|
||||
|
||||
|
||||
@@ -170,9 +170,14 @@ class UserService:
|
||||
(user['id'],)
|
||||
)
|
||||
db.commit()
|
||||
affected = cur.rowcount
|
||||
cur.close()
|
||||
if affected == 0:
|
||||
logger.error(f"Failed to update last_login_at: no rows affected for user_id={user['id']}")
|
||||
else:
|
||||
logger.info(f"Updated last_login_at for user_id={user['id']}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update last_login_at: {e}")
|
||||
logger.error(f"Failed to update last_login_at for user_id={user.get('id')}: {e}")
|
||||
|
||||
# Remove password_hash from return value
|
||||
user.pop('password_hash', None)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
-- Migration: Add notification_settings column to qd_users table
|
||||
-- Run this if you see error: column "notification_settings" does not exist
|
||||
|
||||
-- Add notification_settings column (if not exists)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'qd_users' AND column_name = 'notification_settings'
|
||||
) THEN
|
||||
ALTER TABLE qd_users ADD COLUMN notification_settings TEXT DEFAULT '';
|
||||
RAISE NOTICE 'Column notification_settings added to qd_users';
|
||||
ELSE
|
||||
RAISE NOTICE 'Column notification_settings already exists';
|
||||
END IF;
|
||||
END $$;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Global Market Dashboard API
|
||||
*/
|
||||
import request from '@/utils/request'
|
||||
|
||||
const BASE_URL = '/api/global-market'
|
||||
|
||||
/**
|
||||
* Get global market overview (indices, forex, crypto, commodities)
|
||||
* Includes geo coordinates for world map display
|
||||
*/
|
||||
export function getMarketOverview () {
|
||||
return request({
|
||||
url: `${BASE_URL}/overview`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get market heatmap data (crypto, stock sectors, forex)
|
||||
*/
|
||||
export function getMarketHeatmap () {
|
||||
return request({
|
||||
url: `${BASE_URL}/heatmap`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get financial news - separated by language (cn/en)
|
||||
* @param {string} lang - Language filter: 'cn', 'en', or 'all' (default)
|
||||
*/
|
||||
export function getMarketNews (lang = 'all') {
|
||||
return request({
|
||||
url: `${BASE_URL}/news`,
|
||||
method: 'get',
|
||||
params: { lang }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get economic calendar with impact indicators
|
||||
*/
|
||||
export function getEconomicCalendar () {
|
||||
return request({
|
||||
url: `${BASE_URL}/calendar`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get market sentiment (Fear & Greed Index, VIX)
|
||||
*/
|
||||
export function getMarketSentiment () {
|
||||
return request({
|
||||
url: `${BASE_URL}/sentiment`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trading opportunities based on technical analysis
|
||||
*/
|
||||
export function getTradingOpportunities () {
|
||||
return request({
|
||||
url: `${BASE_URL}/opportunities`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh all market data (clears cache)
|
||||
*/
|
||||
export function refreshMarketData () {
|
||||
return request({
|
||||
url: `${BASE_URL}/refresh`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,13 @@ export const asyncRouterMap = [
|
||||
component: () => import('@/views/dashboard'),
|
||||
meta: { title: 'menu.dashboard', keepAlive: true, icon: 'dashboard', permission: ['dashboard'] }
|
||||
},
|
||||
// 全球金融面板
|
||||
{
|
||||
path: '/global-market',
|
||||
name: 'GlobalMarket',
|
||||
component: () => import('@/views/global-market'),
|
||||
meta: { title: 'menu.dashboard.globalMarket', keepAlive: true, icon: 'global', permission: ['dashboard'] }
|
||||
},
|
||||
// AI 分析
|
||||
{
|
||||
path: '/ai-analysis/:pageNo([1-9]\\d*)?',
|
||||
@@ -44,13 +51,6 @@ export const asyncRouterMap = [
|
||||
component: () => import('@/views/portfolio'),
|
||||
meta: { title: 'menu.dashboard.portfolio', keepAlive: true, icon: 'fund', 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'] }
|
||||
},
|
||||
// 系统设置 (admin only)
|
||||
{
|
||||
path: '/settings',
|
||||
@@ -71,6 +71,13 @@ export const asyncRouterMap = [
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/profile'),
|
||||
meta: { title: 'menu.myProfile', keepAlive: false, icon: 'user', 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
|
||||
|
||||
@@ -26,9 +26,10 @@ const locale = {
|
||||
'menu.dashboard': 'Dashboard',
|
||||
'menu.dashboard.analysis': 'AI Analysis',
|
||||
'menu.dashboard.indicator': 'Indicator Analysis',
|
||||
'menu.dashboard.community': 'Indicator Community',
|
||||
'menu.dashboard.community': 'Official Community',
|
||||
'menu.dashboard.tradingAssistant': 'Trading Assistant',
|
||||
'menu.dashboard.portfolio': 'Portfolio',
|
||||
'menu.dashboard.globalMarket': 'Global Market',
|
||||
'menu.settings': 'Settings',
|
||||
'menu.dashboard.aiTradingAssistant': 'AI Trading Assistant',
|
||||
'menu.dashboard.signalRobot': 'Signal Robot',
|
||||
@@ -2353,6 +2354,8 @@ const locale = {
|
||||
'portfolio.form.deselectAll': 'Deselect All',
|
||||
'portfolio.form.selectedCount': '{count} of {total} selected',
|
||||
'portfolio.form.pleaseSelectPositions': 'Please select at least one position to monitor',
|
||||
'portfolio.form.notificationFromProfile': 'Notifications will be sent to addresses configured in your profile',
|
||||
'portfolio.form.goToProfile': 'Go to settings',
|
||||
'portfolio.message.loadFailed': 'Failed to load data',
|
||||
'portfolio.message.saveSuccess': 'Saved successfully',
|
||||
'portfolio.message.saveFailed': 'Failed to save',
|
||||
@@ -2581,7 +2584,64 @@ const locale = {
|
||||
'settings.desc.CREDITS_REFERRAL_BONUS': 'Credits awarded to referrer when someone signs up with their referral code',
|
||||
'settings.desc.VERIFICATION_CODE_MAX_ATTEMPTS': 'Maximum attempts to verify a code before lockout',
|
||||
'settings.desc.VERIFICATION_CODE_LOCK_MINUTES': 'Lockout duration after exceeding max attempts',
|
||||
'settings.desc.RECHARGE_TELEGRAM_URL': 'Telegram customer service URL for recharge inquiries'
|
||||
'settings.desc.RECHARGE_TELEGRAM_URL': 'Telegram customer service URL for recharge inquiries',
|
||||
|
||||
// Global Market
|
||||
'globalMarket.title': 'Global Market Dashboard',
|
||||
'globalMarket.lastUpdate': 'Last Update',
|
||||
'globalMarket.refresh': 'Refresh',
|
||||
'globalMarket.name': 'Name',
|
||||
'globalMarket.price': 'Price',
|
||||
'globalMarket.change': 'Change',
|
||||
'globalMarket.trend': 'Trend',
|
||||
'globalMarket.pair': 'Pair',
|
||||
'globalMarket.unit': 'Unit',
|
||||
'globalMarket.refreshSuccess': 'Data refreshed successfully',
|
||||
'globalMarket.refreshError': 'Failed to refresh data',
|
||||
'globalMarket.fetchError': 'Failed to fetch data',
|
||||
'globalMarket.loading': 'Loading...',
|
||||
'globalMarket.fearGreedIndex': 'Fear & Greed Index',
|
||||
'globalMarket.fearGreedTip': 'Fear & Greed Index measures market sentiment, 0 = Extreme Fear, 100 = Extreme Greed',
|
||||
'globalMarket.volatilityIndex': 'Volatility Index',
|
||||
'globalMarket.vixTitle': 'VIX Volatility Index',
|
||||
'globalMarket.vixTip': 'VIX measures expected market volatility, higher values indicate more market fear',
|
||||
'globalMarket.extremeFear': 'Extreme Fear',
|
||||
'globalMarket.fear': 'Fear',
|
||||
'globalMarket.neutral': 'Neutral',
|
||||
'globalMarket.greed': 'Greed',
|
||||
'globalMarket.extremeGreed': 'Extreme Greed',
|
||||
'globalMarket.marketOverview': 'Market Overview',
|
||||
'globalMarket.indices': 'Indices',
|
||||
'globalMarket.forex': 'Forex',
|
||||
'globalMarket.crypto': 'Crypto',
|
||||
'globalMarket.commodities': 'Commodities',
|
||||
'globalMarket.heatmap': 'Market Heatmap',
|
||||
'globalMarket.cryptoHeatmap': 'Crypto Heatmap',
|
||||
'globalMarket.sectorHeatmap': 'Sector Heatmap',
|
||||
'globalMarket.forexHeatmap': 'Forex Heatmap',
|
||||
'globalMarket.opportunities': 'Trading Opportunities',
|
||||
'globalMarket.noOpportunities': 'No significant opportunities detected',
|
||||
'globalMarket.financialNews': 'Financial News',
|
||||
'globalMarket.noNews': 'No news available',
|
||||
'globalMarket.economicCalendar': 'Economic Calendar',
|
||||
'globalMarket.noEvents': 'No events scheduled',
|
||||
'globalMarket.actual': 'Actual',
|
||||
'globalMarket.forecast': 'Forecast',
|
||||
'globalMarket.previous': 'Previous',
|
||||
'globalMarket.signal.bullish': 'Bullish Momentum',
|
||||
'globalMarket.signal.bearish': 'Bearish Momentum',
|
||||
'globalMarket.signal.overbought': 'Overbought Warning',
|
||||
'globalMarket.signal.oversold': 'Oversold Opportunity',
|
||||
'globalMarket.worldMap': 'Global Markets Map',
|
||||
'globalMarket.rising': 'Rising',
|
||||
'globalMarket.falling': 'Falling',
|
||||
'globalMarket.bullish': 'Bullish',
|
||||
'globalMarket.bearish': 'Bearish',
|
||||
'globalMarket.expectedImpact': 'Expected Impact',
|
||||
'globalMarket.aboveForecast': 'Above Forecast',
|
||||
'globalMarket.belowForecast': 'Below Forecast',
|
||||
'globalMarket.upcomingEvents': 'Upcoming Events',
|
||||
'globalMarket.releasedEvents': 'Released Data'
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -25,10 +25,11 @@ const locale = {
|
||||
'menu.home': '主页',
|
||||
'menu.dashboard': '仪表盘',
|
||||
'menu.dashboard.indicator': '指标分析',
|
||||
'menu.dashboard.community': '指标社区',
|
||||
'menu.dashboard.community': '官方社区',
|
||||
'menu.dashboard.analysis': 'AI 分析',
|
||||
'menu.dashboard.tradingAssistant': '交易助手',
|
||||
'menu.dashboard.portfolio': '资产监测',
|
||||
'menu.dashboard.globalMarket': '全球金融',
|
||||
'menu.settings': '系统设置',
|
||||
'menu.dashboard.aiTradingAssistant': 'AI交易助手',
|
||||
'menu.dashboard.signalRobot': '信号机器人',
|
||||
@@ -2163,6 +2164,8 @@ const locale = {
|
||||
'portfolio.form.deselectAll': '全不选',
|
||||
'portfolio.form.selectedCount': '已选 {count}/{total}',
|
||||
'portfolio.form.pleaseSelectPositions': '请至少选择一个持仓进行监控',
|
||||
'portfolio.form.notificationFromProfile': '通知将发送到您在个人中心配置的地址',
|
||||
'portfolio.form.goToProfile': '前往配置',
|
||||
'portfolio.message.loadFailed': '加载数据失败',
|
||||
'portfolio.message.saveSuccess': '保存成功',
|
||||
'portfolio.message.saveFailed': '保存失败',
|
||||
@@ -2391,7 +2394,64 @@ const locale = {
|
||||
'settings.desc.CREDITS_REFERRAL_BONUS': '用户通过邀请链接成功邀请新用户时,邀请人获得的积分奖励',
|
||||
'settings.desc.VERIFICATION_CODE_MAX_ATTEMPTS': '验证码验证失败的最大尝试次数,超过后将锁定',
|
||||
'settings.desc.VERIFICATION_CODE_LOCK_MINUTES': '验证码验证失败次数超过限制后的锁定时长(分钟)',
|
||||
'settings.desc.RECHARGE_TELEGRAM_URL': '用户点击充值时跳转的Telegram客服链接'
|
||||
'settings.desc.RECHARGE_TELEGRAM_URL': '用户点击充值时跳转的Telegram客服链接',
|
||||
|
||||
// Global Market
|
||||
'globalMarket.title': '全球金融面板',
|
||||
'globalMarket.lastUpdate': '最后更新',
|
||||
'globalMarket.refresh': '刷新数据',
|
||||
'globalMarket.name': '名称',
|
||||
'globalMarket.price': '价格',
|
||||
'globalMarket.change': '涨跌',
|
||||
'globalMarket.trend': '走势',
|
||||
'globalMarket.pair': '货币对',
|
||||
'globalMarket.unit': '单位',
|
||||
'globalMarket.refreshSuccess': '数据刷新成功',
|
||||
'globalMarket.refreshError': '数据刷新失败',
|
||||
'globalMarket.fetchError': '获取数据失败',
|
||||
'globalMarket.loading': '加载中...',
|
||||
'globalMarket.fearGreedIndex': '恐惧贪婪指数',
|
||||
'globalMarket.fearGreedTip': '恐惧贪婪指数衡量市场情绪,0表示极度恐惧,100表示极度贪婪',
|
||||
'globalMarket.volatilityIndex': '波动率指数',
|
||||
'globalMarket.vixTitle': 'VIX 波动率指数',
|
||||
'globalMarket.vixTip': 'VIX指数衡量市场预期波动率,数值越高表示市场越恐慌',
|
||||
'globalMarket.extremeFear': '极度恐惧',
|
||||
'globalMarket.fear': '恐惧',
|
||||
'globalMarket.neutral': '中性',
|
||||
'globalMarket.greed': '贪婪',
|
||||
'globalMarket.extremeGreed': '极度贪婪',
|
||||
'globalMarket.marketOverview': '全球市场概览',
|
||||
'globalMarket.indices': '主要指数',
|
||||
'globalMarket.forex': '外汇市场',
|
||||
'globalMarket.crypto': '加密货币',
|
||||
'globalMarket.commodities': '大宗商品',
|
||||
'globalMarket.heatmap': '市场热力图',
|
||||
'globalMarket.cryptoHeatmap': '加密货币热力图',
|
||||
'globalMarket.sectorHeatmap': '板块热力图',
|
||||
'globalMarket.forexHeatmap': '外汇热力图',
|
||||
'globalMarket.opportunities': '交易机会',
|
||||
'globalMarket.noOpportunities': '暂无明显交易机会',
|
||||
'globalMarket.financialNews': '财经资讯',
|
||||
'globalMarket.noNews': '暂无新闻',
|
||||
'globalMarket.economicCalendar': '财经日历',
|
||||
'globalMarket.noEvents': '暂无事件',
|
||||
'globalMarket.actual': '实际',
|
||||
'globalMarket.forecast': '预期',
|
||||
'globalMarket.previous': '前值',
|
||||
'globalMarket.signal.bullish': '看涨趋势',
|
||||
'globalMarket.signal.bearish': '看跌趋势',
|
||||
'globalMarket.signal.overbought': '超买警告',
|
||||
'globalMarket.signal.oversold': '超卖机会',
|
||||
'globalMarket.worldMap': '全球市场地图',
|
||||
'globalMarket.rising': '上涨',
|
||||
'globalMarket.falling': '下跌',
|
||||
'globalMarket.bullish': '利多',
|
||||
'globalMarket.bearish': '利空',
|
||||
'globalMarket.expectedImpact': '预期影响',
|
||||
'globalMarket.aboveForecast': '高于预期',
|
||||
'globalMarket.belowForecast': '低于预期',
|
||||
'globalMarket.upcomingEvents': '即将公布',
|
||||
'globalMarket.releasedEvents': '已公布数据'
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -727,9 +727,9 @@
|
||||
>
|
||||
<template #message>
|
||||
<span>
|
||||
{{ $t('portfolio.form.notificationFromProfile') || '通知将发送到您在个人中心配置的地址' }}
|
||||
{{ $t('portfolio.form.notificationFromProfile') }}
|
||||
<router-link to="/profile" style="margin-left: 8px">
|
||||
<a-icon type="setting" /> {{ $t('portfolio.form.goToProfile') || '前往配置' }}
|
||||
<a-icon type="setting" /> {{ $t('portfolio.form.goToProfile') }}
|
||||
</router-link>
|
||||
</span>
|
||||
</template>
|
||||
@@ -806,9 +806,9 @@
|
||||
>
|
||||
<template #message>
|
||||
<span>
|
||||
{{ $t('portfolio.form.notificationFromProfile') || '通知将发送到您在个人中心配置的地址' }}
|
||||
{{ $t('portfolio.form.notificationFromProfile') }}
|
||||
<router-link to="/profile" style="margin-left: 8px">
|
||||
<a-icon type="setting" /> {{ $t('portfolio.form.goToProfile') || '前往配置' }}
|
||||
<a-icon type="setting" /> {{ $t('portfolio.form.goToProfile') }}
|
||||
</router-link>
|
||||
</span>
|
||||
</template>
|
||||
@@ -836,15 +836,21 @@
|
||||
:key="pos.id"
|
||||
class="position-checkbox-item"
|
||||
>
|
||||
<a-checkbox :value="pos.id">
|
||||
<span class="position-checkbox-label">
|
||||
<a-tag :color="getMarketColor(pos.market)" size="small">{{ pos.market }}</a-tag>
|
||||
<span class="symbol">{{ pos.symbol }}</span>
|
||||
<span class="name">{{ pos.name }}</span>
|
||||
<span :class="['pnl', pos.pnl >= 0 ? 'positive' : 'negative']">
|
||||
{{ pos.pnl >= 0 ? '+' : '' }}{{ formatNumber(pos.pnl_percent) }}%
|
||||
</span>
|
||||
</span>
|
||||
<a-checkbox :value="pos.id" class="position-checkbox">
|
||||
<div class="position-checkbox-label">
|
||||
<div class="position-left">
|
||||
<a-tag :color="getMarketColor(pos.market)" size="small">{{ pos.market }}</a-tag>
|
||||
<span class="symbol">{{ pos.symbol }}</span>
|
||||
</div>
|
||||
<div class="position-middle">
|
||||
<span class="name" :title="pos.name">{{ pos.name }}</span>
|
||||
</div>
|
||||
<div class="position-right">
|
||||
<span :class="['pnl', pos.pnl >= 0 ? 'positive' : 'negative']">
|
||||
{{ pos.pnl >= 0 ? '+' : '' }}{{ formatNumber(pos.pnl_percent) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-checkbox>
|
||||
</div>
|
||||
</a-checkbox-group>
|
||||
@@ -2615,34 +2621,64 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.position-checkbox {
|
||||
width: 100%;
|
||||
|
||||
// Override Ant Design checkbox label width
|
||||
::v-deep .ant-checkbox + span {
|
||||
width: calc(100% - 24px);
|
||||
padding-left: 8px;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.position-checkbox-label {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
|
||||
.symbol {
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
min-width: 60px;
|
||||
.position-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.symbol {
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
min-width: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
.position-middle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 120px;
|
||||
|
||||
.name {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.pnl {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
.position-right {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
|
||||
&.positive { color: @green; }
|
||||
&.negative { color: @red; }
|
||||
.pnl {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
|
||||
&.positive { color: @green; }
|
||||
&.negative { color: @red; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2676,8 +2712,8 @@ export default {
|
||||
}
|
||||
|
||||
.position-checkbox-label {
|
||||
.symbol { color: #d1d4dc; }
|
||||
.name { color: #868993; }
|
||||
.position-left .symbol { color: #d1d4dc; }
|
||||
.position-middle .name { color: #868993; }
|
||||
}
|
||||
|
||||
.position-select-actions {
|
||||
|
||||
@@ -171,8 +171,12 @@
|
||||
v-decorator="[item.key, { initialValue: getFieldValue(groupKey, item.key) || item.default }]"
|
||||
:placeholder="item.default ? `${$t('settings.default')}: ${item.default}` : $t('settings.pleaseSelect')"
|
||||
>
|
||||
<a-select-option v-for="opt in item.options" :key="opt" :value="opt">
|
||||
{{ opt }}
|
||||
<a-select-option
|
||||
v-for="opt in getSelectOptions(item.options)"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
@@ -249,6 +253,21 @@ export default {
|
||||
this.loadSettings()
|
||||
},
|
||||
methods: {
|
||||
// 兼容后端 schema options 两种格式:
|
||||
// - string[]: ['openrouter','openai', ...]
|
||||
// - {value,label}[]: [{value:'openrouter',label:'OpenRouter'}, ...]
|
||||
getSelectOptions (options) {
|
||||
const arr = Array.isArray(options) ? options : []
|
||||
return arr.map(opt => {
|
||||
if (opt && typeof opt === 'object') {
|
||||
return {
|
||||
value: opt.value != null ? String(opt.value) : '',
|
||||
label: opt.label != null ? String(opt.label) : String(opt.value || '')
|
||||
}
|
||||
}
|
||||
return { value: String(opt), label: String(opt) }
|
||||
}).filter(o => o.value !== '')
|
||||
},
|
||||
async loadSettings () {
|
||||
this.loading = true
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user