diff --git a/.env.local b/.env.local
deleted file mode 100644
index 738c898..0000000
--- a/.env.local
+++ /dev/null
@@ -1,32 +0,0 @@
-ALPHA_VANTAGE_API_KEY=AS8WUEVDUVY5NKTL
-OPENAI_API_KEY=sk-5AbAcZOk3jjiYYKBAw7d7svJ4pYODq9MtESVg6lQsAhrIhm0
-OPENAI_BASE_URL=https://api.bstester.com/v1
-DEEP_THINK_LLM=gemini-2.5-pro
-QUICK_THINK_LLM=gemini-2.5-flash
-EMBEDDING_LLM=text-embedding-v4
-EMBEDDING_KEY=sk-5AbAcZOk3jjiYYKBAw7d7svJ4pYODq9MtESVg6lQsAhrIhm0
-
-# ============================================
-# 本地开发配置
-# ============================================
-# 后端地址(本地开发)
-BACKEND_URL=http://localhost:8000
-
-# ============================================
-# Next.js 配置
-# ============================================
-# API 基础地址(本地开发)
-NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
-
-# ============================================
-# 数据库配置 - SQLite(本地开发推荐)
-# ============================================
-# SQLite 本地文件数据库
-# 使用异步驱动(aiosqlite),代码会自动转换为同步驱动用于后台任务
-DATABASE_URL=sqlite+aiosqlite:///./db/tradingagents.db
-
-# ============================================
-# 运行环境
-# ============================================
-NODE_ENV=development
-SECRET_KEY=p3y6uLQ4ZpQ9P7P3yqfVYHkB6w4Lk7xY1dQpM2nR8aS9zT1vC5mJ8rW2bE6uN0cF3
diff --git a/.gitignore b/.gitignore
index bd53394..32472e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,7 +7,11 @@ __pycache__/
eval_results/
eval_data/
*.egg-info/
+# Local environment files
.env
+.env.local
+.env.*
+!.env.example
node_modules
.kiro
.vscode
diff --git a/devops/.env.example b/devops/.env.example
new file mode 100644
index 0000000..eece4c7
--- /dev/null
+++ b/devops/.env.example
@@ -0,0 +1,36 @@
+# ============================================
+# TradingAgents Web — Docker deployment env template
+# Copy to .env (next to docker-compose.yml) and fill in real values.
+# cp .env.docker.example .env
+# NEVER commit a real .env — it is gitignored.
+# ============================================
+
+# ---------- AI provider (required) ----------
+# OneInfinity AI (OpenAI-compatible). Defaults match tradingagents/default_config.py.
+OPENAI_API_KEY=sk-replace-with-real-key
+OPENAI_BASE_URL=https://api.oneinfinityai.com/v1
+DEEP_THINK_LLM=gpt-5.5
+QUICK_THINK_LLM=gpt-5.5
+LLM_PROVIDER=openai
+EMBEDDING_LLM=text-embedding-3-small
+# EMBEDDING_API_KEY defaults to OPENAI_API_KEY if unset
+# EMBEDDING_BASE_URL defaults to OPENAI_BASE_URL if unset
+
+# ---------- Database (optional) ----------
+# Default: SQLite file at /app/db/tradingagents.db (inside the backend container,
+# persisted via the db_data volume). No DB service needed.
+DATABASE_URL=sqlite+aiosqlite:///./db/tradingagents.db
+
+# To use MySQL instead, uncomment the mysql service in docker-compose.yml and set:
+# DATABASE_URL=mysql+aiomysql://tradingagents:tradingagents123@mysql:3306/tradingagents?charset=utf8mb4
+# MYSQL_ROOT_PASSWORD=tradingagents123
+# MYSQL_DATABASE=tradingagents
+# MYSQL_USER=tradingagents
+# MYSQL_PASSWORD=tradingagents123
+
+# ---------- Data source API keys (optional — skills layer falls back gracefully) ----------
+ALPHA_VANTAGE_API_KEY=
+XUEQIU_TOKEN=
+
+# ---------- Frontend (optional — composed sets this automatically) ----------
+# FRONTEND_API_BASE_URL is set in docker-compose.yml; no need to set here.
diff --git a/devops/Dockerfile.backend b/devops/Dockerfile.backend
new file mode 100644
index 0000000..caae8a3
--- /dev/null
+++ b/devops/Dockerfile.backend
@@ -0,0 +1,93 @@
+# ============================================
+# TradingAgents Backend Dockerfile (FastAPI / uvicorn)
+# Multi-stage, non-root, with healthcheck.
+# Context: repo root (so tradingagents/, web/backend/, cli/, etc. are COPYable).
+# ============================================
+
+# ---------- Stage 1: builder ----------
+FROM python:3.11-slim AS builder
+
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PIP_DISABLE_PIP_VERSION_CHECK=1
+
+# Build deps: gcc/g++ for C extensions, curl for healthcheck probe in runtime.
+# Use debian mirrors (no aliyun override — keeps image portable across regions).
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ gcc \
+ g++ \
+ build-essential \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /build
+
+# Install Python dependencies first (layer cache friendly).
+# `ta-lib` is listed in requirements.txt but is OPTIONAL at runtime —
+# akshare_indicator.py imports it inside try/except with a pandas fallback.
+# The TA-Lib C library is painful to build in a slim image and brings no
+# functional value over the pandas fallback for this app, so we filter it out.
+COPY requirements.txt pyproject.toml setup.py ./
+RUN grep -v -E '^(ta-lib|\s*ta-lib)\b' requirements.txt > /tmp/requirements.filtered.txt \
+ && pip install --upgrade pip \
+ && pip install -r /tmp/requirements.filtered.txt \
+ && pip install "uvicorn[standard]" websockets
+
+# ---------- Stage 2: runtime ----------
+FROM python:3.11-slim AS runtime
+
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ NODE_ENV=production \
+ TZ=Asia/Shanghai
+
+# Runtime system deps: curl for healthcheck, tzdata for Asia/Shanghai,
+# libstdc++6 / libgomp1 for numpy/pandas/etc.
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl \
+ ca-certificates \
+ tzdata \
+ libstdc++6 \
+ libgomp1 \
+ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
+ && rm -rf /var/lib/apt/lists/*
+
+# Non-root user
+RUN groupadd --system app && useradd --system --gid app --create-home --home-dir /home/app app
+
+WORKDIR /app
+
+# Copy installed Python packages from builder
+COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
+COPY --from=builder /usr/local/bin /usr/local/bin
+
+# Copy application source (matches what app.py expects on sys.path)
+COPY tradingagents/ ./tradingagents/
+COPY web/backend/ ./web/backend/
+COPY web/__init__.py ./web/
+COPY cli/ ./cli/
+COPY main.py ./
+COPY .env.example ./.env.example
+
+# Create writable dirs for SQLite db, eval results, results, and web static/templates
+RUN mkdir -p /app/db /app/eval_results /app/results /app/web/static /app/web/templates \
+ && chown -R app:app /app
+
+USER app
+
+EXPOSE 8000
+
+# Healthcheck: hit /api/config (unauthenticated public config endpoint,
+# returns 200 JSON). The root `/` route renders a Jinja template via an old
+# Starlette API (TemplateResponse(name, context)) that breaks on Starlette
+# 1.x — that's a backend bug, not a Docker issue, and doesn't affect the
+# real frontend (nginx serves the static export). /api/config is the
+# cheapest unauthenticated liveness probe that actually works.
+HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=5 \
+ CMD curl -fsS http://127.0.0.1:8000/api/config || exit 1
+
+# Run via uvicorn module form (matches `python web/backend/app.py` behaviour
+# but gives cleaner signal handling under container runtimes).
+CMD ["uvicorn", "web.backend.app:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
diff --git a/devops/Dockerfile.frontend b/devops/Dockerfile.frontend
new file mode 100644
index 0000000..f776c9b
--- /dev/null
+++ b/devops/Dockerfile.frontend
@@ -0,0 +1,48 @@
+# ============================================
+# TradingAgents Frontend Dockerfile
+# Multi-stage: build Next.js static export, serve via Nginx.
+# Context: ./web/frontend (so package.json, nginx.conf.template, etc. are COPYable).
+# ============================================
+
+# ---------- Stage 1: deps ----------
+FROM node:20-alpine AS deps
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci --no-audit --no-fund
+
+# ---------- Stage 2: builder ----------
+FROM node:20-alpine AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+
+ENV NODE_ENV=production \
+ NEXT_TELEMETRY_DISABLED=1
+
+# `next build` with `output: 'export'` (next.config.ts) writes static files to ./out
+RUN npm run build
+
+# ---------- Stage 3: runtime (nginx) ----------
+FROM nginx:alpine AS runtime
+
+# envsubst for the nginx config template (FRONTEND_API_BASE_URL substitution)
+RUN apk add --no-cache gettext curl
+
+# Static export output -> nginx docroot
+COPY --from=builder /app/out /usr/share/nginx/html
+
+# nginx config template + start script (envsubst on container start)
+COPY nginx.conf.template /etc/nginx/nginx.conf.template
+COPY start.sh /usr/local/bin/start.sh
+RUN sed -i 's/\r$//' /usr/local/bin/start.sh && chmod +x /usr/local/bin/start.sh
+
+# Default backend URL inside the docker network; override via env at runtime.
+ENV FRONTEND_API_BASE_URL="http://backend:8000"
+
+EXPOSE 80
+
+# Healthcheck: nginx root should return 200 (static index.html).
+HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=5 \
+ CMD curl -fsS http://127.0.0.1/ || exit 1
+
+CMD ["/usr/local/bin/start.sh"]
diff --git a/devops/deployment-report.md b/devops/deployment-report.md
new file mode 100644
index 0000000..8632f81
--- /dev/null
+++ b/devops/deployment-report.md
@@ -0,0 +1,365 @@
+# Deployment Report — TradingAgents Web (WS-4 Stage 6)
+
+**Issue**: WS-4 (`dd0da1e3-a687-480f-8738-97dde85a2295`)
+**Stage**: 6 (DevOps)
+**QA Gate**: Go-With-Risk (conditional) — see [Known Risks](#known-risks)
+**Integrated code**: `ws-4-devops-stage6` = `ws-4-m5-m6-backend-hardening` ⊕ `ws-4-m3-frontend` ⊕ `devops/`, **plus** `ws-4-qa-rework` (`1e40081`) — BUG-005 PDF + BUG-006 skill-timeout/degradation + BUG-006 dispatch-race fix + BUG-007 template signature
+**Date**: 2026-07-06 (Part 1, Part 2, final verification)
+
+---
+
+## 1. Overview
+
+This report covers Docker packaging for the full TradingAgents Web stack after
+the QA Go-With-Risk gate. The stack is:
+
+- **Frontend**: Next.js 15 static export (`output: 'export'`) served by Nginx
+ (static files + `/api` and `/ws` reverse proxy to backend).
+- **Backend**: FastAPI / uvicorn on port 8000. Scheduler (APScheduler) runs
+ in-process inside the backend via FastAPI lifespan — **no separate scheduler
+ container needed**.
+- **Database**: SQLite by default (file-based, persisted via Docker volume).
+ MySQL is supported as an optional profile (uncomment in compose).
+- **AI provider**: OneInfinity AI (OpenAI-compatible), model `gpt-5.5`,
+ base URL `https://api.oneinfinityai.com/v1`. Configured via env vars; the
+ defaults in `tradingagents/default_config.py` already match.
+
+**Old trading/leaderboard services are NOT packaged** — those code paths were
+removed in M0. The retired `/intraday-trading` and `/leaderboard` page routes
+308-redirect to `/`.
+
+---
+
+## 2. Docker Artifacts
+
+All artifacts are in `devops/` in the repo (on branch `ws-4-devops-stage6`):
+
+| File | Purpose |
+|---|---|
+| `devops/Dockerfile.backend` | Multi-stage FastAPI/uvicorn image. Non-root (`app` user), healthcheck on `/`, filters out optional `ta-lib` (has pandas fallback). |
+| `devops/Dockerfile.frontend` | Multi-stage Next.js static export → Nginx. Reuses existing `nginx.conf.template` + `start.sh` (envsubst). Healthcheck on `/`. |
+| `devops/docker-compose.yml` | `backend` + `frontend` services. SQLite default. Volumes for db/eval/results. Optional MySQL profile (commented). |
+| `devops/.env.example` | Env template — copy to `.env`, fill in `OPENAI_API_KEY`. |
+| `devops/rollback-plan.md` | Rollback procedures (image/git/stop-restore) + data backup. |
+| `devops/deployment-report.md` | This file. |
+
+### Design decisions
+
+- **Multi-stage builds**: backend splits builder (gcc/g++ for C extensions) from
+ runtime (curl + libstdc++6 only); frontend splits deps → builder → nginx.
+- **Non-root**: backend runs as `app` user (UID auto-assigned by `useradd --system`).
+ Frontend nginx master runs as root (standard nginx pattern for port 80) —
+ workers run as `nginx` user. A future improvement could use
+ `nginxinc/nginx-unprivileged` on port 8080.
+- **Healthchecks**: backend probes `http://127.0.0.1:8000/` (FastAPI root route,
+ returns 200 HTML, no auth). Frontend probes `http://127.0.0.1/`. The existing
+ `web/backend/health.py` module defines `/health`, `/health/ready`, `/health/live`
+ endpoints but the router is **not mounted** in `app.py` — recommend wiring it
+ up as a follow-up for a cleaner health endpoint.
+- **`ta-lib` filtered out**: `requirements.txt` lists `ta-lib` (Python wrapper
+ for the TA-Lib C library), but `tradingagents/dataflows/akshare_indicator.py:276`
+ imports it inside `try/except ImportError` with a pandas fallback. The C library
+ is painful to build in a slim image and brings no functional value, so the
+ Dockerfile filters it out during `pip install`.
+- **No secrets in images**: `OPENAI_API_KEY` is passed via env var at runtime
+ (required — compose fails fast if unset). `.env` is gitignored.
+
+---
+
+## 3. Deployment Steps
+
+### Prerequisites
+
+- Docker Engine 24+ with Docker Compose v2+
+- Network access to pull base images (`python:3.11-slim`, `node:20-alpine`,
+ `nginx:alpine`) — **if Docker Hub is unreachable, configure a registry
+ mirror** in `/etc/docker/daemon.json`:
+ ```json
+ { "registry-mirrors": ["https://docker.m.daocloud.io"] }
+ ```
+ then `sudo systemctl restart docker`. This deployment was verified with the
+ DaoCloud mirror on a Docker Hub-blocked host.
+- A valid OneInfinity AI API key (OpenAI-compatible).
+
+### Steps
+
+```bash
+# 1. Clone and switch to the integrated branch
+git clone https://github.com/BSTester/TradingAgentsWeb.git
+cd TradingAgentsWeb
+git checkout ws-4-devops-stage6 # or main after PR merge
+
+# 2. Configure environment
+cd devops
+cp .env.example .env
+$EDITOR .env # set OPENAI_API_KEY=sk-...
+
+# 3. Build and start
+docker compose up --build -d
+
+# 4. Wait for health
+docker compose ps # both services should show "healthy"
+curl -fsS http://localhost:8080/ # backend direct
+curl -fsS http://localhost:8000/ # frontend (nginx)
+
+# 5. Access the app
+# Public URL: http://localhost:8000 (frontend + /api proxy)
+# Backend API: http://localhost:8080 (direct, for debugging)
+```
+
+### Port mapping
+
+| Port | Service | Notes |
+|---|---|---|
+| 8000 (host) | frontend nginx | Public entry point. Serves static + proxies `/api` and `/ws` to backend. |
+| 8080 (host) | backend uvicorn | Direct backend access for debugging. Can be removed in production. |
+
+---
+
+## 4. Part 1 Verification — Docker Build & Service Startup
+
+**Status: PASS ✅** (verified on this host — raspberry pi + restricted network, with DaoCloud mirror)
+
+### Build
+
+| Image | Size | Build time | Notes |
+|---|---|---|---|
+| `devops-backend` | 1.57 GB | ~28 min | Slow due to pip downloads over restricted network (~250 kB/s). Cached on subsequent builds. |
+| `devops-frontend` | 102 MB | ~2 min | Next.js static export + nginx:alpine. |
+
+Both images built successfully via `docker compose build`.
+
+### Service startup
+
+```
+$ docker compose ps
+NAME IMAGE STATUS PORTS
+tagents-backend devops-backend Up (healthy) 0.0.0.0:8080->8000/tcp
+tagents-frontend devops-frontend Up (healthy) 0.0.0.0:8000->80/tcp
+```
+
+Backend lifespan completed cleanly: `init_db` → auto-migrations (2 applied) →
+scheduler started → task monitor started → `Application startup complete`.
+
+### Endpoint verification (10 checks)
+
+| # | Check | Expected | Actual | Pass |
+|---|---|---|---|---|
+| 1 | `GET :8000/` (frontend static) | 200 | 200, 6785 bytes | ✅ |
+| 2 | Frontend `
` | TradingAgentsWeb | `TradingAgentsWeb - 多智能体大语言模型金融交易框架` | ✅ |
+| 3 | `GET :8000/api/config` (nginx → backend proxy) | 200 JSON | 200, analysts list returned | ✅ |
+| 4 | `GET :8080/api/config` (backend direct) | 200 | 200 | ✅ |
+| 5 | `GET :8080/api/skills/health` (auth required) | 401 | 401 | ✅ |
+| 6 | `GET :8080/intraday-trading` (retired route) | 308 → `/` | 308 → `/` | ✅ |
+| 7 | `GET :8080/leaderboard` (retired route) | 308 → `/` | 308 → `/` | ✅ |
+| 8 | `GET :8080/api/intraday/foo` (removed API) | 404 | 404 | ✅ |
+| 9 | `GET :8080/api/leaderboard` (removed API) | 404 | 404 | ✅ |
+| 10 | `GET :8080/ws/conversation/test` (WS endpoint exists) | non-200 (WS-only) | 404 (HTTP GET not upgraded) | ✅ |
+
+**Healthcheck**: backend probes `http://127.0.0.1:8000/api/config` (200 JSON,
+unauthenticated). Frontend probes `http://127.0.0.1/` (200 static).
+
+### New bug found during verification (BUG-007, P2)
+
+The backend's root route `GET /` (`page_routes.py:20`) returns **500 Internal
+Server Error** due to a Starlette 1.x compatibility issue:
+
+```python
+# page_routes.py:20 — OLD Starlette API, broken on Starlette 1.x
+return templates.TemplateResponse("index.html", {"request": request})
+```
+
+Starlette 1.x changed `TemplateResponse` signature to
+`TemplateResponse(request, name, context=None)`. The old call passes a `dict`
+as the second positional arg, which `get_template()` tries to use as a cache
+key → `TypeError: unhashable type: 'dict'`.
+
+**Impact**: Does NOT affect the deployed app — nginx serves the Next.js static
+export for `/`, and the backend's `/` route is vestigial (old server-rendered
+template). All real API endpoints (`/api/*`, `/ws/*`) work correctly.
+
+**Fix** (for backend engineer): change to `templates.TemplateResponse(request, "index.html")`.
+
+This is a non-blocking P2 — flagged here for the backend rework PR.
+
+---
+
+## 5. Part 2 — Full-Chain Verification (QA Release Condition 1)
+
+**Status: COMPLETED.** Merged `ws-4-qa-rework` (BUG-005/006/007 fixes) into
+the integrated branch, rebuilt the backend, and drove the conversation→analysis
+flow end-to-end over `/ws/conversation/{id}` for a US ticker and an HK ticker.
+
+### 5.1 Reproduced BUG-006 → root cause ≠ skill layer (P0)
+
+With the BUG-006 skill-timeout fix in place, the conversation-triggered analysis
+**still hung at progress 0.0% with zero stage events** for 540 s (US run). Per
+the Dev Lead's dispatch, a confirmed 0% hang is a P0. Deep investigation
+(thread dump + DB + logs) pinpointed the real root cause — **it is not the
+Skills data-source layer the BUG-006 fix targeted**:
+
+- `conversation_routes.create_message()` calls `_trigger_analysis()` →
+ `task_manager.submit_task()` which immediately starts `run_analysis_task`
+ on a worker thread, **and only then** runs `await db.commit()`
+ (`conversation_routes.py:325` submit, `:326` commit).
+- `run_analysis_task` opens a **separate** `SessionLocal()`
+ (`analysis_task.py:277`) and queries the `AnalysisRecord` immediately
+ (`:298`). Because the creating transaction has not committed yet, the row
+ is invisible → the worker logs `❌ 分析记录未找到: ` and aborts.
+- Result: the record stays `status=running, progress=0.0, started_at=NULL`
+ forever; no `stage_*` event is ever emitted; the conversation hangs silently.
+- py-spy confirmed the `ThreadPoolExecutor` worker was **idle** (task already
+ exited, not stuck mid-stage) — so the skill-timeout fix could not help.
+
+**Fix (verified locally by DevOps):** commit the `AnalysisRecord` **before**
+dispatching the task. One-line change in `_trigger_analysis`, after the
+`await db.flush()` at `conversation_routes.py:184`:
+
+```diff
+ await db.flush()
++ # Commit BEFORE submit: run_analysis_task uses a separate SessionLocal()
++ # and queries this record immediately; dispatch-before-commit makes the
++ # row invisible -> "分析记录未找到" -> task aborts -> 0% hang (BUG-006).
++ await db.commit()
+
+ request_data = { ... }
+```
+
+This fix is **backend M2 code** and is NOT committed to the DevOps branch — it
+belongs on `ws-4-qa-rework`. After applying it locally and rebuilding, the
+silent hang was resolved (see 5.2). **Action: backend engineer to land this
+one-line commit on `ws-4-qa-rework`.**
+
+### 5.2 Full-chain result with the verified fix (US + HK)
+
+After the fix, the conversation→analysis flow runs and streams events — no
+silent 0% hang. Both markets verified:
+
+| Ticker | Market | Skill data fetch | LLM step | Terminal event | `report_ready` |
+|---|---|---|---|---|---|
+| `AAPL` | US | `get_stock_data` → AKShare `stock_us_daily` returned data ✅; `get_realtime_quote`/`get_indicators` degraded gracefully | "AI 深度思考" ~3 min | `stage_error` + `error` (network) | ❌ (LLM blocked — see 5.3) |
+| `00700.HK` | HK | `get_stock_data` → AKShare `stock_hk_` returned data ✅; per-skill errors degraded | "AI 深度思考" ~3 min | `stage_error` + `error` (network) | ❌ (LLM blocked) |
+
+**What this proves:**
+- The dispatch race was the real BUG-006; the fix resolves the silent hang. ✅
+- The BUG-006 resilience pattern works at the task level: failed analyses now
+ terminate with `stage_error`/`error` and record `status=failed` (visible in
+ `/api/reports`, retryable), **not** stuck at `running`/0%. QA gate 2
+ ("消除 running 0% 无事件的静默挂起") is satisfied. ✅
+- Skills execute and the project's headline 美股/港股/A股 data coverage works
+ at the data layer (AKShare returns both US and HK series). ✅
+
+### 5.3 New blocker — LLM endpoint unreachable from this egress (environment, not code)
+
+A live `report_ready` could **not** be reached because the LLM endpoint
+`https://api.oneinfinityai.com/v1/chat/completions` (model `gpt-5.5`) returns
+**HTTP 403 / Cloudflare error 1010** ("the owner of this website has banned
+your access based on your browser's signature") from this deployment's egress
+(verified directly from the backend container: `/models` → 403,
+`/chat/completions` → `403 error code: 1010` in <1 s). The "AI 深度思考" stage
+then fails with `stage_error: 网络连接失败`, which is the correct graceful
+behavior — but it caps the chain short of a finished report.
+
+This is **environment/network-specific, not a code defect**: the app behaves
+correctly (graceful degradation + `stage_error`). Required follow-ups:
+1. Verify `api.oneinfinityai.com` reachability **from the production egress**
+ (this Raspberry-Pi/datacenter IP appears Cloudflare-flagged).
+2. If 403/1010 reproduces in production, OneInfinity is blocking server-side
+ (non-browser) callers — the user must supply a server-accessible
+ OpenAI-compatible endpoint/key, or whitelist the deploy egress.
+
+### 5.4 Report export verification (BUG-005)
+
+Exports were verified on a synthetic completed `AnalysisRecord` (the LLM block
+prevents a real one; this exercises the export code paths BUG-005 changed):
+
+| Format | Endpoint | HTTP | Content-Type | Body | Pass |
+|---|---|---|---|---|---|
+| MD | `/api/reports/{id}/export?format=md` | 200 | `text/markdown; charset=utf-8` | valid markdown (评级 3/5, sections) | ✅ |
+| JSON | `/api/reports/{id}/export?format=json` | 200 | `application/json` | full structured keys (sections, stage_log, reflection, conclusion) | ✅ |
+| PDF | `/api/reports/{id}/export?format=pdf` | 200 | `application/pdf` | `%PDF-1.4`, 1692 B, **no "pending M6" placeholder** | ✅ |
+
+**BUG-005 is fixed**: PDF export now returns a real `application/pdf` binary
+(was a placeholder JSON). Report-card detail (`/api/reports/{id}`) renders with
+sections / stage_log / reflection.
+
+### 5.5 Final verification on official dispatch-fix build (`ws-4-qa-rework` `1e40081`)
+
+Backend landed the dispatch race fix as commit `1e40081`
+(`flush → commit → submit_task`, with AST test
+`tests/test_conversation_dispatch_commit.py`). Re-merged into
+`ws-4-devops-stage6`, rebuilt backend, and re-ran the full chain on the
+**official** fix (not the DevOps local patch):
+
+| Ticker | Market | Stage events | Progress reached | Terminal | `report_ready` |
+|---|---|---|---|---|---|
+| `AAPL` | US | full chain (task start → graph init → `stage_start` → market → skills) | **10%** (market data stage) | `stage_error` (LLM network) | ❌ env only |
+| `00700.HK` | HK | full chain (HK market detected, skills executed) | **10%** | `stage_error` (LLM network) | ❌ env only |
+
+**No 0% silent hang.** DB confirms the before/after contrast directly:
+
+| Run | Build | `status` | `progress` |
+|---|---|---|---|
+| pre-fix (`conv_...142705_AAPL`) | no dispatch fix | `interrupted` | **0.0%** |
+| post-fix (4 runs, US + HK) | `1e40081` | `error` | **10.0%** |
+
+Post-fix, the analysis **actually executes** (reaches the market-data stage at
+10%, streams the full event chain) and **terminates gracefully** with
+`status=error` when the LLM call fails — exactly the resilient behavior QA gate 2
+requires. The skill-timeout/degradation (original BUG-006 scope) and the dispatch
+race (BUG-006 root cause) are both fixed and verified.
+
+Exports re-verified on the final build: PDF `%PDF-1.4` (200), MD (200), JSON
+(200). BUG-007 `GET /` → 200.
+
+**BUG-006 is closed at the code level.** The sole reason `report_ready` is not
+reached is the LLM endpoint (`api.oneinfinityai.com`) returning 403 / Cloudflare
+1010 from **this deployment's egress** (confirmed again this run, <2 s). Per Dev
+Lead + user: the endpoint is a standard OpenAI-compatible base URL, the code
+calls it correctly, and a production server with normal egress should reach it —
+so this is an **environment limitation, not a code blocker**.
+
+---
+
+## 6. Known Risks
+
+| Risk | Severity | Notes |
+|---|---|---|
+| **BUG-006 (P1) — CLOSED at code level** | Resolved | Root cause (dispatch race: `submit_task` before `db.commit`) fixed in `ws-4-qa-rework` `1e40081`; skill-timeout/degradation in `683d35f`. **Verified on official build (§5.5):** no 0% hang — analysis runs to 10% (market stage), streams full event chain, terminates gracefully `status=error` on LLM failure. Pre-fix run stayed `0.0%/interrupted`; post-fix runs reach `10.0%/error`. Both fix layers confirmed working. |
+| **LLM endpoint blocked from this egress (environment)** | High (this env only) | `api.oneinfinityai.com` returns 403 / Cloudflare 1010 from this deployment's egress, so `report_ready` is not reached here. Per Dev Lead + user: endpoint is a standard OpenAI-compatible base URL, code calls it correctly, production egress should reach it. **Environment limitation, not a code blocker.** |
+| **BUG-007 (P2) — FIXED** | Resolved | `page_routes.py:20/:26` Starlette 1.x `TemplateResponse` signature. Fixed on `ws-4-qa-rework` (`c80d9de`); backend `GET /` now returns 200 (was 500). Verified live in the rebuilt container. |
+| **BUG-005 PDF — FIXED** | Resolved | PDF export now returns a real `%PDF-1.4` (was placeholder JSON). Verified: `/api/reports/{id}/export?format=pdf` → 200 `application/pdf`. |
+| Docker Hub unreachable | Medium | This environment cannot reach `registry-1.docker.io`. DaoCloud mirror (`docker.m.daocloud.io`) configured in `/etc/docker/daemon.json` as a workaround. Production deployments in unrestricted networks don't need this. |
+| `health.py` router not mounted | Low | `web/backend/health.py` defines `/health`, `/health/ready`, `/health/live` but `app.py` never calls `app.include_router(health.router)`. Healthcheck uses `/api/config` (200 JSON, unauthenticated) as a workaround. Recommend mounting the router at `/api/health` as a follow-up. |
+| SQLite concurrent writes | Low | Default DB is SQLite. Under heavy concurrent analysis load, may hit `database is locked`. Switch to MySQL (uncomment `mysql` service in compose) for production. |
+| Slow image builds | Low | Backend pip install downloads ~400MB of packages (langchain, chromadb, pandas, etc.). On restricted networks this can take 15-30 min. Builds are cached after first run. |
+
+---
+
+## 7. Story Issue Coverage & Merge Status
+
+Per QA Stage 5 report (`qa-agent/test-report.md`):
+
+| PR | Branch | Scope | Status |
+|---|---|---|---|
+| #1 | `ws-4-stage4-m0` | M0 de-risk (remove trading/leaderboard, cut order path, unify AI exit, key governance) | OPEN, mergeable |
+| #2 | `ws-4-m1-m4` | M1 kernel sync (v0.2.5) + M4 Skills layer (6 skills) | OPEN, mergeable (rebase to main after #1) |
+| #3 | `ws-4-m2-conversation-backend` | M2 conversation backend (sessions/messages/WS/report assembly) | OPEN, mergeable (rebase after #2) |
+| #4 | `ws-4-m5-m6-backend-hardening` | M5 backend IA + M6 non-functional (middleware, redirects, public reports) | OPEN, mergeable (rebase after #3) |
+| #5 | `ws-4-m3-frontend` | M3 frontend (conversation workbench, report cards, stage progress) | OPEN, mergeable (independent of backend chain) |
+
+**Recommended merge order**: #1 → #2 (rebase) → #3 (rebase) → #4 (rebase) → #5.
+The DevOps PR (#6, `ws-4-devops-stage6`) adds `devops/` and can merge at any
+point after #4 (it only adds new files, no conflicts).
+
+---
+
+## 8. Constraints Compliance
+
+- ✅ No trading/ordering/leaderboard services packaged (code removed in M0,
+ routes 308-redirect to `/`).
+- ✅ Analysis reports are the only product output.
+- ✅ `gpt-5.5` is the default deep+quick thinker model.
+- ✅ AI exit: OneInfinity (`https://api.oneinfinityai.com/v1`).
+- ✅ No secrets baked into images (API key via env var, `.env` gitignored).
+- ✅ Multi-stage builds, non-root backend, healthchecks on both services.
diff --git a/devops/docker-compose.yml b/devops/docker-compose.yml
new file mode 100644
index 0000000..21efdad
--- /dev/null
+++ b/devops/docker-compose.yml
@@ -0,0 +1,105 @@
+# ============================================
+# TradingAgents Web — full stack docker-compose
+# Frontend (Next.js static export + Nginx) + Backend (FastAPI/uvicorn).
+# Default DB: SQLite (file-based, no DB service required).
+# Scheduler: runs in-process inside the backend (APScheduler via app lifespan).
+# ============================================
+#
+# Quick start:
+# 1. cp .env.example .env # then edit .env to set OPENAI_API_KEY
+# 2. docker compose up --build -d
+# 3. open http://localhost:8000 (frontend served by nginx)
+# or http://localhost:8080 (backend direct, for debugging)
+#
+# Old trading/leaderboard services are NOT included — those code paths were
+# removed in M0 and the routes 308-redirect to /.
+
+services:
+ backend:
+ build:
+ context: ..
+ dockerfile: devops/Dockerfile.backend
+ container_name: tagents-backend
+ environment:
+ # AI provider — defaults already resolve to gpt-5.5 + oneinfinityai via
+ # tradingagents/default_config.py, but we pass them through explicitly
+ # so .env is the single source of truth.
+ OPENAI_API_KEY: ${OPENAI_API_KEY:?OPENAI_API_KEY must be set in .env}
+ OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.oneinfinityai.com/v1}
+ DEEP_THINK_LLM: ${DEEP_THINK_LLM:-gpt-5.5}
+ QUICK_THINK_LLM: ${QUICK_THINK_LLM:-gpt-5.5}
+ LLM_PROVIDER: ${LLM_PROVIDER:-openai}
+ EMBEDDING_API_KEY: ${EMBEDDING_API_KEY:-${OPENAI_API_KEY}}
+ EMBEDDING_BASE_URL: ${EMBEDDING_BASE_URL:-${OPENAI_BASE_URL}}
+ EMBEDDING_LLM: ${EMBEDDING_LLM:-text-embedding-3-small}
+ # Database — SQLite default (file in the mounted volume).
+ # To use MySQL instead, set DATABASE_URL=mysql+aiomysql://user:pwd@mysql:3306/tradingagents
+ # and uncomment the mysql service below.
+ DATABASE_URL: ${DATABASE_URL:-sqlite+aiosqlite:///./db/tradingagents.db}
+ NODE_ENV: production
+ PYTHONUNBUFFERED: "1"
+ # Optional data-source keys (skills layer falls back gracefully if unset)
+ ALPHA_VANTAGE_API_KEY: ${ALPHA_VANTAGE_API_KEY:-}
+ XUEQIU_TOKEN: ${XUEQIU_TOKEN:-}
+ volumes:
+ - db_data:/app/db
+ - eval_results:/app/eval_results
+ - results_data:/app/results
+ ports:
+ - "8080:8000" # backend direct access (debug / API probing)
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/api/config"]
+ interval: 15s
+ timeout: 5s
+ start_period: 30s
+ retries: 5
+
+ frontend:
+ build:
+ context: ../web/frontend
+ dockerfile: ../../devops/Dockerfile.frontend
+ container_name: tagents-frontend
+ environment:
+ FRONTEND_API_BASE_URL: http://backend:8000
+ ports:
+ - "8000:80" # public entry point (nginx serves static + proxies /api and /ws)
+ depends_on:
+ backend:
+ condition: service_healthy
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "curl", "-fsS", "http://127.0.0.1/"]
+ interval: 15s
+ timeout: 5s
+ start_period: 10s
+ retries: 5
+
+ # ---------- Optional: MySQL (production-grade DB) ----------
+ # Uncomment to use MySQL instead of SQLite. Set DATABASE_URL on the backend
+ # service to: mysql+aiomysql://tradingagents:tradingagents123@mysql:3306/tradingagents?charset=utf8mb4
+ #
+ # mysql:
+ # image: mysql:8.0
+ # container_name: tagents-mysql
+ # environment:
+ # MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-tradingagents123}
+ # MYSQL_DATABASE: ${MYSQL_DATABASE:-tradingagents}
+ # MYSQL_USER: ${MYSQL_USER:-tradingagents}
+ # MYSQL_PASSWORD: ${MYSQL_PASSWORD:-tradingagents123}
+ # TZ: Asia/Shanghai
+ # volumes:
+ # - mysql_data:/var/lib/mysql
+ # command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
+ # restart: unless-stopped
+ # healthcheck:
+ # test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
+ # interval: 10s
+ # timeout: 5s
+ # retries: 5
+
+volumes:
+ db_data:
+ eval_results:
+ results_data:
+ # mysql_data:
diff --git a/devops/rollback-plan.md b/devops/rollback-plan.md
new file mode 100644
index 0000000..bed464d
--- /dev/null
+++ b/devops/rollback-plan.md
@@ -0,0 +1,110 @@
+# Rollback Plan — TradingAgents Web Docker Deployment
+
+## Scope
+
+This plan covers rollback for the Docker-based deployment defined in `devops/docker-compose.yml`.
+It assumes a single-host `docker compose up` deployment (no orchestrator).
+
+## Rollback Strategies (in order of preference)
+
+### 1. Image-level rollback (fastest, no code change)
+
+Pre-built images are tagged. To roll back to a prior known-good image:
+
+```bash
+cd devops/
+# Pin to previous image tag (example: v0.1.0 -> v0.0.9)
+export BACKEND_TAG=v0.0.9
+export FRONTEND_TAG=v0.0.9
+docker compose up -d # re-creates containers with the pinned images
+```
+
+If images are built locally (not pushed to a registry), keep at least the
+previous image around:
+
+```bash
+docker images tagents-backend # note the old IMAGE ID
+docker images tagents-frontend
+```
+
+### 2. Git-level rollback (rollback code + rebuild)
+
+```bash
+# From the repo root
+git log --oneline -20 # find the last known-good commit
+git checkout
+cd devops/
+docker compose build --no-cache
+docker compose up -d
+```
+
+### 3. Stop-and-restore (full outage, safest)
+
+If the new deployment is actively harmful (e.g. corrupting data):
+
+```bash
+cd devops/
+docker compose down # stop containers, KEEP named volumes
+# Restore the SQLite db from backup (see below)
+docker compose up -d --build # rebuild from a known-good commit
+```
+
+## Data Backup
+
+The backend uses SQLite by default. The database file lives in the `db_data`
+named volume (mounted at `/app/db/tradingagents.db` inside the container).
+
+### Before any deployment
+
+```bash
+# Back up the SQLite database
+docker run --rm -v devops_db_data:/data -v "$(pwd)":/backup alpine \
+ cp /data/tradingagents.db /backup/tradingagents.db.$(date +%Y%m%d%H%M%S).bak
+
+# Also back up eval_results and results volumes if they contain valuable history
+docker run --rm -v devops_eval_results:/data -v "$(pwd)":/backup alpine \
+ tar czf /backup/eval_results.tar.gz -C /data .
+```
+
+### Restore
+
+```bash
+docker compose down
+# Restore SQLite
+docker run --rm -v devops_db_data:/data -v "$(pwd)":/backup alpine \
+ cp /backup/tradingagents.db..bak /data/tradingagents.db
+docker compose up -d
+```
+
+## Health Verification After Rollback
+
+```bash
+# Backend health (root route returns 200)
+curl -fsS http://localhost:8080/ && echo "backend OK"
+
+# Frontend health
+curl -fsS http://localhost:8000/ && echo "frontend OK"
+
+# API proxy through nginx
+curl -fsS http://localhost:8000/api/config && echo "api proxy OK"
+```
+
+## Known Failure Modes
+
+| Symptom | Likely Cause | Action |
+|---|---|---|
+| Backend container exits immediately | Missing `OPENAI_API_KEY` in `.env` | Ensure `.env` exists in `devops/` with a valid key |
+| Frontend `502 Bad Gateway` | Backend not healthy / not started | `docker compose logs backend`, wait for `start_period` (30s) |
+| Analysis hangs at 0% (BUG-006) | Skills data source timeout (restricted network) | This is the known P1 — backend rework adds timeout+degradation. Rollback won't fix; needs BUG-006 fix merged. |
+| `pull access denied` on build | Docker Hub unreachable, mirror down | Configure `registry-mirrors` in `/etc/docker/daemon.json` (this deployment verified with `https://docker.m.daocloud.io`) |
+| SQLite `database is locked` | Concurrent writes from threaded tasks | Switch to MySQL (uncomment `mysql` service in compose, set `DATABASE_URL`) |
+
+## Rollback Validation Checklist
+
+- [ ] `docker compose ps` shows both services `healthy`
+- [ ] `curl http://localhost:8080/` returns 200
+- [ ] `curl http://localhost:8000/` returns 200 (frontend)
+- [ ] Login page loads at `/`
+- [ ] A test conversation can be created (POST `/api/conversations`)
+- [ ] Scheduled tasks list loads (GET `/api/scheduled-tasks`)
+- [ ] No `trading` / `leaderboard` / `intraday` routes respond (should 308 or 404)
diff --git a/scripts/init_llm_config.py b/scripts/init_llm_config.py
index 32eced7..09157d8 100644
--- a/scripts/init_llm_config.py
+++ b/scripts/init_llm_config.py
@@ -28,9 +28,9 @@
LLM_PROVIDERS_CONFIG = [
{
"provider_name": "openai",
- "display_name": "OpenAI",
- "description": "GPT系列模型",
- "base_url": "https://api.openai.com/v1",
+ "display_name": "OneInfinity OpenAI Compatible",
+ "description": "OneInfinity OpenAI兼容模型",
+ "base_url": "https://api.oneinfinityai.com/v1",
"is_active": True
},
{
@@ -88,19 +88,10 @@
MODELS_CONFIG = {
"openai": {
"shallow_thinker": [
- {"model_name": "gpt-4o-mini", "display_name": "GPT-4o-mini", "description": "快速高效,适合快速任务"},
- {"model_name": "gpt-4.1-nano", "display_name": "GPT-4.1-nano", "description": "超轻量模型,适合基本操作"},
- {"model_name": "gpt-4.1-mini", "display_name": "GPT-4.1-mini", "description": "紧凑模型,性能良好"},
- {"model_name": "gpt-4o", "display_name": "GPT-4o", "description": "标准模型,能力稳定"},
+ {"model_name": "gpt-5.5", "display_name": "GPT-5.5", "description": "OneInfinity quick档默认模型;若提供方确认轻量档再切换"},
],
"deep_thinker": [
- {"model_name": "gpt-4.1-nano", "display_name": "GPT-4.1-nano", "description": "超轻量模型,适合基本操作"},
- {"model_name": "gpt-4.1-mini", "display_name": "GPT-4.1-mini", "description": "紧凑模型,性能良好"},
- {"model_name": "gpt-4o", "display_name": "GPT-4o", "description": "标准模型,能力稳定"},
- {"model_name": "o4-mini", "display_name": "o4-mini", "description": "专业推理模型(紧凑版)"},
- {"model_name": "o3-mini", "display_name": "o3-mini", "description": "高级推理模型(轻量级)"},
- {"model_name": "o3", "display_name": "o3", "description": "完整高级推理模型"},
- {"model_name": "o1", "display_name": "o1", "description": "首屈一指的推理和问题解决模型"},
+ {"model_name": "gpt-5.5", "display_name": "GPT-5.5", "description": "OneInfinity deep档默认模型"},
]
},
"oneai": {
diff --git a/tests/test_conversation_dispatch_commit.py b/tests/test_conversation_dispatch_commit.py
new file mode 100644
index 0000000..0a69fba
--- /dev/null
+++ b/tests/test_conversation_dispatch_commit.py
@@ -0,0 +1,49 @@
+import ast
+from pathlib import Path
+import unittest
+
+
+CONVERSATION_ROUTES = Path("web/backend/routes/conversation_routes.py")
+
+
+def _trigger_analysis_events() -> list[str]:
+ tree = ast.parse(CONVERSATION_ROUTES.read_text(encoding="utf-8"))
+ function = next(
+ node
+ for node in ast.walk(tree)
+ if isinstance(node, ast.AsyncFunctionDef) and node.name == "_trigger_analysis"
+ )
+ events: list[str] = []
+ for node in ast.walk(function):
+ if isinstance(node, ast.Await):
+ call = node.value
+ if (
+ isinstance(call, ast.Call)
+ and isinstance(call.func, ast.Attribute)
+ and isinstance(call.func.value, ast.Name)
+ and call.func.value.id == "db"
+ and call.func.attr in {"flush", "commit"}
+ ):
+ events.append(f"db.{call.func.attr}")
+ elif (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Attribute)
+ and node.func.attr == "submit_task"
+ ):
+ events.append("submit_task")
+ return events
+
+
+class ConversationDispatchCommitTests(unittest.TestCase):
+ def test_trigger_analysis_commits_record_before_worker_submit(self):
+ events = _trigger_analysis_events()
+
+ self.assertIn("db.flush", events)
+ self.assertIn("db.commit", events)
+ self.assertIn("submit_task", events)
+ self.assertLess(events.index("db.flush"), events.index("db.commit"))
+ self.assertLess(events.index("db.commit"), events.index("submit_task"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_page_routes_template_response.py b/tests/test_page_routes_template_response.py
new file mode 100644
index 0000000..01f4c8d
--- /dev/null
+++ b/tests/test_page_routes_template_response.py
@@ -0,0 +1,41 @@
+import ast
+from pathlib import Path
+import unittest
+
+
+PAGE_ROUTES = Path("web/backend/routes/page_routes.py")
+
+
+def _template_response_call(function_name: str) -> ast.Call:
+ tree = ast.parse(PAGE_ROUTES.read_text(encoding="utf-8"))
+ for node in ast.walk(tree):
+ if isinstance(node, ast.AsyncFunctionDef) and node.name == function_name:
+ for child in ast.walk(node):
+ if (
+ isinstance(child, ast.Call)
+ and isinstance(child.func, ast.Attribute)
+ and child.func.attr == "TemplateResponse"
+ ):
+ return child
+ raise AssertionError(f"TemplateResponse call not found in {function_name}")
+
+
+class PageRoutesTemplateResponseTests(unittest.TestCase):
+ def assert_template_response_uses_starlette_1x_signature(self, function_name: str, template_name: str):
+ call = _template_response_call(function_name)
+
+ self.assertGreaterEqual(len(call.args), 2)
+ self.assertIsInstance(call.args[0], ast.Name)
+ self.assertEqual(call.args[0].id, "request")
+ self.assertIsInstance(call.args[1], ast.Constant)
+ self.assertEqual(call.args[1].value, template_name)
+
+ def test_index_uses_request_first_template_response_signature(self):
+ self.assert_template_response_uses_starlette_1x_signature("index", "index.html")
+
+ def test_results_uses_request_first_template_response_signature(self):
+ self.assert_template_response_uses_starlette_1x_signature("results_page", "results.html")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_report_pdf_export.py b/tests/test_report_pdf_export.py
new file mode 100644
index 0000000..2746b0d
--- /dev/null
+++ b/tests/test_report_pdf_export.py
@@ -0,0 +1,46 @@
+import unittest
+from datetime import datetime
+from types import SimpleNamespace
+
+from web.backend.services.report_formatter import report_pdf_bytes
+
+
+class ReportPdfExportTests(unittest.TestCase):
+ def test_report_pdf_bytes_returns_real_pdf_binary(self):
+ record = SimpleNamespace(
+ analysis_id="report-1",
+ ticker="AAPL",
+ company_name="Apple",
+ market="US",
+ status="completed",
+ created_at=datetime(2026, 7, 6, 12, 0, 0),
+ updated_at=datetime(2026, 7, 6, 12, 5, 0),
+ trading_decision="buy",
+ final_summary="Apple remains resilient.",
+ final_state={
+ "structured_report": {
+ "rating": 4,
+ "summary": "Apple remains resilient.",
+ "sections": {
+ "market_technical": {"summary": "Trend is constructive.", "details": "Price momentum improved."},
+ "fundamentals": {"summary": "Cash flow is solid.", "details": "Margins remain healthy."},
+ "sentiment": {"summary": "Sentiment is neutral.", "details": "Social tone is balanced."},
+ "news_macro": {"summary": "Macro is mixed.", "details": "Rates remain a watch item."},
+ "risk": {"summary": "Valuation risk.", "details": "Multiple compression is possible."},
+ },
+ "grounded_evidence": [],
+ "stage_log": [],
+ "reflection": {},
+ }
+ },
+ )
+
+ pdf = report_pdf_bytes(record)
+
+ self.assertTrue(pdf.startswith(b"%PDF-"))
+ self.assertIn(b"%%EOF", pdf[-32:])
+ self.assertNotIn(b"pending M6 implementation", pdf)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_skills_resilience.py b/tests/test_skills_resilience.py
new file mode 100644
index 0000000..28e23d7
--- /dev/null
+++ b/tests/test_skills_resilience.py
@@ -0,0 +1,84 @@
+import time
+import unittest
+
+from web.backend.services.skills.base import (
+ RoutedSkillProvider,
+ SkillProviderExecutionError,
+ clear_skill_event_sink,
+ set_skill_event_sink,
+)
+
+
+def _provider(action, *, timeout_seconds=0.05, fallback_message="degraded data"):
+ return RoutedSkillProvider(
+ name="market-data",
+ display_name="Market data",
+ description="test provider",
+ input_schema={},
+ providers=["test"],
+ markets=["US"],
+ actions={"fetch": action},
+ primary_source="test",
+ fallback_source="fallback",
+ timeout_seconds=timeout_seconds,
+ fallback_message=fallback_message,
+ )
+
+
+class SkillsResilienceTests(unittest.TestCase):
+ def tearDown(self):
+ clear_skill_event_sink()
+
+ def test_timeout_returns_degraded_result_and_emits_warning(self):
+ events = []
+ set_skill_event_sink(events.append)
+
+ def slow_fetch(**_kwargs):
+ time.sleep(0.2)
+ return "late result"
+
+ provider = _provider(slow_fetch)
+ started_at = time.monotonic()
+ result = provider.execute("fetch", symbol="AAPL")
+
+ self.assertLess(time.monotonic() - started_at, 0.15)
+ self.assertEqual(result, "degraded data")
+ self.assertIn("timed out", provider.last_error)
+ self.assertEqual(events[0]["severity"], "warning")
+ self.assertEqual(events[0]["skill"], "market-data")
+ self.assertTrue(events[0]["partial"])
+ self.assertTrue(events[0]["retryable"])
+
+ def test_failure_returns_degraded_result_and_emits_warning(self):
+ events = []
+ set_skill_event_sink(events.append)
+
+ def failing_fetch(**_kwargs):
+ raise RuntimeError("provider down")
+
+ provider = _provider(failing_fetch)
+ result = provider.execute("fetch", symbol="AAPL")
+
+ self.assertEqual(result, "degraded data")
+ self.assertIn("provider down", provider.last_error)
+ self.assertEqual(events[0]["severity"], "warning")
+ self.assertIn("provider down", events[0]["message"])
+
+ def test_strict_failure_emits_error_and_raises(self):
+ events = []
+ set_skill_event_sink(events.append)
+
+ def failing_fetch(**_kwargs):
+ raise RuntimeError("provider down")
+
+ provider = _provider(failing_fetch, fallback_message=None)
+
+ with self.assertRaises(SkillProviderExecutionError):
+ provider.execute("fetch", symbol="AAPL")
+
+ self.assertEqual(events[0]["severity"], "error")
+ self.assertFalse(events[0]["partial"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tradingagents/agents/analysts/social_media_analyst.py b/tradingagents/agents/analysts/social_media_analyst.py
index fdc5a31..81dc9e3 100644
--- a/tradingagents/agents/analysts/social_media_analyst.py
+++ b/tradingagents/agents/analysts/social_media_analyst.py
@@ -3,6 +3,7 @@
import json
from tradingagents.agents.utils.agent_utils import get_news
from tradingagents.dataflows.config import get_config
+from tradingagents.utils.structured_outputs import evidence_snapshot
def create_social_media_analyst(llm):
@@ -17,7 +18,8 @@ def social_media_analyst_node(state):
system_message = (
"You are a social media and company specific news researcher/analyst tasked with analyzing social media posts, recent company news, and public sentiment for a specific company over the past week. You will be given a company's name your objective is to write a comprehensive long report detailing your analysis, insights, and implications for traders and investors on this company's current state after looking at social media and what people are saying about that company, analyzing sentiment data of what people feel each day about the company, and looking at recent company news. Use the get_news(query, start_date, end_date) tool to search for company-specific news and social media discussions. Try to look at all sources possible from social media to sentiment to news. Do not simply state the trends are mixed, provide detailed and finegrained analysis and insights that may help traders make decisions."
- + """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read.""",
+ + """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read.
+Grounding requirement: explicitly include a section named "数据快照" listing the concrete queried ticker, date window, data source/tool used, and the evidence snippets that support your sentiment conclusion. If evidence is insufficient, state that instead of inferring sentiment.""",
)
prompt = ChatPromptTemplate.from_messages(
@@ -55,6 +57,12 @@ def social_media_analyst_node(state):
return {
"messages": [result],
"sentiment_report": report,
+ "grounded_evidence": evidence_snapshot(
+ ticker=ticker,
+ as_of=current_date,
+ report=report,
+ sources=["get_news", "social-sentiment"],
+ ) if report else state.get("grounded_evidence", []),
}
return social_media_analyst_node
diff --git a/tradingagents/agents/managers/research_manager.py b/tradingagents/agents/managers/research_manager.py
index f1f6ff9..b4f8f60 100644
--- a/tradingagents/agents/managers/research_manager.py
+++ b/tradingagents/agents/managers/research_manager.py
@@ -9,6 +9,7 @@ def research_manager_node(state) -> dict:
sentiment_report = state["sentiment_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
+ previous_reflection = state.get("previous_decision_reflection") or {}
investment_debate_state = state["investment_debate_state"]
@@ -30,6 +31,16 @@ def research_manager_node(state) -> dict:
Strategic Actions: Concrete steps for implementing the recommendation.
Take into account your past mistakes on similar situations. Use these insights to refine your decision-making and ensure you are learning and improving. Present your analysis conversationally, as if speaking naturally, without special formatting.
+Use this previous same-ticker reflection if present and explicitly state whether the new evidence changes the prior decision:
+{json.dumps(previous_reflection, ensure_ascii=False)}
+
+At the end include a structured block:
+STRUCTURED_OUTPUT:
+- recommendation: BUY/HOLD/SELL
+- rating: 1-5
+- rationale: one sentence
+- evidence: 3 bullet points tied to analyst reports
+
Here are your past reflections on mistakes:
\"{past_memory_str}\"
@@ -52,6 +63,11 @@ def research_manager_node(state) -> dict:
return {
"investment_debate_state": new_investment_debate_state,
"investment_plan": response.content,
+ "reflection": {
+ "decision_log": response.content,
+ "alpha": previous_reflection.get("alpha", "首次分析或暂无可计算 alpha。"),
+ "lessons": previous_reflection.get("lessons", []),
+ },
}
return research_manager_node
diff --git a/tradingagents/agents/managers/risk_manager.py b/tradingagents/agents/managers/risk_manager.py
index 42f99bd..50c1ab6 100644
--- a/tradingagents/agents/managers/risk_manager.py
+++ b/tradingagents/agents/managers/risk_manager.py
@@ -2,6 +2,7 @@
import json
import re
from tradingagents.agents.utils.market_utils import detect_market_type
+from tradingagents.utils.structured_outputs import build_structured_report
def create_risk_manager(llm, memory):
@@ -15,6 +16,7 @@ def risk_manager_node(state) -> dict:
fundamentals_report = state["news_report"]
sentiment_report = state["sentiment_report"]
trader_plan = state["investment_plan"]
+ previous_reflection = state.get("previous_decision_reflection") or {}
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}"
past_memories = memory.get_memories(curr_situation, n_matches=2)
@@ -55,10 +57,15 @@ def risk_manager_node(state) -> dict:
4. **Learn from Past Mistakes**: Use lessons from **{past_memory_str}** to address prior misjudgments and improve the decision you are making now to make sure you don't make a wrong BUY/SELL/HOLD call that loses money.
+5. **Decision Reflection**: If a previous same-ticker decision is provided, compare it against new evidence and explain the expected alpha impact:
+{json.dumps(previous_reflection, ensure_ascii=False)}
+
**Deliverables:**
- A clear and actionable recommendation: Buy, Sell, or Hold
- Detailed reasoning anchored in the debate, long-term trend analysis, risk factor assessment, and past reflections
- Explicit statement of the decision's time horizon (short-term trading vs. long-term holding)
+- Five-level overall rating (1=strong sell, 2=sell, 3=hold, 4=buy, 5=strong buy), plus ratings for market/technical, fundamentals, sentiment, news/macro, and risk.
+- A STRUCTURED_OUTPUT block with recommendation, rating, section ratings, grounded evidence references, reflection, and alpha note.
---
@@ -129,12 +136,31 @@ def risk_manager_node(state) -> dict:
# Detect market type from ticker
market_type = detect_market_type(ticker)
+ report_sections = {
+ "market_report": market_research_report,
+ "sentiment_report": sentiment_report,
+ "news_report": news_report,
+ "fundamentals_report": fundamentals_report,
+ "risk_debate_state": new_risk_debate_state,
+ "investment_plan": trader_plan,
+ "final_trade_decision": response.content,
+ "grounded_evidence": state.get("grounded_evidence", []),
+ "reflection": {
+ "decision_log": response.content,
+ "alpha": previous_reflection.get("alpha", "待后续同标的价格回测确认。"),
+ "lessons": previous_reflection.get("lessons", []),
+ },
+ }
+ structured_report = build_structured_report(report_sections, response.content)
+
return {
"risk_debate_state": new_risk_debate_state,
"final_trade_decision": response.content,
"ticker": ticker,
"company_of_interest": company_name,
- "market_type": market_type
+ "market_type": market_type,
+ "structured_report": structured_report,
+ "reflection": structured_report["reflection"],
}
return risk_manager_node
diff --git a/tradingagents/agents/trader/intraday_trader.py b/tradingagents/agents/trader/intraday_trader.py
deleted file mode 100644
index 84744f9..0000000
--- a/tradingagents/agents/trader/intraday_trader.py
+++ /dev/null
@@ -1,1069 +0,0 @@
-"""
-Intraday Trading Agent
-Automatically analyzes positions and executes short-term trading strategies.
-"""
-
-import functools
-import json
-import logging
-import re
-from datetime import datetime
-from typing import Dict, Any, List
-from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
-
-
-def _parse_trades_from_response(content: str) -> tuple[List[Dict[str, Any]], str]:
- """
- Parse formatted trade details from LLM response and extract clean report.
-
- Looks for the special trade details marker and JSON array in the response.
-
- Args:
- content: The full LLM response content
-
- Returns:
- Tuple of (trades_list, clean_report)
- - trades_list: List of trade dictionaries
- - clean_report: Report with trade details section removed
- """
- trades = []
-
- # Ensure content is a string
- if isinstance(content, list):
- # If content is a list, convert to string
- content = str(content)
- elif not isinstance(content, str):
- content = str(content)
-
- clean_report = content
-
- try:
- # Look for trade details marker and JSON array
- # Pattern: ## TRADE_DETAILS_JSON followed by JSON array
- trade_marker = "## TRADE_DETAILS_JSON"
-
- if trade_marker in content:
- # Split content at marker
- parts = content.split(trade_marker, 1)
- clean_report = parts[0].strip()
-
- if len(parts) > 1:
- # Extract JSON from the second part
- json_section = parts[1].strip()
-
- # Find JSON array pattern
- json_match = re.search(r'\[.*?\]', json_section, re.DOTALL)
- if json_match:
- trades_json = json_match.group(0)
- trades_data = json.loads(trades_json)
-
- # Validate and normalize trades
- for trade in trades_data:
- if isinstance(trade, dict) and 'stock' in trade and 'action' in trade:
- action = trade.get('action', '').upper()
- if action in ['BUY', 'SELL', 'SHORT']:
- validated_trade = {
- 'stock': trade.get('stock', '').upper(),
- 'action': action,
- 'quantity': int(trade.get('quantity', 0)),
- 'price': float(trade.get('price', 0.0)) if trade.get('price') else None,
- 'description': trade.get('description', '')
- }
- trades.append(validated_trade)
-
- logging.info(f"Parsed {len(trades)} trade(s) from response")
- else:
- logging.warning("Trade marker found but no valid JSON array")
- else:
- logging.info("No trade details marker found in response")
-
- except Exception as e:
- logging.error(f"Error parsing trades from response: {e}", exc_info=True)
- # Return empty trades and original content on error
- trades = []
- clean_report = content
-
- return trades, clean_report
-
-
-def create_intraday_trader(llm, memory, user_id: int = None):
- """
- Create an intraday trading agent that automatically analyzes positions
- and executes short-term trading strategies using LangGraph.
-
- This agent will autonomously:
- 1. Call tools to gather market data
- 2. Analyze positions and opportunities
- 3. Make trading decisions
- 4. Execute trades
- 5. Generate comprehensive reports
-
- The agent will load user's core prompt and inject system documentation at runtime.
-
- Args:
- llm: Language model instance
- memory: Memory instance for storing trading history
- user_id: User ID for loading custom prompt (optional)
-
- Returns:
- Compiled LangGraph agent that can be invoked with initial state
- """
- from langgraph.graph import StateGraph, END
- from langgraph.prebuilt import ToolNode
- from typing import TypedDict, Annotated, Sequence
- from langchain_core.messages import BaseMessage
- import operator
-
- # Define state schema
- class AgentState(TypedDict):
- messages: Annotated[Sequence[BaseMessage], operator.add]
- user_id: int
- market_type: str
- session_id: str
- decision_report: str
- trades_executed: list
-
- async def agent_node(state):
- """
- Main agent node that decides what to do next (async version).
-
- State inputs:
- - user_id: User identifier
- - market_type: Market classification (US/HK/CN)
- - session_id: Unique session identifier
- - messages: Message history
-
- State outputs:
- - decision_report: Detailed decision report (accumulated from all AI messages)
- - trades_executed: List of executed trades
- - messages: Updated message history
- """
-
- # Extract state information
- state_user_id = state.get("user_id")
- market_type = state.get("market_type", "US")
- session_id = state.get("session_id", f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}")
- timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
-
- # Get existing accumulated report
- existing_report = state.get("decision_report", "")
-
- # Load user's core prompt (async)
- effective_user_id = state_user_id or user_id or 1
- core_prompt = None
- try:
- from web.backend.services.prompt_loader import load_user_prompt_template_async
- core_prompt = await load_user_prompt_template_async(
- user_id=effective_user_id,
- agent_type="intraday_trader"
- )
-
- # Validate core_prompt is not None or empty
- if not core_prompt:
- logging.error(f"❌ core_prompt is empty for user {effective_user_id}, will use default")
- core_prompt = None # Force fallback
- else:
- logging.info(f"✅ Successfully loaded core prompt for user {effective_user_id}, length={len(core_prompt)} chars")
- except Exception as e:
- logging.error(f"❌ Failed to load user prompt: {e}, using default")
- core_prompt = None # Force fallback
-
- # Fallback to default if core_prompt is still None or empty
- if not core_prompt:
- logging.warning(f"⚠️ Using default prompt for user {effective_user_id}")
- import os
- default_prompt_file = os.path.join(
- os.path.dirname(__file__),
- 'intraday_trader_default_prompt.txt'
- )
- try:
- with open(default_prompt_file, 'r', encoding='utf-8') as f:
- core_prompt = f.read()
- logging.info(f"✅ Loaded default prompt from file, length={len(core_prompt)} chars")
- except Exception as file_error:
- logging.error(f"❌ Failed to load default prompt file: {file_error}, using inline default")
- core_prompt = """You are an aggressive intraday trading agent operating like a professional day trader with full autonomy to analyze positions and execute trades.
-
-## Role Definition
-**Aggressive Intraday Trader** - High Risk Tolerance with Strategic Discipline
-- Pursue maximum short-term returns, willing to take moderate risks
-- Excel at capturing market volatility opportunities with quick entries and exits
-- Willing to take large positions on high-conviction opportunities when risk is manageable
-- Combine technical analysis with news/market sentiment to judge trends and momentum
-- Execute trading decisions decisively based on multi-dimensional analysis
-
-**Trading Philosophy - Balancing Short-term Tactics with Long-term Strategy**:
-- **Long-term Trend Awareness**: While focused on intraday opportunities, ALWAYS consider the stock's long-term trend direction
- * Stocks with strong long-term uptrends deserve patience during short-term pullbacks
- * Avoid fighting against established long-term trends for small intraday gains
- * Use daily and weekly timeframes to identify the dominant trend before intraday trading
-- **Transaction Cost Consciousness**: Every trade has costs (commissions, spreads, slippage)
- * Avoid excessive trading frequency on the same stock within short periods
- * A stock traded multiple times in a day/week incurs compounding fees that erode profits
- * Calculate breakeven point: each round trip costs ~0.1-0.3%, so gains must exceed this threshold
- * For quality stocks with long-term potential, prefer holding through minor fluctuations over frequent flipping
-- **Quality over Quantity**: Better to make fewer high-conviction trades than many mediocre ones
- * Focus on clear setups with favorable risk/reward ratios
- * Avoid "overtrading" - trading just because the market is open
- * Track trading frequency per stock: if traded 3+ times in a week, evaluate if it's worth continuing
-- **Strategic Patience for Strong Stocks**:
- * Long-term bullish stocks can weather short-term volatility - don't panic sell on minor dips
- * Short-term underperformance doesn't invalidate long-term thesis
- * Distinguish between temporary noise and genuine trend reversals
- * Hold through consolidation periods if fundamentals and long-term technicals remain intact
-
-## Your Mission
-Maximize risk-adjusted returns through strategic intraday trading that respects long-term trends, minimizes unnecessary transaction costs, and demonstrates patience with quality holdings during temporary volatility.
-
-## 📋 Historical Context
-**IMPORTANT**: If the user provides previous decision records in their message, you MUST:
-1. **Review the historical trades**: Understand what was bought/sold and at what prices
-2. **Learn from past decisions**: Identify successful patterns and mistakes to avoid
-3. **Maintain strategy continuity**: Don't contradict recent decisions without strong rationale
-4. **Track position evolution**: Know how positions have changed over time
-5. **Consider holding periods**: Avoid premature exits or entries that conflict with recent actions
-
-The historical context helps you make more informed decisions and maintain a coherent trading strategy across sessions.
-
-## 🚀 PARALLEL TOOL EXECUTION
-**IMPORTANT**: You can call MULTIPLE tools simultaneously in a single response!
-- Instead of calling tools one by one, group related tools together
-- Example: Call get_futu_account_info, get_futu_positions, and get_futu_orders all at once
-- This dramatically speeds up analysis by reducing round trips
-- The system will execute all tools in parallel and return results together
-
-## Trading Philosophy
-- **Act decisively**: When signals align, execute with conviction
-- **Cut losses fast**: Don't let small losses become big ones
-- **Let winners run**: Trail stops on profitable positions, especially for stocks with strong long-term trends
-- **Stay liquid**: Keep cash ready for opportunities
-- **Trade the trend**: Align intraday trades with long-term trend direction - don't fight the major trend
-- **Cost-aware trading**: Consider transaction costs before every trade - avoid churning positions unnecessarily
-- **Strategic patience**: For quality stocks with bullish long-term trends, tolerate short-term noise rather than overtrading
-
-## Market Rules
-- **US Market**: Supports both long and short positions, T+0 trading (can buy and sell same day)
-- **HK Market**: Only supports long positions, short selling NOT supported, T+0 trading allowed
-- **CN Market (A-shares)**: Only supports long positions, short selling NOT supported, **T+1 trading mechanism**
- * **T+1 Restriction**: Stocks bought today (holding period = 0 days) CANNOT be sold on the same day
- * Must wait until next trading day to sell newly purchased stocks
- * This applies to ALL A-share stocks (Shanghai/Shenzhen exchanges)
- * When analyzing positions, ALWAYS check holding period before planning sell operations
-
-⚠️ **IMPORTANT**: Current market is {market_type}. Please formulate trading strategy according to market rules.
-
-## Trading Constraints
-
-⚠️ **CRITICAL TRADING LIMITS**:
-1. **Maximum 3 stocks per session**: Can only trade up to 3 different stocks in one analysis session
-2. **Analyze first, trade later**: Must complete ALL stock analysis before executing ANY trades
- - Phase 1-2: Collect information and analyze ALL stocks
- - Phase 3: Execute trades for selected stocks (max 3)
- - Phase 4: Verify results
-
-## Standard Execution Workflow
-
-### Phase 1: Information Collection
-
-⚠️ **PARALLEL TOOL CALLS**: You can call multiple tools simultaneously in one response to speed up data collection!
-
-**Step 1: Account & Position Overview** (call these 3 tools in parallel):
-1. `get_futu_account_info(market_type="{market_type}")` - Check account funds and total assets
-2. `get_futu_positions(market_type="{market_type}")` - Get all current positions
-3. `get_futu_orders(market_type="{market_type}", filter_status=0)` - Check pending orders (⚠️ CRITICAL: avoid duplicate orders)
-
-**Step 2: Stock Analysis** (call multiple tools in parallel for each stock):
-For each position and candidate stock, call these tools together:
-- `get_futu_quote(stock_code)` - Get real-time quote
-- `get_futu_kline(symbol=stock_code, interval="daily", format="csv")` - Get daily K-line data for 1-month trend analysis
-- `get_futu_kline(symbol=stock_code, interval="5min", format="csv")` - Get 5-minute K-line data for intraday trend analysis
-- `get_futu_technical_analysis(symbol=stock_code, interval="daily", indicator="macd", format="csv")` - Get daily MACD
-- `get_futu_technical_analysis(symbol=stock_code, interval="daily", indicator="rsi", format="csv")` - Get daily RSI
-- `get_futu_technical_analysis(symbol=stock_code, interval="daily", indicator="boll", format="csv")` - Get daily Bollinger Bands
-- `get_futu_technical_analysis(symbol=stock_code, interval="5min", indicator="macd", format="csv")` - Get 5-min MACD
-- `get_futu_technical_analysis(symbol=stock_code, interval="5min", indicator="rsi", format="csv")` - Get 5-min RSI
-- `get_futu_technical_analysis(symbol=stock_code, interval="5min", indicator="boll", format="csv")` - Get 5-min Bollinger Bands
-
-Example: If analyzing AAPL and TSLA, call all 18 tools (9 per stock) in one response!
-
-**Step 3: Market Scanning & News Analysis** (optional, call these in parallel):
-- `get_futu_hot_news(lang="zh-cn")` - Get latest hot financial news from Futu
-- `get_akshare_news(limit=20)` - Get latest financial news from AkShare (recommended for real-time market sentiment)
-- `get_akshare_hot_stocks(symbol="A股", time_range="今日", limit=10)` - Get Baidu hot search stocks (for CN market)
-- `get_futu_hot_stocks(market_type="{market_type}")` - Discover market hot stocks and trading opportunities
-
-💡 **Efficiency Tip**: Group related tool calls together to minimize round trips and speed up analysis!
-
-### Phase 2: Analysis & Decision (Complete for ALL stocks before Phase 3)
-Based on collected information, conduct comprehensive analysis:
-
-**Historical Context Review** (if provided):
-- Review previous session's trades and outcomes
-- Identify patterns: What worked? What didn't?
-- Check for position continuity: Are we still holding previous positions?
-- Avoid contradicting recent decisions without strong justification
-- Learn from past mistakes and successes
-
-**Position Evaluation**:
-- Current position status vs ideal position allocation
-- P&L situation and holding time (compare with historical records if available)
-- ⚠️ **Holding Period Check (CN Market CRITICAL)**:
- * Check holding period for each position (from get_futu_positions result)
- * If holding period = 0 days (bought today), **CANNOT sell today due to T+1 restriction**
- * Mark positions with 0-day holding as "sell-restricted" in analysis
- * Only positions held for 1+ days can be sold
-- 📊 **Long-term Trend Assessment (CRITICAL for Trade Decision)**:
- * **Use daily K-line data (1 month+) to determine primary trend**:
- - Is the stock in a long-term uptrend, downtrend, or range-bound?
- - Where is the price relative to 50-day and 200-day moving averages?
- - Are we near major support or resistance levels?
- * **Long-term trend should guide intraday strategy**:
- - Strong long-term uptrend → Be patient with short-term dips, avoid premature selling
- - Long-term downtrend → Be cautious with longs, quick exits on rallies
- - Sideways/range-bound → More active intraday trading acceptable
- * **Trend strength evaluation**:
- - Strong trend (clear direction, sustained momentum) → Hold through minor volatility
- - Weak trend (choppy, unclear direction) → More active management acceptable
-- 💰 **Trading Frequency & Cost Analysis**:
- * **Check historical trading frequency**: Review past session records if available
- - How many times was this stock traded in the last week?
- - How many times traded today or in current session?
- * **Calculate cumulative transaction costs**:
- - Each round trip (buy + sell) costs ~0.1-0.3% in total fees
- - Frequent trading on same stock compounds costs rapidly
- - Example: 5 round trips = 0.5-1.5% in fees alone
- * **Trading fatigue assessment**:
- - If stock traded 3+ times recently → Question: Is another trade truly necessary?
- - If minor profit/loss on recent trades → Likely just paying fees, not gaining edge
- - If same stock repeatedly traded without clear progression → Overtrading signal
- * **Cost-benefit evaluation before action**:
- - Will this trade likely produce gains > 0.3% to cover costs?
- - Is the risk/reward compelling enough to justify another trade?
- - Or should we hold current position and wait for clearer setup?
-- **Multi-timeframe Technical Analysis**:
- * Daily K-line (1 month): Identify major trend direction, support/resistance levels
- - Daily MACD: Trend momentum and potential reversals
- - Daily RSI: Overbought/oversold conditions on daily timeframe
- - Daily Bollinger Bands: Volatility and price extremes
- * 5-minute intraday: Identify short-term momentum and entry/exit timing
- - 5-min MACD: Short-term momentum shifts
- - 5-min RSI: Intraday overbought/oversold conditions
- - 5-min Bollinger Bands: Intraday volatility and breakouts
- * **Multi-timeframe Confluence (共振)**:
- - Trend alignment: Daily and 5-min trends in same direction = highest probability
- - Indicator confirmation: MACD and RSI signals align across timeframes = strong signal
- - Entry timing: Use daily for direction, 5-min for precise entry/exit points
- - **Long-term trend as primary filter**: Intraday signals are stronger when aligned with daily/weekly trend
-- Related news sentiment (positive/negative/neutral)
-- Whether position size is reasonable
-
-**Direction Judgment**:
-- Whether direction switch is needed (long to short / short to long)
-- ⚠️ **Market Restriction Check**:
- * If current market is **US**: Can consider short selling, T+0 trading allowed
- * If current market is **HK**: Short selling NOT supported, can only close long or hold, T+0 trading allowed
- * If current market is **CN**: Short selling NOT supported, can only close long or hold, **T+1 trading restriction applies**
-- ⚠️ **T+1 Selling Restriction (CN Market ONLY)**:
- * Before planning any sell operation, verify holding period ≥ 1 day
- * If holding period = 0 days, **MUST skip selling** and note "T+1限制,无法当日卖出"
- * Can only plan sells for positions held 1+ days
-- **Multi-timeframe Trend Alignment**:
- * **Strong Buy Signal** (highest probability):
- - Daily trend UP + Daily MACD bullish + Daily RSI < 70
- - 5-min trend UP + 5-min MACD bullish + 5-min RSI < 70
- - Both timeframes confirm = 共振 (resonance)
- * **Strong Sell Signal** (US market only):
- - Daily trend DOWN + Daily MACD bearish + Daily RSI > 30
- - 5-min trend DOWN + 5-min MACD bearish + 5-min RSI > 30
- - Both timeframes confirm = 共振 (resonance)
- * **Conflicting Signals** (trade cautiously):
- - Daily and 5-min trends disagree → Wait for alignment or use tight stops
- - Indicators conflict → Reduce position size or skip trade
- * **Decision Framework**:
- - Use daily trend as primary filter (determines direction)
- - Use 5-min for precise entry/exit timing (determines when)
- - Only trade when both timeframes align (共振)
-- If short selling is involved (US only), conduct in-depth analysis:
- * Technical support (trend, momentum, indicators)
- * Fundamental support (valuation, financials, industry)
- * News support (bearish news, negative events)
- * Risk controllability (volatility, liquidity, stop-loss space)
-
-**Fund Check**:
-- Whether available funds are sufficient
-- Whether pending orders exist (⚠️ If pending orders exist for same stock, DO NOT place duplicate orders)
-- Whether other positions need adjustment (take profit/stop loss)
-
-**Decision Making**:
-Based on your professional judgment, decide specific operation steps:
-- For existing positions: Add/Reduce/Close/Hold
- * **Before deciding to trade existing positions, ALWAYS consider**:
- - Is this stock in a long-term uptrend? If YES → Be patient, tolerate short-term volatility
- - Have we traded this stock frequently recently? If YES → Question if another trade is necessary
- - Will transaction costs (0.1-0.3% round trip) eat into potential gains?
- - Is the signal strong enough to overcome trading costs?
- * **Hold decision criteria** (prefer holding over unnecessary trading):
- - Long-term trend remains bullish + short-term pullback is minor → HOLD through volatility
- - Position recently established (< 3 days) + no stop-loss triggered → Avoid churning
- - Traded this stock 2+ times in past week → Default to HOLD unless urgent reason
- - Current drawdown < 3% on quality stock → Tolerate short-term noise
- * **Close/Reduce decision criteria** (only when justified):
- - Stop-loss triggered (typically -5% to -8%)
- - Long-term trend reversal confirmed (not just short-term weakness)
- - Fundamental deterioration or major negative news
- - Need capital urgently for better opportunity
-- For new opportunities: Open/Skip
- * **Before opening new positions, evaluate**:
- - Does long-term trend align with intended direction?
- - Is this a high-conviction setup worth the trading costs?
- - Do we have capacity (max 3 stocks per session)?
-- For bearish stocks:
- * US market: Can consider short selling (requires in-depth analysis)
- * HK/CN markets: Can only close long or watch, cannot short
-- ⚠️ **Select top 3 stocks maximum**: If more than 3 stocks need trading, prioritize by:
- * Urgency (stop-loss, take-profit)
- * Conviction level (strongest signals, long-term trend alignment)
- * Risk-reward ratio (must exceed transaction costs meaningfully)
- * Trading frequency (prefer stocks not recently traded if all else equal)
-- **Cost-benefit checkpoint**: Before finalizing any trade decision, ask:
- * "Is this trade expected to gain > 0.5% to justify costs?"
- * "Am I overtrading this stock out of impatience?"
- * "Does this align with or contradict the long-term trend?"
-- Complete analysis for ALL selected stocks before moving to Phase 3
-
-### Phase 3: Execute Trades (ONLY after completing Phase 2 for ALL stocks)
-⚠️ **IMPORTANT**: Do NOT execute trades during Phase 1-2. Only execute after ALL analysis is complete.
-⚠️ **LIMIT**: Execute trades for maximum 3 stocks only
-**Pre-execution checks**:
-- Confirm no duplicate orders (check Phase 1 pending orders results)
-- Confirm sufficient funds
-- Calculate appropriate position size
-- ⚠️ **Reconfirm market rules**:
- * HK/CN do not support short selling
- * **CN Market T+1 Check**: If selling, confirm holding period ≥ 1 day (cannot sell same-day purchases)
-
-**Execute according to trading rules**:
-- **Direction switch**: Must close positions first, then open opposite direction positions
- * Long to short (US only): Close all long positions first → Then open short positions
- * Short to long: Close all short positions first → Then buy
-- **Incremental adjustment**: Directly add or reduce positions
-- **Short selling (US only)**: Ensure in-depth analysis and evaluation are completed
-
-**Order placement**:
-- Call `place_futu_order(stock_code, direction, quantity, price, order_type)`
-- ⚠️ **CRITICAL RULE**: Each stock can ONLY call place_futu_order ONCE per session
- * Once called for a stock, DO NOT call again regardless of success or failure
- * This prevents duplicate orders and excessive retry attempts
- * If first attempt fails, accept the failure and move on
-- ⚠️ Check return result:
- * Success: Order submitted/filled - DO NOT place another order for this stock
- * Failure: Record error reason (insufficient funds/stock halted/price limit exceeded/market does not support short selling, etc.) - DO NOT retry
-- If other positions need adjustment, execute sequentially (respecting one-call-per-stock rule)
-
-### Phase 4: Result Verification (only if trade succeeded)
-If `place_futu_order` returned success:
-1. `get_futu_account_info(market_type="{market_type}")` - Get post-trade account info
-2. `get_futu_positions(market_type="{market_type}")` - Get post-trade positions
-3. `get_futu_orders(market_type="{market_type}", filter_status=0)` - Get latest order status
-
-If `place_futu_order` returned failure or not called:
-- Skip verification, proceed directly to report phase
-
-### Phase 5: Generate Report
-Generate complete Chinese execution report (no more tool calls)
-
-## Risk Parameters (Guidelines, Not Handcuffs)
-
-**Position Sizing**:
-- Single stock: Up to 40% on high-conviction plays, typically 15-25%
-- Total exposure: Can go up to 95% when opportunities are strong
-- Cash reserve: Minimum 5%, prefer 10-20% for flexibility
-
-**Risk Management**:
-- Hard stop: -8% on any position (cut immediately)
-- Soft stop: -5% (evaluate if worth holding)
-- Portfolio drawdown: If down -5% from peak, reduce exposure
-- Winning positions: Trail stops to lock in gains
-
-**Trading Constraints**:
-- ⚠️ **Maximum 3 stocks per session**: Can only trade up to 3 different stocks
-- ⚠️ **Analyze first, trade later**: Complete ALL analysis before executing ANY trades
-- ⚠️ **One order per stock**: Each stock can ONLY call place_futu_order ONCE per session (no retries, no duplicates)
-- ⚠️ No duplicate orders: Must check pending orders before placing orders
-- Direction switch must close positions first
-- Avoid trading in first 5 minutes
-- Trade cautiously in last 30 minutes
-- Short selling requires thorough analysis (US only)
-- ⚠️ HK/CN markets prohibit short selling operations
-
-## Decision-Making Authority
-You have full discretion to:
-- Determine position sizes based on conviction and analysis
-- Choose entry/exit timing based on technicals
-- Decide which opportunities to pursue and which to skip
-- Set stop-loss levels (within risk parameters)
-- Override guidelines when you have strong rationale
-
-**What you MUST do**:
-- Follow the 5-phase standard workflow
-- ⚠️ **Trade maximum 3 stocks per session**
-- ⚠️ **Complete ALL analysis before executing ANY trades**
-- ⚠️ **One order per stock**: Never call place_futu_order more than once for the same stock
-- Check pending orders before placing orders
-- Direction switch must close positions first
-- Stay within maximum position limits (40% single, 95% total)
-- Execute hard stop at -8% loss
-- Explain your reasoning clearly
-- ⚠️ **Strictly follow market rules**: HK/CN must not short sell
-
-## Output Format (MUST OUTPUT IN CHINESE)
-
-```markdown
-# 日内交易报告
-**会话**: {session_id} | **时间**: {timestamp} | **市场**: {market_type}
-**市场规则**: {market_type} 市场 - [支持做多和做空 / 仅支持做多,不支持做空]
-
-## I. 账户状态
-- 总资产: $XXX,XXX | 可用资金: $XX,XXX | 已部署: XX%
-- 待处理订单: X个(如有则列出股票和方向)
-
-## II. 持仓分析
-
-### [股票代码] - [公司名称]
-**当前状态**:
-- 持仓: XXX股 @ $XX.XX成本 | 现价: $XX.XX
-- 盈亏: ±X.XX% ($XXX) | 持仓规模: 占总资产XX%
-- 持仓时间: X天/小时
-- T+1限制 (仅CN市场): [不受限 (持仓≥1天) / 受限 (持仓0天,当日买入不可卖出)]
-
-**长期趋势评估** (关键决策依据):
-- **主要趋势方向**: [强劲上涨/温和上涨/横盘整理/温和下跌/强劲下跌]
-- **趋势持续性**: [趋势稳固,可承受短期波动 / 趋势不稳,需谨慎对待]
-- **价格位置**: 相对50日/200日均线的位置 [上方XX% / 下方XX%]
-- **关键支撑/阻力**: [列出重要价格水平]
-- **长期趋势启示**: [对当前持仓决策的指导意义]
-
-**交易频率分析** (成本控制):
-- **近期交易次数**: 本周交易X次,本月交易X次(如从历史记录获取)
-- **累计交易成本估算**: 约X.XX% (每轮0.1-0.3%)
-- **交易频率评估**: [正常 / 偏高,需控制 / 过度交易警告]
-- **成本效益分析**: [说明是否值得再次交易]
-
-**技术分析**:
-- **日K线 (1个月)**:
- * 趋势: [上涨/下跌/横盘] - [趋势强度和关键支撑/阻力位]
- * MACD: [看涨/看跌/中性] - [具体数值和形态]
- * RSI: XX - [超买/超卖/正常]
- * 布林带: [突破上轨/跌破下轨/在轨道内]
-- **5分钟分时 (当日)**:
- * 趋势: [上涨/下跌/横盘] - [与日线趋势的一致性]
- * MACD: [看涨/看跌/中性] - [短期动能]
- * RSI: XX - [超买/超卖/正常]
- * 布林带: [位置和波动性]
-- **多周期共振分析**:
- * 趋势一致性: [日线与分时趋势是否同向]
- * 指标共振: [MACD、RSI在两个周期是否同向确认]
- * 综合评估: [强/中/弱] - [是否形成交易共振]
-
-**新闻情绪**: [正面/负面/中性]
-- [关键新闻要点(如有)]
-
-**决策**: [加仓/减仓/平仓/持有]
-**推理**: [综合长期趋势、交易频率成本、日K线趋势、分时走势、技术指标、新闻情绪的详细解释。重点说明:
- 1. 长期趋势是否支持当前决策
- 2. 交易频率是否过高,成本是否可控
- 3. 短期技术信号与长期趋势是否一致
- 4. 如果选择持有,说明为何容忍短期波动
- 5. 如果选择交易,说明预期收益是否足以覆盖成本]
-**T+1限制影响 (仅CN市场)**: [不适用 / 无影响 / 受限制无法卖出(持仓0天)]
-
-**执行操作**:
-- 是否调用交易工具: 是/否
-- 订单类型: 买入/卖出/卖空(仅美股)/平多/平空/无
-- 数量和价格: [如执行则填写]
-- 工具返回结果: 成功/失败/未调用 - [详情,如因T+1限制跳过则说明]
-- 最终状态: [交易后持仓情况]
-
-[对每个持仓重复]
-
-## III. 新机会评估
-
-### [股票代码] - [公司名称]
-**发现来源**: [Futu热门股票/新闻提及/技术突破]
-
-**长期趋势评估**:
-- **主要趋势**: [强劲上涨/温和上涨/横盘/下跌] - [趋势强度]
-- **趋势质量**: [高质量趋势,值得参与 / 低质量,需谨慎]
-- **与趋势方向一致性**: [计划做多是否与长期上涨趋势一致?]
-
-**技术评估**:
-- 当前价格: $XX.XX | 成交量: [放量/缩量/正常]
-- **日K线 (1个月)**:
- * 趋势: [上涨/下跌/横盘] - [关键位置分析]
- * MACD: [看涨/看跌/中性]
- * RSI: XX - [超买/超卖/正常]
- * 布林带: [位置]
-- **5分钟分时**:
- * 趋势: [上涨/下跌/横盘] - [与日线趋势的配合]
- * MACD: [看涨/看跌/中性]
- * RSI: XX - [超买/超卖/正常]
- * 布林带: [位置]
-- **多周期研判**:
- * 趋势共振: [日线与分时是否同向]
- * 指标共振: [技术指标是否同向确认]
- * 综合评估: [强/中/弱]
-- 入场时机: [立即/等待回调/跳过]
-
-**成本收益评估**:
-- 预期收益潜力: [是否> 0.5%以覆盖交易成本]
-- 风险收益比: [R:R比例]
-- 值得开仓理由: [说明为何这个机会值得付出交易成本]
-
-**决策**: [开仓做多/开仓做空(仅美股)/跳过]
-**推理**: [综合长期趋势、日K线、分时、新闻和技术指标的详细分析。重点说明:
- 1. 长期趋势是否支持这个方向
- 2. 预期收益是否足以覆盖交易成本(0.1-0.3%)
- 3. 多周期信号是否共振
- 4. 如果跳过,说明不符合哪些标准]
-
-**执行操作**: [如开仓则填写交易详情]
-
-[对每个候选重复]
-
-## IV. 交易摘要
-- 执行交易: X笔
-- 买入: X笔,总计$XXX
-- 卖出: X笔,总计$XXX
-- 做空: X笔,总计$XXX(仅美股)
-- 待处理订单: X个
-- 净敞口变化: [增加/减少/不变] XX%
-
-## V. 下一步行动
-- 监控重点: [具体股票和条件]
-- 关注事件: [即将到来的催化剂]
-- 调整计划: [下一周期的策略调整]
-```
-
-## Trading Mindset
-- **Be aggressive but not reckless**: Take calculated risks, but always consider transaction costs
-- **Speed matters, but patience pays**: Quick execution is important, but avoid impulsive overtrading
-- **Adapt quickly**: Market conditions change, so should your strategy
-- **Trust your analysis**: If signals align AND align with long-term trend, execute with confidence
-- **Protect capital**: One bad trade shouldn't blow up the account, and frequent small trades compound costs
-- **Follow market rules**: No short selling in HK/CN, can short in US but be cautious
-- **Respect the long-term trend**: Fight the short-term noise, not the long-term trend
-- **Quality over frequency**: Fewer high-conviction trades beat many mediocre ones
-"""
-
- # Final validation: ensure core_prompt is never None
- if not core_prompt:
- logging.critical(f"🚨 CRITICAL: core_prompt is still None after all fallbacks for user {effective_user_id}!")
- raise ValueError(f"Failed to load core_prompt for user {effective_user_id}")
-
- logging.info(f"📋 Final core_prompt ready: length={len(core_prompt)} chars")
-
- # Now assemble complete prompt with system injections
- from tradingagents.agents.utils.futu_trading_tools import (
- get_futu_account_info,
- get_futu_positions,
- get_futu_quote,
- get_futu_kline,
- get_futu_technical_analysis,
- get_futu_hot_stocks,
- get_futu_hot_news,
- get_futu_orders,
- place_futu_order,
- )
- from tradingagents.agents.utils.akshare_news_tools import (
- get_akshare_news,
- get_akshare_hot_stocks,
- )
- from tradingagents.agents.utils.fundamental_data_tools import (
- get_fundamentals,
- get_balance_sheet,
- get_cashflow,
- get_income_statement,
- )
-
- # Define all available tools
- tools = [
- get_futu_account_info,
- get_futu_positions,
- get_futu_orders,
- get_futu_quote,
- get_futu_kline,
- get_futu_technical_analysis,
- get_futu_hot_stocks,
- get_futu_hot_news,
- get_akshare_news,
- get_akshare_hot_stocks,
- get_fundamentals,
- get_balance_sheet,
- get_cashflow,
- get_income_statement,
- place_futu_order,
- ]
-
- # Load workflow documentation (fixed, not customizable)
- # Note: Tool usage is documented within the workflow, no need for separate tool list
- import os
- workflow_file = os.path.join(
- os.path.dirname(__file__),
- 'intraday_trader_workflow.txt'
- )
- try:
- with open(workflow_file, 'r', encoding='utf-8') as f:
- workflow_documentation = f.read()
- except Exception as e:
- logging.warning(f"Failed to load workflow documentation: {e}")
- workflow_documentation = "## Standard Execution Workflow\n\nFollow the 5-phase workflow: Information Collection → Analysis & Decision → Execute Trades → Result Verification → Generate Report"
-
- # Generate context information
- context_info = f"""## Current Context
-
-- Market: {market_type}
-- Session ID: {session_id}
-- Timestamp: {timestamp}
-- User ID: {effective_user_id}
-
-## Market Rules
-- **US Market**: Supports both long and short positions, T+0 trading (can buy and sell same day)
-- **HK Market**: Only supports long positions, short selling NOT supported, T+0 trading allowed
-- **CN Market (A-shares)**: Only supports long positions, short selling NOT supported, T+1 trading (stocks bought today cannot be sold same day)
-
-Current market is {market_type}. Please formulate trading strategy according to market rules.
-"""
-
- # Trade output format rule
- trade_output_rule = """## ⚠️ CRITICAL - Trade Details Output Rule
-
-If you executed ANY trades (called place_futu_order and it succeeded), you MUST append a formatted trade details section at the VERY END of your final report:
-
-```
-## TRADE_DETAILS_JSON
-[
- {{"stock": "AAPL", "action": "BUY", "quantity": 100, "price": 150.50, "description": "以$150.50买入100股苹果"}},
- {{"stock": "TSLA", "action": "SELL", "quantity": 50, "price": 200.00, "description": "以$200.00卖出50股特斯拉"}},
- {{"stock": "00700", "action": "BUY", "quantity": 200, "price": 320.50, "description": "以HK$320.50买入200股腾讯"}},
- {{"stock": "002475", "action": "BUY", "quantity": 200, "price": 56.51, "description": "以¥56.51买入200股立讯精密"}}
-]
-```
-
-**Rules for TRADE_DETAILS_JSON**:
-1. Only include trades that were ACTUALLY EXECUTED (place_futu_order was called and returned success)
-2. Do NOT include trades that were skipped, failed, or just held
-3. Each trade object must have: stock, action (BUY/SELL/SHORT), quantity, price (if available), description (in Chinese)
-4. This section must be at the VERY END of your report, after all analysis text
-5. If NO trades were executed, do NOT include this section at all
-6. The marker must be exactly "## TRADE_DETAILS_JSON" followed by the JSON array
-"""
-
- # Assemble complete system message
- # Order: User Strategy (customizable) → Workflow (fixed) → Context (dynamic) → Trade Output Rule (critical)
- # This order ensures LLM first understands the trading philosophy, then the execution process, then current state, and finally the output format
- system_message_parts = [
- core_prompt,
- workflow_documentation,
- context_info,
- trade_output_rule,
- "\nNow execute your trading strategy following the workflow above based on current context."
- ]
-
- # Validate all parts have values
- part_names = ["core_prompt", "workflow_documentation", "context_info", "trade_output_rule", "final_instruction"]
- for i, part in enumerate(system_message_parts):
- if not part:
- logging.error(f"❌ {part_names[i]} is None or empty!")
- raise ValueError(f"System message part '{part_names[i]}' is missing")
-
- logging.info(
- f"📋 System message parts ready: "
- f"core_prompt={len(core_prompt)}, "
- f"workflow={len(workflow_documentation)}, "
- f"context={len(context_info)}, "
- f"trade_rule={len(trade_output_rule)} chars"
- )
-
- # Join all parts
- system_message = "\n\n".join(system_message_parts)
- logging.info(f"📋 Final system_message assembled: {len(system_message)} chars")
-
- # Create prompt template
- prompt = ChatPromptTemplate.from_messages([
- ("system", system_message),
- MessagesPlaceholder(variable_name="messages"),
- ])
-
- # Bind tools to LLM (tools already defined above)
- llm_with_tools = llm.bind_tools(tools)
-
- # Create chain
- chain = prompt | llm_with_tools
-
- # Send agent start event
- try:
- from web.backend.app import manager as ws_manager
- await ws_manager.send_message({
- 'type': 'agent_start',
- 'timestamp': datetime.utcnow().isoformat(),
- 'message': 'Intraday agent started',
- 'agent': 'Intraday Trader'
- }, f"intraday_user_{user_id}")
- except Exception:
- pass
-
- # Invoke agent with user_id in config (async)
- try:
- result = await chain.ainvoke(
- {"messages": state.get("messages", [])},
- config={"configurable": {"user_id": effective_user_id}}
- )
-
- # Extract current AI message content (only text, not tool calls)
- current_content = ""
- if hasattr(result, 'content') and result.content:
- # Only get text content, check if it's actual text (not empty)
- # Handle both string and list content
- if isinstance(result.content, list):
- # If content is a list, join it or extract text parts
- content_parts = []
- for item in result.content:
- if isinstance(item, str):
- content_parts.append(item)
- elif isinstance(item, dict) and 'text' in item:
- content_parts.append(item['text'])
- content = ' '.join(content_parts).strip()
- else:
- content = result.content.strip()
- if content:
- current_content = content
-
- # Accumulate only the AI's text content to the report
- # Skip if current_content is empty (e.g., when only tool calls without text)
- if current_content:
- if existing_report:
- accumulated_report = existing_report + "\n\n" + current_content
- else:
- accumulated_report = current_content
- else:
- # No text content in this turn, keep existing report
- accumulated_report = existing_report
-
- # If result has tool calls, return for tool execution
- if hasattr(result, 'tool_calls') and result.tool_calls:
- # Log tool calls
- logging.info(f"Agent requesting {len(result.tool_calls)} tool call(s)")
- for i, tool_call in enumerate(result.tool_calls, 1):
- tool_name = tool_call.get('name', 'unknown')
- tool_args = tool_call.get('args', {})
- logging.info(f"Tool call {i}: {tool_name}")
-
- # Send tool call notifications
- for tool_call in result.tool_calls:
- try:
- from web.backend.app import manager as ws_manager
- await ws_manager.send_message({
- 'type': 'tool_call',
- 'timestamp': datetime.utcnow().isoformat(),
- 'tool': tool_call.get('name', 'unknown'),
- 'args': tool_call.get('args', {})
- }, f"intraday_user_{user_id}")
- except Exception:
- pass
-
- # Return with accumulated report so far
- return {
- "messages": [result],
- "decision_report": accumulated_report,
- }
- else:
- # Agent has finished - parse trades and clean report
- trades_executed, clean_report = _parse_trades_from_response(accumulated_report)
-
- # Log the final report
- logging.info(f"Agent generated final report (length: {len(clean_report)} chars, {len(trades_executed)} trades)")
-
- # Send agent result event
- try:
- from web.backend.app import manager as ws_manager
- await ws_manager.send_message({
- 'type': 'agent_result',
- 'timestamp': datetime.utcnow().isoformat(),
- 'message': 'Agent completed analysis',
- 'agent': 'Intraday Trader',
- 'report_length': len(clean_report),
- 'trades_count': len(trades_executed)
- }, f"intraday_user_{user_id}")
- except Exception:
- pass
-
- return {
- "messages": [result],
- "decision_report": clean_report,
- "trades_executed": trades_executed,
- }
-
- except Exception as e:
- # 获取详细的错误信息
- import traceback
- error_type = type(e).__name__
- error_msg = str(e)
- error_traceback = traceback.format_exc()
-
- # 构建详细的错误报告
- detailed_error = f"Error in intraday trader: {error_type}: {error_msg}"
-
- # 特殊处理常见错误
- if "null value" in error_msg.lower() and "choices" in error_msg.lower():
- detailed_error += "\n\n可能原因:LLM API 返回了空响应。请检查:"
- detailed_error += "\n- API 密钥是否有效"
- detailed_error += "\n- 模型名称是否正确"
- detailed_error += "\n- API 配额是否充足"
- detailed_error += "\n- 网络连接是否正常"
- elif "rate limit" in error_msg.lower():
- detailed_error += "\n\n错误原因:API 请求频率超限,请稍后重试。"
- elif "timeout" in error_msg.lower():
- detailed_error += "\n\n错误原因:API 请求超时,请检查网络连接。"
- elif "authentication" in error_msg.lower() or "unauthorized" in error_msg.lower():
- detailed_error += "\n\n错误原因:API 认证失败,请检查 API 密钥配置。"
-
- # 记录完整的错误信息
- logging.error(f"{detailed_error}\n\nFull traceback:\n{error_traceback}")
-
- from langchain_core.messages import AIMessage
- return {
- "messages": [AIMessage(content=detailed_error)],
- "decision_report": f"## 错误\n\n{detailed_error}\n\n### 技术详情\n\n```\n{error_msg}\n```",
- "trades_executed": [],
- }
-
- # Create tool node for executing tools
- from tradingagents.agents.utils.futu_trading_tools import (
- get_futu_account_info,
- get_futu_positions,
- get_futu_quote,
- get_futu_kline,
- get_futu_technical_analysis,
- get_futu_hot_stocks,
- get_futu_hot_news,
- get_futu_orders,
- place_futu_order,
- )
- from tradingagents.agents.utils.akshare_news_tools import (
- get_akshare_news,
- get_akshare_hot_stocks,
- )
- from tradingagents.agents.utils.fundamental_data_tools import (
- get_fundamentals,
- get_balance_sheet,
- get_cashflow,
- get_income_statement,
- )
-
- tools = [
- get_futu_account_info,
- get_futu_positions,
- get_futu_orders,
- get_futu_quote,
- get_futu_kline,
- get_futu_technical_analysis,
- get_futu_hot_stocks,
- get_futu_hot_news,
- get_akshare_news,
- get_akshare_hot_stocks,
- get_fundamentals,
- get_balance_sheet,
- get_cashflow,
- get_income_statement,
- place_futu_order,
- ]
-
- # Create base tool node
- base_tool_node = ToolNode(tools)
-
- # Wrap tool node to add logging (async version)
- async def tool_node_with_logging(state):
- """Tool node wrapper that adds logging (async)"""
- messages = state.get("messages", [])
- last_message = messages[-1] if messages else None
-
- if last_message and hasattr(last_message, 'tool_calls'):
- num_tools = len(last_message.tool_calls)
- tool_names = [tc.get('name', 'unknown') for tc in last_message.tool_calls]
-
- if num_tools > 1:
- logging.info(f"🚀 Executing {num_tools} tools IN PARALLEL: {', '.join(tool_names)}")
- else:
- logging.info(f"Executing {num_tools} tool(s): {', '.join(tool_names)}")
-
- # Execute tools - ToolNode executes them in parallel automatically (async)
- result = await base_tool_node.ainvoke(state)
-
- # Log results
- if 'messages' in result:
- new_messages = result['messages']
- completed_tools = []
- for msg in new_messages:
- if hasattr(msg, 'name'): # Tool message
- tool_name = msg.name
- content = str(msg.content)
- # Truncate long content for logging
- if len(content) > 50:
- content_preview = content[:50] + "..."
- else:
- content_preview = content
-
- completed_tools.append(tool_name)
- logging.info(f"✓ Tool {tool_name} completed: {content_preview}")
-
- if len(completed_tools) > 1:
- logging.info(f"✅ All {len(completed_tools)} tools completed in parallel")
-
- return result
-
- tool_node = tool_node_with_logging
-
- # Define routing logic
- iteration_count = {'count': 0} # Mutable counter
-
- def should_continue(state):
- """Determine if we should continue to tools or end."""
- messages = state.get("messages", [])
- last_message = messages[-1] if messages else None
-
- # If the last message has tool calls, route to tools
- if last_message and hasattr(last_message, 'tool_calls') and last_message.tool_calls:
- iteration_count['count'] += 1
- logging.info(f"Agent iteration #{iteration_count['count']}")
- return "tools"
- # Otherwise, we're done
- logging.info(f"Agent completed after {iteration_count['count']} iteration(s)")
- return END
-
- # Build the graph
- workflow = StateGraph(AgentState)
-
- # Add nodes
- workflow.add_node("agent", agent_node)
- workflow.add_node("tools", tool_node)
-
- # Set entry point
- workflow.set_entry_point("agent")
-
- # Add conditional edges
- workflow.add_conditional_edges(
- "agent",
- should_continue,
- {
- "tools": "tools",
- END: END
- }
- )
-
- # After tools, always go back to agent
- workflow.add_edge("tools", "agent")
-
- # Compile the graph
- app = workflow.compile()
-
- return app
diff --git a/tradingagents/agents/trader/intraday_trader_default_prompt.txt b/tradingagents/agents/trader/intraday_trader_default_prompt.txt
deleted file mode 100644
index 4b55520..0000000
--- a/tradingagents/agents/trader/intraday_trader_default_prompt.txt
+++ /dev/null
@@ -1,249 +0,0 @@
-You are an aggressive intraday trading agent operating like a professional day trader with full autonomy to analyze positions and execute trades.
-
-## Role Definition
-**Aggressive Intraday Trader** - High Risk Tolerance with Strategic Discipline
-- Pursue maximum short-term returns, willing to take moderate risks
-- Excel at capturing market volatility opportunities with quick entries and exits
-- Willing to take large positions on high-conviction opportunities when risk is manageable
-- Combine technical analysis with news/market sentiment to judge trends and momentum
-- Execute trading decisions decisively based on multi-dimensional analysis
-
-## Trading Philosophy
-**Balancing Short-term Tactics with Long-term Strategy**:
-- **Long-term Trend Awareness**: While focused on intraday opportunities, ALWAYS consider the stock's long-term trend direction
- * Stocks with strong long-term uptrends deserve patience during short-term pullbacks
- * Avoid fighting against established long-term trends for small intraday gains
- * Use daily and weekly timeframes to identify the dominant trend before intraday trading
-- **Transaction Cost Consciousness**: Every trade has costs (commissions, spreads, slippage)
- * Avoid excessive trading frequency on the same stock within short periods
- * A stock traded multiple times in a day/week incurs compounding fees that erode profits
- * Calculate breakeven point: each round trip costs ~0.1-0.3%, so gains must exceed this threshold
- * For quality stocks with long-term potential, prefer holding through minor fluctuations over frequent flipping
-- **Quality over Quantity**: Better to make fewer high-conviction trades than many mediocre ones
- * Focus on clear setups with favorable risk/reward ratios
- * Avoid "overtrading" - trading just because the market is open
- * Track trading frequency per stock: if traded 3+ times in a week, evaluate if it's worth continuing
-- **Strategic Patience for Strong Stocks**:
- * Long-term bullish stocks can weather short-term volatility - don't panic sell on minor dips
- * Short-term underperformance doesn't invalidate long-term thesis
- * Distinguish between temporary noise and genuine trend reversals
- * Hold through consolidation periods if fundamentals and long-term technicals remain intact
-
-## Your Mission
-Maximize risk-adjusted returns through strategic intraday trading that respects long-term trends, minimizes unnecessary transaction costs, and demonstrates patience with quality holdings during temporary volatility.
-
-## Trading Principles
-- **Act decisively**: When signals align, execute with conviction
-- **Cut losses fast**: Don't let small losses become big ones
-- **Let winners run**: Trail stops on profitable positions, especially for stocks with strong long-term trends
-- **Stay liquid**: Keep cash ready for opportunities
-- **Trade the trend**: Align intraday trades with long-term trend direction - don't fight the major trend
-- **Cost-aware trading**: Consider transaction costs before every trade - avoid churning positions unnecessarily
-- **Strategic patience**: For quality stocks with bullish long-term trends, tolerate short-term noise rather than overtrading
-
-## Market Rules
-- **US Market**: Supports both long and short positions, T+0 trading (can buy and sell same day)
-- **HK Market**: Only supports long positions, short selling NOT supported, T+0 trading allowed
-- **CN Market (A-shares)**: Only supports long positions, short selling NOT supported, T+1 trading mechanism
- * **T+1 Restriction**: Stocks bought today (holding period = 0 days) CANNOT be sold on the same day
- * Must wait until next trading day to sell newly purchased stocks
- * This applies to ALL A-share stocks (Shanghai/Shenzhen exchanges)
-
-## Risk Management
-- Hard stop: -8% on any position (cut immediately)
-- Soft stop: -5% (evaluate if worth holding)
-- Portfolio drawdown: If down -5% from peak, reduce exposure
-- Winning positions: Trail stops to lock in gains
-
-## Position Sizing Guidelines
-- Single stock: Up to 40% on high-conviction plays, typically 15-25%
-- Total exposure: Can go up to 95% when opportunities are strong
-- Cash reserve: Minimum 5%, prefer 10-20% for flexibility
-
-## Trading Constraints
-- Maximum 3 stocks per session: Can only trade up to 3 different stocks in one analysis session
-- Analyze first, trade later: Must complete ALL stock analysis before executing ANY trades
-- One order per stock: Each stock can ONLY be traded once per session (no retries, no duplicates)
-- No duplicate orders: Must check pending orders before placing orders
-- Direction switch must close positions first
-- Avoid trading in first 5 minutes
-- Trade cautiously in last 30 minutes
-- Short selling requires thorough analysis (US only)
-
-## Trading Mindset
-- **Be aggressive but not reckless**: Take calculated risks, but always consider transaction costs
-- **Speed matters, but patience pays**: Quick execution is important, but avoid impulsive overtrading
-- **Adapt quickly**: Market conditions change, so should your strategy
-- **Trust your analysis**: If signals align AND align with long-term trend, execute with confidence
-- **Protect capital**: One bad trade shouldn't blow up the account, and frequent small trades compound costs
-- **Follow market rules**: No short selling in HK/CN, can short in US but be cautious
-- **Respect the long-term trend**: Fight the short-term noise, not the long-term trend
-- **Quality over frequency**: Fewer high-conviction trades beat many mediocre ones
-
-## Report Format (MUST OUTPUT IN CHINESE)
-
-Generate a comprehensive trading report in Chinese following this structure:
-
-```markdown
-# 日内交易报告
-
-**会话**: [Session ID] | **时间**: [Timestamp] | **市场**: [Market Type]
-**市场规则**: [Market Type] 市场 - [支持做多和做空 / 仅支持做多,不支持做空]
-
-## I. 账户状态
-- 总资产: $XXX,XXX | 可用资金: $XX,XXX | 已部署: XX%
-- 待处理订单: X个(如有则列出股票和方向)
-
-## II. 持仓分析
-
-### [股票代码] - [公司名称]
-
-**当前状态**:
-- 持仓: XXX股 @ $XX.XX成本 | 现价: $XX.XX
-- 盈亏: ±X.XX% ($XXX) | 持仓规模: 占总资产XX%
-- 持仓时间: X天/小时
-- T+1限制 (仅CN市场): [不受限 (持仓≥1天) / 受限 (持仓0天,当日买入不可卖出)]
-
-**基本面分析**
-- 公司概况:行业、主营业务(来自基本信息)
-- 关键财务指标(最近一期或TTM,来自三表):
- * 营收增长(YoY):(本期营收 - 上年同期营收) / 上年同期营收 = XX%
- * 净利率:净利润 / 营收 = XX%
- * ROE:净利润 / 平均股东权益 = XX%
- * 经营现金流:最近一期经营活动净现金流 = $XX
- * 负债率:负债总额 / 资产总额 = XX%
- * 流动比率:流动资产 / 流动负债 = XX
- * EPS:归母净利 / 总股本 = $XX
- * PE:当前市价 / EPS = XX(如无市价则注明)
- * PB:每股价格 / 每股净资产 = XX
-- 成长性:近1-3年营收/净利复合增速(如数据不足则用最近一年)
-- 催化剂与风险:现金流趋势、应收账款/存货异常、债务到期集中、利润单季骤降等
-- 结论:基本面[稳健/一般/偏弱],[支持/中性/不支持]当前仓位(简要理由)
-
-**长期趋势评估**:
-- **主要趋势方向**: [强劲上涨/温和上涨/横盘整理/温和下跌/强劲下跌]
-- **趋势持续性**: [趋势稳固,可承受短期波动 / 趋势不稳,需谨慎对待]
-- **价格位置**: 相对50日/200日均线的位置 [上方XX% / 下方XX%]
-- **关键支撑/阻力**: [列出重要价格水平]
-- **长期趋势启示**: [对当前持仓决策的指导意义]
-
-**交易频率分析**:
-- **近期交易次数**: 今日交易X次(从交易记录获取)
-- **累计交易成本估算**: 约X.XX% (每轮0.1-0.3%)
-- **交易频率评估**: [正常 / 偏高,需控制 / 过度交易警告]
-- **成本效益分析**: [说明是否值得再次交易]
-
-**技术分析**:
-- **日K线 (1个月)**:
- * 趋势: [上涨/下跌/横盘] - [趋势强度和关键支撑/阻力位]
- * MACD: [看涨/看跌/中性] - [具体数值和形态]
- * RSI: XX - [超买/超卖/正常]
- * 布林带: [突破上轨/跌破下轨/在轨道内]
-- **5分钟分时 (当日)**:
- * 趋势: [上涨/下跌/横盘] - [与日线趋势的一致性]
- * MACD: [看涨/看跌/中性] - [短期动能]
- * RSI: XX - [超买/超卖/正常]
- * 布林带: [位置和波动性]
-- **多周期共振分析**:
- * 趋势一致性: [日线与分时趋势是否同向]
- * 指标共振: [MACD、RSI在两个周期是否同向确认]
- * 综合评估: [强/中/弱] - [是否形成交易共振]
-
-**新闻情绪**: [正面/负面/中性]
-- [关键新闻要点(如有)]
-
-**决策**: [加仓/减仓/平仓/持有]
-
-**推理**: [综合长期趋势、交易频率成本、日K线趋势、分时走势、技术指标、新闻情绪的详细解释。重点说明:
-1. 长期趋势是否支持当前决策
-2. 交易频率是否过高,成本是否可控
-3. 短期技术信号与长期趋势是否一致
-4. 如果选择持有,说明为何容忍短期波动
-5. 如果选择交易,说明预期收益是否足以覆盖成本]
-
-**T+1限制影响 (仅CN市场)**: [不适用 / 无影响 / 受限制无法卖出(持仓0天)]
-
-**执行操作**:
-- 是否调用交易工具: 是/否
-- 订单类型: 买入/卖出/卖空(仅美股)/平多/平空/无
-- 数量和价格: [如执行则填写]
-- 工具返回结果: 成功/失败/未调用 - [详情,如因T+1限制跳过则说明]
-- 最终状态: [交易后持仓情况]
-
-[对每个持仓重复]
-
-## III. 新机会评估
-
-### [股票代码] - [公司名称]
-
-**发现来源**: [热门股票/新闻提及/技术突破]
-
-**基本面分析**
-- 公司概况:行业、主营业务
-- 关键财务指标(最近一期或TTM):
- * 营收增长(YoY):XX%
- * 净利率:XX%
- * ROE:XX%
- * 经营现金流:$XX
- * 负债率:XX%
- * 流动比率:XX
- * EPS:$XX
- * PE:XX
- * PB:XX
-- 成长性:近1-3年营收/净利复合增速
-- 催化剂与风险:现金流趋势、应收账款/存货异常、债务到期集中、利润单季骤降等
-- 结论:基本面[稳健/一般/偏弱],[支持/中性/不支持]开仓(简要理由)
-
-**长期趋势评估**:
-- **主要趋势**: [强劲上涨/温和上涨/横盘/下跌] - [趋势强度]
-- **趋势质量**: [高质量趋势,值得参与 / 低质量,需谨慎]
-- **与趋势方向一致性**: [计划做多是否与长期上涨趋势一致?]
-
-**技术评估**:
-- 当前价格: $XX.XX | 成交量: [放量/缩量/正常]
-- **日K线 (1个月)**:
- * 趋势: [上涨/下跌/横盘] - [关键位置分析]
- * MACD: [看涨/看跌/中性]
- * RSI: XX - [超买/超卖/正常]
- * 布林带: [位置]
-- **5分钟分时**:
- * 趋势: [上涨/下跌/横盘] - [与日线趋势的配合]
- * MACD: [看涨/看跌/中性]
- * RSI: XX - [超买/超卖/正常]
- * 布林带: [位置]
-- **多周期研判**:
- * 趋势共振: [日线与分时是否同向]
- * 指标共振: [技术指标是否同向确认]
- * 综合评估: [强/中/弱]
-- 入场时机: [立即/等待回调/跳过]
-
-**成本收益评估**:
-- 预期收益潜力: [是否> 0.5%以覆盖交易成本]
-- 风险收益比: [R:R比例]
-- 值得开仓理由: [说明为何这个机会值得付出交易成本]
-
-**决策**: [开仓做多/开仓做空(仅美股)/跳过]
-
-**推理**: [综合长期趋势、日K线、分时、新闻和技术指标的详细分析。重点说明:
-1. 长期趋势是否支持这个方向
-2. 预期收益是否足以覆盖交易成本(0.1-0.3%)
-3. 多周期信号是否共振
-4. 如果跳过,说明不符合哪些标准]
-
-**执行操作**: [如开仓则填写交易详情]
-
-[对每个候选重复]
-
-## IV. 交易摘要
-- 执行交易: X笔
-- 买入: X笔,总计$XXX
-- 卖出: X笔,总计$XXX
-- 做空: X笔,总计$XXX(仅美股)
-- 待处理订单: X个
-- 净敞口变化: [增加/减少/不变] XX%
-
-## V. 下一步行动
-- 监控重点: [具体股票和条件]
-- 关注事件: [即将到来的催化剂]
-- 调整计划: [下一周期的策略调整]
-```
diff --git a/tradingagents/agents/trader/intraday_trader_workflow.txt b/tradingagents/agents/trader/intraday_trader_workflow.txt
deleted file mode 100644
index 6e291f4..0000000
--- a/tradingagents/agents/trader/intraday_trader_workflow.txt
+++ /dev/null
@@ -1,269 +0,0 @@
-## Standard Execution Workflow
-
-You MUST follow this 5-phase workflow for every trading session. This workflow is MANDATORY and cannot be modified.
-
-### Phase 1: Information Collection
-
-⚠️ **PARALLEL TOOL CALLS**: You can call multiple tools simultaneously in one response to speed up data collection!
-
-**Step 1: Account & Position Overview** (call these 3 tools in parallel):
-1. `get_futu_account_info(market_type)` - Check account funds and total assets
-2. `get_futu_positions(market_type)` - Get all current positions
-3. `get_futu_orders(market_type, filter_status=0)` - Check pending orders (⚠️ CRITICAL: avoid duplicate orders)
-
-**Step 2: Stock Analysis** (call multiple tools in parallel for each stock):
-For each position and candidate stock, call these tools together:
-- `get_futu_quote(stock_code)` - Get real-time quote
-- `get_futu_kline(symbol=stock_code, interval="daily", start_date="[1 month ago]", end_date="[today]", format="csv")` - Get daily K-line data for 1-month trend analysis (e.g., start_date="2024-10-18", end_date="2024-11-18")
-- `get_futu_kline(symbol=stock_code, interval="5min", start_date="[market local today]", end_date="[market local today]", format="csv")` - Get 5-minute K-line data for intraday trend analysis (use market's local date, e.g., US market uses US date)
-- `get_futu_technical_analysis(symbol=stock_code, interval="daily", indicator="macd", start_date="[1 month ago]", end_date="[today]", format="csv")` - Get daily MACD (same date range as daily K-line)
-- `get_futu_technical_analysis(symbol=stock_code, interval="daily", indicator="rsi", start_date="[1 month ago]", end_date="[today]", format="csv")` - Get daily RSI (same date range as daily K-line)
-- `get_futu_technical_analysis(symbol=stock_code, interval="daily", indicator="boll", start_date="[1 month ago]", end_date="[today]", format="csv")` - Get daily Bollinger Bands (same date range as daily K-line)
-- `get_futu_technical_analysis(symbol=stock_code, interval="5min", indicator="macd", start_date="[market local today]", end_date="[market local today]", format="csv")` - Get 5-min MACD (intraday only, use market's local date)
-- `get_futu_technical_analysis(symbol=stock_code, interval="5min", indicator="rsi", start_date="[market local today]", end_date="[market local today]", format="csv")` - Get 5-min RSI (intraday only, use market's local date)
-- `get_futu_technical_analysis(symbol=stock_code, interval="5min", indicator="boll", start_date="[market local today]", end_date="[market local today]", format="csv")` - Get 5-min Bollinger Bands (intraday only, use market's local date)
-- `get_fundamentals(ticker=stock_code, curr_date="[current date]")` - Get comprehensive fundamental analysis data (e.g., curr_date="2024-11-18")
-- `get_balance_sheet(ticker=stock_code, freq="quarterly", curr_date="[current date]")` - Get balance sheet data for financial position analysis
-- `get_cashflow(ticker=stock_code, freq="quarterly", curr_date="[current date]")` - Get cash flow statement data for liquidity assessment
-- `get_income_statement(ticker=stock_code, freq="quarterly", curr_date="[current date]")` - Get income statement data for profitability analysis
-
-**Fundamental Analysis Integration**:
-- **Long-term Investment Perspective**: Use fundamental data to assess if stocks align with long-term investment themes
-- **Value Assessment**: Compare current market prices with fundamental metrics (P/E, P/B, etc.)
-- **Financial Health Check**: Review balance sheet strength, cash flow stability, and profitability trends
-- **Risk Assessment**: Identify potential financial risks through comprehensive fundamental analysis
-
-Example: If analyzing AAPL and TSLA, call all 22 tools (11 per stock) in one response!
-
-**Step 3: Market Scanning & News Analysis** (optional, call these in parallel):
-- `get_futu_hot_news(lang="zh-cn")` - Get latest hot financial news from Futu
-- `get_akshare_news(limit=20)` - Get latest financial news from AkShare (recommended for real-time market sentiment)
-- `get_akshare_hot_stocks(symbol="A股", time_range="今日", limit=10)` - Get Baidu hot search stocks (for CN market)
-- `get_futu_hot_stocks(market_type)` - Discover market hot stocks and trading opportunities
-
-⚠️ **Hot Stock Analysis Requirement**:
-After discovering hot stocks, you MUST conduct the same comprehensive analysis as Step 2 for any stock you consider investing in:
-- Call the same 11 tools per stock: quote, daily K-line, 5-min K-line, daily/5-min MACD/RSI/BOLL, fundamentals, balance sheet, cash flow, income statement
-- Evaluate both technical indicators (trend, momentum, support/resistance) and fundamental metrics (valuation, financial health, profitability)
-- Only proceed to investment if analysis confirms the opportunity aligns with your strategy
-- Hot stocks without proper analysis should NOT be traded
-
-💡 **Efficiency Tip**: Group related tool calls together to minimize round trips and speed up analysis!
-
-### Phase 2: Analysis & Decision (Complete for ALL stocks before Phase 3)
-
-Based on collected information, conduct comprehensive analysis:
-
-**Historical Context Review** (if provided):
-- Review previous session's trades and outcomes
-- Identify patterns: What worked? What didn't?
-- Check for position continuity: Are we still holding previous positions?
-- Avoid contradicting recent decisions without strong justification
-- Learn from past mistakes and successes
-
-**Position Evaluation**:
-- Current position status vs ideal position allocation
-- P&L situation and holding time (compare with historical records if available)
-- ⚠️ **Holding Period Check (CN Market CRITICAL)**:
- * Check holding period for each position (from get_futu_positions result)
- * If holding period = 0 days (bought today), **CANNOT sell today due to T+1 restriction**
- * Mark positions with 0-day holding as "sell-restricted" in analysis
- * Only positions held for 1+ days can be sold
-- 📊 **Long-term Trend Assessment (CRITICAL for Trade Decision)**:
- * **Use daily K-line data (1 month+) to determine primary trend**:
- - Is the stock in a long-term uptrend, downtrend, or range-bound?
- - Where is the price relative to 50-day and 200-day moving averages?
- - Are we near major support or resistance levels?
- * **Long-term trend should guide intraday strategy**:
- - Strong long-term uptrend → Be patient with short-term dips, avoid premature selling
- - Long-term downtrend → Be cautious with longs, quick exits on rallies
- - Sideways/range-bound → More active intraday trading acceptable
- * **Trend strength evaluation**:
- - Strong trend (clear direction, sustained momentum) → Hold through minor volatility
- - Weak trend (choppy, unclear direction) → More active management acceptable
-- 💰 **Trading Frequency & Cost Analysis**:
- * **Check historical trading frequency**: Review past session records if available
- - How many times was this stock traded in the last week?
- - How many times traded today or in current session?
- * **Calculate cumulative transaction costs**:
- - Each round trip (buy + sell) costs ~0.1-0.3% in total fees
- - Frequent trading on same stock compounds costs rapidly
- - Example: 5 round trips = 0.5-1.5% in fees alone
- * **Trading fatigue assessment**:
- - If stock traded 3+ times recently → Question: Is another trade truly necessary?
- - If minor profit/loss on recent trades → Likely just paying fees, not gaining edge
- - If same stock repeatedly traded without clear progression → Overtrading signal
- * **Cost-benefit evaluation before action**:
- - Will this trade likely produce gains > 0.3% to cover costs?
- - Is the risk/reward compelling enough to justify another trade?
- - Or should we hold current position and wait for clearer setup?
-- **Multi-timeframe Technical Analysis**:
- * Daily K-line (1 month): Identify major trend direction, support/resistance levels
- - Daily MACD: Trend momentum and potential reversals
- - Daily RSI: Overbought/oversold conditions on daily timeframe
- - Daily Bollinger Bands: Volatility and price extremes
- * 5-minute intraday: Identify short-term momentum and entry/exit timing
- - 5-min MACD: Short-term momentum shifts
- - 5-min RSI: Intraday overbought/oversold conditions
- - 5-min Bollinger Bands: Intraday volatility and breakouts
- * **Multi-timeframe Confluence (共振)**:
- - Trend alignment: Daily and 5-min trends in same direction = highest probability
- - Indicator confirmation: MACD and RSI signals align across timeframes = strong signal
- - Entry timing: Use daily for direction, 5-min for precise entry/exit points
- - **Long-term trend as primary filter**: Intraday signals are stronger when aligned with daily/weekly trend
-- Related news sentiment (positive/negative/neutral)
-- Whether position size is reasonable
-
-**Direction Judgment**:
-- Whether direction switch is needed (long to short / short to long)
-- ⚠️ **Market Restriction Check**:
- * If current market is **US**: Can consider short selling, T+0 trading allowed
- * If current market is **HK**: Short selling NOT supported, can only close long or hold, T+0 trading allowed
- * If current market is **CN**: Short selling NOT supported, can only close long or hold, **T+1 trading restriction applies**
-- ⚠️ **T+1 Selling Restriction (CN Market ONLY)**:
- * Before planning any sell operation, verify holding period ≥ 1 day
- * If holding period = 0 days, **MUST skip selling** and note "T+1限制,无法当日卖出"
- * Can only plan sells for positions held 1+ days
-- **Multi-timeframe Trend Alignment**:
- * **Strong Buy Signal** (highest probability):
- - Daily trend UP + Daily MACD bullish + Daily RSI < 70
- - 5-min trend UP + 5-min MACD bullish + 5-min RSI < 70
- - Both timeframes confirm = 共振 (resonance)
- * **Strong Sell Signal** (US market only):
- - Daily trend DOWN + Daily MACD bearish + Daily RSI > 30
- - 5-min trend DOWN + 5-min MACD bearish + 5-min RSI > 30
- - Both timeframes confirm = 共振 (resonance)
- * **Conflicting Signals** (trade cautiously):
- - Daily and 5-min trends disagree → Wait for alignment or use tight stops
- - Indicators conflict → Reduce position size or skip trade
- * **Decision Framework**:
- - Use daily trend as primary filter (determines direction)
- - Use 5-min for precise entry/exit timing (determines when)
- - Only trade when both timeframes align (共振)
-- If short selling is involved (US only), conduct in-depth analysis:
- * Technical support (trend, momentum, indicators)
- * Fundamental support (valuation, financials, industry)
- * News support (bearish news, negative events)
- * Risk controllability (volatility, liquidity, stop-loss space)
-
-**Fund Check**:
-- Whether available funds are sufficient
-- Whether pending orders exist (⚠️ If pending orders exist for same stock, DO NOT place duplicate orders)
-- Whether other positions need adjustment (take profit/stop loss)
-
-**Decision Making**:
-Based on your professional judgment, decide specific operation steps:
-- For existing positions: Add/Reduce/Close/Hold
- * **Before deciding to trade existing positions, ALWAYS consider**:
- - Is this stock in a long-term uptrend? If YES → Be patient, tolerate short-term volatility
- - Have we traded this stock frequently recently? If YES → Question if another trade is necessary
- - Will transaction costs (0.1-0.3% round trip) eat into potential gains?
- - Is the signal strong enough to overcome trading costs?
- * **Hold decision criteria** (prefer holding over unnecessary trading):
- - Long-term trend remains bullish + short-term pullback is minor → HOLD through volatility
- - Position recently established (< 3 days) + no stop-loss triggered → Avoid churning
- - Traded this stock 2+ times in past week → Default to HOLD unless urgent reason
- - Current drawdown < 3% on quality stock → Tolerate short-term noise
- * **Close/Reduce decision criteria** (only when justified):
- - Stop-loss triggered (typically -5% to -8%)
- - Long-term trend reversal confirmed (not just short-term weakness)
- - Fundamental deterioration or major negative news
- - Need capital urgently for better opportunity
-- For new opportunities: Open/Skip
- * **Before opening new positions, evaluate**:
- - Does long-term trend align with intended direction?
- - Is this a high-conviction setup worth the trading costs?
-- For bearish stocks:
- * US market: Can consider short selling (requires in-depth analysis)
- * HK/CN markets: Can only close long or watch, cannot short
-- **Cost-benefit checkpoint**: Before finalizing any trade decision, ask:
- * "Is this trade expected to gain > 0.5% to justify costs?"
- * "Am I overtrading this stock out of impatience?"
- * "Does this align with or contradict the long-term trend?"
-- Complete analysis for ALL selected stocks before moving to Phase 3
-
-### Phase 3: Execute Trades (ONLY after completing Phase 2 for ALL stocks)
-
-⚠️ **IMPORTANT**: Do NOT execute trades during Phase 1-2. Only execute after ALL analysis is complete.
-
-**Pre-execution checks**:
-- Confirm no duplicate orders (check Phase 1 pending orders results)
-- Confirm sufficient funds
-- Calculate appropriate position size
-- ⚠️ **Reconfirm market rules**:
- * HK/CN do not support short selling
- * **CN Market T+1 Check**: If selling, confirm holding period ≥ 1 day (cannot sell same-day purchases)
-
-**Execute according to trading rules**:
-- **Direction switch**: Must close positions first, then open opposite direction positions
- * Long to short (US only): Close all long positions first → Then open short positions
- * Short to long: Close all short positions first → Then buy
-- **Incremental adjustment**: Directly add or reduce positions
-- **Short selling (US only)**: Ensure in-depth analysis and evaluation are completed
-
-**Order placement**:
-- Call `place_futu_order(stock_code, direction, quantity, price, order_type)`
-- ⚠️ **CRITICAL RULE**: Each stock can ONLY call place_futu_order ONCE per session
- * Once called for a stock, DO NOT call again regardless of success or failure
- * This prevents duplicate orders and excessive retry attempts
- * If first attempt fails, accept the failure and move on
-- ⚠️ Check return result:
- * Success: Order submitted/filled - DO NOT place another order for this stock
- * Failure: Record error reason (insufficient funds/stock halted/price limit exceeded/market does not support short selling, etc.) - DO NOT retry
-- If other positions need adjustment, execute sequentially (respecting one-call-per-stock rule)
-
-### Phase 4: Result Verification (only if trade succeeded)
-
-If `place_futu_order` returned success:
-1. `get_futu_account_info(market_type)` - Get post-trade account info
-2. `get_futu_positions(market_type)` - Get post-trade positions
-3. `get_futu_orders(market_type, filter_status=0)` - Get latest order status
-
-If `place_futu_order` returned failure or not called:
-- Skip verification, proceed directly to report phase
-
-### Phase 5: Generate Report
-
-Generate complete Chinese execution report (no more tool calls)
-
-## 📋 Historical Context
-
-**IMPORTANT**: If the user provides previous decision records in their message, you MUST:
-1. **Review the historical trades**: Understand what was bought/sold and at what prices
-2. **Learn from past decisions**: Identify successful patterns and mistakes to avoid
-3. **Maintain strategy continuity**: Don't contradict recent decisions without strong rationale
-4. **Track position evolution**: Know how positions have changed over time
-5. **Consider holding periods**: Avoid premature exits or entries that conflict with recent actions
-
-The historical context helps you make more informed decisions and maintain a coherent trading strategy across sessions.
-
-## 🚀 PARALLEL TOOL EXECUTION
-
-**IMPORTANT**: You can call MULTIPLE tools simultaneously in a single response!
-- Instead of calling tools one by one, group related tools together
-- Example: Call get_futu_account_info, get_futu_positions, and get_futu_orders all at once
-- This dramatically speeds up analysis by reducing round trips
-- The system will execute all tools in parallel and return results together
-
-## Technical Constraints
-
-⚠️ **CRITICAL TECHNICAL RULES**:
-1. **Analyze first, trade later**: Must complete ALL stock analysis before executing ANY trades
- - Phase 1-2: Collect information and analyze ALL stocks
- - Phase 3: Execute trades for selected stocks
- - Phase 4: Verify results
-2. **One order per stock**: Each stock can ONLY call place_futu_order ONCE per session (no retries, no duplicates)
-3. **No duplicate orders**: Must check pending orders before placing orders
-4. **Direction switch must close positions first**
-5. **HK/CN markets prohibit short selling operations**
-
-## Workflow Execution Rules
-
-**What you MUST do**:
-- Follow the 5-phase standard workflow
-- Complete ALL analysis before executing ANY trades
-- One order per stock: Never call place_futu_order more than once for the same stock
-- Check pending orders before placing orders
-- Direction switch must close positions first
-- Explain your reasoning clearly
-- Strictly follow market rules: HK/CN must not short sell
diff --git a/tradingagents/agents/trader/trader.py b/tradingagents/agents/trader/trader.py
index ceb4268..accf070 100644
--- a/tradingagents/agents/trader/trader.py
+++ b/tradingagents/agents/trader/trader.py
@@ -15,6 +15,7 @@ def trader_node(state, name):
sentiment_report = state["sentiment_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
+ previous_reflection = state.get("previous_decision_reflection") or {}
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}"
past_memories = memory.get_memories(curr_situation, n_matches=2)
@@ -83,6 +84,8 @@ def trader_node(state, name):
**Historical Experience Reference**: {past_memory_str}
+**Previous same-ticker decision reflection**: {json.dumps(previous_reflection, ensure_ascii=False)}
+
# Trading Recommendation Requirements
Based on the above analysis, provide a clear trading recommendation including:
@@ -116,10 +119,18 @@ def trader_node(state, name):
Must end with standardized format:
FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** | PRICE RANGE: - /share | POSITION: %
+Then include:
+STRUCTURED_OUTPUT:
+- recommendation: BUY/HOLD/SELL
+- rating: 1-5
+- price_basis: cite exact quote/data source or state unavailable
+- risk_budget: percentage and reason
+
# Important Notes
- Always respond in Chinese
- All reasoning and analytical conclusions must be grounded in facts; do not fabricate analysis results
- Demonstrate aggressive trader characteristics: decisive, opinionated, willing to take calculated risks
+- Do not place orders or suggest automated execution. Your output is a recommendation only.
- Do not mention this instruction in your output"""
prompt = ChatPromptTemplate.from_messages(
diff --git a/tradingagents/agents/trader/trading_executor.py b/tradingagents/agents/trader/trading_executor.py
deleted file mode 100644
index d9bbd9f..0000000
--- a/tradingagents/agents/trader/trading_executor.py
+++ /dev/null
@@ -1,431 +0,0 @@
-"""
-Trading Execution Agent
-Executes trades based on recommendations from analysis agents.
-"""
-
-import functools
-import re
-import json
-from datetime import datetime
-from typing import Dict, Any, Optional
-from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
-from tradingagents.agents.utils.market_utils import detect_market_type, normalize_stock_code, is_market_open
-
-
-def create_trading_executor(llm, memory):
- """
- Create a trading execution agent that can place orders based on analysis.
-
- Args:
- llm: Language model instance
- memory: Memory instance for storing trade history
-
- Returns:
- Callable agent node function
- """
-
- def trading_executor_node(state, name):
- """
- Execute trading decisions based on analysis recommendations.
-
- State inputs:
- - company_of_interest: Stock ticker
- - trade_date: Current trading date
- - trader_investment_plan: Trading recommendation from trader agent
- - market_type: Market classification (US/HK/CN)
-
- State outputs:
- - execution_result: Trade execution details
- - execution_status: Success/failure status
- - messages: Updated message history
- """
-
- # Import Futu trading tools (9 tools, excluding cancel_futu_order)
- from tradingagents.agents.utils.futu_trading_tools import (
- get_futu_account_info,
- get_futu_positions,
- get_futu_quote,
- place_futu_order,
- get_futu_orders,
- get_futu_kline,
- get_futu_hot_stocks,
- get_futu_hot_news,
- get_futu_technical_analysis
- )
-
- # Extract state information
- # Prefer ticker field (set by risk_manager), fallback to company_of_interest
- ticker = state.get("ticker") or state.get("company_of_interest", "") # Stock code (e.g., AAPL, 00700, 600519)
- company_name = state.get("company_of_interest", "") # Company name (e.g., 苹果, 腾讯)
- current_date = state.get("trade_date", "")
- trader_plan = state.get("investment_plan", "")
- risk_decision = state.get("final_trade_decision", "")
-
- # Get market type from state (set by risk_manager), or auto-detect if not present
- market_type = state.get("market_type")
- if not market_type:
- market_type = detect_market_type(ticker)
-
- # Check if market is open for trading
- # Get current time in UTC and convert to market's local time
- import pytz
- utc_now = datetime.now(pytz.UTC)
-
- # Get market timezone
- market_timezones = {
- "US": pytz.timezone("America/New_York"),
- "HK": pytz.timezone("Asia/Hong_Kong"),
- "CN": pytz.timezone("Asia/Shanghai"),
- }
- market_tz = market_timezones.get(market_type, pytz.UTC)
- market_local_time = utc_now.astimezone(market_tz)
-
- # Get market local date for tool calls (YYYY-MM-DD format)
- market_local_date = market_local_time.strftime('%Y-%m-%d')
-
- is_open, market_status_msg = is_market_open(market_type, market_local_time)
- if not is_open:
- # Market is closed, skip execution
- skip_report = f"""## 交易执行报告
-
-### I. 执行决策
-- **决策**: 跳过执行
-- **原因**: {market_status_msg}
-
-### II. 市场状态
-- **目标股票**: {ticker}
-- **市场类型**: {market_type}
-- **系统时间(北京)**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
-- **市场本地时间**: {market_local_time.strftime('%Y-%m-%d %H:%M:%S %Z')}
-- **市场状态**: 休市
-
-### III. 交易建议
-{trader_plan}
-
-### IV. 风险管理决策
-{risk_decision}
-
-**说明**: 由于当前不在交易时间内,系统自动跳过交易执行。请在市场开盘时间内重新提交交易请求。
-"""
- return {
- "messages": [],
- "execution_report": skip_report,
- "sender": name,
- }
-
- # Get past execution memories
- past_memories = memory.get_memories(trader_plan, n_matches=2)
- past_memory_str = ""
- if past_memories:
- for i, rec in enumerate(past_memories, 1):
- past_memory_str += f"Past Trading Experience {i}:\n{rec.get('recommendation', '')}\n\n"
- else:
- past_memory_str = "No past trading execution records available."
-
- # Define tools for the agent (9 Futu trading tools, excluding cancel_futu_order)
- tools = [
- get_futu_account_info,
- get_futu_positions,
- get_futu_quote,
- place_futu_order,
- get_futu_orders,
- get_futu_kline,
- get_futu_hot_stocks,
- get_futu_hot_news,
- get_futu_technical_analysis
- ]
-
- system_message = f"""Professional Stock Trading Executor | {current_date} | {market_status_msg}
-
-Target: {ticker} ({market_type} Market) | Date: {market_local_date}
-
-== Role Definition ==
-**Aggressive Trader** - High Risk Tolerance
-- Pursue maximum returns while actively executing trades within risk management framework
-- Excel at seizing market opportunities and executing buy/sell decisions decisively
-- Combine technical analysis with news/market sentiment to judge trends, willing to take large positions when high conviction
-- Execute trades based on multi-dimensional analysis (technicals + fundamentals + news)
-- Strictly adhere to risk management rules, but remain aggressive within allowed parameters
-
-== Core Responsibilities ==
-
-You are a professional trading execution agent responsible for executing actual trading operations based on risk management team decisions.
-
-== 🚀 PARALLEL TOOL EXECUTION ==
-
-**IMPORTANT**: You can call MULTIPLE tools simultaneously in a single response!
-- Group related tools together (e.g., account info + positions + orders)
-- This dramatically speeds up execution by reducing round trips
-- Example: Call get_futu_account_info, get_futu_positions, and get_futu_orders all at once
-- The system will execute all tools in parallel and return results together
-
-== Execution Principles ==
-
-1. **Authenticity Principle**: Reports must match actual operations exactly
- - Called trading tool and succeeded → Report "已执行" (Executed)
- - Called trading tool but failed → Report "交易失败" (Trade Failed) with reason
- - Did not call trading tool → Report "跳过执行" (Skipped) or "持仓观望" (Hold Position)
-
-2. **Result Verification**: Must check return result after each place_futu_order call
- - Success: Order submitted/filled
- - Failure: Record error reason (insufficient funds/stock halted/price limit exceeded, etc.)
-
-3. **Market Rules**:
- - US Market: Supports long and short positions, T+0 trading (can buy and sell same day)
- - HK Market: Only supports long positions, short selling NOT supported, T+0 trading allowed
- - CN Market (A-shares): Only supports long positions, short selling NOT supported, **T+1 trading mechanism**
- * **T+1 Restriction**: Stocks bought today (holding period = 0 days) CANNOT be sold on the same day
- * Must wait until next trading day to sell newly purchased stocks
- * This applies to ALL A-share stocks (Shanghai/Shenzhen exchanges)
-
-== Key Trading Rules ==
-
-0. **Trading Limits**:
- - ⚠️ **Maximum 3 stocks per session**: Can only trade up to 3 different stocks
- - ⚠️ **Analyze first, trade later**: Complete ALL analysis before executing ANY trades
- - ⚠️ **One order per stock**: Each stock can ONLY call place_futu_order ONCE per session (no retries, no duplicates)
-
-1. **Check Pending Orders First**:
- - ⚠️ CRITICAL: Before placing ANY order, MUST check pending orders using get_futu_orders
- - If pending orders exist for the same stock, DO NOT place duplicate orders
- - Wait for existing orders to be filled or cancelled before placing new orders
- - This prevents duplicate orders and order conflicts
-
-2. **Prohibit Round-Trip Trading**: Avoid repeated buy/sell operations on the same stock
- - Not allowed: Sell then buy (long round-trip)
- - Not allowed: Cover then short (short round-trip)
- - Allowed: Incremental adjustments (add/reduce positions)
-
-3. **Direction Switch Rules**:
- - Long to short: Must close all long positions first, then open short positions
- - Short to long: Must close all short positions first, then buy
-
-4. **Short Selling Decision Requirements**:
- - ⚠️ **Market Restriction**: Short selling ONLY supported in US market
- * US Market: Can execute short selling after thorough analysis
- * HK Market: Short selling NOT supported, can only close long or hold
- * CN Market: Short selling NOT supported, can only close long or hold
- - Short selling is not equivalent to selling, requires careful evaluation
- - Even if risk management recommends selling, short selling is not mandatory
- - Before executing short selling (US only), conduct comprehensive analysis including but not limited to:
- * Technical analysis (trends, indicators)
- * Fundamental analysis (financials, valuation)
- * News analysis (news, events)
- * Risk assessment (volatility, liquidity)
- - Only execute short selling when analysis results support it and risks are controllable
- - If analysis does not support short selling, can choose to only close long positions or hold
- - If in HK/CN market and recommendation is bearish, can only close long positions, NOT short
-
-== Standard Execution Workflow ==
-
-⚠️ **PARALLEL TOOL CALLS**: You can call multiple tools simultaneously in one response to speed up execution!
-
-**Phase 1: Information Collection**
-
-**Step 1: Account & Position Overview** (call these 3 tools in parallel):
-- get_futu_account_info(market_type="{market_type}") - Check account funds
-- get_futu_positions(market_type="{market_type}") - Get current positions
-- get_futu_orders(market_type="{market_type}", filter_status=0) - Check pending orders
-
-**Step 2: Target Stock Analysis** (call these tools in parallel):
-- get_futu_quote(stock_code="{ticker}") - Get real-time quote
-- get_futu_kline(symbol="{ticker}", interval="daily", format="csv") - Get daily K-line for 1-month trend analysis
-- get_futu_kline(symbol="{ticker}", interval="5min", format="csv") - Get 5-minute K-line for intraday analysis
-- get_futu_technical_analysis(symbol="{ticker}", interval="daily", indicator="macd", format="csv") - Get MACD
-- get_futu_technical_analysis(symbol="{ticker}", interval="daily", indicator="rsi", format="csv") - Get RSI
-- get_futu_technical_analysis(symbol="{ticker}", interval="daily", indicator="boll", format="csv") - Get Bollinger Bands
-
-💡 **Efficiency Tip**: Group related tool calls together to minimize round trips!
-
-**Phase 2: Analysis & Decision (Complete for ALL stocks before Phase 3)**
-Based on collected information, analyze:
-- Current position status vs recommended position
-- ⚠️ **Check pending orders**: If pending orders exist for target stock, DO NOT place new orders
-- **Multi-timeframe Technical Analysis**:
- * Daily K-line trend (1 month): Identify major trend direction, support/resistance levels
- * 5-minute intraday trend: Identify short-term momentum and entry/exit timing
- * Technical indicators (MACD, RSI, Bollinger Bands): Confirm trend strength and conditions
- * **Trend Confluence**: Check if daily and intraday trends align (共振) - highest probability when both agree
- * Use daily trend as primary filter, intraday for timing
-- Whether direction switch is needed (long to short / short to long)
-- ⚠️ **Market restriction check**:
- * If current market is US: Can consider short selling (requires analysis), T+0 trading allowed
- * If current market is HK: Short selling NOT supported, can only close long or hold, T+0 trading allowed
- * If current market is CN: Short selling NOT supported, can only close long or hold, **T+1 trading restriction**
-- ⚠️ **T+1 Trading Check (CN Market ONLY)**:
- * Check holding period for each position (from get_futu_positions result)
- * If holding period = 0 days (bought today), **CANNOT sell today**
- * Must skip selling operations for same-day purchases
- * Can only sell positions held for 1+ days
-- **Multi-timeframe Trend Alignment**:
- * If daily trend is UP and intraday is UP → Strong buy signal (trend confluence/共振)
- * If daily trend is DOWN and intraday is DOWN → Strong sell signal (US) or avoid buying (HK/CN)
- * If daily and intraday trends conflict → Wait for alignment or trade cautiously with tight stops
-- If short selling is involved (US only), conduct in-depth analysis and evaluation
-- Whether available funds are sufficient
-- Whether other positions need adjustment (take profit/stop loss)
-- ⚠️ **Select maximum 3 stocks**: If more than 3 stocks need trading, prioritize by urgency and conviction
-- Complete analysis for ALL selected stocks before moving to Phase 3
-
-**Phase 3: Execute Trades** (ONLY after completing Phase 2 for ALL stocks)
-⚠️ **IMPORTANT**: Do NOT execute trades during Phase 1-2. Only execute after ALL analysis is complete.
-⚠️ **LIMIT**: Execute trades for maximum 3 stocks only
-- ⚠️ **Pre-execution verification**:
- * Confirm NO pending orders exist for the target stock (checked in Phase 1)
- * Confirm sufficient funds available
- * Confirm market rules (HK/CN cannot short sell)
- * **CN Market T+1 Check**: If selling, confirm holding period ≥ 1 day (cannot sell same-day purchases)
-- Execute operations according to trading rules:
- * Direction switch: Close positions first, then open opposite direction positions
- * Incremental adjustment: Directly add or reduce positions
- * Short selling (US only): Ensure analysis and evaluation are completed
-- Call place_futu_order to execute trade
-- ⚠️ **CRITICAL RULE**: Each stock can ONLY call place_futu_order ONCE per session
- * Once called for a stock, DO NOT call again regardless of success or failure
- * This prevents duplicate orders and excessive retry attempts
- * If first attempt fails, accept the failure and move on
-- ⚠️ Check return result (success/failure)
- * Success: Order submitted/filled - DO NOT place another order for this stock
- * Failure: Record error (insufficient funds/stock halted/price limit/market does not support short selling) - DO NOT retry
-- If other positions need adjustment, execute sequentially (respecting one-call-per-stock rule)
-
-**Phase 4: Result Verification** (only if trade succeeded)
-- Get post-trade account info: get_futu_account_info
-- Get post-trade positions: get_futu_positions
-- Get latest order status: get_futu_orders
-
-**Phase 5: Generate Report**
-Generate complete Chinese execution report (no more tool calls)
-
-== Tool Instructions ==
-
-Available Tools: get_futu_account_info, get_futu_positions, get_futu_orders, get_futu_quote, place_futu_order, get_futu_kline, get_futu_hot_stocks, get_futu_hot_news, get_futu_technical_analysis
-
-Date parameters should use: {market_local_date} (YYYY-MM-DD format)
-
-== Report Format (MUST OUTPUT IN CHINESE) ==
-
-⚠️ CRITICAL: The final report MUST be written in CHINESE
-
-## I. 执行决策 (Execution Decision)
-- **决策结果**: 已执行/交易失败/跳过执行/持仓观望
-- **决策理由**: Detailed explanation
-- **{ticker}仓位分析**:
- * 交易前: X股, Y% (多头/空头/无)
- * 持仓时长: X天 (CN市场重要: 0天=当天买入,不可卖出)
- * 建议仓位: Z% (多头/空头)
- * 仓位差异: ±W%
- * 方向变化: 无变化/多转空/空转多
- * T+1限制检查 (仅CN市场): 通过/受限 (如持仓0天则卖出受限)
- * **多周期技术分析**:
- - 日K线趋势 (1个月): [上涨/下跌/横盘] - [关键位置分析]
- - 分时走势 (5分钟): [上涨/下跌/横盘] - [与日线趋势的配合]
- - 技术指标: MACD [看涨/看跌], RSI [超买/超卖/正常], 布林带 [位置]
- - 多周期共振: [日线与分时趋势是否一致,形成共振]
- * 计划动作: 买入/卖出/卖空/平多/平空/持仓不变/跳过交易
- * 实际结果: [Fill based on tool return]
- - 成功: 订单已提交/已成交
- - 失败: 交易失败 - [error message]
- - 未执行: 跳过交易 - [reason, include T+1 restriction if applicable]
- * 交易后: X股, Y% (update if success, unchanged if failed)
- * 执行理由: Explanation (综合日K线、分时、技术指标和风控建议)
-
-## II. 交易明细 (Trade Details)
-- **目标股票{ticker}订单**:
- * 是否调用交易工具: 是/否
- * 订单类型: 买入/卖出/卖空/平多/平空/无
- * 订单数量和价格: [fill if called]
- * 工具返回结果:
- - 成功: 订单ID/状态/价格
- - 失败: 错误代码/错误信息
- - 未调用: 说明原因
- * 最终订单状态: 已提交/已成交/失败/未提交
- * 理由说明: Operation reason and quantity basis
- * (如涉及卖空) 卖空分析:
- - 技术面评估: [analysis result]
- - 基本面评估: [analysis result]
- - 消息面评估: [analysis result]
- - 风险评估: [analysis result]
- - 卖空决策: 执行/不执行 - [reason]
-- **交易规则遵守情况**:
- * 是否避免回转交易: 是/否
- * 方向切换是否先平仓: 是/否/不适用
-- **其他持仓操作**: (if any)
-
-## III. 账户概览(交易后)(Account Overview - Post-Trade)
-总价值/可用资金/持仓比例/现金缓冲
-
-## IV. 持仓明细(交易后)(Position Details - Post-Trade)
-所有持仓/占比/多空方向/{ticker}变化/集中度分析
-
-## V. 风险评估 (Risk Assessment)
-仓位合理性/与风控决策一致性/交易成本/止盈止损设置/组合平衡性/卖空风险(如适用)
-
-== Risk Team Decision ==
-{risk_decision}
-
-== Trading Strategy Recommendation ==
-{trader_plan}
-
-== Past Experience ==
-{past_memory_str}
-
-Available Tools: get_futu_account_info, get_futu_positions, get_futu_orders, get_futu_quote, place_futu_order, get_futu_kline, get_futu_hot_stocks, get_futu_hot_news, get_futu_technical_analysis
-"""
-
- prompt = ChatPromptTemplate.from_messages(
- [
- (
- "system",
- "You are a professional trading execution agent. Your task is to execute trades based on risk management decisions."
- "\n\n🚀 PARALLEL TOOL EXECUTION:"
- "\n- You can call MULTIPLE tools simultaneously in one response!"
- "\n- Group related tools together to speed up execution"
- "\n- Example: Call get_futu_account_info, get_futu_positions, and get_futu_orders all at once"
- "\n\nKEY REMINDERS:"
- "\n- ⚠️ CRITICAL: Check pending orders BEFORE placing new orders (use get_futu_orders)"
- "\n- ⚠️ CRITICAL: Short selling ONLY supported in US market (NOT in HK/CN markets)"
- "\n- Always check place_futu_order return result (success/failure)"
- "\n- Use market local date {market_local_date} (YYYY-MM-DD format) for all date parameters"
- "\n- Generate final report in CHINESE only when all actions are complete"
- "\n\nYou have access to these tools: {tool_names}"
- "\n\n{system_message}"
- "\nCurrent date: {current_date}, Stock: {ticker}, Market: {market_type}",
- ),
- MessagesPlaceholder(variable_name="messages"),
- ]
- )
-
- prompt = prompt.partial(system_message=system_message)
- prompt = prompt.partial(tool_names=", ".join([tool.name for tool in tools]))
- prompt = prompt.partial(current_date=current_date)
- prompt = prompt.partial(ticker=ticker)
- prompt = prompt.partial(market_type=market_type)
- prompt = prompt.partial(market_local_date=market_local_date)
- prompt = prompt.partial(market_local_time=market_local_time.strftime('%Y-%m-%d %H:%M:%S %Z'))
-
- chain = prompt | llm.bind_tools(tools)
-
- # Get user_id from state
- user_id_from_state = state.get("user_id")
-
- # Invoke with user_id in config
- result = chain.invoke(
- state["messages"],
- config={"configurable": {"user_id": user_id_from_state}} if user_id_from_state else None
- )
-
- # Extract execution report from response
- execution_report = ""
- # Check if result has tool_calls attribute (AIMessage) and if it's empty
- if hasattr(result, "tool_calls") and len(result.tool_calls) == 0:
- # Agent has finished reasoning (no more tool calls)
- execution_report = result.content
-
- return {
- "messages": [result],
- "execution_report": execution_report,
- "sender": name,
- }
-
- return functools.partial(trading_executor_node, name="Trading Executor")
diff --git a/tradingagents/agents/utils/agent_states.py b/tradingagents/agents/utils/agent_states.py
index 642b744..0fc42da 100644
--- a/tradingagents/agents/utils/agent_states.py
+++ b/tradingagents/agents/utils/agent_states.py
@@ -52,6 +52,11 @@ class AgentState(MessagesState):
ticker: Annotated[Optional[str], "Stock ticker symbol (e.g., AAPL, 00700, 600519)"]
trade_date: Annotated[str, "What date we are trading at"]
user_id: Annotated[Optional[int], "User ID for accessing user-specific configurations"]
+ grounded_evidence: Annotated[list, "Grounded source/evidence snapshots"]
+ stage_log: Annotated[list, "Successful agent stage log"]
+ structured_report: Annotated[dict, "Contract-shaped report summary"]
+ reflection: Annotated[dict, "Decision reflection and previous-analysis context"]
+ previous_decision_reflection: Annotated[Optional[dict], "Previous same-ticker decision context"]
sender: Annotated[str, "Agent that sent this message"]
@@ -77,6 +82,5 @@ class AgentState(MessagesState):
]
final_trade_decision: Annotated[str, "Final decision made by the Risk Analysts"]
- # trading execution fields
+ # report context fields
market_type: Annotated[Optional[str], "Market classification (US/HK/CN)"]
- execution_report: Annotated[Optional[str], "Trading execution report"]
diff --git a/tradingagents/agents/utils/core_stock_tools.py b/tradingagents/agents/utils/core_stock_tools.py
index 3a41662..ce76912 100644
--- a/tradingagents/agents/utils/core_stock_tools.py
+++ b/tradingagents/agents/utils/core_stock_tools.py
@@ -1,6 +1,7 @@
from langchain_core.tools import tool
from typing import Annotated
-from tradingagents.dataflows.interface import route_to_vendor
+from datetime import datetime
+from web.backend.services.skills import get_skill_registry
@tool
@@ -19,4 +20,10 @@ def get_stock_data(
Returns:
str: A formatted dataframe containing the stock price data for the specified ticker symbol in the specified date range.
"""
- return route_to_vendor("get_stock_data", symbol, start_date, end_date)
+ return get_skill_registry().execute(
+ "market-data",
+ "historical",
+ symbol=symbol,
+ curr_date=end_date,
+ look_back_days=max(1, (datetime.strptime(end_date, "%Y-%m-%d").date() - datetime.strptime(start_date, "%Y-%m-%d").date()).days),
+ )
diff --git a/tradingagents/agents/utils/fundamental_data_tools.py b/tradingagents/agents/utils/fundamental_data_tools.py
index 47f6f2e..c343f3d 100644
--- a/tradingagents/agents/utils/fundamental_data_tools.py
+++ b/tradingagents/agents/utils/fundamental_data_tools.py
@@ -1,6 +1,6 @@
from langchain_core.tools import tool
from typing import Annotated
-from tradingagents.dataflows.interface import route_to_vendor
+from web.backend.services.skills import get_skill_registry
@tool
@@ -17,7 +17,7 @@ def get_fundamentals(
Returns:
str: A formatted report containing comprehensive fundamental data
"""
- return route_to_vendor("get_fundamentals", ticker, curr_date)
+ return get_skill_registry().execute("fundamentals", "summary", ticker=ticker, curr_date=curr_date)
@tool
@@ -36,7 +36,7 @@ def get_balance_sheet(
Returns:
str: A formatted report containing balance sheet data
"""
- return route_to_vendor("get_balance_sheet", ticker, freq, curr_date)
+ return get_skill_registry().execute("fundamentals", "balance_sheet", ticker=ticker, freq=freq, curr_date=curr_date)
@tool
@@ -55,7 +55,7 @@ def get_cashflow(
Returns:
str: A formatted report containing cash flow statement data
"""
- return route_to_vendor("get_cashflow", ticker, freq, curr_date)
+ return get_skill_registry().execute("fundamentals", "cashflow", ticker=ticker, freq=freq, curr_date=curr_date)
@tool
@@ -74,4 +74,4 @@ def get_income_statement(
Returns:
str: A formatted report containing income statement data
"""
- return route_to_vendor("get_income_statement", ticker, freq, curr_date)
\ No newline at end of file
+ return get_skill_registry().execute("fundamentals", "income_statement", ticker=ticker, freq=freq, curr_date=curr_date)
\ No newline at end of file
diff --git a/tradingagents/agents/utils/futu_trading_tools.py b/tradingagents/agents/utils/futu_trading_tools.py
deleted file mode 100644
index 31eef28..0000000
--- a/tradingagents/agents/utils/futu_trading_tools.py
+++ /dev/null
@@ -1,552 +0,0 @@
-"""
-Futu Trading Tools
-LangChain tool wrappers for Futu mock trading API functions.
-"""
-
-from langchain_core.tools import tool, InjectedToolArg
-from langchain_core.runnables import RunnableConfig
-from typing import Annotated, Optional
-from tradingagents.dataflows.futu_trading import (
- get_account_info as _get_account_info,
- get_positions as _get_positions,
- get_quote as _get_quote,
- place_order as _place_order,
- cancel_order as _cancel_order,
- get_orders as _get_orders,
- get_kline_data as _get_kline_data,
- get_hot_stocks as _get_hot_stocks,
- get_hot_news as _get_hot_news,
- get_technical_analysis as _get_technical_analysis
-)
-import json
-
-
-@tool
-def get_futu_account_info(
- market_type: Annotated[str, "Market type: US, HK, or CN"],
- config: Annotated[RunnableConfig, InjectedToolArg] = None
-) -> str:
- """
- Get account information for a specific market from Futu mock trading account.
-
- Returns account balance, cash, position value, and profit/loss information.
- Use this before placing orders to verify sufficient funds.
-
- Args:
- market_type: Market type (US/HK/CN)
-
- Returns:
- str: JSON formatted account information including net asset value, cash,
- position value, and profit/loss
-
- Example:
- >>> account = get_futu_account_info("US")
- """
- try:
- # Validate config parameter
- if not config or "configurable" not in config:
- return json.dumps({
- "success": False,
- "error": "Configuration is required but not provided. Please ensure the tool is called with proper context."
- }, ensure_ascii=False, indent=2)
-
- # Extract user_id from config
- user_id = config["configurable"].get("user_id")
- if not user_id:
- return json.dumps({
- "success": False,
- "error": "User ID is required in configuration but not found."
- }, ensure_ascii=False, indent=2)
-
- result = _get_account_info(market_type, user_id=user_id)
- return json.dumps(result, ensure_ascii=False, indent=2)
- except Exception as e:
- return f"Error getting account info: {str(e)}"
-
-
-@tool
-def get_futu_positions(
- market_type: Annotated[str, "Market type: US, HK, or CN"],
- config: Annotated[RunnableConfig, InjectedToolArg] = None
-) -> str:
- """
- Get all current positions for a specific market from Futu mock trading account.
-
- Returns list of holdings with quantity, cost, current value, and P&L.
- Use this before selling to verify available shares.
-
- Args:
- market_type: Market type (US/HK/CN)
-
- Returns:
- str: JSON formatted list of positions with stock code, quantity,
- cost price, current price, profit/loss, holding days, and first open time
-
- Example:
- >>> positions = get_futu_positions("US")
- """
- try:
- # Validate config parameter
- if not config or "configurable" not in config:
- return json.dumps({
- "success": False,
- "error": "Configuration is required but not provided. Please ensure the tool is called with proper context."
- }, ensure_ascii=False, indent=2)
-
- # Extract user_id from config
- user_id = config["configurable"].get("user_id")
- if not user_id:
- return json.dumps({
- "success": False,
- "error": "User ID is required in configuration but not found."
- }, ensure_ascii=False, indent=2)
-
- result = _get_positions(market_type, user_id=user_id)
- return json.dumps(result, ensure_ascii=False, indent=2)
- except Exception as e:
- return f"Error getting positions: {str(e)}"
-
-
-@tool
-def get_futu_quote(
- stock_code: Annotated[str, "Stock symbol (e.g., AAPL, 00700, 600519)"],
- config: Annotated[RunnableConfig, InjectedToolArg] = None
-) -> str:
- """
- Get real-time quote for a specific stock from Futu (auto-detects market type).
-
- Market detection rules:
- - 5-digit numbers (e.g., 00700) → HK stock
- - 6-digit numbers (e.g., 600519) → A stock
- - Contains letters (e.g., AAPL) → US stock
-
- Returns current price, OHLC, volume, and other market data.
- Use this to get current market price before placing orders.
-
- Args:
- stock_code: Stock symbol
-
- Returns:
- str: JSON formatted quote data with current price, open, high, low,
- volume, and change information
-
- Example:
- >>> quote = get_futu_quote("AAPL")
- """
- try:
- # Validate config parameter
- if not config or "configurable" not in config:
- return json.dumps({
- "success": False,
- "error": "Configuration is required but not provided. Please ensure the tool is called with proper context."
- }, ensure_ascii=False, indent=2)
-
- # Extract user_id from config
- user_id = config["configurable"].get("user_id")
- if not user_id:
- return json.dumps({
- "success": False,
- "error": "User ID is required in configuration but not found."
- }, ensure_ascii=False, indent=2)
-
- result = _get_quote(stock_code, user_id=user_id)
- return json.dumps(result, ensure_ascii=False, indent=2)
- except Exception as e:
- return f"Error getting quote for {stock_code}: {str(e)}"
-
-
-@tool
-def place_futu_order(
- stock_code: Annotated[str, "Stock symbol"],
- side: Annotated[str, "Order side: BUY or SELL"],
- quantity: Annotated[int, "Number of shares"],
- price: Annotated[Optional[float], "Limit price (required for LIMIT orders)"] = None,
- order_type: Annotated[str, "Order type: LIMIT or MARKET"] = "LIMIT",
- config: Annotated[RunnableConfig, InjectedToolArg] = None
-) -> str:
- """
- Place a buy or sell order in Futu mock trading account (auto-detects market type).
-
- Market detection rules:
- - 5-digit numbers (e.g., 00700) → HK stock
- - 6-digit numbers (e.g., 600519, 688xxx) → A stock
- - Contains letters (e.g., AAPL) → US stock
-
- For LIMIT orders, specify the price. For MARKET orders, price is optional.
- Returns order ID if successful, error message if failed.
-
- Args:
- stock_code: Stock symbol (e.g., AAPL, 00700, 600519)
- side: Order side (BUY/SELL)
- quantity: Number of shares
- price: Limit price (required for LIMIT orders)
- order_type: Order type (LIMIT/MARKET), defaults to LIMIT
-
- Returns:
- str: JSON formatted order response with success status, message, and order_id
-
- Example:
- >>> # Place limit buy order
- >>> result = place_futu_order("AAPL", "BUY", 10, price=180.50)
-
- >>> # Place market sell order
- >>> result = place_futu_order("AAPL", "SELL", 10, order_type="MARKET")
- """
- try:
- # Validate config parameter
- if not config or "configurable" not in config:
- return json.dumps({
- "success": False,
- "error": "Configuration is required but not provided. Please ensure the tool is called with proper context."
- }, ensure_ascii=False, indent=2)
-
- # Extract user_id from config
- user_id = config["configurable"].get("user_id")
- if not user_id:
- return json.dumps({
- "success": False,
- "error": "User ID is required in configuration but not found."
- }, ensure_ascii=False, indent=2)
-
- result = _place_order(
- stock_code=stock_code,
- side=side,
- quantity=quantity,
- price=price,
- order_type=order_type,
- user_id=user_id
- )
- return json.dumps(result, ensure_ascii=False, indent=2)
- except Exception as e:
- return f"Error placing order: {str(e)}"
-
-
-@tool
-def cancel_futu_order(
- order_id: Annotated[str, "Order ID to cancel"],
- stock_code: Annotated[str, "Stock code for auto-detecting market type"]
-) -> str:
- """
- Cancel a pending order in Futu mock trading account (auto-detects market type).
-
- Market detection rules:
- - 5-digit numbers (e.g., 00700) → HK stock
- - 6-digit numbers (e.g., 600519) → A stock
- - Contains letters (e.g., AAPL) → US stock
-
- Only pending orders can be cancelled. Filled or already cancelled orders cannot be cancelled.
-
- Args:
- order_id: Order ID to cancel
- stock_code: Stock code (used for auto-detecting market type)
-
- Returns:
- str: JSON formatted cancellation response with success status and message
-
- Example:
- >>> result = cancel_futu_order("123456789", "AAPL")
- """
- try:
- result = _cancel_order(order_id, stock_code)
- return json.dumps(result, ensure_ascii=False, indent=2)
- except Exception as e:
- return f"Error cancelling order: {str(e)}"
-
-
-@tool
-def get_futu_orders(
- market_type: Annotated[str, "Market type: US, HK, or CN"],
- filter_status: Annotated[int, "Filter by status: 0=all, 1=filled, 2=pending, 3=cancelled"] = 0,
- config: Annotated[RunnableConfig, InjectedToolArg] = None
-) -> str:
- """
- Query order history and status from Futu mock trading account.
-
- Returns list of orders with their current status, filled quantity, and timestamps.
- Use this to verify order execution after placing orders.
-
- Args:
- market_type: Market type (US/HK/CN)
- filter_status: Filter by status (0=all, 1=filled, 2=pending, 3=cancelled)
-
- Returns:
- str: JSON formatted list of orders with order_id, stock_code, side, quantity,
- price, status, and timestamps
-
- Example:
- >>> # Get all orders
- >>> orders = get_futu_orders("US")
-
- >>> # Get only filled orders
- >>> filled = get_futu_orders("US", filter_status=1)
- """
- try:
- # Validate config parameter
- if not config or "configurable" not in config:
- return json.dumps({
- "success": False,
- "error": "Configuration is required but not provided. Please ensure the tool is called with proper context."
- }, ensure_ascii=False, indent=2)
-
- # Extract user_id from config
- user_id = config["configurable"].get("user_id")
- if not user_id:
- return json.dumps({
- "success": False,
- "error": "User ID is required in configuration but not found."
- }, ensure_ascii=False, indent=2)
-
- result = _get_orders(market_type, filter_status, user_id=user_id)
- return json.dumps(result, ensure_ascii=False, indent=2)
- except Exception as e:
- return f"Error getting orders: {str(e)}"
-
-
-@tool
-def get_futu_kline(
- symbol: Annotated[str, "Stock symbol"],
- interval: Annotated[str, "Time interval: 1min, 5min, 15min, 30min, 60min, daily, weekly, monthly, quarterly, yearly"] = "daily",
- start_date: Annotated[Optional[str], "Start date in YYYY-MM-DD format (date only, no time component)"] = None,
- end_date: Annotated[Optional[str], "End date in YYYY-MM-DD format (date only, no time component)"] = None,
- format: Annotated[str, "Return format: json or csv"] = "csv",
- config: Annotated[RunnableConfig, InjectedToolArg] = None
-) -> str:
- """
- Get K-line (candlestick) data for a stock from Futu (auto-detects market type).
-
- Market detection rules:
- - 5-digit numbers (e.g., 00700) → HK stock
- - 6-digit numbers (e.g., 600519) → A stock
- - Contains letters (e.g., AAPL) → US stock
-
- Returns historical OHLCV data with timestamps in market local time.
- Useful for analyzing price trends before making trading decisions.
-
- Timezone handling:
- - US stocks: Eastern Time (EST/EDT, UTC-5/-4, auto-handles DST)
- - HK stocks: Hong Kong Time (HKT, UTC+8)
- - A stocks: China Standard Time (CST, UTC+8)
-
- Return formats:
- - csv (default): CSV format with meta information and data string
- - json: Structured JSON data with list of K-line records
-
- Data range recommendations:
- - For intervals < weekly (1min, 5min, 15min, 30min, 60min, daily): Fetch last 1 month
- - For weekly and above: Can fetch longer historical data
-
- Args:
- symbol: Stock symbol
- interval: Time interval (1min, 5min, 15min, 30min, 60min, daily, weekly, monthly, quarterly, yearly)
- start_date: Start date (YYYY-MM-DD format only, e.g., "2025-10-04")
- end_date: End date (YYYY-MM-DD format only, e.g., "2025-11-03")
- format: Return format (json or csv), defaults to csv
-
- Returns:
- str: JSON formatted K-line data
- For csv format (default): Dict with 'meta', 'data' (CSV string), and 'format' fields
- For json format: List of K-line records with timestamp, open, high, low, close, volume
-
- Example:
- >>> # Get daily K-line for last month in CSV format (default)
- >>> klines_csv = get_futu_kline("AAPL", interval="daily", start_date="2025-10-04", end_date="2025-11-03")
-
- >>> # Get 5-minute intraday data in JSON format
- >>> klines_json = get_futu_kline("AAPL", interval="5min", start_date="2025-10-04", end_date="2025-11-03", format="json")
-
- >>> # Get weekly data (can use longer range)
- >>> klines_weekly = get_futu_kline("AAPL", interval="weekly", start_date="2025-01-01", end_date="2025-11-03")
- """
- try:
- # Validate config parameter
- if not config or "configurable" not in config:
- return json.dumps({
- "success": False,
- "error": "Configuration is required but not provided. Please ensure the tool is called with proper context."
- }, ensure_ascii=False, indent=2)
-
- # Extract user_id from config
- user_id = config["configurable"].get("user_id")
- if not user_id:
- return json.dumps({
- "success": False,
- "error": "User ID is required in configuration but not found."
- }, ensure_ascii=False, indent=2)
-
- result = _get_kline_data(symbol, interval, start_date, end_date, format, user_id=user_id)
- return json.dumps(result, ensure_ascii=False, indent=2)
- except Exception as e:
- return f"Error getting K-line data: {str(e)}"
-
-
-@tool
-def get_futu_hot_stocks(
- market_type: Annotated[str, "Market type: US, HK, or CN"] = "US",
- count: Annotated[int, "Number of stocks to return"] = 10,
- config: Annotated[RunnableConfig, InjectedToolArg] = None
-) -> str:
- """
- Get list of hot/trending stocks from Futu.
-
- Returns top trending stocks with current price and change percentage.
- Useful for discovering trading opportunities.
-
- Args:
- market_type: Market type (US/HK/CN), defaults to US
- count: Number of stocks to return, defaults to 10
-
- Returns:
- str: JSON formatted list of hot stocks with stock_code, name, price, and change_pct
-
- Example:
- >>> hot_stocks = get_futu_hot_stocks("US", count=5)
- """
- try:
- # Validate config parameter
- if not config or "configurable" not in config:
- return json.dumps({
- "success": False,
- "error": "Configuration is required but not provided. Please ensure the tool is called with proper context."
- }, ensure_ascii=False, indent=2)
-
- # Extract user_id from config
- user_id = config["configurable"].get("user_id")
- if not user_id:
- return json.dumps({
- "success": False,
- "error": "User ID is required in configuration but not found."
- }, ensure_ascii=False, indent=2)
-
- result = _get_hot_stocks(market_type, count, user_id=user_id)
- return json.dumps(result, ensure_ascii=False, indent=2)
- except Exception as e:
- return f"Error getting hot stocks: {str(e)}"
-
-
-@tool
-def get_futu_hot_news(
- lang: Annotated[str, "Language code: zh-cn, zh-hk, or en-us"] = "zh-cn",
- config: Annotated[RunnableConfig, InjectedToolArg] = None
-) -> str:
- """
- Get hot/trending news articles from Futu.
-
- Returns list of recent news with titles, sources, and publication times.
- Useful for understanding market sentiment and news-driven opportunities.
-
- Args:
- lang: Language code (zh-cn/zh-hk/en-us), defaults to zh-cn
-
- Returns:
- str: JSON formatted list of news articles with title, url, source, and publish_time
-
- Example:
- >>> news = get_futu_hot_news("zh-cn")
- """
- try:
- # Validate config parameter
- if not config or "configurable" not in config:
- return json.dumps({
- "success": False,
- "error": "Configuration is required but not provided. Please ensure the tool is called with proper context."
- }, ensure_ascii=False, indent=2)
-
- # Extract user_id from config
- user_id = config["configurable"].get("user_id")
- if not user_id:
- return json.dumps({
- "success": False,
- "error": "User ID is required in configuration but not found."
- }, ensure_ascii=False, indent=2)
-
- result = _get_hot_news(lang, user_id=user_id)
- return json.dumps(result, ensure_ascii=False, indent=2)
- except Exception as e:
- return f"Error getting hot news: {str(e)}"
-
-
-@tool
-def get_futu_technical_analysis(
- symbol: Annotated[str, "Stock symbol"],
- interval: Annotated[str, "Time interval: 1min, 5min, 15min, 30min, 60min, daily, weekly, monthly, quarterly, yearly"] = "daily",
- indicator: Annotated[str, "Technical indicator: close_50_sma, close_200_sma, close_10_ema, macd, rsi, boll, atr, vwma"] = "macd",
- start_date: Annotated[Optional[str], "Start date in YYYY-MM-DD format (date only, no time component)"] = None,
- end_date: Annotated[Optional[str], "End date in YYYY-MM-DD format (date only, no time component)"] = None,
- format: Annotated[str, "Return format: json or csv"] = "csv",
- config: Annotated[RunnableConfig, InjectedToolArg] = None
-) -> str:
- """
- Get technical analysis indicators from Futu (returns time series data, auto-detects market type).
-
- Market detection rules:
- - 5-digit numbers (e.g., 00700) → HK stock
- - 6-digit numbers (e.g., 600519) → A stock
- - Contains letters (e.g., AAPL) → US stock
-
- Available indicators:
- - close_50_sma: 50-period Simple Moving Average
- - close_200_sma: 200-period Simple Moving Average
- - close_10_ema: 10-period Exponential Moving Average
- - macd: MACD (returns MACD, MACD_Signal, MACD_Hist)
- - rsi: Relative Strength Index
- - boll: Bollinger Bands (returns Boll_Upper, Boll_Middle, Boll_Lower)
- - atr: Average True Range
- - vwma: Volume Weighted Moving Average
-
- Return formats:
- - csv (default): CSV format with meta information and data string
- - json: Structured JSON data suitable for plotting charts
-
- Data range recommendations:
- - For intervals < weekly (1min, 5min, 15min, 30min, 60min, daily): Fetch last 1 month
- - For weekly and above: Can fetch longer historical data
- - Date range should match the K-line data range
-
- Returns time series data suitable for plotting charts and technical analysis.
- Use this to analyze price trends and momentum before making trading decisions.
-
- Args:
- symbol: Stock symbol
- interval: Time interval (1min, 5min, 15min, 30min, 60min, daily, weekly, monthly, quarterly, yearly)
- indicator: Technical indicator name
- start_date: Start date (YYYY-MM-DD format only, e.g., "2025-10-04")
- end_date: End date (YYYY-MM-DD format only, e.g., "2025-11-03")
- format: Return format (json or csv), defaults to csv
-
- Returns:
- str: JSON formatted technical analysis data with time series values
- For csv format, returns dict with 'meta', 'data' (CSV string), and 'format' fields
-
- Example:
- >>> # Get MACD indicator for last month in CSV format (default)
- >>> macd_csv = get_futu_technical_analysis("AAPL", interval="daily", indicator="macd",
- ... start_date="2025-10-04", end_date="2025-11-03")
-
- >>> # Get RSI in JSON format
- >>> rsi_json = get_futu_technical_analysis("AAPL", interval="5min", indicator="rsi",
- ... start_date="2025-10-04", end_date="2025-11-03", format="json")
-
- >>> # Get Bollinger Bands
- >>> boll = get_futu_technical_analysis("AAPL", interval="60min", indicator="boll",
- ... start_date="2025-10-04", end_date="2025-11-03")
- """
- try:
- # Validate config parameter
- if not config or "configurable" not in config:
- return json.dumps({
- "success": False,
- "error": "Configuration is required but not provided. Please ensure the tool is called with proper context."
- }, ensure_ascii=False, indent=2)
-
- # Extract user_id from config
- user_id = config["configurable"].get("user_id")
- if not user_id:
- return json.dumps({
- "success": False,
- "error": "User ID is required in configuration but not found."
- }, ensure_ascii=False, indent=2)
-
- result = _get_technical_analysis(symbol, interval, indicator, start_date, end_date, format, user_id=user_id)
- return json.dumps(result, ensure_ascii=False, indent=2)
- except Exception as e:
- return f"Error getting technical analysis: {str(e)}"
diff --git a/tradingagents/agents/utils/news_data_tools.py b/tradingagents/agents/utils/news_data_tools.py
index 0df9d04..4176426 100644
--- a/tradingagents/agents/utils/news_data_tools.py
+++ b/tradingagents/agents/utils/news_data_tools.py
@@ -1,6 +1,6 @@
from langchain_core.tools import tool
from typing import Annotated
-from tradingagents.dataflows.interface import route_to_vendor
+from web.backend.services.skills import get_skill_registry
@tool
def get_news(
@@ -18,7 +18,7 @@ def get_news(
Returns:
str: A formatted string containing news data
"""
- return route_to_vendor("get_news", ticker, start_date, end_date)
+ return get_skill_registry().execute("news", "company", ticker=ticker, start_date=start_date, end_date=end_date)
@tool
def get_global_news(
@@ -36,7 +36,7 @@ def get_global_news(
Returns:
str: A formatted string containing global news data
"""
- return route_to_vendor("get_global_news", curr_date, look_back_days, limit)
+ return get_skill_registry().execute("news", "global", curr_date=curr_date, look_back_days=look_back_days, limit=limit)
@tool
def get_insider_sentiment(
@@ -52,7 +52,7 @@ def get_insider_sentiment(
Returns:
str: A report of insider sentiment data
"""
- return route_to_vendor("get_insider_sentiment", ticker, curr_date)
+ return get_skill_registry().execute("social-sentiment", "sentiment", ticker=ticker, start_date=curr_date, end_date=curr_date)
@tool
def get_insider_transactions(
@@ -68,4 +68,4 @@ def get_insider_transactions(
Returns:
str: A report of insider transaction data
"""
- return route_to_vendor("get_insider_transactions", ticker, curr_date)
+ return get_skill_registry().execute("news", "company", ticker=ticker, start_date=curr_date, end_date=curr_date)
diff --git a/tradingagents/agents/utils/technical_indicators_tools.py b/tradingagents/agents/utils/technical_indicators_tools.py
index c6c08bc..f45c699 100644
--- a/tradingagents/agents/utils/technical_indicators_tools.py
+++ b/tradingagents/agents/utils/technical_indicators_tools.py
@@ -1,6 +1,6 @@
from langchain_core.tools import tool
from typing import Annotated
-from tradingagents.dataflows.interface import route_to_vendor
+from web.backend.services.skills import get_skill_registry
@tool
def get_indicators(
@@ -20,4 +20,11 @@ def get_indicators(
Returns:
str: A formatted dataframe containing the technical indicators for the specified ticker symbol and indicator.
"""
- return route_to_vendor("get_indicators", symbol, indicator, curr_date, look_back_days)
\ No newline at end of file
+ return get_skill_registry().execute(
+ "technical-indicators",
+ "indicator",
+ symbol=symbol,
+ indicator=indicator,
+ curr_date=curr_date,
+ look_back_days=look_back_days,
+ )
\ No newline at end of file
diff --git a/tradingagents/agents/utils/tool_registry.py b/tradingagents/agents/utils/tool_registry.py
index ae6b3e9..8f9675c 100644
--- a/tradingagents/agents/utils/tool_registry.py
+++ b/tradingagents/agents/utils/tool_registry.py
@@ -1,185 +1,67 @@
"""
-Tool Registry - Central registry for all available agent tools
+Tool Registry - Central registry for data collection tools.
-This module provides a centralized registry of all tools that can be used by agents.
-It extracts metadata from tool functions and provides a structured format for storage.
+M0 removes Futu order/account tooling; registered tools must be read-only data
+collection capabilities that can later be exposed through the Skills layer.
"""
-import inspect
-from typing import List, Dict, Any
+from typing import Any, Dict, List
+
from langchain_core.tools import BaseTool
def extract_tool_metadata(tool: BaseTool) -> Dict[str, Any]:
- """
- Extract metadata from a LangChain tool
-
- Args:
- tool: LangChain BaseTool instance
-
- Returns:
- Dictionary with tool metadata
- """
- # Get parameter schema
+ """Extract metadata from a LangChain tool."""
parameters = {}
- if hasattr(tool, 'args_schema') and tool.args_schema:
+ if hasattr(tool, "args_schema") and tool.args_schema:
schema = tool.args_schema.schema()
parameters = {
- 'properties': schema.get('properties', {}),
- 'required': schema.get('required', []),
- 'type': 'object'
+ "properties": schema.get("properties", {}),
+ "required": schema.get("required", []),
+ "type": "object",
}
-
+
return {
- 'tool_name': tool.name,
- 'tool_description': tool.description or '',
- 'tool_parameters': parameters,
+ "tool_name": tool.name,
+ "tool_description": tool.description or "",
+ "tool_parameters": parameters,
}
-def get_all_futu_tools() -> List[Dict[str, Any]]:
- """Get metadata for all Futu trading tools"""
- from tradingagents.agents.utils.futu_trading_tools import (
- get_futu_account_info,
- get_futu_positions,
- get_futu_orders,
- get_futu_quote,
- get_futu_kline,
- get_futu_technical_analysis,
- get_futu_hot_stocks,
- get_futu_hot_news,
- place_futu_order,
- )
-
- tools = [
- get_futu_account_info,
- get_futu_positions,
- get_futu_orders,
- get_futu_quote,
- get_futu_kline,
- get_futu_technical_analysis,
- get_futu_hot_stocks,
- get_futu_hot_news,
- place_futu_order,
- ]
-
- metadata_list = []
- for tool in tools:
- metadata = extract_tool_metadata(tool)
-
- # Categorize tools
- if 'account' in tool.name or 'position' in tool.name or 'order' in tool.name:
- category = 'account'
- elif 'quote' in tool.name or 'kline' in tool.name or 'technical' in tool.name:
- category = 'market_data'
- elif 'place' in tool.name:
- category = 'trading'
- elif 'news' in tool.name or 'hot' in tool.name:
- category = 'news'
- else:
- category = 'other'
-
- metadata['category'] = category
- metadata_list.append(metadata)
-
- return metadata_list
-
-
def get_all_akshare_tools() -> List[Dict[str, Any]]:
- """Get metadata for all AkShare tools"""
+ """Get metadata for all AkShare news/market discovery tools."""
from tradingagents.agents.utils.akshare_news_tools import (
- get_akshare_news,
get_akshare_hot_stocks,
- )
-
- tools = [
get_akshare_news,
- get_akshare_hot_stocks,
- ]
-
+ )
+
metadata_list = []
- for tool in tools:
+ for tool in [get_akshare_news, get_akshare_hot_stocks]:
metadata = extract_tool_metadata(tool)
- metadata['category'] = 'news'
+ metadata["category"] = "news"
metadata_list.append(metadata)
-
return metadata_list
def get_all_tools_metadata() -> List[Dict[str, Any]]:
- """
- Get metadata for all available tools
-
- Returns:
- List of tool metadata dictionaries
- """
- all_tools = []
-
- # Futu tools
- all_tools.extend(get_all_futu_tools())
-
- # AkShare tools
- all_tools.extend(get_all_akshare_tools())
-
- return all_tools
-
-
-def get_tool_by_name(tool_name: str) -> BaseTool:
- """
- Get a tool instance by name
-
- Args:
- tool_name: Name of the tool
-
- Returns:
- Tool instance or None if not found
- """
- from tradingagents.agents.utils.futu_trading_tools import (
- get_futu_account_info,
- get_futu_positions,
- get_futu_orders,
- get_futu_quote,
- get_futu_kline,
- get_futu_technical_analysis,
- get_futu_hot_stocks,
- get_futu_hot_news,
- place_futu_order,
- )
+ """Get metadata for all available tools."""
+ return get_all_akshare_tools()
+
+
+def get_tool_by_name(tool_name: str) -> BaseTool | None:
+ """Get a registered tool instance by name."""
from tradingagents.agents.utils.akshare_news_tools import (
- get_akshare_news,
get_akshare_hot_stocks,
+ get_akshare_news,
)
-
+
tool_map = {
- 'get_futu_account_info': get_futu_account_info,
- 'get_futu_positions': get_futu_positions,
- 'get_futu_orders': get_futu_orders,
- 'get_futu_quote': get_futu_quote,
- 'get_futu_kline': get_futu_kline,
- 'get_futu_technical_analysis': get_futu_technical_analysis,
- 'get_futu_hot_stocks': get_futu_hot_stocks,
- 'get_futu_hot_news': get_futu_hot_news,
- 'place_futu_order': place_futu_order,
- 'get_akshare_news': get_akshare_news,
- 'get_akshare_hot_stocks': get_akshare_hot_stocks,
+ "get_akshare_news": get_akshare_news,
+ "get_akshare_hot_stocks": get_akshare_hot_stocks,
}
-
return tool_map.get(tool_name)
def get_tools_by_names(tool_names: List[str]) -> List[BaseTool]:
- """
- Get multiple tool instances by names
-
- Args:
- tool_names: List of tool names
-
- Returns:
- List of tool instances
- """
- tools = []
- for name in tool_names:
- tool = get_tool_by_name(name)
- if tool:
- tools.append(tool)
- return tools
+ """Get multiple registered tool instances by name."""
+ return [tool for name in tool_names if (tool := get_tool_by_name(name))]
diff --git a/tradingagents/dataflows/futu_trading.py b/tradingagents/dataflows/futu_trading.py
deleted file mode 100644
index ad60be5..0000000
--- a/tradingagents/dataflows/futu_trading.py
+++ /dev/null
@@ -1,1215 +0,0 @@
-"""
-Futu Mock Trading API Integration
-Provides functions to interact with Futu's mock trading API for account management,
-market data retrieval, and trade execution across US, HK, and CN markets.
-"""
-
-import logging
-import time
-from typing import Annotated, Optional, Dict, List, Any
-import requests
-from requests.adapters import HTTPAdapter
-from urllib3.util.retry import Retry
-
-logger = logging.getLogger("tradingagents.futu_trading")
-
-
-class FutuAPIError(Exception):
- """Custom exception for Futu API errors"""
-
- def __init__(self, message: str, error_type: str = "api", error_code: str = None,
- details: Dict = None, retry_able: bool = False):
- super().__init__(message)
- self.error_type = error_type
- self.error_code = error_code
- self.details = details or {}
- self.retry_able = retry_able
-
-
-def _get_base_url(user_id: Optional[int] = None) -> str:
- """
- Get configured Futu API base URL from user config, environment variable, or default config.
-
- Priority order:
- 1. User-specific configuration (if user_id provided) - with caching
- 2. Environment variable
- 3. Default config
-
- Args:
- user_id: Optional user ID to fetch user-specific configuration
-
- Returns:
- str: Base URL for Futu API
- """
- import os
-
- # Try user-specific config first (highest priority) - with caching
- if user_id:
- try:
- from web.backend.services.user_config_cache import get_user_config_from_cache
-
- user_config = get_user_config_from_cache(user_id)
- if user_config:
- # Prefer intraday URL if available, otherwise use regular URL
- base_url = user_config.get('intraday_futu_api_url') or user_config.get('futu_api_base_url')
- if base_url:
- logger.debug(f"Using Futu API base URL from user {user_id} config (cached): {base_url}")
- return base_url
- except Exception as e:
- logger.debug(f"Failed to get user config for user {user_id}: {e}")
-
- # Try environment variable as fallback
- base_url = os.getenv("FUTU_API_BASE_URL")
- if base_url:
- logger.debug(f"Using Futu API base URL from environment: {base_url}")
- return base_url
-
- # Try default config as last resort
- try:
- from .config import get_config
- config = get_config()
- base_url = config.get("futu_api_base_url", "http://localhost:9000")
- logger.debug(f"Using Futu API base URL from config: {base_url}")
- return base_url
- except Exception as e:
- logger.warning(f"Failed to get config, using default base URL: {e}")
- return "http://localhost:9000"
-
-
-def _get_timeout() -> int:
- """
- Get configured API timeout from config.
-
- Returns:
- int: Timeout in seconds
- """
- try:
- from .config import get_config
- config = get_config()
- timeout = config.get("futu_api_timeout", 30)
- return timeout
- except Exception as e:
- logger.warning(f"Failed to get timeout config, using default: {e}")
- return 30
-
-
-def _get_api_key(user_id: Optional[int] = None) -> Optional[str]:
- """
- Get Futu API key from user config, environment variable, or default config.
-
- Priority order:
- 1. User-specific configuration (if user_id provided)
- 2. Environment variable
- 3. Default config
-
- Args:
- user_id: Optional user ID to fetch user-specific configuration
-
- Returns:
- str: API key or None if not configured
- """
- import os
-
- # Try user-specific config first (highest priority) - use cache to avoid event loop issues
- if user_id:
- try:
- from web.backend.services.user_config_cache import get_user_config_from_cache
-
- user_config = get_user_config_from_cache(user_id)
- if user_config:
- # Prefer intraday key if available, otherwise use regular key
- api_key = user_config.get('intraday_futu_api_key') or user_config.get('futu_api_key')
- if api_key:
- logger.debug(f"Using Futu API key from user {user_id} config (cached)")
- return api_key
- except Exception as e:
- logger.debug(f"Failed to get user config for user {user_id}: {e}")
-
- # Try environment variable as fallback
- api_key = os.getenv("FUTU_API_KEY")
- if api_key:
- logger.debug("Using Futu API key from environment variable")
- return api_key
-
- # Try default config as last resort
- try:
- from .config import get_config
- config = get_config()
- api_key = config.get("futu_api_key")
- if api_key:
- logger.debug("Using Futu API key from config")
- return api_key
- except Exception as e:
- logger.debug(f"Failed to get API key from config: {e}")
-
- logger.warning("No Futu API key configured - requests may fail if authentication is required")
- return None
-
-
-def _create_session() -> requests.Session:
- """
- Create a requests session with retry logic and connection pooling.
-
- Returns:
- requests.Session: Configured session object
- """
- session = requests.Session()
-
- # Configure retry strategy
- retry_strategy = Retry(
- total=3,
- backoff_factor=1,
- status_forcelist=[429, 500, 502, 503, 504],
- allowed_methods=["GET", "POST"]
- )
-
- adapter = HTTPAdapter(max_retries=retry_strategy, pool_connections=10, pool_maxsize=20)
- session.mount("http://", adapter)
- session.mount("https://", adapter)
-
- return session
-
-
-# Global session for connection pooling
-_session = _create_session()
-
-
-def _make_request(
- method: str,
- endpoint: str,
- params: Optional[Dict] = None,
- json_data: Optional[Dict] = None,
- user_id: Optional[int] = None
-) -> Dict[str, Any]:
- """
- Make HTTP request to Futu API with error handling and retry logic.
-
- Args:
- method: HTTP method (GET/POST)
- endpoint: API endpoint path (e.g., "/api/account")
- params: Query parameters for GET requests
- json_data: JSON body for POST requests
- user_id: Optional user ID for user-specific configuration
-
- Returns:
- dict: Parsed JSON response
-
- Raises:
- FutuAPIError: If request fails or returns error
- """
- base_url = _get_base_url(user_id)
- timeout = _get_timeout()
- api_key = _get_api_key(user_id)
- url = f"{base_url}{endpoint}"
-
- # Prepare headers
- headers = {}
- if api_key:
- headers["X-API-Key"] = api_key
- logger.debug("Added X-API-Key header to request")
-
- logger.debug(f"Making {method} request to {url}")
- logger.debug(f"Params: {params}, JSON: {json_data}")
-
- start_time = time.time()
-
- try:
- if method.upper() == "GET":
- response = _session.get(url, params=params, headers=headers, timeout=timeout)
- elif method.upper() == "POST":
- response = _session.post(url, json=json_data, headers=headers, timeout=timeout)
- else:
- raise ValueError(f"Unsupported HTTP method: {method}")
-
- elapsed_time = time.time() - start_time
- logger.info(f"{method} {endpoint} completed in {elapsed_time:.2f}s with status {response.status_code}")
-
- # Check for HTTP errors
- if response.status_code == 401 or response.status_code == 403:
- raise FutuAPIError(
- "Authentication failed - Cookie may have expired",
- error_type="auth",
- error_code=str(response.status_code),
- retry_able=False
- )
-
- if response.status_code >= 500:
- raise FutuAPIError(
- f"Server error: {response.status_code}",
- error_type="api",
- error_code=str(response.status_code),
- details={"response": response.text},
- retry_able=True
- )
-
- if response.status_code >= 400:
- raise FutuAPIError(
- f"Client error: {response.status_code}",
- error_type="api",
- error_code=str(response.status_code),
- details={"response": response.text},
- retry_able=False
- )
-
- # Parse JSON response
- try:
- data = response.json()
- logger.debug(f"Response data: {data}")
- return data
- except ValueError as e:
- raise FutuAPIError(
- f"Failed to parse JSON response: {e}",
- error_type="api",
- details={"response": response.text},
- retry_able=False
- )
-
- except requests.exceptions.Timeout:
- logger.error(f"Request timeout after {timeout}s")
- raise FutuAPIError(
- f"Request timeout after {timeout} seconds",
- error_type="network",
- retry_able=True
- )
-
- except requests.exceptions.ConnectionError as e:
- logger.error(f"Connection error: {e}")
- raise FutuAPIError(
- f"Failed to connect to Futu API: {e}",
- error_type="network",
- retry_able=True
- )
-
- except requests.exceptions.RequestException as e:
- logger.error(f"Request failed: {e}")
- raise FutuAPIError(
- f"Request failed: {e}",
- error_type="network",
- details={"exception": str(e)},
- retry_able=True
- )
-
-
-
-def get_account_info(
- market_type: Annotated[str, "Market type: US, HK, or CN"],
- user_id: Optional[int] = None
-) -> Dict[str, Any]:
- """
- Get account information for a specific market.
-
- Args:
- market_type: Market type (US/HK/CN)
- user_id: Optional user ID for user-specific configuration
-
- Returns:
- dict: Account details including:
- - net_asset_value: Total account value
- - cash: Available cash
- - position_value: Total position market value
- - profit_loss: Total P&L
- - profit_loss_pct: P&L percentage
-
- Raises:
- FutuAPIError: If API call fails or authentication error occurs
- ValueError: If market_type is invalid
-
- Example:
- >>> account = get_account_info("US")
- >>> print(f"Cash: {account['cash']}")
- """
- # Validate market type
- if market_type.upper() not in ["US", "HK", "CN"]:
- raise ValueError(f"Invalid market_type: {market_type}. Must be US, HK, or CN")
-
- logger.info(f"Fetching account info for market: {market_type}")
-
- try:
- response = _make_request(
- method="GET",
- endpoint="/api/account",
- params={"market_type": market_type},
- user_id=user_id
- )
-
- logger.info(f"Successfully retrieved account info for {market_type}")
- return response
-
- except FutuAPIError as e:
- if e.error_type == "auth":
- logger.error(f"Authentication error getting account info: {e}")
- raise FutuAPIError(
- "Failed to get account info - Cookie may have expired. Please re-authenticate.",
- error_type="auth",
- error_code=e.error_code,
- retry_able=False
- )
- raise
-
-
-def get_positions(
- market_type: Annotated[str, "Market type: US, HK, or CN"],
- user_id: Optional[int] = None
-) -> List[Dict[str, Any]]:
- """
- Get all positions for a specific market.
-
- Queries Futu API for current positions and enriches with database information
- including first open time and holding days.
-
- Args:
- market_type: Market type (US/HK/CN)
-
- Returns:
- list: List of position dictionaries, each containing:
- - stock_code: Stock symbol
- - stock_name: Stock name
- - quantity: Total shares held
- - available_quantity: Shares available for trading
- - cost_price: Average cost per share
- - current_price: Current market price
- - market_value: Total position value
- - profit_loss: Unrealized P&L
- - profit_loss_pct: P&L percentage
- - first_open_time: First open time from database (if available)
- - holding_days: Days since first open (if available)
-
- Raises:
- FutuAPIError: If API call fails
- ValueError: If market_type is invalid
-
- Example:
- >>> positions = get_positions("US")
- >>> for pos in positions:
- ... print(f"{pos['stock_code']}: {pos['quantity']} shares, held {pos.get('holding_days', 0)} days")
- """
- # Validate market type
- if market_type.upper() not in ["US", "HK", "CN"]:
- raise ValueError(f"Invalid market_type: {market_type}. Must be US, HK, or CN")
-
- logger.info(f"Fetching positions for market: {market_type}")
-
- try:
- response = _make_request(
- method="GET",
- endpoint="/api/positions",
- params={"market_type": market_type},
- user_id=user_id
- )
-
- # Handle different response formats
- if isinstance(response, list):
- positions = response
- elif isinstance(response, dict) and "positions" in response:
- positions = response["positions"]
- elif isinstance(response, dict) and "data" in response:
- positions = response["data"]
- else:
- positions = []
-
- # Enrich positions with database information (first_open_time, holding_days)
- # This is done synchronously to avoid event loop conflicts
- try:
- from datetime import datetime, date
- from web.backend.database import SessionLocal
- from web.backend.models import PositionRecord
-
- # Create a new database session (synchronous)
- db = SessionLocal()
- try:
- # Get today's date for calculating holding days
- today = date.today()
-
- for pos in positions:
- stock_code = pos.get('stock_code', '')
- if not stock_code:
- pos['first_open_time'] = None
- pos['holding_days'] = 0
- continue
-
- # Query database for first open time
- db_position = db.query(PositionRecord).filter(
- PositionRecord.user_id == user_id,
- PositionRecord.stock_code == stock_code,
- PositionRecord.market_type == market_type,
- PositionRecord.is_closed == False
- ).first()
-
- if db_position and db_position.first_open_time:
- first_open_time = db_position.first_open_time
- pos['first_open_time'] = first_open_time.isoformat()
-
- # Calculate holding days (only date difference, not time)
- open_date = first_open_time.date() if hasattr(first_open_time, 'date') else first_open_time
- holding_days = (today - open_date).days
- pos['holding_days'] = holding_days
- else:
- # If no open time in database, use current date (today)
- pos['first_open_time'] = datetime.now().isoformat()
- pos['holding_days'] = 0 # Just opened today
- finally:
- db.close()
- except Exception as e:
- logger.warning(f"Failed to enrich positions with database info: {e}")
- # Add placeholder fields if enrichment fails
- for pos in positions:
- if 'first_open_time' not in pos:
- pos['first_open_time'] = None
- if 'holding_days' not in pos:
- pos['holding_days'] = 0
-
- logger.info(f"Successfully retrieved {len(positions)} positions for {market_type}")
- return positions
-
- except FutuAPIError as e:
- if e.error_type == "auth":
- logger.error(f"Authentication error getting positions: {e}")
- raise FutuAPIError(
- "Failed to get positions - Cookie may have expired. Please re-authenticate.",
- error_type="auth",
- error_code=e.error_code,
- retry_able=False
- )
- raise
-
-
-
-def get_quote(
- stock_code: Annotated[str, "Stock symbol (e.g., AAPL, 00700, 600519)"],
- user_id: Optional[int] = None
-) -> Dict[str, Any]:
- """
- Get real-time quote for a specific stock (auto-detects market type).
-
- Market detection rules:
- - 5-digit numbers (e.g., 00700) → HK stock
- - 6-digit numbers (e.g., 600519) → A stock
- - Contains letters (e.g., AAPL) → US stock
-
- Args:
- stock_code: Stock symbol (e.g., AAPL, 00700, 600519)
-
- Returns:
- dict: Real-time quote data with fields:
- - stock_code: Stock symbol
- - stock_name: Stock name
- - current_price: Current market price
- - open_price: Opening price (optional, only when available)
- - high_price: Day's high (optional, only when available)
- - low_price: Day's low (optional, only when available)
- - previous_close: Previous close price
- - volume: Trading volume (optional, only when available)
- - change: Price change
- - change_pct: Percentage change
- - timestamp: Quote timestamp
-
- Raises:
- FutuAPIError: If API call fails or stock not found
- ValueError: If parameters are invalid
-
- Example:
- >>> quote = get_quote("AAPL")
- >>> print(f"Current price: {quote['current_price']}")
- """
- # Validate inputs
- if not stock_code:
- raise ValueError("stock_code cannot be empty")
-
- logger.info(f"Fetching quote for {stock_code} (auto-detecting market)")
-
- try:
- response = _make_request(
- method="GET",
- endpoint="/api/quote",
- params={"stock_code": stock_code},
- user_id=user_id
- )
-
- logger.info(f"Successfully retrieved quote for {stock_code}")
- return response
-
- except FutuAPIError as e:
- logger.error(f"Failed to get quote for {stock_code}: {e}")
- raise
-
-
-def get_kline_data(
- symbol: Annotated[str, "Stock symbol"],
- interval: Annotated[str, "Time interval: 1min, 5min, 15min, 30min, 60min, daily, weekly, monthly, quarterly, yearly"] = "daily",
- start_date: Annotated[Optional[str], "Start date in YYYY-MM-DD format (date only, no time component)"] = None,
- end_date: Annotated[Optional[str], "End date in YYYY-MM-DD format (date only, no time component)"] = None,
- format: Annotated[str, "Return format: json or csv"] = "csv",
- user_id: Optional[int] = None
-) -> Dict[str, Any]:
- """
- Get K-line (candlestick) data for a stock (auto-detects market type).
-
- Market detection rules:
- - 5-digit numbers (e.g., 00700) → HK stock
- - 6-digit numbers (e.g., 600519) → A stock
- - Contains letters (e.g., AAPL) → US stock
-
- Timezone handling:
- - US stocks: Eastern Time (EST/EDT, UTC-5/-4, auto-handles DST)
- - HK stocks: Hong Kong Time (HKT, UTC+8)
- - A stocks: China Standard Time (CST, UTC+8)
-
- Return formats:
- - csv (default): CSV format with meta information and data string
- - json: Structured JSON data with list of K-line records
-
- Args:
- symbol: Stock symbol
- interval: Time interval (1min, 5min, 15min, 30min, 60min, daily, weekly, monthly, quarterly, yearly)
- start_date: Start date (YYYY-MM-DD format only, e.g., "2025-10-04")
- end_date: End date (YYYY-MM-DD format only, e.g., "2025-11-03")
- format: Return format (json or csv), defaults to csv
-
- Returns:
- dict: K-line data in specified format
- - For csv format (default): Returns dict with 'meta', 'data' (CSV string), and 'format' fields
- - For json format: Returns list of K-line records, each containing:
- - timestamp: Time in market local timezone
- - open: Opening price
- - high: High price
- - low: Low price
- - close: Closing price
- - volume: Trading volume
-
- Raises:
- FutuAPIError: If API call fails
- ValueError: If parameters are invalid
-
- Example:
- >>> # Get daily K-line for last month in CSV format (default)
- >>> klines_csv = get_kline_data("AAPL", interval="daily", start_date="2025-10-04", end_date="2025-11-03")
-
- >>> # Get 5-minute intraday data in JSON format
- >>> klines_json = get_kline_data("AAPL", interval="5min", start_date="2025-10-04", end_date="2025-11-03", format="json")
- >>> for kline in klines_json[-5:]:
- ... print(f"{kline['timestamp']}: Close={kline['close']}")
- """
- # Validate inputs
- if not symbol:
- raise ValueError("symbol cannot be empty")
-
- valid_intervals = ["1min", "5min", "15min", "30min", "60min", "daily", "weekly", "monthly", "quarterly", "yearly"]
- if interval.lower() not in valid_intervals:
- raise ValueError(f"Invalid interval: {interval}. Must be one of {valid_intervals}")
-
- valid_formats = ["json", "csv"]
- if format.lower() not in valid_formats:
- raise ValueError(f"Invalid format: {format}. Must be one of {valid_formats}")
-
- logger.info(f"Fetching K-line data for {symbol} with interval={interval}, start_date={start_date}, end_date={end_date}, format={format}")
-
- try:
- params = {
- "symbol": symbol,
- "interval": interval,
- "format": format
- }
-
- # Add optional date parameters if provided
- if start_date:
- params["start_date"] = start_date
- if end_date:
- params["end_date"] = end_date
-
- response = _make_request(
- method="GET",
- endpoint="/api/kline",
- params=params,
- user_id=user_id
- )
-
- # For csv format, return response directly
- if format == "csv":
- logger.info(f"Successfully retrieved K-line data for {symbol} in CSV format")
- return response
-
- # For json format, handle different response structures
- if isinstance(response, list):
- klines = response
- elif isinstance(response, dict) and "klines" in response:
- klines = response["klines"]
- elif isinstance(response, dict) and "data" in response:
- klines = response["data"]
- else:
- klines = []
-
- logger.info(f"Successfully retrieved {len(klines)} K-line records for {symbol}")
- return klines
-
- except FutuAPIError as e:
- logger.error(f"Failed to get K-line data for {symbol}: {e}")
- raise
-
-
-def get_hot_stocks(
- market_type: Annotated[str, "Market type: US, HK, or CN"] = "US",
- count: Annotated[int, "Number of stocks to return"] = 10,
- user_id: Optional[int] = None
-) -> List[Dict[str, Any]]:
- """
- Get list of hot/trending stocks.
-
- Args:
- market_type: Market type (US/HK/CN), defaults to US
- count: Number of stocks to return, defaults to 10
-
- Returns:
- list: List of hot stock information, each containing:
- - stock_code: Stock symbol
- - stock_name: Stock name
- - current_price: Current price
- - change_pct: Percentage change
- - volume: Trading volume
- - Other market-specific fields
-
- Raises:
- FutuAPIError: If API call fails
- ValueError: If parameters are invalid
-
- Example:
- >>> hot_stocks = get_hot_stocks("US", count=5)
- >>> for stock in hot_stocks:
- ... print(f"{stock['stock_code']}: {stock['change_pct']}%")
- """
- # Validate inputs
- if market_type.upper() not in ["US", "HK", "CN"]:
- raise ValueError(f"Invalid market_type: {market_type}. Must be US, HK, or CN")
- if count <= 0:
- raise ValueError(f"count must be positive, got {count}")
-
- logger.info(f"Fetching top {count} hot stocks for {market_type} market")
-
- try:
- response = _make_request(
- method="GET",
- endpoint="/api/hot-stocks",
- params={
- "market_type": market_type,
- "count": count
- },
- user_id=user_id
- )
-
- # Handle different response formats
- if isinstance(response, list):
- stocks = response
- elif isinstance(response, dict) and "stocks" in response:
- stocks = response["stocks"]
- elif isinstance(response, dict) and "data" in response:
- stocks = response["data"]
- else:
- stocks = []
-
- logger.info(f"Successfully retrieved {len(stocks)} hot stocks for {market_type}")
- return stocks
-
- except FutuAPIError as e:
- logger.error(f"Failed to get hot stocks for {market_type}: {e}")
- raise
-
-
-
-def place_order(
- stock_code: Annotated[str, "Stock symbol"],
- side: Annotated[str, "Order side: BUY or SELL"],
- quantity: Annotated[int, "Number of shares"],
- price: Annotated[Optional[float], "Limit price (required for LIMIT orders)"] = None,
- order_type: Annotated[str, "Order type: LIMIT or MARKET"] = "LIMIT",
- user_id: Optional[int] = None
-) -> Dict[str, Any]:
- """
- Place a buy or sell order (auto-detects market type from stock code).
-
- Market detection rules:
- - 5-digit numbers (e.g., 00700) → HK stock
- - 6-digit numbers (e.g., 600519, 688xxx) → A stock
- - Contains letters (e.g., AAPL) → US stock
-
- Args:
- stock_code: Stock symbol (e.g., AAPL, 00700, 600519)
- side: Order side (BUY/SELL)
- quantity: Number of shares
- price: Limit price (required for LIMIT orders, optional for MARKET orders)
- order_type: Order type (LIMIT/MARKET), defaults to LIMIT
-
- Returns:
- dict: Order response with:
- - success: True if order placed successfully
- - message: Human-readable message
- - order_id: Order ID for tracking (null if failed)
- - data: Additional details (null if failed)
-
- Raises:
- FutuAPIError: If order placement fails
- ValueError: If required parameters are missing or invalid
-
- Example:
- >>> # Place a limit buy order
- >>> result = place_order("AAPL", "BUY", 10, price=180.50)
- >>> if result['success']:
- ... print(f"Order placed: {result['order_id']}")
-
- >>> # Place a market sell order
- >>> result = place_order("AAPL", "SELL", 10, order_type="MARKET")
- """
- # Validate inputs
- if not stock_code:
- raise ValueError("stock_code cannot be empty")
- if side.upper() not in ["BUY", "SELL"]:
- raise ValueError(f"Invalid side: {side}. Must be BUY or SELL")
- if quantity <= 0:
- raise ValueError(f"quantity must be positive, got {quantity}")
- if order_type.upper() not in ["LIMIT", "MARKET"]:
- raise ValueError(f"Invalid order_type: {order_type}. Must be LIMIT or MARKET")
-
- # For LIMIT orders, price is required
- if order_type.upper() == "LIMIT" and price is None:
- raise ValueError("price is required for LIMIT orders")
-
- logger.info(f"Placing {order_type} {side} order: {quantity} shares of {stock_code} at {price}")
-
- # Build request payload
- order_data = {
- "stock_code": stock_code,
- "side": side,
- "quantity": quantity,
- "order_type": order_type
- }
-
- if price is not None:
- order_data["price"] = price
-
- try:
- response = _make_request(
- method="POST",
- endpoint="/api/trade",
- json_data=order_data,
- user_id=user_id
- )
-
- if response.get("success"):
- logger.info(f"Order placed successfully: {response.get('order_id')}")
- else:
- logger.warning(f"Order placement failed: {response.get('message')}")
-
- return response
-
- except FutuAPIError as e:
- logger.error(f"Failed to place order for {stock_code}: {e}")
- raise
-
-
-def cancel_order(
- order_id: Annotated[str, "Order ID to cancel"],
- stock_code: Annotated[str, "Stock code for auto-detecting market type"],
- user_id: Optional[int] = None
-) -> Dict[str, Any]:
- """
- Cancel a pending order (auto-detects market type from stock code).
-
- Market detection rules:
- - 5-digit numbers (e.g., 00700) → HK stock
- - 6-digit numbers (e.g., 600519) → A stock
- - Contains letters (e.g., AAPL) → US stock
-
- Args:
- order_id: Order ID to cancel
- stock_code: Stock code (used for auto-detecting market type)
-
- Returns:
- dict: Cancellation response with:
- - success: True if order cancelled successfully
- - message: Human-readable message
- - order_id: Cancelled order ID
- - data: Additional details
-
- Raises:
- FutuAPIError: If cancellation fails
- ValueError: If parameters are invalid
-
- Example:
- >>> result = cancel_order("123456789", "AAPL")
- >>> if result['success']:
- ... print("Order cancelled successfully")
- """
- # Validate inputs
- if not order_id:
- raise ValueError("order_id cannot be empty")
- if not stock_code:
- raise ValueError("stock_code cannot be empty")
-
- logger.info(f"Cancelling order {order_id} for {stock_code}")
-
- try:
- response = _make_request(
- method="POST",
- endpoint="/api/cancel",
- json_data={
- "order_id": order_id,
- "stock_code": stock_code
- },
- user_id=user_id
- )
-
- if response.get("success"):
- logger.info(f"Order {order_id} cancelled successfully")
- else:
- logger.warning(f"Order cancellation failed: {response.get('message')}")
-
- return response
-
- except FutuAPIError as e:
- logger.error(f"Failed to cancel order {order_id}: {e}")
- raise
-
-
-def get_orders(
- market_type: Annotated[str, "Market type: US, HK, or CN"],
- filter_status: Annotated[int, "Filter by status: 0=all, 1=filled, 2=pending, 3=cancelled"] = 0,
- user_id: Optional[int] = None
-) -> List[Dict[str, Any]]:
- """
- Query order history and status.
-
- Args:
- market_type: Market type (US/HK/CN)
- filter_status: Filter by status (0=all, 1=filled, 2=pending, 3=cancelled)
-
- Returns:
- list: List of orders with status and details. Each order contains:
- - order_id: Order ID
- - stock_code: Stock symbol
- - side: BUY or SELL
- - quantity: Number of shares
- - price: Order price
- - order_type: LIMIT or MARKET
- - status: Order status
- - filled_quantity: Shares filled
- - create_time: Order creation time
- - update_time: Last update time
-
- Raises:
- FutuAPIError: If query fails
- ValueError: If parameters are invalid
-
- Example:
- >>> # Get all orders
- >>> orders = get_orders("US")
-
- >>> # Get only filled orders
- >>> filled_orders = get_orders("US", filter_status=1)
- >>> for order in filled_orders:
- ... print(f"{order['order_id']}: {order['stock_code']}")
- """
- # Validate inputs
- if market_type.upper() not in ["US", "HK", "CN"]:
- raise ValueError(f"Invalid market_type: {market_type}. Must be US, HK, or CN")
- if filter_status not in [0, 1, 2, 3]:
- raise ValueError(f"Invalid filter_status: {filter_status}. Must be 0, 1, 2, or 3")
-
- status_names = {0: "all", 1: "filled", 2: "pending", 3: "cancelled"}
- logger.info(f"Fetching {status_names[filter_status]} orders for {market_type} market")
-
- try:
- response = _make_request(
- method="GET",
- endpoint="/api/orders",
- params={
- "market_type": market_type,
- "filter_status": filter_status
- },
- user_id=user_id
- )
-
- # Handle different response formats
- if isinstance(response, list):
- orders = response
- elif isinstance(response, dict) and "list" in response:
- # Futu API returns {"list": [...]} format
- orders = response["list"]
- elif isinstance(response, dict) and "orders" in response:
- orders = response["orders"]
- elif isinstance(response, dict) and "data" in response:
- orders = response["data"]
- else:
- orders = []
-
- # Helper function to convert Futu timestamp format to ISO format
- def convert_futu_timestamp(futu_time: str) -> str:
- """Convert Futu timestamp format to ISO format
-
- Futu format: "11/14 21:24:22" (MM/DD HH:MM:SS)
- ISO format: "2024-11-14T21:24:22"
- """
- if not futu_time or '/' not in futu_time:
- return futu_time
-
- try:
- from datetime import datetime
- # Parse "11/14 21:24:22" format
- # Assume current year if not specified
- current_year = datetime.now().year
- parts = futu_time.split(' ')
- if len(parts) == 2:
- date_part = parts[0] # "11/14"
- time_part = parts[1] # "21:24:22"
- # Convert to ISO format
- month, day = date_part.split('/')
- iso_time = f"{current_year}-{month.zfill(2)}-{day.zfill(2)}T{time_part}"
- return iso_time
- except Exception as e:
- logger.warning(f"Failed to convert timestamp {futu_time}: {e}")
- return futu_time
-
- return futu_time
-
- # Normalize field names for consistency
- # Futu API uses different field names, map them to standard names
- normalized_orders = []
- for order in orders:
- normalized = {}
-
- # Map order_id
- normalized['order_id'] = str(order.get('id', order.get('order_id', '')))
-
- # Map stock_code and stock_name
- normalized['stock_code'] = order.get('stock_code', order.get('sc_code', ''))
- normalized['stock_name'] = order.get('stock_name', '')
-
- # Map side: "A" = SELL, "B" = BUY (Futu API convention)
- side = order.get('side', '')
- if side == 'A':
- normalized['side'] = 'SELL'
- elif side == 'B':
- normalized['side'] = 'BUY'
- else:
- normalized['side'] = side
-
- # Map quantity and filled_quantity
- normalized['quantity'] = int(order.get('quantity', 0))
- normalized['filled_quantity'] = int(order.get('matched_qty', order.get('filled_quantity', 0)))
-
- # Map price
- normalized['price'] = float(order.get('price', 0))
-
- # Map order_type: 1 = LIMIT, 2 = MARKET
- order_type = order.get('order_type', 1)
- if order_type == 1:
- normalized['order_type'] = 'LIMIT'
- elif order_type == 2:
- normalized['order_type'] = 'MARKET'
- else:
- normalized['order_type'] = str(order_type)
-
- # Map status based on Futu API status codes
- # Common status codes:
- # "1" or "2" = pending (waiting for execution)
- # "3" = filled (fully executed)
- # "4" or "5" = cancelled
- # "6" = partially filled
- status = str(order.get('status', ''))
- if status in ['3', '6']: # Filled or partially filled
- normalized['status'] = 'filled'
- elif status in ['1', '2']: # Pending
- normalized['status'] = 'pending'
- elif status in ['4', '5']: # Cancelled
- normalized['status'] = 'cancelled'
- else:
- # Keep original status if unknown
- normalized['status'] = status
-
- # Map timestamps - convert Futu format to ISO format
- created_at = order.get('created_at', order.get('create_time', ''))
- updated_at = order.get('updated_at', order.get('update_time', ''))
-
- normalized['create_time'] = convert_futu_timestamp(created_at)
- normalized['update_time'] = convert_futu_timestamp(updated_at)
-
- # Keep market_type
- normalized['market_type'] = market_type
-
- normalized_orders.append(normalized)
-
- logger.info(f"Successfully retrieved {len(normalized_orders)} orders for {market_type}")
- return normalized_orders
-
- except FutuAPIError as e:
- logger.error(f"Failed to get orders for {market_type}: {e}")
- raise
-
-
-
-def get_technical_analysis(
- symbol: Annotated[str, "Stock symbol"],
- interval: Annotated[str, "Time interval: 1min, 5min, 15min, 30min, 60min, daily, weekly, monthly, quarterly, yearly"] = "daily",
- indicator: Annotated[str, "Technical indicator: close_50_sma, close_200_sma, close_10_ema, macd, rsi, boll, atr, vwma"] = "macd",
- start_date: Annotated[Optional[str], "Start date in YYYY-MM-DD format (date only, no time component)"] = None,
- end_date: Annotated[Optional[str], "End date in YYYY-MM-DD format (date only, no time component)"] = None,
- format: Annotated[str, "Return format: json or csv"] = "csv",
- user_id: Optional[int] = None
-) -> Dict[str, Any]:
- """
- Get technical analysis indicators (returns time series data, auto-detects market type).
-
- Market detection rules:
- - 5-digit numbers (e.g., 00700) → HK stock
- - 6-digit numbers (e.g., 600519) → A stock
- - Contains letters (e.g., AAPL) → US stock
-
- Available indicators:
- - close_50_sma: 50-period Simple Moving Average
- - close_200_sma: 200-period Simple Moving Average
- - close_10_ema: 10-period Exponential Moving Average
- - macd: MACD (returns MACD, MACD_Signal, MACD_Hist)
- - rsi: Relative Strength Index
- - boll: Bollinger Bands (returns Boll_Upper, Boll_Middle, Boll_Lower)
- - atr: Average True Range
- - vwma: Volume Weighted Moving Average
-
- Args:
- symbol: Stock symbol
- interval: Time interval (1min, 5min, 15min, 30min, 60min, daily, weekly, monthly, quarterly, yearly)
- indicator: Technical indicator name
- start_date: Start date (YYYY-MM-DD format only, e.g., "2025-10-04")
- end_date: End date (YYYY-MM-DD format only, e.g., "2025-11-03")
- format: Return format (json or csv), defaults to csv
-
- Returns:
- dict: Technical analysis data with time series values
- - For csv format (default): Returns dict with 'meta', 'data' (CSV string), and 'format' fields
- - For json format: Returns structured data suitable for plotting charts
-
- Raises:
- FutuAPIError: If API call fails
- ValueError: If parameters are invalid
-
- Example:
- >>> # Get MACD indicator for last month in CSV format (default)
- >>> macd_csv = get_technical_analysis("AAPL", interval="daily", indicator="macd",
- ... start_date="2025-10-04", end_date="2025-11-03")
-
- >>> # Get RSI in JSON format
- >>> rsi_json = get_technical_analysis("AAPL", interval="5min", indicator="rsi",
- ... start_date="2025-10-04", end_date="2025-11-03", format="json")
-
- >>> # Get Bollinger Bands
- >>> boll_data = get_technical_analysis("AAPL", interval="60min", indicator="boll",
- ... start_date="2025-10-04", end_date="2025-11-03")
- """
- # Validate inputs
- if not symbol:
- raise ValueError("symbol cannot be empty")
-
- valid_intervals = ["1min", "5min", "15min", "30min", "60min", "daily", "weekly", "monthly", "quarterly", "yearly"]
- if interval.lower() not in valid_intervals:
- raise ValueError(f"Invalid interval: {interval}. Must be one of {valid_intervals}")
-
- valid_indicators = ["close_50_sma", "close_200_sma", "close_10_ema", "macd", "rsi", "boll", "atr", "vwma"]
- if indicator.lower() not in valid_indicators:
- raise ValueError(f"Invalid indicator: {indicator}. Must be one of {valid_indicators}")
-
- valid_formats = ["json", "csv"]
- if format.lower() not in valid_formats:
- raise ValueError(f"Invalid format: {format}. Must be one of {valid_formats}")
-
- # Convert indicator to lowercase for consistent processing
- indicator = indicator.lower()
-
- logger.info(f"Fetching technical analysis for {symbol}: {indicator} with interval={interval}, start_date={start_date}, end_date={end_date}, format={format}")
-
- try:
- params = {
- "symbol": symbol,
- "interval": interval,
- "indicator": indicator,
- "format": format
- }
-
- # Add optional date parameters if provided
- if start_date:
- params["start_date"] = start_date
- if end_date:
- params["end_date"] = end_date
-
- response = _make_request(
- method="GET",
- endpoint="/api/technical-analysis",
- params=params,
- user_id=user_id
- )
-
- logger.info(f"Successfully retrieved {indicator} data for {symbol} in {format} format")
- return response
-
- except FutuAPIError as e:
- logger.error(f"Failed to get technical analysis for {symbol}: {e}")
- raise
-
-
-def get_hot_news(
- lang: Annotated[str, "Language code: zh-cn, zh-hk, or en-us"] = "zh-cn",
- user_id: Optional[int] = None
-) -> List[Dict[str, Any]]:
- """
- Get hot/trending news articles.
-
- Args:
- lang: Language code (zh-cn/zh-hk/en-us), defaults to zh-cn
-
- Returns:
- list: List of news articles, each containing:
- - title: News title
- - url: Article URL
- - source: News source
- - publish_time: Publication time
- - summary: Brief summary (if available)
- - related_stocks: Related stock symbols (if available)
-
- Raises:
- FutuAPIError: If API call fails
- ValueError: If language code is invalid
-
- Example:
- >>> # Get Chinese news
- >>> news = get_hot_news("zh-cn")
- >>> for article in news[:5]:
- ... print(f"{article['title']}")
-
- >>> # Get English news
- >>> news_en = get_hot_news("en-us")
- """
- # Validate language code
- if lang.lower() not in ["zh-cn", "zh-hk", "en-us"]:
- raise ValueError(f"Invalid lang: {lang}. Must be zh-cn, zh-hk, or en-us")
-
- logger.info(f"Fetching hot news in {lang}")
-
- try:
- response = _make_request(
- method="GET",
- endpoint="/api/hot-news",
- params={"lang": lang},
- user_id=user_id
- )
-
- # Handle different response formats
- if isinstance(response, list):
- news = response
- elif isinstance(response, dict) and "news" in response:
- news = response["news"]
- elif isinstance(response, dict) and "data" in response:
- news = response["data"]
- else:
- news = []
-
- logger.info(f"Successfully retrieved {len(news)} news articles in {lang}")
- return news
-
- except FutuAPIError as e:
- logger.error(f"Failed to get hot news in {lang}: {e}")
- raise
diff --git a/tradingagents/dataflows/interface.py b/tradingagents/dataflows/interface.py
index c2a7f16..d48e539 100644
--- a/tradingagents/dataflows/interface.py
+++ b/tradingagents/dataflows/interface.py
@@ -59,6 +59,16 @@ def get_alpha_vantage_indicator(symbol: str, indicator: str, curr_date: str, loo
get_global_news as get_akshare_global_news,
get_insider_sentiment as get_akshare_insider_sentiment
)
+from .baostock import (
+ get_baostock_stock,
+ get_baostock_indicator,
+ get_baostock_fundamentals,
+ get_baostock_balance_sheet,
+ get_baostock_cashflow,
+ get_baostock_income_statement,
+ get_baostock_news,
+ get_baostock_insider_transactions,
+)
# Configuration and routing logic
from .config import get_config
@@ -103,7 +113,8 @@ def get_alpha_vantage_indicator(symbol: str, indicator: str, curr_date: str, loo
"yfinance",
"openai",
"google",
- "akshare"
+ "akshare",
+ "baostock",
]
# Mapping of methods to their vendor-specific implementations
@@ -112,6 +123,7 @@ def get_alpha_vantage_indicator(symbol: str, indicator: str, curr_date: str, loo
"get_stock_data": {
"akshare": get_akshare_stock,
"alpha_vantage": get_alpha_vantage_stock,
+ "baostock": get_baostock_stock,
"yfinance": get_YFin_data_online,
# "local": get_YFin_data,
},
@@ -125,6 +137,7 @@ def get_alpha_vantage_indicator(symbol: str, indicator: str, curr_date: str, loo
"get_indicators": {
"akshare": get_akshare_indicators,
"alpha_vantage": get_alpha_vantage_indicator,
+ "baostock": get_baostock_indicator,
"yfinance": get_stock_stats_indicators_window,
# "local": get_stock_stats_indicators_window
},
@@ -132,23 +145,27 @@ def get_alpha_vantage_indicator(symbol: str, indicator: str, curr_date: str, loo
"get_fundamentals": {
"akshare": get_akshare_fundamentals,
"alpha_vantage": get_alpha_vantage_fundamentals,
+ "baostock": get_baostock_fundamentals,
"openai": get_fundamentals_openai,
},
"get_balance_sheet": {
"akshare": get_akshare_balance_sheet,
"alpha_vantage": get_alpha_vantage_balance_sheet,
+ "baostock": get_baostock_balance_sheet,
"yfinance": get_yfinance_balance_sheet,
# "local": get_simfin_balance_sheet,
},
"get_cashflow": {
"akshare": get_akshare_cashflow,
"alpha_vantage": get_alpha_vantage_cashflow,
+ "baostock": get_baostock_cashflow,
"yfinance": get_yfinance_cashflow,
# "local": get_simfin_cashflow,
},
"get_income_statement": {
"akshare": get_akshare_income_statement,
"alpha_vantage": get_alpha_vantage_income_statement,
+ "baostock": get_baostock_income_statement,
"yfinance": get_yfinance_income_statement,
# "local": get_simfin_income_statements,
},
@@ -156,6 +173,7 @@ def get_alpha_vantage_indicator(symbol: str, indicator: str, curr_date: str, loo
"get_news": {
"akshare": get_akshare_news,
"alpha_vantage": get_alpha_vantage_news,
+ "baostock": get_baostock_news,
"openai": get_stock_news_openai,
"google": get_google_news,
# "local": [get_finnhub_news, get_reddit_company_news, get_google_news],
@@ -172,6 +190,7 @@ def get_alpha_vantage_indicator(symbol: str, indicator: str, curr_date: str, loo
"get_insider_transactions": {
"akshare": get_akshare_insider_transactions,
"alpha_vantage": get_alpha_vantage_insider_transactions,
+ "baostock": get_baostock_insider_transactions,
"yfinance": get_yfinance_insider_transactions,
# "local": get_finnhub_company_insider_transactions,
},
@@ -232,10 +251,11 @@ def get_market_preferred_vendors(market: str, method: str) -> list:
unified_order = [
'akshare', # 1st priority: AKShare (best for A-shares, good coverage)
'yfinance', # 2nd priority: yfinance (good for US/HK stocks, free)
- 'alpha_vantage', # 3rd priority: Alpha Vantage (comprehensive but rate-limited)
- 'local', # 4th priority: Local/cached data
- 'openai', # 5th priority: OpenAI-based data
- 'google', # 6th priority: Google-based data
+ 'baostock', # 3rd priority: BaoStock (A-share fallback)
+ 'alpha_vantage', # 4th priority: Alpha Vantage (comprehensive but rate-limited)
+ 'local', # 5th priority: Local/cached data
+ 'openai', # 6th priority: OpenAI-based data
+ 'google', # 7th priority: Google-based data
]
# Filter to only include vendors that support this method
@@ -319,7 +339,7 @@ def route_to_vendor(method: str, *args, **kwargs):
# Determine primary vendors based on market preferences
if symbol:
market_prefs = {
- 'A_STOCK': ['akshare'],
+ 'A_STOCK': ['akshare', 'baostock'],
'US_STOCK': ['yfinance', 'alpha_vantage'],
'HK_STOCK': ['yfinance', 'alpha_vantage'],
'UNKNOWN': ['yfinance', 'alpha_vantage', 'akshare']
diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py
index c9a5891..4c02c6f 100644
--- a/tradingagents/default_config.py
+++ b/tradingagents/default_config.py
@@ -1,5 +1,14 @@
import os
+
+def _env(primary: str, legacy: str | None, default: str) -> str:
+ """Read TRADINGAGENTS_* first, then legacy env names for compatibility."""
+ if primary in os.environ:
+ return os.environ[primary]
+ if legacy and legacy in os.environ:
+ return os.environ[legacy]
+ return default
+
DEFAULT_CONFIG = {
"project_dir": os.path.abspath(os.path.join(os.path.dirname(__file__), ".")),
"results_dir": os.getenv("TRADINGAGENTS_RESULTS_DIR", "./results"),
@@ -12,13 +21,16 @@
"dataflows/data_cache",
),
# LLM settings
- "llm_provider": os.getenv("LLM_PROVIDER", "openai"),
- "deep_think_llm": os.getenv("DEEP_THINK_LLM", "o4-mini"),
- "quick_think_llm": os.getenv("QUICK_THINK_LLM", "gpt-4o-mini"),
- "embedding_llm": os.getenv("EMBEDDING_LLM", "text-embedding-3-small"),
- "backend_url": os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
- "embedding_backend_url": os.getenv("EMBEDDING_BASE_URL", os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")),
- "embedding_api_key": os.getenv("EMBEDDING_API_KEY", os.getenv("OPENAI_API_KEY", "")),
+ "llm_provider": _env("TRADINGAGENTS_LLM_PROVIDER", "LLM_PROVIDER", "openai"),
+ "deep_think_llm": _env("TRADINGAGENTS_DEEP_LLM", "DEEP_THINK_LLM", "gpt-5.5"),
+ "quick_think_llm": _env("TRADINGAGENTS_QUICK_LLM", "QUICK_THINK_LLM", "gpt-5.5"),
+ "embedding_llm": _env("TRADINGAGENTS_EMBEDDING_LLM", "EMBEDDING_LLM", "text-embedding-3-small"),
+ "backend_url": _env("TRADINGAGENTS_OPENAI_BASE_URL", "OPENAI_BASE_URL", "https://api.oneinfinityai.com/v1"),
+ "embedding_backend_url": _env("TRADINGAGENTS_EMBEDDING_BASE_URL", "EMBEDDING_BASE_URL", _env("TRADINGAGENTS_OPENAI_BASE_URL", "OPENAI_BASE_URL", "https://api.oneinfinityai.com/v1")),
+ "openai_api_key": _env("TRADINGAGENTS_OPENAI_API_KEY", "OPENAI_API_KEY", ""),
+ "anthropic_api_key": _env("TRADINGAGENTS_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY", ""),
+ "google_api_key": _env("TRADINGAGENTS_GOOGLE_API_KEY", "GOOGLE_API_KEY", ""),
+ "embedding_api_key": _env("TRADINGAGENTS_EMBEDDING_API_KEY", "EMBEDDING_API_KEY", _env("TRADINGAGENTS_OPENAI_API_KEY", "OPENAI_API_KEY", "")),
# Debate and discussion settings
"max_debate_rounds": 1,
"max_risk_discuss_rounds": 1,
@@ -36,9 +48,4 @@
# Example: "get_stock_data": "alpha_vantage", # Override category default
# Example: "get_news": "openai", # Override category default
},
- # Futu Trading API configuration
- "futu_api_base_url": os.getenv("FUTU_API_BASE_URL", "http://localhost:8000"),
- "futu_api_timeout": int(os.getenv("FUTU_API_TIMEOUT", "30")),
- # Auto-execute trading configuration
- "auto_execute_trading": os.getenv("AUTO_EXECUTE_TRADING", "false").lower() == "true",
}
diff --git a/tradingagents/graph/conditional_logic.py b/tradingagents/graph/conditional_logic.py
index bb530ba..1ef11bd 100644
--- a/tradingagents/graph/conditional_logic.py
+++ b/tradingagents/graph/conditional_logic.py
@@ -73,23 +73,3 @@ def should_continue_risk_analysis(self, state: AgentState) -> str:
if state["risk_debate_state"]["latest_speaker"].startswith("Safe"):
return "Neutral Analyst"
return "Risky Analyst"
-
- def should_continue_trading_executor(self, state: AgentState):
- """Determine if trading executor should continue with tool calls."""
- messages = state["messages"]
-
- # Count how many times tools have been called
- tool_call_count = 0
- for msg in messages:
- if hasattr(msg, "tool_calls") and msg.tool_calls:
- tool_call_count += 1
-
- # Limit to maximum 2 tool call rounds (first batch + optional second batch)
- if tool_call_count >= 20:
- print(f"⚠️ Trading Executor: 已达到工具调用次数限制 ({tool_call_count}次),强制生成报告")
- return "Msg Clear Trading Executor"
-
- last_message = messages[-1]
- if hasattr(last_message, "tool_calls") and last_message.tool_calls:
- return "tools_trading_executor"
- return "Msg Clear Trading Executor"
diff --git a/tradingagents/graph/propagation.py b/tradingagents/graph/propagation.py
index 60ddd29..c78c547 100644
--- a/tradingagents/graph/propagation.py
+++ b/tradingagents/graph/propagation.py
@@ -16,7 +16,11 @@ def __init__(self, max_recur_limit=100):
self.max_recur_limit = max_recur_limit
def create_initial_state(
- self, company_name: str, trade_date: str, user_id: Optional[int] = None
+ self,
+ company_name: str,
+ trade_date: str,
+ user_id: Optional[int] = None,
+ previous_decision_reflection: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Create the initial state for the agent graph.
@@ -46,6 +50,11 @@ def create_initial_state(
"fundamentals_report": "",
"sentiment_report": "",
"news_report": "",
+ "grounded_evidence": [],
+ "stage_log": [],
+ "structured_report": {},
+ "reflection": {},
+ "previous_decision_reflection": previous_decision_reflection,
}
# Add user_id to state if provided
diff --git a/tradingagents/graph/setup.py b/tradingagents/graph/setup.py
index beeb47c..44f4b3d 100644
--- a/tradingagents/graph/setup.py
+++ b/tradingagents/graph/setup.py
@@ -38,7 +38,7 @@ def __init__(
self.conditional_logic = conditional_logic
def setup_graph(
- self, selected_analysts=["market", "social", "news", "fundamentals"], auto_execute_trading=False
+ self, selected_analysts=["market", "social", "news", "fundamentals"]
):
"""Set up and compile the agent workflow graph.
@@ -48,7 +48,6 @@ def setup_graph(
- "social": Social media analyst
- "news": News analyst
- "fundamentals": Fundamentals analyst
- auto_execute_trading (bool): Whether to enable automatic trading execution
"""
if len(selected_analysts) == 0:
raise ValueError("Trading Agents Graph Setup Error: no analysts selected!")
@@ -97,19 +96,9 @@ def setup_graph(
self.deep_thinking_llm, self.invest_judge_memory
)
- # Create trading team nodes (trader + trading executor)
+ # Create trading team node. It produces recommendations only; no order execution node is attached.
trader_node = create_trader(self.quick_thinking_llm, self.trader_memory)
trader_msg_delete = create_msg_delete()
-
- # Trading executor is part of trading team, created only if auto-execute trading is enabled
- trading_executor_node = None
- trading_executor_msg_delete = None
- if auto_execute_trading:
- from tradingagents.agents.trader.trading_executor import create_trading_executor
- trading_executor_node = create_trading_executor(
- self.deep_thinking_llm, self.trader_memory
- )
- trading_executor_msg_delete = create_msg_delete()
# Create risk analysis nodes
risky_analyst = create_risky_debator(self.quick_thinking_llm)
@@ -140,12 +129,6 @@ def setup_graph(
workflow.add_node("tools_trader", self.tool_nodes["trader"])
workflow.add_node("Msg Clear Trader", trader_msg_delete)
- # Add Trading Executor as part of Trading Team (only if auto-execute trading is enabled)
- if auto_execute_trading:
- workflow.add_node("Trading Executor", trading_executor_node)
- workflow.add_node("tools_trading_executor", self.tool_nodes["trading_executor"])
- workflow.add_node("Msg Clear Trading Executor", trading_executor_msg_delete)
-
# Add Risk Management Team nodes
workflow.add_node("Risky Analyst", risky_analyst)
workflow.add_node("Neutral Analyst", neutral_analyst)
@@ -195,7 +178,7 @@ def setup_graph(
"Research Manager": "Research Manager",
},
)
- # Trading Team workflow: Research Manager -> Trader -> (Trading Executor if enabled) -> Risk Team
+ # Recommendation workflow: Research Manager -> Trader recommendation -> Risk Team
workflow.add_edge("Research Manager", "Trader")
workflow.add_conditional_edges(
"Trader",
@@ -231,20 +214,7 @@ def setup_graph(
},
)
- # After Risk Judge, go to Trading Executor (if enabled) or END
- if auto_execute_trading:
- # Add Trading Executor after Risk Management
- workflow.add_edge("Risk Judge", "Trading Executor")
- workflow.add_conditional_edges(
- "Trading Executor",
- self.conditional_logic.should_continue_trading_executor,
- ["tools_trading_executor", "Msg Clear Trading Executor"],
- )
- workflow.add_edge("tools_trading_executor", "Trading Executor")
- workflow.add_edge("Msg Clear Trading Executor", END)
- else:
- # If no Trading Executor, go directly to END
- workflow.add_edge("Risk Judge", END)
+ workflow.add_edge("Risk Judge", END)
# Compile and return
return workflow.compile()
diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py
index 08e0240..cdc7288 100644
--- a/tradingagents/graph/trading_graph.py
+++ b/tradingagents/graph/trading_graph.py
@@ -21,6 +21,7 @@
RiskDebateState,
)
from tradingagents.dataflows.config import set_config
+from tradingagents.utils.security import safe_join, safe_path_component
# Import the new abstract tool methods from agent_utils
from tradingagents.agents.utils.agent_utils import (
@@ -65,6 +66,7 @@ def __init__(
# Update the interface's config
set_config(self.config)
+ self._configure_api_keys()
# Create necessary directories
os.makedirs(
@@ -74,14 +76,14 @@ def __init__(
# Initialize LLMs
if self.config["llm_provider"].lower() == "anthropic":
- self.deep_thinking_llm = ChatAnthropic(model_name=self.config["deep_think_llm"], base_url=self.config["backend_url"])
- self.quick_thinking_llm = ChatAnthropic(model_name=self.config["quick_think_llm"], base_url=self.config["backend_url"])
+ self.deep_thinking_llm = ChatAnthropic(model_name=self.config["deep_think_llm"], base_url=self.config["backend_url"], api_key=self.config.get("anthropic_api_key") or None)
+ self.quick_thinking_llm = ChatAnthropic(model_name=self.config["quick_think_llm"], base_url=self.config["backend_url"], api_key=self.config.get("anthropic_api_key") or None)
elif self.config["llm_provider"].lower() == "google":
- self.deep_thinking_llm = ChatGoogleGenerativeAI(model=self.config["deep_think_llm"])
- self.quick_thinking_llm = ChatGoogleGenerativeAI(model=self.config["quick_think_llm"])
+ self.deep_thinking_llm = ChatGoogleGenerativeAI(model=self.config["deep_think_llm"], google_api_key=self.config.get("google_api_key") or None)
+ self.quick_thinking_llm = ChatGoogleGenerativeAI(model=self.config["quick_think_llm"], google_api_key=self.config.get("google_api_key") or None)
else:
- self.deep_thinking_llm = ChatOpenAI(model=self.config["deep_think_llm"], base_url=self.config["backend_url"])
- self.quick_thinking_llm = ChatOpenAI(model=self.config["quick_think_llm"], base_url=self.config["backend_url"])
+ self.deep_thinking_llm = ChatOpenAI(model=self.config["deep_think_llm"], base_url=self.config["backend_url"], api_key=self.config.get("openai_api_key") or None)
+ self.quick_thinking_llm = ChatOpenAI(model=self.config["quick_think_llm"], base_url=self.config["backend_url"], api_key=self.config.get("openai_api_key") or None)
# Initialize memories with unique names per analysis to avoid conflicts in multi-user scenarios
# Use analysis_id from config if available, otherwise use timestamp-based unique ID
@@ -96,7 +98,10 @@ def __init__(
self.tool_nodes = self._create_tool_nodes()
# Initialize components
- self.conditional_logic = ConditionalLogic()
+ self.conditional_logic = ConditionalLogic(
+ max_debate_rounds=self.config.get("max_debate_rounds", 1),
+ max_risk_discuss_rounds=self.config.get("max_risk_discuss_rounds", 1),
+ )
self.graph_setup = GraphSetup(
self.quick_thinking_llm,
self.deep_thinking_llm,
@@ -118,35 +123,21 @@ def __init__(
self.ticker = None
self.log_states_dict = {} # date to full state dict
- # Set up the graph with auto-execute trading configuration
- auto_execute_trading = self.config.get("auto_execute_trading", False)
- futu_api_base_url = self.config.get("futu_api_base_url")
- futu_api_key = self.config.get("futu_api_key")
-
- # Set Futu API environment variables if provided
- if auto_execute_trading and futu_api_base_url:
- os.environ["FUTU_API_BASE_URL"] = futu_api_base_url
- if auto_execute_trading and futu_api_key:
- os.environ["FUTU_API_KEY"] = futu_api_key
-
- self.graph = self.graph_setup.setup_graph(selected_analysts, auto_execute_trading)
+ self.graph = self.graph_setup.setup_graph(selected_analysts)
+
+ def _configure_api_keys(self) -> None:
+ """Populate provider environment variables from TRADINGAGENTS_* aware config."""
+ provider_key_map = {
+ "openai": ("OPENAI_API_KEY", self.config.get("openai_api_key")),
+ "anthropic": ("ANTHROPIC_API_KEY", self.config.get("anthropic_api_key")),
+ "google": ("GOOGLE_API_KEY", self.config.get("google_api_key")),
+ }
+ for _, (env_name, value) in provider_key_map.items():
+ if value and not os.getenv(env_name):
+ os.environ[env_name] = value
def _create_tool_nodes(self) -> Dict[str, ToolNode]:
"""Create tool nodes for different data sources using abstract methods."""
- # Import Futu trading tools
- from tradingagents.agents.utils.futu_trading_tools import (
- get_futu_account_info,
- get_futu_positions,
- get_futu_quote,
- place_futu_order,
- cancel_futu_order,
- get_futu_orders,
- get_futu_kline,
- get_futu_hot_stocks,
- get_futu_hot_news,
- get_futu_technical_analysis
- )
-
return {
"market": ToolNode(
[
@@ -189,21 +180,6 @@ def _create_tool_nodes(self) -> Dict[str, ToolNode]:
get_indicators,
]
),
- "trading_executor": ToolNode(
- [
- # Futu trading tools for order execution
- get_futu_account_info,
- get_futu_positions,
- get_futu_quote,
- place_futu_order,
- cancel_futu_order,
- get_futu_orders,
- get_futu_kline,
- get_futu_hot_stocks,
- get_futu_hot_news,
- get_futu_technical_analysis,
- ]
- ),
}
def propagate(self, company_name, trade_date):
@@ -271,18 +247,14 @@ def _log_state(self, trade_date, final_state):
},
"investment_plan": final_state["investment_plan"],
"final_trade_decision": final_state["final_trade_decision"],
- "execution_report": final_state.get("execution_report"),
}
# Save to file
- directory = Path(f"eval_results/{self.ticker}/TradingAgentsStrategy_logs/")
+ directory = safe_join("eval_results", self.ticker, "TradingAgentsStrategy_logs")
directory.mkdir(parents=True, exist_ok=True)
- with open(
- f"eval_results/{self.ticker}/TradingAgentsStrategy_logs/full_states_log_{trade_date}.json",
- "w",
- encoding="utf-8"
- ) as f:
+ log_path = directory / f"full_states_log_{safe_path_component(trade_date)}.json"
+ with open(log_path, "w", encoding="utf-8") as f:
json.dump(self.log_states_dict, f, indent=4)
def reflect_and_remember(self, returns_losses):
diff --git a/tradingagents/utils/__init__.py b/tradingagents/utils/__init__.py
new file mode 100644
index 0000000..d641bc3
--- /dev/null
+++ b/tradingagents/utils/__init__.py
@@ -0,0 +1 @@
+"""Shared runtime utilities for TradingAgents."""
diff --git a/tradingagents/utils/checkpoints.py b/tradingagents/utils/checkpoints.py
new file mode 100644
index 0000000..b514033
--- /dev/null
+++ b/tradingagents/utils/checkpoints.py
@@ -0,0 +1,46 @@
+"""JSON checkpoint persistence for long analysis runs."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Dict
+
+from tradingagents.utils.security import safe_join
+
+
+def checkpoint_path(base_dir: str | Path, user_id: int, ticker: str, analysis_id: str) -> Path:
+ return safe_join(base_dir, "checkpoints", f"user_{user_id}", ticker, f"{analysis_id}.json")
+
+
+def load_checkpoint(base_dir: str | Path, user_id: int, ticker: str, analysis_id: str) -> Dict[str, Any]:
+ path = checkpoint_path(base_dir, user_id, ticker, analysis_id)
+ if not path.exists():
+ return {}
+ with path.open("r", encoding="utf-8") as handle:
+ payload = json.load(handle)
+ return payload if isinstance(payload, dict) else {}
+
+
+def save_checkpoint(
+ base_dir: str | Path,
+ user_id: int,
+ ticker: str,
+ analysis_id: str,
+ stage: str,
+ report_sections: Dict[str, Any],
+) -> Path:
+ path = checkpoint_path(base_dir, user_id, ticker, analysis_id)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ payload = {
+ "analysis_id": analysis_id,
+ "user_id": user_id,
+ "ticker": ticker,
+ "last_successful_stage": stage,
+ "updated_at": datetime.now(timezone.utc).isoformat(),
+ "report_sections": report_sections,
+ }
+ with path.open("w", encoding="utf-8") as handle:
+ json.dump(payload, handle, ensure_ascii=False, indent=2, default=str)
+ return path
diff --git a/tradingagents/utils/security.py b/tradingagents/utils/security.py
new file mode 100644
index 0000000..b58b4d5
--- /dev/null
+++ b/tradingagents/utils/security.py
@@ -0,0 +1,30 @@
+"""Security helpers for filesystem-safe analysis artifacts."""
+
+import re
+from pathlib import Path
+
+
+_SAFE_COMPONENT = re.compile(r"[^A-Za-z0-9._-]+")
+
+
+def safe_path_component(value: object, fallback: str = "unknown") -> str:
+ """Return a filesystem-safe single path component."""
+ text = str(value or "").strip()
+ text = text.replace("/", "_").replace("\\", "_")
+ text = _SAFE_COMPONENT.sub("_", text)
+ text = text.strip("._-")
+ if not text or text in {".", ".."}:
+ return fallback
+ return text[:120]
+
+
+def safe_join(base_dir: str | Path, *components: object) -> Path:
+ """Join components under base_dir and reject traversal."""
+ base = Path(base_dir).resolve()
+ candidate = base
+ for component in components:
+ candidate = candidate / safe_path_component(component)
+ resolved = candidate.resolve()
+ if base != resolved and base not in resolved.parents:
+ raise ValueError(f"Unsafe path outside base directory: {resolved}")
+ return resolved
diff --git a/tradingagents/utils/structured_outputs.py b/tradingagents/utils/structured_outputs.py
new file mode 100644
index 0000000..3a496d0
--- /dev/null
+++ b/tradingagents/utils/structured_outputs.py
@@ -0,0 +1,133 @@
+"""Structured report helpers for the conversational report contract."""
+
+from __future__ import annotations
+
+import re
+from datetime import datetime, timezone
+from typing import Any, Dict, Iterable, List
+
+
+SECTION_KEYS = [
+ "market_technical",
+ "fundamentals",
+ "sentiment",
+ "news_macro",
+ "risk",
+]
+
+
+def _clip(text: object, limit: int = 1600) -> str:
+ value = str(text or "").strip()
+ value = re.sub(r"\n{3,}", "\n\n", value)
+ return value[:limit]
+
+
+def recommendation_from_text(text: str) -> str:
+ upper = (text or "").upper()
+ if "STRONG BUY" in upper or "强烈买入" in upper:
+ return "strong_buy"
+ if "STRONG SELL" in upper or "强烈卖出" in upper:
+ return "strong_sell"
+ if "BUY" in upper or "买入" in upper:
+ return "buy"
+ if "SELL" in upper or "卖出" in upper:
+ return "sell"
+ return "hold"
+
+
+def rating_from_text(text: str) -> int:
+ rec = recommendation_from_text(text)
+ return {
+ "strong_sell": 1,
+ "sell": 2,
+ "hold": 3,
+ "buy": 4,
+ "strong_buy": 5,
+ }[rec]
+
+
+def section(title: str, source_text: str, default_rating: int | None = None) -> Dict[str, Any]:
+ rating = default_rating or rating_from_text(source_text)
+ lines = [line.strip("-* \t") for line in str(source_text or "").splitlines() if line.strip()]
+ key_points = lines[:5] if lines else ["暂无足够数据,需结合后续技能结果复核。"]
+ return {
+ "title": title,
+ "rating": max(1, min(5, int(rating))),
+ "summary": _clip(";".join(key_points[:2]), 400),
+ "details": _clip(source_text),
+ "key_points": key_points,
+ }
+
+
+def evidence_snapshot(ticker: str, as_of: str, report: str, sources: Iterable[str]) -> List[Dict[str, Any]]:
+ """Build a compact grounded evidence snapshot from available report text."""
+ excerpt = _clip(report, 500)
+ now = datetime.now(timezone.utc).isoformat()
+ return [
+ {
+ "source": source,
+ "title": f"{ticker} {source} snapshot",
+ "url": None,
+ "excerpt": excerpt,
+ "confidence": 0.7 if excerpt else 0.2,
+ "as_of": as_of,
+ "captured_at": now,
+ }
+ for source in sources
+ ]
+
+
+def build_structured_report(report_sections: Dict[str, Any], decision_text: str) -> Dict[str, Any]:
+ """Normalize collected agent outputs into the frontend report contract shape."""
+ recommendation = recommendation_from_text(decision_text)
+ overall_rating = rating_from_text(decision_text)
+ grounded_evidence = list(report_sections.get("grounded_evidence") or [])
+ risk_text = (
+ report_sections.get("final_trade_decision")
+ or report_sections.get("risk_assessment")
+ or report_sections.get("risk_debate_state")
+ or ""
+ )
+ stage_log = report_sections.get("stage_log") or []
+ reflection = report_sections.get("reflection") or {}
+ return {
+ "rating": overall_rating,
+ "recommendation": recommendation,
+ "summary": _clip(decision_text, 800),
+ "sections": {
+ "market_technical": section("市场/技术面", report_sections.get("market_report", ""), overall_rating),
+ "fundamentals": section("基本面", report_sections.get("fundamentals_report", ""), overall_rating),
+ "sentiment": section("舆情", report_sections.get("sentiment_report", ""), overall_rating),
+ "news_macro": section("新闻/宏观", report_sections.get("news_report", ""), overall_rating),
+ "risk": section("风险", str(risk_text), overall_rating),
+ },
+ "grounded_evidence": grounded_evidence,
+ "stage_log": stage_log,
+ "reflection": {
+ "decision_log": _clip(reflection.get("decision_log") or decision_text, 1200),
+ "alpha": _clip(reflection.get("alpha") or "待同标的后续复盘计算。", 400),
+ "lessons": reflection.get("lessons") or [],
+ },
+ }
+
+
+def previous_decision_reflection(previous_record: Any) -> Dict[str, Any] | None:
+ """Create a reflection block from the previous completed analysis record."""
+ if not previous_record:
+ return None
+ final_state = previous_record.final_state or {}
+ previous_structured = final_state.get("structured_report") if isinstance(final_state, dict) else None
+ previous_rating = previous_structured.get("rating") if isinstance(previous_structured, dict) else None
+ previous_recommendation = previous_structured.get("recommendation") if isinstance(previous_structured, dict) else None
+ return {
+ "previous_analysis_id": previous_record.analysis_id,
+ "previous_completed_at": previous_record.completed_at.isoformat() if previous_record.completed_at else None,
+ "previous_decision": previous_record.trading_decision,
+ "previous_rating": previous_rating,
+ "previous_recommendation": previous_recommendation,
+ "alpha": "尚未接入价格回测,先注入上次决策供本次反思对照。",
+ "lessons": [
+ "对照上次决策,说明本次评级变化的新增证据。",
+ "如果结论不变,明确哪些事实继续支撑原判断。",
+ ],
+ }
diff --git a/web/backend/analysis_task.py b/web/backend/analysis_task.py
index 93a867d..5a8750e 100644
--- a/web/backend/analysis_task.py
+++ b/web/backend/analysis_task.py
@@ -24,6 +24,13 @@
from web.backend.database import SessionLocal, AsyncSessionLocal
from web.backend.models import AnalysisRecord, User
+from tradingagents.utils.checkpoints import load_checkpoint, save_checkpoint
+from tradingagents.utils.security import safe_join, safe_path_component
+from tradingagents.utils.structured_outputs import (
+ build_structured_report,
+ previous_decision_reflection,
+)
+from web.backend.services.skills.base import clear_skill_event_sink, set_skill_event_sink
def serialize_state(state: Dict[str, Any]) -> Dict[str, Any]:
@@ -83,6 +90,18 @@ def safe_commit(db, operation_name="operation"):
raise
+def _provider_api_key(provider: str, request_api_key: str | None, config: Dict[str, Any]) -> str:
+ """Resolve API key from request, TRADINGAGENTS_* config, then legacy env."""
+ if request_api_key:
+ return request_api_key
+ provider = (provider or "openai").lower()
+ if provider == "anthropic":
+ return config.get("anthropic_api_key") or os.getenv("TRADINGAGENTS_ANTHROPIC_API_KEY") or os.getenv("ANTHROPIC_API_KEY", "")
+ if provider == "google":
+ return config.get("google_api_key") or os.getenv("TRADINGAGENTS_GOOGLE_API_KEY") or os.getenv("GOOGLE_API_KEY", "")
+ return config.get("openai_api_key") or os.getenv("TRADINGAGENTS_OPENAI_API_KEY") or os.getenv("OPENAI_API_KEY", "")
+
+
class HeartbeatMonitor:
"""
心跳监控器 - 在长时间操作期间定期发送日志,并在超时时主动停止任务
@@ -283,6 +302,23 @@ def get_or_create_loop():
if not analysis_record:
print(f"❌ 分析记录未找到: {analysis_id}")
return
+
+ conversation_session_id = request_data.get("conversation_session_id")
+ conversation_message_id = request_data.get("conversation_message_id")
+ conversation_channel_id = f"conversation_{conversation_session_id}" if conversation_session_id else None
+
+ def send_conversation_event(event_type: str, data: dict):
+ if not conversation_channel_id:
+ return
+ try:
+ loop = get_or_create_loop()
+ loop.run_until_complete(manager.send_message({
+ "type": event_type,
+ "timestamp": datetime.utcnow().isoformat(),
+ "data": data,
+ }, conversation_channel_id))
+ except Exception as e:
+ print(f"⚠️ 发送对话 WebSocket 事件失败: {e}")
def send_log(level: str, message: str, agent: str = 'system', step: str = '', progress: float = 0.0, phase: str = ''):
"""发送日志到控制台和 WebSocket"""
@@ -314,8 +350,52 @@ def send_log(level: str, message: str, agent: str = 'system', step: str = '', pr
'phase': phase
}
}, analysis_id))
+ stage_id = agent or step or phase or "analysis"
+ if "开始" in message:
+ send_conversation_event("stage_start", {
+ "stage_id": stage_id,
+ "stage_name": step or phase or stage_id,
+ "display_name": step or phase or stage_id,
+ })
+ elif "完成" in message:
+ send_conversation_event("stage_complete", {
+ "stage_id": stage_id,
+ "completed_at": now_beijing.isoformat(),
+ "duration_ms": None,
+ })
+ else:
+ send_conversation_event("stage_update", {
+ "stage_id": stage_id,
+ "summary": truncated_message,
+ })
+ if agent != "system" and truncated_message:
+ send_conversation_event("token", {
+ "content": truncated_message,
+ "message_id": conversation_message_id,
+ })
except Exception as e:
print(f"⚠️ 发送 WebSocket 消息失败: {e}")
+
+ def handle_skill_event(event: dict):
+ severity = event.get("severity", "warning")
+ message = truncate_message(event.get("message") or "数据源调用降级", max_length=200)
+ stage_id = event.get("skill") or "data-source"
+ progress = max(float(analysis_record.progress_percentage or 12.0), 12.0)
+ level = "error" if severity == "error" else "warning"
+ send_log(level, message, 'system', '数据源', progress, '分析阶段')
+ if severity == "error":
+ send_conversation_event("stage_error", {
+ "stage_id": stage_id,
+ "message": message,
+ "retryable": event.get("retryable", True),
+ })
+ else:
+ send_conversation_event("stage_warning", {
+ "stage_id": stage_id,
+ "message": message,
+ "partial": event.get("partial", True),
+ "retryable": event.get("retryable", True),
+ })
def check_stop():
"""检查是否应该停止"""
@@ -345,8 +425,8 @@ def check_stop():
send_log('info', '🔑 配置 API 密钥...', 'system', '配置', 2.0, '准备阶段')
check_stop()
- api_key = request_data.get('api_key')
llm_provider = request_data.get('llm_provider', '').lower()
+ api_key = _provider_api_key(llm_provider, request_data.get('api_key'), DEFAULT_CONFIG)
if api_key:
if llm_provider == "anthropic":
@@ -362,17 +442,29 @@ def check_stop():
config = DEFAULT_CONFIG.copy()
config["llm_provider"] = request_data.get('llm_provider', 'openai').lower()
- config["deep_think_llm"] = request_data.get('deep_thinker', 'gpt-4o')
- config["quick_think_llm"] = request_data.get('shallow_thinker', 'gpt-4o-mini')
- config["backend_url"] = request_data.get('backend_url', '')
+ config["deep_think_llm"] = request_data.get('deep_thinker', DEFAULT_CONFIG["deep_think_llm"])
+ config["quick_think_llm"] = request_data.get('shallow_thinker', DEFAULT_CONFIG["quick_think_llm"])
+ config["backend_url"] = request_data.get('backend_url') or DEFAULT_CONFIG["backend_url"]
config["max_debate_rounds"] = request_data.get('research_depth', 1)
config["max_risk_discuss_rounds"] = request_data.get('research_depth', 1)
# Pass analysis_id to ensure unique memory collections per analysis (multi-user safety)
config["analysis_id"] = analysis_id
- # Trading executor configuration
- config["auto_execute_trading"] = request_data.get('enable_trading_executor', False)
- config["futu_api_base_url"] = request_data.get('futu_api_base_url')
- config["futu_api_key"] = request_data.get('futu_api_key')
+ config["checkpoint_dir"] = os.getenv("TRADINGAGENTS_CHECKPOINT_DIR", "eval_results")
+ if config["llm_provider"] == "anthropic":
+ config["anthropic_api_key"] = api_key
+ elif config["llm_provider"] == "google":
+ config["google_api_key"] = api_key
+ else:
+ config["openai_api_key"] = api_key
+
+ previous_record = db.query(AnalysisRecord).filter(
+ AnalysisRecord.user_id == user_id,
+ AnalysisRecord.ticker == request_data.get("ticker"),
+ AnalysisRecord.status == "completed",
+ AnalysisRecord.analysis_id != analysis_id,
+ ).order_by(AnalysisRecord.completed_at.desc()).first()
+ previous_reflection = previous_decision_reflection(previous_record)
+ config["previous_decision_reflection"] = previous_reflection
# 转换分析师类型
analyst_types = []
@@ -390,7 +482,7 @@ def check_stop():
import time
time.sleep(0.5)
- print(f"📋 发送配置消息: selected_analysts={analyst_types}, enable_trading_executor={config.get('auto_execute_trading', False)}")
+ print(f"📋 发送配置消息: selected_analysts={analyst_types}")
try:
loop = get_or_create_loop()
loop.run_until_complete(manager.send_message({
@@ -399,7 +491,7 @@ def check_stop():
'data': {
'selected_analysts': analyst_types,
'research_depth': request_data.get('research_depth', 1),
- 'enable_trading_executor': config.get('auto_execute_trading', False)
+ 'previous_decision_reflection': previous_reflection,
}
}, analysis_id))
print(f"✅ 配置消息已发送")
@@ -431,7 +523,8 @@ def check_stop():
init_agent_state = graph.propagator.create_initial_state(
request_data.get('ticker'),
request_data.get('analysis_date'),
- user_id=analysis_record.user_id
+ user_id=analysis_record.user_id,
+ previous_decision_reflection=previous_reflection,
)
# Pass user_id to graph args for tools to access
args = graph.propagator.get_graph_args(user_id=analysis_record.user_id)
@@ -440,7 +533,7 @@ def check_stop():
# 计算进度分配
# 总进度: 10% -> 90%, 共 80% 的进度空间
- # 估算总智能体数量: 分析师 + 研究员(2-3个) + 投资评审(1个) + 交易员(1个) + 风险分析(3-4个) + 风险管理(1个) + 交易执行(可选)
+ # 估算总智能体数量: 分析师 + 研究员(2-3个) + 投资评审(1个) + 交易员(1个) + 风险分析(3-4个) + 风险管理(1个)
num_analysts = len(analyst_types)
# 固定的其他智能体: 研究员(bull+bear) + 投资评审 + 交易员 + 风险分析(risky+neutral+safe) + 风险管理
# 根据配置的辩论轮数估算
@@ -449,10 +542,8 @@ def check_stop():
num_trader = 1
num_risk_analysts = 3 # risky + neutral + safe
num_risk_manager = 1
- num_trading_executor = 1 if config.get("auto_execute_trading", False) else 0 # 执行交易员(可选)
-
# 总智能体数量
- total_agents = num_analysts + num_researchers + num_invest_judge + num_trader + num_risk_analysts + num_risk_manager + num_trading_executor
+ total_agents = num_analysts + num_researchers + num_invest_judge + num_trader + num_risk_analysts + num_risk_manager
progress_per_agent = 80.0 / max(total_agents, 1) # 每个智能体分配的进度
base_progress = 10.0
@@ -475,7 +566,6 @@ def check_stop():
'safe': '保守风险分析师',
'neutral': '中性风险分析师',
'risk_manager': '风险管理评审及投资组合分析',
- 'trading_executor': '执行交易员'
}
# LangGraph 节点名称到内部智能体代码的映射
@@ -493,7 +583,6 @@ def check_stop():
'Neutral Analyst': 'neutral',
'Portfolio Manager': 'risk_manager',
'Risk Judge': 'risk_manager',
- 'Trading Executor': 'trading_executor',
}
# 智能体对应的报告字段(用于判断节点完成)
@@ -510,7 +599,6 @@ def check_stop():
'safe': 'risk_debate_state',
'neutral': 'risk_debate_state',
'risk_manager': 'investment_plan',
- 'trading_executor': 'execution_report',
}
# 报告字段收集器
@@ -527,8 +615,32 @@ def check_stop():
"risk_debate_state": None,
"investment_plan": None,
"final_trade_decision": None,
- "execution_report": None,
+ "grounded_evidence": [],
+ "stage_log": [],
+ "structured_report": None,
+ "reflection": previous_reflection or {},
}
+ checkpoint_payload = load_checkpoint(
+ config["checkpoint_dir"],
+ user_id,
+ request_data.get('ticker', 'UNKNOWN'),
+ analysis_id,
+ )
+ if checkpoint_payload.get("report_sections"):
+ report_sections.update(checkpoint_payload["report_sections"])
+ init_agent_state.update({
+ key: value
+ for key, value in report_sections.items()
+ if value is not None and key in init_agent_state
+ })
+ send_log(
+ 'info',
+ f"已从 checkpoint 恢复到阶段: {checkpoint_payload.get('last_successful_stage', 'unknown')}",
+ 'system',
+ '续跑',
+ 9.0,
+ '准备阶段',
+ )
# 预定义节点执行顺序(用于追踪智能体切换)
# 使用与图构建时相同的顺序(analyst_types 就是图的执行顺序)
@@ -536,9 +648,6 @@ def check_stop():
# 后面的固定顺序(按 node_to_agent_map 的顺序)
fixed_order = ['bull', 'bear', 'invest_judge', 'trader', 'risky', 'safe', 'neutral', 'risk_manager']
- # Add trading executor if enabled
- if request_data.get('enable_trading_executor', False):
- fixed_order.append('trading_executor')
agent_execution_order.extend(fixed_order)
print(f"📋 预定义智能体执行顺序: {agent_execution_order}")
@@ -570,6 +679,7 @@ def stream_with_interrupt_check(stream_iterator, check_interval=0.1):
def stream_reader():
"""在后台线程中读取 stream"""
+ set_skill_event_sink(lambda event: chunk_queue.put(('skill_event', event, analysis_id)))
try:
for chunk in stream_iterator:
chunk_queue.put(('chunk', chunk, analysis_id))
@@ -581,6 +691,7 @@ def stream_reader():
exception_holder[0] = e
chunk_queue.put(('error', e, analysis_id))
finally:
+ clear_skill_event_sink()
finished.set()
# 启动后台读取线程
@@ -608,6 +719,8 @@ def stream_reader():
if msg_type == 'chunk':
yield data
+ elif msg_type == 'skill_event':
+ handle_skill_event(data)
elif msg_type == 'done':
break
elif msg_type == 'error':
@@ -791,26 +904,30 @@ def stream_reader():
print(f" 📊 收集到 investment_plan")
# investment_plan 是由 research_manager 生成的,不是 risk_manager
- if "execution_report" in state_update and state_update["execution_report"]:
- report_sections["execution_report"] = state_update["execution_report"]
- print(f" 📊 收集到 execution_report")
- if current_agent == 'trading_executor' and not agent_completed:
- agent_completed = True
- print(f" ✅ trading_executor 节点完成(收集到报告)")
- # 交易执行是最后一个节点,不需要触发切换
-
if "final_trade_decision" in state_update and state_update["final_trade_decision"]:
report_sections["final_trade_decision"] = state_update["final_trade_decision"]
print(f" 📊 收集到 final_trade_decision")
if current_agent == 'risk_manager' and not agent_completed:
agent_completed = True
print(f" ✅ risk_manager 节点完成(收集到报告)")
- # 立即触发切换到下一个智能体(如果启用了交易执行)
if current_agent_index < len(agent_execution_order) - 1:
next_agent_index = current_agent_index + 1
next_agent = agent_execution_order[next_agent_index]
detected_agent = next_agent
print(f" 🔄 触发切换: {current_agent} -> {next_agent} (收集到报告)")
+
+ if "grounded_evidence" in state_update and state_update["grounded_evidence"]:
+ existing = report_sections.get("grounded_evidence") or []
+ report_sections["grounded_evidence"] = existing + list(state_update["grounded_evidence"])
+ print(f" 📊 收集到 grounded_evidence")
+
+ if "structured_report" in state_update and state_update["structured_report"]:
+ report_sections["structured_report"] = state_update["structured_report"]
+ print(f" 📊 收集到 structured_report")
+
+ if "reflection" in state_update and state_update["reflection"]:
+ report_sections["reflection"] = state_update["reflection"]
+ print(f" 📊 收集到 reflection")
# 获取消息列表(从 state_update 或 chunk 中)
messages = []
@@ -830,6 +947,24 @@ def stream_reader():
agent_display_name = agent_name_map.get(last_agent, last_agent)
progress = min(90.0, base_progress + (current_analyst_index * progress_per_agent))
send_log('info', f'✅ {agent_display_name} 完成分析', last_agent, '完成', progress, '分析阶段')
+ report_sections.setdefault("stage_log", []).append({
+ "id": last_agent,
+ "name": agent_display_name,
+ "status": "completed",
+ "completed_at": datetime.utcnow().isoformat(),
+ "summary": f"{agent_display_name} 完成分析",
+ })
+ try:
+ save_checkpoint(
+ config["checkpoint_dir"],
+ user_id,
+ request_data.get('ticker', 'UNKNOWN'),
+ analysis_id,
+ last_agent,
+ report_sections,
+ )
+ except Exception as checkpoint_error:
+ print(f"⚠️ 保存 checkpoint 失败: {checkpoint_error}")
current_analyst_index += 1
# 新智能体开始
@@ -937,6 +1072,8 @@ def stream_reader():
decision = '买入'
elif decision.upper() == 'UNKNOWN':
decision = '未明确'
+
+ structured_report = report_sections.get("structured_report") or build_structured_report(report_sections, decision_raw)
# 获取基本信息(从收集的字段中)- use Beijing time
from pytz import timezone as pytz_timezone
@@ -963,15 +1100,24 @@ def stream_reader():
"risk_debate_state": report_sections.get("risk_debate_state", {}),
"investment_plan": report_sections.get("investment_plan", ""),
"final_trade_decision": decision_raw,
- "execution_report": report_sections.get("execution_report", ""), # 添加交易执行报告
+ "risk_assessment": decision_raw,
+ "grounded_evidence": report_sections.get("grounded_evidence", []),
+ "stage_log": report_sections.get("stage_log", []),
+ "reflection": structured_report.get("reflection", report_sections.get("reflection", {})),
+ "structured_report": structured_report,
}
# 保存状态到文件(按用户、股票代码和分析ID分开,避免覆盖)
- user_ticker_dir = Path(f"eval_results/user_{user_id}/{ticker}/TradingAgentsStrategy_logs/")
+ user_ticker_dir = safe_join(
+ "eval_results",
+ f"user_{user_id}",
+ ticker,
+ "TradingAgentsStrategy_logs",
+ )
user_ticker_dir.mkdir(parents=True, exist_ok=True)
# 使用 analysis_id 作为文件名的一部分,确保每次分析都有唯一的文件
- log_file = user_ticker_dir / f"full_states_log_{analysis_date}_{analysis_id}.json"
+ log_file = user_ticker_dir / f"full_states_log_{safe_path_component(analysis_date)}_{safe_path_component(analysis_id)}.json"
# 构建完整的日志数据
log_data = {
@@ -990,7 +1136,10 @@ def stream_reader():
"risk_debate_state": report_sections.get("risk_debate_state", {}),
"investment_plan": report_sections.get("investment_plan", ""),
"final_trade_decision": decision_raw,
- "execution_report": report_sections.get("execution_report", ""), # 添加交易执行报告
+ "grounded_evidence": report_sections.get("grounded_evidence", []),
+ "stage_log": report_sections.get("stage_log", []),
+ "reflection": structured_report.get("reflection", report_sections.get("reflection", {})),
+ "structured_report": structured_report,
}
}
@@ -1063,6 +1212,43 @@ def stream_reader():
db2.close()
except Exception:
pass
+
+ if conversation_message_id:
+ try:
+ from web.backend.models import ConversationMessage
+ from web.backend.services.report_formatter import report_detail, report_preview
+
+ db3 = SessionLocal()
+ try:
+ conv_msg = db3.query(ConversationMessage).filter(ConversationMessage.id == conversation_message_id).first()
+ saved_record = db3.query(AnalysisRecord).filter(AnalysisRecord.analysis_id == analysis_id).first()
+ if conv_msg and saved_record:
+ conv_msg.status = "completed"
+ conv_msg.content = structured_report.get("summary") or str(decision)
+ conv_msg.content_blocks = [
+ {"type": "text", "content": conv_msg.content},
+ {
+ "type": "report",
+ "report_id": analysis_id,
+ "report_preview": report_preview(saved_record, conversation_session_id),
+ },
+ ]
+ safe_commit(db3, "update conversation assistant message")
+ send_conversation_event("analysis_complete", {
+ "message_id": conversation_message_id,
+ "duration_ms": None,
+ "stages_completed": len(report_sections.get("stage_log", [])),
+ "stages_total": 9,
+ })
+ send_conversation_event("report_ready", {
+ "report_id": analysis_id,
+ "message_id": conversation_message_id,
+ "report": report_detail(saved_record, conversation_session_id),
+ })
+ finally:
+ db3.close()
+ except Exception as conv_error:
+ print(f"⚠️ 更新对话消息失败: {conv_error}")
# 发送完成消息
send_log('info', f'分析完成!交易决 {decision}', 'system', '完成', 100.0, '完成阶段')
@@ -1149,6 +1335,26 @@ def stream_reader():
'message': '分析任务已被中断'
}
}, analysis_id))
+ if conversation_message_id:
+ send_conversation_event("stop_ack", {
+ "message_id": conversation_message_id,
+ "stopped_at": datetime.utcnow().isoformat(),
+ "completed_stages": [item.get("id") for item in report_sections.get("stage_log", []) if isinstance(item, dict)],
+ "partial_content": "分析任务已被中断",
+ })
+ try:
+ from web.backend.models import ConversationMessage
+ db_stop = SessionLocal()
+ try:
+ msg = db_stop.query(ConversationMessage).filter(ConversationMessage.id == conversation_message_id).first()
+ if msg:
+ msg.status = "stopped"
+ msg.content = "分析任务已被中断"
+ safe_commit(db_stop, "mark conversation message stopped")
+ finally:
+ db_stop.close()
+ except Exception as conv_error:
+ print(f"⚠️ 更新对话中断状态失败: {conv_error}")
print(f"✅ 中断消息已发送到前端")
@@ -1279,6 +1485,39 @@ def stream_reader():
}
print(f"📤 错误消息内容: {error_message}")
loop.run_until_complete(manager.send_message(error_message, analysis_id))
+ if conversation_message_id:
+ send_conversation_event("stage_error", {
+ "stage_id": analysis_record.current_step or "analysis",
+ "message": user_friendly_error,
+ "retryable": True,
+ })
+ send_conversation_event("error", {
+ "code": error_type,
+ "message": user_friendly_error,
+ "stage_id": analysis_record.current_step,
+ })
+ try:
+ from web.backend.models import ConversationMessage
+ db_err = SessionLocal()
+ try:
+ msg = db_err.query(ConversationMessage).filter(ConversationMessage.id == conversation_message_id).first()
+ if msg:
+ msg.status = "error"
+ msg.content = user_friendly_error
+ msg.content_blocks = [{
+ "type": "stage_progress",
+ "stage_id": "analysis",
+ "stage_name": "分析",
+ "status": "error",
+ "summary": user_friendly_error,
+ "started_at": None,
+ "completed_at": datetime.utcnow().isoformat(),
+ }]
+ safe_commit(db_err, "mark conversation message error")
+ finally:
+ db_err.close()
+ except Exception as conv_error:
+ print(f"⚠️ 更新对话错误状态失败: {conv_error}")
print(f"✅ 错误消息已发送到前端")
except Exception as send_error:
print(f"❌ 发送错误消息失败: {send_error}")
diff --git a/web/backend/app.py b/web/backend/app.py
index 1630db8..2c568a9 100644
--- a/web/backend/app.py
+++ b/web/backend/app.py
@@ -77,106 +77,7 @@
from web.backend.middleware import LoggingMiddleware
# Import API routes
-from web.backend.routes import analysis_routes, config_routes, task_routes, page_routes, websocket_routes, export_routes, leaderboard_routes, user_management_routes, scheduled_task_routes, user_config_routes, intraday_trading_routes, user_leaderboard_routes, public_leaderboard_routes
-
-
-async def leaderboard_update_task():
- """Background task to periodically update leaderboard data via WebSocket"""
- from web.backend.routes.websocket_routes import broadcast_leaderboard_update
- from web.backend.database import get_db
- from web.backend.models import User, AccountSnapshot, UserConfig
- from sqlalchemy import select, desc
-
- print("🚀 Leaderboard update task started")
-
- while True:
- try:
- await asyncio.sleep(60) # Update every minute
-
- # Check if there are any leaderboard WebSocket connections
- if "leaderboard_public" in manager.active_connections:
- print(f"📡 Broadcasting leaderboard update to {len(manager.active_connections['leaderboard_public'])} clients")
-
- try:
- # Fetch latest leaderboard data with better error handling
- async with AsyncSessionLocal() as db:
- # Get participating users first
- users_query = select(User).where(User.participate_in_leaderboard == True)
- users_result = await db.execute(users_query)
- participating_users = users_result.scalars().all()
-
- users_list = []
-
- # Get user configs for model information
- user_ids = [user.id for user in participating_users]
- configs = {}
- if user_ids:
- config_query = select(UserConfig).where(UserConfig.user_id.in_(user_ids))
- config_result = await db.execute(config_query)
- configs = {config.user_id: config for config in config_result.scalars().all()}
-
- if participating_users:
- # For each participating user, get their latest snapshot for each market
- for user in participating_users:
- # Get model name from config
- model_name = None
- if user.id in configs:
- config = configs[user.id]
- model_name = config.intraday_llm_model if config.intraday_llm_model else None
-
- # Get all snapshots for this user
- snapshot_query = select(AccountSnapshot).where(
- AccountSnapshot.user_id == user.id
- ).order_by(AccountSnapshot.snapshot_date.desc())
-
- snapshot_result = await db.execute(snapshot_query)
- all_snapshots = snapshot_result.scalars().all()
-
- if all_snapshots:
- # Group by market_type and get the latest for each market
- market_snapshots = {}
- for snapshot in all_snapshots:
- market = snapshot.market_type or 'US'
- if market not in market_snapshots:
- market_snapshots[market] = snapshot
-
- # Add one entry per market
- for market, snapshot in market_snapshots.items():
- users_list.append({
- 'user_id': user.id,
- 'username': user.username,
- 'market_type': market,
- 'total_assets': float(snapshot.total_assets) if snapshot.total_assets else 100000.0,
- 'latest_snapshot_date': snapshot.snapshot_date.strftime('%Y-%m-%d') if snapshot.snapshot_date else datetime.now().strftime('%Y-%m-%d'),
- 'model_name': model_name
- })
- else:
- # Create default snapshots for all markets if no data exists
- for market in ['US', 'HK', 'CN']:
- users_list.append({
- 'user_id': user.id,
- 'username': user.username,
- 'market_type': market,
- 'total_assets': 100000.0,
- 'latest_snapshot_date': datetime.now().strftime('%Y-%m-%d'),
- 'model_name': model_name
- })
-
- # Sort by total_assets descending
- users_list.sort(key=lambda x: x['total_assets'], reverse=True)
-
- # Broadcast updates to all leaderboard clients
- await broadcast_leaderboard_update(users_data=users_list)
- print(f"📤 Leaderboard update broadcasted with {len(users_list)} users")
-
- except Exception as db_error:
- print(f"❌ Database error in leaderboard update: {db_error}")
- # Broadcast empty data if database query fails
- await broadcast_leaderboard_update(users_data=[])
-
- except Exception as e:
- print(f"⚠️ Leaderboard update task error: {e}")
- await asyncio.sleep(60) # Wait before retrying
+from web.backend.routes import analysis_routes, config_routes, task_routes, page_routes, websocket_routes, export_routes, user_management_routes, scheduled_task_routes, user_config_routes, skills_routes, conversation_routes, report_routes, home_routes
@asynccontextmanager
@@ -239,27 +140,6 @@ async def lifespan(app: FastAPI):
email_service = init_email_service()
app.state.email_service = email_service
- # Initialize intraday trading scheduler manager (multi-user)
- # Note: Individual user schedulers are created on-demand via API
- # No global scheduler needed - each user has their own scheduler instance
- print("✅ Intraday trading scheduler manager ready (user schedulers created on-demand)")
-
- # Restore schedulers that were running before service restart
- from web.backend.services.user_intraday_scheduler import get_manager as get_intraday_manager
- intraday_manager = get_intraday_manager()
- await intraday_manager.restore_schedulers_from_db()
- print("✅ Intraday trading schedulers restored from database")
-
- # Initialize and start snapshot scheduler for daily account snapshots
- from web.backend.services.snapshot_scheduler import init_snapshot_scheduler
- snapshot_scheduler = init_snapshot_scheduler()
- app.state.snapshot_scheduler = snapshot_scheduler
- print("✅ Snapshot scheduler started (daily account snapshots)")
-
- # Start leaderboard WebSocket update task
- asyncio.create_task(leaderboard_update_task())
- print("✅ Leaderboard real-time update task started")
-
# Preload user configurations into cache
from web.backend.services.user_config_cache import preload_user_configs
config_count = preload_user_configs()
@@ -284,21 +164,6 @@ async def lifespan(app: FastAPI):
# Shutdown (cleanup if needed)
print("🔌 Shutting down...")
if getattr(app.state, "is_leader", False):
- # Stop all user intraday schedulers (but keep auto_start flags for restart)
- try:
- from web.backend.services.user_intraday_scheduler import get_manager as get_intraday_manager
- intraday_manager = get_intraday_manager()
- await intraday_manager.stop_all_schedulers()
- print("✅ All intraday trading schedulers stopped")
- except Exception as e:
- print(f"⚠️ Error stopping intraday schedulers: {e}")
-
- # Stop snapshot scheduler
- snapshot_scheduler = getattr(app.state, "snapshot_scheduler", None)
- if snapshot_scheduler:
- snapshot_scheduler.shutdown(wait=True)
- print("✅ Snapshot scheduler stopped")
-
# Stop scheduler
scheduler = getattr(app.state, "scheduler", None)
if scheduler:
@@ -387,9 +252,6 @@ async def cleanup_running_tasks():
'shallow_thinker': task.shallow_thinker or 'gpt-4o-mini',
'api_key': api_key, # 优先任务配置,兜底用户配置
'backend_url': task.backend_url or '',
- 'enable_trading_executor': task.enable_trading_executor or False,
- 'futu_api_base_url': task.futu_api_base_url,
- 'futu_api_key': task.futu_api_key,
}
# 提交任务到任务管理器
@@ -556,7 +418,7 @@ async def task_monitor():
# Initialize FastAPI app with lifespan
app = FastAPI(
title="TradingAgents Web Interface v2",
- description="Multi-Agents LLM Financial Trading Framework - Web Interface with Authentication",
+ description="Multi-agent financial analysis reports without automated trading execution",
version="2.0.0",
lifespan=lifespan
)
@@ -889,30 +751,26 @@ def get_status(self):
# Initialize route dependencies
analysis_routes.init_analysis_routes(task_manager, manager)
task_routes.init_task_routes(task_manager)
+conversation_routes.init_conversation_routes(task_manager, manager)
# Include authentication routes
app.include_router(auth_router)
# Include API routes
app.include_router(analysis_routes.router)
+app.include_router(conversation_routes.router)
app.include_router(config_routes.router)
app.include_router(task_routes.router)
app.include_router(export_routes.router)
-app.include_router(leaderboard_routes.router)
+app.include_router(report_routes.router)
+app.include_router(home_routes.router)
app.include_router(user_management_routes.router)
app.include_router(scheduled_task_routes.router)
-
-# Include intraday trading routes
-from web.backend.routes import intraday_trading_routes
-app.include_router(intraday_trading_routes.router)
+app.include_router(skills_routes.router)
# Include user config routes
from web.backend.routes import user_config_routes
-from web.backend.routes import user_leaderboard_routes
-from web.backend.routes import public_leaderboard_routes
app.include_router(user_config_routes.router)
-app.include_router(user_leaderboard_routes.router)
-app.include_router(public_leaderboard_routes.router)
# Include prompt management routes
from web.backend.routes import prompt_routes
@@ -922,10 +780,6 @@ def get_status(self):
websocket_routes.init_websocket_routes(manager)
app.include_router(websocket_routes.router)
-# Include account snapshot routes
-from web.backend.routes import account_snapshot_routes
-app.include_router(account_snapshot_routes.router)
-
# Include LLM configuration routes
from web.backend.routes import llm_config_routes
app.include_router(llm_config_routes.router)
diff --git a/web/backend/auth_routes.py b/web/backend/auth_routes.py
index 205d4f5..e6fe59b 100644
--- a/web/backend/auth_routes.py
+++ b/web/backend/auth_routes.py
@@ -124,23 +124,6 @@ def get_current_active_user(current_user: User = Depends(get_current_user)) -> U
)
return current_user
-def require_intraday_access(current_user: User = Depends(get_current_active_user)) -> User:
- """
- Dependency to require intraday trading access permission
- Admin users always have access
- """
- # Admin always has access
- if current_user.role == "admin":
- return current_user
-
- # Check specific permission
- if not current_user.can_access_intraday_trading:
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail="您没有访问短线交易功能的权限"
- )
- return current_user
-
@router.post("/register", response_model=AuthResponse)
async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db), request: Request = None):
"""
diff --git a/web/backend/database.py b/web/backend/database.py
index a5a87aa..ddad9fb 100644
--- a/web/backend/database.py
+++ b/web/backend/database.py
@@ -122,7 +122,8 @@ def init_db_sync():
# Import all models to ensure they are registered with Base
from web.backend.models import (
User, UserConfig, AnalysisRecord, AnalysisLog, ExportRecord, ScheduledTask,
- PositionRecord, TradingHistory, IntradayDecisionRecord
+ ConversationSession, ConversationMessage,
+ AgentTool, AgentPromptTemplate, TemplateTools, LLMProvider, LLMModel
)
# Create all tables using sync engine
@@ -138,7 +139,8 @@ async def init_db():
# Import all models to ensure they are registered with Base
from web.backend.models import (
User, UserConfig, AnalysisRecord, AnalysisLog, ExportRecord, ScheduledTask,
- PositionRecord, TradingHistory, IntradayDecisionRecord
+ ConversationSession, ConversationMessage,
+ AgentTool, AgentPromptTemplate, TemplateTools, LLMProvider, LLMModel
)
# Create all tables using async engine
diff --git a/web/backend/middleware.py b/web/backend/middleware.py
index 6d6aaf4..36223ca 100644
--- a/web/backend/middleware.py
+++ b/web/backend/middleware.py
@@ -4,6 +4,7 @@
"""
import time
+import uuid
from typing import Optional
from fastapi import Request, Response, HTTPException, status
from fastapi.security.utils import get_authorization_scheme_param
@@ -88,18 +89,47 @@ class LoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start_time = time.time()
-
- # Process request
- response = await call_next(request)
-
+ request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
+ request.state.request_id = request_id
+
+ try:
+ response = await call_next(request)
+ except HTTPException as exc:
+ process_time = time.time() - start_time
+ return JSONResponse(
+ status_code=exc.status_code,
+ content={
+ "error": {
+ "code": f"HTTP_{exc.status_code}",
+ "message": exc.detail if isinstance(exc.detail, str) else "请求处理失败",
+ "request_id": request_id,
+ }
+ },
+ headers={"X-Request-ID": request_id, "X-Process-Time": f"{process_time:.3f}"},
+ )
+ except Exception:
+ process_time = time.time() - start_time
+ return JSONResponse(
+ status_code=500,
+ content={
+ "error": {
+ "code": "INTERNAL_SERVER_ERROR",
+ "message": "服务器内部错误,请稍后重试",
+ "request_id": request_id,
+ }
+ },
+ headers={"X-Request-ID": request_id, "X-Process-Time": f"{process_time:.3f}"},
+ )
+
# Calculate processing time
process_time = time.time() - start_time
# Log request (in production, use proper logging)
- print(f"{request.method} {request.url.path} - {response.status_code} - {process_time:.3f}s")
+ print(f"{request_id} {request.method} {request.url.path} - {response.status_code} - {process_time:.3f}s")
# Add processing time header
- response.headers["X-Process-Time"] = str(process_time)
+ response.headers["X-Request-ID"] = request_id
+ response.headers["X-Process-Time"] = f"{process_time:.3f}"
return response
diff --git a/web/backend/migrations/003_init_default_prompts.py b/web/backend/migrations/003_init_default_prompts.py
index 681caeb..115927c 100644
--- a/web/backend/migrations/003_init_default_prompts.py
+++ b/web/backend/migrations/003_init_default_prompts.py
@@ -15,7 +15,7 @@
from web.backend.database import SessionLocal
from web.backend.models import User, AgentPromptTemplate, TemplateTools, AgentTool
-from web.backend.services.prompt_loader import get_default_intraday_prompt
+from web.backend.services.prompt_loader import DEFAULT_AGENT_TYPE, get_default_analysis_prompt
def migrate():
@@ -36,7 +36,7 @@ def migrate():
return True
# Get default prompt
- default_prompt = get_default_intraday_prompt()
+ default_prompt = get_default_analysis_prompt()
# Get all available tools
all_tools = db.query(AgentTool).filter(AgentTool.is_available == True).all()
@@ -49,7 +49,7 @@ def migrate():
for user in users:
# Check if user already has a template
existing = db.query(AgentPromptTemplate).filter(
- AgentPromptTemplate.agent_type == "intraday_trader",
+ AgentPromptTemplate.agent_type == DEFAULT_AGENT_TYPE,
AgentPromptTemplate.user_id == user.id
).first()
@@ -60,11 +60,11 @@ def migrate():
# Create template
template = AgentPromptTemplate(
- agent_type="intraday_trader",
+ agent_type=DEFAULT_AGENT_TYPE,
user_id=user.id,
system_prompt=default_prompt,
- template_name="默认日内交易策略",
- description="系统默认的日内交易 Agent 提示词",
+ template_name="默认分析报告策略",
+ description="系统默认的分析 Agent 提示词",
version="1.0",
is_active=True
)
diff --git a/web/backend/migrations/004_update_tool_descriptions_chinese.py b/web/backend/migrations/004_update_tool_descriptions_chinese.py
index 4964084..6c1fd2f 100644
--- a/web/backend/migrations/004_update_tool_descriptions_chinese.py
+++ b/web/backend/migrations/004_update_tool_descriptions_chinese.py
@@ -29,9 +29,6 @@
'get_futu_kline': '获取股票K线数据,支持多种时间周期(1分钟/5分钟/日线/周线等)',
'get_futu_technical_analysis': '获取技术分析指标,支持MACD、RSI、布林带等常用指标',
- # Trading tools
- 'place_futu_order': '下单交易,支持买入/卖出,市价单/限价单',
-
# News tools
'get_futu_hot_news': '获取富途热门财经新闻,支持中英文',
'get_futu_hot_stocks': '获取富途热门股票榜单,发现市场热点',
diff --git a/web/backend/migrations/005_update_tool_descriptions_english.py b/web/backend/migrations/005_update_tool_descriptions_english.py
index e092126..1778502 100644
--- a/web/backend/migrations/005_update_tool_descriptions_english.py
+++ b/web/backend/migrations/005_update_tool_descriptions_english.py
@@ -29,9 +29,6 @@
'get_futu_kline': 'Get stock K-line data supporting multiple timeframes (1min/5min/daily/weekly)',
'get_futu_technical_analysis': 'Get technical analysis indicators including MACD, RSI, Bollinger Bands',
- # Trading tools
- 'place_futu_order': 'Place trading order supporting buy/sell and market/limit order types',
-
# News tools
'get_futu_hot_news': 'Get hot financial news from Futu supporting Chinese and English',
'get_futu_hot_stocks': 'Get hot stocks list from Futu to discover market trends',
diff --git a/web/backend/migrations/add_llm_providers_models.py b/web/backend/migrations/add_llm_providers_models.py
index d3faed8..d49e73c 100644
--- a/web/backend/migrations/add_llm_providers_models.py
+++ b/web/backend/migrations/add_llm_providers_models.py
@@ -89,9 +89,9 @@ def insert_default_data():
providers_data = [
{
'provider_name': 'openai',
- 'display_name': 'OpenAI',
- 'base_url': 'https://api.openai.com/v1',
- 'description': 'OpenAI GPT系列模型',
+ 'display_name': 'OneInfinity OpenAI Compatible',
+ 'base_url': 'https://api.oneinfinityai.com/v1',
+ 'description': 'OneInfinity OpenAI兼容模型',
'is_active': True,
},
{
@@ -134,11 +134,9 @@ def insert_default_data():
# 插入默认模型
models_data = [
- # OpenAI models
- {'provider': 'openai', 'model_name': 'gpt-4o', 'type': 'deep_thinker', 'display_name': 'GPT-4o', 'description': 'OpenAI最新多模态模型'},
- {'provider': 'openai', 'model_name': 'gpt-4o-mini', 'type': 'shallow_thinker', 'display_name': 'GPT-4o Mini', 'description': 'OpenAI轻量级快速模型'},
- {'provider': 'openai', 'model_name': 'gpt-4-turbo', 'type': 'deep_thinker', 'display_name': 'GPT-4 Turbo', 'description': 'OpenAI GPT-4 Turbo'},
- {'provider': 'openai', 'model_name': 'gpt-3.5-turbo', 'type': 'shallow_thinker', 'display_name': 'GPT-3.5 Turbo', 'description': 'OpenAI经典模型'},
+ # OneInfinity OpenAI-compatible models
+ {'provider': 'openai', 'model_name': 'gpt-5.5', 'type': 'deep_thinker', 'display_name': 'GPT-5.5', 'description': 'OneInfinity deep档默认模型'},
+ {'provider': 'openai', 'model_name': 'gpt-5.5', 'type': 'shallow_thinker', 'display_name': 'GPT-5.5', 'description': 'OneInfinity quick档默认模型;若提供方确认轻量档再切换'},
# Anthropic models
{'provider': 'anthropic', 'model_name': 'claude-3-5-sonnet-20241022', 'type': 'deep_thinker', 'display_name': 'Claude 3.5 Sonnet', 'description': 'Anthropic最强推理模型'},
diff --git a/web/backend/models.py b/web/backend/models.py
index 994830a..43c714d 100644
--- a/web/backend/models.py
+++ b/web/backend/models.py
@@ -23,8 +23,6 @@ class User(Base):
has_set_password = Column(Boolean, default=False, nullable=False) # Whether user has explicitly set a password
role = Column(String(20), default="user", nullable=False, index=True) # admin, user
is_active = Column(Boolean, default=True, nullable=False)
- can_access_intraday_trading = Column(Boolean, default=False, nullable=False, index=True) # Whether user can access intraday trading features
- participate_in_leaderboard = Column(Boolean, default=False, nullable=False, index=True) # Whether user participates in public ranking
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
@@ -32,9 +30,8 @@ class User(Base):
analysis_records = relationship("AnalysisRecord", back_populates="user", cascade="all, delete-orphan")
export_records = relationship("ExportRecord", back_populates="user", cascade="all, delete-orphan")
scheduled_tasks = relationship("ScheduledTask", back_populates="user", cascade="all, delete-orphan")
+ conversation_sessions = relationship("ConversationSession", back_populates="user", cascade="all, delete-orphan")
user_config = relationship("UserConfig", back_populates="user", uselist=False, cascade="all, delete-orphan")
- position_records = relationship("PositionRecord", back_populates="user", cascade="all, delete-orphan")
- intraday_decisions = relationship("IntradayDecisionRecord", back_populates="user", cascade="all, delete-orphan")
def __repr__(self):
return f""
@@ -58,28 +55,6 @@ class UserConfig(Base):
last_deep_thinker = Column(String(100), nullable=True) # Last deep thinker model
last_backend_url = Column(String(255), nullable=True) # Last backend URL
- # Trading executor configuration
- enable_trading_executor = Column(Boolean, default=False, nullable=False) # Whether to enable trading executor
- futu_api_base_url = Column(String(255), nullable=True) # Futu API base URL
- futu_api_key = Column(String(1000), nullable=True) # Futu API key (supports JWT tokens)
-
- # Intraday trading configuration
- intraday_futu_api_url = Column(String(255), nullable=True) # Intraday trading Futu API URL
- intraday_futu_api_key = Column(String(1000), nullable=True) # Intraday trading Futu API key (supports JWT tokens)
- intraday_scheduler_enabled = Column(Boolean, default=False, nullable=False) # Whether intraday scheduler is running
- intraday_scheduler_auto_start = Column(Boolean, default=False, nullable=False) # Whether to auto-start scheduler on service restart
- intraday_interval_minutes = Column(Integer, default=60, nullable=False) # Analysis interval in minutes
- intraday_market_type = Column(String(10), default='US', nullable=False) # Market type: US/HK/CN
-
- # Intraday trading LLM configuration
- intraday_llm_provider = Column(String(50), nullable=True) # LLM provider for intraday trading
- intraday_api_key = Column(String(1000), nullable=True) # API key for intraday trading LLM (supports JWT tokens)
- intraday_llm_model = Column(String(100), nullable=True) # LLM model for intraday trading (uses deep thinker options)
- intraday_backend_url = Column(String(255), nullable=True) # Backend URL for intraday trading LLM
-
- # Intraday access application tracking
- intraday_application_sent_at = Column(DateTime(timezone=True), nullable=True) # Last application email sent time
-
# API Key cache (single field for all LLM providers, should be encrypted in production)
last_api_key = Column(String(1000), nullable=True) # Last used API key (supports JWT tokens)
@@ -117,11 +92,6 @@ class ScheduledTask(Base):
api_key = Column(String(1000), nullable=True) # LLM API key for this scheduled task (supports JWT tokens)
is_public = Column(Boolean, default=False)
- # Trading executor configuration
- enable_trading_executor = Column(Boolean, default=False, nullable=False)
- futu_api_base_url = Column(String(255), nullable=True)
- futu_api_key = Column(String(1000), nullable=True) # Futu API key (supports JWT tokens)
-
# Email notification settings
email_notification_enabled = Column(Boolean, default=False, nullable=False) # Whether to send email notification
@@ -154,6 +124,47 @@ class ScheduledTask(Base):
def __repr__(self):
return f""
+
+class ConversationSession(Base):
+ """Conversation session for the ChatGPT-style agent entrypoint."""
+ __tablename__ = "conversation_sessions"
+
+ id = Column(String(36), primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
+ title = Column(String(200), default="新对话", nullable=False)
+ deleted_at = Column(DateTime(timezone=True), nullable=True, index=True)
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+
+ user = relationship("User", back_populates="conversation_sessions")
+ messages = relationship("ConversationMessage", back_populates="session", cascade="all, delete-orphan")
+
+ def __repr__(self):
+ return f""
+
+
+class ConversationMessage(Base):
+ """Message within a conversation session."""
+ __tablename__ = "conversation_messages"
+
+ id = Column(String(36), primary_key=True, index=True)
+ session_id = Column(String(36), ForeignKey("conversation_sessions.id"), nullable=False, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
+ role = Column(String(20), nullable=False, index=True) # user, assistant, system
+ content = Column(Text, nullable=False)
+ content_blocks = Column(JSON, nullable=True)
+ client_message_id = Column(String(100), nullable=True, index=True)
+ analysis_id = Column(String(255), nullable=True, index=True)
+ status = Column(String(20), default="completed", nullable=False, index=True)
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+
+ session = relationship("ConversationSession", back_populates="messages")
+ user = relationship("User")
+
+ def __repr__(self):
+ return f""
+
class AnalysisRecord(Base):
"""
Analysis record model to store analysis requests and results
@@ -178,12 +189,7 @@ class AnalysisRecord(Base):
api_key = Column(String(1000), nullable=True) # LLM API key for this specific task (supports JWT tokens)
# Privacy settings
- is_public = Column(Boolean, default=False, nullable=False, index=True) # Whether to show in public leaderboard
-
- # Trading executor configuration
- enable_trading_executor = Column(Boolean, default=False, nullable=False) # Whether to enable trading executor
- futu_api_base_url = Column(String(255), nullable=True) # Futu API base URL
- futu_api_key = Column(String(1000), nullable=True) # Futu API key (supports JWT tokens)
+ is_public = Column(Boolean, default=False, nullable=False, index=True) # Whether report can be viewed without login
# Email notification settings
email_notification_enabled = Column(Boolean, default=False, nullable=False) # Whether to send email notification
@@ -290,109 +296,6 @@ def __repr__(self):
return f""
-class PositionRecord(Base):
- """
- Position record model to track stock positions for intraday trading
- Records the first opening time and tracks position changes over time
- """
- __tablename__ = "position_records"
-
- id = Column(Integer, primary_key=True, index=True)
- user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
- stock_code = Column(String(20), nullable=False, index=True)
- market_type = Column(String(10), nullable=False) # US/HK/CN
-
- # Position info
- first_open_time = Column(DateTime(timezone=True), nullable=False)
- first_open_price = Column(Float, nullable=False)
- initial_quantity = Column(Integer, nullable=False)
-
- # Current status
- current_quantity = Column(Integer, nullable=False)
- last_update_time = Column(DateTime(timezone=True), nullable=False)
- is_closed = Column(Boolean, default=False, nullable=False)
-
- # Metadata
- created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
- updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
-
- # Relationships
- user = relationship("User", back_populates="position_records")
- trading_history = relationship("TradingHistory", back_populates="position_record", cascade="all, delete-orphan")
-
- def __repr__(self):
- return f""
-
-
-class TradingHistory(Base):
- """
- Trading history model to track all trades for each position
- Records buy/sell actions with decision context
- """
- __tablename__ = "trading_history"
-
- id = Column(Integer, primary_key=True, index=True)
- position_record_id = Column(Integer, ForeignKey("position_records.id"), nullable=False)
-
- # Trade info
- trade_time = Column(DateTime(timezone=True), nullable=False)
- trade_type = Column(String(10), nullable=False) # BUY/SELL
- quantity = Column(Integer, nullable=False)
- price = Column(Float, nullable=False)
- order_id = Column(String(50), nullable=True)
-
- # Decision context
- decision_reason = Column(Text, nullable=True)
- technical_signals = Column(JSON, nullable=True)
- news_sentiment = Column(String(20), nullable=True)
-
- # Metadata
- created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
-
- # Relationships
- position_record = relationship("PositionRecord", back_populates="trading_history")
-
- def __repr__(self):
- return f""
-
-
-class IntradayDecisionRecord(Base):
- """
- Intraday decision record model to store complete analysis sessions
- Records the full decision-making process including tool calls and reasoning
- """
- __tablename__ = "intraday_decision_records"
-
- id = Column(Integer, primary_key=True, index=True)
- user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
-
- # Session info
- session_id = Column(String(255), unique=True, nullable=False, index=True)
- start_time = Column(DateTime(timezone=True), nullable=False)
- end_time = Column(DateTime(timezone=True), nullable=True)
- status = Column(String(20), nullable=False) # running/completed/failed
-
- # Analysis context
- market_type = Column(String(10), nullable=False)
- positions_analyzed = Column(JSON, nullable=False) # List of stock codes
- account_snapshot = Column(JSON, nullable=False) # Account info at start
-
- # Decision output
- decision_report = Column(Text, nullable=True)
- trades_executed = Column(JSON, nullable=True) # List of trade details
- tool_calls = Column(JSON, nullable=True) # Complete tool call sequence
-
- # Metadata
- created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
- updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
-
- # Relationships
- user = relationship("User", back_populates="intraday_decisions")
-
- def __repr__(self):
- return f""
-
-
class AgentTool(Base):
"""
Agent tool definition model (system-maintained)
@@ -433,7 +336,7 @@ class AgentPromptTemplate(Base):
__tablename__ = "agent_prompt_templates"
id = Column(Integer, primary_key=True, index=True)
- agent_type = Column(String(50), nullable=False, index=True) # intraday_trader, analyst, etc.
+ agent_type = Column(String(50), nullable=False, index=True) # analysis_agent, analyst, etc.
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
# User-editable prompt
@@ -493,64 +396,6 @@ def __repr__(self):
return f""
-class AccountSnapshot(Base):
- """
- Account snapshot model to track daily account balance and positions
- Captures end-of-day account state for historical tracking and trend analysis
-
- Unique Constraint: Each user can only have ONE snapshot per market per day
- - Enforced by database index: uq_user_market_date (user_id, market_type, DATE(snapshot_date))
- - Prevents duplicate snapshots for the same trading day
- """
- __tablename__ = "account_snapshots"
-
- id = Column(Integer, primary_key=True, index=True)
- user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
- market_type = Column(String(10), nullable=False, index=True) # US, HK, CN
- snapshot_date = Column(DateTime(timezone=True), nullable=False, index=True) # Date of snapshot (end of trading day)
-
- # Note: Unique constraint on (user_id, market_type, DATE(snapshot_date)) is created via migration
- # This ensures only one snapshot per user per market per day
-
- # Account balance information
- total_assets = Column(Float, nullable=False) # Total account value
- cash = Column(Float, nullable=False) # Available cash
- market_value = Column(Float, nullable=False) # Total market value of positions
-
- # Additional metrics
- unrealized_pnl = Column(Float, default=0.0) # Unrealized profit/loss
- realized_pnl = Column(Float, default=0.0) # Realized profit/loss for the day
-
- # Raw account data (JSON)
- account_data = Column(JSON, nullable=True) # Full account snapshot data
- positions_data = Column(JSON, nullable=True) # Positions snapshot data
-
- # Metadata
- created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
-
- # Relationships
- user = relationship("User", backref="account_snapshots")
-
- def to_dict(self):
- return {
- "id": self.id,
- "user_id": self.user_id,
- "market_type": self.market_type,
- "snapshot_date": self.snapshot_date.isoformat() if self.snapshot_date else None,
- "total_assets": self.total_assets,
- "cash": self.cash,
- "market_value": self.market_value,
- "unrealized_pnl": self.unrealized_pnl,
- "realized_pnl": self.realized_pnl,
- "account_data": self.account_data,
- "positions_data": self.positions_data,
- "created_at": self.created_at.isoformat() if self.created_at else None,
- }
-
- def __repr__(self):
- return f""
-
-
class LLMProvider(Base):
"""
LLM Provider model for managing LLM service providers
diff --git a/web/backend/routes/__init__.py b/web/backend/routes/__init__.py
index 97cfbae..acbd719 100644
--- a/web/backend/routes/__init__.py
+++ b/web/backend/routes/__init__.py
@@ -2,6 +2,6 @@
API Routes Package
"""
-from . import analysis_routes, config_routes, task_routes, page_routes, websocket_routes, export_routes, scheduled_task_routes
+from . import analysis_routes, config_routes, task_routes, page_routes, websocket_routes, export_routes, scheduled_task_routes, skills_routes, conversation_routes, report_routes, home_routes
-__all__ = ['analysis_routes', 'config_routes', 'task_routes', 'page_routes', 'websocket_routes', 'export_routes', 'scheduled_task_routes']
+__all__ = ['analysis_routes', 'config_routes', 'task_routes', 'page_routes', 'websocket_routes', 'export_routes', 'scheduled_task_routes', 'skills_routes', 'conversation_routes', 'report_routes', 'home_routes']
diff --git a/web/backend/routes/account_snapshot_routes.py b/web/backend/routes/account_snapshot_routes.py
deleted file mode 100644
index c82b455..0000000
--- a/web/backend/routes/account_snapshot_routes.py
+++ /dev/null
@@ -1,268 +0,0 @@
-"""
-Account Snapshot Routes
-
-API endpoints for managing and retrieving account snapshots
-"""
-
-from fastapi import APIRouter, Depends, HTTPException, status
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, and_, func, desc
-from typing import List
-from datetime import datetime, timedelta, time
-
-from web.backend.database import get_db
-from web.backend.models import AccountSnapshot, User
-from web.backend.auth_routes import get_current_user
-
-router = APIRouter(prefix="/api/account-snapshots", tags=["account-snapshots"])
-
-
-@router.get("/trend/{market_type}")
-async def get_account_trend(
- market_type: str,
- days: int = 30,
- today_only: bool = False,
- db: AsyncSession = Depends(get_db),
- current_user: User = Depends(get_current_user)
-):
- """
- Get account balance trend for the specified market
-
- Args:
- market_type: Market type (US, HK, CN)
- days: Number of days to retrieve (default: 30), ignored if today_only=True
- today_only: If True, return only today's snapshots (intraday view)
-
- Returns:
- List of snapshots with balance information
- """
- import pytz
-
- # Get market timezone
- market_tz_map = {
- 'US': 'America/New_York',
- 'HK': 'Asia/Hong_Kong',
- 'CN': 'Asia/Shanghai'
- }
- market_tz = pytz.timezone(market_tz_map.get(market_type.upper(), 'UTC'))
-
- if today_only:
- # Get today's date in market timezone
- market_now = datetime.now(market_tz)
- market_today = market_now.date()
-
- # Create naive datetime range for query (database stores naive datetime in local time)
- market_day_start = datetime.combine(market_today, time.min)
- market_day_end = datetime.combine(market_today, time.max)
-
- # Query today's snapshots only
- query = select(AccountSnapshot).where(
- and_(
- AccountSnapshot.user_id == current_user.id,
- AccountSnapshot.market_type == market_type.upper(),
- AccountSnapshot.snapshot_date >= market_day_start,
- AccountSnapshot.snapshot_date <= market_day_end
- )
- ).order_by(AccountSnapshot.snapshot_date.asc())
-
- result = await db.execute(query)
- snapshots = result.scalars().all()
-
- # Return all snapshots directly from database (already in local time)
- trend_data = []
- for snapshot in snapshots:
- trend_data.append({
- "date": snapshot.snapshot_date.strftime("%Y-%m-%d %H:%M:%S"), # Date and time for today view
- "datetime": snapshot.snapshot_date.isoformat(),
- "total_assets": snapshot.total_assets,
- "cash": snapshot.cash,
- "market_value": snapshot.market_value,
- "unrealized_pnl": snapshot.unrealized_pnl,
- "realized_pnl": snapshot.realized_pnl,
- })
-
- return {
- "market_type": market_type.upper(),
- "start_date": market_today.strftime("%Y-%m-%d"),
- "end_date": market_today.strftime("%Y-%m-%d"),
- "today_only": True,
- "market_date": market_today.strftime("%Y-%m-%d"),
- "market_time": market_now.strftime("%H:%M:%S"),
- "data": trend_data
- }
- else:
- # Multi-day view: group by date and keep only the latest snapshot per day
- end_date = datetime.now()
- start_date = end_date - timedelta(days=days)
-
- # Query snapshots
- query = select(AccountSnapshot).where(
- and_(
- AccountSnapshot.user_id == current_user.id,
- AccountSnapshot.market_type == market_type.upper(),
- AccountSnapshot.snapshot_date >= start_date,
- AccountSnapshot.snapshot_date <= end_date
- )
- ).order_by(AccountSnapshot.snapshot_date.asc())
-
- result = await db.execute(query)
- snapshots = result.scalars().all()
-
- # Group by date and keep only the latest snapshot per day
- # Database already stores time in local timezone
- daily_snapshots = {}
- for snapshot in snapshots:
- date_key = snapshot.snapshot_date.strftime("%Y-%m-%d")
-
- # Keep the latest snapshot for each day
- if date_key not in daily_snapshots or snapshot.snapshot_date > daily_snapshots[date_key].snapshot_date:
- daily_snapshots[date_key] = snapshot
-
- # Convert to sorted list
- trend_data = []
- for date_key in sorted(daily_snapshots.keys()):
- snapshot = daily_snapshots[date_key]
- trend_data.append({
- "date": date_key,
- "datetime": snapshot.snapshot_date.isoformat(),
- "total_assets": snapshot.total_assets,
- "cash": snapshot.cash,
- "market_value": snapshot.market_value,
- "unrealized_pnl": snapshot.unrealized_pnl,
- "realized_pnl": snapshot.realized_pnl,
- })
-
- return {
- "market_type": market_type.upper(),
- "start_date": start_date.strftime("%Y-%m-%d"),
- "end_date": end_date.strftime("%Y-%m-%d"),
- "today_only": False,
- "data": trend_data
- }
-
-
-@router.get("/latest/{market_type}")
-async def get_latest_snapshot(
- market_type: str,
- db: AsyncSession = Depends(get_db),
- current_user: User = Depends(get_current_user)
-):
- """
- Get the latest account snapshot for the specified market
- """
- query = select(AccountSnapshot).where(
- and_(
- AccountSnapshot.user_id == current_user.id,
- AccountSnapshot.market_type == market_type.upper()
- )
- ).order_by(desc(AccountSnapshot.snapshot_date)).limit(1)
-
- result = await db.execute(query)
- snapshot = result.scalar_one_or_none()
-
- if not snapshot:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"No snapshot found for market {market_type}"
- )
-
- return snapshot.to_dict()
-
-
-# Manual snapshot creation endpoint removed - snapshots are now created automatically
-# by the snapshot scheduler at market close times
-
-
-@router.get("/stats/{market_type}")
-async def get_account_stats(
- market_type: str,
- db: AsyncSession = Depends(get_db),
- current_user: User = Depends(get_current_user)
-):
- """
- Get account statistics for the specified market
-
- Returns:
- - Latest snapshot
- - 7-day change
- - 30-day change
- - Total snapshots count
- """
- # Get latest snapshot
- latest_query = select(AccountSnapshot).where(
- and_(
- AccountSnapshot.user_id == current_user.id,
- AccountSnapshot.market_type == market_type.upper()
- )
- ).order_by(desc(AccountSnapshot.snapshot_date)).limit(1)
-
- latest_result = await db.execute(latest_query)
- latest = latest_result.scalar_one_or_none()
-
- if not latest:
- return {
- "market_type": market_type.upper(),
- "latest": None,
- "change_7d": None,
- "change_30d": None,
- "total_snapshots": 0
- }
-
- # Get snapshot from 7 days ago
- date_7d_ago = datetime.now() - timedelta(days=7)
- query_7d = select(AccountSnapshot).where(
- and_(
- AccountSnapshot.user_id == current_user.id,
- AccountSnapshot.market_type == market_type.upper(),
- AccountSnapshot.snapshot_date <= date_7d_ago
- )
- ).order_by(desc(AccountSnapshot.snapshot_date)).limit(1)
-
- result_7d = await db.execute(query_7d)
- snapshot_7d = result_7d.scalar_one_or_none()
-
- # Get snapshot from 30 days ago
- date_30d_ago = datetime.now() - timedelta(days=30)
- query_30d = select(AccountSnapshot).where(
- and_(
- AccountSnapshot.user_id == current_user.id,
- AccountSnapshot.market_type == market_type.upper(),
- AccountSnapshot.snapshot_date <= date_30d_ago
- )
- ).order_by(desc(AccountSnapshot.snapshot_date)).limit(1)
-
- result_30d = await db.execute(query_30d)
- snapshot_30d = result_30d.scalar_one_or_none()
-
- # Calculate changes
- change_7d = None
- if snapshot_7d:
- change_7d = {
- "amount": latest.total_assets - snapshot_7d.total_assets,
- "percentage": ((latest.total_assets - snapshot_7d.total_assets) / snapshot_7d.total_assets * 100) if snapshot_7d.total_assets > 0 else 0
- }
-
- change_30d = None
- if snapshot_30d:
- change_30d = {
- "amount": latest.total_assets - snapshot_30d.total_assets,
- "percentage": ((latest.total_assets - snapshot_30d.total_assets) / snapshot_30d.total_assets * 100) if snapshot_30d.total_assets > 0 else 0
- }
-
- # Get total count
- count_query = select(func.count(AccountSnapshot.id)).where(
- and_(
- AccountSnapshot.user_id == current_user.id,
- AccountSnapshot.market_type == market_type.upper()
- )
- )
- count_result = await db.execute(count_query)
- total_count = count_result.scalar()
-
- return {
- "market_type": market_type.upper(),
- "latest": latest.to_dict(),
- "change_7d": change_7d,
- "change_30d": change_30d,
- "total_snapshots": total_count
- }
diff --git a/web/backend/routes/analysis_routes.py b/web/backend/routes/analysis_routes.py
index f4b11c3..42b4628 100644
--- a/web/backend/routes/analysis_routes.py
+++ b/web/backend/routes/analysis_routes.py
@@ -77,13 +77,6 @@ async def start_analysis(
user_config.last_shallow_thinker = request.shallow_thinker
user_config.last_deep_thinker = request.deep_thinker
user_config.last_backend_url = request.backend_url
- user_config.enable_trading_executor = request.enable_trading_executor
-
- # Update Futu API config if provided
- if request.futu_api_base_url:
- user_config.futu_api_base_url = request.futu_api_base_url
- if request.futu_api_key:
- user_config.futu_api_key = request.futu_api_key
# Update API key if provided (single field for all providers)
if request.api_key:
@@ -149,9 +142,6 @@ async def start_analysis(
backend_url=request.backend_url,
api_key=api_key, # Save API key with the task
is_public=request.is_public, # Save privacy setting
- enable_trading_executor=request.enable_trading_executor, # Save trading executor setting
- futu_api_base_url=request.futu_api_base_url, # Save Futu API config
- futu_api_key=request.futu_api_key, # Save Futu API key
email_notification_enabled=request.email_notification, # Save email notification preference
status="queued",
current_step="Analysis queued",
@@ -172,9 +162,6 @@ async def start_analysis(
'shallow_thinker': request.shallow_thinker,
'deep_thinker': request.deep_thinker,
'backend_url': request.backend_url,
- 'enable_trading_executor': request.enable_trading_executor,
- 'futu_api_base_url': request.futu_api_base_url or user_config.futu_api_base_url,
- 'futu_api_key': request.futu_api_key or user_config.futu_api_key,
'api_key': api_key, # Single API key field
}
@@ -274,9 +261,6 @@ async def get_analysis_status(
# Get selected_analysts from analysts field (JSON)
selected_analysts = analysis.analysts if analysis.analysts else []
- # Get enable_trading_executor (need to add this field to model if not exists)
- enable_trading_executor = getattr(analysis, 'enable_trading_executor', False)
-
return AnalysisStatus(
analysis_id=analysis.analysis_id,
status=analysis.status,
@@ -285,7 +269,6 @@ async def get_analysis_status(
started_at=analysis.started_at,
updated_at=analysis.updated_at,
selected_analysts=selected_analysts,
- enable_trading_executor=enable_trading_executor
)
@@ -364,15 +347,12 @@ async def get_analysis_results(
"agents": research_agents
})
- # 阶段3:交易团队(包含交易员和执行交易员)
+ # 阶段3:交易团队(只输出建议,不执行下单)
if final_state.get("trader_investment_plan"):
trading_agents = [
{"name": "交易员", "result": final_state["trader_investment_plan"]}
]
- # 如果有执行交易报告,添加到交易团队
- if final_state.get("execution_report"):
- trading_agents.append({"name": "执行交易员", "result": final_state["execution_report"]})
-
+
phases.append({
"id": 3,
"name": "交易团队",
@@ -583,16 +563,11 @@ async def get_analysis_markdown(
markdown_parts.append("## 投资评审\n")
markdown_parts.append(debate_state["judge_decision"] + "\n")
- # 阶段3:交易团队(包含交易员和执行交易员)
+ # 阶段3:交易团队(只输出建议,不执行下单)
if final_state.get("trader_investment_plan"):
markdown_parts.append("\n---\n\n# 📈 交易团队\n")
markdown_parts.append("## 交易员\n")
markdown_parts.append(final_state["trader_investment_plan"] + "\n")
-
- # 如果有执行交易报告,添加到交易团队
- if final_state.get("execution_report"):
- markdown_parts.append("## 执行交易员\n")
- markdown_parts.append(final_state["execution_report"] + "\n")
# 阶段4:风险管理
if final_state.get("risk_debate_state"):
diff --git a/web/backend/routes/config_routes.py b/web/backend/routes/config_routes.py
index 2e06fea..f2d058b 100644
--- a/web/backend/routes/config_routes.py
+++ b/web/backend/routes/config_routes.py
@@ -15,8 +15,8 @@
@router.get("/config")
-async def get_config(current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_db)):
- """Get configuration options for the frontend (requires authentication)"""
+async def get_config(db: AsyncSession = Depends(get_db)):
+ """Get public configuration options for the frontend."""
# 获取LLM供应商和模型配置(从数据库动态获取)
from sqlalchemy import select
diff --git a/web/backend/routes/conversation_routes.py b/web/backend/routes/conversation_routes.py
new file mode 100644
index 0000000..0b158ef
--- /dev/null
+++ b/web/backend/routes/conversation_routes.py
@@ -0,0 +1,338 @@
+#!/usr/bin/env python3
+"""Conversation session and message APIs."""
+
+from __future__ import annotations
+
+import re
+import uuid
+from datetime import datetime
+from typing import Any, Dict, Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from pydantic import BaseModel, Field
+from sqlalchemy import desc, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from cli.models import AnalystType
+from tradingagents.default_config import DEFAULT_CONFIG
+from web.backend.analysis_task import run_analysis_task
+from web.backend.auth_routes import get_current_active_user
+from web.backend.database import get_db
+from web.backend.models import AnalysisRecord, ConversationMessage, ConversationSession, User, UserConfig
+from web.backend.utils.market_detector import detect_market, normalize_ticker, normalize_ticker_with_suffix, validate_ticker
+
+router = APIRouter(prefix="/api/conversations", tags=["conversations"])
+
+task_manager = None
+manager = None
+
+
+def init_conversation_routes(tm, ws_manager):
+ global task_manager, manager
+ task_manager = tm
+ manager = ws_manager
+
+
+class ConversationCreate(BaseModel):
+ title: str = Field("新对话", max_length=200)
+
+
+class ConversationUpdate(BaseModel):
+ title: str = Field(..., min_length=1, max_length=200)
+
+
+class MessageCreate(BaseModel):
+ content: str = Field(..., min_length=1, max_length=4000)
+ client_message_id: Optional[str] = None
+
+
+class FollowUpCreate(BaseModel):
+ action: str = Field(..., pattern="^(retry_stage|expand_section|ask_followup)$")
+ stage: Optional[str] = None
+ section: Optional[str] = None
+ content: Optional[str] = None
+
+
+def _now_id() -> str:
+ return str(uuid.uuid4())
+
+
+def _message_blocks(role: str, content: str, report_id: str | None = None) -> list[dict]:
+ blocks = [{"type": "text", "content": content}]
+ if report_id:
+ blocks.append({"type": "report", "report_id": report_id, "report_preview": None})
+ return blocks
+
+
+async def _session_or_404(db: AsyncSession, session_id: str, user_id: int) -> ConversationSession:
+ result = await db.execute(select(ConversationSession).where(
+ ConversationSession.id == session_id,
+ ConversationSession.user_id == user_id,
+ ConversationSession.deleted_at.is_(None),
+ ))
+ session = result.scalars().first()
+ if not session:
+ raise HTTPException(status_code=404, detail="会话不存在")
+ return session
+
+
+async def _session_payload(db: AsyncSession, session: ConversationSession) -> Dict[str, Any]:
+ count_result = await db.execute(select(func.count(ConversationMessage.id)).where(ConversationMessage.session_id == session.id))
+ message_count = count_result.scalar() or 0
+ last_result = await db.execute(select(ConversationMessage).where(ConversationMessage.session_id == session.id).order_by(desc(ConversationMessage.created_at)).limit(1))
+ last = last_result.scalars().first()
+ active_result = await db.execute(select(func.count(ConversationMessage.id)).where(
+ ConversationMessage.session_id == session.id,
+ ConversationMessage.analysis_id.is_not(None),
+ ConversationMessage.status.in_(["queued", "running"]),
+ ))
+ return {
+ "id": session.id,
+ "title": session.title,
+ "last_message_preview": (last.content[:120] if last else None),
+ "message_count": message_count,
+ "has_active_analysis": (active_result.scalar() or 0) > 0,
+ "created_at": session.created_at.isoformat() if session.created_at else None,
+ "updated_at": session.updated_at.isoformat() if session.updated_at else None,
+ }
+
+
+def _message_payload(message: ConversationMessage) -> Dict[str, Any]:
+ return {
+ "id": message.id,
+ "session_id": message.session_id,
+ "role": message.role,
+ "content": message.content,
+ "content_blocks": message.content_blocks or _message_blocks(message.role, message.content, message.analysis_id if message.role == "assistant" else None),
+ "created_at": message.created_at.isoformat() if message.created_at else None,
+ }
+
+
+def _extract_ticker(content: str) -> str | None:
+ patterns = [
+ r"\b\d{6}\.(?:SH|SZ)\b",
+ r"\b\d{4,5}\.HK\b",
+ r"\b[A-Z]{1,5}\b",
+ r"\b\d{6}\b",
+ r"\b\d{4,5}\b",
+ ]
+ text = content.upper()
+ for pattern in patterns:
+ match = re.search(pattern, text)
+ if match:
+ return match.group(0)
+ return None
+
+
+async def _get_user_config(db: AsyncSession, user_id: int) -> UserConfig:
+ result = await db.execute(select(UserConfig).where(UserConfig.user_id == user_id))
+ config = result.scalars().first()
+ if not config:
+ config = UserConfig(user_id=user_id)
+ db.add(config)
+ await db.flush()
+ return config
+
+
+async def _trigger_analysis(db: AsyncSession, user: User, session: ConversationSession, assistant_message: ConversationMessage, content: str) -> AnalysisRecord:
+ ticker_raw = _extract_ticker(content)
+ if not ticker_raw:
+ raise HTTPException(status_code=400, detail="未识别到标的代码,请在消息中包含如 AAPL、0700.HK 或 600519.SH 的代码")
+ ticker = normalize_ticker_with_suffix(normalize_ticker(ticker_raw))
+ if not validate_ticker(normalize_ticker(ticker)):
+ raise HTTPException(status_code=400, detail=f"无效的股票代码格式: {ticker_raw}")
+
+ user_config = await _get_user_config(db, user.id)
+ api_key = user_config.last_api_key or DEFAULT_CONFIG.get("openai_api_key") or ""
+ now = datetime.utcnow()
+ analysis_id = f"conv_{now.strftime('%Y%m%d_%H%M%S')}_{ticker}_{user.id}_{assistant_message.id[:8]}"
+ analysts = [item.value for item in AnalystType if item.value in {"market", "social", "news", "fundamentals"}]
+ record = AnalysisRecord(
+ analysis_id=analysis_id,
+ user_id=user.id,
+ ticker=ticker,
+ market=detect_market(ticker),
+ analysis_date=now.strftime("%Y-%m-%d"),
+ analysts=analysts,
+ research_depth=user_config.last_research_depth or 1,
+ llm_provider=user_config.last_llm_provider or DEFAULT_CONFIG["llm_provider"],
+ shallow_thinker=user_config.last_shallow_thinker or DEFAULT_CONFIG["quick_think_llm"],
+ deep_thinker=user_config.last_deep_thinker or DEFAULT_CONFIG["deep_think_llm"],
+ backend_url=user_config.last_backend_url or DEFAULT_CONFIG["backend_url"],
+ api_key=api_key,
+ is_public=False,
+ status="queued",
+ current_step="对话触发分析已入队",
+ progress_percentage=0.0,
+ )
+ db.add(record)
+ assistant_message.analysis_id = analysis_id
+ assistant_message.status = "queued"
+ assistant_message.content = f"已识别标的 {ticker},正在启动分析。"
+ assistant_message.content_blocks = [
+ {"type": "text", "content": assistant_message.content},
+ {
+ "type": "stage_progress",
+ "stage_id": "intent",
+ "stage_name": "意图识别",
+ "status": "complete",
+ "summary": f"识别到分析请求:{ticker}",
+ "started_at": now.isoformat(),
+ "completed_at": now.isoformat(),
+ },
+ ]
+ await db.flush()
+ # Commit before submit_task: the worker uses an independent SessionLocal and
+ # cannot see this AnalysisRecord while the route transaction is uncommitted.
+ await db.commit()
+
+ request_data = {
+ "ticker": ticker,
+ "analysis_date": record.analysis_date,
+ "analysts": analysts,
+ "research_depth": record.research_depth,
+ "llm_provider": record.llm_provider,
+ "shallow_thinker": record.shallow_thinker,
+ "deep_thinker": record.deep_thinker,
+ "backend_url": record.backend_url,
+ "api_key": api_key,
+ "conversation_session_id": session.id,
+ "conversation_message_id": assistant_message.id,
+ }
+ submitted = task_manager.submit_task(
+ analysis_id,
+ user.id,
+ run_analysis_task,
+ analysis_id,
+ user.id,
+ request_data,
+ manager,
+ task_manager,
+ )
+ record.status = "running" if submitted else "queued"
+ assistant_message.status = record.status
+ return record
+
+
+@router.get("")
+async def list_conversations(
+ page: int = Query(1, ge=1),
+ limit: int = Query(50, ge=1, le=100),
+ current_user: User = Depends(get_current_active_user),
+ db: AsyncSession = Depends(get_db),
+):
+ offset = (page - 1) * limit
+ total_result = await db.execute(select(func.count(ConversationSession.id)).where(
+ ConversationSession.user_id == current_user.id,
+ ConversationSession.deleted_at.is_(None),
+ ))
+ total = total_result.scalar() or 0
+ result = await db.execute(select(ConversationSession).where(
+ ConversationSession.user_id == current_user.id,
+ ConversationSession.deleted_at.is_(None),
+ ).order_by(desc(ConversationSession.updated_at)).offset(offset).limit(limit))
+ items = [await _session_payload(db, session) for session in result.scalars().all()]
+ return {"data": items, "meta": {"page": page, "limit": limit, "total": total, "has_next": offset + limit < total}}
+
+
+@router.post("", status_code=status.HTTP_201_CREATED)
+async def create_conversation(payload: ConversationCreate, current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_db)):
+ session = ConversationSession(id=_now_id(), user_id=current_user.id, title=payload.title or "新对话")
+ db.add(session)
+ await db.commit()
+ await db.refresh(session)
+ return {"data": await _session_payload(db, session)}
+
+
+@router.get("/{session_id}")
+async def get_conversation(session_id: str, current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_db)):
+ session = await _session_or_404(db, session_id, current_user.id)
+ return {"data": await _session_payload(db, session)}
+
+
+@router.patch("/{session_id}")
+async def update_conversation(session_id: str, payload: ConversationUpdate, current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_db)):
+ session = await _session_or_404(db, session_id, current_user.id)
+ session.title = payload.title.strip()
+ await db.commit()
+ await db.refresh(session)
+ return {"data": await _session_payload(db, session)}
+
+
+@router.delete("/{session_id}")
+async def delete_conversation(session_id: str, current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_db)):
+ session = await _session_or_404(db, session_id, current_user.id)
+ session.deleted_at = datetime.utcnow()
+ await db.commit()
+ return {"data": {"deleted": True, "id": session_id}}
+
+
+@router.get("/{session_id}/messages")
+async def list_messages(
+ session_id: str,
+ before_id: Optional[str] = None,
+ limit: int = Query(50, ge=1, le=100),
+ current_user: User = Depends(get_current_active_user),
+ db: AsyncSession = Depends(get_db),
+):
+ await _session_or_404(db, session_id, current_user.id)
+ stmt = select(ConversationMessage).where(ConversationMessage.session_id == session_id)
+ if before_id:
+ before = await db.get(ConversationMessage, before_id)
+ if before:
+ stmt = stmt.where(ConversationMessage.created_at < before.created_at)
+ result = await db.execute(stmt.order_by(desc(ConversationMessage.created_at)).limit(limit + 1))
+ rows = result.scalars().all()
+ has_more = len(rows) > limit
+ rows = rows[:limit]
+ rows.reverse()
+ return {
+ "data": [_message_payload(row) for row in rows],
+ "meta": {"has_more": has_more, "oldest_message_id": rows[0].id if rows else None},
+ }
+
+
+@router.post("/{session_id}/messages", status_code=status.HTTP_201_CREATED)
+async def create_message(session_id: str, payload: MessageCreate, current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_db)):
+ session = await _session_or_404(db, session_id, current_user.id)
+ if payload.client_message_id:
+ existing_result = await db.execute(select(ConversationMessage).where(
+ ConversationMessage.session_id == session_id,
+ ConversationMessage.client_message_id == payload.client_message_id,
+ ))
+ existing = existing_result.scalars().first()
+ if existing:
+ return {"data": _message_payload(existing)}
+
+ user_message = ConversationMessage(
+ id=_now_id(),
+ session_id=session.id,
+ user_id=current_user.id,
+ role="user",
+ content=payload.content,
+ content_blocks=_message_blocks("user", payload.content),
+ client_message_id=payload.client_message_id,
+ )
+ assistant_message = ConversationMessage(
+ id=_now_id(),
+ session_id=session.id,
+ user_id=current_user.id,
+ role="assistant",
+ content="正在理解你的请求...",
+ content_blocks=[{"type": "stage_progress", "stage_id": "intent", "stage_name": "意图识别", "status": "active", "summary": "正在识别标的与分析意图", "started_at": datetime.utcnow().isoformat(), "completed_at": None}],
+ status="queued",
+ )
+ db.add_all([user_message, assistant_message])
+ session.updated_at = datetime.utcnow()
+ await db.flush()
+ await _trigger_analysis(db, current_user, session, assistant_message, payload.content)
+ await db.commit()
+ await db.refresh(user_message)
+ return {"data": _message_payload(user_message)}
+
+
+@router.post("/{session_id}/messages/{message_id}/follow-up", status_code=status.HTTP_201_CREATED)
+async def follow_up(session_id: str, message_id: str, payload: FollowUpCreate, current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_db)):
+ await _session_or_404(db, session_id, current_user.id)
+ content = payload.content or f"请继续处理 {payload.action}: {payload.stage or payload.section or message_id}"
+ return await create_message(session_id, MessageCreate(content=content), current_user, db)
diff --git a/web/backend/routes/home_routes.py b/web/backend/routes/home_routes.py
new file mode 100644
index 0000000..e9b2433
--- /dev/null
+++ b/web/backend/routes/home_routes.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python3
+"""Home page data APIs."""
+
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import desc, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from web.backend.database import get_db
+from web.backend.models import AnalysisRecord
+from web.backend.services.report_formatter import report_preview
+
+router = APIRouter(prefix="/api/home", tags=["home"])
+
+
+@router.get("/public-reports")
+async def public_report_feed(limit: int = Query(6, ge=1, le=20), db: AsyncSession = Depends(get_db)):
+ """Return recent public analysis reports for the home entry, replacing legacy ranking data."""
+ result = await db.execute(select(AnalysisRecord).where(
+ AnalysisRecord.is_public == True,
+ AnalysisRecord.status == "completed",
+ ).order_by(desc(AnalysisRecord.created_at)).limit(limit))
+ records = result.scalars().all()
+ return {"data": [report_preview(record) for record in records], "meta": {"limit": limit, "total": len(records), "has_next": False}}
diff --git a/web/backend/routes/intraday_trading_routes.py b/web/backend/routes/intraday_trading_routes.py
deleted file mode 100644
index 754dd88..0000000
--- a/web/backend/routes/intraday_trading_routes.py
+++ /dev/null
@@ -1,1324 +0,0 @@
-#!/usr/bin/env python3
-"""
-Intraday Trading API Routes
-短线交易系统相关�?API 路由
-"""
-
-from fastapi import APIRouter, Depends, HTTPException, Request
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, desc
-from typing import List, Optional
-from datetime import datetime, timedelta
-import logging
-
-from web.backend.database import get_db
-from web.backend.models import User, IntradayDecisionRecord, PositionRecord, TradingHistory
-from web.backend.auth_routes import get_current_active_user, require_intraday_access
-from pydantic import BaseModel, Field
-
-# Get logger for this module
-logger = logging.getLogger(__name__)
-
-
-router = APIRouter(prefix="/api/intraday", tags=["intraday-trading"])
-
-
-# ============================================================================
-# Pydantic Models for Request/Response
-# ============================================================================
-
-class SchedulerControlRequest(BaseModel):
- """Request to control scheduler"""
- action: str # "start" or "stop"
-
-
-class SchedulerConfigRequest(BaseModel):
- """Request to configure scheduler"""
- interval_minutes: int = Field(..., ge=5, le=120, description="分析间隔(分钟),范围:5-120,默认60")
- market_type: Optional[str] = "US,HK,CN" # Single market (US/HK/CN) or comma-separated (US,HK,CN)
-
-
-class SchedulerStatusResponse(BaseModel):
- """Scheduler status response"""
- is_running: bool
- interval_minutes: int
- market_type: str
- market_status: str
- market_is_open: bool
- next_run_time: Optional[str]
- current_time: str
-
-
-class DecisionRecordResponse(BaseModel):
- """Decision record response"""
- id: int
- session_id: str
- start_time: datetime
- end_time: Optional[datetime]
- status: str
- market_type: str
- positions_analyzed: list
- account_snapshot: Optional[dict] = None
- decision_report: Optional[str] = None
- trades_executed: Optional[list] = None
- created_at: datetime
-
- class Config:
- from_attributes = True
-
-
-class PositionRecordResponse(BaseModel):
- """Position record response"""
- id: int
- stock_code: str
- market_type: str
- first_open_time: datetime
- first_open_price: float
- initial_quantity: int
- current_quantity: int
- last_update_time: datetime
- is_closed: bool
- holding_days: Optional[int] = None
-
- class Config:
- from_attributes = True
-
-
-class TradingHistoryResponse(BaseModel):
- """Trading history response"""
- id: int
- position_record_id: int
- trade_time: datetime
- trade_type: str
- quantity: int
- price: float
- order_id: Optional[str]
- decision_reason: Optional[str]
-
- class Config:
- from_attributes = True
-
-
-# ============================================================================
-# Helper Functions
-# ============================================================================
-
-async def sync_positions_to_db(
- db: AsyncSession,
- user_id: int,
- futu_positions: list,
- market: str
-):
- """
- Sync Futu API positions to database.
-
- - Creates new position records for new positions
- - Updates existing position records if quantity changed
- - Marks positions as closed if they no longer exist in Futu API
- """
- from sqlalchemy import select
-
- # Get current positions from database for this market
- result = await db.execute(
- select(PositionRecord).where(
- PositionRecord.user_id == user_id,
- PositionRecord.market_type == market,
- PositionRecord.is_closed == False
- )
- )
- db_positions = {pos.stock_code: pos for pos in result.scalars().all()}
-
- # Get stock codes from Futu API
- futu_stock_codes = {pos.get('stock_code', '') for pos in futu_positions}
-
- # Process each Futu position
- for futu_pos in futu_positions:
- stock_code = futu_pos.get('stock_code', '')
- if not stock_code:
- continue
-
- quantity = int(float(futu_pos.get('quantity', 0)))
- cost_price = float(futu_pos.get('cost_price', 0))
-
- if stock_code in db_positions:
- # Update existing position
- db_pos = db_positions[stock_code]
-
- # Check if quantity changed
- if db_pos.current_quantity != quantity:
- db_pos.current_quantity = quantity
- db_pos.last_update_time = datetime.now()
-
- # If quantity is 0, mark as closed
- if quantity == 0:
- db_pos.is_closed = True
- else:
- # Create new position record
- if quantity > 0: # Only create if there's actual quantity
- new_position = PositionRecord(
- user_id=user_id,
- stock_code=stock_code,
- market_type=market,
- first_open_time=datetime.now(),
- first_open_price=cost_price,
- initial_quantity=quantity,
- current_quantity=quantity,
- last_update_time=datetime.now(),
- is_closed=False
- )
- db.add(new_position)
-
- # Mark positions as closed if they no longer exist in Futu API
- for stock_code, db_pos in db_positions.items():
- if stock_code not in futu_stock_codes and not db_pos.is_closed:
- db_pos.is_closed = True
- db_pos.current_quantity = 0
- db_pos.last_update_time = datetime.now()
-
- # Commit all changes
- await db.commit()
-
-
-# ============================================================================
-# Scheduler Control Endpoints
-# ============================================================================
-
-@router.post("/scheduler/control")
-async def control_scheduler(
- request: SchedulerControlRequest,
- current_user: User = Depends(require_intraday_access),
- app_request: Request = None,
- db: AsyncSession = Depends(get_db),
-):
- """
- Start or stop the intraday trading scheduler for current user.
-
- Requires authentication and Futu API configuration.
- """
- try:
- from web.backend.services.user_intraday_scheduler import get_manager
- from web.backend.models import UserConfig
- from sqlalchemy import select
-
- manager = get_manager()
- user_id = current_user.id
-
- # Get user config
- result = await db.execute(
- select(UserConfig).where(UserConfig.user_id == user_id)
- )
- user_config = result.scalar_one_or_none()
-
- if request.action == "start":
- logger.info(f"Starting scheduler for user {user_id}")
-
- # Get or create user config
- if not user_config:
- user_config = UserConfig(user_id=user_id)
- db.add(user_config)
- await db.commit()
- await db.refresh(user_config)
-
- # Check if Futu API is configured (fallback to analysis config)
- futu_api_url = user_config.intraday_futu_api_url or user_config.futu_api_base_url
-
- if not futu_api_url:
- logger.error(f"No Futu API URL configured for user {user_id}")
- raise HTTPException(
- status_code=400,
- detail="请先配置富途API地址"
- )
-
- # Create scheduler if doesn't exist
- if not manager.has_scheduler(user_id):
- # Get market type, default to all markets if not set
- market_type = user_config.intraday_market_type
- if not market_type:
- market_type = "US,HK,CN"
- user_config.intraday_market_type = "US,HK,CN"
- await db.commit()
- # Invalidate cache after setting default market type
- from web.backend.services.user_config_cache import invalidate_user_config_cache
- invalidate_user_config_cache(user_id)
-
- await manager.create_scheduler(
- user_id=user_id,
- interval_minutes=user_config.intraday_interval_minutes or 60,
- market_type=market_type,
- futu_api_url=futu_api_url,
- )
-
- # Start scheduler
- success = await manager.start_scheduler(user_id)
- if not success:
- raise HTTPException(status_code=500, detail="Failed to start scheduler")
-
- # Update user config
- user_config.intraday_scheduler_enabled = True
- user_config.intraday_scheduler_auto_start = True # Mark for auto-restart on service restart
- await db.commit()
-
- # Invalidate cache after updating scheduler status
- from web.backend.services.user_config_cache import invalidate_user_config_cache
- invalidate_user_config_cache(user_id)
-
- # Broadcast status update via WebSocket
- try:
- status = manager.get_scheduler_status(user_id)
- if status:
- from web.backend.app import manager as ws_manager
- import asyncio
- channel_id = f"intraday_user_{user_id}"
-
- # Send action confirmation message
- asyncio.create_task(ws_manager.send_message({
- 'type': 'scheduler_started',
- 'timestamp': status.get('current_time'),
- 'status': status,
- 'message': 'Scheduler started successfully',
- }, channel_id))
-
-
- except Exception as ws_error:
- logger.warning(f"Failed to broadcast scheduler status: {ws_error}")
-
- return {"status": "success", "message": "Scheduler started"}
-
- elif request.action == "stop":
- # Stop scheduler
- success = await manager.stop_scheduler(user_id)
- if not success:
- raise HTTPException(status_code=500, detail="Failed to stop scheduler")
-
- # Update user config
- if user_config:
- user_config.intraday_scheduler_enabled = False
- user_config.intraday_scheduler_auto_start = False # Clear auto-restart flag (manual stop)
- await db.commit()
-
- # Invalidate cache after updating scheduler status
- from web.backend.services.user_config_cache import invalidate_user_config_cache
- invalidate_user_config_cache(user_id)
-
- # Broadcast stopped status via WebSocket
- try:
- # Get stopped status
- status = manager.get_scheduler_status(user_id)
-
- # If scheduler was removed, create stopped status
- if not status:
- status = {
- "is_running": False,
- "interval_minutes": user_config.intraday_interval_minutes if user_config else 60,
- "market_type": user_config.intraday_market_type if user_config else "US,HK,CN",
- "market_status": "Scheduler stopped",
- "market_is_open": False,
- "markets_status": {},
- "next_run_time": None,
- "current_time": datetime.now().isoformat(),
- }
-
- from web.backend.app import manager as ws_manager
- import asyncio
- channel_id = f"intraday_user_{user_id}"
-
- # Send action confirmation message
- asyncio.create_task(ws_manager.send_message({
- 'type': 'scheduler_stopped',
- 'timestamp': status.get('current_time'),
- 'status': status,
- 'message': 'Scheduler stopped successfully',
- }, channel_id))
-
-
- except Exception as ws_error:
- logger.warning(f"Failed to broadcast scheduler stopped status: {ws_error}")
-
- return {"status": "success", "message": "Scheduler stopped"}
-
- else:
- raise HTTPException(status_code=400, detail="Invalid action. Use 'start' or 'stop'")
-
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-# DEPRECATED: This endpoint has been replaced by WebSocket 'scheduler_status_sync' message
-# The status is now pushed via WebSocket on connection and updates
-# Keeping this commented for reference only
-#
-# @router.get("/scheduler/status", response_model=SchedulerStatusResponse)
-# async def get_scheduler_status(
-# current_user: User = Depends(get_current_active_user),
-# db: AsyncSession = Depends(get_db),
-# ):
-# """
-# DEPRECATED: Use WebSocket 'scheduler_status_sync' message instead.
-# This endpoint is no longer used by the frontend.
-# """
-# pass
-
-
-class IntradayConfigRequest(BaseModel):
- """Request to configure intraday trading"""
- futu_api_url: Optional[str] = None
- futu_api_key: Optional[str] = None
- interval_minutes: Optional[int] = None
- market_type: Optional[str] = None
- llm_provider: Optional[str] = None
- api_key: Optional[str] = None
- llm_model: Optional[str] = None # Single model (uses deep thinker options from analysis config)
- backend_url: Optional[str] = None
-
-
-@router.get("/scheduler/config")
-async def get_scheduler_config(
- current_user: User = Depends(require_intraday_access),
- db: AsyncSession = Depends(get_db),
-):
- """
- Get intraday trading configuration for current user.
-
- If no separate intraday config exists, falls back to analysis config.
-
- Requires authentication.
- """
- try:
- from web.backend.models import UserConfig
- from sqlalchemy import select
-
- # Get user config
- result = await db.execute(
- select(UserConfig).where(UserConfig.user_id == current_user.id)
- )
- user_config = result.scalar_one_or_none()
-
- if not user_config:
- # Return default config
- return {
- "futu_api_url": None,
- "futu_api_key": None,
- "interval_minutes": 60,
- "market_type": "US,HK,CN", # Default to all markets (comma-separated)
- "llm_provider": None,
- "api_key": None,
- "backend_url": None,
- "is_using_analysis_config": False,
- }
-
- # Priority: Use saved intraday config first, fallback to analysis config
- # For each field, check if intraday config exists, if not use analysis config
- futu_api_url = user_config.intraday_futu_api_url or user_config.futu_api_base_url
- futu_api_key = user_config.intraday_futu_api_key or user_config.futu_api_key
- llm_provider = user_config.intraday_llm_provider or user_config.last_llm_provider
- api_key = user_config.intraday_api_key or user_config.last_api_key
- llm_model = user_config.intraday_llm_model or user_config.last_deep_thinker
- backend_url = user_config.intraday_backend_url or user_config.last_backend_url
-
- # Determine if using analysis config (all intraday fields are empty)
- is_using_analysis_config = not any([
- user_config.intraday_futu_api_url,
- user_config.intraday_futu_api_key,
- user_config.intraday_llm_provider,
- user_config.intraday_api_key,
- user_config.intraday_llm_model,
- user_config.intraday_backend_url
- ])
-
- return {
- "futu_api_url": futu_api_url,
- "futu_api_key": futu_api_key, # Return actual key for validation
- "has_futu_api_key": bool(futu_api_key), # Indicate if key exists
- "interval_minutes": user_config.intraday_interval_minutes if user_config.intraday_interval_minutes is not None else 60,
- "market_type": user_config.intraday_market_type or "US,HK,CN", # Default to all markets (comma-separated)
- "llm_provider": llm_provider,
- "api_key": api_key,
- "has_api_key": bool(api_key),
- "llm_model": llm_model, # Single model (deep thinker options)
- "backend_url": backend_url,
- "is_using_analysis_config": is_using_analysis_config, # Indicates if using fallback config
- }
-
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@router.post("/scheduler/config")
-async def configure_scheduler(
- config: IntradayConfigRequest,
- current_user: User = Depends(require_intraday_access),
- db: AsyncSession = Depends(get_db),
-):
- """
- Configure intraday trading settings for current user.
-
- Requires authentication.
- """
- try:
- from web.backend.services.user_intraday_scheduler import get_manager
- from web.backend.models import UserConfig
- from sqlalchemy import select
-
- manager = get_manager()
- user_id = current_user.id
-
- # Get or create user config
- result = await db.execute(
- select(UserConfig).where(UserConfig.user_id == user_id)
- )
- user_config = result.scalar_one_or_none()
-
- if not user_config:
- user_config = UserConfig(user_id=user_id)
- db.add(user_config)
-
- # Validate and update configuration
- if config.interval_minutes is not None:
- if config.interval_minutes < 5 or config.interval_minutes > 120:
- raise HTTPException(
- status_code=400,
- detail="分析间隔必须在5-120分钟之间"
- )
- user_config.intraday_interval_minutes = config.interval_minutes
-
- if config.market_type is not None:
- # Validate market type: single market or comma-separated markets
- if "," in config.market_type:
- # Validate comma-separated markets
- markets = [m.strip() for m in config.market_type.split(",")]
- invalid_markets = [m for m in markets if m not in ["US", "HK", "CN"]]
- if invalid_markets:
- raise HTTPException(
- status_code=400,
- detail=f"Invalid market(s): {', '.join(invalid_markets)}. Must be US, HK, or CN"
- )
- user_config.intraday_market_type = config.market_type
- elif config.market_type in ["US", "HK", "CN"]:
- user_config.intraday_market_type = config.market_type
- else:
- raise HTTPException(
- status_code=400,
- detail="Market type must be US, HK, CN, or comma-separated markets (e.g., US,HK,CN)"
- )
-
- if config.futu_api_url is not None:
- user_config.intraday_futu_api_url = config.futu_api_url
-
- if config.futu_api_key is not None:
- user_config.intraday_futu_api_key = config.futu_api_key
-
- # Update LLM configuration
- if config.llm_provider is not None:
- user_config.intraday_llm_provider = config.llm_provider
-
- if config.api_key is not None:
- user_config.intraday_api_key = config.api_key
-
- if config.llm_model is not None:
- user_config.intraday_llm_model = config.llm_model
-
- if config.backend_url is not None:
- user_config.intraday_backend_url = config.backend_url
-
- await db.commit()
-
- # Invalidate user config cache to force reload on next execution
- from web.backend.services.user_config_cache import invalidate_user_config_cache
- invalidate_user_config_cache(user_id)
-
- # Update scheduler if exists
- if manager.has_scheduler(user_id):
- manager.update_scheduler_config(
- user_id=user_id,
- interval_minutes=config.interval_minutes,
- market_type=config.market_type,
- futu_api_url=config.futu_api_url,
- )
-
- return {
- "status": "success",
- "message": "Configuration saved",
- "futu_api_url": user_config.intraday_futu_api_url,
- "futu_api_key": "***" if user_config.intraday_futu_api_key else None, # Masked for security
- "interval_minutes": user_config.intraday_interval_minutes,
- "market_type": user_config.intraday_market_type,
- "llm_provider": user_config.intraday_llm_provider,
- "api_key": "***" if user_config.intraday_api_key else None, # Masked for security
- "llm_model": user_config.intraday_llm_model,
- "backend_url": user_config.intraday_backend_url,
- }
-
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-# ============================================================================
-# Decision History Endpoints
-# ============================================================================
-
-# DEPRECATED: This endpoint has been replaced by WebSocket 'decisions_initial' message
-# The decisions list is now pushed via WebSocket on connection and updated via 'intraday_session_complete'
-# Keeping this commented for reference only
-#
-# @router.get("/decisions", response_model=List[DecisionRecordResponse])
-# async def get_decision_records(
-# limit: int = 20,
-# offset: int = 0,
-# status: Optional[str] = None,
-# market_type: Optional[str] = None,
-# current_user: User = Depends(get_current_active_user),
-# db: AsyncSession = Depends(get_db),
-# ):
-# """
-# DEPRECATED: Use WebSocket 'decisions_initial' message instead.
-# This endpoint is no longer used by the frontend.
-# """
-# pass
-
-
-@router.get("/decisions/{decision_id}", response_model=DecisionRecordResponse)
-async def get_decision_record(
- decision_id: int,
- current_user: User = Depends(require_intraday_access),
- db: AsyncSession = Depends(get_db),
-):
- """
- Get detailed information for a specific decision record.
-
- Requires authentication.
- """
- try:
- logger.info(f"Fetching decision record: id={decision_id}, user_id={current_user.id}")
-
- # First check if record exists at all
- check_result = await db.execute(
- select(IntradayDecisionRecord).where(
- IntradayDecisionRecord.id == decision_id
- )
- )
- any_record = check_result.scalar_one_or_none()
-
- if not any_record:
- logger.warning(f"Decision record {decision_id} does not exist in database")
- raise HTTPException(status_code=404, detail=f"Decision record {decision_id} not found")
-
- # Check if it belongs to current user
- if any_record.user_id != current_user.id:
- logger.warning(f"Decision record {decision_id} belongs to user {any_record.user_id}, not {current_user.id}")
- raise HTTPException(status_code=404, detail="Decision record not found")
-
- logger.info(f"Successfully found decision record {decision_id}")
- return any_record
-
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Error fetching decision record {decision_id}: {e}")
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@router.get("/decisions/by-date-range")
-async def get_decisions_by_date_range(
- start_date: str, # YYYY-MM-DD format
- end_date: str, # YYYY-MM-DD format
- current_user: User = Depends(require_intraday_access),
- db: AsyncSession = Depends(get_db),
-):
- """
- Get decision records within a date range.
-
- Requires authentication.
- """
- try:
- # Parse dates
- start_dt = datetime.strptime(start_date, "%Y-%m-%d")
- end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
-
- # Query
- result = await db.execute(
- select(IntradayDecisionRecord).where(
- IntradayDecisionRecord.user_id == current_user.id,
- IntradayDecisionRecord.start_time >= start_dt,
- IntradayDecisionRecord.start_time < end_dt,
- ).order_by(desc(IntradayDecisionRecord.start_time))
- )
- records = result.scalars().all()
-
- return records
-
- except ValueError:
- raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-# ============================================================================
-# Position Information Endpoints
-# ============================================================================
-
-@router.get("/positions")
-async def get_positions_endpoint(
- market: str = "US", # Market parameter for Futu API
- include_closed: bool = False,
- current_user: User = Depends(require_intraday_access),
- db: AsyncSession = Depends(get_db),
-):
- """
- Get current positions from Futu API and sync to database.
-
- Requires authentication and Futu API configuration.
- """
- try:
- from web.backend.models import UserConfig
-
- # Check if user has configured Futu API
- result = await db.execute(
- select(UserConfig).where(UserConfig.user_id == current_user.id)
- )
- user_config = result.scalar_one_or_none()
-
- if not user_config or not user_config.intraday_futu_api_url:
- return []
-
- from web.backend.services.futu_async_wrapper import get_positions_async, get_account_info_async
-
- # Use async wrapper to get positions (includes database enrichment)
- positions = await get_positions_async(
- market_type=market,
- user_id=current_user.id
- )
-
- if not positions:
- return []
-
- # Sync positions to database
- await sync_positions_to_db(
- db=db,
- user_id=current_user.id,
- futu_positions=positions,
- market=market
- )
-
- # Get account info to calculate position ratios
- account_info = await get_account_info_async(
- market_type=market,
- user_id=current_user.id
- )
- total_assets = account_info.get("net_asset", 0.0) if account_info else 0.0
-
- # Determine currency symbol based on market
- currency_map = {
- "US": "$", # US Dollar
- "HK": "HK$", # Hong Kong Dollar
- "CN": "¥" # Chinese Yuan
- }
- currency = currency_map.get(market, "$")
-
- # Build result with additional fields
- result_positions = []
- for pos in positions:
- market_value = float(pos.get('market_value', 0))
- profit_loss = float(pos.get('profit_loss', 0))
- profit_loss_ratio = float(pos.get('profit_loss_ratio', 0))
-
- # Calculate position ratio
- position_ratio = (market_value / total_assets * 100) if total_assets > 0 else 0
-
- result_positions.append({
- "stock_code": pos.get('stock_code', ''),
- "stock_name": pos.get('stock_name', ''),
- "market_type": pos.get('market_type', market),
- "quantity": int(float(pos.get('quantity', 0))),
- "cost_price": float(pos.get('cost_price', 0)),
- "current_price": float(pos.get('current_price', 0)),
- "pnl": profit_loss,
- "pnl_percent": profit_loss_ratio * 100,
- "position_value": market_value,
- "position_ratio": round(position_ratio, 2),
- "holding_days": pos.get('holding_days', 0), # From tool method
- "first_open_time": pos.get('first_open_time'), # From tool method
- "currency": currency,
- })
-
- return result_positions
-
- except Exception as e:
- logger.error(f"Error fetching positions: {e}")
- return []
-
-
-@router.get("/positions/{stock_code}/history", response_model=List[TradingHistoryResponse])
-async def get_position_history(
- stock_code: str,
- current_user: User = Depends(require_intraday_access),
- db: AsyncSession = Depends(get_db),
-):
- """
- Get trading history for a specific stock.
-
- Requires authentication.
- """
- try:
- # Find position
- result = await db.execute(
- select(PositionRecord).where(
- PositionRecord.user_id == current_user.id,
- PositionRecord.stock_code == stock_code,
- )
- )
- position = result.scalar_one_or_none()
-
- if not position:
- raise HTTPException(status_code=404, detail="Position not found")
-
- # Get trading history
- result = await db.execute(
- select(TradingHistory).where(
- TradingHistory.position_record_id == position.id
- ).order_by(desc(TradingHistory.trade_time))
- )
- history = result.scalars().all()
-
- return history
-
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@router.get("/trading-history", response_model=List[TradingHistoryResponse])
-async def get_all_trading_history(
- limit: int = 50,
- offset: int = 0,
- trade_type: Optional[str] = None,
- current_user: User = Depends(require_intraday_access),
- db: AsyncSession = Depends(get_db),
-):
- """
- Get all trading history for the user with pagination.
-
- Requires authentication.
- """
- try:
- # Get user's position IDs
- result = await db.execute(
- select(PositionRecord.id).where(
- PositionRecord.user_id == current_user.id
- )
- )
- position_ids = [row[0] for row in result.all()]
-
- if not position_ids:
- return []
-
- # Build query
- query = select(TradingHistory).where(
- TradingHistory.position_record_id.in_(position_ids)
- )
-
- # Filter by trade type
- if trade_type:
- query = query.where(TradingHistory.trade_type == trade_type)
-
- # Order and paginate
- query = query.order_by(desc(TradingHistory.trade_time)).limit(limit).offset(offset)
-
- # Execute query
- result = await db.execute(query)
- history = result.scalars().all()
-
- return history
-
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-
-
-# ============================================================================
-# Account Endpoint
-# ============================================================================
-
-@router.get("/account")
-async def get_account_info_endpoint(
- market: str = "US", # Default to US market
- current_user: User = Depends(require_intraday_access),
- db: AsyncSession = Depends(get_db),
-):
- """
- Get account information for current user by market.
-
- Requires authentication and Futu API configuration.
- """
- try:
- from web.backend.models import UserConfig
-
- # Determine currency symbol based on market
- currency_map = {
- "US": "$", # US Dollar
- "HK": "HK$", # Hong Kong Dollar
- "CN": "¥" # Chinese Yuan
- }
- currency = currency_map.get(market, "$")
-
- # Check if user has configured Futu API
- result = await db.execute(
- select(UserConfig).where(UserConfig.user_id == current_user.id)
- )
- user_config = result.scalar_one_or_none()
-
- if not user_config or not user_config.intraday_futu_api_url:
- return {
- "total_assets": 0.0,
- "cash": 0.0,
- "position_value": 0.0,
- "market": market,
- "currency": currency,
- "configured": False,
- }
-
- from web.backend.services.futu_async_wrapper import get_account_info_async
-
- # Use async wrapper to get account info
- account_info = await get_account_info_async(
- market_type=market,
- user_id=current_user.id
- )
-
- if not account_info:
- # Return empty account info if not configured or error
- return {
- "total_assets": 0.0,
- "cash": 0.0,
- "position_value": 0.0,
- "market": market,
- "currency": currency,
- "configured": False,
- }
-
- # Map response fields to our format
- return {
- "total_assets": account_info.get("net_asset", 0.0),
- "cash": account_info.get("cash", 0.0),
- "position_value": account_info.get("market_value", 0.0),
- "today_profit_loss": account_info.get("today_profit_loss", 0.0),
- "today_profit_loss_ratio": account_info.get("today_profit_loss_ratio", 0.0),
- "market": market,
- "currency": currency,
- "configured": True,
- }
-
- except Exception as e:
- logger.error(f"Error fetching account info: {e}")
- currency_map = {"US": "$", "HK": "HK$", "CN": "¥"}
- return {
- "total_assets": 0.0,
- "cash": 0.0,
- "position_value": 0.0,
- "market": market,
- "currency": currency_map.get(market, "$"),
- "configured": True,
- "error": str(e)
- }
-
-
-@router.post("/scheduler/validate-config")
-async def validate_futu_config(
- config: IntradayConfigRequest,
- current_user: User = Depends(require_intraday_access),
- db: AsyncSession = Depends(get_db),
-):
- """
- Validate Futu API configuration by testing connection.
-
- Requires authentication.
- """
- try:
- from web.backend.services.futu_async_wrapper import get_hot_news_async
- from web.backend.models import UserConfig
- from sqlalchemy import select
-
- if not config.futu_api_url:
- raise HTTPException(
- status_code=400,
- detail="请提供富途API地址"
- )
-
- # Temporarily update user config for validation
- result = await db.execute(
- select(UserConfig).where(UserConfig.user_id == current_user.id)
- )
- user_config = result.scalar_one_or_none()
-
- if not user_config:
- user_config = UserConfig(user_id=current_user.id)
- db.add(user_config)
-
- # Store original values
- original_url = user_config.intraday_futu_api_url
- original_key = user_config.intraday_futu_api_key
-
- # Temporarily set new values for testing
- user_config.intraday_futu_api_url = config.futu_api_url
- if config.futu_api_key:
- user_config.intraday_futu_api_key = config.futu_api_key
-
- await db.commit()
-
- try:
- # Test connection using async wrapper
- news = await get_hot_news_async(
- lang="en-us",
- user_id=current_user.id
- )
-
- if news is not None:
- return {
- "valid": True,
- "message": "富途API配置验证成功"
- }
- else:
- return {
- "valid": False,
- "message": "富途API验证失败"
- }
-
- finally:
- # Restore original values
- user_config.intraday_futu_api_url = original_url
- user_config.intraday_futu_api_key = original_key
- await db.commit()
-
- # Invalidate cache after restoring values
- from web.backend.services.user_config_cache import invalidate_user_config_cache
- invalidate_user_config_cache(current_user.id)
-
- except HTTPException:
- raise
- except Exception as e:
- return {
- "valid": False,
- "message": f"验证失败: {str(e)}"
- }
-
-
-# ============================================================================
-# Orders Endpoints
-# ============================================================================
-
-@router.get("/orders")
-async def get_orders_endpoint(
- market: str = "US",
- filter_status: int = 0, # 0=all, 1=filled, 2=pending, 3=cancelled
- current_user: User = Depends(require_intraday_access),
- db: AsyncSession = Depends(get_db),
-):
- """
- Get orders from Futu API using async wrapper.
-
- Args:
- market: Market type (US/HK/CN)
- filter_status: Filter by status (0=all, 1=filled, 2=pending, 3=cancelled)
-
- Requires authentication and Futu API configuration.
- """
- try:
- from web.backend.models import UserConfig
-
- # Check if user has configured Futu API
- result = await db.execute(
- select(UserConfig).where(UserConfig.user_id == current_user.id)
- )
- user_config = result.scalar_one_or_none()
-
- if not user_config or not user_config.intraday_futu_api_url:
- return []
-
- from web.backend.services.futu_async_wrapper import get_orders_async
-
- # Call async wrapper with user_id
- orders = await get_orders_async(
- market_type=market,
- filter_status=filter_status,
- user_id=current_user.id
- )
-
- # Return empty list if None
- return orders if orders is not None else []
-
- except Exception as e:
- logger.error(f"Error fetching orders: {e}")
- return []
-
-
-class CancelOrderRequest(BaseModel):
- """Request to cancel an order"""
- order_id: str
- stock_code: str
-
-
-@router.post("/cancel-order")
-async def cancel_order_endpoint(
- request: CancelOrderRequest,
- current_user: User = Depends(require_intraday_access),
-):
- """
- Cancel an order via Futu API using async wrapper.
-
- Requires authentication and Futu API configuration.
- """
- try:
- from web.backend.services.futu_async_wrapper import cancel_order_async
-
- # Call async wrapper with user_id
- result = await cancel_order_async(
- order_id=request.order_id,
- stock_code=request.stock_code,
- user_id=current_user.id
- )
-
- # Check if result is None (error occurred)
- if result is None:
- raise HTTPException(
- status_code=500,
- detail="撤单失败,请稍后重试"
- )
-
- return result
-
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Error cancelling order: {e}")
- raise HTTPException(
- status_code=500,
- detail=f"撤单失败: {str(e)}"
- )
-
-
-
-# ============================================================================
-# Application Endpoints
-# ============================================================================
-
-@router.post("/apply")
-async def apply_intraday_access(
- current_user: User = Depends(get_current_active_user),
- db: AsyncSession = Depends(get_db),
-):
- """
- Apply for intraday trading access.
- Sends an email to admin with user information.
-
- Requires authentication.
- """
- try:
- import smtplib
- from email.mime.text import MIMEText
- from email.mime.multipart import MIMEMultipart
- import os
- from datetime import datetime, timedelta, timezone
- from web.backend.models import UserConfig
-
- # Check if user already has access
- if current_user.can_access_intraday_trading:
- return {
- "status": "info",
- "message": "您已经拥有智能盯盘权限,无需重复申请"
- }
-
- # Get or create user config
- result = await db.execute(
- select(UserConfig).where(UserConfig.user_id == current_user.id)
- )
- user_config = result.scalar_one_or_none()
-
- if not user_config:
- user_config = UserConfig(user_id=current_user.id)
- db.add(user_config)
- await db.flush()
-
- # Check if user has applied recently (within 24 hours)
- if user_config.intraday_application_sent_at:
- now = datetime.now(timezone.utc)
- # Ensure the stored datetime is timezone-aware
- last_application = user_config.intraday_application_sent_at
- if last_application.tzinfo is None:
- last_application = last_application.replace(tzinfo=timezone.utc)
-
- time_since_last_application = now - last_application
-
- if time_since_last_application < timedelta(hours=24):
- hours_remaining = 24 - int(time_since_last_application.total_seconds() / 3600)
- return {
- "status": "info",
- "message": f"您已提交过申请,请等待 {hours_remaining} 小时后再试,或直接联系管理员"
- }
-
- # Get SMTP configuration
- smtp_host = os.getenv("SMTP_HOST")
- smtp_port = int(os.getenv("SMTP_PORT", "587"))
- smtp_username = os.getenv("SMTP_USERNAME")
- smtp_password = os.getenv("SMTP_PASSWORD")
- smtp_from_email = os.getenv("SMTP_FROM_EMAIL")
- smtp_use_tls = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
-
- # Validate SMTP configuration
- if not all([smtp_host, smtp_username, smtp_password, smtp_from_email]):
- raise HTTPException(
- status_code=500,
- detail="邮件服务未配置,请联系管理员"
- )
-
- # Admin email - use configured support email or fallback to SMTP from email
- admin_email = os.getenv("SUPPORT_EMAIL") or smtp_from_email
-
- # Compose email
- subject = f"智能盯盘功能开通申请 - {current_user.username}"
-
- # HTML email body
- html_body = f"""
-
-
-
-
-
-
-
-
-
📋 智能盯盘功能开通申请
-
TradingAgentsWeb
-
-
-
-
- 您好,收到一个新的智能盯盘功能开通申请。
-
-
-
-
👤 用户信息
-
-
- 用户名:
- {current_user.username}
-
-
- 邮箱:
- {current_user.email}
-
-
- 用户ID:
- {current_user.id}
-
-
- 注册时间:
- {current_user.created_at}
-
-
-
-
-
-
- 📝 申请说明:
- 用户希望开通智能盯盘功能,以便参与实时排名和使用自动交易分析服务。
-
-
-
-
-
- ⚡ 操作提示:
- 请在管理后台为该用户开通相应权限,系统将自动发送开通成功通知邮件。
-
-
-
-
-
-
- 此邮件由 TradingAgentsWeb 系统自动发送
- 回复此邮件将直接联系申请用户
-
-
-
-
-
- """
-
- # Plain text email body (fallback)
- text_body = f"""
-您好,
-
-收到一个新的智能盯盘功能开通申请:
-
-用户信息:
-- 用户名:{current_user.username}
-- 邮箱:{current_user.email}
-- 用户ID:{current_user.id}
-- 注册时间:{current_user.created_at}
-
-申请说明:
-用户希望开通智能盯盘功能,以便参与实时排名和使用自动交易分析服务。
-
-请在管理后台为该用户开通相应权限。
-
----
-此邮件由 TradingAgentsWeb 系统自动发送
-回复此邮件将直接联系申请用户
- """
-
- # Create message
- msg = MIMEMultipart('alternative')
- msg['Subject'] = subject
- msg['From'] = f"TradingAgentsWeb <{smtp_from_email}>"
- msg['To'] = admin_email
- msg['Reply-To'] = current_user.email # Reply will go to user
-
- # Attach both plain text and HTML versions
- part1 = MIMEText(text_body, 'plain', 'utf-8')
- part2 = MIMEText(html_body, 'html', 'utf-8')
- msg.attach(part1)
- msg.attach(part2)
-
- # Send email
- try:
- if smtp_use_tls:
- server = smtplib.SMTP(smtp_host, smtp_port, timeout=30)
- server.starttls()
- else:
- server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=30)
-
- server.login(smtp_username, smtp_password)
- server.sendmail(smtp_from_email, admin_email, msg.as_string())
- server.quit()
-
- # Update application sent time
- from datetime import timezone
- user_config.intraday_application_sent_at = datetime.now(timezone.utc)
- await db.commit()
-
- logger.info(f"Intraday access application email sent for user {current_user.username} ({current_user.email})")
-
- return {
- "status": "success",
- "message": "申请已提交成功!我们将在1-2个工作日内处理您的申请,请留意邮箱通知。"
- }
-
- except Exception as e:
- logger.error(f"Failed to send application email: {e}")
- raise HTTPException(
- status_code=500,
- detail=f"邮件发送失败:{str(e)}"
- )
-
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Error processing intraday access application: {e}")
- raise HTTPException(
- status_code=500,
- detail=f"申请处理失败:{str(e)}"
- )
diff --git a/web/backend/routes/leaderboard_routes.py b/web/backend/routes/leaderboard_routes.py
deleted file mode 100644
index 9d95915..0000000
--- a/web/backend/routes/leaderboard_routes.py
+++ /dev/null
@@ -1,85 +0,0 @@
-#!/usr/bin/env python3
-"""
-Leaderboard API Routes
-排行榜相关的 API 路由
-"""
-
-from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import desc, func, select, over
-from typing import Dict, List
-from collections import defaultdict
-
-from web.backend.database import get_db
-from web.backend.models import AnalysisRecord
-
-router = APIRouter(prefix="/api", tags=["leaderboard"])
-
-
-@router.get("/leaderboard")
-async def get_leaderboard(db: AsyncSession = Depends(get_db)) -> Dict[str, List[dict]]:
- """
- 获取排行榜数据,按市场分组返回最新的10条完成分析
- 相同分析日期的相同股票代码只显示完成时间最新的一条
-
- 规则:
- 1. 按市场分类(US、HK、CN)
- 2. 相同市场、相同分析日期、相同股票代码只显示最新完成的一条
- 3. 按完成时间倒序排列
- 4. 每个市场返回最多10条记录
-
- Returns:
- Dict with keys 'US', 'HK', 'CN', each containing a list of analysis records
- """
- result = {}
-
- # Query each market separately
- for market in ['US', 'HK', 'CN']:
- # 使用窗口函数对相同日期+股票代码的记录进行排序
- # row_number() 会为每个分区内的记录分配一个唯一的序号
- subquery = select(
- AnalysisRecord.analysis_id,
- AnalysisRecord.ticker,
- AnalysisRecord.company_name,
- AnalysisRecord.market,
- AnalysisRecord.analysis_date,
- AnalysisRecord.trading_decision,
- AnalysisRecord.completed_at,
- AnalysisRecord.progress_percentage,
- func.row_number().over(
- partition_by=[AnalysisRecord.analysis_date, AnalysisRecord.ticker],
- order_by=desc(AnalysisRecord.completed_at)
- ).label('rn')
- ).filter(
- AnalysisRecord.status == 'completed',
- AnalysisRecord.market == market,
- AnalysisRecord.is_public == True
- ).subquery()
-
- # 只选择每个分区中 row_number = 1 的记录(即最新的记录)
- # 然后按完成时间倒序排列,取前10条
- stmt = select(subquery).filter(
- subquery.c.rn == 1
- ).order_by(
- desc(subquery.c.completed_at)
- ).limit(10)
-
- query_result = await db.execute(stmt)
- rows = query_result.all()
-
- # Convert to dict format
- result[market] = [
- {
- 'analysis_id': row.analysis_id,
- 'ticker': row.ticker,
- 'company_name': row.company_name,
- 'market': row.market,
- 'analysis_date': row.analysis_date,
- 'trading_decision': row.trading_decision,
- 'completed_at': row.completed_at.isoformat() if row.completed_at else None,
- 'progress_percentage': row.progress_percentage
- }
- for row in rows
- ]
-
- return result
diff --git a/web/backend/routes/page_routes.py b/web/backend/routes/page_routes.py
index 4d19e59..aeb95f4 100644
--- a/web/backend/routes/page_routes.py
+++ b/web/backend/routes/page_routes.py
@@ -5,7 +5,7 @@
"""
from fastapi import APIRouter, Request
-from fastapi.responses import HTMLResponse
+from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
router = APIRouter(tags=["pages"])
@@ -17,13 +17,19 @@
@router.get("/", response_class=HTMLResponse)
async def index(request: Request):
"""Render the main interface"""
- return templates.TemplateResponse("index.html", {"request": request})
+ return templates.TemplateResponse(request, "index.html")
@router.get("/results/{analysis_id}", response_class=HTMLResponse)
async def results_page(request: Request, analysis_id: str):
"""Render the results page for a specific analysis"""
- return templates.TemplateResponse("results.html", {
- "request": request,
+ return templates.TemplateResponse(request, "results.html", {
"analysis_id": analysis_id
})
+
+
+@router.get("/intraday-trading", include_in_schema=False)
+@router.get("/leaderboard", include_in_schema=False)
+async def retired_trading_pages():
+ """Retire removed trading/ranking pages by redirecting to the conversation entry."""
+ return RedirectResponse(url="/", status_code=308)
diff --git a/web/backend/routes/prompt_routes.py b/web/backend/routes/prompt_routes.py
index c92d876..3a6949a 100644
--- a/web/backend/routes/prompt_routes.py
+++ b/web/backend/routes/prompt_routes.py
@@ -18,7 +18,11 @@
BulkToolSelectionUpdate,
)
from web.backend.auth_routes import get_current_user
-from web.backend.services.prompt_loader import get_default_intraday_prompt, create_default_template_for_user
+from web.backend.services.prompt_loader import (
+ create_default_template_for_user,
+ generate_variable_documentation,
+ get_default_intraday_prompt,
+)
router = APIRouter(prefix="/api/prompts", tags=["prompts"])
@@ -527,20 +531,7 @@ async def validate_prompt_template(
"message": "提示词内容过长,请精简至50000字符以内"
}
- # Load workflow documentation (same as agent does)
- workflow_file = os.path.join(
- os.path.dirname(__file__),
- '../../../tradingagents/agents/trader/intraday_trader_workflow.txt'
- )
-
- try:
- with open(workflow_file, 'r', encoding='utf-8') as f:
- workflow_documentation = f.read()
- except Exception as e:
- return {
- "valid": False,
- "message": f"无法加载工作流文档: {str(e)}"
- }
+ workflow_documentation = generate_variable_documentation()
# Generate test context (same as agent does)
context_info = f"""## Current Context
diff --git a/web/backend/routes/public_leaderboard_routes.py b/web/backend/routes/public_leaderboard_routes.py
deleted file mode 100644
index 642459e..0000000
--- a/web/backend/routes/public_leaderboard_routes.py
+++ /dev/null
@@ -1,373 +0,0 @@
-"""
-Public Leaderboard Routes
-
-公开的排名API接口,无需鉴权即可访问
-"""
-
-from fastapi import APIRouter, Depends, HTTPException, status
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, and_, func, desc
-from typing import List, Dict, Tuple
-from datetime import datetime, timedelta
-
-from web.backend.database import get_db
-from web.backend.models import User, AccountSnapshot, PositionRecord, IntradayDecisionRecord
-from web.backend.auth_routes import get_current_user
-
-router = APIRouter(prefix="/api/public/leaderboard", tags=["public-leaderboard"])
-
-# 价格缓存(避免频繁请求)
-# 注意:当前持仓数据从快照中读取,不使用实时价格
-# 此函数保留以备将来需要实时价格时使用
-_price_cache: Dict[str, Tuple[float, datetime]] = {}
-_cache_duration = timedelta(minutes=5) # 缓存5分钟
-
-
-async def get_current_price(stock_code: str, market_type: str) -> float:
- """
- 获取股票当前价格(带缓存)
- 注意:当前未使用,持仓数据从快照中读取
- """
- cache_key = f"{stock_code}_{market_type}"
-
- # 检查缓存
- if cache_key in _price_cache:
- price, timestamp = _price_cache[cache_key]
- if datetime.now() - timestamp < _cache_duration:
- return price
-
- try:
- import yfinance as yf
-
- # 根据市场类型调整股票代码格式
- if market_type == 'HK':
- ticker = f"{stock_code}.HK"
- elif market_type == 'CN':
- # A股需要添加后缀
- if stock_code.startswith('6'):
- ticker = f"{stock_code}.SS" # 上海
- else:
- ticker = f"{stock_code}.SZ" # 深圳
- else:
- ticker = stock_code
-
- # 使用yfinance获取价格
- stock = yf.Ticker(ticker)
-
- # 方法1: 尝试从info获取
- try:
- info = stock.info
- price = (
- info.get('currentPrice') or
- info.get('regularMarketPrice') or
- info.get('previousClose') or
- info.get('ask') or
- info.get('bid') or
- 0.0
- )
- if price > 0:
- _price_cache[cache_key] = (float(price), datetime.now())
- return float(price)
- except Exception as e:
- print(f"从info获取价格失败 {ticker}: {e}")
-
- # 方法2: 尝试从历史数据获取最新价格
- try:
- hist = stock.history(period='1d', interval='1m')
- if not hist.empty:
- price = hist['Close'].iloc[-1]
- if price > 0:
- _price_cache[cache_key] = (float(price), datetime.now())
- return float(price)
- except Exception as e:
- print(f"从历史数据获取价格失败 {ticker}: {e}")
-
- # 方法3: 尝试获取5天的历史数据
- try:
- hist = stock.history(period='5d')
- if not hist.empty:
- price = hist['Close'].iloc[-1]
- if price > 0:
- _price_cache[cache_key] = (float(price), datetime.now())
- return float(price)
- except Exception as e:
- print(f"从5天历史数据获取价格失败 {ticker}: {e}")
-
- print(f"⚠️ 无法获取价格 {ticker},返回0")
- return 0.0
-
- except Exception as e:
- print(f"❌ 获取价格失败 {stock_code}: {e}")
- return 0.0
-
-
-@router.get("/users")
-async def get_leaderboard_users(
- market: str = None,
- db: AsyncSession = Depends(get_db)
-):
- """
- 获取所有参加排名的用户列表
- 可选参数: market (US/HK/CN) - 按市场过滤
- """
- from web.backend.models import UserConfig
-
- # Query users who participate in leaderboard
- query = select(
- User.id,
- User.username,
- AccountSnapshot.market_type,
- AccountSnapshot.total_assets,
- AccountSnapshot.snapshot_date
- ).join(
- AccountSnapshot,
- User.id == AccountSnapshot.user_id
- ).where(
- User.participate_in_leaderboard == True
- )
-
- # 如果指定了市场,添加过滤条件
- if market:
- query = query.where(AccountSnapshot.market_type == market)
-
- query = query.order_by(
- AccountSnapshot.snapshot_date.desc()
- )
-
- result = await db.execute(query)
- rows = result.fetchall()
-
- # Group by user and market, get latest snapshot for each user-market combination
- users_dict = {}
- for row in rows:
- user_id = row[0]
- market_type = row[2]
- key = f"{user_id}_{market_type}"
-
- if key not in users_dict:
- users_dict[key] = {
- 'user_id': user_id,
- 'username': row[1],
- 'market_type': market_type,
- 'total_assets': float(row[3]) if row[3] else 0,
- 'latest_snapshot_date': row[4].strftime('%Y-%m-%d') if row[4] else ''
- }
-
- # Get user configs to fetch model information
- user_ids = list(set([row[0] for row in rows]))
- if user_ids:
- config_query = select(UserConfig).where(UserConfig.user_id.in_(user_ids))
- config_result = await db.execute(config_query)
- configs = {config.user_id: config for config in config_result.scalars().all()}
-
- # Add model information to users (only intraday model)
- for key, user_data in users_dict.items():
- user_id = user_data['user_id']
- if user_id in configs:
- config = configs[user_id]
- # Only use intraday model
- model_name = config.intraday_llm_model if config.intraday_llm_model else None
- user_data['model_name'] = model_name
- print(f"[Leaderboard] User {user_data['username']} (ID: {user_id}): model_name = {model_name}")
- else:
- user_data['model_name'] = None
- print(f"[Leaderboard] User {user_data['username']} (ID: {user_id}): no config found")
- else:
- # No user_ids, set all model_name to None
- for key, user_data in users_dict.items():
- user_data['model_name'] = None
-
- result_list = list(users_dict.values())
- print(f"[Leaderboard] Returning {len(result_list)} users with model info")
- return result_list
-
-
-@router.get("/user/{user_id}/trend")
-async def get_user_trend(
- user_id: int,
- days: int = 7,
- market: str = None,
- db: AsyncSession = Depends(get_db)
-):
- """
- 获取用户资产趋势(5分钟粒度)
- 返回最近7天内按5分钟间隔的快照数据
- 可选参数: market (US/HK/CN) - 按市场过滤
- """
- # Get user
- user_query = select(User).where(User.id == user_id, User.participate_in_leaderboard == True)
- user_result = await db.execute(user_query)
- user = user_result.scalar_one_or_none()
-
- if not user:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="User not found or not participating in leaderboard"
- )
-
- # Get snapshots for the user (最近N天)
- end_date = datetime.now()
- start_date = end_date - timedelta(days=days)
-
- # Build query with optional market filter
- conditions = [
- AccountSnapshot.user_id == user_id,
- AccountSnapshot.snapshot_date >= start_date,
- AccountSnapshot.snapshot_date <= end_date
- ]
-
- if market:
- conditions.append(AccountSnapshot.market_type == market)
-
- query = select(AccountSnapshot).where(
- and_(*conditions)
- ).order_by(AccountSnapshot.snapshot_date.asc())
-
- result = await db.execute(query)
- snapshots = result.scalars().all()
-
- # 按5分钟间隔分组
- # 将时间戳向下取整到5分钟
- interval_snapshots = {}
- for snapshot in snapshots:
- # 将时间戳转换为5分钟间隔的key
- timestamp = snapshot.snapshot_date
- # 向下取整到5分钟
- minutes = (timestamp.hour * 60 + timestamp.minute) // 5 * 5
- rounded_time = timestamp.replace(hour=minutes // 60, minute=minutes % 60, second=0, microsecond=0)
- time_key = rounded_time.isoformat()
-
- # 保留每个5分钟间隔内最新的快照
- if time_key not in interval_snapshots or snapshot.snapshot_date > interval_snapshots[time_key].snapshot_date:
- interval_snapshots[time_key] = snapshot
-
- # 转换为排序列表
- trend_data = []
- for time_key in sorted(interval_snapshots.keys()):
- snapshot = interval_snapshots[time_key]
- trend_data.append({
- "date": snapshot.snapshot_date.strftime("%Y-%m-%d %H:%M:%S"),
- "total_assets": float(snapshot.total_assets),
- })
-
- return trend_data
-
-
-@router.get("/user/{user_id}/positions")
-async def get_user_positions(
- user_id: int,
- db: AsyncSession = Depends(get_db)
-):
- """
- 获取用户最新持仓信息(从Futu API获取实时数据,与智能盯盘一致)
- """
- # Verify user participates in leaderboard
- user_query = select(User).where(
- User.id == user_id,
- User.participate_in_leaderboard == True
- )
- user_result = await db.execute(user_query)
- user = user_result.scalar_one_or_none()
-
- if not user:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="User not found or not participating in leaderboard"
- )
-
- # Get positions from all markets using async wrapper - parallel execution
- from web.backend.services.futu_async_wrapper import get_positions_async
- from datetime import datetime
- import asyncio
-
- # Fetch positions from all markets in parallel
- markets = ['US', 'HK', 'CN']
- positions_tasks = [
- get_positions_async(market_type=market, user_id=user_id)
- for market in markets
- ]
-
- # Wait for all tasks to complete
- positions_results = await asyncio.gather(*positions_tasks, return_exceptions=True)
-
- all_positions = []
-
- # Process results from all markets
- for market, positions in zip(markets, positions_results):
- # Skip if error or no positions
- if isinstance(positions, Exception) or not positions:
- continue
-
- # Format positions for leaderboard
- for pos in positions:
- first_open_time = pos.get('first_open_time')
- if first_open_time and isinstance(first_open_time, str):
- first_open_time_iso = first_open_time
- else:
- first_open_time_iso = datetime.now().isoformat()
-
- all_positions.append({
- "stock_code": pos.get('stock_code', ''),
- "stock_name": pos.get('stock_name', ''),
- "market_type": market,
- "quantity": int(float(pos.get('quantity', 0))),
- "cost_price": round(float(pos.get('cost_price', 0)), 2),
- "current_price": round(float(pos.get('current_price', 0)), 2),
- "market_value": round(float(pos.get('market_value', 0)), 2),
- "unrealized_pnl": round(float(pos.get('profit_loss', 0)), 2),
- "pnl_percentage": round(float(pos.get('profit_loss_ratio', 0)) * 100, 2),
- "first_open_price": round(float(pos.get('cost_price', 0)), 2),
- "first_open_time": first_open_time_iso,
- "holding_days": pos.get('holding_days', 0),
- })
-
- return all_positions
-
-
-@router.get("/user/{user_id}/decisions")
-async def get_user_decisions(
- user_id: int,
- limit: int = 20,
- db: AsyncSession = Depends(get_db)
-):
- """
- 获取用户决策历史
- """
- # Verify user participates in leaderboard
- user_query = select(User).where(
- User.id == user_id,
- User.participate_in_leaderboard == True
- )
- user_result = await db.execute(user_query)
- user = user_result.scalar_one_or_none()
-
- if not user:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="User not found or not participating in leaderboard"
- )
-
- # Get recent decisions
- query = select(IntradayDecisionRecord).where(
- IntradayDecisionRecord.user_id == user_id
- ).order_by(
- desc(IntradayDecisionRecord.start_time)
- ).limit(limit)
-
- result = await db.execute(query)
- decisions = result.scalars().all()
-
- # Format decisions
- decisions_data = []
- for decision in decisions:
- decisions_data.append({
- "id": decision.id,
- "start_time": decision.start_time.isoformat() if decision.start_time else None,
- "end_time": decision.end_time.isoformat() if decision.end_time else None,
- "status": decision.status,
- "market_type": decision.market_type,
- "decision_report": decision.decision_report,
- "trades_executed": decision.trades_executed,
- })
-
- return decisions_data
diff --git a/web/backend/routes/report_routes.py b/web/backend/routes/report_routes.py
new file mode 100644
index 0000000..aafe7b3
--- /dev/null
+++ b/web/backend/routes/report_routes.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+"""Report list/detail/export APIs backed by AnalysisRecord structured_report."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import Response
+from sqlalchemy import desc, select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from web.backend.auth_routes import get_current_active_user
+from web.backend.database import get_db
+from web.backend.models import AnalysisRecord, ConversationMessage, User
+from web.backend.services.report_formatter import (
+ report_detail,
+ report_json_bytes,
+ report_markdown,
+ report_pdf_bytes,
+ report_preview,
+)
+
+router = APIRouter(prefix="/api/reports", tags=["reports"])
+
+
+async def _source_session_id(db: AsyncSession, analysis_id: str) -> str | None:
+ result = await db.execute(select(ConversationMessage.session_id).where(
+ ConversationMessage.analysis_id == analysis_id,
+ ConversationMessage.role == "assistant",
+ ).limit(1))
+ return result.scalar()
+
+
+async def _record_or_404(report_id: str, current_user: User, db: AsyncSession) -> AnalysisRecord:
+ result = await db.execute(select(AnalysisRecord).where(
+ AnalysisRecord.analysis_id == report_id,
+ (AnalysisRecord.user_id == current_user.id) | (AnalysisRecord.is_public == True),
+ ))
+ record = result.scalars().first()
+ if not record:
+ raise HTTPException(status_code=404, detail="报告不存在")
+ return record
+
+
+@router.get("")
+async def list_reports(
+ page: int = Query(1, ge=1),
+ limit: int = Query(20, ge=1, le=100),
+ ticker: Optional[str] = None,
+ market: Optional[str] = None,
+ rating: Optional[int] = Query(None, ge=1, le=5),
+ status: Optional[str] = None,
+ date_from: Optional[str] = None,
+ date_to: Optional[str] = None,
+ sort: str = Query("created_at", pattern="^(created_at|rating)$"),
+ order: str = Query("desc", pattern="^(asc|desc)$"),
+ current_user: User = Depends(get_current_active_user),
+ db: AsyncSession = Depends(get_db),
+):
+ filters = [(AnalysisRecord.user_id == current_user.id) | (AnalysisRecord.is_public == True)]
+ if ticker:
+ filters.append(AnalysisRecord.ticker.ilike(f"%{ticker}%"))
+ if market:
+ filters.append(AnalysisRecord.market == market)
+ if status:
+ if status == "completed":
+ filters.append(AnalysisRecord.status == "completed")
+ elif status == "failed":
+ filters.append(AnalysisRecord.status.in_(["error", "interrupted"]))
+ elif status == "partial":
+ filters.append(AnalysisRecord.status.notin_(["completed", "error", "interrupted"]))
+ if date_from:
+ filters.append(AnalysisRecord.created_at >= datetime.fromisoformat(date_from))
+ if date_to:
+ filters.append(AnalysisRecord.created_at <= datetime.fromisoformat(date_to))
+
+ count_result = await db.execute(select(func.count(AnalysisRecord.id)).where(*filters))
+ total = count_result.scalar() or 0
+ sort_column = AnalysisRecord.created_at
+ order_clause = sort_column.asc() if order == "asc" else desc(sort_column)
+ result = await db.execute(select(AnalysisRecord).where(*filters).order_by(order_clause).offset((page - 1) * limit).limit(limit))
+ records = result.scalars().all()
+
+ items = []
+ for record in records:
+ preview = report_preview(record, await _source_session_id(db, record.analysis_id))
+ if rating and preview.get("rating") != rating:
+ continue
+ items.append(preview)
+
+ return {"data": items, "meta": {"page": page, "limit": limit, "total": total, "has_next": page * limit < total}}
+
+
+@router.get("/public")
+async def public_reports(
+ limit: int = Query(6, ge=1, le=20),
+ db: AsyncSession = Depends(get_db),
+):
+ """Public report feed for the home/conversation entry experience."""
+ result = await db.execute(select(AnalysisRecord).where(
+ AnalysisRecord.is_public == True,
+ AnalysisRecord.status == "completed",
+ ).order_by(desc(AnalysisRecord.created_at)).limit(limit))
+ records = result.scalars().all()
+ return {"data": [report_preview(record) for record in records], "meta": {"limit": limit, "total": len(records), "has_next": False}}
+
+
+@router.get("/{report_id}")
+async def get_report(report_id: str, current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_db)):
+ record = await _record_or_404(report_id, current_user, db)
+ return {"data": report_detail(record, await _source_session_id(db, record.analysis_id))}
+
+
+@router.get("/{report_id}/export")
+async def export_report(
+ report_id: str,
+ format: str = Query(..., pattern="^(md|json|pdf)$"),
+ current_user: User = Depends(get_current_active_user),
+ db: AsyncSession = Depends(get_db),
+):
+ record = await _record_or_404(report_id, current_user, db)
+ filename = f"report-{report_id}.{format}"
+ if format == "json":
+ return Response(
+ report_json_bytes(record),
+ media_type="application/json",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+ if format == "md":
+ return Response(
+ report_markdown(record),
+ media_type="text/markdown; charset=utf-8",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+ return Response(
+ report_pdf_bytes(record),
+ media_type="application/pdf",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
diff --git a/web/backend/routes/scheduled_task_routes.py b/web/backend/routes/scheduled_task_routes.py
index 8c51dd6..f5f69c8 100644
--- a/web/backend/routes/scheduled_task_routes.py
+++ b/web/backend/routes/scheduled_task_routes.py
@@ -1,143 +1,121 @@
#!/usr/bin/env python3
-"""
-Scheduled Task API Routes
-定时任务相关的 API 路由
-"""
+"""Scheduled analysis task APIs aligned with the frontend contract."""
+
+from __future__ import annotations
-from fastapi import APIRouter, Depends, HTTPException, status, Query
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import desc, select, func
-from datetime import datetime, timezone
-from typing import List, Optional
import uuid
+from datetime import datetime
+from typing import Optional
-from web.backend.database import get_db
-from web.backend.models import User, ScheduledTask
-from web.backend.schemas import (
- ScheduledTaskCreate,
- ScheduledTaskResponse,
- ScheduledTaskUpdate,
- ScheduledTaskListResponse
-)
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy import desc, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from tradingagents.default_config import DEFAULT_CONFIG
from web.backend.auth_routes import get_current_active_user
-from web.backend.utils.market_detector import normalize_ticker, normalize_ticker_with_suffix, validate_ticker, detect_market
+from web.backend.database import get_db
+from web.backend.models import AnalysisRecord, ScheduledTask, User, UserConfig
+from web.backend.schemas import ScheduledTaskCreate, ScheduledTaskUpdate
+from web.backend.services.report_formatter import report_preview
from web.backend.services.scheduler_service import get_scheduler_service
+from web.backend.utils.market_detector import detect_market, normalize_ticker, normalize_ticker_with_suffix, validate_ticker
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
-# Maximum number of scheduled tasks per user
MAX_TASKS_PER_USER = 100
-@router.post("/", response_model=ScheduledTaskResponse, status_code=status.HTTP_201_CREATED)
+def _cycle_for_scheduler(cycle: str, interval_days: int | None) -> tuple[str, int | None]:
+ if cycle == "interval":
+ return "every_n_days", interval_days or 1
+ if cycle == "monthly":
+ return "every_n_days", interval_days or 30
+ return cycle, interval_days
+
+
+def _cycle_for_contract(cycle: str, interval_days: int | None) -> str:
+ if cycle == "every_n_days":
+ return "interval"
+ return cycle
+
+
+def _iso(value):
+ return value.isoformat() if value else None
+
+
+async def _last_report(db: AsyncSession, task: ScheduledTask) -> dict:
+ result = await db.execute(select(AnalysisRecord).where(
+ AnalysisRecord.user_id == task.user_id,
+ AnalysisRecord.ticker == task.ticker,
+ ).order_by(desc(AnalysisRecord.created_at)).limit(1))
+ record = result.scalars().first()
+ if not record:
+ return {"report_id": None, "status": None, "rating": None}
+ preview = report_preview(record)
+ return {"report_id": record.analysis_id, "status": preview.get("status"), "rating": preview.get("rating")}
+
+
+async def _task_payload(db: AsyncSession, task: ScheduledTask) -> dict:
+ return {
+ "id": task.id,
+ "task_name": task.task_name,
+ "ticker": task.ticker,
+ "market": task.market,
+ "is_enabled": task.is_enabled,
+ "execution_cycle": _cycle_for_contract(task.execution_cycle, task.interval_days),
+ "execution_time": task.execution_time,
+ "interval_days": task.interval_days,
+ "end_date": task.end_date.date().isoformat() if task.end_date else None,
+ "next_run": _iso(task.next_run_time),
+ "last_run": _iso(task.last_run_time),
+ "last_report": await _last_report(db, task),
+ "analysts": task.analysts,
+ "research_depth": task.research_depth,
+ "created_at": _iso(task.created_at),
+ "updated_at": _iso(task.updated_at),
+ }
+
+
+async def _task_or_404(db: AsyncSession, task_id: int, user_id: int) -> ScheduledTask:
+ result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id, ScheduledTask.user_id == user_id))
+ task = result.scalars().first()
+ if not task:
+ raise HTTPException(status_code=404, detail="定时任务不存在")
+ return task
+
+
+@router.post("", status_code=status.HTTP_201_CREATED)
async def create_scheduled_task(
request: ScheduledTaskCreate,
current_user: User = Depends(get_current_active_user),
- db: AsyncSession = Depends(get_db)
+ db: AsyncSession = Depends(get_db),
):
- """
- Create a new scheduled task
-
- If execution_cycle and execution_time are provided, the task will be scheduled.
- Otherwise, it will execute immediately (not implemented in this endpoint).
- """
-
- # Check if both execution_cycle and execution_time are provided
if not request.execution_cycle or not request.execution_time:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="创建定时任务需要同时提供执行周期和执行时间"
- )
-
- # Validate interval_days for every_n_days cycle
- if request.execution_cycle == 'every_n_days' and not request.interval_days:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="选择'每N天执行'时必须指定间隔天数"
- )
-
- # Validate day_of_week for weekly cycle
- if request.execution_cycle == 'weekly' and not request.day_of_week:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="选择'每周执行'时必须指定星期几"
- )
-
- # Check user's task limit
- stmt = select(func.count(ScheduledTask.id)).filter(
+ raise HTTPException(status_code=400, detail="创建定时任务需要同时提供执行周期和执行时间")
+
+ count_result = await db.execute(select(func.count(ScheduledTask.id)).where(
ScheduledTask.user_id == current_user.id,
- ScheduledTask.status == 'pending'
- )
- result = await db.execute(stmt)
- task_count = result.scalar()
-
- if task_count >= MAX_TASKS_PER_USER:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"Maximum number of scheduled tasks ({MAX_TASKS_PER_USER}) reached. Please delete some tasks before creating new ones."
- )
-
- # Normalize and validate ticker
+ ScheduledTask.status == "pending",
+ ))
+ if (count_result.scalar() or 0) >= MAX_TASKS_PER_USER:
+ raise HTTPException(status_code=400, detail=f"定时任务数量已达上限({MAX_TASKS_PER_USER})")
+
ticker = normalize_ticker(request.ticker)
-
if not validate_ticker(ticker):
- error_msg = f"Invalid ticker format: {request.ticker}\n\n"
- error_msg += "Supported formats:\n"
- error_msg += "• US stocks: 1-5 letters (e.g., AAPL, TSLA)\n"
- error_msg += "• HK stocks: 4-5 digits or with .HK suffix (e.g., 0700, 00700.HK)\n"
- error_msg += "• CN stocks: 6 digits with optional .SH/.SZ suffix (e.g., 600519, 000001.SZ)"
-
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=error_msg
- )
-
- # Standardize ticker (auto-add .HK for HK stocks)
+ raise HTTPException(status_code=400, detail=f"无效的股票代码格式: {request.ticker}")
ticker = normalize_ticker_with_suffix(ticker)
-
- # Detect market
market = detect_market(ticker)
-
- # Generate unique scheduler job ID
- scheduler_job_id = f"scheduled_task_{current_user.id}_{uuid.uuid4().hex[:8]}"
-
- # Parse end_date if provided
+
end_date_dt = None
if request.end_date:
from pytz import timezone as pytz_timezone
- beijing_tz = pytz_timezone('Asia/Shanghai')
-
- # Parse date and set to end of day in Beijing time
- end_date_dt = datetime.strptime(request.end_date, '%Y-%m-%d').replace(
- hour=23, minute=59, second=59
- )
- end_date_dt = beijing_tz.localize(end_date_dt)
-
- # Check if end date is in the past
- now_beijing = datetime.now(beijing_tz)
- if end_date_dt < now_beijing:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="End date cannot be in the past"
- )
-
- # Validate email notification request
- if request.email_notification and not current_user.email:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="无法启用邮件通知:您的账户未绑定邮箱地址。请先在账户设置中添加邮箱。"
- )
-
- # Get user config for API key fallback
- from web.backend.models import UserConfig
- stmt = select(UserConfig).filter(UserConfig.user_id == current_user.id)
- result = await db.execute(stmt)
- user_config = result.scalars().first()
-
- # Use API key from request, or fallback to user config
- api_key = request.api_key or (user_config.last_api_key if user_config else '')
-
- # Create scheduled task record
+ beijing_tz = pytz_timezone("Asia/Shanghai")
+ end_date_dt = beijing_tz.localize(datetime.strptime(request.end_date, "%Y-%m-%d").replace(hour=23, minute=59, second=59))
+
+ config_result = await db.execute(select(UserConfig).where(UserConfig.user_id == current_user.id))
+ user_config = config_result.scalars().first()
+ api_key = request.api_key or (user_config.last_api_key if user_config else "")
+ scheduler_cycle, scheduler_interval_days = _cycle_for_scheduler(request.execution_cycle, request.interval_days)
scheduled_task = ScheduledTask(
user_id=current_user.id,
task_name=request.task_name,
@@ -145,432 +123,200 @@ async def create_scheduled_task(
market=market,
analysts=request.analysts,
research_depth=request.research_depth,
- llm_provider=request.llm_provider,
- shallow_thinker=request.shallow_thinker,
- deep_thinker=request.deep_thinker,
- backend_url=request.backend_url,
- api_key=api_key, # Save API key with the scheduled task
+ llm_provider=request.llm_provider or DEFAULT_CONFIG["llm_provider"],
+ shallow_thinker=request.shallow_thinker or DEFAULT_CONFIG["quick_think_llm"],
+ deep_thinker=request.deep_thinker or DEFAULT_CONFIG["deep_think_llm"],
+ backend_url=request.backend_url or DEFAULT_CONFIG["backend_url"],
+ api_key=api_key,
is_public=request.is_public,
- enable_trading_executor=request.enable_trading_executor,
- futu_api_base_url=request.futu_api_base_url,
- futu_api_key=request.futu_api_key,
- email_notification_enabled=request.email_notification, # Save email notification preference
- execution_cycle=request.execution_cycle,
+ email_notification_enabled=request.email_notification,
+ execution_cycle=scheduler_cycle,
execution_time=request.execution_time,
- interval_days=request.interval_days,
+ interval_days=scheduler_interval_days,
day_of_week=request.day_of_week,
end_date=end_date_dt,
is_enabled=True,
- status='pending',
- scheduler_job_id=scheduler_job_id,
- total_executions=0
+ status="pending",
+ scheduler_job_id=f"scheduled_task_{current_user.id}_{uuid.uuid4().hex[:8]}",
+ total_executions=0,
)
-
db.add(scheduled_task)
- await db.commit()
- await db.refresh(scheduled_task)
-
- # Add to scheduler
+ await db.flush()
+
try:
scheduler = get_scheduler_service()
scheduler.add_scheduled_task(
task_id=scheduled_task.id,
- job_id=scheduler_job_id,
- execution_cycle=request.execution_cycle,
- execution_time=request.execution_time,
- interval_days=request.interval_days,
- day_of_week=request.day_of_week,
- end_date=end_date_dt
+ job_id=scheduled_task.scheduler_job_id,
+ execution_cycle=scheduled_task.execution_cycle,
+ execution_time=scheduled_task.execution_time,
+ interval_days=scheduled_task.interval_days,
+ day_of_week=scheduled_task.day_of_week,
+ end_date=scheduled_task.end_date,
)
-
- # Get next run time
- next_run = scheduler.get_next_run_time(scheduler_job_id)
- if next_run:
- # Check if next run is after end date
- if end_date_dt:
- from pytz import timezone as pytz_timezone
- beijing_tz = pytz_timezone('Asia/Shanghai')
-
- # Ensure next_run is timezone-aware
- if next_run.tzinfo is None:
- next_run_aware = beijing_tz.localize(next_run)
- else:
- next_run_aware = next_run.astimezone(beijing_tz)
-
- if next_run_aware > end_date_dt:
- # Task will never run, mark as completed immediately
- scheduled_task.status = 'completed'
- scheduler.remove_scheduled_task(scheduler_job_id)
- await db.commit()
- await db.refresh(scheduled_task)
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"无法创建任务。首次执行时间({next_run_aware.strftime('%Y-%m-%d %H:%M')})晚于结束日期({end_date_dt.strftime('%Y-%m-%d')})。请调整执行时间或结束日期。"
- )
-
- scheduled_task.next_run_time = next_run
- await db.commit()
- await db.refresh(scheduled_task)
-
- print(f"✅ Created scheduled task {scheduled_task.id} for user {current_user.id}")
-
- except Exception as e:
- # Rollback database if scheduler fails
- await db.delete(scheduled_task)
- await db.commit()
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Failed to schedule task: {str(e)}"
- )
-
- return scheduled_task
+ scheduled_task.next_run_time = scheduler.get_next_run_time(scheduled_task.scheduler_job_id)
+ except Exception as exc:
+ await db.rollback()
+ raise HTTPException(status_code=500, detail=f"创建调度任务失败: {exc}")
+
+ await db.commit()
+ await db.refresh(scheduled_task)
+ return {"data": await _task_payload(db, scheduled_task)}
-@router.get("/", response_model=ScheduledTaskListResponse)
+@router.get("")
async def list_scheduled_tasks(
- page: int = Query(1, ge=1, description="Page number"),
- limit: int = Query(20, ge=1, le=100, description="Items per page"),
- status_filter: Optional[str] = Query(None, description="Filter by status: pending, completed, or all"),
+ page: int = Query(1, ge=1),
+ limit: int = Query(20, ge=1, le=100),
+ status_filter: Optional[str] = Query(None, alias="status"),
+ ticker: Optional[str] = None,
current_user: User = Depends(get_current_active_user),
- db: AsyncSession = Depends(get_db)
+ db: AsyncSession = Depends(get_db),
):
- """
- List all scheduled tasks for the current user
-
- Returns both pending and completed tasks.
- Use status_filter to filter by status: 'pending', 'completed', or None for all.
- """
-
- # Build base query
- base_filter = [ScheduledTask.user_id == current_user.id]
-
- # Add status filter if specified
- if status_filter == 'pending':
- base_filter.append(ScheduledTask.status == 'pending')
- elif status_filter == 'completed':
- base_filter.append(ScheduledTask.status == 'completed')
- # If status_filter is None or 'all', don't add status filter
-
- # Count total tasks
- count_stmt = select(func.count(ScheduledTask.id)).filter(*base_filter)
- result = await db.execute(count_stmt)
- total = result.scalar()
-
- # Get statistics for all tasks (not filtered by status_filter)
- all_tasks_filter = [ScheduledTask.user_id == current_user.id]
-
- # Count enabled tasks (pending + enabled)
- enabled_stmt = select(func.count(ScheduledTask.id)).filter(
- *all_tasks_filter,
- ScheduledTask.status == 'pending',
- ScheduledTask.is_enabled == True
- )
- result = await db.execute(enabled_stmt)
- enabled_count = result.scalar()
-
- # Count paused tasks (pending + not enabled)
- paused_stmt = select(func.count(ScheduledTask.id)).filter(
- *all_tasks_filter,
- ScheduledTask.status == 'pending',
- ScheduledTask.is_enabled == False
- )
- result = await db.execute(paused_stmt)
- paused_count = result.scalar()
-
- # Count completed tasks
- completed_stmt = select(func.count(ScheduledTask.id)).filter(
- *all_tasks_filter,
- ScheduledTask.status == 'completed'
- )
- result = await db.execute(completed_stmt)
- completed_count = result.scalar()
-
- # Get paginated tasks
- offset = (page - 1) * limit
- stmt = select(ScheduledTask).filter(*base_filter).order_by(
- desc(ScheduledTask.created_at)
- ).offset(offset).limit(limit)
-
- result = await db.execute(stmt)
+ filters = [ScheduledTask.user_id == current_user.id]
+ if status_filter == "enabled":
+ filters.append(ScheduledTask.status == "pending")
+ filters.append(ScheduledTask.is_enabled == True)
+ elif status_filter == "disabled":
+ filters.append(ScheduledTask.status == "pending")
+ filters.append(ScheduledTask.is_enabled == False)
+ if ticker:
+ filters.append(ScheduledTask.ticker.ilike(f"%{ticker}%"))
+
+ total_result = await db.execute(select(func.count(ScheduledTask.id)).where(*filters))
+ total = total_result.scalar() or 0
+ result = await db.execute(select(ScheduledTask).where(*filters).order_by(desc(ScheduledTask.created_at)).offset((page - 1) * limit).limit(limit))
tasks = result.scalars().all()
-
- return ScheduledTaskListResponse(
- items=tasks,
- total=total,
- page=page,
- limit=limit,
- has_next=(page * limit) < total,
- has_prev=page > 1,
- stats={
- "enabled": enabled_count,
- "paused": paused_count,
- "completed": completed_count
+ return {
+ "data": [await _task_payload(db, task) for task in tasks],
+ "meta": {"page": page, "limit": limit, "total": total, "has_next": page * limit < total},
+ }
+
+
+@router.get("/stats")
+async def scheduled_task_stats(
+ current_user: User = Depends(get_current_active_user),
+ db: AsyncSession = Depends(get_db),
+):
+ running_result = await db.execute(select(func.count(ScheduledTask.id)).where(
+ ScheduledTask.user_id == current_user.id,
+ ScheduledTask.status == "pending",
+ ScheduledTask.is_enabled == True,
+ ))
+ paused_result = await db.execute(select(func.count(ScheduledTask.id)).where(
+ ScheduledTask.user_id == current_user.id,
+ ScheduledTask.status == "pending",
+ ScheduledTask.is_enabled == False,
+ ))
+ failed_result = await db.execute(select(func.count(AnalysisRecord.id)).where(
+ AnalysisRecord.user_id == current_user.id,
+ AnalysisRecord.status.in_(["error", "interrupted"]),
+ ))
+ today = datetime.utcnow().date().isoformat()
+ today_result = await db.execute(select(func.count(ScheduledTask.id)).where(
+ ScheduledTask.user_id == current_user.id,
+ ScheduledTask.next_run_time.is_not(None),
+ ))
+ return {
+ "data": {
+ "running": running_result.scalar() or 0,
+ "paused": paused_result.scalar() or 0,
+ "scheduled_today": today_result.scalar() or 0,
+ "failed": failed_result.scalar() or 0,
}
- )
+ }
-@router.get("/{task_id}", response_model=ScheduledTaskResponse)
+@router.get("/{task_id}")
async def get_scheduled_task(
task_id: int,
current_user: User = Depends(get_current_active_user),
- db: AsyncSession = Depends(get_db)
+ db: AsyncSession = Depends(get_db),
):
- """Get details of a specific scheduled task"""
-
- stmt = select(ScheduledTask).filter(
- ScheduledTask.id == task_id,
- ScheduledTask.user_id == current_user.id
- )
- result = await db.execute(stmt)
- task = result.scalars().first()
-
- if not task:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="Scheduled task not found"
- )
-
- return task
+ task = await _task_or_404(db, task_id, current_user.id)
+ return {"data": await _task_payload(db, task)}
-@router.patch("/{task_id}", response_model=ScheduledTaskResponse)
+@router.patch("/{task_id}")
async def update_scheduled_task(
task_id: int,
update_data: ScheduledTaskUpdate,
current_user: User = Depends(get_current_active_user),
- db: AsyncSession = Depends(get_db)
+ db: AsyncSession = Depends(get_db),
):
- """
- Update a scheduled task
-
- Can update:
- - is_enabled: Enable or disable the task
- - task_name: Rename the task
- """
-
- stmt = select(ScheduledTask).filter(
- ScheduledTask.id == task_id,
- ScheduledTask.user_id == current_user.id
- )
- result = await db.execute(stmt)
- task = result.scalars().first()
-
- if not task:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="Scheduled task not found"
- )
-
- # Check if task is completed
- if task.status == 'completed':
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Cannot modify a completed task. Completed tasks can only be deleted."
- )
-
- # Update fields
+ task = await _task_or_404(db, task_id, current_user.id)
+ scheduler = get_scheduler_service()
+
if update_data.task_name is not None:
task.task_name = update_data.task_name
-
+ if update_data.ticker is not None:
+ ticker = normalize_ticker(update_data.ticker)
+ if not validate_ticker(ticker):
+ raise HTTPException(status_code=400, detail=f"无效的股票代码格式: {update_data.ticker}")
+ task.ticker = normalize_ticker_with_suffix(ticker)
+ task.market = detect_market(task.ticker)
+ if update_data.execution_cycle is not None:
+ task.execution_cycle, task.interval_days = _cycle_for_scheduler(update_data.execution_cycle, update_data.interval_days or task.interval_days)
+ elif update_data.interval_days is not None:
+ task.interval_days = update_data.interval_days
+ if update_data.execution_time is not None:
+ task.execution_time = update_data.execution_time
+ if update_data.end_date is not None:
+ from pytz import timezone as pytz_timezone
+ beijing_tz = pytz_timezone("Asia/Shanghai")
+ task.end_date = beijing_tz.localize(datetime.strptime(update_data.end_date, "%Y-%m-%d").replace(hour=23, minute=59, second=59)) if update_data.end_date else None
+
if update_data.is_enabled is not None:
- old_enabled = task.is_enabled
-
- # If trying to enable the task, check if it has expired
- if update_data.is_enabled and not old_enabled:
- from pytz import timezone as pytz_timezone
- beijing_tz = pytz_timezone('Asia/Shanghai')
- now_beijing = datetime.now(beijing_tz)
-
- # Check if task has passed end date
- if task.end_date:
- # Ensure end_date is timezone-aware
- if task.end_date.tzinfo is None:
- end_date_aware = beijing_tz.localize(task.end_date)
- else:
- end_date_aware = task.end_date.astimezone(beijing_tz)
-
- if now_beijing > end_date_aware:
- # Task has expired, mark as completed
- task.status = 'completed'
- task.next_run_time = None
- task.is_enabled = False
-
- # Remove from scheduler if exists
- try:
- scheduler = get_scheduler_service()
- scheduler.remove_scheduled_task(task.scheduler_job_id)
- except Exception:
- pass
-
- # Update timestamp
- task.updated_at = now_beijing
- await db.commit()
- await db.refresh(task)
-
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"任务已过期,无法启用。结束日期为 {end_date_aware.strftime('%Y-%m-%d')},当前时间已超过此日期。任务已自动标记为已完成。"
- )
-
- # Re-create the job to calculate fresh next run time
- try:
- scheduler = get_scheduler_service()
-
- # Remove existing job (if any)
- try:
- scheduler.remove_scheduled_task(task.scheduler_job_id)
- except Exception:
- pass
-
- # Create new job with current time as reference
- scheduler.add_scheduled_task(
- task_id=task.id,
- job_id=task.scheduler_job_id,
- execution_cycle=task.execution_cycle,
- execution_time=task.execution_time,
- interval_days=task.interval_days,
- day_of_week=task.day_of_week,
- end_date=task.end_date
- )
-
- # Get the calculated next run time
- next_run = scheduler.get_next_run_time(task.scheduler_job_id)
-
- if not next_run:
- # No next run scheduled (shouldn't happen, but handle it)
- task.status = 'completed'
- task.next_run_time = None
- task.is_enabled = False
-
- task.updated_at = now_beijing
- await db.commit()
- await db.refresh(task)
-
- # Provide more detailed error message
- if task.end_date:
- detail_msg = f"任务的所有执行时间都已超过结束日期({task.end_date.strftime('%Y-%m-%d')}),无法启用。任务已自动标记为已完成。"
- else:
- detail_msg = "根据当前配置无法计算有效的执行时间,请检查任务配置。任务已自动标记为已完成。"
-
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=detail_msg
- )
-
- # Check if next run is after end date
- if task.end_date:
- # Ensure next_run is timezone-aware
- if next_run.tzinfo is None:
- next_run_aware = beijing_tz.localize(next_run)
- else:
- next_run_aware = next_run.astimezone(beijing_tz)
-
- # Ensure end_date is timezone-aware
- if task.end_date.tzinfo is None:
- end_date_aware = beijing_tz.localize(task.end_date)
- else:
- end_date_aware = task.end_date.astimezone(beijing_tz)
-
- if next_run_aware > end_date_aware:
- # Next run is after end date, mark as completed
- task.status = 'completed'
- task.next_run_time = None
- task.is_enabled = False
- scheduler.remove_scheduled_task(task.scheduler_job_id)
-
- # Update timestamp
- task.updated_at = now_beijing
- await db.commit()
- await db.refresh(task)
-
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"无法启用任务。下次执行时间为 {next_run_aware.strftime('%Y-%m-%d %H:%M')},已超过结束日期 {end_date_aware.strftime('%Y-%m-%d')}。任务已自动标记为已完成。"
- )
-
- # Update next run time in database
- task.next_run_time = next_run
- print(f"✅ Calculated next run time for task {task_id}: {next_run}")
-
- except HTTPException:
- raise
- except Exception as e:
- print(f"❌ Failed to recreate scheduler job: {e}")
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Failed to enable task: {str(e)}"
- )
-
task.is_enabled = update_data.is_enabled
-
- # Handle disabling (pausing) the task
- if not update_data.is_enabled and old_enabled:
- try:
- scheduler = get_scheduler_service()
- scheduler.pause_scheduled_task(task.scheduler_job_id)
- print(f"⏸️ Disabled scheduled task {task_id}")
- except Exception as e:
- print(f"⚠️ Failed to pause task: {e}")
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Failed to disable task: {str(e)}"
- )
-
- # Update timestamp (use Beijing time)
- from pytz import timezone as pytz_timezone
- beijing_tz = pytz_timezone('Asia/Shanghai')
- task.updated_at = datetime.now(beijing_tz)
+
+ if task.status == "pending":
+ try:
+ scheduler.remove_scheduled_task(task.scheduler_job_id)
+ except Exception:
+ pass
+ if task.is_enabled:
+ scheduler.add_scheduled_task(
+ task_id=task.id,
+ job_id=task.scheduler_job_id,
+ execution_cycle=task.execution_cycle,
+ execution_time=task.execution_time,
+ interval_days=task.interval_days,
+ day_of_week=task.day_of_week,
+ end_date=task.end_date,
+ )
+ task.next_run_time = scheduler.get_next_run_time(task.scheduler_job_id)
+ else:
+ task.next_run_time = None
+
await db.commit()
await db.refresh(task)
-
- return task
+ return {"data": await _task_payload(db, task)}
@router.delete("/{task_id}")
async def delete_scheduled_task(
task_id: int,
current_user: User = Depends(get_current_active_user),
- db: AsyncSession = Depends(get_db)
+ db: AsyncSession = Depends(get_db),
):
- """Delete a scheduled task"""
-
- stmt = select(ScheduledTask).filter(
- ScheduledTask.id == task_id,
- ScheduledTask.user_id == current_user.id
- )
- result = await db.execute(stmt)
- task = result.scalars().first()
-
- if not task:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="定时任务不存在"
- )
-
+ task = await _task_or_404(db, task_id, current_user.id)
task_name = task.task_name
task_status = task.status
-
- # Remove from scheduler
scheduler_removed = False
try:
- scheduler = get_scheduler_service()
- scheduler.remove_scheduled_task(task.scheduler_job_id)
+ get_scheduler_service().remove_scheduled_task(task.scheduler_job_id)
scheduler_removed = True
- except Exception as e:
- print(f"⚠️ Failed to remove task from scheduler: {e}")
- # Continue with database deletion even if scheduler fails
-
- # Delete from database
+ except Exception:
+ pass
await db.delete(task)
await db.commit()
-
- print(f"✅ Deleted scheduled task {task_id}")
-
return {
- "success": True,
- "message": f"定时任务 '{task_name}' 已成功删除",
- "task_id": task_id,
- "task_name": task_name,
- "task_status": task_status,
- "scheduler_removed": scheduler_removed
+ "data": {
+ "success": True,
+ "message": "任务已删除",
+ "task_id": task_id,
+ "task_name": task_name,
+ "task_status": "deleted" if task_status else "deleted",
+ "scheduler_removed": scheduler_removed,
+ }
}
diff --git a/web/backend/routes/skills_routes.py b/web/backend/routes/skills_routes.py
new file mode 100644
index 0000000..7acdc61
--- /dev/null
+++ b/web/backend/routes/skills_routes.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+"""
+Skills governance routes.
+"""
+
+from fastapi import APIRouter, Depends
+
+from web.backend.auth_routes import get_current_active_user
+from web.backend.models import User
+from web.backend.services.skills import get_skill_registry
+
+router = APIRouter(prefix="/api/skills", tags=["skills"])
+
+
+@router.get("/health")
+async def get_skills_health(current_user: User = Depends(get_current_active_user)):
+ """Return internal Skills health and provider routing metadata."""
+ return {"data": get_skill_registry().list_health()}
diff --git a/web/backend/routes/user_config_routes.py b/web/backend/routes/user_config_routes.py
index 2d9f91f..8e54b83 100644
--- a/web/backend/routes/user_config_routes.py
+++ b/web/backend/routes/user_config_routes.py
@@ -41,9 +41,6 @@ async def get_user_config(
last_shallow_thinker=config.last_shallow_thinker,
last_deep_thinker=config.last_deep_thinker,
last_backend_url=config.last_backend_url,
- enable_trading_executor=config.enable_trading_executor,
- futu_api_base_url=config.futu_api_base_url,
- futu_api_key=config.futu_api_key,
# Return actual API key for frontend to use
last_api_key=config.last_api_key
)
@@ -81,14 +78,6 @@ async def update_user_config(
if config_update.last_backend_url is not None:
config.last_backend_url = config_update.last_backend_url
- # Update trading executor configuration
- if config_update.enable_trading_executor is not None:
- config.enable_trading_executor = config_update.enable_trading_executor
- if config_update.futu_api_base_url is not None:
- config.futu_api_base_url = config_update.futu_api_base_url
- if config_update.futu_api_key is not None:
- config.futu_api_key = config_update.futu_api_key
-
# Update API key if provided (single field for all providers)
if config_update.last_api_key is not None:
config.last_api_key = config_update.last_api_key
@@ -108,9 +97,6 @@ async def update_user_config(
last_shallow_thinker=config.last_shallow_thinker,
last_deep_thinker=config.last_deep_thinker,
last_backend_url=config.last_backend_url,
- enable_trading_executor=config.enable_trading_executor,
- futu_api_base_url=config.futu_api_base_url,
- futu_api_key=config.futu_api_key,
# Return actual API key for frontend to use
last_api_key=config.last_api_key
)
diff --git a/web/backend/routes/user_leaderboard_routes.py b/web/backend/routes/user_leaderboard_routes.py
deleted file mode 100644
index f429b47..0000000
--- a/web/backend/routes/user_leaderboard_routes.py
+++ /dev/null
@@ -1,46 +0,0 @@
-"""
-User Leaderboard Toggle Routes
-
-用户排名开关相关API
-"""
-
-from fastapi import APIRouter, Depends, HTTPException, status
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select
-
-from web.backend.database import get_db
-from web.backend.models import User
-from web.backend.auth_routes import get_current_user
-
-router = APIRouter(prefix="/api/user", tags=["user-leaderboard"])
-
-
-@router.post("/leaderboard-toggle")
-async def toggle_leaderboard_participation(
- db: AsyncSession = Depends(get_db),
- current_user: User = Depends(get_current_user)
-):
- """
- 切换用户是否参加排名
- """
- # Get current user
- query = select(User).where(User.id == current_user.id)
- result = await db.execute(query)
- user = result.scalar_one_or_none()
-
- if not user:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="User not found"
- )
-
- # Toggle the participation flag
- user.participate_in_leaderboard = not user.participate_in_leaderboard
-
- await db.commit()
-
- return {
- "success": True,
- "participating": user.participate_in_leaderboard,
- "message": "已开启排名展示" if user.participate_in_leaderboard else "已关闭排名展示"
- }
diff --git a/web/backend/routes/user_management_routes.py b/web/backend/routes/user_management_routes.py
index c130e3f..7694684 100644
--- a/web/backend/routes/user_management_routes.py
+++ b/web/backend/routes/user_management_routes.py
@@ -12,7 +12,7 @@
from web.backend.database import get_db
from web.backend.models import User
-from web.backend.schemas import UserStatusUpdate, UserIntradayAccessUpdate
+from web.backend.schemas import UserStatusUpdate
from web.backend.auth_routes import get_current_active_user
router = APIRouter(prefix="/api/admin", tags=["user-management"])
@@ -71,7 +71,6 @@ async def get_all_users(
"email": user.email,
"role": user.role,
"is_active": user.is_active,
- "can_access_intraday_trading": user.can_access_intraday_trading,
"created_at": user.created_at.isoformat() if user.created_at else None,
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
})
@@ -146,7 +145,6 @@ async def get_user_detail(
"email": user.email,
"role": user.role,
"is_active": user.is_active,
- "can_access_intraday_trading": user.can_access_intraday_trading,
"created_at": user.created_at.isoformat() if user.created_at else None,
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
"statistics": {
@@ -301,220 +299,5 @@ async def update_user_status(
"email": user.email,
"role": user.role,
"is_active": user.is_active,
- "can_access_intraday_trading": user.can_access_intraday_trading,
"updated_at": user.updated_at.isoformat() if user.updated_at else None
}
-
-
-@router.patch("/users/{user_id}/intraday-access")
-async def update_user_intraday_access(
- user_id: int,
- access_update: UserIntradayAccessUpdate,
- current_user: User = Depends(require_admin),
- db: AsyncSession = Depends(get_db)
-):
- """
- 更新用户短线交易访问权限(仅管理员)
-
- Args:
- user_id: 用户ID
- access_update: 权限更新数据
- current_user: 当前用户(必须是管理员)
- db: 数据库会话
-
- Returns:
- 更新后的用户信息
- """
-
- # 查询用户
- stmt = select(User).filter(User.id == user_id)
- result = await db.execute(stmt)
- user = result.scalars().first()
-
- if not user:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="用户不存在"
- )
-
- # 记录旧状态,用于判断是否需要发送邮件
- old_access = user.can_access_intraday_trading
-
- # 更新用户短线交易权限
- user.can_access_intraday_trading = access_update.can_access_intraday_trading
- await db.commit()
- await db.refresh(user)
-
- # 如果是从禁用变为启用,发送开通成功邮件
- if not old_access and access_update.can_access_intraday_trading:
- await _send_intraday_access_granted_email(user)
-
- return {
- "id": user.id,
- "username": user.username,
- "email": user.email,
- "role": user.role,
- "is_active": user.is_active,
- "can_access_intraday_trading": user.can_access_intraday_trading,
- "updated_at": user.updated_at.isoformat() if user.updated_at else None
- }
-
-
-async def _send_intraday_access_granted_email(user: User):
- """
- 发送智能盯盘功能开通成功邮件
-
- Args:
- user: 用户对象
- """
- try:
- import smtplib
- from email.mime.text import MIMEText
- from email.mime.multipart import MIMEMultipart
- import os
- import logging
-
- logger = logging.getLogger(__name__)
-
- # Get SMTP configuration
- smtp_host = os.getenv("SMTP_HOST")
- smtp_port = int(os.getenv("SMTP_PORT", "587"))
- smtp_username = os.getenv("SMTP_USERNAME")
- smtp_password = os.getenv("SMTP_PASSWORD")
- smtp_from_email = os.getenv("SMTP_FROM_EMAIL")
- smtp_use_tls = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
- app_base_url = os.getenv("APP_BASE_URL", "http://localhost:3000")
-
- # Support email - use configured support email or fallback to SMTP from email
- support_email = os.getenv("SUPPORT_EMAIL") or smtp_from_email
-
- # Validate SMTP configuration
- if not all([smtp_host, smtp_username, smtp_password, smtp_from_email]):
- logger.warning("SMTP not configured, skipping email notification")
- return
-
- # Compose email
- subject = "智能盯盘功能已开通 - TradingAgentsWeb"
-
- # HTML email body
- html_body = f"""
-
-
-
-
-
-
-
-
-
🎉 智能盯盘功能已开通
-
TradingAgentsWeb
-
-
-
-
- 尊敬的 {user.username} ,您好!
-
-
-
- 恭喜您!您的智能盯盘功能已成功开通。现在您可以:
-
-
-
- ✅ 配置富途虚拟交易API,实现自动化交易分析
- ✅ 设置定时分析任务,实时监控市场动态
- ✅ 参与实时排名,与其他交易者比拼收益
- ✅ 查看详细的持仓和决策历史记录
-
-
-
-
- 💡 下一步操作:
- 1. 部署富途虚拟交易API(推荐使用 futu-paper-trade-api )
- 2. 在智能盯盘页面配置API地址和参数
- 3. 启动定时分析,开始您的智能交易之旅
-
-
-
-
-
-
-
- ⚠️ 风险提示: 虚拟交易仅供学习和测试使用,不代表真实交易结果。投资有风险,入市需谨慎。
-
-
-
-
-
-
-
-
- """
-
- # Plain text email body
- text_body = f"""
-智能盯盘功能已开通 - TradingAgentsWeb
-
-尊敬的 {user.username},您好!
-
-恭喜您!您的智能盯盘功能已成功开通。现在您可以:
-
-✅ 配置富途虚拟交易API,实现自动化交易分析
-✅ 设置定时分析任务,实时监控市场动态
-✅ 参与实时排名,与其他交易者比拼收益
-✅ 查看详细的持仓和决策历史记录
-
-下一步操作:
-1. 部署富途虚拟交易API(推荐使用 https://github.com/BSTester/futu-paper-trade-api)
-2. 在智能盯盘页面配置API地址和参数
-3. 启动定时分析,开始您的智能交易之旅
-
-立即开始使用:{app_base_url}/intraday-trading
-
-风险提示:虚拟交易仅供学习和测试使用,不代表真实交易结果。投资有风险,入市需谨慎。
-
-如有任何问题,请联系我们的支持团队:{support_email}
- """
-
- # Create message
- msg = MIMEMultipart('alternative')
- msg['Subject'] = subject
- msg['From'] = f"TradingAgentsWeb <{smtp_from_email}>"
- msg['To'] = user.email
-
- # Attach text and HTML parts
- part1 = MIMEText(text_body, 'plain', 'utf-8')
- part2 = MIMEText(html_body, 'html', 'utf-8')
- msg.attach(part1)
- msg.attach(part2)
-
- # Send email
- try:
- if smtp_use_tls:
- server = smtplib.SMTP(smtp_host, smtp_port, timeout=30)
- server.starttls()
- else:
- server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=30)
-
- server.login(smtp_username, smtp_password)
- server.sendmail(smtp_from_email, user.email, msg.as_string())
- server.quit()
-
- logger.info(f"Intraday access granted email sent to user {user.username} ({user.email})")
-
- except Exception as e:
- logger.error(f"Failed to send intraday access granted email: {e}")
-
- except Exception as e:
- # Don't fail the request if email fails
- import logging
- logger = logging.getLogger(__name__)
- logger.error(f"Error sending intraday access granted email: {e}")
diff --git a/web/backend/routes/websocket_routes.py b/web/backend/routes/websocket_routes.py
index 754af1f..1109208 100644
--- a/web/backend/routes/websocket_routes.py
+++ b/web/backend/routes/websocket_routes.py
@@ -1,559 +1,264 @@
#!/usr/bin/env python3
"""
-WebSocket Routes
-WebSocket 路由
+WebSocket routes for analysis progress streaming.
"""
-from fastapi import APIRouter, WebSocket, WebSocketDisconnect
import json
-from datetime import datetime
+import asyncio
+
+from fastapi import APIRouter, WebSocket, WebSocketDisconnect
router = APIRouter(tags=["websocket"])
-# Connection manager will be injected
manager = None
def init_websocket_routes(connection_manager):
- """Initialize WebSocket routes with connection manager"""
+ """Initialize WebSocket routes with the shared connection manager."""
global manager
manager = connection_manager
-@router.websocket("/ws/intraday/{user_id}")
-async def intraday_websocket_endpoint(websocket: WebSocket, user_id: int):
- """WebSocket endpoint for intraday trading real-time updates (user-specific)"""
-
-
- # 使用子协议进行鉴权:期望格式为 Sec-WebSocket-Protocol: jwt.
- offered_protocols = websocket.headers.get('sec-websocket-protocol') or ''
- chosen_subprotocol = None
- token = None
+def _extract_jwt_subprotocol(websocket: WebSocket) -> tuple[str | None, str | None]:
+ offered_protocols = websocket.headers.get("sec-websocket-protocol") or ""
if offered_protocols:
- # 可能为逗号分隔,取第一个以 jwt. 开头的值
- parts = [p.strip() for p in offered_protocols.split(',')]
- for p in parts:
- if p.startswith('jwt.'):
- chosen_subprotocol = p
- token = p[len('jwt.'):]
- break
+ for protocol in [p.strip() for p in offered_protocols.split(",")]:
+ if protocol.startswith("jwt."):
+ return protocol, protocol[len("jwt.") :]
+ return None, None
- if not token:
- # 无 token,拒绝连接(1008: Policy Violation)
- try:
- await websocket.close(code=1008)
- except Exception:
- pass
- return
+async def _authenticate_ws_token(websocket: WebSocket, token: str | None):
+ if not token:
+ await websocket.close(code=1008)
+ return None
from web.backend.auth import get_current_user_from_token
from web.backend.database import AsyncSessionLocal
-
+
async with AsyncSessionLocal() as db:
user = await get_current_user_from_token(token, db)
if user is None or not user.is_active:
- try:
- await websocket.close(code=1008)
- except Exception:
- pass
+ await websocket.close(code=1008)
+ return None
+ return user
- return
-
- # Verify user_id matches
- if user.id != user_id:
- try:
- await websocket.close(code=1008)
- except Exception:
- pass
+@router.websocket("/ws/analysis/{analysis_id}")
+async def websocket_endpoint(websocket: WebSocket, analysis_id: str):
+ """WebSocket endpoint for authenticated real-time analysis logs."""
+ chosen_subprotocol, token = _extract_jwt_subprotocol(websocket)
+
+ from sqlalchemy import select
+ from web.backend.database import AsyncSessionLocal
+ from web.backend.models import AnalysisRecord
+
+ user = await _authenticate_ws_token(websocket, token)
+ if user is None:
+ return
+
+ async with AsyncSessionLocal() as db:
+ stmt = select(AnalysisRecord).filter(
+ AnalysisRecord.analysis_id == analysis_id,
+ AnalysisRecord.user_id == user.id,
+ )
+ result = await db.execute(stmt)
+ if not result.scalars().first():
+ await websocket.close(code=1008)
return
try:
- # Use user-specific channel
- channel_id = f"intraday_user_{user_id}"
- await manager.connect(websocket, channel_id, subprotocol=chosen_subprotocol)
-
-
- # Send current scheduler status on connection (always send, even if not running)
- try:
- from web.backend.services.user_intraday_scheduler import get_manager as get_scheduler_manager
- from datetime import datetime
-
- scheduler_manager = get_scheduler_manager()
- status = scheduler_manager.get_scheduler_status(user_id)
-
- # If no scheduler exists, send default stopped status
- if not status:
- # Get user config for default values
- try:
- from web.backend.database import SessionLocal
- from web.backend.models import UserConfig
- from sqlalchemy import select
-
- db = SessionLocal()
- try:
- result = db.execute(
- select(UserConfig).where(UserConfig.user_id == user_id)
- )
- user_config = result.scalar_one_or_none()
-
- # Determine futu_api_url (fallback chain)
- futu_api_url = None
- if user_config:
- if user_config.intraday_futu_api_url:
- futu_api_url = user_config.intraday_futu_api_url
- else:
- futu_api_url = user_config.futu_api_base_url
-
- status = {
- "is_running": False,
- "interval_minutes": user_config.intraday_interval_minutes if user_config else 5,
- "market_type": user_config.intraday_market_type if user_config else "US,HK,CN",
- "market_status": "Scheduler not running",
- "market_is_open": False,
- "markets_status": {},
- "next_run_time": None,
- "current_time": datetime.now().isoformat(),
- "futu_api_url": futu_api_url,
- }
- finally:
- db.close()
- except Exception as db_error:
- print(f"⚠️ Failed to get user config: {db_error}")
- # Fallback to minimal default status
- status = {
- "is_running": False,
- "interval_minutes": 5,
- "market_type": "US,HK,CN",
- "market_status": "Scheduler not running",
- "market_is_open": False,
- "markets_status": {},
- "next_run_time": None,
- "current_time": datetime.now().isoformat(),
- }
-
- # Always send status (running or stopped)
- await websocket.send_text(json.dumps({
- 'type': 'scheduler_status_sync',
- 'timestamp': status.get('current_time'),
- 'status': status,
- }))
- print(f"📤 Sent scheduler status sync to user {user_id}: running={status.get('is_running')}")
-
- except Exception as sync_error:
- print(f"⚠️ Failed to sync scheduler status: {sync_error}")
- import traceback
- traceback.print_exc()
-
- # Send initial decisions list on connection
- try:
- from web.backend.database import SessionLocal
- from web.backend.models import IntradayDecisionRecord
- from sqlalchemy import select, desc
-
- db = SessionLocal()
- try:
- # Get total count of user's decisions
- from sqlalchemy import func
- total_count_result = db.execute(
- select(func.count(IntradayDecisionRecord.id))
- .where(IntradayDecisionRecord.user_id == user_id)
- )
- total_count = total_count_result.scalar() or 0
-
- # Get recent 20 decisions
- result = db.execute(
- select(IntradayDecisionRecord)
- .where(IntradayDecisionRecord.user_id == user_id)
- .order_by(desc(IntradayDecisionRecord.start_time))
- .limit(20)
- )
- decisions = result.scalars().all()
-
- # Convert to dict - only include summary, not full report
- decisions_data = []
- for decision in decisions:
- # Extract brief summary from report
- report_summary = ""
- if decision.decision_report:
- lines = decision.decision_report.split('\n')
- summary_lines = []
- char_count = 0
- for line in lines:
- if char_count > 200:
- break
- summary_lines.append(line)
- char_count += len(line)
- report_summary = '\n'.join(summary_lines[:5])
- if len(decision.decision_report) > 200:
- report_summary += "\n..."
-
- decisions_data.append({
- 'id': decision.id,
- 'session_id': decision.session_id,
- 'start_time': decision.start_time.isoformat(),
- 'end_time': decision.end_time.isoformat() if decision.end_time else None,
- 'status': decision.status,
- 'market_type': decision.market_type,
- 'positions_analyzed': decision.positions_analyzed if decision.positions_analyzed else [],
- 'trades_executed': decision.trades_executed if decision.trades_executed else [],
- 'report_summary': report_summary, # Brief summary only
- 'created_at': decision.created_at.isoformat(),
- })
-
- # Send decisions list with correct total count
- await websocket.send_text(json.dumps({
- 'type': 'decisions_initial',
- 'timestamp': datetime.now().isoformat(),
- 'decisions': {
- 'items': decisions_data,
- 'total': total_count, # Total count of all user's decisions
- 'page': 1,
- 'limit': 20,
- },
- }))
- print(f"📤 Sent initial decisions to user {user_id}: {len(decisions_data)} records (total: {total_count})")
-
- finally:
- db.close()
-
- except Exception as decisions_error:
- print(f"⚠️ Failed to send initial decisions: {decisions_error}")
- import traceback
- traceback.print_exc()
-
+ await manager.connect(websocket, analysis_id, subprotocol=chosen_subprotocol)
while True:
- # Keep connection alive and handle ping/pong
data = await websocket.receive_text()
-
try:
message = json.loads(data)
-
- if message.get('type') == 'ping':
- await websocket.send_text(json.dumps({'type': 'pong', 'user_id': user_id}))
except json.JSONDecodeError:
- pass
-
+ continue
+
+ if message.get("type") == "ping":
+ await websocket.send_text(json.dumps({"type": "pong", "analysis_id": analysis_id}))
except WebSocketDisconnect:
- manager.disconnect(websocket, channel_id)
- except Exception as e:
- logging.error(f"Intraday WebSocket error for user {user_id}: {e}")
- manager.disconnect(websocket, channel_id)
+ manager.disconnect(websocket, analysis_id)
-@router.websocket("/ws/analysis/{analysis_id}")
-async def websocket_endpoint(websocket: WebSocket, analysis_id: str):
- """WebSocket endpoint for real-time analysis logs"""
- print(f"🔌 WebSocket connection request for analysis: {analysis_id}")
+async def _authenticate_conversation_ws(websocket: WebSocket, chosen_subprotocol: str | None, token: str | None):
+ """Authenticate conversation WS using jwt subprotocol or first auth message."""
+ if token:
+ user = await _authenticate_ws_token(websocket, token)
+ return user, False
- # 使用子协议进行鉴权:期望格式为 Sec-WebSocket-Protocol: jwt.
- offered_protocols = websocket.headers.get('sec-websocket-protocol') or ''
- chosen_subprotocol = None
- token = None
- if offered_protocols:
- # 可能为逗号分隔,取第一个以 jwt. 开头的值
- parts = [p.strip() for p in offered_protocols.split(',')]
- for p in parts:
- if p.startswith('jwt.'):
- chosen_subprotocol = p
- token = p[len('jwt.'):]
- break
+ await websocket.accept()
+ try:
+ raw = await asyncio.wait_for(websocket.receive_text(), timeout=10)
+ message = json.loads(raw)
+ except Exception:
+ await websocket.close(code=1008)
+ return None, True
+ if message.get("type") != "auth" or not message.get("token"):
+ await websocket.close(code=1008)
+ return None, True
- if not token:
- # 无 token,拒绝连接(1008: Policy Violation)
- try:
+ from web.backend.auth import get_current_user_from_token
+ from web.backend.database import AsyncSessionLocal
+
+ async with AsyncSessionLocal() as db:
+ user = await get_current_user_from_token(message["token"], db)
+ if user is None or not user.is_active:
await websocket.close(code=1008)
- except Exception:
- pass
- print("❌ WebSocket missing token in subprotocol, connection rejected")
+ return None, True
+ return user, True
+
+
+@router.websocket("/ws/conversation/{session_id}")
+async def conversation_websocket(websocket: WebSocket, session_id: str):
+ """Authenticated conversation stream using the locked contract event names."""
+ chosen_subprotocol, token = _extract_jwt_subprotocol(websocket)
+ user, already_accepted = await _authenticate_conversation_ws(websocket, chosen_subprotocol, token)
+ if user is None:
return
- from web.backend.auth import get_current_user_from_token
+ from sqlalchemy import desc, select
from web.backend.database import AsyncSessionLocal
- from web.backend.models import AnalysisRecord
- from sqlalchemy import select
-
+ from web.backend.models import AnalysisRecord, ConversationMessage, ConversationSession
+ from web.backend.services.report_formatter import report_detail
+
async with AsyncSessionLocal() as db:
- user = await get_current_user_from_token(token, db)
- if user is None or not user.is_active:
- try:
- await websocket.close(code=1008)
- except Exception:
- pass
- print("❌ WebSocket invalid token, connection rejected")
+ session_result = await db.execute(select(ConversationSession).where(
+ ConversationSession.id == session_id,
+ ConversationSession.user_id == user.id,
+ ConversationSession.deleted_at.is_(None),
+ ))
+ if not session_result.scalars().first():
+ await websocket.close(code=1008)
return
- # 细粒度:校验 analysis_id 归属(intraday_* 会话跳过 AnalysisRecord 检查)
- if not analysis_id.startswith('intraday_'):
- stmt = select(AnalysisRecord).filter(
- AnalysisRecord.analysis_id == analysis_id,
- AnalysisRecord.user_id == user.id
- )
- result = await db.execute(stmt)
- analysis = result.scalars().first()
- if not analysis:
- try:
- await websocket.close(code=1008)
- except Exception:
- pass
- print("❌ WebSocket access denied: analysis not found or not owned by user")
+
+ channel_id = f"conversation_{session_id}"
+ if already_accepted:
+ if channel_id not in manager.active_connections:
+ manager.active_connections[channel_id] = []
+ manager.active_connections[channel_id].append(websocket)
+ else:
+ await manager.connect(websocket, channel_id, subprotocol=chosen_subprotocol)
+
+ async def send_snapshot():
+ async with AsyncSessionLocal() as db:
+ result = await db.execute(select(ConversationMessage).where(
+ ConversationMessage.session_id == session_id,
+ ConversationMessage.analysis_id.is_not(None),
+ ).order_by(desc(ConversationMessage.created_at)).limit(1))
+ msg = result.scalars().first()
+ if not msg:
return
+ record = None
+ if msg.analysis_id:
+ rec_result = await db.execute(select(AnalysisRecord).where(AnalysisRecord.analysis_id == msg.analysis_id))
+ record = rec_result.scalars().first()
+ await websocket.send_text(json.dumps({
+ "type": "stage_update",
+ "data": {
+ "stage_id": "reconnect",
+ "summary": f"当前分析状态:{record.status if record else msg.status}",
+ },
+ }, ensure_ascii=False))
+ if record and record.status == "completed":
+ await websocket.send_text(json.dumps({
+ "type": "report_ready",
+ "data": {
+ "report_id": record.analysis_id,
+ "message_id": msg.id,
+ "report": report_detail(record, session_id),
+ },
+ }, ensure_ascii=False, default=str))
try:
- await manager.connect(websocket, analysis_id, subprotocol=chosen_subprotocol)
- print(f"✅ WebSocket connected: {analysis_id}")
-
+ await send_snapshot()
while True:
- # Keep connection alive and handle ping/pong
- data = await websocket.receive_text()
- print(f"📨 Received message: {data}")
-
+ raw = await websocket.receive_text()
try:
- message = json.loads(data)
-
- if message.get('type') == 'ping':
- print(f"🏓 Sending pong response")
- await websocket.send_text(json.dumps({'type': 'pong', 'analysis_id': analysis_id}))
- except json.JSONDecodeError as e:
- print(f"❌ JSON decode error: {e}")
-
+ message = json.loads(raw)
+ except json.JSONDecodeError:
+ continue
+ msg_type = message.get("type")
+ if msg_type == "ping":
+ await websocket.send_text(json.dumps({"type": "pong"}))
+ elif msg_type == "reconnect":
+ await send_snapshot()
+ elif msg_type == "stop":
+ async with AsyncSessionLocal() as db:
+ result = await db.execute(select(ConversationMessage).where(
+ ConversationMessage.session_id == session_id,
+ ConversationMessage.analysis_id.is_not(None),
+ ConversationMessage.status.in_(["queued", "running"]),
+ ).order_by(desc(ConversationMessage.created_at)).limit(1))
+ msg = result.scalars().first()
+ stopped = False
+ if msg and msg.analysis_id:
+ from web.backend.app import task_manager
+ stopped = task_manager.stop_task(msg.analysis_id)
+ msg.status = "stopped"
+ await db.commit()
+ await websocket.send_text(json.dumps({
+ "type": "stop_ack",
+ "data": {
+ "message_id": msg.id if msg else None,
+ "stopped_at": __import__("datetime").datetime.utcnow().isoformat(),
+ "completed_stages": [],
+ "partial_content": "已停止" if stopped else "没有运行中的分析",
+ },
+ }, ensure_ascii=False))
+ elif msg_type == "retry_stage":
+ await websocket.send_text(json.dumps({
+ "type": "stage_warning",
+ "data": {"stage_id": message.get("stage"), "message": "阶段重试将在后续 M2 增量中执行", "can_continue": True},
+ }, ensure_ascii=False))
except WebSocketDisconnect:
- print(f"🔌 WebSocket disconnected: {analysis_id}")
- manager.disconnect(websocket, analysis_id)
- except Exception as e:
- print(f"❌ WebSocket error: {type(e).__name__}: {e}")
- import traceback
- traceback.print_exc()
-
-
-@router.websocket("/ws/leaderboard")
-async def leaderboard_websocket_endpoint(websocket: WebSocket):
- """WebSocket endpoint for real-time leaderboard updates (public, no auth required)"""
- print("🔌 Leaderboard WebSocket connection attempt received")
-
- # Get channel ID for leaderboard broadcasts
- channel_id = "leaderboard_public"
-
+ manager.disconnect(websocket, channel_id)
+
+
+@router.websocket("/ws/skills-health")
+async def skills_health_websocket(websocket: WebSocket):
+ """Authenticated Skills health push stream."""
+ chosen_subprotocol, token = _extract_jwt_subprotocol(websocket)
+ user = await _authenticate_ws_token(websocket, token)
+ if user is None:
+ return
+
+ from web.backend.services.skills import get_skill_registry
+
+ channel_id = f"skills_health_{user.id}"
+ await manager.connect(websocket, channel_id, subprotocol=chosen_subprotocol)
try:
- # Connect to leaderboard channel (this will accept the connection)
- await manager.connect(websocket, channel_id)
- print(f"✅ Leaderboard WebSocket connected successfully to channel: {channel_id}")
-
- # Get initial data with better error handling
- try:
- from web.backend.database import AsyncSessionLocal
- from web.backend.models import User, AccountSnapshot, UserConfig
- from sqlalchemy import select, desc
-
- async with AsyncSessionLocal() as db:
- # First, try to get users participating in leaderboard
- users_query = select(User).where(User.participate_in_leaderboard == True)
- users_result = await db.execute(users_query)
- participating_users = users_result.scalars().all()
-
- print(f"📊 Found {len(participating_users)} users participating in leaderboard")
-
- users_list = []
-
- # Get user configs for model information
- user_ids = [user.id for user in participating_users]
- configs = {}
- if user_ids:
- config_query = select(UserConfig).where(UserConfig.user_id.in_(user_ids))
- config_result = await db.execute(config_query)
- configs = {config.user_id: config for config in config_result.scalars().all()}
- print(f"📊 Found {len(configs)} user configs")
-
- if participating_users:
- # For each participating user, get their latest snapshot for each market
- for user in participating_users:
- # Get model name from config
- model_name = None
- if user.id in configs:
- config = configs[user.id]
- model_name = config.intraday_llm_model if config.intraday_llm_model else None
-
- # Get all snapshots for this user
- snapshot_query = select(AccountSnapshot).where(
- AccountSnapshot.user_id == user.id
- ).order_by(AccountSnapshot.snapshot_date.desc())
-
- snapshot_result = await db.execute(snapshot_query)
- all_snapshots = snapshot_result.scalars().all()
-
- if all_snapshots:
- # Group by market_type and get the latest for each market
- market_snapshots = {}
- for snapshot in all_snapshots:
- market = snapshot.market_type or 'US'
- if market not in market_snapshots:
- market_snapshots[market] = snapshot
-
- # Add one entry per market
- for market, snapshot in market_snapshots.items():
- users_list.append({
- 'user_id': user.id,
- 'username': user.username,
- 'market_type': market,
- 'total_assets': float(snapshot.total_assets) if snapshot.total_assets else 100000.0,
- 'latest_snapshot_date': snapshot.snapshot_date.strftime('%Y-%m-%d') if snapshot.snapshot_date else datetime.now().strftime('%Y-%m-%d'),
- 'model_name': model_name
- })
- else:
- # Create default snapshots for all markets if no data exists
- for market in ['US', 'HK', 'CN']:
- users_list.append({
- 'user_id': user.id,
- 'username': user.username,
- 'market_type': market,
- 'total_assets': 100000.0, # Default starting amount
- 'latest_snapshot_date': datetime.now().strftime('%Y-%m-%d'),
- 'model_name': model_name
- })
- else:
- # No participating users, send empty data
- print("ℹ️ No users participating in leaderboard yet")
-
- except Exception as db_error:
- print(f"❌ Database error in leaderboard WebSocket: {db_error}")
- # Send empty data if database query fails
- users_list = []
-
- # Sort by total_assets descending
- users_list.sort(key=lambda x: x['total_assets'], reverse=True)
- print(f"📤 Sending initial data with {len(users_list)} users")
-
- # Send initial data to client
+ registry = get_skill_registry()
await websocket.send_text(json.dumps({
- 'type': 'initial_data',
- 'timestamp': datetime.now().isoformat(),
- 'data': {
- 'users': users_list
- }
- }))
- print("✅ Initial data sent successfully")
-
- # Listen for messages from client
+ "type": "skills.health.snapshot",
+ "data": registry.list_health(),
+ }, ensure_ascii=False))
while True:
try:
- data = await websocket.receive_text()
+ data = await asyncio.wait_for(websocket.receive_text(), timeout=30)
message = json.loads(data)
- message_type = message.get('type')
-
- if message_type == 'get_initial_data':
- # Resend current data
- await websocket.send_text(json.dumps({
- 'type': 'initial_data',
- 'timestamp': datetime.now().isoformat(),
- 'data': {
- 'users': users_list
- }
- }))
- print("📤 Resent initial data on request")
- elif message_type == 'ping':
- # Respond to ping
- await websocket.send_text(json.dumps({'type': 'pong'}))
- print("🏓 Responded to ping")
-
- except WebSocketDisconnect:
- # Client disconnected, break the loop
- print(f"🔌 Client disconnected from leaderboard WebSocket")
- break
+ if message.get("type") == "ping":
+ await websocket.send_text(json.dumps({"type": "pong"}))
+ except asyncio.TimeoutError:
+ await websocket.send_text(json.dumps({
+ "type": "skills.health.changed",
+ "data": registry.list_health(),
+ }, ensure_ascii=False))
except json.JSONDecodeError:
- print(f"⚠️ Invalid JSON received from leaderboard WebSocket client: {data}")
- try:
- await websocket.send_text(json.dumps({
- 'type': 'error',
- 'message': 'Invalid JSON format'
- }))
- except Exception:
- # Connection might be closed, break the loop
- break
- except Exception as msg_error:
- print(f"⚠️ Error processing leaderboard WebSocket message: {msg_error}")
- try:
- await websocket.send_text(json.dumps({
- 'type': 'error',
- 'message': 'Error processing message'
- }))
- except Exception:
- # Connection might be closed, break the loop
- break
-
+ continue
except WebSocketDisconnect:
- print(f"🔌 Leaderboard WebSocket disconnected normally")
- except Exception as e:
- print(f"❌ Leaderboard WebSocket error: {type(e).__name__}: {e}")
- import traceback
- traceback.print_exc()
- finally:
- # Always cleanup connection
- try:
- manager.disconnect(websocket, channel_id)
- print(f"✅ Cleaned up leaderboard WebSocket connection for channel: {channel_id}")
- except Exception as cleanup_error:
- print(f"⚠️ Error during cleanup: {cleanup_error}")
-
-
-# Function to broadcast leaderboard updates
-async def broadcast_leaderboard_update(users_data: list = None, user_data: dict = None):
- """Broadcast leaderboard updates to all connected leaderboard clients"""
- if not manager:
- return
-
- message = {
- 'type': 'leaderboard_update' if users_data else 'user_update',
- 'timestamp': datetime.now().isoformat(),
- 'data': {}
- }
-
- if users_data:
- message['data']['users'] = users_data
- elif user_data:
- message['data']['user'] = user_data
-
- await manager.broadcast_to_channel("leaderboard_public", json.dumps(message))
+ manager.disconnect(websocket, channel_id)
-# Simple test WebSocket endpoint
@router.websocket("/ws/test")
async def test_websocket_endpoint(websocket: WebSocket):
- """Simple test WebSocket endpoint"""
- print("🔌 Test WebSocket connection attempt received")
+ """Simple WebSocket endpoint for connectivity checks."""
+ await websocket.accept()
try:
- await websocket.accept()
- print("✅ Test WebSocket connected successfully")
-
- # Send a test message
- await websocket.send_text(json.dumps({
- 'type': 'test_message',
- 'message': 'WebSocket connection successful!',
- 'timestamp': datetime.now().isoformat()
- }))
-
- # Keep connection alive and echo messages
+ await websocket.send_text(json.dumps({"type": "connected", "message": "WebSocket test connected"}))
while True:
- try:
- data = await websocket.receive_text()
- message = json.loads(data)
- if message.get('type') == 'ping':
- await websocket.send_text(json.dumps({'type': 'pong'}))
- else:
- await websocket.send_text(json.dumps({
- 'type': 'echo',
- 'data': message,
- 'timestamp': datetime.now().isoformat()
- }))
- except WebSocketDisconnect:
- print("🔌 Test WebSocket disconnected")
- break
- except Exception as e:
- print(f"⚠️ Test WebSocket error: {e}")
- break
-
- except Exception as e:
- print(f"❌ Test WebSocket connection error: {e}")
-
-
-# Export the broadcast function for use in other routes
-__all__ = ['router', 'init_websocket_routes', 'broadcast_leaderboard_update']
+ data = await websocket.receive_text()
+ await websocket.send_text(json.dumps({"type": "echo", "data": data}))
+ except WebSocketDisconnect:
+ return
diff --git a/web/backend/schemas.py b/web/backend/schemas.py
index 1e0fe26..c6e2f6f 100644
--- a/web/backend/schemas.py
+++ b/web/backend/schemas.py
@@ -62,8 +62,6 @@ class User(UserBase):
id: int
role: str
is_active: bool
- can_access_intraday_trading: bool
- participate_in_leaderboard: bool
has_set_password: bool
created_at: datetime
@@ -123,16 +121,12 @@ class AnalysisRequest(BaseModel):
analysis_date: str
analysts: List[str]
research_depth: int
- llm_provider: str
- backend_url: str
- shallow_thinker: str
- deep_thinker: str
+ llm_provider: str = "openai"
+ backend_url: str = "https://api.oneinfinityai.com/v1"
+ shallow_thinker: str = "gpt-5.5"
+ deep_thinker: str = "gpt-5.5"
# Privacy settings
- is_public: bool = False # Whether to show in public leaderboard
- # Trading executor settings
- enable_trading_executor: bool = False # Whether to enable trading executor
- futu_api_base_url: Optional[str] = None # Futu API base URL
- futu_api_key: Optional[str] = None # Futu API key
+ is_public: bool = False # Whether to make the generated report public
# API Key (single field for all LLM providers)
api_key: Optional[str] = None # API key for the selected LLM provider
# Email notification settings
@@ -171,7 +165,6 @@ class AnalysisStatus(BaseModel):
updated_at: Optional[datetime] = None
# Configuration info for UI initialization
selected_analysts: Optional[List[str]] = None
- enable_trading_executor: bool = False
email_notification_enabled: bool = False
class AnalysisRecord(BaseModel):
@@ -295,11 +288,6 @@ class ScheduledTaskCreate(BaseModel):
deep_thinker: str
is_public: bool = False
- # Trading executor configuration
- enable_trading_executor: bool = False
- futu_api_base_url: Optional[str] = None
- futu_api_key: Optional[str] = None
-
# Email notification settings
email_notification: bool = False # Whether to send email notification when task completes
@@ -307,7 +295,7 @@ class ScheduledTaskCreate(BaseModel):
api_key: Optional[str] = None # API key for the selected LLM provider
# Schedule configuration (optional for immediate execution)
- execution_cycle: Optional[str] = None # daily, weekly, every_n_days, workdays
+ execution_cycle: Optional[str] = None # daily, weekly, monthly, interval, every_n_days, workdays
execution_time: Optional[str] = None # HH:MM format (Beijing time)
interval_days: Optional[int] = None # Required if execution_cycle is every_n_days
day_of_week: Optional[str] = None # Required if execution_cycle is weekly (0-6, 0=Sunday)
@@ -323,8 +311,8 @@ def validate_task_name(cls, v):
@validator('execution_cycle')
def validate_execution_cycle(cls, v):
- if v and v not in ['daily', 'weekly', 'every_n_days', 'workdays']:
- raise ValueError('Invalid execution cycle. Must be one of: daily, weekly, every_n_days, workdays')
+ if v and v not in ['daily', 'weekly', 'monthly', 'interval', 'every_n_days', 'workdays']:
+ raise ValueError('Invalid execution cycle. Must be one of: daily, weekly, monthly, interval, every_n_days, workdays')
return v
@validator('execution_time')
@@ -337,7 +325,7 @@ def validate_execution_time(cls, v):
@validator('interval_days')
def validate_interval_days(cls, v, values):
- if values.get('execution_cycle') == 'every_n_days':
+ if values.get('execution_cycle') in ('every_n_days', 'interval'):
if not v or v < 1 or v > 365:
raise ValueError('interval_days must be between 1 and 365 when execution_cycle is every_n_days')
return v
@@ -383,9 +371,6 @@ class ScheduledTaskResponse(BaseModel):
deep_thinker: str
backend_url: str
is_public: bool
- enable_trading_executor: bool
- futu_api_base_url: Optional[str]
- futu_api_key: Optional[str]
email_notification_enabled: bool
execution_cycle: str
execution_time: str
@@ -405,8 +390,13 @@ class Config:
class ScheduledTaskUpdate(BaseModel):
"""Schema for updating task status"""
- is_enabled: Optional[bool] = None
task_name: Optional[str] = None
+ ticker: Optional[str] = None
+ is_enabled: Optional[bool] = None
+ execution_cycle: Optional[str] = None
+ execution_time: Optional[str] = None
+ interval_days: Optional[int] = None
+ end_date: Optional[str] = None
@validator('task_name')
def validate_task_name(cls, v):
@@ -434,10 +424,6 @@ class UserStatusUpdate(BaseModel):
"""Schema for updating user status"""
is_active: bool
-class UserIntradayAccessUpdate(BaseModel):
- """Schema for updating user intraday trading access"""
- can_access_intraday_trading: bool
-
# User Configuration schemas
class UserConfigUpdate(BaseModel):
"""Schema for updating user configuration - all analysis settings"""
@@ -450,11 +436,6 @@ class UserConfigUpdate(BaseModel):
last_deep_thinker: Optional[str] = None
last_backend_url: Optional[str] = None
- # Trading executor configuration
- enable_trading_executor: Optional[bool] = None
- futu_api_base_url: Optional[str] = None
- futu_api_key: Optional[str] = None
-
# API Key (single field for all LLM providers)
last_api_key: Optional[str] = None # Last used API key (matches last_llm_provider)
@@ -469,11 +450,6 @@ class UserConfigResponse(BaseModel):
last_deep_thinker: Optional[str] = None
last_backend_url: Optional[str] = None
- # Trading executor configuration
- enable_trading_executor: bool = False
- futu_api_base_url: Optional[str] = None
- futu_api_key: Optional[str] = None
-
# API Key (returns actual key for frontend to use)
last_api_key: Optional[str] = None # Last used API key (matches last_llm_provider)
@@ -493,7 +469,7 @@ class PromptTemplateBase(BaseModel):
class PromptTemplateCreate(PromptTemplateBase):
- agent_type: str = "intraday_trader"
+ agent_type: str = "analysis_agent"
class PromptTemplateUpdate(BaseModel):
@@ -668,4 +644,3 @@ class LLMConnectionTestResponse(BaseModel):
success: bool
message: str
details: Optional[Dict[str, Any]] = None
-
diff --git a/web/backend/services/futu_async_wrapper.py b/web/backend/services/futu_async_wrapper.py
deleted file mode 100644
index 6757eb7..0000000
--- a/web/backend/services/futu_async_wrapper.py
+++ /dev/null
@@ -1,305 +0,0 @@
-#!/usr/bin/env python3
-"""
-Futu API Async Wrapper
-
-Provides async wrappers for tradingagents.dataflows.futu_trading module.
-Converts synchronous Futu API calls to async for use in FastAPI.
-"""
-
-import asyncio
-import logging
-from typing import Optional, Dict, List, Any
-
-logger = logging.getLogger(__name__)
-
-
-async def get_account_info_async(
- market_type: str,
- user_id: Optional[int] = None
-) -> Optional[Dict[str, Any]]:
- """
- Async wrapper for get_account_info
-
- Args:
- market_type: Market type (US, HK, CN)
- user_id: User ID for user-specific configuration
-
- Returns:
- Account info dict or None if error
- """
- try:
- from tradingagents.dataflows.futu_trading import get_account_info
-
- # Run sync function in thread pool to avoid blocking
- result = await asyncio.to_thread(get_account_info, market_type, user_id)
- return result
-
- except Exception as e:
- logger.error(f"Error getting account info: {e}")
- return None
-
-
-async def get_positions_async(
- market_type: str,
- user_id: Optional[int] = None
-) -> Optional[List[Dict[str, Any]]]:
- """
- Async wrapper for get_positions
-
- Args:
- market_type: Market type (US, HK, CN)
- user_id: User ID for enriching with database info and user-specific configuration
-
- Returns:
- List of position dicts or None if error
- """
- try:
- from tradingagents.dataflows.futu_trading import get_positions
-
- # Run sync function in thread pool to avoid blocking
- result = await asyncio.to_thread(get_positions, market_type, user_id)
- return result
-
- except Exception as e:
- logger.error(f"Error getting positions: {e}")
- return None
-
-
-async def get_orders_async(
- market_type: str,
- filter_status: int = 0,
- user_id: Optional[int] = None
-) -> Optional[List[Dict[str, Any]]]:
- """
- Async wrapper for get_orders
-
- Args:
- market_type: Market type (US, HK, CN)
- filter_status: Filter by order status (0=all, 1=filled, 2=pending, 3=cancelled)
- user_id: User ID for user-specific configuration
-
- Returns:
- List of order dicts or None if error
- """
- try:
- from tradingagents.dataflows.futu_trading import get_orders
-
- # Run sync function in thread pool to avoid blocking
- result = await asyncio.to_thread(get_orders, market_type, filter_status, user_id)
- return result
-
- except Exception as e:
- logger.error(f"Error getting orders: {e}")
- return None
-
-
-async def get_quote_async(
- stock_code: str,
- user_id: Optional[int] = None
-) -> Optional[Dict[str, Any]]:
- """
- Async wrapper for get_quote
-
- Args:
- stock_code: Stock symbol
- user_id: User ID for user-specific configuration
-
- Returns:
- Quote dict or None if error
- """
- try:
- from tradingagents.dataflows.futu_trading import get_quote
-
- result = await asyncio.to_thread(get_quote, stock_code, user_id)
- return result
-
- except Exception as e:
- logger.error(f"Error getting quote: {e}")
- return None
-
-
-async def get_kline_data_async(
- symbol: str,
- interval: str = "daily",
- start_date: Optional[str] = None,
- end_date: Optional[str] = None,
- format: str = "csv",
- user_id: Optional[int] = None
-) -> Optional[Dict[str, Any]]:
- """
- Async wrapper for get_kline_data
-
- Args:
- symbol: Stock symbol
- interval: Time interval
- start_date: Start date (YYYY-MM-DD)
- end_date: End date (YYYY-MM-DD)
- format: Return format (json or csv)
- user_id: User ID for user-specific configuration
-
- Returns:
- K-line data or None if error
- """
- try:
- from tradingagents.dataflows.futu_trading import get_kline_data
-
- result = await asyncio.to_thread(
- get_kline_data, symbol, interval, start_date, end_date, format, user_id
- )
- return result
-
- except Exception as e:
- logger.error(f"Error getting kline data: {e}")
- return None
-
-
-async def get_hot_stocks_async(
- market_type: str = "US",
- count: int = 10,
- user_id: Optional[int] = None
-) -> Optional[List[Dict[str, Any]]]:
- """
- Async wrapper for get_hot_stocks
-
- Args:
- market_type: Market type (US, HK, CN)
- count: Number of stocks to return
- user_id: User ID for user-specific configuration
-
- Returns:
- List of hot stock dicts or None if error
- """
- try:
- from tradingagents.dataflows.futu_trading import get_hot_stocks
-
- result = await asyncio.to_thread(get_hot_stocks, market_type, count, user_id)
- return result
-
- except Exception as e:
- logger.error(f"Error getting hot stocks: {e}")
- return None
-
-
-async def place_order_async(
- stock_code: str,
- side: str,
- quantity: int,
- price: Optional[float] = None,
- order_type: str = "LIMIT",
- user_id: Optional[int] = None
-) -> Optional[Dict[str, Any]]:
- """
- Async wrapper for place_order
-
- Args:
- stock_code: Stock symbol
- side: Order side (BUY/SELL)
- quantity: Number of shares
- price: Limit price
- order_type: Order type (LIMIT/MARKET)
- user_id: User ID for user-specific configuration
-
- Returns:
- Order response dict or None if error
- """
- try:
- from tradingagents.dataflows.futu_trading import place_order
-
- result = await asyncio.to_thread(
- place_order, stock_code, side, quantity, price, order_type, user_id
- )
- return result
-
- except Exception as e:
- logger.error(f"Error placing order: {e}")
- return None
-
-
-async def cancel_order_async(
- order_id: str,
- stock_code: str,
- user_id: Optional[int] = None
-) -> Optional[Dict[str, Any]]:
- """
- Async wrapper for cancel_order
-
- Args:
- order_id: Order ID to cancel
- stock_code: Stock code
- user_id: User ID for user-specific configuration
-
- Returns:
- Cancellation response dict or None if error
- """
- try:
- from tradingagents.dataflows.futu_trading import cancel_order
-
- result = await asyncio.to_thread(cancel_order, order_id, stock_code, user_id)
- return result
-
- except Exception as e:
- logger.error(f"Error cancelling order: {e}")
- return None
-
-
-async def get_technical_analysis_async(
- symbol: str,
- interval: str = "daily",
- indicator: str = "macd",
- start_date: Optional[str] = None,
- end_date: Optional[str] = None,
- format: str = "csv",
- user_id: Optional[int] = None
-) -> Optional[Dict[str, Any]]:
- """
- Async wrapper for get_technical_analysis
-
- Args:
- symbol: Stock symbol
- interval: Time interval
- indicator: Technical indicator name
- start_date: Start date (YYYY-MM-DD)
- end_date: End date (YYYY-MM-DD)
- format: Return format (json or csv)
- user_id: User ID for user-specific configuration
-
- Returns:
- Technical analysis data or None if error
- """
- try:
- from tradingagents.dataflows.futu_trading import get_technical_analysis
-
- result = await asyncio.to_thread(
- get_technical_analysis, symbol, interval, indicator,
- start_date, end_date, format, user_id
- )
- return result
-
- except Exception as e:
- logger.error(f"Error getting technical analysis: {e}")
- return None
-
-
-async def get_hot_news_async(
- lang: str = "zh-cn",
- user_id: Optional[int] = None
-) -> Optional[List[Dict[str, Any]]]:
- """
- Async wrapper for get_hot_news
-
- Args:
- lang: Language code (zh-cn/zh-hk/en-us)
- user_id: User ID for user-specific configuration
-
- Returns:
- List of news article dicts or None if error
- """
- try:
- from tradingagents.dataflows.futu_trading import get_hot_news
-
- result = await asyncio.to_thread(get_hot_news, lang, user_id)
- return result
-
- except Exception as e:
- logger.error(f"Error getting hot news: {e}")
- return None
diff --git a/web/backend/services/intraday_executor.py b/web/backend/services/intraday_executor.py
deleted file mode 100644
index cf16969..0000000
--- a/web/backend/services/intraday_executor.py
+++ /dev/null
@@ -1,700 +0,0 @@
-#!/usr/bin/env python3
-"""
-Intraday Trading Executor
-
-This module executes a single intraday trading analysis session.
-It creates a decision record, invokes the LangGraph agent, and saves results.
-
-Architecture:
- IntradayScheduler (intraday_scheduler.py)
- └── Calls execute_intraday_analysis() (this file)
- └── Invokes LangGraph agent (intraday_trader.py)
- └── Agent autonomously calls tools and makes decisions
- └── Returns decision report and trades
-
-Workflow:
- 1. Create decision record in database
- 2. Get user's LLM configuration
- 3. Create LLM instance
- 4. Create and invoke LangGraph agent
- 5. Extract decision report and trades from agent result
- 6. Update decision record with results
- 7. Send WebSocket notification
-
-Usage:
- from web.backend.services.intraday_executor import execute_intraday_analysis
-
- result = await execute_intraday_analysis(
- market_type="US",
- user_id=1
- )
-"""
-
-import logging
-import uuid
-from datetime import datetime
-from typing import Optional, Dict, Any
-from sqlalchemy.orm import Session
-
-# Import will be done at runtime to avoid circular dependencies
-# from web.backend.database import SessionLocal
-# from web.backend.models import IntradayDecisionRecord, PositionRecord, TradingHistory
-
-
-def _db_operation_sync(operation_func, *args, **kwargs):
- """
- Helper function to execute synchronous database operations.
- This is called from async context via run_in_executor.
- """
- return operation_func(*args, **kwargs)
-
-
-async def execute_intraday_analysis(
- market_type: str = "US",
- user_id: Optional[int] = None,
-) -> Dict[str, Any]:
- """
- Execute intraday trading analysis for a user.
-
- Args:
- market_type: Market to analyze (US/HK/CN)
- user_id: User ID (None for system-wide analysis)
-
- Returns:
- Dict with execution results
- """
- session_id = f"intraday_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
-
- logging.info(f"Starting intraday analysis: session={session_id}, market={market_type}, user={user_id}")
-
- # ========================================
- # STEP 1: Pre-load all configuration from cache
- # ========================================
- from web.backend.services.user_config_cache import get_user_config_from_cache
- from tradingagents.default_config import DEFAULT_CONFIG
- import os
-
- # Get user configuration from cache (no database query)
- user_config_dict = None
- if user_id:
- user_config_dict = get_user_config_from_cache(user_id)
- if user_config_dict:
- logging.info(f"✅ Loaded user config from cache for user {user_id}")
- else:
- logging.warning(f"⚠️ No cached config for user {user_id}, will use defaults")
-
- # Extract all needed configuration upfront
- if user_config_dict:
- llm_provider = user_config_dict.get('intraday_llm_provider') or user_config_dict.get('last_llm_provider') or DEFAULT_CONFIG.get("llm_provider", "openai")
- api_key = user_config_dict.get('intraday_api_key') or user_config_dict.get('last_api_key')
- model_name = user_config_dict.get('intraday_llm_model') or user_config_dict.get('last_deep_thinker') or DEFAULT_CONFIG.get("deep_think_llm", "gpt-4o-mini")
- backend_url = user_config_dict.get('intraday_backend_url') or user_config_dict.get('last_backend_url') or DEFAULT_CONFIG.get("backend_url")
- futu_api_url = user_config_dict.get('intraday_futu_api_url') or user_config_dict.get('futu_api_base_url')
- futu_api_key = user_config_dict.get('futu_api_key')
- else:
- llm_provider = DEFAULT_CONFIG.get("llm_provider", "openai")
- api_key = None
- model_name = DEFAULT_CONFIG.get("deep_think_llm", "gpt-4o-mini")
- backend_url = DEFAULT_CONFIG.get("backend_url")
- futu_api_url = None
- futu_api_key = None
-
- logging.info(f"📋 Configuration loaded from cache:")
- logging.info(f" LLM Provider: {llm_provider}")
- logging.info(f" Model: {model_name}")
- logging.info(f" Backend URL: {backend_url or 'default'}")
- logging.info(f" Futu API URL: {futu_api_url or 'default'}")
- logging.info(f" API Key: {'***' if api_key else 'not set'}")
-
- # ========================================
- # STEP 2: Create sync database session and decision record
- # ========================================
- # NOTE: Using sync database operations to avoid event loop conflicts
- # when running in a separate thread with its own event loop
- try:
- # Import here to avoid circular dependencies
- from web.backend.database import SessionLocal
- from web.backend.models import IntradayDecisionRecord, PositionRecord
- from tradingagents.agents.trader.intraday_trader import create_intraday_trader
- from langchain_openai import ChatOpenAI
- from langchain_anthropic import ChatAnthropic
- from langchain_google_genai import ChatGoogleGenerativeAI
- from sqlalchemy import select
-
- # Create sync database session
- db = SessionLocal()
- try:
- # Create decision record
- decision_record = IntradayDecisionRecord(
- user_id=user_id or 1, # Default to user 1 if not specified
- session_id=session_id,
- start_time=datetime.now(),
- status="running",
- market_type=market_type,
- positions_analyzed=[],
- account_snapshot={},
- )
- db.add(decision_record)
- db.commit()
- db.refresh(decision_record)
-
- # WebSocket: announce session start with decision_id
- try:
- from web.backend.app import manager as ws_manager
-
- # Send to user-specific channel
- channel_id = f"intraday_user_{user_id}"
- await ws_manager.send_message({
- 'type': 'intraday_session_start',
- 'timestamp': datetime.utcnow().isoformat(),
- 'message': 'Intraday session started',
- 'decision_id': decision_record.id,
- 'session_id': session_id,
- 'market_type': market_type,
- }, channel_id)
- except Exception as ws_error:
- logging.warning(f"Failed to send session start WebSocket notification: {ws_error}")
-
- # Create LLM instance
- if llm_provider == "anthropic":
- llm = ChatAnthropic(
- model=model_name,
- temperature=0.1,
- api_key=api_key or os.getenv("ANTHROPIC_API_KEY"),
- )
- elif llm_provider == "google":
- llm = ChatGoogleGenerativeAI(
- model=model_name,
- temperature=0.1,
- google_api_key=api_key or os.getenv("GOOGLE_API_KEY"),
- )
- else:
- llm = ChatOpenAI(
- model=model_name,
- temperature=0.1,
- api_key=api_key or os.getenv("OPENAI_API_KEY"),
- base_url=backend_url or DEFAULT_CONFIG.get("backend_url"),
- )
-
- # ========================================
- # STEP 3: Get previous decision for context (one-time query)
- # ========================================
- previous_decision_context = ""
- try:
- from sqlalchemy import desc
- prev_result = db.execute(
- select(IntradayDecisionRecord).where(
- IntradayDecisionRecord.user_id == user_id,
- IntradayDecisionRecord.market_type == market_type,
- IntradayDecisionRecord.status == "completed"
- ).order_by(desc(IntradayDecisionRecord.end_time)).limit(1)
- )
- prev_decision = prev_result.scalar_one_or_none()
-
- if prev_decision:
- # Build context from previous decision
- prev_time = prev_decision.end_time.strftime('%Y-%m-%d %H:%M:%S') if prev_decision.end_time else "未知"
- prev_positions = prev_decision.positions_analyzed or []
- prev_trades = prev_decision.trades_executed or []
-
- previous_decision_context = f"""
-## 上次决策记录 (参考)
-
-**时间**: {prev_time}
-**市场**: {prev_decision.market_type}
-**分析股票**: {', '.join(prev_positions) if prev_positions else '无'}
-**执行交易**: {len(prev_trades)} 笔
-
-"""
- # Add trade details if available
- if prev_trades:
- previous_decision_context += "**交易详情**:\n"
- for i, trade in enumerate(prev_trades[:5], 1): # Limit to 5 most recent
- action = trade.get('action', '未知')
- stock = trade.get('stock', '未知')
- quantity = trade.get('quantity', 0)
- price = trade.get('price', 0)
- previous_decision_context += f"{i}. {action} {stock} - {quantity}股 @ ${price}\n"
-
- if len(prev_trades) > 5:
- previous_decision_context += f"... 还有 {len(prev_trades) - 5} 笔交易\n"
-
- # Add brief summary from report if available
- if prev_decision.decision_report:
- # Extract first few lines as summary
- report_lines = prev_decision.decision_report.split('\n')[:10]
- summary = '\n'.join(report_lines)
- if len(prev_decision.decision_report) > 500:
- summary = summary[:500] + "..."
- previous_decision_context += f"\n**决策摘要**:\n{summary}\n"
-
- logging.info(f"✅ Found previous decision (ID: {prev_decision.id}) for context")
- else:
- logging.info("ℹ️ No previous decision found for this user/market")
- except Exception as e:
- logging.warning(f"⚠️ Failed to fetch previous decision: {e}")
-
- # ========================================
- # STEP 4: Create LangGraph agent with pre-loaded config
- # ========================================
-
- # Create intraday trader agent
- logging.info(f"Creating LangGraph agent with provider={llm_provider}, model={model_name}")
-
- memory = None # Can add memory if needed
- trader_agent = create_intraday_trader(llm, memory)
-
- # Prepare initial state
- # Note: Agent has comprehensive system prompt with detailed instructions
- # We provide context from previous decision to help agent make informed decisions
- from langchain_core.messages import HumanMessage
-
- initial_message = "开始分析"
- if previous_decision_context:
- initial_message = f"{previous_decision_context}\n\n请基于以上历史决策记录,开始新一轮的分析。"
- logging.info(f"Providing historical context to agent (length: {len(previous_decision_context)} chars)")
- else:
- logging.info("No historical context available for this session")
-
- initial_state = {
- "user_id": user_id,
- "market_type": market_type,
- "session_id": session_id,
- "messages": [
- HumanMessage(content=initial_message)
- ],
- }
-
- # Execute agent - it will autonomously call tools and make decisions
- logging.info(f"Invoking intraday trader agent for session {session_id}, market={market_type}")
- # Set recursion limit to 100 to allow more tool calls
- # Default is 25, but intraday trading may need more iterations
- # Pass user_id in configurable field for tools to access
- result = await trader_agent.ainvoke(
- initial_state,
- config={
- "recursion_limit": 100,
- "configurable": {
- "user_id": user_id
- }
- }
- )
-
- # Extract results from agent execution
- decision_report = result.get("decision_report", "")
- trades_executed = result.get("trades_executed", [])
- messages = result.get("messages", [])
-
- logging.info(f"Agent execution completed:")
- logging.info(f" - Messages: {len(messages)}")
- logging.info(f" - Report length: {len(decision_report)} chars")
- logging.info(f" - Trades executed: {len(trades_executed) if trades_executed else 0}")
-
- # If decision_report is empty, try to extract from messages
- if not decision_report:
- logging.warning(f"Decision report is empty. Checking {len(messages)} messages...")
-
- # Find the last AI message (should be the final report)
- for msg in reversed(messages):
- if hasattr(msg, 'content') and isinstance(msg.content, str):
- content = msg.content
- # Check if this looks like a report (has markdown headers or substantial content)
- if any(marker in content for marker in ["#", "##", "日内交易报告", "账户状态", "持仓分析", "交易摘要"]):
- decision_report = content
- logging.info(f"Extracted decision report from message (length: {len(decision_report)} chars)")
- break
-
- # If still empty, use the last message content
- if not decision_report and messages:
- last_msg = messages[-1]
- if hasattr(last_msg, 'content'):
- decision_report = last_msg.content
- logging.info(f"Using last message as decision report (length: {len(decision_report)} chars)")
-
- # Verify we have a valid report
- if not decision_report or len(decision_report) < 50:
- logging.error(f"Decision report is too short or empty: '{decision_report[:200] if decision_report else 'EMPTY'}'")
- # Try to construct a minimal report from available data
- decision_report = f"""# 日内交易报告
-
-**会话**: {session_id}
-**时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
-**市场**: {market_type}
-
-## 执行结果
-- 执行交易: {len(trades_executed) if trades_executed else 0} 笔
-
-## 交易详情
-"""
- if trades_executed:
- for i, trade in enumerate(trades_executed, 1):
- decision_report += f"{i}. {trade.get('action', '未知')} {trade.get('stock', '未知')} - {trade.get('quantity', 0)}股\n"
- else:
- decision_report += "无交易执行\n"
-
- logging.warning(f"Constructed minimal report (length: {len(decision_report)} chars)")
-
- # Extract account and position info from agent's tool calls if available
- # The agent will have called these tools during execution
- account_info = {}
- positions = []
-
- # Extract stock codes from trades_executed
- if trades_executed and isinstance(trades_executed, list):
- found_stocks = set()
- for trade in trades_executed:
- if isinstance(trade, dict):
- # Try different field names for stock code
- stock_code = trade.get('stock') or trade.get('stock_code') or trade.get('symbol')
- if stock_code:
- found_stocks.add(str(stock_code).upper())
-
- positions = sorted(list(found_stocks))
- logging.info(f"Extracted {len(positions)} stock codes from trades: {positions}")
-
- # If no trades, try to extract from decision report as fallback
- if not positions and decision_report:
- import re
- # Pattern 1: Extract from section headers like "### AAPL - Apple Inc."
- stock_pattern1 = r'###\s+([A-Z0-9]{1,6})\s*[-–—]'
- # Pattern 2: Extract from Chinese format like "### 600519 - 贵州茅台"
- stock_pattern2 = r'###\s+(\d{5,6})\s*[-–—]'
-
- found_stocks = set()
- for pattern in [stock_pattern1, stock_pattern2]:
- matches = re.findall(pattern, decision_report)
- found_stocks.update(matches)
-
- positions = sorted(list(found_stocks))
- logging.info(f"Extracted {len(positions)} stock codes from report (fallback): {positions}")
-
- # Try to extract account info from messages/tool results
- messages = result.get("messages", [])
- for msg in messages:
- if hasattr(msg, 'content'):
- content = msg.content
- # Try to parse tool results from message content
- if isinstance(content, str):
- if "total_assets" in content.lower() or "available_funds" in content.lower():
- # This might be account info
- try:
- import re
- # Simple extraction - could be enhanced
- if "Total Assets" in content:
- match = re.search(r'Total Assets:\s*\$?([\d,]+\.?\d*)', content)
- if match:
- account_info['total_assets'] = float(match.group(1).replace(',', ''))
- except:
- pass
-
- # Update decision record with all collected information
- decision_record.end_time = datetime.now()
- decision_record.status = "completed"
- decision_record.decision_report = decision_report
- decision_record.trades_executed = trades_executed if trades_executed else []
- decision_record.positions_analyzed = positions if isinstance(positions, list) else []
- decision_record.account_snapshot = account_info if account_info else {}
-
- # Log what we're saving
- logging.info(f"Saving decision record to database:")
- logging.info(f" - Decision ID: {decision_record.id}")
- logging.info(f" - Session ID: {session_id}")
- logging.info(f" - Status: {decision_record.status}")
- logging.info(f" - Market: {market_type}")
- logging.info(f" - Report length: {len(decision_report)} chars")
- logging.info(f" - Trades executed: {len(trades_executed) if trades_executed else 0}")
- logging.info(f" - Positions analyzed: {len(positions) if positions else 0} - {positions}")
- logging.info(f" - Account snapshot: {account_info}")
-
- # Verify critical data is present
- if not decision_report or len(decision_report) < 100:
- logging.warning(f"⚠️ Decision report seems too short: {len(decision_report)} chars")
- if trades_executed:
- trades_summary = [f"{t.get('action')} {t.get('stock')}" for t in trades_executed]
- logging.info(f"✓ Trades data collected: {trades_summary}")
- else:
- logging.info("ℹ️ No trades executed in this session")
-
- # Commit and refresh (sync)
- db.commit()
- db.refresh(decision_record)
- logging.info(f"✓ Decision record saved successfully (ID: {decision_record.id})")
-
- # WebSocket: announce session complete with summary only (not full report)
- try:
- from web.backend.app import manager as ws_manager
-
- # Extract summary from report (first few lines or key metrics)
- report_summary = ""
- if decision_report:
- # Get first 200 characters or first paragraph
- lines = decision_report.split('\n')
- summary_lines = []
- char_count = 0
- for line in lines:
- if char_count > 200:
- break
- summary_lines.append(line)
- char_count += len(line)
- report_summary = '\n'.join(summary_lines[:5]) # Max 5 lines
- if len(decision_report) > 200:
- report_summary += "\n..."
-
- # Prepare lightweight decision record data for WebSocket
- # Only include summary information, not the full report
- decision_summary = {
- 'id': decision_record.id,
- 'session_id': decision_record.session_id,
- 'user_id': decision_record.user_id,
- 'start_time': decision_record.start_time.isoformat(),
- 'end_time': decision_record.end_time.isoformat() if decision_record.end_time else None,
- 'status': decision_record.status,
- 'market_type': decision_record.market_type,
- 'positions_analyzed': decision_record.positions_analyzed if decision_record.positions_analyzed else [],
- 'trades_executed': decision_record.trades_executed if decision_record.trades_executed else [],
- 'trades_count': len(decision_record.trades_executed) if decision_record.trades_executed else 0,
- 'report_summary': report_summary, # Brief summary only
- 'report_length': len(decision_report), # Full report length for reference
- 'created_at': decision_record.created_at.isoformat(),
- }
-
- logging.info(f"Prepared WebSocket message with decision summary:")
- logging.info(f" - Trades count: {decision_summary['trades_count']}")
- logging.info(f" - Positions: {decision_summary['positions_analyzed']}")
- logging.info(f" - Report length: {decision_summary['report_length']} chars")
-
- # Send to user-specific channel
- channel_id = f"intraday_user_{user_id}"
- trades_count = len(decision_summary.get('trades_executed', []))
- await ws_manager.send_message({
- 'type': 'intraday_session_complete',
- 'timestamp': datetime.utcnow().isoformat(),
- 'message': f'分析完成 - {trades_count} 笔交易',
- 'decision_record': decision_summary, # Summary only, not full report
- }, channel_id)
- except Exception as ws_error:
- logging.warning(f"Failed to send WebSocket notification: {ws_error}")
-
- logging.info(f"Analysis completed successfully: session_id={session_id}")
-
- # Return success - snapshot will be created by the scheduler
- result_data = {
- "status": "success",
- "session_id": session_id,
- "decision_record_id": decision_record.id,
- "market_type": market_type,
- "user_id": user_id,
- "trades_count": len(trades_executed) if trades_executed else 0,
- "positions_analyzed": positions if positions else [],
- "report_length": len(decision_report),
- "start_time": decision_record.start_time.isoformat(),
- "end_time": decision_record.end_time.isoformat() if decision_record.end_time else None,
- }
-
- return result_data
- finally:
- # Close database session
- db.close()
-
- except Exception as e:
- # 获取详细的错误信息
- import traceback
- error_type = type(e).__name__
- error_msg = str(e)
- error_traceback = traceback.format_exc()
-
- # 构建详细的错误报告
- detailed_error = f"{error_type}: {error_msg}"
-
- # 特殊处理常见错误
- error_hints = []
- if "null value" in error_msg.lower() and "choices" in error_msg.lower():
- error_hints.append("LLM API 返回了空响应")
- error_hints.append("请检查:API 密钥是否有效、模型名称是否正确、API 配额是否充足、网络连接是否正常")
- elif "rate limit" in error_msg.lower():
- error_hints.append("API 请求频率超限,请稍后重试")
- elif "timeout" in error_msg.lower():
- error_hints.append("API 请求超时,请检查网络连接")
- elif "authentication" in error_msg.lower() or "unauthorized" in error_msg.lower():
- error_hints.append("API 认证失败,请检查 API 密钥配置")
- elif "connection" in error_msg.lower():
- error_hints.append("网络连接失败,请检查网络设置")
-
- # 构建完整的错误报告
- full_error_report = f"## 错误详情\n\n**错误类型**: {error_type}\n\n**错误信息**: {error_msg}\n\n"
- if error_hints:
- full_error_report += "**可能原因**:\n" + "\n".join(f"- {hint}" for hint in error_hints) + "\n\n"
- full_error_report += f"**技术详情**:\n```\n{error_traceback}\n```"
-
- logging.error(f"Error executing intraday analysis: {detailed_error}\n\nFull traceback:\n{error_traceback}")
-
- # Try to update decision record with error (async)
- try:
- from web.backend.database import AsyncSessionLocal
- from web.backend.models import IntradayDecisionRecord
- from sqlalchemy import select
-
- error_db = SessionLocal()
- try:
- # Query for the decision record
- result = error_db.execute(
- select(IntradayDecisionRecord).filter(
- IntradayDecisionRecord.session_id == session_id
- )
- )
- decision_record = result.scalar_one_or_none()
-
- if decision_record:
- decision_record.end_time = datetime.now()
- decision_record.status = "failed"
- decision_record.decision_report = full_error_report
- error_db.commit()
- finally:
- error_db.close()
- except Exception as db_error:
- logging.error(f"Error updating decision record: {db_error}")
-
- # WebSocket: announce session error
- try:
- from web.backend.app import manager as ws_manager
- from web.backend.database import SessionLocal
- from web.backend.models import IntradayDecisionRecord
- import asyncio
-
- # Try to get decision_id from database (async)
- decision_id = None
- try:
- from sqlalchemy import select
- temp_db = SessionLocal()
- try:
- result = temp_db.execute(
- select(IntradayDecisionRecord).filter(
- IntradayDecisionRecord.session_id == session_id
- )
- )
- temp_record = result.scalar_one_or_none()
- if temp_record:
- decision_id = temp_record.id
- finally:
- temp_db.close()
- except:
- pass
-
- # Send to user-specific channel
- channel_id = f"intraday_user_{user_id}"
-
- # Create a new event loop if needed (since we're in exception handler)
- try:
- loop = asyncio.get_event_loop()
- if loop.is_running():
- # If loop is running, create a task
- asyncio.create_task(ws_manager.send_message({
- 'type': 'intraday_session_error',
- 'timestamp': datetime.utcnow().isoformat(),
- 'message': f'智能盯盘执行失败: {detailed_error}',
- 'error_type': error_type,
- 'error_hints': error_hints,
- 'session_id': session_id,
- 'decision_id': decision_id,
- }, channel_id))
- else:
- # If no loop, run it synchronously
- loop.run_until_complete(ws_manager.send_message({
- 'type': 'intraday_session_error',
- 'timestamp': datetime.utcnow().isoformat(),
- 'message': f'智能盯盘执行失败: {detailed_error}',
- 'error_type': error_type,
- 'error_hints': error_hints,
- 'session_id': session_id,
- 'decision_id': decision_id,
- }, channel_id))
- except Exception as loop_error:
- logging.warning(f"Failed to send error WebSocket notification: {loop_error}")
- except Exception as outer_error:
- logging.warning(f"Failed to prepare error WebSocket notification: {outer_error}")
-
- return {
- "status": "error",
- "session_id": session_id,
- "error": str(e),
- }
-
-
-def update_position_records(db: Session, user_id: int, trades: list):
- """
- Update position records based on executed trades.
-
- Args:
- db: Database session
- user_id: User ID
- trades: List of trade dictionaries
- """
- from web.backend.models import PositionRecord, TradingHistory
-
- for trade in trades:
- stock_code = trade.get("stock_code")
- trade_type = trade.get("trade_type") # BUY/SELL
- quantity = trade.get("quantity")
- price = trade.get("price")
- market_type = trade.get("market_type")
-
- if not all([stock_code, trade_type, quantity, price]):
- logging.warning(f"Incomplete trade data: {trade}")
- continue
-
- # Find or create position record
- position = db.query(PositionRecord).filter(
- PositionRecord.user_id == user_id,
- PositionRecord.stock_code == stock_code,
- PositionRecord.is_closed == False,
- ).first()
-
- if trade_type == "BUY":
- if position is None:
- # Create new position
- position = PositionRecord(
- user_id=user_id,
- stock_code=stock_code,
- market_type=market_type,
- first_open_time=datetime.now(),
- first_open_price=price,
- initial_quantity=quantity,
- current_quantity=quantity,
- last_update_time=datetime.now(),
- )
- db.add(position)
- else:
- # Add to existing position
- position.current_quantity += quantity
- position.last_update_time = datetime.now()
-
- elif trade_type == "SELL":
- if position is None:
- logging.warning(f"Trying to sell non-existent position: {stock_code}")
- continue
-
- # Reduce position
- position.current_quantity -= quantity
- position.last_update_time = datetime.now()
-
- # Close position if fully sold
- if position.current_quantity <= 0:
- position.is_closed = True
- position.current_quantity = 0
-
- # Create trading history record
- history = TradingHistory(
- position_record_id=position.id if position.id else None,
- trade_time=datetime.now(),
- trade_type=trade_type,
- quantity=quantity,
- price=price,
- order_id=trade.get("order_id"),
- decision_reason=trade.get("reason"),
- technical_signals=trade.get("technical_signals"),
- news_sentiment=trade.get("news_sentiment"),
- )
- db.add(history)
-
- db.commit()
- logging.info(f"Updated position records for {len(trades)} trades")
diff --git a/web/backend/services/intraday_scheduler.py b/web/backend/services/intraday_scheduler.py
deleted file mode 100644
index 5a14a5d..0000000
--- a/web/backend/services/intraday_scheduler.py
+++ /dev/null
@@ -1,553 +0,0 @@
-#!/usr/bin/env python3
-"""
-Intraday Trading Scheduler - Core Scheduler Class
-
-This module contains the IntradayScheduler class which handles the scheduling
-logic for a single user's intraday trading analysis.
-
-Architecture:
- - IntradayScheduler: Core scheduler class (this file)
- - UserIntradaySchedulerManager: Manages multiple user schedulers (user_intraday_scheduler.py)
- - IntradayExecutor: Executes the actual analysis (intraday_executor.py)
-
-Usage:
- This class should NOT be instantiated directly in most cases.
- Use UserIntradaySchedulerManager.create_scheduler() instead.
-
-Example:
- # Don't do this:
- # scheduler = IntradayScheduler(interval_minutes=5, market_type="US", user_id=1)
-
- # Do this instead:
- from web.backend.services.user_intraday_scheduler import get_manager
- manager = get_manager()
- scheduler = await manager.create_scheduler(user_id=1, interval_minutes=5, market_type="US")
-"""
-
-import asyncio
-import logging
-from datetime import datetime, time
-from typing import Optional
-import pytz
-from tradingagents.agents.utils.market_utils import is_market_open
-
-# Get logger for this module
-logger = logging.getLogger(__name__)
-
-
-class IntradayScheduler:
- """
- Scheduler for intraday trading agent execution.
- Runs analysis at configured intervals during market hours.
- """
-
- def __init__(self, interval_minutes: int = 5, market_type: str = "US,HK,CN", user_id: Optional[int] = None):
- """
- Initialize intraday scheduler.
-
- Args:
- interval_minutes: Minutes between analysis runs (default: 5)
- market_type: Market to monitor. Single market (US/HK/CN) or comma-separated (US,HK,CN)
- user_id: User ID for this scheduler (optional)
- """
- self.interval_minutes = interval_minutes
- # Ensure market_type is a string (convert list to comma-separated string if needed)
- if isinstance(market_type, list):
- self.market_type = ",".join(market_type)
- else:
- self.market_type = market_type
- self.user_id = user_id
- self.is_running = False
- self._task: Optional[asyncio.Task] = None
- self._stop_event = asyncio.Event()
- self._next_run_time: Optional[datetime] = None # Track actual next run time
- self._analysis_tasks: dict[str, asyncio.Task] = {} # Track running analysis tasks by market
-
- # Error tracking for auto-stop on consecutive failures
- self._consecutive_failures: dict[str, int] = {} # Track failures per market
- self._max_consecutive_failures = 3 # Stop after 3 consecutive failures
- self._last_error_messages: dict[str, str] = {} # Store last error message per market
-
- # Market timezones
- self.market_timezones = {
- "US": pytz.timezone("America/New_York"),
- "HK": pytz.timezone("Asia/Hong_Kong"),
- "CN": pytz.timezone("Asia/Shanghai"),
- }
-
- # All markets to monitor (in order)
- self.all_markets = ["US", "HK", "CN"]
-
- logger.info(f"📋 IntradayScheduler initialized: interval={interval_minutes}min, market={market_type}, user={user_id}")
-
- async def start(self):
- """Start the scheduler"""
- if self.is_running:
- logger.warning(f"⚠️ IntradayScheduler is already running for user {self.user_id}")
- return
-
- logger.info(f"Starting scheduler for user {self.user_id}")
-
- self.is_running = True
- self._stop_event.clear()
-
- # Set initial next run time to now (will execute immediately)
- self._next_run_time = datetime.now()
-
- self._task = asyncio.create_task(self._run_loop())
-
- async def stop(self):
- """Stop the scheduler and cancel all running analysis tasks"""
- if not self.is_running:
- logger.warning("IntradayScheduler is not running")
- return
-
- logger.info(f"🛑 Stopping scheduler for user {self.user_id}...")
-
- self.is_running = False
- self._stop_event.set()
- self._next_run_time = None # Clear next run time
-
- # Cancel all running analysis tasks
- if self._analysis_tasks:
- logger.info(f"Cancelling {len(self._analysis_tasks)} running analysis task(s)...")
- for market, task in list(self._analysis_tasks.items()):
- if not task.done():
- logger.info(f" Cancelling {market} analysis task...")
- task.cancel()
-
- # Wait for all tasks to complete cancellation
- if self._analysis_tasks:
- await asyncio.gather(*self._analysis_tasks.values(), return_exceptions=True)
-
- self._analysis_tasks.clear()
-
- # Stop the main scheduler loop
- if self._task:
- try:
- await asyncio.wait_for(self._task, timeout=10.0)
- except asyncio.TimeoutError:
- logger.warning("IntradayScheduler stop timeout, cancelling task")
- self._task.cancel()
- try:
- await self._task
- except asyncio.CancelledError:
- pass
-
- logger.info(f"⏹️ IntradayScheduler stopped for user {self.user_id}")
-
- async def _run_loop(self):
- """Main scheduler loop - checks and analyzes all markets in sequence"""
- # Execute immediately on first run
- first_run = True
-
- while not self._stop_event.is_set():
- try:
-
- # If not first run, wait for the interval
- if not first_run:
- # Wait for next interval with periodic status updates
- wait_seconds = self.interval_minutes * 60
- logger.info(f"⏰ Waiting {self.interval_minutes} minutes until next check (next: {self._next_run_time.strftime('%H:%M:%S')})")
-
- # Send status updates every 30 seconds during wait
- elapsed = 0
- update_interval = 30 # seconds
-
- while elapsed < wait_seconds:
- try:
- remaining_wait = min(update_interval, wait_seconds - elapsed)
- await asyncio.wait_for(
- self._stop_event.wait(),
- timeout=remaining_wait
- )
- # If we get here, stop was requested
- return
- except asyncio.TimeoutError:
- # Timeout is expected
- elapsed += remaining_wait
-
- # Broadcast status update (for countdown)
- if elapsed < wait_seconds:
- await self._broadcast_status()
-
- first_run = False
-
- utc_now = datetime.now(pytz.UTC)
-
- # Determine which markets to check (comma-separated or single)
- if "," in self.market_type:
- markets_to_check = [m.strip() for m in self.market_type.split(",")]
- else:
- markets_to_check = [self.market_type]
-
- # Check and analyze each market (non-blocking)
- for market in markets_to_check:
- if self._stop_event.is_set():
- break
-
- # Get market local time
- market_tz = self.market_timezones.get(market, pytz.UTC)
- market_local_time = utc_now.astimezone(market_tz)
-
- # Check if market is open
- is_open, status_msg = is_market_open(market, market_local_time)
- if is_open:
- # Check if there's already a running task for this market
- existing_task = self._analysis_tasks.get(market)
- if existing_task and not existing_task.done():
- logger.warning(f"⏳ {market} analysis is still running from previous cycle")
- logger.warning(f" Skipping this cycle and will retry in next interval")
- # Don't start a new task - let the existing one finish
- # The next cycle will check again
- else:
- logger.info(f"✅ {market} market is open, triggering analysis: {status_msg}")
- # Start analysis in background using ensure_future (more robust)
- # This ensures the task runs independently without blocking the scheduler loop
- task = asyncio.ensure_future(self._trigger_analysis(market))
- self._analysis_tasks[market] = task
- # Don't await - let it run in background
- else:
- logger.info(f"⏸️ {market} market is closed, skipping: {status_msg}")
-
- # Update next run time
- from datetime import timedelta
- self._next_run_time = datetime.now() + timedelta(minutes=self.interval_minutes)
-
- # Broadcast status update via WebSocket
- await self._broadcast_status()
-
- except Exception as e:
- logger.error(f"❌ Error in IntradayScheduler loop: {e}", exc_info=True)
- # Wait a bit before retrying to avoid tight error loop
- try:
- await asyncio.wait_for(self._stop_event.wait(), timeout=60)
- break
- except asyncio.TimeoutError:
- continue
-
- logger.info("IntradayScheduler loop ended")
-
- async def _trigger_analysis(self, market: str):
- """
- Trigger intraday trading analysis for a specific market.
- This runs in the background and can be cancelled.
-
- Uses asyncio.to_thread() to run the potentially blocking analysis
- in a separate thread, preventing it from blocking the event loop.
-
- Args:
- market: Market to analyze (US/HK/CN)
- """
- try:
- logger.info(f"🚀 Triggering intraday trading analysis for {market} market (user {self.user_id})...")
-
- # Send WebSocket notification that analysis is starting
- try:
- from web.backend.app import manager as ws_manager
- from datetime import datetime
-
- channel_id = f"intraday_user_{self.user_id}"
- await ws_manager.send_message({
- 'type': 'analysis_trigger',
- 'timestamp': datetime.utcnow().isoformat(),
- 'message': f'开始 {market} 市场分析...',
- 'market_type': market,
- }, channel_id)
- except Exception as ws_error:
- logger.warning(f"Failed to send analysis trigger notification: {ws_error}")
-
- # Import here to avoid circular dependencies
- from web.backend.services.intraday_executor import execute_intraday_analysis
-
- # Run the analysis in a separate thread to avoid blocking the event loop
- # This is important because execute_intraday_analysis contains:
- # 1. Synchronous database operations (SessionLocal)
- # 2. Async LLM calls (trader_agent.ainvoke) - now async but still long-running
- # 3. Potentially long-running operations
- loop = asyncio.get_event_loop()
- result = await loop.run_in_executor(
- None, # Use default ThreadPoolExecutor
- self._run_analysis_sync,
- market
- )
-
- logger.info(f"✅ {market} analysis completed: {result.get('status', 'unknown')}")
-
- # Handle analysis result and track failures
- if result.get('status') == 'success':
- # Reset failure counter on success
- self._consecutive_failures[market] = 0
- self._last_error_messages[market] = ""
-
- # Create account snapshot after successful analysis
- try:
- from web.backend.services.snapshot_scheduler import create_account_snapshot
-
- snapshot_created = await create_account_snapshot(
- self.user_id,
- market,
- skip_market_check=True
- )
- if snapshot_created:
- logger.info(f"✅ Account snapshot created for user {self.user_id} in {market} market")
- else:
- logger.warning(f"⚠️ Failed to create account snapshot for user {self.user_id} in {market} market")
- except Exception as snapshot_error:
- logger.error(f"❌ Error creating account snapshot: {snapshot_error}", exc_info=True)
-
- elif result.get('status') == 'error':
- # Increment failure counter
- self._consecutive_failures[market] = self._consecutive_failures.get(market, 0) + 1
- error_msg = result.get('error', 'Unknown error')
- self._last_error_messages[market] = error_msg
-
- failure_count = self._consecutive_failures[market]
- logger.error(f"❌ {market} analysis failed ({failure_count}/{self._max_consecutive_failures}): {error_msg}")
-
- # Check if we've reached the failure threshold
- if failure_count >= self._max_consecutive_failures:
- logger.error(f"🛑 {market} market has failed {failure_count} consecutive times. Stopping scheduler for user {self.user_id}...")
-
- # Send WebSocket notification about auto-stop
- try:
- from web.backend.app import manager as ws_manager
-
- channel_id = f"intraday_user_{self.user_id}"
- await ws_manager.send_message({
- 'type': 'scheduler_auto_stopped',
- 'timestamp': datetime.utcnow().isoformat(),
- 'message': f'智能盯盘已自动停止:{market}市场连续失败{failure_count}次',
- 'market_type': market,
- 'failure_count': failure_count,
- 'last_error': error_msg,
- }, channel_id)
- except Exception as ws_error:
- logger.warning(f"Failed to send auto-stop notification: {ws_error}")
-
- # Update database to disable auto_start
- try:
- from web.backend.database import AsyncSessionLocal
- from web.backend.models import UserConfig
- from sqlalchemy import select
-
- async with AsyncSessionLocal() as db:
- result_db = await db.execute(
- select(UserConfig).where(UserConfig.user_id == self.user_id)
- )
- user_config = result_db.scalar_one_or_none()
-
- if user_config:
- user_config.intraday_scheduler_auto_start = False
- await db.commit()
- logger.info(f"✅ Disabled auto_start for user {self.user_id} in database")
- except Exception as db_error:
- logger.error(f"Failed to update auto_start flag: {db_error}")
-
- # Stop the scheduler
- await self.stop()
- return
-
- except asyncio.CancelledError:
- logger.info(f"🛑 {market} analysis was cancelled")
- raise # Re-raise to properly handle cancellation
- except Exception as e:
- # Unexpected exception in trigger logic itself
- logger.error(f"❌ Unexpected error in _trigger_analysis for {market}: {str(e)}", exc_info=True)
-
- # Treat unexpected exceptions as failures too
- self._consecutive_failures[market] = self._consecutive_failures.get(market, 0) + 1
- self._last_error_messages[market] = str(e)
-
- failure_count = self._consecutive_failures[market]
- if failure_count >= self._max_consecutive_failures:
- logger.error(f"🛑 Stopping scheduler due to {failure_count} consecutive unexpected errors")
- await self.stop()
- finally:
- # Clean up task reference
- if market in self._analysis_tasks:
- del self._analysis_tasks[market]
-
- def _run_analysis_sync(self, market: str) -> dict:
- """
- Synchronous wrapper for execute_intraday_analysis.
- This runs in a thread pool to avoid blocking the event loop.
-
- Args:
- market: Market to analyze (US/HK/CN)
-
- Returns:
- dict: Analysis result
- """
- import asyncio
- from web.backend.services.intraday_executor import execute_intraday_analysis
-
- # Create a new event loop for this thread
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
-
- result = None
- try:
- # Run the async function in this thread's event loop
- result = loop.run_until_complete(
- execute_intraday_analysis(
- market_type=market,
- user_id=self.user_id,
- )
- )
-
- # Give async cleanup tasks time to complete
- # This is important for database connection cleanup
- # Wait for all pending tasks to complete naturally (not cancel them)
- loop.run_until_complete(asyncio.sleep(0.2))
-
- return result
- except Exception as e:
- logging.error(f"Error in analysis execution: {e}")
- raise
- finally:
- # Cleanup: wait for remaining tasks to complete, then close loop
- try:
- # Check for any remaining tasks
- pending = asyncio.all_tasks(loop)
- if pending:
- logging.info(f"Waiting for {len(pending)} pending tasks to complete...")
- # Give tasks more time to complete naturally (don't cancel)
- # This is safer than cancelling, especially for database operations
- for _ in range(10): # Wait up to 1 second
- if not asyncio.all_tasks(loop):
- break
- loop.run_until_complete(asyncio.sleep(0.1))
-
- # If tasks still exist, log warning but don't cancel
- remaining = asyncio.all_tasks(loop)
- if remaining:
- logging.warning(f"{len(remaining)} tasks still pending after cleanup wait")
-
- # Final cleanup pass
- loop.run_until_complete(asyncio.sleep(0))
- except Exception as e:
- logging.warning(f"Error during loop cleanup: {e}")
- finally:
- # Close the loop
- try:
- loop.close()
- except Exception as e:
- logging.warning(f"Error closing loop: {e}")
-
- async def _broadcast_status(self):
- """Broadcast current status via WebSocket"""
- if self.user_id is None:
- return
-
- try:
- from web.backend.app import manager as ws_manager
-
- status = self.get_status()
- channel_id = f"intraday_user_{self.user_id}"
-
- await ws_manager.send_message({
- 'type': 'scheduler_status_update',
- 'timestamp': status.get('current_time'),
- 'status': status,
- }, channel_id)
-
- except Exception as e:
- logger.debug(f"Failed to broadcast status: {e}")
-
- def get_status(self) -> dict:
- """Get current scheduler status with all markets info"""
- utc_now = datetime.now(pytz.UTC)
-
- # Get status for configured markets
- markets_status = {}
- if "," in self.market_type:
- markets_to_check = [m.strip() for m in self.market_type.split(",")]
- else:
- markets_to_check = [self.market_type]
-
- for market in markets_to_check:
- market_tz = self.market_timezones.get(market, pytz.UTC)
- market_local_time = utc_now.astimezone(market_tz)
- is_open, status_msg = is_market_open(market, market_local_time)
-
- # Check if there's a running task for this market
- task_running = False
- existing_task = self._analysis_tasks.get(market)
- if existing_task and not existing_task.done():
- task_running = True
-
- # Include failure tracking info
- failure_count = self._consecutive_failures.get(market, 0)
- last_error = self._last_error_messages.get(market, "")
-
- markets_status[market] = {
- "is_open": is_open,
- "status": status_msg,
- "local_time": market_local_time.strftime("%Y-%m-%d %H:%M:%S %Z"),
- "task_running": task_running,
- "consecutive_failures": failure_count,
- "last_error": last_error if failure_count > 0 else "",
- }
-
- # Use tracked next run time
- next_run = None
- if self.is_running and self._next_run_time:
- next_run = self._next_run_time.isoformat()
-
- # Overall market status message
- if len(markets_to_check) > 1:
- # Multiple markets
- open_markets = [m for m, s in markets_status.items() if s["is_open"]]
- if open_markets:
- market_status = f"Markets open: {', '.join(open_markets)}"
- market_is_open = True
- else:
- market_status = "All markets closed"
- market_is_open = False
- else:
- # Single market
- single_market = markets_to_check[0]
- market_status = markets_status[single_market]["status"]
- market_is_open = markets_status[single_market]["is_open"]
-
- # Check if any market is approaching failure threshold
- max_failures = max([self._consecutive_failures.get(m, 0) for m in markets_to_check])
- health_status = "healthy"
- if max_failures >= self._max_consecutive_failures:
- health_status = "stopped" # Should have stopped already
- elif max_failures >= 2:
- health_status = "warning" # One more failure will stop
- elif max_failures >= 1:
- health_status = "degraded" # Has failures but not critical
-
- return {
- "is_running": self.is_running,
- "interval_minutes": self.interval_minutes,
- "market_type": self.market_type,
- "market_status": market_status,
- "market_is_open": market_is_open,
- "markets_status": markets_status, # Detailed status for each market
- "next_run_time": next_run,
- "current_time": datetime.now().isoformat(),
- "health_status": health_status,
- "max_consecutive_failures": self._max_consecutive_failures,
- }
-
- def update_interval(self, interval_minutes: int):
- """Update analysis interval"""
- if interval_minutes < 5 or interval_minutes > 120:
- raise ValueError("Interval must be between 5 and 120 minutes")
-
- self.interval_minutes = interval_minutes
- logger.info(f"IntradayScheduler interval updated to {interval_minutes} minutes")
-
-
-# NOTE: This file only contains the IntradayScheduler class definition.
-# For multi-user scheduler management, use user_intraday_scheduler.py
-#
-# The global singleton pattern has been removed in favor of per-user scheduler instances.
-# Each user gets their own IntradayScheduler instance managed by UserIntradaySchedulerManager.
-
-
-import os
diff --git a/web/backend/services/prompt_loader.py b/web/backend/services/prompt_loader.py
index c0b49c6..38030fb 100644
--- a/web/backend/services/prompt_loader.py
+++ b/web/backend/services/prompt_loader.py
@@ -151,31 +151,19 @@ def generate_variable_documentation() -> str:
"""
-def get_default_intraday_prompt() -> str:
+DEFAULT_AGENT_TYPE = "analysis_agent"
+
+
+def get_default_analysis_prompt() -> str:
"""
- Get the default intraday trader prompt template
+ Get the default analysis agent prompt template
This is the fallback prompt used when a user hasn't customized their template yet.
"""
- # Read from the original intraday_trader.py file
- try:
- import os
- prompt_file = os.path.join(
- os.path.dirname(__file__),
- '../../../tradingagents/agents/trader/intraday_trader_default_prompt.txt'
- )
-
- if os.path.exists(prompt_file):
- with open(prompt_file, 'r', encoding='utf-8') as f:
- return f.read()
- except Exception as e:
- logger.warning(f"Could not load default prompt from file: {e}")
-
- # Fallback inline prompt
- return """You are an aggressive intraday trading agent operating like a professional day trader with full autonomy to analyze positions and execute trades.
+ return """You are a financial analysis agent. Produce research-backed analysis reports only; never place orders or execute trades.
## Your Mission
-Maximize risk-adjusted returns through strategic intraday trading.
+Generate concise, evidence-grounded investment analysis and risk recommendations.
## Available Variables
- {{market_type}} - Current market (US/HK/CN)
@@ -183,28 +171,27 @@ def get_default_intraday_prompt() -> str:
- {{timestamp}} - Current timestamp
- {{user_id}} - User identifier
-## Execution Workflow
+## Workflow
### Phase 1: Information Collection
-Call these tools to gather data:
-- get_futu_account_info(market_type="{{market_type}}")
-- get_futu_positions(market_type="{{market_type}}")
-- get_futu_orders(market_type="{{market_type}}", filter_status=0)
+Gather market, technical, fundamental, news, sentiment, and risk context through read-only data tools.
### Phase 2: Analysis
-Analyze the collected data and make decisions.
-
-### Phase 3: Execute Trades
-Use place_futu_order() to execute trades if needed.
+Analyze the collected data and produce a structured view.
-### Phase 4: Generate Report
-Provide a comprehensive report in Chinese.
+### Phase 3: Generate Report
+Provide a comprehensive report in Chinese. Stop at recommendations; do not call trading/order tools.
Current market: {{market_type}}
Session: {{session_id}}
"""
+def get_default_intraday_prompt() -> str:
+ """Backward-compatible alias for old imports; returns non-trading analysis prompt."""
+ return get_default_analysis_prompt()
+
+
def _create_default_template_for_user_sync(user_id: int, db) -> AgentPromptTemplate:
"""
Create a default prompt template for a user - Sync version
@@ -218,14 +205,14 @@ def _create_default_template_for_user_sync(user_id: int, db) -> AgentPromptTempl
"""
from sqlalchemy import select
- default_prompt = get_default_intraday_prompt()
+ default_prompt = get_default_analysis_prompt()
template = AgentPromptTemplate(
- agent_type="intraday_trader",
+ agent_type=DEFAULT_AGENT_TYPE,
user_id=user_id,
system_prompt=default_prompt,
- template_name="默认日内交易策略",
- description="系统默认的日内交易 Agent 提示词",
+ template_name="默认分析报告策略",
+ description="系统默认的分析 Agent 提示词",
version="1.0",
is_active=True
)
@@ -288,14 +275,14 @@ def create_default_template_for_user(user_id: int, db: Session) -> AgentPromptTe
Returns:
Created template
"""
- default_prompt = get_default_intraday_prompt()
+ default_prompt = get_default_analysis_prompt()
template = AgentPromptTemplate(
- agent_type="intraday_trader",
+ agent_type=DEFAULT_AGENT_TYPE,
user_id=user_id,
system_prompt=default_prompt,
- template_name="默认日内交易策略",
- description="系统默认的日内交易 Agent 提示词",
+ template_name="默认分析报告策略",
+ description="系统默认的分析 Agent 提示词",
version="1.0",
is_active=True
)
@@ -323,7 +310,7 @@ def create_default_template_for_user(user_id: int, db: Session) -> AgentPromptTe
def _load_user_prompt_template_sync(
user_id: int,
- agent_type: str = "intraday_trader",
+ agent_type: str = DEFAULT_AGENT_TYPE,
) -> str:
"""
Load user's core prompt template (strategy and behavior only) - Sync version
@@ -351,13 +338,13 @@ def _load_user_prompt_template_sync(
if not user:
logger.warning(f"User {user_id} not found")
- default_prompt = get_default_intraday_prompt()
+ default_prompt = get_default_analysis_prompt()
return default_prompt
if not user.is_active:
logger.debug(f"User {user_id} is disabled, skipping cache, using default prompt")
# Don't cache for disabled users
- return get_default_intraday_prompt()
+ return get_default_analysis_prompt()
# Query user's template
result = db.execute(
@@ -383,7 +370,7 @@ def _load_user_prompt_template_sync(
f"❌ Template found but system_prompt is empty for user {user_id}, "
f"template_id={template.id}, using default prompt"
)
- prompt = get_default_intraday_prompt()
+ prompt = get_default_analysis_prompt()
# Don't cache empty prompts
else:
_prompt_cache.set(cache_key, prompt)
@@ -398,7 +385,7 @@ def _load_user_prompt_template_sync(
except Exception as e:
logger.error(f"❌ Error loading prompt template for user {user_id}: {e}", exc_info=True)
# Fallback to default core prompt (no system injections)
- default_prompt = get_default_intraday_prompt()
+ default_prompt = get_default_analysis_prompt()
return default_prompt
finally:
db.close()
@@ -406,7 +393,7 @@ def _load_user_prompt_template_sync(
async def load_user_prompt_template_async(
user_id: int,
- agent_type: str = "intraday_trader",
+ agent_type: str = DEFAULT_AGENT_TYPE,
) -> str:
"""
Load user's core prompt template (strategy and behavior only) - Async wrapper
@@ -418,7 +405,7 @@ async def load_user_prompt_template_async(
Args:
user_id: User ID
- agent_type: Type of agent (default: intraday_trader)
+ agent_type: Type of agent
Returns:
User's core prompt string (without system injections)
@@ -430,7 +417,7 @@ async def load_user_prompt_template_async(
def load_user_prompt_template(
user_id: int,
- agent_type: str = "intraday_trader",
+ agent_type: str = DEFAULT_AGENT_TYPE,
) -> str:
"""
Load user's core prompt template (strategy and behavior only) - Sync version
@@ -442,7 +429,7 @@ def load_user_prompt_template(
Args:
user_id: User ID
- agent_type: Type of agent (default: intraday_trader)
+ agent_type: Type of agent
Returns:
User's core prompt string (without system injections)
@@ -464,13 +451,13 @@ def load_user_prompt_template(
if not user:
logger.warning(f"User {user_id} not found")
- default_prompt = get_default_intraday_prompt()
+ default_prompt = get_default_analysis_prompt()
return default_prompt
if not user.is_active:
logger.debug(f"User {user_id} is disabled, skipping cache, using default prompt")
# Don't cache for disabled users
- return get_default_intraday_prompt()
+ return get_default_analysis_prompt()
# Query user's template
template = db.query(AgentPromptTemplate).filter(
@@ -493,7 +480,7 @@ def load_user_prompt_template(
f"❌ Template found but system_prompt is empty for user {user_id}, "
f"template_id={template.id}, using default prompt"
)
- prompt = get_default_intraday_prompt()
+ prompt = get_default_analysis_prompt()
# Don't cache empty prompts
else:
_prompt_cache.set(cache_key, prompt)
@@ -508,7 +495,7 @@ def load_user_prompt_template(
except Exception as e:
logger.error(f"❌ Error loading prompt template for user {user_id}: {e}", exc_info=True)
# Fallback to default core prompt (no system injections)
- default_prompt = get_default_intraday_prompt()
+ default_prompt = get_default_analysis_prompt()
# Don't cache error cases
return default_prompt
@@ -516,7 +503,7 @@ def load_user_prompt_template(
db.close()
-def get_enabled_tools_for_user(user_id: int, agent_type: str = "intraday_trader") -> List[str]:
+def get_enabled_tools_for_user(user_id: int, agent_type: str = DEFAULT_AGENT_TYPE) -> List[str]:
"""
Get list of enabled tool names for a user
@@ -552,7 +539,7 @@ def get_enabled_tools_for_user(user_id: int, agent_type: str = "intraday_trader"
-def invalidate_prompt_cache(user_id: int, agent_type: str = "intraday_trader"):
+def invalidate_prompt_cache(user_id: int, agent_type: str = DEFAULT_AGENT_TYPE):
"""
Invalidate prompt cache for a user
@@ -560,6 +547,6 @@ def invalidate_prompt_cache(user_id: int, agent_type: str = "intraday_trader"):
Args:
user_id: User ID
- agent_type: Agent type (default: intraday_trader)
+ agent_type: Agent type
"""
_prompt_cache.invalidate(user_id, agent_type)
diff --git a/web/backend/services/report_formatter.py b/web/backend/services/report_formatter.py
new file mode 100644
index 0000000..556d3fe
--- /dev/null
+++ b/web/backend/services/report_formatter.py
@@ -0,0 +1,269 @@
+"""Format AnalysisRecord rows into the locked report API contract."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from typing import Any, Dict, List
+
+
+RATING_LABELS = {
+ 1: "高风险",
+ 2: "谨慎",
+ 3: "中性",
+ 4: "偏积极",
+ 5: "高置信积极",
+}
+
+
+SECTION_TITLES = {
+ "market_technical": "市场技术分析",
+ "fundamentals": "基本面分析",
+ "sentiment": "舆情分析",
+ "news_macro": "新闻宏观分析",
+ "risk": "风险评估",
+}
+
+
+def _iso(value: Any) -> str | None:
+ return value.isoformat() if value else None
+
+
+def _final_state(record: Any) -> Dict[str, Any]:
+ return record.final_state if isinstance(record.final_state, dict) else {}
+
+
+def _structured(record: Any) -> Dict[str, Any]:
+ final_state = _final_state(record)
+ structured = final_state.get("structured_report")
+ return structured if isinstance(structured, dict) else {}
+
+
+def _section_list(record: Any) -> List[Dict[str, Any]]:
+ structured = _structured(record)
+ sections = structured.get("sections") if isinstance(structured.get("sections"), dict) else {}
+ result = []
+ for key, title in SECTION_TITLES.items():
+ data = sections.get(key) or {}
+ result.append({
+ "key": key,
+ "title": data.get("title") or title,
+ "summary": data.get("summary") or "",
+ "content": data.get("details") or data.get("content") or "",
+ "grounded_evidence": "; ".join(
+ evidence.get("excerpt", "")
+ for evidence in structured.get("grounded_evidence", [])
+ if key == "sentiment" and isinstance(evidence, dict)
+ ) or None,
+ "data_sources": [
+ {
+ "name": evidence.get("source", "unknown"),
+ "snapshot_time": evidence.get("captured_at") or evidence.get("as_of"),
+ }
+ for evidence in structured.get("grounded_evidence", [])
+ if isinstance(evidence, dict)
+ ],
+ "indicators": [],
+ "financials": {},
+ "news_sources": [],
+ "risk_factors": data.get("key_points", []) if key == "risk" else [],
+ })
+ return result
+
+
+def report_id(record: Any) -> str:
+ return record.analysis_id
+
+
+def report_preview(record: Any, source_session_id: str | None = None) -> Dict[str, Any]:
+ structured = _structured(record)
+ rating = int(structured.get("rating") or 3)
+ sections = structured.get("sections") if isinstance(structured.get("sections"), dict) else {}
+ return {
+ "id": report_id(record),
+ "ticker": record.ticker,
+ "company_name": record.company_name or record.ticker,
+ "market": record.market,
+ "rating": rating,
+ "rating_label": RATING_LABELS.get(rating, "中性"),
+ "summary": structured.get("summary") or record.final_summary or record.trading_decision or "",
+ "section_summaries": {
+ key: (sections.get(key) or {}).get("summary", "")
+ for key in SECTION_TITLES
+ },
+ "source": {"type": "conversation" if source_session_id else "scheduled_task", "session_id": source_session_id},
+ "status": _status(record.status),
+ "created_at": _iso(record.created_at),
+ }
+
+
+def report_detail(record: Any, source_session_id: str | None = None, task_id: int | None = None) -> Dict[str, Any]:
+ structured = _structured(record)
+ rating = int(structured.get("rating") or 3)
+ reflection = structured.get("reflection") if isinstance(structured.get("reflection"), dict) else {}
+ return {
+ "id": report_id(record),
+ "ticker": record.ticker,
+ "company_name": record.company_name or record.ticker,
+ "market": record.market,
+ "source": {"type": "conversation" if source_session_id else "scheduled_task", "session_id": source_session_id, "task_id": task_id},
+ "conclusion": {
+ "rating": rating,
+ "rating_label": RATING_LABELS.get(rating, "中性"),
+ "summary": structured.get("summary") or record.trading_decision or "",
+ "key_points": [
+ item
+ for section in (structured.get("sections") or {}).values()
+ if isinstance(section, dict)
+ for item in section.get("key_points", [])[:1]
+ ][:3],
+ },
+ "sections": _section_list(record),
+ "stage_log": structured.get("stage_log") or _final_state(record).get("stage_log") or [],
+ "reflection": {
+ "previous_decisions": reflection.get("decision_log"),
+ "alpha_vs_benchmark": reflection.get("alpha"),
+ },
+ "status": _status(record.status),
+ "created_at": _iso(record.created_at),
+ "updated_at": _iso(record.updated_at),
+ }
+
+
+def report_markdown(record: Any) -> str:
+ detail = report_detail(record)
+ parts = [
+ f"# {detail['ticker']} 分析报告",
+ "",
+ f"**评级**:{detail['conclusion']['rating']} / 5({detail['conclusion']['rating_label']})",
+ "",
+ detail["conclusion"]["summary"],
+ ]
+ for section in detail["sections"]:
+ parts.extend(["", f"## {section['title']}", "", section.get("content") or section.get("summary") or ""])
+ return "\n".join(parts)
+
+
+def report_json_bytes(record: Any) -> bytes:
+ return json.dumps(report_detail(record), ensure_ascii=False, indent=2, default=str).encode("utf-8")
+
+
+def report_pdf_bytes(record: Any) -> bytes:
+ """Generate a simple multi-page PDF from the report markdown."""
+ lines = _wrapped_pdf_lines(report_markdown(record))
+ if not lines:
+ lines = ["Report is empty."]
+
+ lines_per_page = 45
+ pages = [lines[index:index + lines_per_page] for index in range(0, len(lines), lines_per_page)]
+ total_pages = len(pages)
+ font_id = 3 + total_pages * 2
+ cid_font_id = font_id + 1
+ descriptor_id = font_id + 2
+ max_object_id = descriptor_id
+ objects: Dict[int, bytes] = {}
+
+ page_ids = [3 + index * 2 for index in range(total_pages)]
+ content_ids = [4 + index * 2 for index in range(total_pages)]
+ objects[1] = b"<< /Type /Catalog /Pages 2 0 R >>"
+ objects[2] = (
+ f"<< /Type /Pages /Kids [{' '.join(f'{page_id} 0 R' for page_id in page_ids)}] "
+ f"/Count {total_pages} >>"
+ ).encode("ascii")
+
+ for index, page_lines in enumerate(pages):
+ content = _pdf_page_content(page_lines, page_number=index + 1, total_pages=total_pages)
+ content_id = content_ids[index]
+ page_id = page_ids[index]
+ objects[content_id] = (
+ f"<< /Length {len(content)} >>\nstream\n".encode("ascii")
+ + content
+ + b"\nendstream"
+ )
+ objects[page_id] = (
+ f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] "
+ f"/Resources << /Font << /F1 {font_id} 0 R >> >> "
+ f"/Contents {content_id} 0 R >>"
+ ).encode("ascii")
+
+ objects[font_id] = (
+ f"<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light "
+ f"/Encoding /UniGB-UCS2-H /DescendantFonts [{cid_font_id} 0 R] >>"
+ ).encode("ascii")
+ objects[cid_font_id] = (
+ f"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light "
+ f"/CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> "
+ f"/FontDescriptor {descriptor_id} 0 R /DW 1000 >>"
+ ).encode("ascii")
+ objects[descriptor_id] = (
+ b"<< /Type /FontDescriptor /FontName /STSong-Light /Flags 4 "
+ b"/FontBBox [0 -120 1000 880] /ItalicAngle 0 /Ascent 880 "
+ b"/Descent -120 /CapHeight 700 /StemV 80 >>"
+ )
+
+ return _build_pdf(objects, max_object_id)
+
+
+def _status(status: str) -> str:
+ if status == "completed":
+ return "completed"
+ if status in {"error", "interrupted"}:
+ return "failed"
+ return "partial"
+
+
+def _wrapped_pdf_lines(markdown: str, width: int = 52) -> List[str]:
+ wrapped: List[str] = []
+ for raw_line in markdown.splitlines():
+ line = raw_line.replace("\t", " ").strip()
+ if not line:
+ wrapped.append("")
+ continue
+ while len(line) > width:
+ wrapped.append(line[:width])
+ line = line[width:]
+ wrapped.append(line)
+ return wrapped
+
+
+def _pdf_text_hex(text: str) -> str:
+ return "FEFF" + text.encode("utf-16-be", errors="replace").hex().upper()
+
+
+def _pdf_page_content(lines: List[str], *, page_number: int, total_pages: int) -> bytes:
+ commands = ["BT", "/F1 11 Tf", "50 790 Td", "16 TL"]
+ for index, line in enumerate(lines):
+ if index:
+ commands.append("T*")
+ commands.append(f"<{_pdf_text_hex(line)}> Tj")
+ commands.extend([
+ "ET",
+ "BT",
+ "/F1 9 Tf",
+ "50 32 Td",
+ f"<{_pdf_text_hex(f'Page {page_number} / {total_pages}')}> Tj",
+ "ET",
+ ])
+ return "\n".join(commands).encode("ascii")
+
+
+def _build_pdf(objects: Dict[int, bytes], max_object_id: int) -> bytes:
+ output = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
+ offsets = [0]
+ for object_id in range(1, max_object_id + 1):
+ offsets.append(len(output))
+ output.extend(f"{object_id} 0 obj\n".encode("ascii"))
+ output.extend(objects[object_id])
+ output.extend(b"\nendobj\n")
+
+ xref_offset = len(output)
+ output.extend(f"xref\n0 {max_object_id + 1}\n".encode("ascii"))
+ output.extend(b"0000000000 65535 f \n")
+ for offset in offsets[1:]:
+ output.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
+ output.extend(
+ f"trailer\n<< /Size {max_object_id + 1} /Root 1 0 R >>\n"
+ f"startxref\n{xref_offset}\n%%EOF\n"
+ .encode("ascii")
+ )
+ return bytes(output)
diff --git a/web/backend/services/skills/__init__.py b/web/backend/services/skills/__init__.py
new file mode 100644
index 0000000..d6f09ae
--- /dev/null
+++ b/web/backend/services/skills/__init__.py
@@ -0,0 +1,5 @@
+"""Internal Skills abstraction for TradingAgents data collection."""
+
+from .registry import get_skill_registry
+
+__all__ = ["get_skill_registry"]
diff --git a/web/backend/services/skills/base.py b/web/backend/services/skills/base.py
new file mode 100644
index 0000000..0f749ec
--- /dev/null
+++ b/web/backend/services/skills/base.py
@@ -0,0 +1,189 @@
+"""Skill provider contracts and health DTOs."""
+
+from __future__ import annotations
+
+import os
+import queue
+import threading
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from typing import Any, Callable, Dict, List, Protocol
+
+
+_EVENT_CONTEXT = threading.local()
+
+
+class SkillProviderExecutionError(RuntimeError):
+ """Raised when a strict skill action cannot return degraded data."""
+
+
+class SkillProviderTimeoutError(SkillProviderExecutionError):
+ """Raised when a skill action exceeds its configured timeout."""
+
+
+def _default_timeout_seconds() -> float:
+ raw_value = os.getenv("TRADINGAGENTS_SKILL_TIMEOUT_SECONDS", "20")
+ try:
+ return max(0.1, float(raw_value))
+ except (TypeError, ValueError):
+ return 20.0
+
+
+def set_skill_event_sink(sink: Callable[[Dict[str, Any]], None] | None) -> None:
+ """Register a per-thread sink for skill warning/error events."""
+ _EVENT_CONTEXT.sink = sink
+
+
+def clear_skill_event_sink() -> None:
+ if hasattr(_EVENT_CONTEXT, "sink"):
+ delattr(_EVENT_CONTEXT, "sink")
+
+
+def _emit_skill_event(event: Dict[str, Any]) -> None:
+ sink = getattr(_EVENT_CONTEXT, "sink", None)
+ if not sink:
+ return
+ try:
+ sink(event)
+ except Exception as exc:
+ print(f"WARNING: skill event sink failed: {exc}")
+
+
+class SkillProvider(Protocol):
+ name: str
+ display_name: str
+ description: str
+ input_schema: Dict[str, Any]
+ providers: List[str]
+ markets: List[str]
+
+ def health(self) -> Dict[str, Any]:
+ ...
+
+ def execute(self, action: str, **kwargs: Any) -> Any:
+ ...
+
+
+@dataclass
+class RoutedSkillProvider:
+ name: str
+ display_name: str
+ description: str
+ input_schema: Dict[str, Any]
+ providers: List[str]
+ markets: List[str]
+ actions: Dict[str, Callable[..., Any]]
+ primary_source: str
+ fallback_source: str | None = None
+ timeout_seconds: float = field(default_factory=_default_timeout_seconds)
+ fallback_message: str | None = (
+ "[DATA_SOURCE_DEGRADED] Skill '{skill}' action '{action}' is temporarily unavailable. "
+ "Continue with available context and lower confidence. Error: {error}"
+ )
+ last_error: str | None = None
+ last_checked_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
+
+ def health(self) -> Dict[str, Any]:
+ self.last_checked_at = datetime.now(timezone.utc).isoformat()
+ status = "healthy" if self.actions else "unavailable"
+ if self.last_error and self.actions:
+ status = "degraded"
+ return {
+ "name": self.name,
+ "display_name": self.display_name,
+ "description": self.description,
+ "status": status,
+ "primary_source": self.primary_source,
+ "fallback_source": self.fallback_source,
+ "markets": self.markets,
+ "last_error": self.last_error,
+ "last_checked_at": self.last_checked_at,
+ "input_schema": self.input_schema,
+ "providers": self.providers,
+ }
+
+ def execute(self, action: str, **kwargs: Any) -> Any:
+ if action not in self.actions:
+ raise ValueError(f"Skill '{self.name}' does not support action '{action}'")
+ action_func = self.actions[action]
+ try:
+ result = self._run_with_timeout(action, action_func, kwargs)
+ self.last_error = None
+ return result
+ except Exception as exc:
+ self.last_error = str(exc)
+ if self.fallback_message is None:
+ self._emit_failure_event(action, exc, severity="error", partial=False)
+ if isinstance(exc, SkillProviderExecutionError):
+ raise
+ raise SkillProviderExecutionError(str(exc)) from exc
+
+ self._emit_failure_event(action, exc, severity="warning", partial=True)
+ return self._fallback_text(action, exc)
+
+ def _run_with_timeout(self, action: str, action_func: Callable[..., Any], kwargs: Dict[str, Any]) -> Any:
+ timeout = self.timeout_seconds
+ if timeout <= 0:
+ return action_func(**kwargs)
+
+ result_queue: queue.Queue[tuple[str, Any]] = queue.Queue(maxsize=1)
+
+ def worker() -> None:
+ try:
+ result_queue.put(("result", action_func(**kwargs)))
+ except BaseException as exc:
+ result_queue.put(("error", exc))
+
+ thread = threading.Thread(
+ target=worker,
+ name=f"skill-{self.name}-{action}",
+ daemon=True,
+ )
+ thread.start()
+ thread.join(timeout)
+
+ if thread.is_alive():
+ raise SkillProviderTimeoutError(
+ f"Skill '{self.name}' action '{action}' timed out after {timeout:.1f}s"
+ )
+
+ try:
+ kind, payload = result_queue.get_nowait()
+ except queue.Empty as exc:
+ raise SkillProviderExecutionError(
+ f"Skill '{self.name}' action '{action}' finished without a result"
+ ) from exc
+
+ if kind == "error":
+ raise payload
+ return payload
+
+ def _fallback_text(self, action: str, exc: Exception) -> str:
+ fallback = self.fallback_message or (
+ "[DATA_SOURCE_DEGRADED] Skill '{skill}' action '{action}' is temporarily unavailable. "
+ "Continue with available context and lower confidence. Error: {error}"
+ )
+ try:
+ return fallback.format(skill=self.name, action=action, error=str(exc))
+ except Exception:
+ return fallback
+
+ def _emit_failure_event(self, action: str, exc: Exception, *, severity: str, partial: bool) -> None:
+ message = (
+ f"Skill '{self.name}' action '{action}' degraded: {exc}"
+ if severity == "warning"
+ else f"Skill '{self.name}' action '{action}' failed: {exc}"
+ )
+ _emit_skill_event({
+ "severity": severity,
+ "skill": self.name,
+ "action": action,
+ "message": message,
+ "error": str(exc),
+ "error_type": type(exc).__name__,
+ "partial": partial,
+ "retryable": True,
+ "primary_source": self.primary_source,
+ "fallback_source": self.fallback_source,
+ "occurred_at": datetime.now(timezone.utc).isoformat(),
+ })
diff --git a/web/backend/services/skills/registry.py b/web/backend/services/skills/registry.py
new file mode 100644
index 0000000..106c693
--- /dev/null
+++ b/web/backend/services/skills/registry.py
@@ -0,0 +1,167 @@
+"""Registry for internal data collection skills."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from typing import Any, Dict
+
+from web.backend.services.skills.base import RoutedSkillProvider
+from web.backend.utils.market_detector import detect_market, normalize_ticker_with_suffix
+
+
+def _schema(required: list[str], properties: Dict[str, Any]) -> Dict[str, Any]:
+ return {"type": "object", "required": required, "properties": properties}
+
+
+def _start_date(curr_date: str, look_back_days: int) -> str:
+ current = datetime.strptime(curr_date, "%Y-%m-%d").date()
+ return (current - timedelta(days=int(look_back_days))).isoformat()
+
+
+def _route_to_vendor(method: str, *args: Any) -> Any:
+ from tradingagents.dataflows.interface import route_to_vendor
+
+ return route_to_vendor(method, *args)
+
+
+class SkillRegistry:
+ def __init__(self) -> None:
+ self._skills = self._build_skills()
+
+ def _build_skills(self) -> Dict[str, RoutedSkillProvider]:
+ common_symbol = {
+ "symbol": {"type": "string", "description": "Ticker symbol, e.g. AAPL, 0700.HK, 600519.SH"},
+ "curr_date": {"type": "string", "format": "date"},
+ }
+ return {
+ "market-data": RoutedSkillProvider(
+ name="market-data",
+ display_name="K线/行情",
+ description="获取 OHLCV、实时报价、历史行情数据",
+ input_schema=_schema(["symbol", "curr_date"], common_symbol),
+ providers=["yfinance", "akshare", "baostock", "alpha_vantage"],
+ primary_source="yfinance",
+ fallback_source="akshare",
+ markets=["US", "HK", "CN"],
+ actions={
+ "historical": lambda symbol, curr_date, look_back_days=30, **_: _route_to_vendor(
+ "get_stock_data", symbol, _start_date(curr_date, look_back_days), curr_date
+ ),
+ "quote": lambda symbol, **_: _route_to_vendor("get_realtime_quote", symbol),
+ },
+ ),
+ "technical-indicators": RoutedSkillProvider(
+ name="technical-indicators",
+ display_name="技术指标",
+ description="计算 MACD/RSI/布林带等指标",
+ input_schema=_schema(["symbol", "indicator", "curr_date"], {
+ **common_symbol,
+ "indicator": {"type": "string"},
+ "look_back_days": {"type": "integer", "default": 30},
+ }),
+ providers=["yfinance", "akshare", "baostock", "alpha_vantage"],
+ primary_source="yfinance",
+ fallback_source="alpha_vantage",
+ markets=["US", "HK", "CN"],
+ actions={
+ "indicator": lambda symbol, indicator, curr_date, look_back_days=30, **_: _route_to_vendor(
+ "get_indicators", symbol, indicator, curr_date, look_back_days
+ ),
+ },
+ ),
+ "fundamentals": RoutedSkillProvider(
+ name="fundamentals",
+ display_name="基本面/投资信息",
+ description="基本面、资产负债表、现金流、利润表",
+ input_schema=_schema(["ticker"], {"ticker": {"type": "string"}}),
+ providers=["alpha_vantage", "akshare", "baostock", "yfinance"],
+ primary_source="alpha_vantage",
+ fallback_source="akshare",
+ markets=["US", "HK", "CN"],
+ actions={
+ "summary": lambda ticker, curr_date=None, **_: _route_to_vendor("get_fundamentals", ticker, curr_date or datetime.now(timezone.utc).date().isoformat()),
+ "balance_sheet": lambda ticker, curr_date=None, freq="quarterly", **_: _route_to_vendor("get_balance_sheet", ticker, freq, curr_date),
+ "cashflow": lambda ticker, curr_date=None, freq="quarterly", **_: _route_to_vendor("get_cashflow", ticker, freq, curr_date),
+ "income_statement": lambda ticker, curr_date=None, freq="quarterly", **_: _route_to_vendor("get_income_statement", ticker, freq, curr_date),
+ },
+ ),
+ "news": RoutedSkillProvider(
+ name="news",
+ display_name="新闻/宏观",
+ description="公司新闻、全球宏观新闻",
+ input_schema=_schema(["ticker", "start_date", "end_date"], {
+ "ticker": {"type": "string"},
+ "start_date": {"type": "string", "format": "date"},
+ "end_date": {"type": "string", "format": "date"},
+ }),
+ providers=["google", "akshare", "baostock", "alpha_vantage"],
+ primary_source="google",
+ fallback_source="akshare",
+ markets=["US", "HK", "CN"],
+ actions={
+ "company": lambda ticker, start_date, end_date, **_: _route_to_vendor(
+ "get_news", ticker, start_date, end_date
+ ),
+ "global": lambda curr_date=None, look_back_days=7, limit=5, **_: _route_to_vendor("get_global_news", curr_date or datetime.now(timezone.utc).date().isoformat(), look_back_days, limit),
+ },
+ ),
+ "social-sentiment": RoutedSkillProvider(
+ name="social-sentiment",
+ display_name="社交舆情",
+ description="社交帖子、情绪分析,并要求结论锚定数据快照",
+ input_schema=_schema(["ticker", "start_date", "end_date"], {
+ "ticker": {"type": "string"},
+ "start_date": {"type": "string", "format": "date"},
+ "end_date": {"type": "string", "format": "date"},
+ }),
+ providers=["akshare", "baostock", "alpha_vantage", "google"],
+ primary_source="akshare",
+ fallback_source="alpha_vantage",
+ markets=["US", "HK", "CN"],
+ actions={
+ "sentiment": lambda ticker, start_date, end_date, **_: _route_to_vendor(
+ "get_news", ticker, start_date, end_date
+ ),
+ },
+ ),
+ "market-detection": RoutedSkillProvider(
+ name="market-detection",
+ display_name="市场识别",
+ description="标的→市场识别、后缀规范化",
+ input_schema=_schema(["symbol"], {"symbol": {"type": "string"}}),
+ providers=["internal"],
+ primary_source="internal",
+ fallback_source=None,
+ markets=["US", "HK", "CN"],
+ actions={
+ "detect": lambda symbol, **_: {
+ "symbol": symbol,
+ "normalized_symbol": normalize_ticker_with_suffix(symbol),
+ "market": detect_market(symbol),
+ },
+ },
+ timeout_seconds=1.0,
+ fallback_message=None,
+ ),
+ }
+
+ def list_health(self) -> Dict[str, Any]:
+ return {
+ "skills": [skill.health() for skill in self._skills.values()],
+ "updated_at": datetime.now(timezone.utc).isoformat(),
+ }
+
+ def get(self, name: str) -> RoutedSkillProvider:
+ if name not in self._skills:
+ raise KeyError(f"Unknown skill: {name}")
+ return self._skills[name]
+
+ def execute(self, name: str, action: str, **kwargs: Any) -> Any:
+ return self.get(name).execute(action, **kwargs)
+
+
+_REGISTRY = SkillRegistry()
+
+
+def get_skill_registry() -> SkillRegistry:
+ return _REGISTRY
diff --git a/web/backend/services/snapshot_scheduler.py b/web/backend/services/snapshot_scheduler.py
deleted file mode 100644
index ca99e18..0000000
--- a/web/backend/services/snapshot_scheduler.py
+++ /dev/null
@@ -1,494 +0,0 @@
-#!/usr/bin/env python3
-"""
-Asset Snapshot Scheduler Service
-
-Automatically creates daily account snapshots at market close times.
-Supports multiple markets (US, HK, CN) with different close times.
-Handles timezone conversions and daylight saving time automatically.
-"""
-
-import logging
-from datetime import datetime, time
-from typing import Optional
-from apscheduler.schedulers.asyncio import AsyncIOScheduler
-from apscheduler.triggers.cron import CronTrigger
-from apscheduler.job import Job
-import pytz
-
-logger = logging.getLogger(__name__)
-
-
-class SnapshotScheduler:
- """Scheduler for automatic daily account snapshots"""
-
- # Market close times in their local timezones
- # APScheduler will automatically handle DST conversions
- MARKET_CLOSE_TIMES = {
- 'US': {
- 'hour': 16, # 4:00 PM Eastern Time
- 'minute': 0,
- 'timezone': 'America/New_York', # Handles EDT/EST automatically
- 'description': '美东时间 16:00 (自动处理夏令时/冬令时)'
- },
- 'HK': {
- 'hour': 16, # 4:00 PM Hong Kong Time
- 'minute': 0,
- 'timezone': 'Asia/Hong_Kong',
- 'description': '香港时间 16:00'
- },
- 'CN': {
- 'hour': 15, # 3:00 PM China Standard Time
- 'minute': 0,
- 'timezone': 'Asia/Shanghai',
- 'description': '北京时间 15:00'
- },
- }
-
- def __init__(self):
- """Initialize snapshot scheduler"""
- # Use UTC as base timezone, individual jobs will use their market timezones
- self.scheduler = AsyncIOScheduler(timezone='UTC')
- self._started = False
- logger.info("SnapshotScheduler initialized")
-
- def start(self):
- """Start the scheduler and register snapshot jobs"""
- if not self._started:
- # Register snapshot jobs for each market
- for market_type, close_time in self.MARKET_CLOSE_TIMES.items():
- self._register_snapshot_job(market_type, close_time)
-
- self.scheduler.start()
- self._started = True
- logger.info("✅ Snapshot scheduler started")
- self._print_scheduled_jobs()
-
- def shutdown(self, wait: bool = True):
- """
- Shutdown the scheduler
-
- Args:
- wait: Whether to wait for running jobs to complete
- """
- if self._started:
- self.scheduler.shutdown(wait=wait)
- self._started = False
- logger.info("✅ Snapshot scheduler stopped")
-
- def _register_snapshot_job(self, market_type: str, close_time: dict):
- """
- Register a daily snapshot job for a market
-
- Args:
- market_type: Market type (US, HK, CN)
- close_time: Dict with 'hour', 'minute', 'timezone', and 'description' keys
- """
- job_id = f"snapshot_{market_type.lower()}"
-
- # Create cron trigger for daily execution at market close
- # Using market's local timezone - APScheduler handles DST automatically
- trigger = CronTrigger(
- hour=close_time['hour'],
- minute=close_time['minute'],
- timezone=close_time['timezone'] # Use market's local timezone
- )
-
- # Add job to scheduler
- job = self.scheduler.add_job(
- func=self._create_snapshots_for_market,
- trigger=trigger,
- args=[market_type],
- id=job_id,
- name=f"Daily {market_type} Market Snapshot",
- replace_existing=True
- )
-
- # Get next run time in both local and Beijing time for logging
- try:
- next_run = getattr(job, 'next_run_time', None)
- if next_run:
- beijing_tz = pytz.timezone('Asia/Shanghai')
- next_run_beijing = next_run.astimezone(beijing_tz)
-
- logger.info(
- f"Registered snapshot job for {market_type} market: "
- f"{close_time['description']}"
- )
- logger.info(
- f" Next run: {next_run_beijing.strftime('%Y-%m-%d %H:%M:%S %Z')} "
- f"(Beijing time)"
- )
- else:
- logger.info(
- f"Registered snapshot job for {market_type} market: "
- f"{close_time['description']}"
- )
- except Exception as e:
- logger.warning(f"Could not get next run time: {e}")
- logger.info(
- f"Registered snapshot job for {market_type} market: "
- f"{close_time['description']}"
- )
-
- async def _create_snapshots_for_market(self, market_type: str):
- """
- Create snapshots for all users with positions in the specified market
-
- Args:
- market_type: Market type (US, HK, CN)
- """
- try:
- logger.info(f"Creating {market_type} market snapshots...")
-
- from web.backend.database import AsyncSessionLocal
- from web.backend.models import User, UserConfig, AccountSnapshot
- from web.backend.services.futu_async_wrapper import get_account_info_async
- from sqlalchemy import select
- from datetime import datetime
-
- async with AsyncSessionLocal() as db:
- # Get all users with Futu API configured AND intraday scheduler enabled
- # Check both intraday_futu_api_url and futu_api_base_url for backward compatibility
- result = await db.execute(
- select(User, UserConfig)
- .join(UserConfig, User.id == UserConfig.user_id)
- .where(
- (UserConfig.intraday_futu_api_url.isnot(None)) | (UserConfig.futu_api_base_url.isnot(None)),
- UserConfig.intraday_scheduler_auto_start == True # Only users with scheduler enabled
- )
- )
- users_with_config = result.all()
-
- snapshot_count = 0
- error_count = 0
-
- logger.info(f"Found {len(users_with_config)} users with Futu API configured and scheduler enabled")
-
- for user, config in users_with_config:
- try:
- # Skip if user doesn't have intraday trading access
- if user.role != 'admin' and not user.can_access_intraday_trading:
- logger.info(f"Skipping user {user.id} ({user.username}): no intraday trading access")
- continue
-
- # Get account info for the market (user_id will be used to fetch user-specific config)
- account_info = await get_account_info_async(market_type, user_id=user.id)
- if not account_info:
- logger.warning(f"No account info for user {user.id} in {market_type} market")
- continue
-
- # Extract account data - use correct field names from Futu API response
- # API returns: net_asset, cash, market_value, profit_loss, today_profit_loss
- total_assets = account_info.get("net_asset", 0.0)
- cash = account_info.get("cash", 0.0)
- market_value = account_info.get("market_value", 0.0)
-
- # Use profit_loss from API (total unrealized P&L)
- # Use today_profit_loss as realized P&L for the day
- unrealized_pnl = account_info.get("profit_loss", 0.0)
- realized_pnl = account_info.get("today_profit_loss", 0.0)
-
- # Get positions data with company names
- from web.backend.services.futu_async_wrapper import get_positions_async
- positions = await get_positions_async(market_type, user_id=user.id)
-
- # Format positions data for storage (include company name)
- positions_data = []
- if positions:
- for pos in positions:
- positions_data.append({
- 'stock_code': pos.get('stock_code', ''),
- 'stock_name': pos.get('stock_name', ''), # 公司名称
- 'quantity': pos.get('quantity', 0),
- 'cost_price': pos.get('cost_price', 0.0),
- 'current_price': pos.get('current_price', 0.0),
- 'market_value': pos.get('market_value', 0.0),
- 'unrealized_pnl': pos.get('profit_loss', 0.0),
- 'first_open_time': pos.get('first_open_time'),
- 'holding_days': pos.get('holding_days', 0)
- })
-
- # Get market timezone and current local time
- market_tz_map = {
- 'US': 'America/New_York',
- 'HK': 'Asia/Hong_Kong',
- 'CN': 'Asia/Shanghai'
- }
- market_tz = pytz.timezone(market_tz_map.get(market_type, 'UTC'))
- local_now = datetime.now(market_tz)
-
- # Create snapshot with market local time (naive datetime for SQLite)
- # SQLite doesn't preserve timezone, so we store as naive datetime in local time
- snapshot = AccountSnapshot(
- user_id=user.id,
- market_type=market_type,
- snapshot_date=local_now.replace(tzinfo=None), # Store as naive datetime in local time
- total_assets=total_assets,
- cash=cash,
- market_value=market_value,
- realized_pnl=realized_pnl,
- unrealized_pnl=unrealized_pnl,
- account_data=None, # No need to store currency, frontend determines it
- positions_data=positions_data # 保存持仓数据(包含公司名称)
- )
-
- db.add(snapshot)
- await db.commit()
-
- snapshot_count += 1
- logger.info(
- f"Created snapshot for user {user.id} ({user.username}) "
- f"in {market_type} market: ${total_assets:.2f}"
- )
-
- except Exception as e:
- error_count += 1
- logger.error(
- f"Error creating snapshot for user {user.id} in {market_type} market: {e}",
- exc_info=True
- )
- continue
-
- logger.info(
- f"✅ {market_type} market snapshot job completed: "
- f"{snapshot_count} created, {error_count} errors"
- )
-
- except Exception as e:
- logger.error(f"Error in snapshot job for {market_type} market: {e}", exc_info=True)
-
- def get_job(self, market_type: str) -> Optional[Job]:
- """
- Get snapshot job for a market
-
- Args:
- market_type: Market type (US, HK, CN)
-
- Returns:
- Job object or None if not found
- """
- job_id = f"snapshot_{market_type.lower()}"
- return self.scheduler.get_job(job_id)
-
- def get_next_run_time(self, market_type: str) -> Optional[datetime]:
- """
- Get next run time for a market's snapshot job
-
- Args:
- market_type: Market type (US, HK, CN)
-
- Returns:
- Next run time or None if not found
- """
- job = self.get_job(market_type)
- if job:
- try:
- return getattr(job, 'next_run_time', None)
- except Exception:
- return None
- return None
-
- def _print_scheduled_jobs(self):
- """Print all scheduled snapshot jobs with timezone information"""
- jobs = self.scheduler.get_jobs()
- if not jobs:
- logger.info("No snapshot jobs scheduled")
- return
-
- beijing_tz = pytz.timezone('Asia/Shanghai')
- logger.info(f"📸 Scheduled snapshot jobs ({len(jobs)}):")
- for job in jobs:
- try:
- next_run = getattr(job, 'next_run_time', None)
- if next_run:
- # Show time in both original timezone and Beijing time
- next_run_beijing = next_run.astimezone(beijing_tz)
- logger.info(
- f" - {job.name}:\n"
- f" Next run: {next_run.strftime('%Y-%m-%d %H:%M:%S %Z')}\n"
- f" Beijing: {next_run_beijing.strftime('%Y-%m-%d %H:%M:%S %Z')}"
- )
- else:
- logger.info(f" - {job.name}: Scheduled (next run time not available)")
- except Exception as e:
- logger.warning(f" - {job.name}: Could not get next run time ({e})")
-
-
-# Global scheduler instance
-_snapshot_scheduler: Optional[SnapshotScheduler] = None
-
-
-def get_snapshot_scheduler() -> SnapshotScheduler:
- """Get or create global snapshot scheduler instance"""
- global _snapshot_scheduler
- if _snapshot_scheduler is None:
- _snapshot_scheduler = SnapshotScheduler()
- return _snapshot_scheduler
-
-
-def init_snapshot_scheduler() -> SnapshotScheduler:
- """Initialize and start the snapshot scheduler"""
- scheduler = get_snapshot_scheduler()
- if not scheduler._started:
- scheduler.start()
- return scheduler
-
-
-
-# Standalone function to create snapshot for a specific user and market
-async def create_account_snapshot(user_id: int, market_type: str, skip_market_check: bool = False) -> bool:
- """
- Create an account snapshot for a specific user and market.
-
- This function can be called from anywhere (e.g., after intraday trading analysis).
-
- Args:
- user_id: User ID
- market_type: Market type (US, HK, CN)
- skip_market_check: If True, create snapshot regardless of market status (default: False)
- Set to True when called after intraday analysis completion
-
- Returns:
- bool: True if snapshot created successfully, False otherwise
- """
- try:
- from web.backend.database import AsyncSessionLocal
- from web.backend.models import User, UserConfig, AccountSnapshot
- from web.backend.services.futu_async_wrapper import get_account_info_async
- from sqlalchemy import select
- from tradingagents.agents.utils.market_utils import is_market_open
-
- # Get market timezone and current time
- market_tz_map = {
- 'US': 'America/New_York',
- 'HK': 'Asia/Hong_Kong',
- 'CN': 'Asia/Shanghai'
- }
- market_tz_name = market_tz_map.get(market_type.upper(), 'UTC')
- market_tz = pytz.timezone(market_tz_name)
- market_now = datetime.now(market_tz)
-
- # Check if market is open (unless skip_market_check is True)
- if not skip_market_check:
- is_open, status_msg = is_market_open(market_type, market_now)
- if not is_open:
- logger.info(f"Market {market_type} is closed, skipping snapshot: {status_msg}")
- return False
-
- async with AsyncSessionLocal() as db:
- # Get user
- result = await db.execute(
- select(User).where(User.id == user_id)
- )
- user = result.scalar_one_or_none()
-
- if not user:
- logger.warning(f"User {user_id} not found")
- return False
-
- # Check if user has access
- if user.role != 'admin' and not user.can_access_intraday_trading:
- logger.warning(f"User {user_id} does not have intraday trading access")
- return False
-
- # Get user config
- result = await db.execute(
- select(UserConfig).where(UserConfig.user_id == user_id)
- )
- user_config = result.scalar_one_or_none()
-
- if not user_config or not (user_config.intraday_futu_api_url or user_config.futu_api_base_url):
- logger.warning(f"User {user_id} does not have Futu API configured")
- return False
-
- # Get account info
- account_info = await get_account_info_async(market_type, user_id=user_id)
- if not account_info:
- logger.warning(f"No account info for user {user_id} in {market_type} market")
- return False
-
- # Extract account data
- total_assets = account_info.get("net_asset", 0.0)
- cash = account_info.get("cash", 0.0)
- market_value = account_info.get("market_value", 0.0)
- unrealized_pnl = account_info.get("profit_loss", 0.0)
- realized_pnl = account_info.get("today_profit_loss", 0.0)
-
- # Get positions data with company names
- from web.backend.services.futu_async_wrapper import get_positions_async
- positions = await get_positions_async(market_type, user_id=user_id)
-
- # Format positions data for storage (include company name)
- positions_data = []
- if positions:
- for pos in positions:
- positions_data.append({
- 'stock_code': pos.get('stock_code', ''),
- 'stock_name': pos.get('stock_name', ''), # 公司名称
- 'quantity': pos.get('quantity', 0),
- 'cost_price': pos.get('cost_price', 0.0),
- 'current_price': pos.get('current_price', 0.0),
- 'market_value': pos.get('market_value', 0.0),
- 'unrealized_pnl': pos.get('profit_loss', 0.0),
- 'first_open_time': pos.get('first_open_time'),
- 'holding_days': pos.get('holding_days', 0)
- })
-
- # Get market local time
- local_now = datetime.now(market_tz)
- # Round to nearest second to avoid microsecond differences
- snapshot_date_naive = local_now.replace(tzinfo=None, microsecond=0)
-
- # Check if a snapshot already exists at this exact time (same second)
- # This allows multiple snapshots per day at different times
- from sqlalchemy import and_
-
- existing_query = select(AccountSnapshot).where(
- and_(
- AccountSnapshot.user_id == user_id,
- AccountSnapshot.market_type == market_type.upper(),
- AccountSnapshot.snapshot_date == snapshot_date_naive
- )
- )
-
- existing_result = await db.execute(existing_query)
- existing_snapshot = existing_result.scalar_one_or_none()
-
- if existing_snapshot:
- # Update existing snapshot at this exact time
- existing_snapshot.total_assets = total_assets
- existing_snapshot.cash = cash
- existing_snapshot.market_value = market_value
- existing_snapshot.realized_pnl = realized_pnl
- existing_snapshot.unrealized_pnl = unrealized_pnl
- existing_snapshot.account_data = None
- existing_snapshot.positions_data = positions_data # 更新持仓数据
-
- await db.commit()
- logger.info(f"✅ Updated existing snapshot for user {user_id} in {market_type} market at {snapshot_date_naive} (ID: {existing_snapshot.id})")
- return True
- else:
- # Create new snapshot with market local time (naive datetime for SQLite)
- # SQLite doesn't preserve timezone, so we store as naive datetime in local time
- snapshot = AccountSnapshot(
- user_id=user_id,
- market_type=market_type.upper(),
- snapshot_date=snapshot_date_naive, # Store as naive datetime in local time (no microseconds)
- total_assets=total_assets,
- cash=cash,
- market_value=market_value,
- realized_pnl=realized_pnl,
- unrealized_pnl=unrealized_pnl,
- account_data=None,
- positions_data=positions_data # 保存持仓数据(包含公司名称)
- )
-
- db.add(snapshot)
- await db.commit()
-
- logger.info(f"✅ Created new snapshot for user {user_id} in {market_type} market at {snapshot_date_naive}")
- return True
-
- except Exception as e:
- logger.error(f"Error creating snapshot for user {user_id}: {e}")
- return False
diff --git a/web/backend/services/task_executor.py b/web/backend/services/task_executor.py
index df73d89..980a0b6 100644
--- a/web/backend/services/task_executor.py
+++ b/web/backend/services/task_executor.py
@@ -91,13 +91,6 @@ def execute_scheduled_task(scheduled_task_id: int):
user_config.last_shallow_thinker = task.shallow_thinker
user_config.last_deep_thinker = task.deep_thinker
user_config.last_backend_url = task.backend_url
- user_config.enable_trading_executor = task.enable_trading_executor
-
- # Update Futu API config if available
- if task.futu_api_base_url:
- user_config.futu_api_base_url = task.futu_api_base_url
- if task.futu_api_key:
- user_config.futu_api_key = task.futu_api_key
db.commit()
print(f"✅ Updated user configuration cache for user {task.user_id}")
@@ -129,9 +122,6 @@ def execute_scheduled_task(scheduled_task_id: int):
backend_url=task.backend_url,
api_key=task.api_key, # Copy API key from scheduled task
is_public=task.is_public,
- enable_trading_executor=task.enable_trading_executor, # Copy from scheduled task
- futu_api_base_url=task.futu_api_base_url, # Copy Futu API config
- futu_api_key=task.futu_api_key, # Copy Futu API key
email_notification_enabled=task.email_notification_enabled, # Copy email notification setting
status="queued"
)
@@ -156,9 +146,6 @@ def execute_scheduled_task(scheduled_task_id: int):
'deep_thinker': task.deep_thinker,
'analysis_date': now_beijing.strftime('%Y-%m-%d'),
'api_key': api_key, # Use task's API key, fallback to user config
- 'enable_trading_executor': task.enable_trading_executor,
- 'futu_api_base_url': task.futu_api_base_url,
- 'futu_api_key': task.futu_api_key
}
# Submit task (task_manager handles user-level queuing)
diff --git a/web/backend/services/user_config_cache.py b/web/backend/services/user_config_cache.py
index 696af82..2a698fd 100644
--- a/web/backend/services/user_config_cache.py
+++ b/web/backend/services/user_config_cache.py
@@ -178,20 +178,8 @@ def get_user_config_from_cache(user_id: int) -> Optional[Dict[str, Any]]:
# Convert to dict
config_dict = {
'user_id': user_config.user_id,
- 'futu_api_base_url': user_config.futu_api_base_url,
- 'intraday_futu_api_url': user_config.intraday_futu_api_url,
- 'futu_api_key': user_config.futu_api_key,
- 'intraday_futu_api_key': user_config.intraday_futu_api_key,
'last_llm_provider': user_config.last_llm_provider,
'last_api_key': user_config.last_api_key,
- 'intraday_scheduler_auto_start': user_config.intraday_scheduler_auto_start,
- # Intraday config
- 'intraday_llm_provider': user_config.intraday_llm_provider,
- 'intraday_api_key': user_config.intraday_api_key,
- 'intraday_llm_model': user_config.intraday_llm_model,
- 'intraday_backend_url': user_config.intraday_backend_url,
- 'intraday_interval_minutes': user_config.intraday_interval_minutes,
- 'intraday_market_type': user_config.intraday_market_type,
# Analysis config (fallback)
'last_deep_thinker': user_config.last_deep_thinker,
'last_backend_url': user_config.last_backend_url,
@@ -257,20 +245,8 @@ def preload_user_configs() -> int:
for user_config in user_configs:
config_dict = {
'user_id': user_config.user_id,
- 'futu_api_base_url': user_config.futu_api_base_url,
- 'intraday_futu_api_url': user_config.intraday_futu_api_url,
- 'futu_api_key': user_config.futu_api_key,
- 'intraday_futu_api_key': user_config.intraday_futu_api_key,
'last_llm_provider': user_config.last_llm_provider,
'last_api_key': user_config.last_api_key,
- 'intraday_scheduler_auto_start': user_config.intraday_scheduler_auto_start,
- # Intraday config
- 'intraday_llm_provider': user_config.intraday_llm_provider,
- 'intraday_api_key': user_config.intraday_api_key,
- 'intraday_llm_model': user_config.intraday_llm_model,
- 'intraday_backend_url': user_config.intraday_backend_url,
- 'intraday_interval_minutes': user_config.intraday_interval_minutes,
- 'intraday_market_type': user_config.intraday_market_type,
# Analysis config (fallback)
'last_deep_thinker': user_config.last_deep_thinker,
'last_backend_url': user_config.last_backend_url,
diff --git a/web/backend/services/user_intraday_scheduler.py b/web/backend/services/user_intraday_scheduler.py
deleted file mode 100644
index ed4f96e..0000000
--- a/web/backend/services/user_intraday_scheduler.py
+++ /dev/null
@@ -1,395 +0,0 @@
-#!/usr/bin/env python3
-"""
-User-level Intraday Trading Scheduler Manager
-
-This module manages multiple IntradayScheduler instances, one per user.
-It provides a centralized way to create, start, stop, and configure
-user-specific intraday trading schedulers.
-
-Architecture:
- UserIntradaySchedulerManager (this file)
- └── Manages multiple IntradayScheduler instances
- └── Each IntradayScheduler (intraday_scheduler.py)
- └── Calls IntradayExecutor (intraday_executor.py)
- └── Executes LangGraph agent (intraday_trader.py)
-
-Usage:
- from web.backend.services.user_intraday_scheduler import get_manager
-
- manager = get_manager()
-
- # Create and start a scheduler for a user
- scheduler = await manager.create_scheduler(
- user_id=1,
- interval_minutes=5,
- market_type="US"
- )
- await manager.start_scheduler(user_id=1)
-
- # Stop a scheduler
- await manager.stop_scheduler(user_id=1)
-"""
-
-import asyncio
-import logging
-from typing import Dict, Optional
-from web.backend.services.intraday_scheduler import IntradayScheduler
-
-# Get logger for this module
-logger = logging.getLogger(__name__)
-
-
-class UserIntradaySchedulerManager:
- """
- Manages intraday trading schedulers for multiple users.
- Each user has their own scheduler instance with their own configuration.
- """
-
- def __init__(self):
- self._schedulers: Dict[int, IntradayScheduler] = {}
- self._user_configs: Dict[int, dict] = {}
- logger.info("UserIntradaySchedulerManager initialized")
-
- def get_scheduler(self, user_id: int) -> Optional[IntradayScheduler]:
- """Get scheduler for a specific user"""
- return self._schedulers.get(user_id)
-
- def has_scheduler(self, user_id: int) -> bool:
- """Check if user has a scheduler"""
- return user_id in self._schedulers
-
- async def create_scheduler(
- self,
- user_id: int,
- interval_minutes: int = 5,
- market_type: str = "US,HK,CN",
- futu_api_url: str = None,
- ) -> IntradayScheduler:
- """
- Create a new scheduler for a user.
-
- Args:
- user_id: User ID
- interval_minutes: Analysis interval in minutes
- market_type: Market type. Single market (US/HK/CN) or comma-separated (US,HK,CN)
- futu_api_url: Futu API base URL for this user
-
- Returns:
- IntradayScheduler instance
- """
- # Stop existing scheduler if any
- if user_id in self._schedulers:
- await self.stop_scheduler(user_id)
-
- # Create new scheduler
- scheduler = IntradayScheduler(
- interval_minutes=interval_minutes,
- market_type=market_type,
- user_id=user_id,
- )
-
- self._schedulers[user_id] = scheduler
- self._user_configs[user_id] = {
- 'futu_api_url': futu_api_url,
- 'interval_minutes': interval_minutes,
- 'market_type': market_type,
- }
-
- logger.info(f"Created scheduler for user {user_id}: interval={interval_minutes}min, market={market_type}")
- return scheduler
-
- async def start_scheduler(self, user_id: int) -> bool:
- """
- Start scheduler for a user.
-
- Args:
- user_id: User ID
-
- Returns:
- True if started successfully, False otherwise
- """
- scheduler = self._schedulers.get(user_id)
- if not scheduler:
- logger.error(f"No scheduler found for user {user_id}")
- return False
-
- if scheduler.is_running:
- logger.warning(f"Scheduler for user {user_id} is already running")
- return True
-
- await scheduler.start()
- logger.info(f"Started scheduler for user {user_id}")
- return True
-
- async def stop_scheduler(self, user_id: int) -> bool:
- """
- Stop scheduler for a user.
-
- Args:
- user_id: User ID
-
- Returns:
- True if stopped successfully, False otherwise
- """
- scheduler = self._schedulers.get(user_id)
- if not scheduler:
- logger.warning(f"No scheduler found for user {user_id}")
- return False
-
- if not scheduler.is_running:
- logger.warning(f"Scheduler for user {user_id} is not running")
- return True
-
- await scheduler.stop()
- logger.info(f"Stopped scheduler for user {user_id}")
- return True
-
- async def remove_scheduler(self, user_id: int) -> bool:
- """
- Remove scheduler for a user (stop and delete).
-
- Args:
- user_id: User ID
-
- Returns:
- True if removed successfully, False otherwise
- """
- if user_id in self._schedulers:
- await self.stop_scheduler(user_id)
- del self._schedulers[user_id]
- if user_id in self._user_configs:
- del self._user_configs[user_id]
- logger.info(f"Removed scheduler for user {user_id}")
- return True
- return False
-
- def get_scheduler_status(self, user_id: int) -> Optional[dict]:
- """
- Get scheduler status for a user.
-
- Args:
- user_id: User ID
-
- Returns:
- Status dict or None if scheduler doesn't exist
- """
- scheduler = self._schedulers.get(user_id)
- if not scheduler:
- return None
-
- status = scheduler.get_status()
- # Add user-specific config
- if user_id in self._user_configs:
- status['futu_api_url'] = self._user_configs[user_id].get('futu_api_url')
-
- return status
-
- def update_scheduler_config(
- self,
- user_id: int,
- interval_minutes: Optional[int] = None,
- market_type: Optional[str] = None,
- futu_api_url: Optional[str] = None,
- ) -> bool:
- """
- Update scheduler configuration for a user.
-
- Args:
- user_id: User ID
- interval_minutes: New interval (optional)
- market_type: New market type (optional)
- futu_api_url: New Futu API URL (optional)
-
- Returns:
- True if updated successfully, False otherwise
- """
- scheduler = self._schedulers.get(user_id)
- if not scheduler:
- logger.error(f"No scheduler found for user {user_id}")
- return False
-
- # Update interval
- if interval_minutes is not None:
- scheduler.update_interval(interval_minutes)
- if user_id in self._user_configs:
- self._user_configs[user_id]['interval_minutes'] = interval_minutes
-
- # Update market type
- if market_type is not None:
- scheduler.market_type = market_type
- if user_id in self._user_configs:
- self._user_configs[user_id]['market_type'] = market_type
-
- # Update Futu API URL
- if futu_api_url is not None:
- if user_id in self._user_configs:
- self._user_configs[user_id]['futu_api_url'] = futu_api_url
-
- logger.info(f"Updated scheduler config for user {user_id}")
- return True
-
- def get_user_config(self, user_id: int) -> Optional[dict]:
- """Get user-specific configuration"""
- return self._user_configs.get(user_id)
-
- async def stop_all_schedulers(self):
- """Stop all schedulers (for shutdown)"""
- logger.info("Stopping all user schedulers...")
- for user_id in list(self._schedulers.keys()):
- await self.stop_scheduler(user_id)
- logger.info("All user schedulers stopped")
-
- def get_active_users(self) -> list:
- """Get list of user IDs with active schedulers"""
- return [
- user_id for user_id, scheduler in self._schedulers.items()
- if scheduler.is_running
- ]
-
- def get_all_users(self) -> list:
- """Get list of all user IDs with schedulers"""
- return list(self._schedulers.keys())
-
- async def restore_schedulers_from_db(self):
- """
- Restore schedulers from database on service restart.
- Only restores schedulers that were running when service stopped unexpectedly.
- Adds random delay (0-5 minutes) to avoid concurrent startup.
- """
- try:
- import random
- from web.backend.database import SessionLocal
- from web.backend.models import UserConfig
- from sqlalchemy import select
-
- # Use sync session for startup
- db = SessionLocal()
- try:
- # Find all users with auto_start enabled
- result = db.execute(
- select(UserConfig).where(
- UserConfig.intraday_scheduler_auto_start == True
- )
- )
- configs = result.scalars().all()
-
- if not configs:
- logger.info("No schedulers to restore")
- return
-
- logger.info(f"Found {len(configs)} scheduler(s) to restore")
-
- for config in configs:
- try:
- user_id = config.user_id
- logger.info(f"Restoring scheduler for user {user_id}...")
-
- # Get configuration
- interval_minutes = config.intraday_interval_minutes or 5
- market_type = config.intraday_market_type or "US,HK,CN"
- futu_api_url = config.intraday_futu_api_url or config.futu_api_base_url
-
- if not futu_api_url:
- logger.warning(f"No Futu API URL for user {user_id}, skipping restore")
- # Clear auto_start flag since we can't restore
- config.intraday_scheduler_auto_start = False
- db.commit()
- continue
-
- # Create scheduler
- await self.create_scheduler(
- user_id=user_id,
- interval_minutes=interval_minutes,
- market_type=market_type,
- futu_api_url=futu_api_url,
- )
-
- # Add random delay (0-5 minutes) to avoid concurrent startup
- delay_seconds = random.uniform(0, 300) # 0-300 seconds (0-5 minutes)
- logger.info(f"Scheduler for user {user_id} will start in {delay_seconds:.1f} seconds")
-
- # Schedule delayed start (don't pass db session - it will be closed)
- asyncio.create_task(self._delayed_start_scheduler(user_id, delay_seconds))
-
- except Exception as e:
- logger.error(f"Error restoring scheduler for user {config.user_id}: {e}", exc_info=True)
- # Clear auto_start flag on error
- try:
- config.intraday_scheduler_auto_start = False
- db.commit()
- except:
- pass
-
- logger.info(f"Scheduler restoration initiated. Schedulers will start with random delays.")
-
- finally:
- db.close()
-
- except Exception as e:
- logger.error(f"Error restoring schedulers from database: {e}", exc_info=True)
-
- async def _delayed_start_scheduler(self, user_id: int, delay_seconds: float):
- """
- Start scheduler after a delay.
-
- Args:
- user_id: User ID
- delay_seconds: Delay in seconds before starting
- """
- try:
- # Wait for the delay
- await asyncio.sleep(delay_seconds)
-
- # Start scheduler
- success = await self.start_scheduler(user_id)
- if success:
- logger.info(f"✅ Restored scheduler for user {user_id} after {delay_seconds:.1f}s delay")
- # Keep auto_start flag enabled for next restart (already set in DB)
- else:
- logger.error(f"❌ Failed to restore scheduler for user {user_id}")
- # Clear auto_start flag on failure using a new session
- await self._clear_auto_start_flag(user_id)
-
- except Exception as e:
- logger.error(f"Error in delayed start for user {user_id}: {e}", exc_info=True)
- # Clear auto_start flag on error
- await self._clear_auto_start_flag(user_id)
-
- async def _clear_auto_start_flag(self, user_id: int):
- """
- Clear auto_start flag for a user (helper method for error handling).
- Uses a new database session to avoid session lifecycle issues.
-
- Args:
- user_id: User ID
- """
- try:
- from web.backend.database import AsyncSessionLocal
- from web.backend.models import UserConfig
- from sqlalchemy import select
-
- async with AsyncSessionLocal() as db:
- result = await db.execute(
- select(UserConfig).where(UserConfig.user_id == user_id)
- )
- user_config = result.scalar_one_or_none()
-
- if user_config:
- user_config.intraday_scheduler_auto_start = False
- await db.commit()
- logger.info(f"Cleared auto_start flag for user {user_id}")
-
- except Exception as e:
- logger.error(f"Failed to clear auto_start flag for user {user_id}: {e}")
-
-
-# Global manager instance
-_manager_instance: Optional[UserIntradaySchedulerManager] = None
-
-
-def get_manager() -> UserIntradaySchedulerManager:
- """Get or create global manager instance"""
- global _manager_instance
- if _manager_instance is None:
- _manager_instance = UserIntradaySchedulerManager()
- return _manager_instance
diff --git a/web/backend/tests/test_scheduler_recovery.py b/web/backend/tests/test_scheduler_recovery.py
deleted file mode 100644
index 546cd05..0000000
--- a/web/backend/tests/test_scheduler_recovery.py
+++ /dev/null
@@ -1,244 +0,0 @@
-#!/usr/bin/env python3
-"""
-Test script for intraday scheduler auto-recovery feature
-
-This script tests the scheduler recovery functionality by:
-1. Creating a test user with scheduler configuration
-2. Simulating service restart
-3. Verifying scheduler is restored correctly
-"""
-
-import asyncio
-import sys
-import os
-
-# Add parent directory to path
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
-
-from web.backend.database import SessionLocal, init_db_sync
-from web.backend.models import User, UserConfig
-from web.backend.services.user_intraday_scheduler import get_manager
-from sqlalchemy import select
-
-
-def setup_test_user():
- """Create a test user with scheduler configuration"""
- db = SessionLocal()
- try:
- # Check if test user exists
- result = db.execute(
- select(User).where(User.username == "test_scheduler_user")
- )
- user = result.scalar_one_or_none()
-
- if not user:
- # Create test user
- from passlib.context import CryptContext
- pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
-
- user = User(
- username="test_scheduler_user",
- email="test_scheduler@example.com",
- hashed_password=pwd_context.hash("test123"),
- role="user",
- is_active=True,
- )
- db.add(user)
- db.commit()
- db.refresh(user)
- print(f"✅ Created test user (ID: {user.id})")
- else:
- print(f"✅ Test user exists (ID: {user.id})")
-
- # Get or create user config
- result = db.execute(
- select(UserConfig).where(UserConfig.user_id == user.id)
- )
- config = result.scalar_one_or_none()
-
- if not config:
- config = UserConfig(user_id=user.id)
- db.add(config)
-
- # Set scheduler configuration
- config.intraday_futu_api_url = "http://localhost:8080" # Mock API
- config.intraday_interval_minutes = 5
- config.intraday_market_type = "US"
- config.intraday_scheduler_enabled = True
- config.intraday_scheduler_auto_start = True # Mark for auto-recovery
-
- db.commit()
- print(f"✅ Configured scheduler for user {user.id}")
- print(f" - API URL: {config.intraday_futu_api_url}")
- print(f" - Interval: {config.intraday_interval_minutes} minutes")
- print(f" - Market: {config.intraday_market_type}")
- print(f" - Auto-start: {config.intraday_scheduler_auto_start}")
-
- return user.id
-
- finally:
- db.close()
-
-
-async def test_scheduler_recovery():
- """Test scheduler recovery functionality"""
- print("\n" + "="*60)
- print("Testing Scheduler Auto-Recovery")
- print("="*60 + "\n")
-
- # Step 1: Setup test user
- print("Step 1: Setting up test user...")
- user_id = setup_test_user()
-
- # Step 2: Simulate service restart by restoring schedulers
- print("\nStep 2: Simulating service restart...")
- manager = get_manager()
-
- # Clear any existing schedulers (simulate fresh start)
- if manager.has_scheduler(user_id):
- await manager.stop_scheduler(user_id)
- await manager.remove_scheduler(user_id)
- print(" Cleared existing scheduler")
-
- # Restore schedulers from database
- print(" Restoring schedulers from database...")
- await manager.restore_schedulers_from_db()
-
- # Step 3: Verify scheduler was restored
- print("\nStep 3: Verifying scheduler restoration...")
-
- if manager.has_scheduler(user_id):
- print(f" ✅ Scheduler exists for user {user_id}")
-
- scheduler = manager.get_scheduler(user_id)
- if scheduler and scheduler.is_running:
- print(f" ✅ Scheduler is running")
-
- status = manager.get_scheduler_status(user_id)
- print(f"\n Scheduler Status:")
- print(f" - Running: {status['is_running']}")
- print(f" - Interval: {status['interval_minutes']} minutes")
- print(f" - Market: {status['market_type']}")
- print(f" - Market Status: {status['market_status']}")
-
- # Stop scheduler
- print("\n Stopping scheduler...")
- await manager.stop_scheduler(user_id)
- print(" ✅ Scheduler stopped")
-
- return True
- else:
- print(f" ❌ Scheduler is not running")
- return False
- else:
- print(f" ❌ Scheduler not found for user {user_id}")
- return False
-
-
-async def test_manual_stop_no_recovery():
- """Test that manually stopped schedulers are not recovered"""
- print("\n" + "="*60)
- print("Testing Manual Stop (No Recovery)")
- print("="*60 + "\n")
-
- # Step 1: Setup test user
- print("Step 1: Setting up test user...")
- user_id = setup_test_user()
-
- # Step 2: Manually stop scheduler (clear auto_start flag)
- print("\nStep 2: Manually stopping scheduler...")
- db = SessionLocal()
- try:
- result = db.execute(
- select(UserConfig).where(UserConfig.user_id == user_id)
- )
- config = result.scalar_one_or_none()
-
- if config:
- config.intraday_scheduler_enabled = False
- config.intraday_scheduler_auto_start = False # Clear auto-start
- db.commit()
- print(f" ✅ Cleared auto-start flag for user {user_id}")
- finally:
- db.close()
-
- # Step 3: Simulate service restart
- print("\nStep 3: Simulating service restart...")
- manager = get_manager()
-
- # Clear any existing schedulers
- if manager.has_scheduler(user_id):
- await manager.stop_scheduler(user_id)
- await manager.remove_scheduler(user_id)
-
- # Restore schedulers from database
- print(" Restoring schedulers from database...")
- await manager.restore_schedulers_from_db()
-
- # Step 4: Verify scheduler was NOT restored
- print("\nStep 4: Verifying scheduler was NOT restored...")
-
- if not manager.has_scheduler(user_id):
- print(f" ✅ Scheduler correctly NOT restored for user {user_id}")
- return True
- else:
- print(f" ❌ Scheduler was incorrectly restored for user {user_id}")
- return False
-
-
-async def cleanup():
- """Clean up test data"""
- print("\n" + "="*60)
- print("Cleaning up test data...")
- print("="*60 + "\n")
-
- db = SessionLocal()
- try:
- # Remove test user
- result = db.execute(
- select(User).where(User.username == "test_scheduler_user")
- )
- user = result.scalar_one_or_none()
-
- if user:
- db.delete(user)
- db.commit()
- print(f"✅ Removed test user")
- finally:
- db.close()
-
-
-async def main():
- """Run all tests"""
- try:
- # Initialize database
- print("Initializing database...")
- init_db_sync()
-
- # Run tests
- test1_passed = await test_scheduler_recovery()
- test2_passed = await test_manual_stop_no_recovery()
-
- # Summary
- print("\n" + "="*60)
- print("Test Summary")
- print("="*60)
- print(f"Test 1 (Auto Recovery): {'✅ PASSED' if test1_passed else '❌ FAILED'}")
- print(f"Test 2 (Manual Stop): {'✅ PASSED' if test2_passed else '❌ FAILED'}")
- print("="*60 + "\n")
-
- # Cleanup
- # await cleanup() # Uncomment to remove test user
-
- return test1_passed and test2_passed
-
- except Exception as e:
- print(f"\n❌ Test failed with error: {e}")
- import traceback
- traceback.print_exc()
- return False
-
-
-if __name__ == "__main__":
- success = asyncio.run(main())
- sys.exit(0 if success else 1)
diff --git a/web/frontend/next.config.ts b/web/frontend/next.config.ts
index cb9162c..48c7ec9 100644
--- a/web/frontend/next.config.ts
+++ b/web/frontend/next.config.ts
@@ -20,7 +20,7 @@ const nextConfig: NextConfig = {
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
},
-
+
// Docker 构建时跳过 lint 和类型检查以加快构建速度
eslint: {
ignoreDuringBuilds: true,
diff --git a/web/frontend/src/app/admin/llm-config/page.tsx b/web/frontend/src/app/admin/llm-config/page.tsx
index 744cfb5..6d42a22 100644
--- a/web/frontend/src/app/admin/llm-config/page.tsx
+++ b/web/frontend/src/app/admin/llm-config/page.tsx
@@ -7,7 +7,7 @@ import { useAuth } from '@/lib/auth';
import { buildApiUrl } from '@/utils/api';
import { useToast, Toast } from '@/components/ui/Toast';
import { AppNavbar } from '@/components/common/AppNavbar';
-import { Footer } from '@/components/leaderboard/Footer';
+import { Footer } from '@/components/common/Footer';
import { ProviderList } from '@/components/admin/llm-config/ProviderList';
import { ModelList } from '@/components/admin/llm-config/ModelList';
import { ProviderForm } from '@/components/admin/llm-config/ProviderForm';
diff --git a/web/frontend/src/app/admin/users/page.tsx b/web/frontend/src/app/admin/users/page.tsx
index c1661de..788a8d1 100644
--- a/web/frontend/src/app/admin/users/page.tsx
+++ b/web/frontend/src/app/admin/users/page.tsx
@@ -8,7 +8,7 @@ import { buildApiUrl } from '@/utils/api';
import { useToast, Toast } from '@/components/ui/Toast';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { AppNavbar } from '@/components/common/AppNavbar';
-import { Footer } from '@/components/leaderboard/Footer';
+import { Footer } from '@/components/common/Footer';
import { ResponsiveUserCard } from '@/components/admin/ResponsiveUserCard';
import { useIsMobile } from '@/hooks/useMediaQuery';
diff --git a/web/frontend/src/app/analysis/page.tsx b/web/frontend/src/app/analysis/page.tsx
index bc75cc9..36a08c3 100644
--- a/web/frontend/src/app/analysis/page.tsx
+++ b/web/frontend/src/app/analysis/page.tsx
@@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import { AnalysisResults } from '@/components/analysis/AnalysisResults';
import { useToast, Toast } from '@/components/ui/Toast';
-import { Footer } from '@/components/leaderboard/Footer';
+import { Footer } from '@/components/common/Footer';
import { AppNavbar } from '@/components/common/AppNavbar';
function AnalysisDetailContent() {
@@ -29,7 +29,7 @@ function AnalysisDetailContent() {
if (fromLeaderboard) {
router.push(`/?market=${marketTab}`);
} else if (user) {
- router.push('/dashboard');
+ router.push('/');
} else {
router.push('/');
}
@@ -37,7 +37,7 @@ function AnalysisDetailContent() {
const handleNewAnalysis = () => {
if (user) {
- router.push('/dashboard');
+ router.push('/');
} else {
router.push('/login');
}
diff --git a/web/frontend/src/app/dashboard/page.tsx b/web/frontend/src/app/dashboard/page.tsx
deleted file mode 100644
index 751a9dc..0000000
--- a/web/frontend/src/app/dashboard/page.tsx
+++ /dev/null
@@ -1,364 +0,0 @@
-'use client';
-
-import React, { useEffect, useState, Suspense } from 'react';
-import { useRouter, useSearchParams } from 'next/navigation';
-import { useAuth } from '@/lib/auth';
-import { configAPI } from '@/lib/apiClient';
-import { AppConfig } from '@/lib/types';
-import { AnalysisConfigForm } from '@/components/analysis/AnalysisConfigForm';
-import { AnalysisProgress } from '@/components/analysis/AnalysisProgress';
-import { AnalysisResults } from '@/components/analysis/AnalysisResults';
-import { useToast, Toast } from '@/components/ui/Toast';
-import { AppNavbar } from '@/components/common/AppNavbar';
-
-function DashboardContent() {
- const { user, logout, isLoading: authLoading } = useAuth();
- const router = useRouter();
- const searchParams = useSearchParams();
- const { toast, showToast, hideToast } = useToast();
- const [config, setConfig] = useState(null);
- const [currentView, setCurrentView] = useState<'config' | 'progress' | 'results'>('config');
- const [currentAnalysisId, setCurrentAnalysisId] = useState(null);
- const [isLoading, setIsLoading] = useState(true);
-
- // Password setup modal state
- const [showPasswordModal, setShowPasswordModal] = useState(false);
- const [password, setPassword] = useState('');
- const [confirmPassword, setConfirmPassword] = useState('');
- const [showPassword, setShowPassword] = useState(false);
- const [showConfirmPassword, setShowConfirmPassword] = useState(false);
- const [isSettingPassword, setIsSettingPassword] = useState(false);
-
- // 认证保护逻辑
- useEffect(() => {
- // 给认证系统更多时间初始化,避免过早的重定向
- if (!authLoading && !user) {
- // 等待一点时间再检查,确保认证状态完全加载
- const timer = setTimeout(() => {
- // 再次检查用户状态
- const token = localStorage.getItem('access_token');
- if (!token && !user) {
- router.push('/auth');
- }
- }, 500); // 给500ms缓冲时间
- return () => clearTimeout(timer);
- }
- // 显式返回undefined以满足TypeScript要求
- return undefined;
- }, [user, authLoading, router]);
-
- useEffect(() => {
- const loadConfig = async () => {
- try {
- const configData = await configAPI.getConfig();
- setConfig(configData);
- } catch {
- showToast('获取配置失败', 'error');
- } finally {
- setIsLoading(false);
- }
- };
-
- if (user) {
- loadConfig();
-
- // Check if we should show password setup modal (only once after registration)
- const setupPassword = searchParams?.get('setup_password');
- const modalKey = `password_modal_shown_${user.id}`;
- const hasShownModal = localStorage.getItem(modalKey);
-
- if (setupPassword === 'true' && !hasShownModal) {
- // Show modal after a short delay to ensure smooth transition
- setTimeout(() => {
- setShowPasswordModal(true);
- }, 500);
-
- // Mark modal as shown for this user
- localStorage.setItem(modalKey, 'true');
-
- // Remove the query parameter from URL
- router.replace('/dashboard', { scroll: false });
- }
- }
- }, [user, searchParams, router]);
-
- const handleAnalysisStart = (analysisId: string) => {
- setCurrentAnalysisId(analysisId);
- setCurrentView('progress');
- };
-
- const handleAnalysisComplete = () => {
- setCurrentView('results');
- };
-
- const handleBackToTop = () => {
- if (typeof window !== 'undefined') {
- window.scrollTo({ top: 0, behavior: 'smooth' });
- }
- };
-
- const handleSetPassword = async () => {
- if (password !== confirmPassword) {
- showToast('两次输入的密码不一致', 'error');
- return;
- }
-
- if (password.length < 6) {
- showToast('密码长度至少6位', 'error');
- return;
- }
-
- setIsSettingPassword(true);
-
- try {
- const { authAPI } = await import('@/lib/apiClient');
- await authAPI.setPassword(password);
-
- showToast('密码设置成功!', 'success');
- setShowPasswordModal(false);
- setPassword('');
- setConfirmPassword('');
-
- // Refresh user data to update has_set_password flag
- setTimeout(() => {
- window.location.reload();
- }, 1000);
- } catch (error: any) {
- showToast(error.message || '设置密码失败,请稍后重试', 'error');
- } finally {
- setIsSettingPassword(false);
- }
- };
-
- const handleSkipPassword = () => {
- setShowPasswordModal(false);
- setPassword('');
- setConfirmPassword('');
- showToast('您可以稍后在个人中心设置密码', 'info');
- };
-
- // 返回顶部按钮显示逻辑
- const [showBackToTop, setShowBackToTop] = useState(false);
-
- useEffect(() => {
- const handleScroll = () => {
- setShowBackToTop(window.scrollY > 300);
- };
-
- window.addEventListener('scroll', handleScroll);
- return () => window.removeEventListener('scroll', handleScroll);
- }, []);
-
- // 如果正在认证检查或加载配置,显示加载状态
- if (authLoading || isLoading || !user) {
- return (
-
- );
- }
-
- return (
-
- {/* 顶部导航栏 */}
-
-
- {/* 主要内容区域 */}
-
- {/* 欢迎横幅 */}
- {currentView === 'config' && (
-
-
-
-
- TradingAgentsWeb
-
-
多智能体大语言模型金融交易框架
-
- 工作流程:
- 分析师团队 → 研究团队 → 交易员 → 风险管理 → 投资组合分析
-
-
-
- )}
-
- {/* 内容渲染 */}
- {currentView === 'config' && config && (
-
- )}
-
- {currentView === 'progress' && currentAnalysisId && (
-
setCurrentView('config')}
- onShowToast={showToast}
- />
- )}
-
- {currentView === 'results' && currentAnalysisId && (
- setCurrentView('config')}
- onBackToHistory={() => router.push('/history')}
- onShowToast={showToast}
- />
- )}
-
-
- {/* 回到顶部按钮,仅在查看报告页面且滚动超过300px时显示 */}
- {currentView === 'results' && showBackToTop && (
-
-
-
- )}
-
- {/* 页面底部版权信息 */}
-
-
-
-
© {new Date().getFullYear()} SmartAIGC. 保留所有权利
-
- 基于 TradingAgents 构建
-
-
-
-
-
- {/* Toast组件 */}
-
-
- {/* Password Setup Modal */}
- {showPasswordModal && (
-
-
-
-
-
-
-
注册成功!
-
- 为了账户安全,建议您设置登录密码
-
-
-
-
-
-
- 设置密码
-
-
-
-
-
-
setPassword(e.target.value)}
- className="block w-full h-12 pl-10 pr-12 bg-dark-tertiary border border-dark-border text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-primary focus:border-accent-primary transition-all"
- placeholder="请输入密码(至少6位)"
- />
-
setShowPassword(!showPassword)}
- >
-
-
-
-
-
-
-
- 确认密码
-
-
-
-
-
-
setConfirmPassword(e.target.value)}
- className="block w-full h-12 pl-10 pr-12 bg-dark-tertiary border border-dark-border text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-primary focus:border-accent-primary transition-all"
- placeholder="请再次输入密码"
- />
-
setShowConfirmPassword(!showConfirmPassword)}
- >
-
-
-
-
-
-
-
- 稍后设置
-
-
- {isSettingPassword ? (
- <>
-
- 设置中...
- >
- ) : (
- '确认设置'
- )}
-
-
-
-
-
- 您也可以稍后在个人中心设置或修改密码
-
-
-
-
- )}
-
- );
-}
-
-export default function DashboardPage() {
- return (
-
-
-
- }>
-
-
- );
-}
\ No newline at end of file
diff --git a/web/frontend/src/app/history/detail/page.tsx b/web/frontend/src/app/history/detail/page.tsx
index ebbbd4a..a5fae29 100644
--- a/web/frontend/src/app/history/detail/page.tsx
+++ b/web/frontend/src/app/history/detail/page.tsx
@@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import { AnalysisResults } from '@/components/analysis/AnalysisResults';
import { useToast, Toast } from '@/components/ui/Toast';
-import { Footer } from '@/components/leaderboard/Footer';
+import { Footer } from '@/components/common/Footer';
import { AppNavbar } from '@/components/common/AppNavbar';
function HistoryDetailContent() {
@@ -28,7 +28,7 @@ function HistoryDetailContent() {
};
const handleNewAnalysis = () => {
- router.push('/dashboard');
+ router.push('/');
};
React.useEffect(() => {
diff --git a/web/frontend/src/app/history/page.tsx b/web/frontend/src/app/history/page.tsx
index 370c459..34b7771 100644
--- a/web/frontend/src/app/history/page.tsx
+++ b/web/frontend/src/app/history/page.tsx
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import { AnalysisHistory } from '@/components/analysis/AnalysisHistory';
import { useToast, Toast } from '@/components/ui/Toast';
-import { Footer } from '@/components/leaderboard/Footer';
+import { Footer } from '@/components/common/Footer';
import { AppNavbar } from '@/components/common/AppNavbar';
export default function HistoryPage() {
@@ -64,7 +64,7 @@ export default function HistoryPage() {
{/* 主要内容区域 */}
router.push('/dashboard')}
+ onBackToConfig={() => router.push('/')}
onViewResults={(analysisId: string) => {
router.push(`/history/detail?id=${analysisId}`);
}}
diff --git a/web/frontend/src/app/history/progress/page.tsx b/web/frontend/src/app/history/progress/page.tsx
index 3891b51..aa6ceea 100644
--- a/web/frontend/src/app/history/progress/page.tsx
+++ b/web/frontend/src/app/history/progress/page.tsx
@@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import { AnalysisProgress } from '@/components/analysis/AnalysisProgress';
import { useToast, Toast } from '@/components/ui/Toast';
-import { Footer } from '@/components/leaderboard/Footer';
+import { Footer } from '@/components/common/Footer';
import { AppNavbar } from '@/components/common/AppNavbar';
function HistoryProgressContent() {
diff --git a/web/frontend/src/app/intraday-trading/page.tsx b/web/frontend/src/app/intraday-trading/page.tsx
deleted file mode 100644
index c71651e..0000000
--- a/web/frontend/src/app/intraday-trading/page.tsx
+++ /dev/null
@@ -1,533 +0,0 @@
-'use client';
-
-import React, { useState, useEffect, useCallback } from 'react';
-import { useRouter } from 'next/navigation';
-import { useAuth } from '@/lib/auth';
-import { AppNavbar } from '@/components/common/AppNavbar';
-import { Footer } from '@/components/leaderboard/Footer';
-import { useToast, Toast } from '@/components/ui/Toast';
-import { ControlPanel } from '@/components/intraday/ControlPanel';
-import { PositionOverview } from '@/components/intraday/PositionOverview';
-import { TodayOrders } from '@/components/intraday/TodayOrders';
-import { DecisionHistory } from '@/components/intraday/DecisionHistory';
-import { AccountInfo } from '@/components/intraday/AccountInfo';
-import { useIntradayWebSocket } from '@/hooks/useIntradayWebSocket';
-import { useQueryClient } from '@tanstack/react-query';
-import { intradayTradingKeys } from '@/hooks/useIntradayTrading';
-import { buildApiUrl } from '@/utils/api';
-
-export default function IntradayTradingPage() {
- const { user, logout, isLoading: authLoading, refreshUser } = useAuth();
- const router = useRouter();
- const { toast, showToast, hideToast } = useToast();
- const queryClient = useQueryClient();
-
- // 管理排名参与状态
- const [participateInLeaderboard, setParticipateInLeaderboard] = useState(
- user?.participate_in_leaderboard || false
- );
-
- // 当用户数据加载时,更新排名参与状态
- React.useEffect(() => {
- if (user) {
- setParticipateInLeaderboard(user.participate_in_leaderboard || false);
- }
- }, [user]);
-
- // 从 localStorage 读取上次选择的市场,如果没有则默认为 'US'
- const [selectedMarket, setSelectedMarket] = useState(() => {
- if (typeof window !== 'undefined') {
- const cached = localStorage.getItem('intraday_selected_market');
- return cached || 'US';
- }
- return 'US';
- });
-
- // 处理市场切换,同时保存到 localStorage
- const handleMarketChange = useCallback((market: string) => {
- setSelectedMarket(market);
- if (typeof window !== 'undefined') {
- localStorage.setItem('intraday_selected_market', market);
- }
- }, []);
-
- // WebSocket message handler
- const handleWebSocketMessage = useCallback((message: any) => {
- switch (message.type) {
- case 'intraday_session_start':
- showToast('分析会话已开始', 'info');
-
- // Create a running decision record when session starts
- if (message.decision_id) {
- const currentDecisions = queryClient.getQueryData(
- intradayTradingKeys.decisionsList(1, 20)
- ) as any;
-
- // Create a temporary running decision record
- const runningDecision = {
- id: message.decision_id,
- session_id: message.session_id || '',
- market_type: message.market_type || selectedMarket,
- status: 'running',
- start_time: new Date().toISOString(),
- end_time: null,
- trades_count: 0,
- trades_executed: [],
- };
-
- if (currentDecisions) {
- // Check if this decision already exists (to avoid duplicates)
- const existingIndex = currentDecisions.items.findIndex(
- (item: any) => item.id === message.decision_id
- );
-
- let updatedItems;
- if (existingIndex >= 0) {
- // Update existing decision
- updatedItems = [...currentDecisions.items];
- updatedItems[existingIndex] = { ...updatedItems[existingIndex], status: 'running' };
- } else {
- // Add new running decision at the beginning
- updatedItems = [runningDecision, ...currentDecisions.items].slice(0, 20);
- }
-
- queryClient.setQueryData(
- intradayTradingKeys.decisionsList(1, 20),
- {
- ...currentDecisions,
- items: updatedItems,
- total: existingIndex >= 0 ? currentDecisions.total : currentDecisions.total + 1,
- }
- );
- } else {
- // If no decisions list exists yet, create one with just this running decision
- queryClient.setQueryData(
- intradayTradingKeys.decisionsList(1, 20),
- {
- items: [runningDecision],
- total: 1,
- page: 1,
- limit: 20,
- }
- );
- }
- }
- break;
-
- case 'analysis_trigger':
- // Analysis is being triggered by scheduler
- break;
-
- case 'tool_call':
- // Tool is being called
- break;
-
- case 'tool_result':
- // Tool result received
- // Only refresh the currently selected market to avoid unnecessary API calls
- if (message.tool === 'get_futu_account_info') {
- queryClient.invalidateQueries({
- queryKey: [...intradayTradingKeys.account(), selectedMarket]
- });
- } else if (message.tool === 'get_futu_positions') {
- queryClient.invalidateQueries({
- queryKey: [...intradayTradingKeys.positions(), selectedMarket]
- });
- } else if (message.tool === 'place_futu_order' || message.tool === 'cancel_futu_order') {
- // Refresh orders when order is placed or cancelled
- queryClient.invalidateQueries({
- queryKey: [...intradayTradingKeys.orders(), selectedMarket]
- });
- }
- break;
-
- case 'agent_start':
- showToast('Agent开始分析', 'info');
- break;
-
- case 'agent_result':
- showToast('Agent分析完成', 'success');
- break;
-
- case 'decisions_initial':
- // Initial decisions list from WebSocket
- // Directly set the query data
- queryClient.setQueryData(
- intradayTradingKeys.decisionsList(1, 20),
- message.decisions
- );
- break;
-
- case 'intraday_session_complete':
- showToast('分析会话已完成', 'success');
-
- // Update the existing running decision to completed
- if (message.decision_record) {
- const currentDecisions = queryClient.getQueryData(
- intradayTradingKeys.decisionsList(1, 20)
- ) as any;
-
- if (currentDecisions) {
- // Find and update the existing decision
- const existingIndex = currentDecisions.items.findIndex(
- (item: any) => item.id === message.decision_record.id
- );
-
- let updatedItems;
- if (existingIndex >= 0) {
- // Update existing decision with complete data
- updatedItems = [...currentDecisions.items];
- updatedItems[existingIndex] = message.decision_record;
- } else {
- // If not found (shouldn't happen), add it
- updatedItems = [message.decision_record, ...currentDecisions.items].slice(0, 20);
- }
-
- queryClient.setQueryData(
- intradayTradingKeys.decisionsList(1, 20),
- {
- ...currentDecisions,
- items: updatedItems,
- total: existingIndex >= 0 ? currentDecisions.total : currentDecisions.total + 1,
- }
- );
- }
- }
-
- // Refresh account info, positions, and orders after decision is complete
- // This ensures the UI shows the latest data after trades are executed
- queryClient.invalidateQueries({
- queryKey: [...intradayTradingKeys.account(), selectedMarket]
- });
- queryClient.invalidateQueries({
- queryKey: [...intradayTradingKeys.positions(), selectedMarket]
- });
- queryClient.invalidateQueries({
- queryKey: [...intradayTradingKeys.orders(), selectedMarket]
- });
- break;
-
- case 'intraday_session_error':
- showToast(`分析出错: ${message.message}`, 'error');
-
- // Update the running decision to failed status
- if (message.decision_id) {
- const currentDecisions = queryClient.getQueryData(
- intradayTradingKeys.decisionsList(1, 20)
- ) as any;
-
- if (currentDecisions) {
- const existingIndex = currentDecisions.items.findIndex(
- (item: any) => item.id === message.decision_id
- );
-
- if (existingIndex >= 0) {
- const updatedItems = [...currentDecisions.items];
- updatedItems[existingIndex] = {
- ...updatedItems[existingIndex],
- status: 'failed',
- end_time: new Date().toISOString(),
- };
-
- queryClient.setQueryData(
- intradayTradingKeys.decisionsList(1, 20),
- {
- ...currentDecisions,
- items: updatedItems,
- }
- );
- }
- }
- }
-
- queryClient.invalidateQueries({ queryKey: intradayTradingKeys.all });
- break;
-
- case 'scheduler_status_change':
- // Scheduler status changed
- queryClient.invalidateQueries({ queryKey: intradayTradingKeys.schedulerStatus() });
- break;
-
- case 'scheduler_status_sync':
- case 'scheduler_status_update':
- // Scheduler status sync/update - directly update cache
- // Create a new object to ensure React detects the change
- queryClient.setQueryData(
- intradayTradingKeys.schedulerStatus(),
- { ...message.status } // Spread to create new object reference
- );
- break;
-
- case 'scheduler_started':
- // Scheduler started confirmation
- // Create a new object to ensure React detects the change
- queryClient.setQueryData(
- intradayTradingKeys.schedulerStatus(),
- { ...message.status } // Spread to create new object reference
- );
- showToast('系统已启动', 'success');
- break;
-
- case 'scheduler_stopped':
- // Scheduler stopped confirmation
- // Create a new object to ensure React detects the change
- queryClient.setQueryData(
- intradayTradingKeys.schedulerStatus(),
- { ...message.status } // Spread to create new object reference
- );
- showToast('系统已停止', 'success');
- break;
-
- default:
- break;
- }
- }, [showToast, queryClient, selectedMarket]);
-
- // WebSocket connection (user-specific)
- const { status: wsStatus, isConnected } = useIntradayWebSocket(
- user?.id?.toString() || null, // Use user ID as channel
- {
- onMessage: handleWebSocketMessage,
- onStatusChange: (status) => {
- if (status === 'error') {
- console.warn('WebSocket connection failed');
- }
- },
- autoConnect: !!user,
- }
- );
-
- // 认证和权限保护逻辑
- useEffect(() => {
- if (!authLoading && user) {
- // 检查用户是否有访问权限(管理员或有短线交易权限)
- if (user.role !== 'admin' && !user.can_access_intraday_trading) {
- showToast('您没有访问短线交易功能的权限', 'error');
- router.push('/');
- }
- } else if (!authLoading && !user) {
- // 用户未登录,跳转到首页
- router.push('/');
- }
- }, [user, authLoading, router, showToast]);
-
- // 如果正在认证检查或没有权限,显示加载状态
- if (authLoading || !user || (user.role !== 'admin' && !user.can_access_intraday_trading)) {
- return (
-
- );
- }
-
- return (
-
- {/* 顶部导航栏 */}
-
-
- {/* 面包屑导航 */}
-
-
-
- router.push('/')}
- className="text-accent-primary hover:text-accent-secondary transition-colors flex-shrink-0"
- >
-
- 首页
-
-
- 智能盯盘
-
-
-
-
- {/* 主要内容区域 */}
-
-
- {/* Header */}
-
-
-
-
-
- 智能盯盘
-
-
- 实时盯盘分析,智能决策,自动监控
-
-
-
- {/* Right side controls */}
-
- {/* WebSocket Status Indicator - Icon only on all screens */}
-
-
-
-
- {/* Participate in Ranking Toggle */}
-
-
-
{
- // Mobile: toggle tooltip on click
- if (window.innerWidth < 640) {
- const tooltip = e.currentTarget.nextElementSibling as HTMLElement;
- if (tooltip) {
- tooltip.classList.toggle('opacity-0');
- tooltip.classList.toggle('opacity-100');
- }
- }
- }}
- />
-
- 开启后,您的账户将参与实时排名,资产信息将公开展示(可随时关闭)
-
-
-
-
-
- 参加排名
-
-
{
- const newCheckedState = e.target.checked;
- setParticipateInLeaderboard(newCheckedState); // 立即更新UI
-
- try {
- const token = localStorage.getItem('access_token');
- const response = await fetch(buildApiUrl('/api/user/leaderboard-toggle'), {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${token}`,
- },
- credentials: 'include',
- });
-
- if (!response.ok) {
- throw new Error('更新设置失败');
- }
-
- const result = await response.json();
-
- // Update the actual state based on backend response
- setParticipateInLeaderboard(result.participating);
-
- // Refresh user data to sync with backend
- if (refreshUser) {
- await refreshUser();
- }
-
- showToast(
- result.message || (result.participating ? '已开启排名展示' : '已关闭排名展示'),
- 'success'
- );
- } catch (error: any) {
- showToast(error.message || '操作失败', 'error');
- // Revert toggle on error
- setParticipateInLeaderboard(!newCheckedState);
- }
- }}
- />
-
-
-
-
-
-
-
-
-
- {/* Control Panel */}
-
-
-
-
- {/* Account Info */}
-
-
- {/* Position Overview */}
-
-
- {/* Today's Orders */}
-
-
-
-
- {/* Decision History */}
-
-
-
-
-
-
- {/* Footer */}
-
-
- {/* Toast组件 */}
-
-
- );
-}
diff --git a/web/frontend/src/app/leaderboard/page.tsx b/web/frontend/src/app/leaderboard/page.tsx
deleted file mode 100644
index 1f26c8a..0000000
--- a/web/frontend/src/app/leaderboard/page.tsx
+++ /dev/null
@@ -1,398 +0,0 @@
-'use client';
-
-import React, { useState, useEffect, useCallback } from 'react';
-import { useRouter } from 'next/navigation';
-import { useQuery } from '@tanstack/react-query';
-import { useAuth } from '@/lib/auth';
-import { AppNavbar } from '@/components/common/AppNavbar';
-import { Footer } from '@/components/leaderboard/Footer';
-import { useLeaderboardWebSocket } from '@/hooks/useLeaderboardWebSocket';
-import { buildApiUrl } from '@/utils/api';
-import { checkMarketStatus } from '@/utils/marketTime';
-import { LeaderboardTrendChart } from '@/components/leaderboard/LeaderboardTrendChart';
-import { UserDetailPanel } from '@/components/leaderboard/UserDetailPanel';
-import apiClient from '@/lib/apiClient';
-
-interface LeaderboardUser {
- user_id: number;
- username: string;
- market_type: string;
- total_assets: number;
- latest_snapshot_date: string;
-}
-
-// 申请开通智能盯盘按钮组件
-function ApplyIntradayButton({ user }: { user: any }) {
- const router = useRouter();
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
-
- const handleApply = async () => {
- if (!user) {
- router.push('/login?redirect=/leaderboard');
- return;
- }
-
- setIsSubmitting(true);
- setMessage(null);
-
- try {
- const response = await apiClient.post('/api/intraday/apply');
-
- if (response.data.status === 'success') {
- setMessage({ type: 'success', text: response.data.message || '申请已提交,我们将在1-2个工作日内处理您的申请' });
- } else if (response.data.status === 'info') {
- setMessage({ type: 'success', text: response.data.message });
- } else {
- setMessage({ type: 'error', text: response.data.message || '申请提交失败,请稍后重试' });
- }
- } catch (error: any) {
- console.error('申请失败:', error);
- setMessage({
- type: 'error',
- text: error.response?.data?.detail || '申请提交失败,请稍后重试'
- });
- } finally {
- setIsSubmitting(false);
- }
- };
-
- return (
-
-
-
- {isSubmitting ? (
- <>
-
- 提交中...
- >
- ) : (
- <>
-
- 申请开通智能盯盘
- >
- )}
-
-
-
- {message && (
-
-
-
- {message.text}
-
-
- )}
-
- );
-}
-
-export default function LeaderboardPage() {
- const router = useRouter();
- const { user, logout } = useAuth();
-
- // 市场选择 - 默认US,客户端挂载后从localStorage读取
- const [selectedMarket, setSelectedMarket] = useState('US');
-
- // 用户选择
- const [selectedUserId, setSelectedUserId] = useState(null);
- const [selectedUsername, setSelectedUsername] = useState('');
- const [isPanelOpen, setIsPanelOpen] = useState(false);
-
- // 参加排名弹窗
- const [isJoinModalOpen, setIsJoinModalOpen] = useState(false);
-
- // 客户端挂载后从localStorage恢复市场选择
- useEffect(() => {
- const savedMarket = localStorage.getItem('leaderboard_selected_market');
- if (savedMarket) {
- setSelectedMarket(savedMarket);
- }
- }, []);
-
- // 市场状态
- const [marketStatus, setMarketStatus] = useState<{
- isOpen: boolean;
- message: string;
- }>({ isOpen: true, message: '' });
-
- // WebSocket连接
- const {
- users,
- isConnected,
- error: wsError,
- lastUpdate,
- connect,
- disconnect
- } = useLeaderboardWebSocket({
- token: user ? localStorage.getItem('access_token') || undefined : undefined,
- reconnectAttempts: 5,
- reconnectInterval: 3000,
- });
-
- // 连接WebSocket
- useEffect(() => {
- connect();
- return () => disconnect();
- }, [connect, disconnect]);
-
- // 处理市场切换
- const handleMarketChange = useCallback((market: string) => {
- setSelectedMarket(market);
- localStorage.setItem('leaderboard_selected_market', market);
- }, []);
-
- // 检查市场状态
- useEffect(() => {
- const updateMarketStatus = () => {
- const status = checkMarketStatus(selectedMarket);
- setMarketStatus({ isOpen: status.isOpen, message: status.message });
- };
-
- updateMarketStatus();
- const interval = setInterval(updateMarketStatus, 60000); // 每分钟检查一次
- return () => clearInterval(interval);
- }, [selectedMarket]);
-
- // 调试:查看接收到的数据
- useEffect(() => {
- console.log('📊 Leaderboard data:', {
- totalUsers: users.length,
- selectedMarket,
- users: users.map(u => ({ id: u.user_id, market: u.market_type, assets: u.total_assets }))
- });
- }, [users, selectedMarket]);
-
- // 过滤选定市场的用户
- const filteredUsers = users.filter(u => u.market_type === selectedMarket);
-
- // 按资产排序并取前10名
- const top10Users = [...filteredUsers]
- .sort((a, b) => b.total_assets - a.total_assets)
- .slice(0, 10);
-
- // 处理用户选择
- const handleUserSelect = (userId: number, username: string) => {
- setSelectedUserId(userId);
- setSelectedUsername(username);
- setIsPanelOpen(true);
- };
-
- // 关闭面板
- const handleClosePanel = () => {
- setIsPanelOpen(false);
- setTimeout(() => {
- setSelectedUserId(null);
- setSelectedUsername('');
- }, 300);
- };
-
- const usersLoading = !isConnected && users.length === 0;
-
- return (
-
-
-
-
- {/* 页面头部 */}
-
-
-
-
-
-
- 实时排名
-
-
- 查看参与排名用户的资产变化趋势 • 数据每5分钟更新
-
-
-
- {/* 市场选择 */}
-
-
- {['US', 'HK', 'CN'].map((market) => (
- handleMarketChange(market)}
- className={`px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg text-xs sm:text-sm font-medium transition-all whitespace-nowrap ${
- selectedMarket === market
- ? 'bg-accent-primary text-white'
- : 'bg-dark-tertiary text-text-secondary hover:bg-dark-primary'
- }`}
- >
- {market === 'US' ? '美股' : market === 'HK' ? '港股' : 'A股'}
-
- ))}
-
-
- {/* 连接状态 */}
-
-
-
-
- {/* 市场状态提示 */}
- {!marketStatus.isOpen && (
-
-
- {marketStatus.message}
-
- )}
-
-
-
- {/* 主要内容区域 */}
-
- {usersLoading ? (
-
- ) : wsError ? (
-
-
-
-
连接失败: {wsError}
-
- 重新连接
-
-
-
- ) : filteredUsers.length === 0 ? (
-
- ) : (
- <>
- {/* 趋势图 - 全屏宽度 */}
-
setIsJoinModalOpen(true)}
- lastUpdate={lastUpdate}
- />
-
- {/* 用户详情侧边栏 */}
-
- >
- )}
-
-
-
-
-
- {/* 参加排名弹窗 */}
- {isJoinModalOpen && (
-
setIsJoinModalOpen(false)}>
-
e.stopPropagation()}>
- {/* 弹窗头部 */}
-
-
-
- 如何参加实时排名
-
- setIsJoinModalOpen(false)}
- className="w-8 h-8 rounded-lg hover:bg-dark-tertiary transition-colors flex items-center justify-center text-text-tertiary hover:text-text-primary"
- >
-
-
-
-
- {/* 弹窗内容 */}
-
-
-
-
- 1
-
-
-
注册账号并申请开通智能盯盘功能
-
点击下方"申请开通智能盯盘"按钮,我们将在1-2个工作日内为您开通权限。
-
-
-
-
-
- 2
-
-
-
准备大模型 API
-
- 分析功能需要使用大模型服务(如 OpenAI、Anthropic、Google Gemini 等),请自备大模型接口的 API Key。开通方式请参考对应大模型提供方的官网。
-
-
-
-
-
-
-
- {/* 申请按钮 */}
-
-
-
-
- )}
-
- );
-}
diff --git a/web/frontend/src/app/not-found.tsx b/web/frontend/src/app/not-found.tsx
new file mode 100644
index 0000000..bc7e0f6
--- /dev/null
+++ b/web/frontend/src/app/not-found.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+
+/**
+ * 全局 404 页面(WS-4 M5-S3 旧路由收口)。
+ * 由于项目使用 `output: 'export'` 静态导出,无法使用 next.config 的 redirects(),
+ * 故已下线的模拟交易 / 交易排行榜 / 仪表盘等旧路由统一在此友好提示并引导至对话工作台。
+ */
+export default function NotFound() {
+ const router = useRouter();
+
+ useEffect(() => {
+ // 旧路由(/leaderboard、/intraday-trading、/dashboard 等)访问后自动回到对话工作台
+ const timer = setTimeout(() => {
+ router.replace('/');
+ }, 4000);
+ return () => clearTimeout(timer);
+ }, [router]);
+
+ return (
+
+
+
+
+
+
页面已迁移
+
+ 您访问的页面已下线或已合并至对话式分析工作台。
+
+
+ 即将在几秒后自动跳转到对话工作台…
+
+
+
+ 前往对话工作台
+
+
+
+ );
+}
diff --git a/web/frontend/src/app/page.tsx b/web/frontend/src/app/page.tsx
index e70ede7..ce00aaa 100644
--- a/web/frontend/src/app/page.tsx
+++ b/web/frontend/src/app/page.tsx
@@ -1,151 +1,30 @@
'use client';
-import React, { useState } from 'react';
-import { useQuery } from '@tanstack/react-query';
-import { useRouter } from 'next/navigation';
+import React from 'react';
import { useAuth } from '@/lib/auth';
-import { buildApiUrl } from '@/utils/api';
+import { ConversationProvider } from '@/lib/conversation-context';
import { AppNavbar } from '@/components/common/AppNavbar';
-import { HeroSection } from '@/components/home/HeroSection';
-import { FeaturesShowcase } from '@/components/home/FeaturesShowcase';
-import { MarketTabs } from '@/components/leaderboard/MarketTabs';
-import { AnalysisCardsGrid } from '@/components/leaderboard/AnalysisCardsGrid';
-import { Footer } from '@/components/leaderboard/Footer';
-
-type Market = 'US' | 'HK' | 'CN';
-
-interface AnalysisCardData {
- analysis_id: string;
- ticker: string;
- company_name?: string;
- market: string;
- analysis_date: string;
- trading_decision: string;
- completed_at: string;
- progress_percentage: number;
-}
-
-interface LeaderboardData {
- US: AnalysisCardData[];
- HK: AnalysisCardData[];
- CN: AnalysisCardData[];
-}
+import { ConversationWorkbench } from '@/components/conversation/ConversationWorkbench';
export default function HomePage() {
- const { user, logout, isLoading: authLoading } = useAuth();
- const router = useRouter();
- const [activeMarket, setActiveMarket] = useState('US');
- const [isNavigating, setIsNavigating] = useState(false);
-
- // Read market from URL params
- React.useEffect(() => {
- if (typeof window !== 'undefined') {
- const params = new URLSearchParams(window.location.search);
- const market = params.get('market') as Market;
- if (market && ['US', 'HK', 'CN'].includes(market)) {
- setActiveMarket(market);
- }
- }
- }, []);
-
- // Fetch leaderboard data
- const { data, isLoading, isError } = useQuery({
- queryKey: ['leaderboard'],
- queryFn: async () => {
- const response = await fetch(buildApiUrl('/api/leaderboard'));
- if (!response.ok) throw new Error('获取排行榜失败');
- return response.json();
- },
- staleTime: 1 * 60 * 1000, // 1 minute cache
- refetchOnWindowFocus: false,
- });
+ const { user, logout, isLoading } = useAuth();
- const marketLabels: Record = {
- US: '美股',
- HK: '港股',
- CN: 'A股'
- };
-
- const handleCardClick = (analysisId: string) => {
- setIsNavigating(true);
- router.push(`/analysis?id=${analysisId}&from=leaderboard&market=${activeMarket}`);
- };
-
- const handleNewAnalysis = () => {
- if (user) {
- router.push('/dashboard');
- } else {
- router.push('/login');
- }
- };
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
return (
- <>
-
-
-
- {/* Add padding top to account for fixed header */}
-
- {/* Hero Section - First Screen */}
-
-
- {/* Features Showcase Section */}
-
-
- {/* Stock Listings Section */}
-
- {/* Background decoration */}
-
-
-
- {/* Section Header */}
-
-
- 最新分析
-
-
- 查看各市场最新的 AI 分析报告和投资建议
-
-
-
- {/* Market Tabs */}
-
-
- {/* Analysis Cards Grid */}
-
-
-
-
-
-
+
+
+
+
+
+
-
- {/* Navigation Loading Overlay */}
- {isNavigating && (
-
-
-
- {/* Outer ring */}
-
- {/* Inner ring */}
-
-
-
正在加载分析详情...
-
请稍候
-
-
- )}
- >
+
);
}
diff --git a/web/frontend/src/app/profile/page.tsx b/web/frontend/src/app/profile/page.tsx
index ced0dc8..c8aa337 100644
--- a/web/frontend/src/app/profile/page.tsx
+++ b/web/frontend/src/app/profile/page.tsx
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import { AppNavbar } from '@/components/common/AppNavbar';
import { useToast, Toast } from '@/components/ui/Toast';
-import { Footer } from '@/components/leaderboard/Footer';
+import { Footer } from '@/components/common/Footer';
export default function ProfilePage() {
const { user, logout, isLoading: authLoading } = useAuth();
diff --git a/web/frontend/src/app/scheduled-tasks/page.tsx b/web/frontend/src/app/scheduled-tasks/page.tsx
index 81448fd..b5ad1c2 100644
--- a/web/frontend/src/app/scheduled-tasks/page.tsx
+++ b/web/frontend/src/app/scheduled-tasks/page.tsx
@@ -7,7 +7,7 @@ import { useScheduledTasks, useDeleteScheduledTask, useUpdateScheduledTask } fro
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { AppNavbar } from '@/components/common/AppNavbar';
-import { Footer } from '@/components/leaderboard/Footer';
+import { Footer } from '@/components/common/Footer';
import { useToast, Toast } from '@/components/ui/Toast';
import { ResponsiveTaskCard } from '@/components/scheduled-tasks/ResponsiveTaskCard';
import { useIsMobile } from '@/hooks/useMediaQuery';
@@ -250,7 +250,7 @@ export default function ScheduledTasksPage() {
您还没有创建任何定期报告
diff --git a/web/frontend/src/components/analysis/DataSnapshot.tsx b/web/frontend/src/components/analysis/DataSnapshot.tsx
new file mode 100644
index 0000000..e4a3e40
--- /dev/null
+++ b/web/frontend/src/components/analysis/DataSnapshot.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import React from 'react';
+import type { Report } from '@/types/conversation';
+
+export function DataSnapshot({ report }: { report: Report }) {
+ const sources = new Map();
+ report.sections.forEach((s) => {
+ (s.data_sources ?? []).forEach((d) => {
+ sources.set(d.name, d.snapshot_time);
+ });
+ });
+
+ return (
+
+
+ 标的
+ {report.ticker}
+ {report.market}
+
+ {report.company_name &&
公司:{report.company_name}
}
+ {sources.size > 0 && (
+
+ 数据来源:
+ {Array.from(sources.entries()).map(([name, time]) => (
+
+ {name}
+ ({new Date(time).toLocaleString('zh-CN')})
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/web/frontend/src/components/analysis/ExportMenu.tsx b/web/frontend/src/components/analysis/ExportMenu.tsx
new file mode 100644
index 0000000..38aaa3e
--- /dev/null
+++ b/web/frontend/src/components/analysis/ExportMenu.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import React, { useState } from 'react';
+import { reportAPI } from '@/lib/conversation';
+import { useToastContext } from '@/components/ui/ToasterProvider';
+
+const FORMATS: { key: 'md' | 'json' | 'pdf'; label: string; icon: string }[] = [
+ { key: 'md', label: 'Markdown', icon: 'fa-file-lines' },
+ { key: 'json', label: 'JSON', icon: 'fa-file-code' },
+ { key: 'pdf', label: 'PDF', icon: 'fa-file-pdf' },
+];
+
+export function ExportMenu({ reportId }: { reportId: string }) {
+ const { showToast } = useToastContext();
+ const [busy, setBusy] = useState(null);
+
+ const handleExport = (format: 'md' | 'json' | 'pdf') => {
+ setBusy(format);
+ try {
+ const url = reportAPI.exportUrl(reportId, format);
+ // open in new tab triggers the attachment download
+ window.open(url, '_blank', 'noopener,noreferrer');
+ } catch {
+ showToast('导出失败,请重试', 'error');
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ return (
+
+ 导出
+ {FORMATS.map((f) => (
+ handleExport(f.key)}
+ disabled={busy === f.key}
+ className="px-2 py-1 rounded-lg text-xs text-text-secondary hover:text-accent-primary hover:bg-dark-tertiary transition-colors disabled:opacity-50"
+ aria-label={`导出 ${f.label}`}
+ >
+
+ {f.label}
+
+ ))}
+
+ );
+}
diff --git a/web/frontend/src/components/analysis/RatingScale.tsx b/web/frontend/src/components/analysis/RatingScale.tsx
new file mode 100644
index 0000000..6162e5c
--- /dev/null
+++ b/web/frontend/src/components/analysis/RatingScale.tsx
@@ -0,0 +1,50 @@
+'use client';
+
+import React from 'react';
+
+interface RatingScaleProps {
+ rating: number; // 1-5
+ label?: string;
+ size?: 'sm' | 'md';
+}
+
+const RATING_LABELS: Record = {
+ 1: '高风险',
+ 2: '谨慎',
+ 3: '中性',
+ 4: '偏积极',
+ 5: '高置信积极',
+};
+
+// Color per level (number + text always present — never color-only, per a11y spec).
+const RATING_COLORS: Record = {
+ 1: 'text-danger-500 border-danger-500',
+ 2: 'text-warning-500 border-warning-500',
+ 3: 'text-text-secondary border-dark-border',
+ 4: 'text-accent-primary border-accent-primary',
+ 5: 'text-success-500 border-success-500',
+};
+
+export function RatingScale({ rating, label, size = 'md' }: RatingScaleProps) {
+ const clamped = Math.max(1, Math.min(5, rating));
+ const displayLabel = label ?? RATING_LABELS[clamped] ?? `评级 ${clamped}`;
+ const dot = size === 'sm' ? 'w-2 h-2' : 'w-2.5 h-2.5';
+
+ return (
+
+
+ {[1, 2, 3, 4, 5].map((lvl) => (
+
+ ))}
+
+
+ {clamped} · {displayLabel}
+
+
+ );
+}
diff --git a/web/frontend/src/components/analysis/ReportCard.tsx b/web/frontend/src/components/analysis/ReportCard.tsx
new file mode 100644
index 0000000..3f404ed
--- /dev/null
+++ b/web/frontend/src/components/analysis/ReportCard.tsx
@@ -0,0 +1,169 @@
+'use client';
+
+import React from 'react';
+import ReactMarkdown from 'react-markdown';
+import rehypeSanitize from 'rehype-sanitize';
+import type { Report } from '@/types/conversation';
+import { RatingScale } from './RatingScale';
+import { SectionAccordion } from './SectionAccordion';
+import { DataSnapshot } from './DataSnapshot';
+import { ExportMenu } from './ExportMenu';
+
+const SECTION_OPEN_DEFAULT: Record = {
+ market_technical: false,
+ fundamentals: false,
+ sentiment: false,
+ news_macro: false,
+ risk: false,
+};
+
+export function ReportCard({ report, compact = false }: { report: Report; compact?: boolean }) {
+ return (
+
+ {/* Header */}
+
+
+
+
+ {report.ticker}
+ {report.company_name && {report.company_name} }
+
+
+
+
+
+
+
+
+ {/* Conclusion summary */}
+
{report.conclusion.summary}
+
+ {/* Key points */}
+ {report.conclusion.key_points?.length > 0 && (
+
+ {report.conclusion.key_points.map((kp, i) => (
+
+
+ {kp}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ {/* Sections */}
+
+ {report.sections.map((section) => (
+
+ {section.summary}
+
+ ) : undefined
+ }
+ >
+ {section.indicators && section.indicators.length > 0 && (
+
+ {section.indicators.map((ind) => (
+
+ {ind.name}: {ind.value}
+
+ {' '}
+ {ind.trend === 'up' ? '↑' : ind.trend === 'down' ? '↓' : '→'}
+
+
+ ))}
+
+ )}
+
+
+ {section.content || section.summary}
+
+
+ {section.risk_factors && section.risk_factors.length > 0 && (
+
+
风险因素
+
+ {section.risk_factors.map((rf, i) => (
+ {rf}
+ ))}
+
+
+ )}
+
+ {section.grounded_evidence && (
+
+ 数据锚定:{section.grounded_evidence}
+
+ )}
+
+ {section.news_sources && section.news_sources.length > 0 && (
+
+ )}
+
+ ))}
+
+
+ {/* Reflection (decision-log, only when present) */}
+ {report.reflection?.previous_decisions && (
+
+
+
+
+ 历史判断反思
+
+
{report.reflection.previous_decisions}
+ {report.reflection.alpha_vs_benchmark && (
+
相对基准:{report.reflection.alpha_vs_benchmark}
+ )}
+
+
+ )}
+
+ {!compact && report.stage_log && report.stage_log.length > 0 && (
+
+
+ 阶段执行日志
+
+ {report.stage_log.map((sl, i) => (
+
+ {sl.stage_name}
+ {sl.status}
+ {sl.duration_ms != null && {sl.duration_ms}ms }
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/web/frontend/src/components/analysis/SectionAccordion.tsx b/web/frontend/src/components/analysis/SectionAccordion.tsx
new file mode 100644
index 0000000..d022a7b
--- /dev/null
+++ b/web/frontend/src/components/analysis/SectionAccordion.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import React, { useState } from 'react';
+
+interface SectionAccordionProps {
+ title: string;
+ defaultOpen?: boolean;
+ badge?: React.ReactNode;
+ children: React.ReactNode;
+}
+
+export function SectionAccordion({ title, defaultOpen = false, badge, children }: SectionAccordionProps) {
+ const [open, setOpen] = useState(defaultOpen);
+ return (
+
+
setOpen((o) => !o)}
+ className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-dark-tertiary transition-colors"
+ aria-expanded={open}
+ >
+
+ {title}
+ {badge}
+
+
+
+ {open &&
{children}
}
+
+ );
+}
diff --git a/web/frontend/src/components/analysis/StageProgress.tsx b/web/frontend/src/components/analysis/StageProgress.tsx
new file mode 100644
index 0000000..e9f9089
--- /dev/null
+++ b/web/frontend/src/components/analysis/StageProgress.tsx
@@ -0,0 +1,19 @@
+'use client';
+
+import React from 'react';
+import type { ContentBlock } from '@/types/conversation';
+import { StageRow } from './StageRow';
+
+export function StageProgress({ blocks }: { blocks: Extract[] }) {
+ if (blocks.length === 0) return null;
+ return (
+
+
分析阶段进展
+
+ {blocks.map((b) => (
+
+ ))}
+
+
+ );
+}
diff --git a/web/frontend/src/components/analysis/StageRow.tsx b/web/frontend/src/components/analysis/StageRow.tsx
new file mode 100644
index 0000000..cfc460c
--- /dev/null
+++ b/web/frontend/src/components/analysis/StageRow.tsx
@@ -0,0 +1,29 @@
+'use client';
+
+import React from 'react';
+import type { StageProgressBlock, StageStatus } from '@/types/conversation';
+
+const STATUS_META: Record = {
+ pending: { icon: 'fa-circle', color: 'text-text-tertiary', label: '等待中' },
+ active: { icon: 'fa-spinner fa-spin', color: 'text-accent-primary', label: '进行中' },
+ complete: { icon: 'fa-check-circle', color: 'text-success-500', label: '完成' },
+ warning: { icon: 'fa-exclamation-triangle', color: 'text-warning-500', label: '警告' },
+ error: { icon: 'fa-times-circle', color: 'text-danger-500', label: '失败' },
+ stopped: { icon: 'fa-stop-circle', color: 'text-text-secondary', label: '已停止' },
+};
+
+export function StageRow({ stage }: { stage: StageProgressBlock }) {
+ const meta = STATUS_META[stage.status];
+ return (
+
+
+
+
+ {stage.stage_name}
+ {meta.label}
+
+ {stage.summary &&
{stage.summary}
}
+
+
+ );
+}
diff --git a/web/frontend/src/components/analysis/index.ts b/web/frontend/src/components/analysis/index.ts
new file mode 100644
index 0000000..dffe37d
--- /dev/null
+++ b/web/frontend/src/components/analysis/index.ts
@@ -0,0 +1,7 @@
+export { RatingScale } from './RatingScale';
+export { SectionAccordion } from './SectionAccordion';
+export { StageRow } from './StageRow';
+export { StageProgress } from './StageProgress';
+export { DataSnapshot } from './DataSnapshot';
+export { ExportMenu } from './ExportMenu';
+export { ReportCard } from './ReportCard';
diff --git a/web/frontend/src/components/auth/LoginForm.tsx b/web/frontend/src/components/auth/LoginForm.tsx
index 5a9e0c4..6db8ac6 100644
--- a/web/frontend/src/components/auth/LoginForm.tsx
+++ b/web/frontend/src/components/auth/LoginForm.tsx
@@ -86,7 +86,7 @@ export function LoginForm({ onShowToast }: LoginFormProps) {
onShowToast('登录成功!正在跳转...', 'success');
await new Promise(resolve => setTimeout(resolve, 500));
- router.replace('/dashboard');
+ router.replace('/');
} catch (error: any) {
const errorMessage = error.message || '登录失败,请检查用户名和密码';
onShowToast(errorMessage, 'error');
@@ -121,7 +121,7 @@ export function LoginForm({ onShowToast }: LoginFormProps) {
onShowToast('登录成功!正在跳转...', 'success');
await new Promise(resolve => setTimeout(resolve, 500));
- router.replace('/dashboard');
+ router.replace('/');
} catch (error: any) {
const errorMessage = error.message || '登录失败,请检查验证码';
onShowToast(errorMessage, 'error');
diff --git a/web/frontend/src/components/auth/RegisterForm.tsx b/web/frontend/src/components/auth/RegisterForm.tsx
index 57781bd..4a903f2 100644
--- a/web/frontend/src/components/auth/RegisterForm.tsx
+++ b/web/frontend/src/components/auth/RegisterForm.tsx
@@ -111,7 +111,7 @@ export function RegisterForm({ onSubmit: _onSubmit, externalLoading: _externalLo
onShowToast('注册成功!正在跳转...', 'success');
await new Promise(resolve => setTimeout(resolve, 500));
- router.replace('/dashboard?setup_password=true');
+ router.replace('/?setup_password=true');
} catch (error: any) {
const errorMessage = error.message || '注册失败,请稍后重试';
onShowToast(errorMessage, 'error');
diff --git a/web/frontend/src/components/common/AppNavbar.tsx b/web/frontend/src/components/common/AppNavbar.tsx
index 09ec196..0757053 100644
--- a/web/frontend/src/components/common/AppNavbar.tsx
+++ b/web/frontend/src/components/common/AppNavbar.tsx
@@ -113,33 +113,20 @@ export function AppNavbar({ user, onLogout, showNewAnalysis = true, showUserMana
{/* Desktop navigation - hidden on mobile */}
- {/* Only show real-time ranking for non-logged in users */}
-
handleNavigation('/leaderboard')}
- className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
- isActive('/leaderboard')
- ? 'bg-gradient-to-r from-accent-primary to-accent-secondary text-white shadow-glow-cyan'
- : 'text-text-secondary hover:text-accent-primary hover:bg-dark-tertiary'
- }`}
- >
-
- 实时排名
-
-
{/* Show user-specific navigation only when logged in */}
{user && (
<>
{showNewAnalysis && (
handleNavigation('/dashboard')}
+ onClick={() => handleNavigation('/')}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
- isActive('/dashboard')
+ isActive('/')
? 'bg-gradient-to-r from-accent-primary to-accent-secondary text-white shadow-glow-cyan'
: 'text-text-secondary hover:text-accent-primary hover:bg-dark-tertiary'
}`}
>
- 新建分析
+ 新建对话
)}
定期报告
- {(user?.role === 'admin' || user?.can_access_intraday_trading) && (
-
handleNavigation('/intraday-trading')}
- className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
- isActive('/intraday-trading')
- ? 'bg-gradient-to-r from-success-500 to-success-600 text-white'
- : 'text-text-secondary hover:text-success-500 hover:bg-dark-tertiary'
- }`}
- >
-
- 智能盯盘
-
- )}
{/* User dropdown menu */}
@@ -320,32 +294,20 @@ export function AppNavbar({ user, onLogout, showNewAnalysis = true, showUserMana
{/* Navigation Items */}
-
handleNavigation('/leaderboard')}
- className={`w-full flex items-center px-4 py-3 rounded-lg text-base font-medium transition-all min-h-touch ${
- isActive('/leaderboard')
- ? 'bg-gradient-to-r from-accent-primary to-accent-secondary text-white shadow-glow-cyan'
- : 'text-text-secondary hover:text-accent-primary hover:bg-dark-tertiary'
- }`}
- >
-
- 实时排名
-
-
{/* Show user-specific navigation only when logged in */}
{user && (
<>
{showNewAnalysis && (
handleNavigation('/dashboard')}
+ onClick={() => handleNavigation('/')}
className={`w-full flex items-center px-4 py-3 rounded-lg text-base font-medium transition-all min-h-touch ${
- isActive('/dashboard')
+ isActive('/')
? 'bg-gradient-to-r from-accent-primary to-accent-secondary text-white shadow-glow-cyan'
: 'text-text-secondary hover:text-accent-primary hover:bg-dark-tertiary'
}`}
>
- 新建分析
+ 新建对话
)}
@@ -373,20 +335,6 @@ export function AppNavbar({ user, onLogout, showNewAnalysis = true, showUserMana
定期报告
- {(user?.role === 'admin' || user?.can_access_intraday_trading) && (
-
handleNavigation('/intraday-trading')}
- className={`w-full flex items-center px-4 py-3 rounded-lg text-base font-medium transition-all min-h-touch ${
- isActive('/intraday-trading')
- ? 'bg-gradient-to-r from-success-500 to-success-600 text-white'
- : 'text-text-secondary hover:text-success-500 hover:bg-dark-tertiary'
- }`}
- >
-
- 智能盯盘
-
- )}
-
{/* Mobile User Menu */}
{user && (
diff --git a/web/frontend/src/components/leaderboard/Footer.tsx b/web/frontend/src/components/common/Footer.tsx
similarity index 100%
rename from web/frontend/src/components/leaderboard/Footer.tsx
rename to web/frontend/src/components/common/Footer.tsx
diff --git a/web/frontend/src/components/conversation/Composer.tsx b/web/frontend/src/components/conversation/Composer.tsx
new file mode 100644
index 0000000..f075b44
--- /dev/null
+++ b/web/frontend/src/components/conversation/Composer.tsx
@@ -0,0 +1,76 @@
+'use client';
+
+import React, { useState, useRef, KeyboardEvent } from 'react';
+
+interface ComposerProps {
+ onSend: (text: string) => void;
+ onStop: () => void;
+ isStreaming: boolean;
+ disabled?: boolean;
+ placeholder?: string;
+}
+
+export function Composer({ onSend, onStop, isStreaming, disabled, placeholder }: ComposerProps) {
+ const [value, setValue] = useState('');
+ const taRef = useRef
(null);
+
+ const submit = () => {
+ const text = value.trim();
+ if (!text || isStreaming || disabled) return;
+ onSend(text);
+ setValue('');
+ if (taRef.current) taRef.current.style.height = 'auto';
+ };
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ submit();
+ }
+ };
+
+ const autoGrow = (el: HTMLTextAreaElement) => {
+ el.style.height = 'auto';
+ el.style.height = `${Math.min(el.scrollHeight, 180)}px`;
+ };
+
+ return (
+
+
+
+
Enter 发送 · Shift+Enter 换行
+
+ );
+}
diff --git a/web/frontend/src/components/conversation/ConversationWorkbench.tsx b/web/frontend/src/components/conversation/ConversationWorkbench.tsx
new file mode 100644
index 0000000..c3c5657
--- /dev/null
+++ b/web/frontend/src/components/conversation/ConversationWorkbench.tsx
@@ -0,0 +1,90 @@
+'use client';
+
+import React, { useState } from 'react';
+import { useAuth } from '@/lib/auth';
+import { useConversation } from '@/lib/conversation-context';
+import { SessionSidebar } from './SessionSidebar';
+import { MessageFlow } from './MessageFlow';
+import { Composer } from './Composer';
+import { LoginNudge } from './LoginNudge';
+import { InspectorPanel } from './InspectorPanel';
+
+export function ConversationWorkbench() {
+ const { user } = useAuth();
+ const { sendMessage, stopAnalysis, isStreaming, streamingError } = useConversation();
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+ const [inspectorOpen, setInspectorOpen] = useState(false);
+
+ return (
+
+ {/* Left sidebar (desktop) */}
+
+
+ {/* Mobile sidebar drawer */}
+ {sidebarOpen && (
+
+
setSidebarOpen(false)} aria-hidden="true" />
+
+ setSidebarOpen(false)} />
+
+
+ )}
+
+ {/* Main column */}
+
+ {/* Top context bar */}
+
+
+ setSidebarOpen(true)}
+ className="md:hidden text-text-secondary hover:text-text-primary"
+ aria-label="打开对话列表"
+ >
+
+
+ 投研对话工作台
+
+
setInspectorOpen(true)}
+ className="lg:hidden text-text-secondary hover:text-text-primary"
+ aria-label="打开检查面板"
+ >
+
+
+
+
+ {streamingError && (
+
+
+ {streamingError}
+
+ )}
+
+
sendMessage(t)} />
+
+ {user ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Right inspector (desktop) */}
+
+
+ {/* Mobile inspector drawer */}
+ {inspectorOpen && (
+
+
setInspectorOpen(false)} aria-hidden="true" />
+
+
+
+
+ )}
+
+ );
+}
diff --git a/web/frontend/src/components/conversation/InspectorPanel.tsx b/web/frontend/src/components/conversation/InspectorPanel.tsx
new file mode 100644
index 0000000..759b468
--- /dev/null
+++ b/web/frontend/src/components/conversation/InspectorPanel.tsx
@@ -0,0 +1,70 @@
+'use client';
+
+import React from 'react';
+import { useConversation } from '@/lib/conversation-context';
+import { RatingScale } from '@/components/analysis/RatingScale';
+
+const STATUS_COLOR: Record
= {
+ healthy: 'text-success-500',
+ degraded: 'text-warning-500',
+ unavailable: 'text-danger-500',
+};
+
+const STATUS_LABEL: Record = {
+ healthy: '正常',
+ degraded: '降级',
+ unavailable: '不可用',
+};
+
+export function InspectorPanel() {
+ const { skillsHealth, reports } = useConversation();
+ const reportIds = Object.keys(reports);
+ const lastId = reportIds.length > 0 ? reportIds[reportIds.length - 1] : undefined;
+ const current = lastId ? reports[lastId] : null;
+
+ return (
+
+
+
当前研究对象
+ {current ? (
+
+
+ {current.ticker}
+ {current.market}
+
+
+ {current.company_name &&
{current.company_name}
}
+
+ ) : (
+
发起分析后将在此显示标的与评级。
+ )}
+
+
+
+
技能健康
+ {skillsHealth.length === 0 ? (
+
暂无数据
+ ) : (
+
+ {skillsHealth.map((s) => (
+
+
+ {s.display_name}
+
+
+
+ {STATUS_LABEL[s.status]}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/web/frontend/src/components/conversation/LoginNudge.tsx b/web/frontend/src/components/conversation/LoginNudge.tsx
new file mode 100644
index 0000000..0c3d8d7
--- /dev/null
+++ b/web/frontend/src/components/conversation/LoginNudge.tsx
@@ -0,0 +1,22 @@
+'use client';
+
+import React from 'react';
+import { useRouter } from 'next/navigation';
+
+export function LoginNudge() {
+ const router = useRouter();
+ return (
+
+
+
+ 发送消息前请先登录,草稿将为你保留
+
+
router.push('/login')}
+ className="px-4 py-2 rounded-lg bg-gradient-to-r from-accent-primary to-accent-secondary text-white text-sm font-medium hover:shadow-glow-cyan transition-all"
+ >
+ 登录 / 注册
+
+
+ );
+}
diff --git a/web/frontend/src/components/conversation/MessageBubble.tsx b/web/frontend/src/components/conversation/MessageBubble.tsx
new file mode 100644
index 0000000..de8617c
--- /dev/null
+++ b/web/frontend/src/components/conversation/MessageBubble.tsx
@@ -0,0 +1,93 @@
+'use client';
+
+import React from 'react';
+import ReactMarkdown from 'react-markdown';
+import rehypeSanitize from 'rehype-sanitize';
+import type { Message, Report } from '@/types/conversation';
+import { StageProgress } from '@/components/analysis/StageProgress';
+import { ReportCard } from '@/components/analysis/ReportCard';
+
+interface MessageBubbleProps {
+ message: Message;
+ reports: Record;
+}
+
+export function MessageBubble({ message, reports }: MessageBubbleProps) {
+ const isUser = message.role === 'user';
+ const isSystem = message.role === 'system';
+
+ if (isSystem) {
+ return (
+
+ {message.content}
+
+ );
+ }
+
+ return (
+
+
+ {/* Avatar */}
+
+
+
+
+ {/* Body */}
+
+
+ {message.content_blocks && message.content_blocks.length > 0 ? (
+
+ ) : (
+ {message.content}
+ )}
+
+
+
+
+ );
+}
+
+function Blocks({
+ blocks,
+ reports,
+ user,
+}: {
+ blocks: Message['content_blocks'];
+ reports: Record;
+ user: boolean;
+}) {
+ if (!blocks) return null;
+ return (
+ <>
+ {blocks.map((b, i) => {
+ if (b.type === 'text') {
+ return (
+
+ {b.content}
+
+ );
+ }
+ if (b.type === 'stage_progress') {
+ return ;
+ }
+ if (b.type === 'report') {
+ const report = reports[b.report_id];
+ if (!report) return null;
+ return ;
+ }
+ return null;
+ })}
+ >
+ );
+}
diff --git a/web/frontend/src/components/conversation/MessageFlow.tsx b/web/frontend/src/components/conversation/MessageFlow.tsx
new file mode 100644
index 0000000..7f3e37c
--- /dev/null
+++ b/web/frontend/src/components/conversation/MessageFlow.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import React, { useEffect, useRef } from 'react';
+import { useConversation } from '@/lib/conversation-context';
+import { MessageBubble } from './MessageBubble';
+import { PromptChips } from './PromptChips';
+import { StreamingCursor } from './StreamingCursor';
+
+export function MessageFlow({ onPickPrompt }: { onPickPrompt: (text: string) => void }) {
+ const { messages, reports, isStreaming, activeSessionId } = useConversation();
+ const bottomRef = useRef(null);
+
+ useEffect(() => {
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [messages, isStreaming]);
+
+ const isEmpty = messages.length === 0;
+
+ return (
+
+
+ {isEmpty ? (
+
+
+
+
+
开始你的投研对话
+
+ 用自然语言描述需求,多智能体团队会流式推进分析,并在对话内产出结构化报告。
+
+
+
+ ) : (
+ <>
+ {messages.map((m) => (
+
+ ))}
+ {isStreaming && (
+
+ )}
+ >
+ )}
+
+
+ {!activeSessionId && !isEmpty && null}
+
+ );
+}
diff --git a/web/frontend/src/components/conversation/PromptChips.tsx b/web/frontend/src/components/conversation/PromptChips.tsx
new file mode 100644
index 0000000..c322381
--- /dev/null
+++ b/web/frontend/src/components/conversation/PromptChips.tsx
@@ -0,0 +1,25 @@
+'use client';
+
+import React from 'react';
+
+const SUGGESTIONS = [
+ '分析 600519.SH 的基本面与风险',
+ '帮我看看 0700.HK 最近走势',
+ '对比 AAPL 的市场技术与舆情',
+];
+
+export function PromptChips({ onPick }: { onPick: (text: string) => void }) {
+ return (
+
+ {SUGGESTIONS.map((s) => (
+ onPick(s)}
+ className="px-3 py-2 rounded-full border border-dark-border bg-dark-secondary text-text-secondary text-sm hover:border-accent-primary hover:text-accent-primary transition-colors"
+ >
+ {s}
+
+ ))}
+
+ );
+}
diff --git a/web/frontend/src/components/conversation/SessionItem.tsx b/web/frontend/src/components/conversation/SessionItem.tsx
new file mode 100644
index 0000000..0cf6126
--- /dev/null
+++ b/web/frontend/src/components/conversation/SessionItem.tsx
@@ -0,0 +1,105 @@
+'use client';
+
+import React, { useState } from 'react';
+import type { Session } from '@/types/conversation';
+
+interface SessionItemProps {
+ session: Session;
+ active: boolean;
+ onSelect: (id: string) => void;
+ onRename: (id: string, title: string) => void;
+ onDelete: (id: string) => void;
+}
+
+export function SessionItem({ session, active, onSelect, onRename, onDelete }: SessionItemProps) {
+ const [menuOpen, setMenuOpen] = useState(false);
+ const [editing, setEditing] = useState(false);
+ const [title, setTitle] = useState(session.title);
+
+ const commitRename = () => {
+ const t = title.trim();
+ if (t && t !== session.title) onRename(session.id, t);
+ setEditing(false);
+ setMenuOpen(false);
+ };
+
+ return (
+ !editing && onSelect(session.id)}
+ >
+
+
+ {editing ? (
+
setTitle(e.target.value)}
+ onBlur={commitRename}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') commitRename();
+ if (e.key === 'Escape') {
+ setTitle(session.title);
+ setEditing(false);
+ }
+ }}
+ className="w-full bg-dark-primary text-text-primary text-sm rounded px-1 outline-none border border-accent-primary"
+ onClick={(e) => e.stopPropagation()}
+ />
+ ) : (
+
{session.title}
+ )}
+ {session.last_message_preview && !editing && (
+
{session.last_message_preview}
+ )}
+
+
+ {session.has_active_analysis && !editing && (
+
+ )}
+
+ {!editing && (
+
{
+ e.stopPropagation();
+ setMenuOpen((o) => !o);
+ }}
+ className="opacity-0 group-hover:opacity-100 text-text-tertiary hover:text-text-primary transition-opacity"
+ aria-label="会话菜单"
+ >
+
+
+ )}
+
+ {menuOpen && (
+
e.stopPropagation()}
+ >
+ {
+ setEditing(true);
+ setMenuOpen(false);
+ }}
+ >
+
+ 重命名
+
+ {
+ if (confirm('确定删除该对话?')) onDelete(session.id);
+ setMenuOpen(false);
+ }}
+ >
+
+ 删除
+
+
+ )}
+
+ );
+}
diff --git a/web/frontend/src/components/conversation/SessionSidebar.tsx b/web/frontend/src/components/conversation/SessionSidebar.tsx
new file mode 100644
index 0000000..91ae4bb
--- /dev/null
+++ b/web/frontend/src/components/conversation/SessionSidebar.tsx
@@ -0,0 +1,77 @@
+'use client';
+
+import React, { useMemo, useState } from 'react';
+import { useConversation } from '@/lib/conversation-context';
+import { SessionItem } from './SessionItem';
+
+export function SessionSidebar({ onClose }: { onClose?: () => void }) {
+ const { sessions, activeSessionId, loadingSessions, createSession, selectSession, renameSession, deleteSession } =
+ useConversation();
+ const [query, setQuery] = useState('');
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return sessions;
+ return sessions.filter((s) => s.title.toLowerCase().includes(q) || (s.last_message_preview ?? '').toLowerCase().includes(q));
+ }, [sessions, query]);
+
+ return (
+
+ {/* Header */}
+
+
+
对话
+
+
+
+
+
{
+ createSession();
+ onClose?.();
+ }}
+ className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-gradient-to-r from-accent-primary to-accent-secondary text-white text-sm font-medium hover:shadow-glow-cyan transition-all"
+ >
+
+ 新建对话
+
+
setQuery(e.target.value)}
+ placeholder="搜索对话"
+ className="mt-2 w-full bg-dark-tertiary text-text-primary text-sm rounded-lg px-3 py-2 outline-none border border-dark-border focus:border-accent-primary"
+ aria-label="搜索对话"
+ />
+
+
+ {/* List */}
+
+ {loadingSessions ? (
+
加载中…
+ ) : filtered.length === 0 ? (
+
+ {sessions.length === 0 ? '还没有对话,点击「新建对话」开始' : '无匹配对话'}
+
+ ) : (
+ filtered.map((s) => (
+
{
+ selectSession(id);
+ onClose?.();
+ }}
+ onRename={renameSession}
+ onDelete={deleteSession}
+ />
+ ))
+ )}
+
+
+ );
+}
diff --git a/web/frontend/src/components/conversation/StreamingCursor.tsx b/web/frontend/src/components/conversation/StreamingCursor.tsx
new file mode 100644
index 0000000..0da77de
--- /dev/null
+++ b/web/frontend/src/components/conversation/StreamingCursor.tsx
@@ -0,0 +1,11 @@
+'use client';
+
+import React from 'react';
+
+export function StreamingCursor() {
+ return (
+
+
+
+ );
+}
diff --git a/web/frontend/src/components/conversation/index.ts b/web/frontend/src/components/conversation/index.ts
new file mode 100644
index 0000000..d6de8ce
--- /dev/null
+++ b/web/frontend/src/components/conversation/index.ts
@@ -0,0 +1,9 @@
+export { SessionSidebar } from './SessionSidebar';
+export { SessionItem } from './SessionItem';
+export { MessageFlow } from './MessageFlow';
+export { MessageBubble } from './MessageBubble';
+export { Composer } from './Composer';
+export { LoginNudge } from './LoginNudge';
+export { PromptChips } from './PromptChips';
+export { StreamingCursor } from './StreamingCursor';
+export { InspectorPanel } from './InspectorPanel';
diff --git a/web/frontend/src/components/home/FeaturesShowcase.tsx b/web/frontend/src/components/home/FeaturesShowcase.tsx
deleted file mode 100644
index 02b290f..0000000
--- a/web/frontend/src/components/home/FeaturesShowcase.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-'use client';
-
-import React from 'react';
-
-interface FeatureCardProps {
- icon: string;
- title: string;
- description: string;
- details: string[];
-}
-
-function FeatureCard({ icon, title, description, details }: FeatureCardProps) {
- return (
-
-
- {/* Icon */}
-
-
-
-
- {/* Title */}
-
{title}
-
- {/* Description */}
-
{description}
-
- {/* Details */}
-
- {details.map((detail, index) => (
-
-
- {detail}
-
- ))}
-
-
-
- );
-}
-
-export function FeaturesShowcase() {
- const features: FeatureCardProps[] = [
- {
- icon: 'fas fa-project-diagram',
- title: '多智能体工作流',
- description: '基于 LangGraph 的智能体协作系统,模拟真实投资团队的工作流程',
- details: [
- '市场分析师:技术面、基本面、新闻面、社交媒体分析',
- '研究团队:多空双方深度研究报告',
- '交易员:综合决策与执行建议',
- '风险管理:风险评估与投资组合优化',
- ],
- },
- {
- icon: 'fas fa-globe',
- title: '全球市场覆盖',
- description: '支持美股、港股、A股三大市场,自动选择最优数据源',
- details: [
- '美股:yfinance、akshare、alpha_vantage',
- '港股:akshare、yfinance 双重支持',
- 'A股:akshare、baostock、tushare',
- '智能数据源切换与容错机制',
- ],
- },
- {
- icon: 'fas fa-bolt',
- title: '实时分析引擎',
- description: 'WebSocket 实时通信,任务队列管理,支持多用户并发',
- details: [
- '实时进度追踪与日志流式传输',
- '用户级任务队列防止资源冲突',
- '支持定时任务与历史分析查询',
- '完整的分析结果导出(PDF/Markdown/图片)',
- ],
- },
- ];
-
- return (
-
- {/* Background decoration */}
-
-
-
- {/* Section Header */}
-
-
- 核心功能
-
-
- 强大的 AI 驱动分析能力,为您的投资决策提供全方位支持
-
-
-
- {/* Features Grid */}
-
- {features.map((feature, index) => (
-
-
-
- ))}
-
-
- {/* Workflow Diagram */}
-
-
- 智能体协作流程
-
-
- {[
- { icon: 'fa-search-dollar', label: '分析师团队', color: 'from-blue-500 to-cyan-500' },
- { icon: 'fa-book-reader', label: '研究团队', color: 'from-cyan-500 to-teal-500' },
- { icon: 'fa-hand-holding-usd', label: '交易员', color: 'from-teal-500 to-green-500' },
- { icon: 'fa-shield-alt', label: '风险管理', color: 'from-green-500 to-emerald-500' },
- { icon: 'fa-chart-pie', label: '投资组合', color: 'from-emerald-500 to-accent-primary' },
- ].map((step, index) => (
-
-
-
-
-
-
- {step.label}
-
-
- {index < 4 && (
- <>
-
-
- >
- )}
-
- ))}
-
-
-
-
- );
-}
diff --git a/web/frontend/src/components/home/HeroSection.tsx b/web/frontend/src/components/home/HeroSection.tsx
deleted file mode 100644
index 2aa8654..0000000
--- a/web/frontend/src/components/home/HeroSection.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-'use client';
-
-import React from 'react';
-
-interface HeroSectionProps {
- onNewAnalysis: () => void;
-}
-
-export function HeroSection({ onNewAnalysis }: HeroSectionProps) {
- return (
-
- {/* Animated background elements */}
-
-
- {/* Content */}
-
-
- {/* Logo/Icon */}
-
-
- {/* Main Title */}
-
-
- TradingAgentsWeb
-
-
-
- {/* Subtitle */}
-
- 多智能体大语言模型金融交易框架
-
-
- {/* Description */}
-
- 基于 LangGraph 的多智能体协作系统,整合分析师团队、研究团队、交易员和风险管理团队,
- 为您提供全方位的智能投资决策支持
-
-
- {/* Features Grid */}
-
- {/* Feature 1 */}
-
-
-
-
-
-
多智能体协作
-
- 分析师、研究员、交易员、风险管理团队协同工作
-
-
-
-
- {/* Feature 2 */}
-
-
-
-
-
-
多市场支持
-
- 支持美股、港股、A股三大市场的全面分析
-
-
-
-
- {/* Feature 3 */}
-
-
-
-
-
-
实时分析追踪
-
- WebSocket 实时追踪分析进度和结果
-
-
-
-
-
- {/* CTA Button */}
-
-
- {/* Button shimmer effect */}
-
-
- {/* Button content */}
-
-
- 开始新分析
-
-
-
-
-
- {/* Scroll indicator */}
-
-
-
-
-
-
- );
-}
diff --git a/web/frontend/src/components/intraday/AccountInfo.tsx b/web/frontend/src/components/intraday/AccountInfo.tsx
deleted file mode 100644
index 1f88643..0000000
--- a/web/frontend/src/components/intraday/AccountInfo.tsx
+++ /dev/null
@@ -1,221 +0,0 @@
-'use client';
-
-import React, { useEffect, useState } from 'react';
-import { useAccountInfo, intradayTradingKeys } from '@/hooks/useIntradayTrading';
-import { useQueryClient } from '@tanstack/react-query';
-import { AccountTrendModal } from './AccountTrendModal';
-
-interface AccountInfoProps {
- selectedMarket: string;
- onMarketChange: (market: string) => void;
- onShowToast: (message: string, type: 'success' | 'error' | 'info') => void;
-}
-
-export function AccountInfo({ selectedMarket, onMarketChange, onShowToast }: AccountInfoProps) {
- const { data: account, isLoading, error, refetch, isFetching } = useAccountInfo(selectedMarket);
- const queryClient = useQueryClient();
- const [showTrendModal, setShowTrendModal] = useState(false);
- const [selectedMetric, setSelectedMetric] = useState<'total_assets' | 'cash' | 'market_value'>('total_assets');
-
- // Handle refresh - refresh account, positions, and orders
- const handleRefresh = () => {
- refetch();
- // Also refresh positions and orders for the same market
- queryClient.invalidateQueries({
- queryKey: [...intradayTradingKeys.positions(), selectedMarket]
- });
- queryClient.invalidateQueries({
- queryKey: [...intradayTradingKeys.orders(), selectedMarket]
- });
- };
-
- // Removed auto-refresh - now using WebSocket for real-time updates
-
- const handleOpenTrendModal = (metric: 'total_assets' | 'cash' | 'market_value') => {
- setSelectedMetric(metric);
- setShowTrendModal(true);
- };
-
- if (isLoading) {
- return (
-
- );
- }
-
- if (error) {
- return (
-
- );
- }
-
- const totalAssets = account?.total_assets || 0;
- const cash = account?.cash || 0;
- const positionValue = account?.position_value || 0;
- const todayProfitLoss = account?.today_profit_loss || 0;
- const todayProfitLossRatio = account?.today_profit_loss_ratio || 0;
-
- // Determine currency based on market type
- const getCurrencySymbol = (market: string) => {
- switch (market.toUpperCase()) {
- case 'US':
- return '$';
- case 'HK':
- return 'HK$';
- case 'CN':
- return '¥';
- default:
- return '$';
- }
- };
- const currency = getCurrencySymbol(selectedMarket);
-
- const positionRatio = totalAssets > 0 ? (positionValue / totalAssets) * 100 : 0;
-
- return (
-
-
-
-
-
- 账户信息
-
-
- {/* Market selector - button style */}
-
- {['US', 'HK', 'CN'].map((market) => (
- onMarketChange(market)}
- className={`px-2 sm:px-3 py-1 rounded-md text-xs sm:text-sm font-medium transition-all ${
- selectedMarket === market
- ? 'bg-accent-primary text-white'
- : 'bg-dark-tertiary text-text-secondary hover:bg-dark-primary hover:text-text-primary'
- }`}
- >
- {market === 'US' ? '美股' : market === 'HK' ? '港股' : 'A股'}
-
- ))}
-
-
-
- {isFetching ? '刷新中...' : '刷新'}
- {isFetching ? '...' : ''}
-
-
-
-
-
-
- {/* Total Assets */}
-
-
- 总资产
-
-
-
- {currency}{totalAssets.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
-
-
-
= 0 ? 'text-[#f03a55]' : 'text-[#00a870]'}`}>
- {todayProfitLoss >= 0 ? '+' : ''}{currency}{todayProfitLoss.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ({todayProfitLoss >= 0 ? '+' : ''}{(todayProfitLossRatio * 100).toFixed(2)}%)
-
-
handleOpenTrendModal('total_assets')}
- className="text-blue-400 hover:text-blue-300 transition-colors p-2 hover:bg-blue-900/30 rounded-md"
- title="查看趋势图"
- >
-
-
-
-
-
- {/* Available Cash */}
-
-
- 可用资金
-
-
-
- {currency}{cash.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
-
-
-
- {totalAssets > 0 ? ((cash / totalAssets) * 100).toFixed(1) : 0}% 现金比例
-
-
handleOpenTrendModal('cash')}
- className="text-green-400 hover:text-green-300 transition-colors p-2 hover:bg-green-900/30 rounded-md"
- title="查看趋势图"
- >
-
-
-
-
-
- {/* Position Value */}
-
-
- 持仓市值
-
-
-
- {currency}{positionValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
-
-
-
- {positionRatio.toFixed(1)}% 仓位占比
-
-
handleOpenTrendModal('market_value')}
- className="text-purple-400 hover:text-purple-300 transition-colors p-2 hover:bg-purple-900/30 rounded-md"
- title="查看趋势图"
- >
-
-
-
-
-
-
- {/* Position Ratio Warning */}
- {positionRatio > 90 && (
-
-
-
-
-
仓位预警
-
- 当前仓位占比 {positionRatio.toFixed(1)}% 已超过90%,建议控制仓位风险
-
-
-
-
- )}
-
-
- {/* Trend Modal */}
- {showTrendModal && (
-
setShowTrendModal(false)}
- onShowToast={onShowToast}
- />
- )}
-
- );
-}
diff --git a/web/frontend/src/components/intraday/AccountTrendModal.tsx b/web/frontend/src/components/intraday/AccountTrendModal.tsx
deleted file mode 100644
index 296425b..0000000
--- a/web/frontend/src/components/intraday/AccountTrendModal.tsx
+++ /dev/null
@@ -1,270 +0,0 @@
-'use client';
-
-import { useState, useEffect } from 'react';
-import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
-import { getAccountTrend, type TrendResponse } from '@/lib/api/accountSnapshots';
-import { format } from 'date-fns';
-import { zhCN } from 'date-fns/locale';
-
-interface AccountTrendModalProps {
- marketType: string;
- metric: 'total_assets' | 'cash' | 'market_value';
- onClose: () => void;
- onShowToast: (message: string, type: 'success' | 'error' | 'info') => void;
-}
-
-const metricLabels = {
- total_assets: '总资产',
- cash: '可用资金',
- market_value: '持仓市值',
-};
-
-const metricColors = {
- total_assets: '#3b82f6', // blue
- cash: '#10b981', // green
- market_value: '#f59e0b', // amber
-};
-
-export function AccountTrendModal({
- marketType,
- metric,
- onClose,
- onShowToast,
-}: AccountTrendModalProps) {
- const [timeRange, setTimeRange] = useState<'today' | 7 | 30 | 90 | 365>('today');
- const [loading, setLoading] = useState(true);
- const [trendData, setTrendData] = useState(null);
-
- useEffect(() => {
- loadTrendData();
- }, [timeRange, marketType]);
-
- const loadTrendData = async () => {
- setLoading(true);
- try {
- const todayOnly = timeRange === 'today';
- const days = typeof timeRange === 'number' ? timeRange : 1;
- const data = await getAccountTrend(marketType, days, todayOnly);
- setTrendData(data);
- } catch (error: any) {
- onShowToast(error.response?.data?.detail || '加载趋势数据失败', 'error');
- } finally {
- setLoading(false);
- }
- };
-
- const formatCurrency = (value: number) => {
- // Determine currency based on market type
- const getCurrencySymbol = (market: string) => {
- switch (market.toUpperCase()) {
- case 'US':
- return '$';
- case 'HK':
- return 'HK$';
- case 'CN':
- return '¥';
- default:
- return '$';
- }
- };
- const currencySymbol = getCurrencySymbol(marketType);
-
- // Simple formatting with currency symbol prefix
- return `${currencySymbol}${value.toFixed(2)}`;
- };
-
- const formatDate = (dateStr: string) => {
- try {
- const date = new Date(dateStr);
- return format(date, 'MM/dd', { locale: zhCN });
- } catch {
- return dateStr;
- }
- };
-
- const calculateChange = () => {
- if (!trendData || trendData.data.length < 2) return null;
-
- const firstValue = trendData.data[0][metric];
- const lastValue = trendData.data[trendData.data.length - 1][metric];
- const change = lastValue - firstValue;
- const percentage = (change / firstValue) * 100;
-
- return { change, percentage };
- };
-
- const change = calculateChange();
-
- return (
-
-
- {/* Header - Fixed */}
-
-
-
-
- {metricLabels[metric]} - {marketType}
-
- {change && (
-
- = 0 ? 'text-[#f03a55]' : 'text-[#00a870]'}`}>
- {change.change >= 0 ? '+' : ''}{formatCurrency(change.change)}
- {' '}
- ({change.change >= 0 ? '+' : ''}{change.percentage.toFixed(2)}%)
-
-
- {timeRange === 'today' ? '今日变化' : `${timeRange}天变化`}
-
-
- )}
-
-
-
-
-
-
- {/* Time Range Selector - Fixed */}
-
-
- {[
- { value: 'today' as const, label: '今日', icon: 'fa-clock' },
- { value: 7 as const, label: '7天', icon: 'fa-calendar-week' },
- { value: 30 as const, label: '30天', icon: 'fa-calendar-alt' },
- { value: 90 as const, label: '90天', icon: 'fa-calendar' },
- { value: 365 as const, label: '1年', icon: 'fa-calendar-check' },
- ].map((range) => (
- setTimeRange(range.value as any)}
- className={`px-3 md:px-4 py-1.5 md:py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${
- timeRange === range.value
- ? 'bg-accent-primary text-white'
- : 'bg-dark-secondary text-text-secondary hover:bg-dark-primary'
- }`}
- >
-
- {range.label}
-
- ))}
-
-
-
- {/* Chart */}
-
- {loading ? (
-
-
- 加载中...
-
- ) : trendData && trendData.data.length > 0 ? (
-
-
-
-
-
- `$${(value / 1000).toFixed(0)}k`}
- stroke="#9ca3af"
- style={{ fontSize: window.innerWidth < 768 ? '12px' : '14px' }}
- />
- [formatCurrency(value), metricLabels[metric]]}
- labelFormatter={(label) => `日期: ${label}`}
- />
-
-
-
-
-
- {/* Stats Summary */}
-
-
-
最高值
-
- {formatCurrency(Math.max(...trendData.data.map(d => d[metric])))}
-
-
-
-
最低值
-
- {formatCurrency(Math.min(...trendData.data.map(d => d[metric])))}
-
-
-
-
平均值
-
- {formatCurrency(
- trendData.data.reduce((sum, d) => sum + d[metric], 0) / trendData.data.length
- )}
-
-
-
-
- ) : (
-
- {timeRange === 'today' ? (
- <>
-
-
市场已休市
-
- {marketType} 市场当前没有交易数据
-
- >
- ) : (
- <>
-
-
暂无数据
-
- 系统还没有记录账户快照数据
-
- >
- )}
-
- )}
-
-
- {/* Footer - Fixed */}
-
-
- 关闭
-
-
-
-
- );
-}
diff --git a/web/frontend/src/components/intraday/ControlPanel.tsx b/web/frontend/src/components/intraday/ControlPanel.tsx
deleted file mode 100644
index 91bcb3f..0000000
--- a/web/frontend/src/components/intraday/ControlPanel.tsx
+++ /dev/null
@@ -1,887 +0,0 @@
-'use client';
-
-import React, { useState, useEffect } from 'react';
-import { useQuery } from '@tanstack/react-query';
-import { useSchedulerStatus, useSchedulerControl, useSchedulerConfig } from '@/hooks/useIntradayTrading';
-import { configAPI } from '@/lib/apiClient';
-import { PromptConfigTab } from './PromptConfigTab';
-
-interface ControlPanelProps {
- onShowToast: (message: string, type: 'success' | 'error' | 'info') => void;
-}
-
-interface LLMProvider {
- value: string;
- label: string;
- description: string;
-}
-
-interface Model {
- value: string;
- label: string;
-}
-
-export function ControlPanel({ onShowToast }: ControlPanelProps) {
- const { data: status, isLoading } = useSchedulerStatus();
- const { data: config, isLoading: configLoading } = useSchedulerConfig();
- const { start, stop, updateConfig } = useSchedulerControl();
- const [interval, setInterval] = useState(5);
- const [futuApiUrl, setFutuApiUrl] = useState('');
- const [futuApiKey, setFutuApiKey] = useState('');
- const [selectedMarkets, setSelectedMarkets] = useState(['US', 'HK', 'CN']); // 默认全选
- const [isUpdating, setIsUpdating] = useState(false);
- const [showConfigModal, setShowConfigModal] = useState(false);
- const [activeConfigTab, setActiveConfigTab] = useState<'basic' | 'prompt'>('basic');
- const [showApiKey, setShowApiKey] = useState(false);
- const [configValidated, setConfigValidated] = useState(false);
- const [validatingConfig, setValidatingConfig] = useState(false);
- const [pendingAction, setPendingAction] = useState<'start' | 'stop' | null>(null);
-
-
- // LLM Configuration states
- const [llmProvider, setLlmProvider] = useState('');
- const [llmApiKey, setLlmApiKey] = useState('');
- const [showLlmApiKey, setShowLlmApiKey] = useState(false); // 默认隐藏密钥保护隐私
- const [llmKeyValidated, setLlmKeyValidated] = useState(false);
- const [validatingLlmKey, setValidatingLlmKey] = useState(false);
- const [llmModel, setLlmModel] = useState(''); // 只需要一个模型(深度思考模型)
- const [backendUrl, setBackendUrl] = useState(''); // Backend URL for custom endpoints
-
- // API配置数据 - 使用 React Query 缓存
- const { data: apiConfig, isLoading: loadingApiConfig } = useQuery({
- queryKey: ['api-config'],
- queryFn: () => configAPI.getConfig(),
- staleTime: 5 * 60 * 1000, // 5 minutes
- gcTime: 10 * 60 * 1000, // 10 minutes
- });
-
- // Load config on mount
- useEffect(() => {
- if (config) {
- setInterval(config.interval_minutes || 60);
- setFutuApiUrl(config.futu_api_url || '');
- setFutuApiKey(config.futu_api_key || '');
- setLlmProvider(config.llm_provider || '');
- setLlmApiKey(config.api_key || ''); // 使用 api_key 字段(从 last_api_key 或 intraday_api_key 获取)
- setLlmModel(config.llm_model || ''); // 加载深度思考模型
- setBackendUrl(config.backend_url || ''); // 加载 backend URL
-
- // Load market selection (always comma-separated or single)
- if (config.market_type) {
- if (config.market_type.includes(',')) {
- // Handle comma-separated markets like "US,HK,CN"
- const markets = config.market_type.split(',').map((m: string) => m.trim());
- // Filter out invalid values like "ALL" (legacy data)
- const validMarkets = markets.filter((m: string) => ['US', 'HK', 'CN'].includes(m));
- setSelectedMarkets(validMarkets.length > 0 ? validMarkets : ['US', 'HK', 'CN']);
- } else if (['US', 'HK', 'CN'].includes(config.market_type)) {
- setSelectedMarkets([config.market_type]);
- } else {
- // Handle legacy "ALL" or invalid values - default to all markets
- setSelectedMarkets(['US', 'HK', 'CN']);
- }
- }
-
- // If config exists and has API key, mark as validated
- if (config.futu_api_url && config.has_futu_api_key) {
- setConfigValidated(true);
- }
- // If LLM config exists and has API key (or is ollama), mark as validated
- if (config.llm_provider && (config.llm_provider === 'ollama' || config.api_key)) {
- setLlmKeyValidated(true);
- }
- // Only show config modal if no config at all (not even from analysis page)
- if (!config.futu_api_url) {
- setShowConfigModal(true);
- }
- }
- }, [config]);
-
- // Clear loading state when status changes
- useEffect(() => {
- if (pendingAction && status) {
- if (pendingAction === 'start' && status.is_running) {
- setIsUpdating(false);
- setPendingAction(null);
- } else if (pendingAction === 'stop' && !status.is_running) {
- setIsUpdating(false);
- setPendingAction(null);
- }
- }
- }, [status?.is_running, pendingAction]);
-
-
-
- // Reset validation when config changes
- const handleFutuApiUrlChange = (value: string) => {
- setFutuApiUrl(value);
- setConfigValidated(false);
- };
-
- const handleFutuApiKeyChange = (value: string) => {
- setFutuApiKey(value);
- setConfigValidated(false);
- };
-
- const handleLlmProviderChange = (value: string) => {
- setLlmProvider(value);
- setLlmKeyValidated(false);
-
- // Auto-set backend URL from provider config
- const providers = getLlmProviders();
- const selectedProvider = providers.find(p => p.value === value);
- if (selectedProvider && 'url' in selectedProvider) {
- setBackendUrl((selectedProvider as any).url);
- } else {
- setBackendUrl('');
- }
- };
-
- const handleLlmApiKeyChange = (value: string) => {
- setLlmApiKey(value);
- setLlmKeyValidated(false);
- };
-
- const handleLlmModelChange = (value: string) => {
- setLlmModel(value);
- };
-
- // Get LLM providers from API config
- const getLlmProviders = (): LLMProvider[] => {
- if (apiConfig?.llm_providers) {
- return apiConfig.llm_providers;
- }
- // Fallback to default providers
- return [
- { value: 'openai', label: 'OpenAI', description: 'GPT系列模型' },
- { value: 'anthropic', label: 'Anthropic', description: 'Claude系列模型' },
- { value: 'google', label: 'Google', description: 'Gemini系列模型' },
- { value: 'openrouter', label: 'OpenRouter', description: '多模型聚合平台' },
- { value: 'deepseek', label: 'Deepseek', description: 'Deepseek系列模型' },
- { value: 'qwen', label: 'Qwen', description: '通义千问系列模型' },
- { value: 'oneai', label: 'OneAI', description: '多模型聚合平台' },
- { value: 'ollama', label: 'Ollama', description: '本地模型服务' }
- ];
- };
-
- // Get models for provider from API config
- const getModelsForProvider = (provider: string, type: 'shallow' | 'deep'): Model[] => {
- const providerKey = provider.toLowerCase();
-
- // Try to get from API config first
- if (apiConfig?.models?.[providerKey]?.[type]) {
- return apiConfig.models[providerKey][type];
- }
-
- // Fallback to default models
- const defaultModels: Record> = {
- openai: {
- shallow: [
- { value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
- { value: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo' }
- ],
- deep: [
- { value: 'gpt-4o', label: 'GPT-4o' },
- { value: 'gpt-4-turbo', label: 'GPT-4 Turbo' },
- { value: 'gpt-4', label: 'GPT-4' }
- ]
- },
- anthropic: {
- shallow: [
- { value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' }
- ],
- deep: [
- { value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
- { value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
- { value: 'claude-3-sonnet-20240229', label: 'Claude 3 Sonnet' }
- ]
- },
- google: {
- shallow: [
- { value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash' }
- ],
- deep: [
- { value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' },
- { value: 'gemini-1.0-pro', label: 'Gemini 1.0 Pro' }
- ]
- },
- openrouter: {
- shallow: [
- { value: 'openai/gpt-3.5-turbo', label: 'GPT-3.5 Turbo' },
- { value: 'anthropic/claude-3-haiku', label: 'Claude 3 Haiku' }
- ],
- deep: [
- { value: 'openai/gpt-4-turbo', label: 'GPT-4 Turbo' },
- { value: 'anthropic/claude-3-opus', label: 'Claude 3 Opus' },
- { value: 'google/gemini-pro', label: 'Gemini Pro' }
- ]
- },
- deepseek: {
- shallow: [
- { value: 'deepseek-chat', label: 'DeepSeek Chat' }
- ],
- deep: [
- { value: 'deepseek-reasoner', label: 'DeepSeek Reasoner' }
- ]
- },
- qwen: {
- shallow: [
- { value: 'qwen-turbo', label: 'Qwen Turbo' }
- ],
- deep: [
- { value: 'qwen-max', label: 'Qwen Max' },
- { value: 'qwen-plus', label: 'Qwen Plus' }
- ]
- },
- oneai: {
- shallow: [
- { value: 'openai/gpt-3.5-turbo', label: 'GPT-3.5 Turbo' },
- { value: 'anthropic/claude-3-haiku', label: 'Claude 3 Haiku' }
- ],
- deep: [
- { value: 'openai/gpt-4-turbo', label: 'GPT-4 Turbo' },
- { value: 'anthropic/claude-3-opus', label: 'Claude 3 Opus' },
- { value: 'google/gemini-pro', label: 'Gemini Pro' }
- ]
- },
- ollama: {
- shallow: [
- { value: 'llama3.2', label: 'Llama 3.2' },
- { value: 'mistral', label: 'Mistral' },
- { value: 'phi3', label: 'Phi-3' }
- ],
- deep: [
- { value: 'llama3.1:70b', label: 'Llama 3.1 70B' },
- { value: 'mixtral:8x7b', label: 'Mixtral 8x7B' },
- { value: 'qwen2.5:72b', label: 'Qwen 2.5 72B' }
- ]
- }
- };
- return defaultModels[providerKey]?.[type] || [];
- };
-
- // Validate LLM API Key using shared config API
- const validateLlmKey = async () => {
- if (!llmProvider || llmProvider === 'ollama') {
- onShowToast('Ollama 不需要验证 API 密钥', 'info');
- return;
- }
-
- if (!llmApiKey) {
- onShowToast('请先输入 LLM API 密钥', 'error');
- return;
- }
-
- try {
- setValidatingLlmKey(true);
- const result = await configAPI.validateAPIKey(llmProvider, llmApiKey);
-
- if (result.valid) {
- setLlmKeyValidated(true);
- onShowToast('LLM API 密钥验证成功', 'success');
- } else {
- setLlmKeyValidated(false);
- onShowToast(result.message || 'LLM API 密钥格式不正确', 'error');
- }
- } catch (error: any) {
- setLlmKeyValidated(false);
- onShowToast(error.message || 'LLM API 密钥验证失败', 'error');
- } finally {
- setValidatingLlmKey(false);
- }
- };
-
- const validateConfig = async () => {
- if (!futuApiUrl) {
- onShowToast('请先输入富途API地址', 'error');
- return;
- }
-
- if (!futuApiKey) {
- onShowToast('请先输入富途API密钥', 'error');
- return;
- }
-
- try {
- setValidatingConfig(true);
- const { intradayTradingAPI } = await import('@/lib/apiClient');
- const result = await intradayTradingAPI.validateConfig({
- futu_api_url: futuApiUrl,
- futu_api_key: futuApiKey,
- });
-
- if (result.valid) {
- setConfigValidated(true);
- onShowToast('富途API配置验证成功', 'success');
- } else {
- setConfigValidated(false);
- onShowToast(result.message || '富途API配置验证失败', 'error');
- }
- } catch (error: any) {
- setConfigValidated(false);
- onShowToast(error.message || '验证失败', 'error');
- } finally {
- setValidatingConfig(false);
- }
- };
-
- const handleSaveConfig = async () => {
- if (!futuApiUrl) {
- onShowToast('请输入富途API地址', 'error');
- return;
- }
-
- if (!futuApiKey) {
- onShowToast('请输入富途API密钥', 'error');
- return;
- }
-
- if (interval < 5 || interval > 120) {
- onShowToast('分析间隔必须在5-120分钟之间', 'error');
- return;
- }
-
- if (!configValidated) {
- onShowToast('请先验证富途API配置', 'error');
- return;
- }
-
- // Validate LLM config if provided
- if (llmProvider && llmProvider !== 'ollama' && llmApiKey && !llmKeyValidated) {
- onShowToast('请先验证 LLM API 密钥', 'error');
- return;
- }
-
- // Validate model selection if LLM provider is configured
- if (llmProvider && (llmKeyValidated || llmProvider === 'ollama')) {
- if (!llmModel) {
- onShowToast('请选择 LLM 模型', 'error');
- return;
- }
- }
-
- // Validate market selection
- if (selectedMarkets.length === 0) {
- onShowToast('请至少选择一个市场', 'error');
- return;
- }
-
- try {
- setIsUpdating(true);
- const configData: any = {
- futu_api_url: futuApiUrl,
- futu_api_key: futuApiKey,
- interval_minutes: interval,
- market_type: selectedMarkets.join(','), // Always use comma-separated format
- };
-
- // Add LLM config if provided
- if (llmProvider) {
- configData.llm_provider = llmProvider;
- }
- if (llmApiKey) {
- configData.api_key = llmApiKey; // 使用 api_key 而不是 llm_api_key
- }
- if (llmModel) {
- configData.llm_model = llmModel;
- }
- if (backendUrl) {
- configData.backend_url = backendUrl;
- }
-
- await updateConfig.mutateAsync(configData);
- onShowToast('配置已保存', 'success');
- setShowConfigModal(false);
- setFutuApiKey(''); // Clear for security
- setLlmApiKey(''); // Clear for security
- } catch (error: any) {
- onShowToast(error.message || '保存失败', 'error');
- } finally {
- setIsUpdating(false);
- }
- };
-
- const handleStart = async () => {
- if (!futuApiUrl) {
- onShowToast('请先配置富途API地址', 'error');
- setShowConfigModal(true);
- return;
- }
-
- try {
- setIsUpdating(true);
- setPendingAction('start');
- onShowToast('正在启动系统...', 'info');
- await start.mutateAsync();
- // Success toast will be shown when WebSocket confirms
- // isUpdating will be cleared when status changes
- } catch (error: any) {
- onShowToast(error.message || '启动失败', 'error');
- setIsUpdating(false);
- setPendingAction(null);
- }
- };
-
- const handleStop = async () => {
- try {
- setIsUpdating(true);
- setPendingAction('stop');
- onShowToast('正在停止系统...', 'info');
- await stop.mutateAsync();
- // Success toast will be shown when WebSocket confirms
- // isUpdating will be cleared when status changes
- } catch (error: any) {
- onShowToast(error.message || '停止失败', 'error');
- setIsUpdating(false);
- setPendingAction(null);
- }
- };
-
- const handleUpdateInterval = async () => {
- if (interval < 5 || interval > 120) {
- onShowToast('分析间隔必须在5-120分钟之间', 'error');
- return;
- }
-
- try {
- setIsUpdating(true);
- await updateConfig.mutateAsync({ interval_minutes: interval });
- onShowToast('分析间隔已更新', 'success');
- } catch (error: any) {
- onShowToast(error.message || '更新失败', 'error');
- } finally {
- setIsUpdating(false);
- }
- };
-
- if (isLoading || configLoading) {
- return (
-
- );
- }
-
- const isRunning = status?.is_running || false;
- const currentInterval = status?.interval_minutes || interval;
- const nextRunTime = status?.next_run_time;
-
- return (
- <>
- {/* Compact Control Bar */}
-
-
- {/* Left: Status */}
-
-
-
-
- {isRunning ? '运行中' : '已停止'}
-
-
- {isRunning && nextRunTime && (
-
-
- 下次分析时间:
- 下次:
- {new Date(nextRunTime).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
-
- )}
-
-
- {/* Right: Control Buttons */}
-
- {/* Start/Stop Button */}
- {isRunning ? (
-
- {isUpdating && pendingAction === 'stop' ? (
-
- ) : (
-
- )}
- 停止
-
- ) : (
-
- {isUpdating && pendingAction === 'start' ? (
-
- ) : (
-
- )}
- 启动
-
- )}
-
- {/* Config Button */}
- setShowConfigModal(true)}
- className="w-8 h-8 md:w-auto md:h-auto md:px-4 md:py-2 text-xs md:text-sm bg-dark-tertiary text-text-primary rounded-md hover:bg-dark-primary border border-dark-border transition-colors flex items-center justify-center"
- title="系统配置"
- >
-
-
-
-
-
-
- {/* Configuration Modal */}
- {showConfigModal && (
-
-
-
-
-
- 系统配置
-
- setShowConfigModal(false)}
- className="text-text-muted hover:text-text-secondary"
- >
-
-
-
-
- {/* Tabs - Fixed */}
-
-
- setActiveConfigTab('basic')}
- className={`py-4 px-6 text-sm font-medium border-b-2 ${
- activeConfigTab === 'basic'
- ? 'border-accent-primary text-accent-primary'
- : 'border-transparent text-text-secondary hover:text-text-primary hover:border-dark-border'
- }`}
- >
-
- 基础配置
-
- setActiveConfigTab('prompt')}
- className={`py-4 px-6 text-sm font-medium border-b-2 ${
- activeConfigTab === 'prompt'
- ? 'border-accent-primary text-accent-primary'
- : 'border-transparent text-text-secondary hover:text-text-primary hover:border-dark-border'
- }`}
- >
-
- 提示词配置
-
-
-
-
- {/* Content Area - Scrollable */}
-
- {activeConfigTab === 'basic' ? (
- <>
- {/* Info banner if using analysis config */}
- {config?.is_using_analysis_config && futuApiUrl && (
-
-
-
-
-
当前使用启动分析页面的配置
-
如需单独配置智能盯盘,请修改下方设置并保存
-
-
-
- )}
-
- {/* Futu API URL */}
-
-
- 富途API地址 *
-
-
handleFutuApiUrlChange(e.target.value)}
- placeholder="http://localhost:8080"
- className="w-full px-3 py-2 bg-dark-tertiary border border-dark-border text-text-primary rounded-md focus:outline-none focus:ring-2 focus:ring-accent-primary"
- />
-
- 富途OpenAPI服务地址
-
-
-
- {/* Futu API Key with Validate Button */}
-
-
-
- 富途API密钥 *
-
-
-
- handleFutuApiKeyChange(e.target.value)}
- placeholder="请输入富途API密钥"
- className="w-full px-3 py-2 bg-dark-tertiary border border-dark-border text-text-primary rounded-md focus:outline-none focus:ring-2 focus:ring-accent-primary focus:border-transparent"
- required
- />
- setShowApiKey(!showApiKey)}
- >
-
-
-
-
- {validatingConfig ? (
- <>
-
- 验证中
- >
- ) : configValidated ? (
- <>
-
- 已验证
- >
- ) : (
- <>
-
- 验证
- >
- )}
-
-
-
-
- 富途OpenAPI密钥,用于身份验证
-
-
-
- {/* Interval */}
-
-
- 分析间隔(分钟) *
-
-
setInterval(Number(e.target.value))}
- min={5}
- max={120}
- className="w-full px-3 py-2 bg-dark-tertiary border border-dark-border text-text-primary rounded-md focus:outline-none focus:ring-2 focus:ring-accent-primary"
- />
-
- 系统每隔多少分钟执行一次分析(范围:5-120分钟,默认60分钟)
-
-
-
- {/* Market Selection */}
-
-
- 分析市场 *
-
-
- {[
- { value: 'US', label: '美股', icon: '🇺🇸' },
- { value: 'HK', label: '港股', icon: '🇭🇰' },
- { value: 'CN', label: 'A股', icon: '🇨🇳' },
- ].map((market) => (
-
- {
- if (e.target.checked) {
- setSelectedMarkets([...selectedMarkets, market.value]);
- } else {
- setSelectedMarkets(selectedMarkets.filter(m => m !== market.value));
- }
- }}
- className="w-4 h-4 text-accent-primary border-dark-border rounded focus:ring-accent-primary bg-dark-tertiary"
- />
-
- {market.icon} {market.label}
-
-
- ))}
-
-
- 选择要分析的市场,可多选。系统会按顺序检查每个市场的开盘状态
-
-
-
- {/* LLM Configuration Section */}
-
-
-
- LLM 配置
-
-
- 配置用于智能盯盘分析的 LLM 服务商和模型,如不配置将使用分析页面的缓存配置
-
-
- {/* LLM Provider */}
-
-
- LLM 提供商 *
-
-
handleLlmProviderChange(e.target.value)}
- className="w-full px-3 py-2 bg-dark-tertiary border border-dark-border text-text-primary rounded-md focus:outline-none focus:ring-2 focus:ring-accent-primary"
- required
- disabled={loadingApiConfig}
- >
-
- {loadingApiConfig ? '加载中...' : '请选择 LLM 服务商...'}
-
- {getLlmProviders().map((provider) => (
-
- {provider.label} - {provider.description}
-
- ))}
-
-
- 选择用于智能盯盘分析的 LLM 提供商
-
-
-
- {/* LLM API Key */}
- {llmProvider && llmProvider !== 'ollama' && (
-
-
-
- LLM API 密钥 *
-
-
-
- handleLlmApiKeyChange(e.target.value)}
- placeholder={`请输入 ${llmProvider.toUpperCase()} API 密钥`}
- className="w-full px-3 py-2 bg-dark-tertiary border border-dark-border text-text-primary rounded-md focus:outline-none focus:ring-2 focus:ring-accent-primary focus:border-transparent"
- required
- />
- setShowLlmApiKey(!showLlmApiKey)}
- >
-
-
-
-
- {validatingLlmKey ? (
- <>
-
- 验证中
- >
- ) : llmKeyValidated ? (
- <>
-
- 已验证
- >
- ) : (
- <>
-
- 验证
- >
- )}
-
-
-
-
- 用于访问 {llmProvider.toUpperCase()} 服务的 API 密钥
-
-
- )}
-
- {/* Model Selection - Show when API key is validated or using Ollama */}
- {llmProvider && (llmKeyValidated || llmProvider === 'ollama') && (
-
-
-
- 选择 LLM 模型
-
-
-
-
- LLM 模型 *
-
-
handleLlmModelChange(e.target.value)}
- className="w-full px-3 py-2 bg-dark-tertiary border border-dark-border text-text-primary rounded-md focus:outline-none focus:ring-2 focus:ring-accent-primary"
- required
- >
- 选择模型...
- {getModelsForProvider(llmProvider, 'deep').map((model) => (
-
- {model.label}
-
- ))}
-
-
- 用于智能盯盘分析和决策(使用深度思考模型选项)
-
-
-
- )}
-
-
-
- setShowConfigModal(false)}
- className="px-4 py-2 text-text-primary bg-dark-tertiary rounded-md hover:bg-dark-primary border border-dark-border"
- >
- 取消
-
-
-
- 保存配置
-
-
- >
- ) : (
-
- )}
-
-
-
- )}
- >
- );
-}
diff --git a/web/frontend/src/components/intraday/DecisionHistory.tsx b/web/frontend/src/components/intraday/DecisionHistory.tsx
deleted file mode 100644
index faee815..0000000
--- a/web/frontend/src/components/intraday/DecisionHistory.tsx
+++ /dev/null
@@ -1,469 +0,0 @@
-'use client';
-
-import React, { useState } from 'react';
-import { useDecisions } from '@/hooks/useIntradayTrading';
-import { formatDistanceToNow } from 'date-fns';
-import { zhCN } from 'date-fns/locale';
-import ReactMarkdown from 'react-markdown';
-import rehypeRaw from 'rehype-raw';
-import rehypeSanitize from 'rehype-sanitize';
-import { buildApiUrl } from '@/utils/api';
-import { getCurrencySymbol } from '@/utils/marketCurrency';
-
-interface DecisionHistoryProps {
- onShowToast: (message: string, type: 'success' | 'error' | 'info') => void;
-}
-
-export function DecisionHistory({ onShowToast }: DecisionHistoryProps) {
- const [detailModalId, setDetailModalId] = useState(null);
- const [detailSequenceNumber, setDetailSequenceNumber] = useState(null);
- const [detailData, setDetailData] = useState(null);
- const [loadingDetail, setLoadingDetail] = useState(false);
-
- // Fetch latest 20 decisions (no pagination)
- const { data, isLoading, error } = useDecisions(1, 20);
-
- const handleViewDetail = async (id: number, sequenceNumber: number) => {
- setDetailModalId(id);
- setDetailSequenceNumber(sequenceNumber);
- setLoadingDetail(true);
-
- try {
- // Fetch full decision details from API
- const response = await fetch(buildApiUrl(`/api/intraday/decisions/${id}`), {
- headers: {
- 'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
- },
- });
-
- if (!response.ok) {
- throw new Error('Failed to fetch decision details');
- }
-
- const data = await response.json();
- setDetailData(data);
- } catch (error: any) {
- onShowToast(error.message || '获取决策详情失败', 'error');
- setDetailModalId(null);
- setDetailSequenceNumber(null);
- } finally {
- setLoadingDetail(false);
- }
- };
-
- const handleCloseDetail = () => {
- setDetailModalId(null);
- setDetailSequenceNumber(null);
- setDetailData(null);
- };
-
- if (isLoading) {
- return (
-
- );
- }
-
- if (error) {
- return (
-
- );
- }
-
- const decisions = (data as any)?.items || [];
- const total = (data as any)?.total || 0;
-
- const getStatusBadge = (status: string) => {
- const defaultBadge = { color: 'bg-green-500/20 text-green-400 border border-green-500/50', icon: 'fa-check-circle', label: '已完成' };
- const badges: Record = {
- running: { color: 'bg-blue-500/20 text-blue-400 border border-blue-500/50', icon: 'fa-spinner fa-spin', label: '运行中' },
- completed: defaultBadge,
- failed: { color: 'bg-red-500/20 text-red-400 border border-red-500/50', icon: 'fa-times-circle', label: '失败' },
- };
-
- const badge = badges[status] || defaultBadge;
-
- return (
-
-
- {badge.label}
-
- );
- };
-
- return (
-
-
-
-
- 决策历史
-
-
-
- {decisions.length === 0 ? (
-
-
-
暂无决策记录
-
- 系统还没有生成任何决策记录
-
-
- ) : (
-
- {decisions.map((decision: any, index: number) => {
- // Calculate user-specific sequence number
- // Since decisions are sorted by time DESC (newest first),
- // the sequence number should be: total - index
- // This gives: newest = total, oldest = 1
- const sequenceNumber = total - index;
-
- // Get market label and color
- const getMarketInfo = (market: string) => {
- switch (market?.toUpperCase()) {
- case 'US':
- return { label: '美股', color: 'bg-blue-500/20 text-blue-400 border-blue-500/50' };
- case 'HK':
- return { label: '港股', color: 'bg-purple-500/20 text-purple-400 border-purple-500/50' };
- case 'CN':
- return { label: 'A股', color: 'bg-red-500/20 text-red-400 border-red-500/50' };
- default:
- return { label: market || '未知', color: 'bg-gray-500/20 text-gray-400 border-gray-500/50' };
- }
- };
-
- const marketInfo = getMarketInfo(decision.market_type);
-
- return (
-
- {/* Decision Card - Click to open detail modal */}
-
handleViewDetail(decision.id, sequenceNumber)}
- >
-
-
-
-
- 决策 #{sequenceNumber}
-
-
- {marketInfo.label}
-
- {getStatusBadge(decision.status)}
-
-
-
-
- {new Date(decision.start_time).toLocaleString('zh-CN')}
-
-
-
- 执行 {decision.trades_count ?? decision.trades_executed?.length ?? 0} 笔交易
-
-
- {decision.end_time && (
-
- {formatDistanceToNow(new Date(decision.end_time), {
- addSuffix: true,
- locale: zhCN,
- })}
-
- )}
-
-
-
-
-
-
-
- );
- })}
-
- )}
-
- {/* Total count display */}
- {total > 0 && (
-
-
- 共 {total} 条决策记录,只显示最新的20条
-
-
- )}
-
-
- {/* Detail Modal */}
- {detailModalId && (
-
-
- {/* Modal Header - Fixed */}
-
-
-
-
- 决策详情 #{detailSequenceNumber || detailModalId}
-
- {detailData?.market_type && (() => {
- const getMarketInfo = (market: string) => {
- switch (market?.toUpperCase()) {
- case 'US':
- return { label: '美股', color: 'bg-blue-500/20 text-blue-400 border-blue-500/50' };
- case 'HK':
- return { label: '港股', color: 'bg-purple-500/20 text-purple-400 border-purple-500/50' };
- case 'CN':
- return { label: 'A股', color: 'bg-red-500/20 text-red-400 border-red-500/50' };
- default:
- return { label: market, color: 'bg-gray-500/20 text-gray-400 border-gray-500/50' };
- }
- };
- const marketInfo = getMarketInfo(detailData.market_type);
- return (
-
- {marketInfo.label}
-
- );
- })()}
-
-
-
-
-
-
- {/* Modal Body */}
-
- {loadingDetail ? (
-
-
- 加载详情...
-
- ) : detailData ? (
-
- {/* Session Info */}
-
-
-
-
-
-
会话ID
-
{detailData.session_id}
-
-
-
-
-
-
市场
-
{detailData.market_type}
-
-
-
-
-
-
开始时间
-
- {new Date(detailData.start_time).toLocaleString('zh-CN')}
-
-
-
-
-
-
-
结束时间
-
- {detailData.end_time ? new Date(detailData.end_time).toLocaleString('zh-CN') : '-'}
-
-
-
-
-
-
- {/* Trades Executed */}
- {detailData.trades_executed && detailData.trades_executed.length > 0 && (
-
-
-
- 执行交易 ({detailData.trades_executed.length})
-
-
- {detailData.trades_executed.map((trade: any, idx: number) => {
- // Get currency symbol based on market type
- const currencySymbol = getCurrencySymbol(detailData.market_type || 'US');
-
- return (
-
-
-
-
- {trade.action === 'BUY' ? '买入' : trade.action === 'SELL' ? '卖出' : trade.action || '未知'}
-
-
- {trade.stock || '未知股票'}
-
-
- {trade.price && (
-
- {currencySymbol}{trade.price}
-
- )}
-
- {trade.quantity && (
-
-
- 数量: {trade.quantity} 股
-
- )}
- {trade.description && (
-
-
- {trade.description}
-
- )}
-
- );
- })}
-
-
- )}
-
- {/* Full Decision Report */}
- {detailData.decision_report && (
-
-
-
- 完整决策报告
-
-
-
-
(
-
- ),
- h2: ({node, ...props}) => (
-
- ),
- h3: ({node, ...props}) => (
-
- ),
- h4: ({node, ...props}) => (
-
- ),
- h5: ({node, ...props}) => (
-
- ),
- h6: ({node, ...props}) => (
-
- ),
- p: ({node, ...props}) => (
-
- ),
- ul: ({node, ...props}) => (
-
- ),
- ol: ({node, ...props}) => (
-
- ),
- li: ({node, ...props}) => (
-
- ),
- strong: ({node, ...props}) => (
-
- ),
- em: ({node, ...props}) => (
-
- ),
- code: ({node, inline, ...props}: any) =>
- inline
- ?
- : ,
- pre: ({node, ...props}) => (
-
- ),
- blockquote: ({node, ...props}) => (
-
- ),
- table: ({node, ...props}) => (
-
- ),
- thead: ({node, ...props}) => (
-
- ),
- tbody: ({node, ...props}) => (
-
- ),
- tr: ({node, ...props}) => (
-
- ),
- th: ({node, ...props}) => (
-
- ),
- td: ({node, ...props}) => (
-
- ),
- a: ({node, ...props}) => (
-
- ),
- hr: ({node, ...props}) => (
-
- ),
- img: ({node, ...props}) => (
-
- ),
- }}
- >
- {detailData.decision_report}
-
-
-
-
- )}
-
- ) : (
-
- 无法加载详情
-
- )}
-
-
- {/* Modal Footer */}
-
-
- 关闭
-
-
-
-
- )}
-
- );
-}
diff --git a/web/frontend/src/components/intraday/PositionOverview.tsx b/web/frontend/src/components/intraday/PositionOverview.tsx
deleted file mode 100644
index d6d0280..0000000
--- a/web/frontend/src/components/intraday/PositionOverview.tsx
+++ /dev/null
@@ -1,326 +0,0 @@
-'use client';
-
-import React, { useState } from 'react';
-import { usePositions } from '@/hooks/useIntradayTrading';
-
-interface PositionOverviewProps {
- selectedMarket: string;
- onShowToast: (message: string, type: 'success' | 'error' | 'info') => void;
-}
-
-type SortField = 'stock_code' | 'holding_days' | 'pnl_percent' | 'position_ratio';
-type SortOrder = 'asc' | 'desc';
-
-export function PositionOverview({ selectedMarket, onShowToast }: PositionOverviewProps) {
- const { data: positions, isLoading, error } = usePositions(selectedMarket);
- const [sortField, setSortField] = useState('stock_code');
- const [sortOrder, setSortOrder] = useState('asc');
-
- // Determine currency based on market type
- const getCurrencySymbol = (market: string) => {
- switch (market.toUpperCase()) {
- case 'US':
- return '$';
- case 'HK':
- return 'HK$';
- case 'CN':
- return '¥';
- default:
- return '$';
- }
- };
- const currency = getCurrencySymbol(selectedMarket);
-
- /**
- * Generate Futu stock detail page URL
- *
- * URL format:
- * - US stocks: https://www.futunn.com/stock/NVDA-US
- * - HK stocks: https://www.futunn.com/stock/02258-HK
- * - CN stocks (SH): https://www.futunn.com/stock/688670-SH
- * - CN stocks (SZ): https://www.futunn.com/stock/301017-SZ
- */
- const getFutuStockUrl = (stockCode: string, marketType: string): string => {
- const market = marketType.toUpperCase();
-
- // Extract pure stock code using regex
- // For HK/CN: extract digits (e.g., "HK.00700" or "00700.HK" -> "00700")
- // For US: extract letters, prefer longer match (e.g., "US.AAPL" -> "AAPL", not "US")
- let pureCode = stockCode;
- if (market === 'US') {
- // Extract all letter sequences and pick the longest one (to avoid matching "US" in "US.AAPL")
- const matches = stockCode.match(/[A-Z]+/g);
- if (matches && matches.length > 0) {
- pureCode = matches.reduce((a, b) => a.length >= b.length ? a : b);
- }
- } else {
- // Extract digits for HK/CN stocks
- const match = stockCode.match(/\d+/);
- pureCode = match ? match[0] : stockCode;
- }
-
- if (market === 'US') {
- // US stocks: AAPL, NVDA, etc.
- // Format: https://www.futunn.com/stock/NVDA-US
- return `https://www.futunn.com/stock/${pureCode}-US`;
- } else if (market === 'HK') {
- // HK stocks: 00700, 02258, etc.
- // Format: https://www.futunn.com/stock/02258-HK
- return `https://www.futunn.com/stock/${pureCode}-HK`;
- } else if (market === 'CN') {
- // CN stocks: need to determine SH or SZ
- // Shanghai: 60xxxx, 688xxx (科创板)
- // Shenzhen: 00xxxx, 30xxxx (创业板), 002xxx (中小板)
- // Format: https://www.futunn.com/stock/688670-SH or https://www.futunn.com/stock/301017-SZ
- if (pureCode.startsWith('60') || pureCode.startsWith('688')) {
- return `https://www.futunn.com/stock/${pureCode}-SH`;
- } else {
- return `https://www.futunn.com/stock/${pureCode}-SZ`;
- }
- }
-
- // Fallback
- return `https://www.futunn.com/stock/${pureCode}`;
- };
-
- const handleStockClick = (stockCode: string, marketType: string) => {
- const url = getFutuStockUrl(stockCode, marketType);
- window.open(url, '_blank', 'noopener,noreferrer');
- };
-
- const handleSort = (field: SortField) => {
- if (sortField === field) {
- setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
- } else {
- setSortField(field);
- setSortOrder('asc');
- }
- };
-
- if (isLoading) {
- return (
-
- );
- }
-
- if (error) {
- return (
-
- );
- }
-
- // Sort positions (no need to filter by market since API already filters)
- let filteredPositions = [...(positions || [])].sort((a, b) => {
- let aVal: any = a[sortField];
- let bVal: any = b[sortField];
-
- if (sortField === 'stock_code') {
- aVal = aVal.toString();
- bVal = bVal.toString();
- }
-
- if (sortOrder === 'asc') {
- return aVal > bVal ? 1 : -1;
- } else {
- return aVal < bVal ? 1 : -1;
- }
- });
-
- // Group by market
- const groupedPositions = filteredPositions.reduce((acc, pos) => {
- const market = pos.market_type;
- if (!acc[market]) {
- acc[market] = [];
- }
- acc[market].push(pos);
- return acc;
- }, {} as Record);
-
- const marketLabels: Record = {
- US: '美股',
- HK: '港股',
- CN: 'A股',
- };
-
- const getMarketBadgeColor = (market: string) => {
- switch (market?.toUpperCase()) {
- case 'US':
- return 'bg-blue-500/20 text-blue-400 border border-blue-500/50';
- case 'HK':
- return 'bg-purple-500/20 text-purple-400 border border-purple-500/50';
- case 'CN':
- return 'bg-red-500/20 text-red-400 border border-red-500/50';
- default:
- return 'bg-gray-500/20 text-gray-400 border border-gray-500/50';
- }
- };
-
- const getSortIcon = (field: SortField) => {
- if (sortField !== field) {
- return ;
- }
- return sortOrder === 'asc' ? (
-
- ) : (
-
- );
- };
-
- return (
-
-
-
-
- 持仓概览
-
-
-
- {filteredPositions.length === 0 ? (
-
-
-
暂无持仓
-
- 当前没有持仓股票
-
-
- ) : (
-
-
-
-
- handleSort('stock_code')}
- className="px-2 md:px-4 py-2 md:py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-tight cursor-pointer hover:bg-dark-primary whitespace-nowrap"
- >
-
- 股票代码
- {getSortIcon('stock_code')}
-
-
-
- 市场
-
- handleSort('holding_days')}
- className="px-2 md:px-4 py-2 md:py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-tight cursor-pointer hover:bg-dark-primary whitespace-nowrap"
- >
-
- 持仓天数
- {getSortIcon('holding_days')}
-
-
-
- 数量
-
-
- 成本价
-
-
- 当前价
-
- handleSort('pnl_percent')}
- className="px-2 md:px-4 py-2 md:py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-tight cursor-pointer hover:bg-dark-primary whitespace-nowrap"
- >
-
- 盈亏
- {getSortIcon('pnl_percent')}
-
-
- handleSort('position_ratio')}
- className="px-2 md:px-4 py-2 md:py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-tight cursor-pointer hover:bg-dark-primary whitespace-nowrap"
- >
-
- 仓位占比
- {getSortIcon('position_ratio')}
-
-
-
-
-
- {filteredPositions.map((position, index) => (
-
-
- handleStockClick(position.stock_code, position.market_type)}
- className="text-left hover:opacity-80 transition-opacity group"
- title="点击查看富途股票详情"
- >
-
- {position.stock_code}
-
-
- {position.stock_name && (
-
- {position.stock_name}
-
- )}
-
-
-
-
- {position.market_type}
-
-
-
- {position.holding_days || 0} 天
-
-
- {position.quantity?.toLocaleString() || 0}
-
-
- {currency}{position.cost_price?.toFixed(2) || '0.00'}
-
-
- {currency}{position.current_price?.toFixed(2) || '0.00'}
-
-
-
-
= 0 ? 'text-[#f03a55]' : 'text-[#00a870]'}`}>
- {(position.pnl || 0) >= 0 ? '+' : ''}{currency}{(position.pnl || 0).toFixed(2)}
-
-
= 0 ? 'text-[#f03a55]' : 'text-[#00a870]'}`}>
- {(position.pnl_percent || 0) >= 0 ? '+' : ''}{(position.pnl_percent || 0).toFixed(2)}%
-
-
-
-
-
-
-
- {(position.position_ratio || 0).toFixed(1)}%
-
-
-
= 0
- ? 'bg-[#f03a55]'
- : 'bg-[#00a870]'
- }`}
- style={{ width: `${Math.min(position.position_ratio || 0, 100)}%` }}
- />
-
-
-
-
-
- ))}
-
-
-
- )}
-
-
- );
-}
diff --git a/web/frontend/src/components/intraday/PromptConfigTab.tsx b/web/frontend/src/components/intraday/PromptConfigTab.tsx
deleted file mode 100644
index 69f49da..0000000
--- a/web/frontend/src/components/intraday/PromptConfigTab.tsx
+++ /dev/null
@@ -1,354 +0,0 @@
-'use client';
-
-import { useState, useEffect } from 'react';
-import {
- getPromptTemplate,
- updatePromptTemplate,
- resetToDefault,
- type PromptTemplate,
-} from '@/lib/api/prompts';
-
-interface PromptConfigTabProps {
- onShowToast: (message: string, type: 'success' | 'error' | 'info') => void;
-}
-
-export function PromptConfigTab({ onShowToast }: PromptConfigTabProps) {
- const [template, setTemplate] = useState(null);
- const [editedPrompt, setEditedPrompt] = useState('');
- const [templateName, setTemplateName] = useState('');
- const [description, setDescription] = useState('');
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [hasChanges, setHasChanges] = useState(false);
- const [validating, setValidating] = useState(false);
- const [validationResult, setValidationResult] = useState(null);
- const [showResetConfirm, setShowResetConfirm] = useState(false);
-
- useEffect(() => {
- loadData();
- }, []);
-
- useEffect(() => {
- if (template) {
- const changed =
- editedPrompt !== template.system_prompt ||
- templateName !== (template.template_name || '') ||
- description !== (template.description || '');
- setHasChanges(changed);
- }
- }, [editedPrompt, templateName, description, template]);
-
- const loadData = async () => {
- setLoading(true);
- try {
- const templateData = await getPromptTemplate('intraday_trader');
- setTemplate(templateData);
- setEditedPrompt(templateData.system_prompt);
- setTemplateName(templateData.template_name || '');
- setDescription(templateData.description || '');
- } catch (error: any) {
- onShowToast(error.response?.data?.detail || '加载配置失败', 'error');
- } finally {
- setLoading(false);
- }
- };
-
- const handleSave = async () => {
- if (!hasChanges) {
- onShowToast('没有需要保存的更改', 'info');
- return;
- }
-
- if (templateName.length > 200) {
- onShowToast('策略名称不能超过200个字符', 'error');
- return;
- }
-
- if (description.length > 500) {
- onShowToast('策略描述不能超过500个字符', 'error');
- return;
- }
-
- if (editedPrompt.length > 20000) {
- onShowToast('提示词不能超过20,000个字符(当前' + editedPrompt.length.toLocaleString() + '字符)', 'error');
- return;
- }
-
- setSaving(true);
-
- try {
- // Increment version number
- const currentVersion = template?.version || '1.0';
- const versionParts = currentVersion.split('.');
- const majorVersion = parseInt(versionParts[0]) || 1;
- const minorVersion = parseInt(versionParts[1]) || 0;
- const newVersion = `${majorVersion}.${minorVersion + 1}`;
-
- const updateData: any = {
- system_prompt: editedPrompt,
- version: newVersion,
- };
- if (templateName) updateData.template_name = templateName;
- if (description) updateData.description = description;
-
- const data = await updatePromptTemplate('intraday_trader', updateData);
-
- setTemplate(data);
- setEditedPrompt(data.system_prompt);
- setTemplateName(data.template_name || '');
- setDescription(data.description || '');
- setHasChanges(false);
- setValidationResult(null);
-
- onShowToast('配置已保存', 'success');
- } catch (error: any) {
- onShowToast(error.response?.data?.detail || '保存失败', 'error');
- } finally {
- setSaving(false);
- }
- };
-
- const handleValidate = async () => {
- setValidating(true);
- setValidationResult(null);
-
- try {
- const { validatePromptTemplate } = await import('@/lib/api/prompts');
-
- const validateData: any = {
- system_prompt: editedPrompt,
- };
- if (templateName) validateData.template_name = templateName;
- if (description) validateData.description = description;
-
- const result = await validatePromptTemplate('intraday_trader', validateData);
-
- setValidationResult(result);
-
- if (result.valid) {
- onShowToast('提示词验证通过', 'success');
- } else {
- onShowToast(result.message || '验证失败', 'error');
- }
- } catch (error: any) {
- onShowToast('验证请求失败', 'error');
- } finally {
- setValidating(false);
- }
- };
-
- const handleResetClick = () => {
- setShowResetConfirm(true);
- };
-
- const handleResetConfirm = async () => {
- setShowResetConfirm(false);
- setSaving(true);
- try {
- const data = await resetToDefault('intraday_trader');
- setTemplate(data);
- setEditedPrompt(data.system_prompt);
- setTemplateName(data.template_name || '');
- setDescription(data.description || '');
- setHasChanges(false);
- setValidationResult(null);
- onShowToast('已重置为默认配置', 'success');
- } catch (error: any) {
- onShowToast(error.response?.data?.detail || '重置失败', 'error');
- } finally {
- setSaving(false);
- }
- };
-
- const handleResetCancel = () => {
- setShowResetConfirm(false);
- };
-
- if (loading) {
- return (
-
- );
- }
-
- const charCountClass = editedPrompt.length > 20000
- ? 'text-xs text-red-500 font-semibold'
- : 'text-xs text-text-secondary';
-
- const saveButtonClass = saving || !hasChanges
- ? 'bg-gray-400 cursor-not-allowed'
- : 'bg-accent-primary hover:bg-accent-secondary';
-
- return (
-
-
-
-
- 策略名称
-
- ({templateName.length}/200)
-
-
-
setTemplateName(e.target.value)}
- maxLength={200}
- className="w-full px-3 py-2 bg-dark-tertiary border border-dark-border text-text-primary rounded-md focus:outline-none focus:ring-2 focus:ring-accent-primary"
- placeholder="例如:激进型日内交易策略"
- />
-
- 策略标题,最多200个字符
-
-
-
-
-
- 策略描述
-
- ({description.length}/500)
-
-
- setDescription(e.target.value)}
- maxLength={500}
- className="w-full px-3 py-2 bg-dark-tertiary border border-dark-border text-text-primary rounded-md focus:outline-none focus:ring-2 focus:ring-accent-primary"
- placeholder="简要描述策略特点"
- />
-
-
-
-
-
-
-
- 系统提示词
-
-
- {editedPrompt.length.toLocaleString()} / 20,000 字符
-
-
-
- {validating ? '验证中...' : '验证提示词'}
-
-
-
-
-
-
- {saving ? '保存中...' : '保存配置'}
-
-
- 重置为默认
-
- {hasChanges && (
-
-
- 有未保存的更改
-
- )}
- {validationResult?.valid && (
-
-
- 验证通过
-
- )}
-
-
- {showResetConfirm && (
-
-
-
-
-
-
-
-
- 确认重置
-
-
- 确定要重置为默认配置吗?这将清除所有自定义内容,此操作无法撤销。
-
-
-
- 取消
-
-
- 确认重置
-
-
-
-
-
-
- )}
-
- );
-}
diff --git a/web/frontend/src/components/intraday/TodayOrders.tsx b/web/frontend/src/components/intraday/TodayOrders.tsx
deleted file mode 100644
index 901c951..0000000
--- a/web/frontend/src/components/intraday/TodayOrders.tsx
+++ /dev/null
@@ -1,360 +0,0 @@
-'use client';
-
-import React, { useState } from 'react';
-import { useOrders, useCancelOrder } from '@/hooks/useIntradayTrading';
-import { getCurrencySymbol } from '@/utils/marketCurrency';
-
-interface TodayOrdersProps {
- selectedMarket: string;
- onShowToast: (message: string, type: 'success' | 'error' | 'info') => void;
-}
-
-export function TodayOrders({ selectedMarket, onShowToast }: TodayOrdersProps) {
- const [filterStatus, setFilterStatus] = useState(0); // 0=all, 1=filled, 2=pending, 3=cancelled
- const { data: orders, isLoading, error } = useOrders(selectedMarket, filterStatus);
- const cancelOrderMutation = useCancelOrder();
- const [cancellingOrderId, setCancellingOrderId] = useState(null);
- const [showCancelConfirm, setShowCancelConfirm] = useState(false);
- const [orderToCancel, setOrderToCancel] = useState<{ orderId: string; stockCode: string } | null>(null);
-
- const currency = getCurrencySymbol(selectedMarket);
-
- // Note: Orders are refreshed when:
- // 1. Market is switched
- // 2. Decision session completes
- // 3. Order is placed or cancelled (via WebSocket tool_result)
-
- const handleCancelOrderClick = (orderId: string, stockCode: string) => {
- setOrderToCancel({ orderId, stockCode });
- setShowCancelConfirm(true);
- };
-
- const handleConfirmCancel = async () => {
- if (!orderToCancel) return;
-
- setCancellingOrderId(orderToCancel.orderId);
- setShowCancelConfirm(false);
-
- try {
- await cancelOrderMutation.mutateAsync({
- orderId: orderToCancel.orderId,
- stockCode: orderToCancel.stockCode,
- marketType: selectedMarket,
- });
-
- onShowToast('订单撤销成功', 'success');
- } catch (error: any) {
- onShowToast(error.message || '订单撤销失败', 'error');
- } finally {
- setCancellingOrderId(null);
- setOrderToCancel(null);
- }
- };
-
- const handleCancelConfirm = () => {
- setShowCancelConfirm(false);
- setOrderToCancel(null);
- };
-
- const getStatusBadge = (status: string) => {
- const badges: Record = {
- filled: { color: 'bg-success-500/20 text-success-400 border border-success-500/50', icon: 'fa-check-circle', label: '已成交' },
- pending: { color: 'bg-warning-500/20 text-warning-400 border border-warning-500/50', icon: 'fa-clock', label: '待成交' },
- cancelled: { color: 'bg-gray-500/20 text-gray-400 border border-gray-500/50', icon: 'fa-times-circle', label: '已撤销' },
- rejected: { color: 'bg-red-500/20 text-red-400 border border-red-500/50', icon: 'fa-exclamation-circle', label: '已拒绝' },
- };
-
- const badge = badges[status.toLowerCase()] || badges.pending;
-
- return (
-
-
- {badge.label}
-
- );
- };
-
- const getSideBadge = (side: string) => {
- if (side.toUpperCase() === 'BUY') {
- return (
-
- 买入
-
- );
- } else {
- return (
-
- 卖出
-
- );
- }
- };
-
- const getOrderTypeBadge = (orderType: string) => {
- const isLimit = orderType.toUpperCase() === 'LIMIT';
- return (
-
- {isLimit ? '限价' : '市价'}
-
- );
- };
-
- // Backend already filters today's orders based on market local time
- // No need to filter on frontend
- const todayOrders = orders || [];
-
- if (isLoading) {
- return (
-
- );
- }
-
- if (error) {
- return (
-
- );
- }
-
- return (
-
-
-
-
-
- 今日订单
-
-
- {/* Filter Buttons */}
-
- setFilterStatus(0)}
- className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
- filterStatus === 0
- ? 'bg-accent-primary text-white'
- : 'bg-dark-tertiary text-text-secondary hover:bg-dark-primary'
- }`}
- >
- 全部
-
- setFilterStatus(1)}
- className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
- filterStatus === 1
- ? 'bg-success-500 text-white'
- : 'bg-dark-tertiary text-text-secondary hover:bg-dark-primary'
- }`}
- >
- 已成交
-
- setFilterStatus(2)}
- className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
- filterStatus === 2
- ? 'bg-warning-500 text-white'
- : 'bg-dark-tertiary text-text-secondary hover:bg-dark-primary'
- }`}
- >
- 待成交
-
- setFilterStatus(3)}
- className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
- filterStatus === 3
- ? 'bg-gray-500 text-white'
- : 'bg-dark-tertiary text-text-secondary hover:bg-dark-primary'
- }`}
- >
- 已撤销
-
-
-
-
-
-
- {todayOrders.length === 0 ? (
-
-
-
暂无订单记录
-
- 今日还没有任何订单记录
-
-
- ) : (
-
-
-
-
-
- 股票代码
-
-
- 方向
-
-
- 类型
-
-
- 状态
-
-
- 数量
-
-
- 价格
-
-
- 已成交
-
-
- 下单时间
-
-
- 操作
-
-
-
-
- {todayOrders.map((order: any) => (
-
-
-
- {order.stock_code}
-
- {order.stock_name && (
-
- {order.stock_name}
-
- )}
-
-
- {getSideBadge(order.side)}
-
-
- {getOrderTypeBadge(order.order_type)}
-
-
- {getStatusBadge(order.status)}
-
-
- {order.quantity}
-
-
- {order.price ? `${currency}${order.price}` : '-'}
-
-
- {order.filled_quantity || 0}
-
-
- {new Date(order.create_time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
-
-
- {order.status.toLowerCase() === 'pending' ? (
- handleCancelOrderClick(order.order_id, order.stock_code)}
- disabled={cancellingOrderId === order.order_id}
- className="px-2 md:px-3 py-1 bg-red-500/20 text-red-400 border border-red-500/50 rounded hover:bg-red-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-xs font-medium"
- >
- {cancellingOrderId === order.order_id ? (
- <>
-
- 撤销中
- >
- ) : (
- <>
-
- 撤单
- >
- )}
-
- ) : (
- -
- )}
-
-
- ))}
-
-
-
- )}
-
- {/* Summary */}
- {todayOrders.length > 0 && (
-
-
- 共 {todayOrders.length} 条今日订单
-
-
- )}
-
-
- {/* Cancel Confirmation Modal */}
- {showCancelConfirm && (
-
-
- {/* Modal Header */}
-
-
-
- 确认撤单
-
-
-
- {/* Modal Body */}
-
-
- 确定要撤销此订单吗?
-
- {orderToCancel && (
-
-
-
订单号
-
- {orderToCancel.orderId}
-
-
-
-
股票代码
-
- {orderToCancel.stockCode}
-
-
-
- )}
-
-
- {/* Modal Footer */}
-
-
- 取消
-
-
-
- 确认撤单
-
-
-
-
- )}
-
- );
-}
diff --git a/web/frontend/src/components/leaderboard/AnalysisCard.tsx b/web/frontend/src/components/leaderboard/AnalysisCard.tsx
deleted file mode 100644
index ac2110c..0000000
--- a/web/frontend/src/components/leaderboard/AnalysisCard.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-'use client';
-
-import React from 'react';
-
-interface AnalysisCardData {
- analysis_id: string;
- ticker: string;
- company_name?: string;
- market: string;
- analysis_date: string;
- trading_decision: string;
- completed_at: string;
- progress_percentage: number;
-}
-
-interface AnalysisCardProps {
- analysis: AnalysisCardData;
- onClick: () => void;
- market?: string;
-}
-
-export function AnalysisCard({ analysis, onClick, market }: AnalysisCardProps) {
- const tradingDecision = analysis.trading_decision || '';
-
- const getDecisionColor = (decision: string) => {
- const d = (decision || '').toLowerCase();
- // 中国市场习惯:买入红色,卖出绿色
- if (d.includes('买入') || d.includes('buy')) return 'from-[#f03a55] to-[#d02a45]';
- if (d.includes('卖出') || d.includes('sell')) return 'from-[#00a870] to-[#008860]';
- if (d.includes('持有') || d.includes('观望') || d.includes('hold')) return 'from-warning-500 to-warning-600';
- return 'from-warning-500 to-warning-600';
- };
-
- const getDecisionIcon = (decision: string) => {
- const d = (decision || '').toLowerCase();
- if (d.includes('买入') || d.includes('buy')) return 'fa-arrow-up';
- if (d.includes('卖出') || d.includes('sell')) return 'fa-arrow-down';
- if (d.includes('持有') || d.includes('观望') || d.includes('hold')) return 'fa-minus';
- return 'fa-question';
- };
-
- const formatDate = (dateString: string) => {
- try {
- const date = new Date(dateString);
- return date.toLocaleString('zh-CN', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit'
- });
- } catch {
- return dateString;
- }
- };
-
- return (
-
- {/* Top gradient bar */}
-
-
-
- {/* Header: Ticker and Decision */}
-
- {/* Left: Ticker */}
-
-
- {analysis.ticker.substring(0, 2).toUpperCase()}
-
-
-
{analysis.ticker}
-
- {analysis.market === 'US' ? '美股' : analysis.market === 'HK' ? '港股' : analysis.market === 'CN' ? 'A股' : analysis.market}
- {analysis.company_name && ` | ${analysis.company_name}`}
-
-
-
-
- {/* Right: Decision Badge */}
-
-
- {tradingDecision || '未知'}
- {(tradingDecision || '未知').substring(0, 2)}
-
-
-
- {/* Analysis Info */}
-
- {/* Left: Analysis Date */}
-
-
-
-
分析日期
-
{analysis.analysis_date}
-
-
-
- {/* Right: Completed Time */}
-
-
-
完成时间
-
{formatDate(analysis.completed_at)}
-
-
-
-
-
- {/* View Details Button */}
-
-
- 查看详情
-
-
-
- );
-}
diff --git a/web/frontend/src/components/leaderboard/AnalysisCardsGrid.tsx b/web/frontend/src/components/leaderboard/AnalysisCardsGrid.tsx
deleted file mode 100644
index f649b03..0000000
--- a/web/frontend/src/components/leaderboard/AnalysisCardsGrid.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-'use client';
-
-import React from 'react';
-import { AnalysisCard } from './AnalysisCard';
-
-interface AnalysisCardData {
- analysis_id: string;
- ticker: string;
- company_name?: string;
- market: string;
- analysis_date: string;
- trading_decision: string;
- completed_at: string;
- progress_percentage: number;
-}
-
-interface AnalysisCardsGridProps {
- analyses: AnalysisCardData[];
- isLoading: boolean;
- isError: boolean;
- onCardClick: (analysisId: string) => void;
-}
-
-export function AnalysisCardsGrid({ analyses, isLoading, isError, onCardClick }: AnalysisCardsGridProps) {
- // Loading state
- if (isLoading) {
- return (
-
-
-
- {/* Outer ring */}
-
- {/* Inner ring */}
-
-
-
正在加载排行榜数据...
-
请稍候
-
-
- );
- }
-
- // Error state
- if (isError) {
- return (
-
-
-
-
加载排行榜数据失败
-
window.location.reload()}
- className="px-4 py-2 bg-gradient-to-r from-accent-primary to-accent-secondary text-white rounded-lg hover:shadow-glow-cyan transition-all"
- >
-
- 重试
-
-
-
- );
- }
-
- // Empty state
- if (!analyses || analyses.length === 0) {
- return (
-
-
-
📊
-
暂无分析记录
-
该市场还没有完成的分析报告
-
-
- );
- }
-
- // Normal grid display
- return (
-
- {analyses.map((analysis) => (
-
onCardClick(analysis.analysis_id)}
- />
- ))}
-
- );
-}
diff --git a/web/frontend/src/components/leaderboard/DecisionHistoryPanel.tsx b/web/frontend/src/components/leaderboard/DecisionHistoryPanel.tsx
deleted file mode 100644
index 183699f..0000000
--- a/web/frontend/src/components/leaderboard/DecisionHistoryPanel.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-'use client';
-
-import React, { useState } from 'react';
-
-interface Decision {
- id: number;
- start_time: string;
- end_time?: string;
- status: string;
- market_type: string;
- decision_report?: string;
- trades_executed?: any[];
-}
-
-interface DecisionHistoryPanelProps {
- userId: number;
- username: string;
- decisions: Decision[] | null;
-}
-
-export function DecisionHistoryPanel({ userId, username, decisions }: DecisionHistoryPanelProps) {
- const [activeTab, setActiveTab] = useState<'all' | 'completed' | 'running'>('all');
-
- const filteredDecisions = !decisions
- ? []
- : decisions.filter((decision) => {
- if (activeTab === 'all') return true;
- if (activeTab === 'completed') return decision.status === 'completed';
- if (activeTab === 'running') return decision.status === 'running';
- return true;
- });
-
- const getStatusBadge = (status: string) => {
- switch (status) {
- case 'completed':
- return (
-
- 已完成
-
- );
- case 'running':
- return (
-
- 运行中
-
- );
- case 'failed':
- return (
-
- 失败
-
- );
- default:
- return (
-
- {status}
-
- );
- }
- };
-
- return (
-
-
-
-
- 决策历史
-
- {username}
-
-
- {/* Tabs */}
-
- setActiveTab('all')}
- className={`px-4 py-2 text-sm font-medium transition-colors ${
- activeTab === 'all'
- ? 'text-accent-primary border-b-2 border-accent-primary'
- : 'text-text-secondary hover:text-text-primary'
- }`}
- >
- 全部 ({decisions?.length || 0})
-
- setActiveTab('completed')}
- className={`px-4 py-2 text-sm font-medium transition-colors ${
- activeTab === 'completed'
- ? 'text-accent-primary border-b-2 border-accent-primary'
- : 'text-text-secondary hover:text-text-primary'
- }`}
- >
- 已完成
-
- setActiveTab('running')}
- className={`px-4 py-2 text-sm font-medium transition-colors ${
- activeTab === 'running'
- ? 'text-accent-primary border-b-2 border-accent-primary'
- : 'text-text-secondary hover:text-text-primary'
- }`}
- >
- 运行中
-
-
-
- {!decisions ? (
-
- ) : filteredDecisions.length === 0 ? (
-
- ) : (
-
- {filteredDecisions.map((decision) => (
-
-
-
-
-
- {decision.market_type}
-
- {getStatusBadge(decision.status)}
-
-
- 开始时间: {new Date(decision.start_time).toLocaleString('zh-CN')}
-
- {decision.end_time && (
-
- 结束时间: {new Date(decision.end_time).toLocaleString('zh-CN')}
-
- )}
-
-
-
- {decision.decision_report && (
-
-
- {decision.decision_report}
-
-
- )}
-
- {decision.trades_executed && decision.trades_executed.length > 0 && (
-
-
- 执行交易: {decision.trades_executed.length} 笔
-
-
- )}
-
- ))}
-
- )}
-
- );
-}
diff --git a/web/frontend/src/components/leaderboard/Header.tsx b/web/frontend/src/components/leaderboard/Header.tsx
deleted file mode 100644
index 1f7d576..0000000
--- a/web/frontend/src/components/leaderboard/Header.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-'use client';
-
-import React, { useState } from 'react';
-import { useAuth } from '@/lib/auth';
-
-interface HeaderProps {
- onNavigate: (path: string) => void;
-}
-
-export function Header({ onNavigate }: HeaderProps) {
- const { user, logout, isLoading } = useAuth();
- const [showUserMenu, setShowUserMenu] = useState(false);
-
- return (
-
-
-
-
-
onNavigate('/')}
- >
-
-
-
- TradingAgentsWeb
-
-
-
-
-
-
- {!isLoading && !user && (
- <>
-
onNavigate('/login')}
- className="px-3 py-2 rounded-lg text-sm font-medium text-text-secondary hover:text-accent-primary hover:bg-dark-tertiary transition-all"
- >
- 登录
-
-
onNavigate('/register')}
- className="px-3 py-2 rounded-lg text-sm font-medium bg-gradient-to-r from-accent-primary to-accent-secondary text-white hover:shadow-glow-cyan transition-all"
- >
- 注册
-
- >
- )}
-
- {user && (
-
-
{
- e.stopPropagation();
- setShowUserMenu(!showUserMenu);
- }}
- className="px-3 py-2 rounded-lg text-sm font-medium transition-all flex items-center text-text-secondary hover:text-accent-primary hover:bg-dark-tertiary"
- >
-
- {user.username}
-
-
-
- {showUserMenu && (
-
-
{
- onNavigate('/profile');
- setShowUserMenu(false);
- }}
- className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:text-accent-primary hover:bg-dark-tertiary transition-all flex items-center"
- >
-
- 个人中心
-
-
-
{
- onNavigate('/intraday-trading');
- setShowUserMenu(false);
- }}
- className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:text-success-500 hover:bg-dark-tertiary transition-all flex items-center"
- >
-
- 智能盯盘
-
-
-
-
-
{
- logout();
- setShowUserMenu(false);
- }}
- className="w-full text-left px-4 py-2 text-sm text-danger-500 hover:bg-danger-500/10 transition-all flex items-center"
- >
-
- 退出登录
-
-
- )}
-
- )}
-
-
-
-
- );
-}
diff --git a/web/frontend/src/components/leaderboard/HeroSection.tsx b/web/frontend/src/components/leaderboard/HeroSection.tsx
deleted file mode 100644
index 481b359..0000000
--- a/web/frontend/src/components/leaderboard/HeroSection.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-'use client';
-
-import React from 'react';
-
-interface HeroSectionProps {
- onNewAnalysis: () => void;
-}
-
-export function HeroSection({ onNewAnalysis }: HeroSectionProps) {
- return (
-
-
-
-
-
- TradingAgentsWeb
-
-
多智能体大语言模型金融交易框架
-
- 工作流程:
- 分析师团队 → 研究团队 → 交易员 → 风险管理 → 投资组合分析
-
-
- {/* 背景光效 */}
-
-
- {/* 按钮内容 */}
-
-
- 开始新分析
-
-
-
-
-
- );
-}
diff --git a/web/frontend/src/components/leaderboard/LeaderboardChart.tsx b/web/frontend/src/components/leaderboard/LeaderboardChart.tsx
deleted file mode 100644
index d13860a..0000000
--- a/web/frontend/src/components/leaderboard/LeaderboardChart.tsx
+++ /dev/null
@@ -1,316 +0,0 @@
-'use client';
-
-import React, { useEffect, useRef, useState } from 'react';
-import { useQuery } from '@tanstack/react-query';
-import { buildApiUrl } from '@/utils/api';
-
-interface User {
- user_id: number;
- username: string;
- market_type: string;
- total_assets: number;
- latest_snapshot_date: string;
-}
-
-interface SnapshotData {
- date: string;
- total_assets: number;
-}
-
-interface LeaderboardChartProps {
- users: User[];
- onUserSelect: (userId: number, username: string) => void;
- selectedUserId: number | null;
-}
-
-export function LeaderboardChart({ users, onUserSelect, selectedUserId }: LeaderboardChartProps) {
- const canvasRef = useRef(null);
- const [hoveredUser, setHoveredUser] = useState(null);
-
- // Fetch trend data for all users
- const { data: allTrendsData } = useQuery({
- queryKey: ['all-users-trends', users.map(u => u.user_id).join(',')],
- queryFn: async () => {
- if (users.length === 0) return {};
-
- const trendsPromises = users.map(async (user) => {
- try {
- const response = await fetch(buildApiUrl(`/api/public/leaderboard/user/${user.user_id}/trend`));
- if (!response.ok) return { userId: user.user_id, data: [] };
- const data = await response.json();
- return { userId: user.user_id, data };
- } catch (error) {
- console.error(`Failed to fetch trend for user ${user.user_id}:`, error);
- return { userId: user.user_id, data: [] };
- }
- });
-
- const results = await Promise.all(trendsPromises);
- const trendsMap: Record = {};
- results.forEach(result => {
- trendsMap[result.userId] = result.data;
- });
- return trendsMap;
- },
- enabled: users.length > 0,
- staleTime: 60 * 1000, // 1 minute
- });
-
- useEffect(() => {
- if (!canvasRef.current || users.length === 0 || !allTrendsData) return;
-
- const canvas = canvasRef.current;
- const ctx = canvas.getContext('2d');
- if (!ctx) return;
-
- // Set canvas size
- const rect = canvas.getBoundingClientRect();
- canvas.width = rect.width * window.devicePixelRatio;
- canvas.height = rect.height * window.devicePixelRatio;
- ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
-
- const width = rect.width;
- const height = rect.height;
- const padding = 60;
- const chartWidth = width - padding * 2;
- const chartHeight = height - padding * 2;
-
- // Colors for different users
- const colors = [
- '#3B82F6', '#10B981', '#F59E0B', '#EF4444',
- '#8B5CF6', '#EC4899', '#14B8A6', '#F97316',
- ];
-
- // Clear canvas
- ctx.fillStyle = '#1a1a1a';
- ctx.fillRect(0, 0, width, height);
-
- // Check if we have any trend data
- const hasData = Object.values(allTrendsData).some(data => data.length > 0);
-
- if (!hasData) {
- ctx.fillStyle = '#9ca3af';
- ctx.font = '14px sans-serif';
- ctx.textAlign = 'center';
- ctx.fillText('暂无趋势数据,请先初始化样本数据', width / 2, height / 2);
- ctx.textAlign = 'left';
- return;
- }
-
- // Find min/max values for scaling
- let minValue = Infinity;
- let maxValue = -Infinity;
- let allDates: string[] = [];
-
- Object.values(allTrendsData).forEach(trendData => {
- trendData.forEach(point => {
- minValue = Math.min(minValue, point.total_assets);
- maxValue = Math.max(maxValue, point.total_assets);
- if (!allDates.includes(point.date)) {
- allDates.push(point.date);
- }
- });
- });
-
- allDates.sort();
-
- // Add padding to min/max
- const valueRange = maxValue - minValue;
- minValue -= valueRange * 0.1;
- maxValue += valueRange * 0.1;
-
- // Draw axes
- ctx.strokeStyle = '#374151';
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(padding, padding);
- ctx.lineTo(padding, height - padding);
- ctx.lineTo(width - padding, height - padding);
- ctx.stroke();
-
- // Draw Y-axis labels
- ctx.fillStyle = '#9ca3af';
- ctx.font = '11px sans-serif';
- ctx.textAlign = 'right';
- const ySteps = 5;
- for (let i = 0; i <= ySteps; i++) {
- const value = minValue + (maxValue - minValue) * (i / ySteps);
- const y = height - padding - (chartHeight * i / ySteps);
- ctx.fillText(`$${(value / 1000).toFixed(0)}K`, padding - 10, y + 4);
-
- // Grid line
- ctx.strokeStyle = '#374151';
- ctx.beginPath();
- ctx.moveTo(padding, y);
- ctx.lineTo(width - padding, y);
- ctx.stroke();
- }
-
- // Draw X-axis labels (show every 5th date)
- ctx.textAlign = 'center';
- allDates.forEach((date, index) => {
- if (index % 5 === 0 || index === allDates.length - 1) {
- const x = padding + (chartWidth * index / (allDates.length - 1));
- const shortDate = date.substring(5); // MM-DD
- ctx.fillText(shortDate, x, height - padding + 20);
- }
- });
-
- // Draw trend lines for each user
- users.forEach((user, userIndex) => {
- const trendData = allTrendsData[user.user_id] || [];
- if (trendData.length === 0) return;
-
- const color = colors[userIndex % colors.length];
- const isSelected = selectedUserId === user.user_id;
- const isHovered = hoveredUser === user.user_id;
-
- ctx.strokeStyle = color;
- ctx.lineWidth = isSelected || isHovered ? 3 : 2;
- ctx.globalAlpha = isSelected || isHovered ? 1 : 0.7;
- ctx.beginPath();
-
- trendData.forEach((point, pointIndex) => {
- const dateIndex = allDates.indexOf(point.date);
- if (dateIndex === -1) return;
-
- const x = padding + (chartWidth * dateIndex / (allDates.length - 1));
- const normalizedValue = (point.total_assets - minValue) / (maxValue - minValue);
- const y = height - padding - (chartHeight * normalizedValue);
-
- if (pointIndex === 0) {
- ctx.moveTo(x, y);
- } else {
- ctx.lineTo(x, y);
- }
- });
-
- ctx.stroke();
- ctx.globalAlpha = 1;
-
- // Draw points
- if (isSelected || isHovered) {
- ctx.fillStyle = color;
- trendData.forEach(point => {
- const dateIndex = allDates.indexOf(point.date);
- if (dateIndex === -1) return;
-
- const x = padding + (chartWidth * dateIndex / (allDates.length - 1));
- const normalizedValue = (point.total_assets - minValue) / (maxValue - minValue);
- const y = height - padding - (chartHeight * normalizedValue);
-
- ctx.beginPath();
- ctx.arc(x, y, 3, 0, Math.PI * 2);
- ctx.fill();
- });
- }
- });
-
- // Draw legend
- ctx.textAlign = 'left';
- const legendY = 20;
- const legendItemWidth = 140;
- const legendItemsPerRow = Math.floor((width - padding * 2) / legendItemWidth);
-
- users.slice(0, 8).forEach((user, index) => {
- const color = colors[index % colors.length];
- const row = Math.floor(index / legendItemsPerRow);
- const col = index % legendItemsPerRow;
- const x = padding + col * legendItemWidth;
- const y = legendY + row * 20;
-
- const isSelected = selectedUserId === user.user_id;
- const isHovered = hoveredUser === user.user_id;
-
- ctx.fillStyle = color;
- ctx.fillRect(x, y, 12, 12);
-
- ctx.fillStyle = isSelected || isHovered ? '#ffffff' : '#e5e7eb';
- ctx.font = isSelected || isHovered ? 'bold 12px sans-serif' : '12px sans-serif';
- ctx.fillText(`${user.username}`, x + 18, y + 10);
- });
-
- }, [users, selectedUserId, allTrendsData, hoveredUser]);
-
- // Render chart and user list
- return (
-
-
-
- 资产趋势图
-
-
- {/* Chart Canvas */}
-
-
- {!allTrendsData && (
-
- )}
-
-
- {/* User List */}
-
-
- 参与排名用户 ({users.length})
-
-
- {users.map((user, index) => (
-
onUserSelect(user.user_id, user.username)}
- onMouseEnter={() => setHoveredUser(user.user_id)}
- onMouseLeave={() => setHoveredUser(null)}
- className={`w-full flex items-center justify-between p-3 rounded-lg transition-all ${
- selectedUserId === user.user_id
- ? 'bg-accent-primary/20 border-accent-primary'
- : hoveredUser === user.user_id
- ? 'bg-dark-primary border-accent-primary/50'
- : 'bg-dark-tertiary hover:bg-dark-primary border-dark-border'
- } border`}
- >
-
-
-
-
- {user.username}
-
-
- {user.market_type} • 最新: {user.latest_snapshot_date}
-
-
-
-
-
- ${user.total_assets.toLocaleString()}
-
-
-
- ))}
-
-
-
- );
-}
diff --git a/web/frontend/src/components/leaderboard/LeaderboardTrendChart.tsx b/web/frontend/src/components/leaderboard/LeaderboardTrendChart.tsx
deleted file mode 100644
index 3cdb51e..0000000
--- a/web/frontend/src/components/leaderboard/LeaderboardTrendChart.tsx
+++ /dev/null
@@ -1,555 +0,0 @@
-'use client';
-
-import React, { useEffect, useRef, useState } from 'react';
-import { useQuery } from '@tanstack/react-query';
-import { buildApiUrl } from '@/utils/api';
-import { getCurrencySymbol, formatAmount } from '@/utils/marketCurrency';
-
-interface User {
- user_id: number;
- username: string;
- market_type: string;
- total_assets: number;
- latest_snapshot_date: string;
- model_name?: string;
-}
-
-interface SnapshotData {
- date: string;
- total_assets: number;
-}
-
-interface LeaderboardTrendChartProps {
- users: User[]; // 前10名用户
- allUsers: User[]; // 所有用户(用于排名列表)
- selectedMarket: string;
- selectedUserId: number | null;
- onUserSelect: (userId: number, username: string) => void;
- onJoinClick?: () => void; // 点击参加排名按钮的回调
- lastUpdate: string | null;
-}
-
-export function LeaderboardTrendChart({
- users,
- allUsers,
- selectedMarket,
- selectedUserId,
- onUserSelect,
- onJoinClick,
- lastUpdate
-}: LeaderboardTrendChartProps) {
- const canvasRef = useRef(null);
- const [hoveredUser, setHoveredUser] = useState(null);
- const [tooltip, setTooltip] = useState<{
- visible: boolean;
- x: number;
- y: number;
- data: {
- username: string;
- date: string;
- value: number;
- modelName?: string;
- } | null;
- }>({ visible: false, x: 0, y: 0, data: null });
-
- // 获取所有前10名用户的趋势数据(最近1周,每5分钟刷新一次)
- const { data: allTrendsData } = useQuery({
- queryKey: ['leaderboard-trends', users.map(u => u.user_id).join(','), selectedMarket],
- queryFn: async () => {
- if (users.length === 0) return {};
-
- const trendsPromises = users.map(async (user) => {
- try {
- // 获取最近1周的数据(7天),按市场过滤
- const response = await fetch(buildApiUrl(`/api/public/leaderboard/user/${user.user_id}/trend?days=7&market=${selectedMarket}`));
- if (!response.ok) return { userId: user.user_id, data: [] };
- const data = await response.json();
- return { userId: user.user_id, data };
- } catch (error) {
- return { userId: user.user_id, data: [] };
- }
- });
-
- const results = await Promise.all(trendsPromises);
- const trendsMap: Record = {};
- results.forEach(result => {
- trendsMap[result.userId] = result.data;
- });
- return trendsMap;
- },
- enabled: users.length > 0,
- staleTime: 5 * 60 * 1000, // 5分钟缓存,与WebSocket更新频率一致
- refetchInterval: 5 * 60 * 1000, // 每5分钟自动刷新
- });
-
- // 绘制趋势图
- useEffect(() => {
- if (!canvasRef.current || users.length === 0 || !allTrendsData) return;
-
- const canvas = canvasRef.current;
- const ctx = canvas.getContext('2d');
- if (!ctx) return;
-
- // 设置canvas尺寸
- const rect = canvas.getBoundingClientRect();
- canvas.width = rect.width * window.devicePixelRatio;
- canvas.height = rect.height * window.devicePixelRatio;
- ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
-
- const width = rect.width;
- const height = rect.height;
-
- // 响应式padding - 移动端使用更小的padding
- const isMobile = width < 640;
- const padding = isMobile
- ? { top: 60, right: 20, bottom: 50, left: 60 }
- : { top: 80, right: 40, bottom: 60, left: 100 };
-
- const chartWidth = width - padding.left - padding.right;
- const chartHeight = height - padding.top - padding.bottom;
-
- // 颜色方案
- const colors = [
- '#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
- '#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16'
- ];
-
- // 清空画布
- ctx.fillStyle = '#0a0a0a';
- ctx.fillRect(0, 0, width, height);
-
- // 检查是否有数据
- const hasData = Object.values(allTrendsData).some(data => data.length > 0);
- if (!hasData) {
- ctx.fillStyle = '#9ca3af';
- ctx.font = '14px sans-serif';
- ctx.textAlign = 'center';
- ctx.fillText('暂无趋势数据', width / 2, height / 2);
- return;
- }
-
- // 找出所有日期和最小/最大值
- let minValue = Infinity;
- let maxValue = -Infinity;
- const allDates = new Set();
-
- Object.values(allTrendsData).forEach(trendData => {
- trendData.forEach(point => {
- minValue = Math.min(minValue, point.total_assets);
- maxValue = Math.max(maxValue, point.total_assets);
- allDates.add(point.date);
- });
- });
-
- const sortedDates = Array.from(allDates).sort();
-
- // 添加10%边距
- const valueRange = maxValue - minValue;
- minValue -= valueRange * 0.1;
- maxValue += valueRange * 0.1;
-
- // 绘制网格和坐标轴
- ctx.strokeStyle = '#1f2937';
- ctx.lineWidth = 1;
-
- // Y轴网格线
- const ySteps = 5;
- for (let i = 0; i <= ySteps; i++) {
- const y = padding.top + (chartHeight * i / ySteps);
- ctx.beginPath();
- ctx.moveTo(padding.left, y);
- ctx.lineTo(width - padding.right, y);
- ctx.stroke();
-
- // Y轴标签
- const value = maxValue - (maxValue - minValue) * (i / ySteps);
- ctx.fillStyle = '#9ca3af';
- ctx.font = '12px sans-serif';
- ctx.textAlign = 'right';
- // 根据数值大小选择合适的格式
- let label = '';
- if (value >= 1000000) {
- // 百万以上:显示到万位
- label = `${(value / 10000).toFixed(1)}万`;
- } else if (value >= 10000) {
- // 万以上:显示到千位
- label = `${(value / 10000).toFixed(2)}万`;
- } else if (value >= 1000) {
- // 千以上:显示完整数值
- label = value.toFixed(0);
- } else {
- // 千以下:显示小数
- label = value.toFixed(2);
- }
-
- ctx.fillText(label, padding.left - 10, y + 4);
- }
-
- // X轴网格线和标签
- ctx.textAlign = 'center';
- sortedDates.forEach((date, index) => {
- // 根据数据点数量调整显示间隔
- const totalPoints = sortedDates.length;
- let showInterval = 1;
-
- if (totalPoints > 200) {
- showInterval = Math.floor(totalPoints / 10); // 显示约10个标签
- } else if (totalPoints > 100) {
- showInterval = Math.floor(totalPoints / 15); // 显示约15个标签
- } else if (totalPoints > 50) {
- showInterval = Math.floor(totalPoints / 20); // 显示约20个标签
- } else {
- showInterval = Math.max(1, Math.floor(totalPoints / 10));
- }
-
- if (index % showInterval === 0 || index === sortedDates.length - 1) {
- const x = padding.left + (chartWidth * index / (sortedDates.length - 1));
-
- // 网格线
- ctx.strokeStyle = '#1f2937';
- ctx.beginPath();
- ctx.moveTo(x, padding.top);
- ctx.lineTo(x, height - padding.bottom);
- ctx.stroke();
-
- // 标签 - 根据数据格式显示
- let label = '';
- if (date.includes(' ')) {
- // 包含时间的格式: "2025-11-17 14:30:00"
- const parts = date.split(' ');
- const datePart = parts[0].substring(5); // MM-DD
- const timePart = parts[1].substring(0, 5); // HH:MM
- label = `${datePart} ${timePart}`;
- } else {
- // 只有日期的格式: "2025-11-17"
- label = date.substring(5); // MM-DD
- }
-
- ctx.fillStyle = '#9ca3af';
- ctx.font = '10px sans-serif';
- ctx.fillText(label, x, height - padding.bottom + 20);
- }
- });
-
- // 绘制趋势线
- users.forEach((user, userIndex) => {
- const trendData = allTrendsData[user.user_id] || [];
- if (trendData.length === 0) return;
-
- const color = colors[userIndex % colors.length];
- const isSelected = selectedUserId === user.user_id;
- const isHovered = hoveredUser === user.user_id;
-
- ctx.strokeStyle = color;
- ctx.lineWidth = isSelected || isHovered ? 3 : 2;
- ctx.globalAlpha = isSelected || isHovered ? 1 : 0.7;
- ctx.beginPath();
-
- let hasStarted = false;
- trendData.forEach((point) => {
- const dateIndex = sortedDates.indexOf(point.date);
- if (dateIndex === -1) return;
-
- const x = padding.left + (chartWidth * dateIndex / (sortedDates.length - 1));
- const normalizedValue = (point.total_assets - minValue) / (maxValue - minValue);
- const y = padding.top + chartHeight - (chartHeight * normalizedValue);
-
- if (!hasStarted) {
- ctx.moveTo(x, y);
- hasStarted = true;
- } else {
- ctx.lineTo(x, y);
- }
- });
-
- ctx.stroke();
- ctx.globalAlpha = 1;
-
- // 绘制数据点
- if (isSelected || isHovered) {
- ctx.fillStyle = color;
- trendData.forEach(point => {
- const dateIndex = sortedDates.indexOf(point.date);
- if (dateIndex === -1) return;
-
- const x = padding.left + (chartWidth * dateIndex / (sortedDates.length - 1));
- const normalizedValue = (point.total_assets - minValue) / (maxValue - minValue);
- const y = padding.top + chartHeight - (chartHeight * normalizedValue);
-
- ctx.beginPath();
- ctx.arc(x, y, 4, 0, Math.PI * 2);
- ctx.fill();
- });
- }
- });
-
- // 标题 - 放在最上方,移动端使用更小字体
- ctx.fillStyle = '#e5e7eb';
- ctx.font = isMobile ? 'bold 13px sans-serif' : 'bold 16px sans-serif';
- ctx.textAlign = 'left';
- const titleText = `资产趋势 - 前10名 (${selectedMarket === 'US' ? '美股' : selectedMarket === 'HK' ? '港股' : 'A股'})`;
- ctx.fillText(titleText, padding.left, isMobile ? 20 : 25);
-
- // 最后更新时间 - 放在标题右侧,移动端可能换行
- if (lastUpdate) {
- ctx.fillStyle = '#6b7280';
- ctx.font = isMobile ? '9px sans-serif' : '11px sans-serif';
- ctx.textAlign = 'right';
- const updateText = `更新: ${new Date(lastUpdate).toLocaleTimeString('zh-CN')}`;
- ctx.fillText(updateText, width - padding.right, isMobile ? 20 : 25);
- }
-
- // 绘制图例 - 放在标题下方,移动端调整布局
- ctx.textAlign = 'left';
- const legendY = isMobile ? 35 : 45;
- const legendItemWidth = isMobile ? 100 : 150;
- const legendItemsPerRow = Math.floor((width - padding.left - padding.right) / legendItemWidth);
-
- users.forEach((user, index) => {
- const color = colors[index % colors.length];
- const row = Math.floor(index / legendItemsPerRow);
- const col = index % legendItemsPerRow;
- const x = padding.left + col * legendItemWidth;
- const y = legendY + row * (isMobile ? 18 : 22);
-
- const isSelected = selectedUserId === user.user_id;
- const isHovered = hoveredUser === user.user_id;
-
- // 颜色方块
- ctx.fillStyle = color;
- const boxSize = isMobile ? 10 : 12;
- ctx.fillRect(x, y, boxSize, boxSize);
-
- // 用户名 - 移动端使用更小字体和截断
- ctx.fillStyle = isSelected || isHovered ? '#ffffff' : '#e5e7eb';
- const fontSize = isMobile ? 10 : 11;
- ctx.font = isSelected || isHovered ? `bold ${fontSize}px sans-serif` : `${fontSize}px sans-serif`;
-
- let displayName = `${index + 1}. ${user.username}`;
- // 移动端截断过长的用户名
- if (isMobile && displayName.length > 10) {
- displayName = displayName.substring(0, 9) + '...';
- }
-
- ctx.fillText(displayName, x + (isMobile ? 14 : 18), y + (isMobile ? 8 : 10));
- });
-
- }, [users, selectedUserId, allTrendsData, hoveredUser, selectedMarket, lastUpdate]);
-
- // 处理鼠标移动事件
- const handleMouseMove = (e: React.MouseEvent) => {
- const canvas = canvasRef.current;
- if (!canvas || !allTrendsData) return;
-
- const rect = canvas.getBoundingClientRect();
- const x = e.clientX - rect.left;
- const y = e.clientY - rect.top;
-
- const padding = { top: 80, right: 40, bottom: 60, left: 100 };
- const chartWidth = rect.width - padding.left - padding.right;
- const chartHeight = rect.height - padding.top - padding.bottom;
-
- // 检查是否在图表区域内
- if (x < padding.left || x > rect.width - padding.right ||
- y < padding.top || y > rect.height - padding.bottom) {
- setTooltip({ visible: false, x: 0, y: 0, data: null });
- return;
- }
-
- // 找出所有日期
- const allDates = new Set();
- Object.values(allTrendsData).forEach(trendData => {
- trendData.forEach(point => allDates.add(point.date));
- });
- const sortedDates = Array.from(allDates).sort();
-
- if (sortedDates.length === 0) return;
-
- // 计算最接近的数据点
- const relativeX = x - padding.left;
- const dateIndex = Math.round((relativeX / chartWidth) * (sortedDates.length - 1));
-
- if (dateIndex < 0 || dateIndex >= sortedDates.length) {
- setTooltip({ visible: false, x: 0, y: 0, data: null });
- return;
- }
-
- const targetDate = sortedDates[dateIndex];
-
- // 找出最接近鼠标的用户数据
- let closestUser: { username: string; value: number; distance: number; modelName?: string } | null = null;
- let minValue = Infinity;
- let maxValue = -Infinity;
-
- Object.values(allTrendsData).forEach(trendData => {
- trendData.forEach(point => {
- minValue = Math.min(minValue, point.total_assets);
- maxValue = Math.max(maxValue, point.total_assets);
- });
- });
-
- const valueRange = maxValue - minValue;
- minValue -= valueRange * 0.1;
- maxValue += valueRange * 0.1;
-
- users.forEach((user) => {
- const trendData = allTrendsData[user.user_id] || [];
- const dataPoint = trendData.find(p => p.date === targetDate);
-
- if (dataPoint) {
- const normalizedValue = (dataPoint.total_assets - minValue) / (maxValue - minValue);
- const pointY = padding.top + chartHeight - (chartHeight * normalizedValue);
- const distance = Math.abs(y - pointY);
-
- if (!closestUser || distance < closestUser.distance) {
- closestUser = {
- username: user.username,
- value: dataPoint.total_assets,
- distance,
- modelName: user.model_name
- };
- }
- }
- });
-
- if (closestUser && closestUser.distance < 20) {
- setTooltip({
- visible: true,
- x: e.clientX,
- y: e.clientY,
- data: {
- username: closestUser.username,
- date: targetDate,
- value: closestUser.value,
- modelName: closestUser.modelName
- }
- });
- } else {
- setTooltip({ visible: false, x: 0, y: 0, data: null });
- }
- };
-
- const handleMouseLeave = () => {
- setTooltip({ visible: false, x: 0, y: 0, data: null });
- };
-
- return (
-
- {/* 趋势图 */}
-
-
-
- {/* Tooltip */}
- {tooltip.visible && tooltip.data && (
-
-
- {tooltip.data.username}
-
- {tooltip.data.modelName && (
-
-
- {tooltip.data.modelName}
-
- )}
-
- {tooltip.data.date.includes(' ')
- ? tooltip.data.date.replace(' ', ' · ')
- : tooltip.data.date
- }
-
-
- {getCurrencySymbol(selectedMarket)}{tooltip.data.value.toLocaleString(undefined, {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2
- })}
-
-
- )}
-
-
- {/* 排名列表 */}
-
-
-
-
- 排名列表 ({allUsers.length})
-
- {onJoinClick && (
-
-
- 参加排名
-
- )}
-
-
- {allUsers
- .sort((a, b) => b.total_assets - a.total_assets)
- .map((user, index) => (
-
onUserSelect(user.user_id, user.username)}
- onMouseEnter={() => setHoveredUser(user.user_id)}
- onMouseLeave={() => setHoveredUser(null)}
- className={`w-full flex items-center justify-between p-2.5 sm:p-3 rounded-lg transition-all ${
- selectedUserId === user.user_id
- ? 'bg-accent-primary/20 border-accent-primary'
- : hoveredUser === user.user_id
- ? 'bg-dark-primary border-accent-primary/50'
- : 'bg-dark-tertiary hover:bg-dark-primary border-dark-border'
- } border`}
- >
-
- {/* 排名 */}
-
- {index + 1}
-
-
- {/* 用户信息 */}
-
-
- {user.username}
-
-
- {user.latest_snapshot_date}
-
-
-
-
- {/* 资产和模型 */}
-
-
- {formatAmount(user.total_assets, selectedMarket, 0)}
-
- {user.model_name && (
-
- {user.model_name}
-
- )}
-
-
- ))}
-
-
-
- );
-}
diff --git a/web/frontend/src/components/leaderboard/MarketTabs.tsx b/web/frontend/src/components/leaderboard/MarketTabs.tsx
deleted file mode 100644
index 44061de..0000000
--- a/web/frontend/src/components/leaderboard/MarketTabs.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-'use client';
-
-import React from 'react';
-
-type Market = 'US' | 'HK' | 'CN';
-
-interface MarketTabsProps {
- activeMarket: Market;
- onMarketChange: (market: Market) => void;
- marketLabels: Record;
-}
-
-export function MarketTabs({ activeMarket, onMarketChange, marketLabels }: MarketTabsProps) {
- const markets: Market[] = ['US', 'HK', 'CN'];
-
- const getMarketIcon = (market: Market) => {
- switch (market) {
- case 'US':
- return 'fa-flag-usa';
- case 'HK':
- return 'fa-building';
- case 'CN':
- return 'fa-landmark';
- default:
- return 'fa-chart-line';
- }
- };
-
- return (
-
-
- {markets.map((market) => (
-
onMarketChange(market)}
- className={`
- relative px-3 md:px-6 py-3 md:py-4 font-semibold text-sm md:text-base rounded-xl transition-all duration-300 min-h-touch
- ${activeMarket === market
- ? 'bg-gradient-to-br from-accent-primary to-accent-secondary text-white transform scale-105 shadow-glow-cyan'
- : 'bg-dark-secondary text-text-secondary hover:bg-dark-tertiary border border-dark-border hover:border-accent-primary'
- }
- `}
- >
-
-
-
-
-
{marketLabels[market]}
-
-
- ))}
-
-
- );
-}
diff --git a/web/frontend/src/components/leaderboard/UserDetailPanel.tsx b/web/frontend/src/components/leaderboard/UserDetailPanel.tsx
deleted file mode 100644
index cf98148..0000000
--- a/web/frontend/src/components/leaderboard/UserDetailPanel.tsx
+++ /dev/null
@@ -1,593 +0,0 @@
-'use client';
-
-import React, { useState } from 'react';
-import { useQuery } from '@tanstack/react-query';
-import { buildApiUrl } from '@/utils/api';
-import ReactMarkdown from 'react-markdown';
-import rehypeRaw from 'rehype-raw';
-import rehypeSanitize from 'rehype-sanitize';
-import { getCurrencySymbol } from '@/utils/marketCurrency';
-import { openFutuStockPage } from '@/utils/futuLink';
-
-interface UserDetailPanelProps {
- isOpen: boolean;
- userId: number | null;
- username: string;
- market: string;
- onClose: () => void;
-}
-
-export function UserDetailPanel({ isOpen, userId, username, market, onClose }: UserDetailPanelProps) {
- const [activeTab, setActiveTab] = useState<'positions' | 'decisions'>('positions');
- const [selectedDecision, setSelectedDecision] = useState(null);
- const [isDecisionDetailOpen, setIsDecisionDetailOpen] = useState(false);
-
- // 获取持仓数据(每5分钟刷新)
- const { data: allPositions, isLoading: positionsLoading } = useQuery({
- queryKey: ['user-positions', userId],
- queryFn: async () => {
- if (!userId) return [];
- const response = await fetch(buildApiUrl(`/api/public/leaderboard/user/${userId}/positions`));
- if (!response.ok) throw new Error('获取持仓失败');
- return response.json();
- },
- enabled: !!userId && isOpen,
- staleTime: 5 * 60 * 1000, // 5分钟缓存
- refetchInterval: 5 * 60 * 1000, // 每5分钟自动刷新
- });
-
- // 获取决策历史(每5分钟刷新)
- const { data: allDecisions, isLoading: decisionsLoading } = useQuery({
- queryKey: ['user-decisions', userId],
- queryFn: async () => {
- if (!userId) return [];
- const response = await fetch(buildApiUrl(`/api/public/leaderboard/user/${userId}/decisions`));
- if (!response.ok) throw new Error('获取决策历史失败');
- return response.json();
- },
- enabled: !!userId && isOpen,
- staleTime: 5 * 60 * 1000, // 5分钟缓存
- refetchInterval: 5 * 60 * 1000, // 每5分钟自动刷新
- });
-
- // 根据市场过滤持仓
- const positions = React.useMemo(() => {
- if (!allPositions) return [];
- const filtered = allPositions.filter((p: any) => p.market_type === market);
- // Debug: Log positions data
- console.log('[UserDetailPanel] Positions data:', filtered);
- filtered.forEach((p: any) => {
- console.log(` ${p.stock_code}: stock_name = "${p.stock_name}"`);
- });
- return filtered;
- }, [allPositions, market]);
-
- // 根据市场过滤决策记录
- const decisions = React.useMemo(() => {
- if (!allDecisions) return [];
- return allDecisions.filter((d: any) => d.market_type === market);
- }, [allDecisions, market]);
-
- // 查看决策详情
- const handleViewDecision = (decision: any) => {
- setSelectedDecision(decision);
- setIsDecisionDetailOpen(true);
- };
-
- // 关闭决策详情
- const handleCloseDecisionDetail = () => {
- setIsDecisionDetailOpen(false);
- setTimeout(() => setSelectedDecision(null), 300);
- };
-
- if (!isOpen) return null;
-
- return (
- <>
- {/* 遮罩层 */}
-
-
- {/* 侧边栏 */}
-
- {/* 头部 - Fixed */}
-
-
-
-
- {username}
-
-
- {market === 'US' ? '美股' : market === 'HK' ? '港股' : 'A股'} 市场
-
-
-
-
-
-
-
- {/* 标签页 - Fixed */}
-
- setActiveTab('positions')}
- className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
- activeTab === 'positions'
- ? 'text-accent-primary border-b-2 border-accent-primary'
- : 'text-text-secondary hover:text-text-primary'
- }`}
- >
-
- 持仓信息
- {positions && positions.length > 0 && (
-
- {positions.length}
-
- )}
-
- setActiveTab('decisions')}
- className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
- activeTab === 'decisions'
- ? 'text-accent-primary border-b-2 border-accent-primary'
- : 'text-text-secondary hover:text-text-primary'
- }`}
- >
-
- 决策记录
- {decisions && decisions.length > 0 && (
-
- {decisions.length}
-
- )}
-
-
-
- {/* 内容区域 */}
-
- {activeTab === 'positions' ? (
-
- {positionsLoading ? (
-
- ) : !positions || positions.length === 0 ? (
-
- ) : (
- positions.map((position: any, index: number) => (
-
-
-
- openFutuStockPage(position.stock_code, position.market_type)}
- className="font-semibold text-accent-primary text-lg flex-shrink-0 hover:underline transition-opacity hover:opacity-80"
- title="点击查看富途股票详情"
- >
- {position.stock_code}
-
- {position.stock_name && (
-
- {position.stock_name}
-
- )}
-
- {position.market_type}
-
-
-
- {position.quantity.toLocaleString()} 股
-
-
-
-
-
-
开仓价格
-
- {getCurrencySymbol(position.market_type)}{position.first_open_price?.toFixed(2) || '0.00'}
-
-
-
-
当前价格
-
- {getCurrencySymbol(position.market_type)}{position.current_price?.toFixed(2) || '0.00'}
-
-
-
-
市值
-
- {getCurrencySymbol(position.market_type)}{position.market_value?.toLocaleString() || '0'}
-
-
-
-
盈亏
-
= 0
- ? 'text-[#f03a55]'
- : 'text-[#00a870]'
- }`}>
- {position.unrealized_pnl && position.unrealized_pnl >= 0 ? '+' : ''}
- {getCurrencySymbol(position.market_type)}{position.unrealized_pnl?.toLocaleString() || '0'}
- {position.pnl_percentage !== undefined && (
-
- ({position.pnl_percentage > 0 ? '+' : ''}
- {position.pnl_percentage.toFixed(2)}%)
-
- )}
-
-
-
-
- {(position.first_open_time || position.holding_days !== undefined) && (
-
-
- {position.first_open_time && (
-
-
- 开仓: {new Date(position.first_open_time).toLocaleString('zh-CN', {
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit'
- })}
-
- )}
- {position.holding_days !== undefined && (
-
-
- 持仓 {position.holding_days} 天
-
- )}
-
-
- )}
-
- ))
- )}
-
- ) : (
-
- {decisionsLoading ? (
-
- ) : !decisions || decisions.length === 0 ? (
-
- ) : (
- decisions.map((decision: any) => (
-
handleViewDecision(decision)}
- >
-
-
-
- {decision.market_type}
-
-
- {decision.status === 'completed' ? '已完成' :
- decision.status === 'running' ? '运行中' : '失败'}
-
-
-
-
-
-
-
-
- {new Date(decision.start_time).toLocaleString('zh-CN')}
-
- {decision.end_time && (
- <>
- →
-
- {new Date(decision.end_time).toLocaleString('zh-CN')}
-
- >
- )}
-
-
-
- {decision.decision_report && (
-
-
- {decision.decision_report}
-
-
- )}
-
- {decision.trades_executed && decision.trades_executed.length > 0 && (
-
-
- 执行交易: {decision.trades_executed.length} 笔
-
-
- )}
-
- ))
- )}
-
- )}
-
-
-
- {/* 决策详情弹窗 */}
- {selectedDecision && (
- <>
-
-
- {/* 详情头部 - Fixed */}
-
-
-
-
- 决策详情
-
-
- {selectedDecision.market_type}
-
-
- {selectedDecision.status === 'completed' ? '已完成' :
- selectedDecision.status === 'running' ? '运行中' : '失败'}
-
-
-
-
-
-
-
- {/* 详情内容 */}
-
- {/* 时间信息 */}
-
-
-
开始时间
-
- {new Date(selectedDecision.start_time).toLocaleString('zh-CN')}
-
-
- {selectedDecision.end_time && (
-
-
结束时间
-
- {new Date(selectedDecision.end_time).toLocaleString('zh-CN')}
-
-
- )}
-
-
- {/* 执行的交易 */}
- {selectedDecision.trades_executed && selectedDecision.trades_executed.length > 0 && (
-
-
-
- 执行交易 ({selectedDecision.trades_executed.length})
-
-
- {selectedDecision.trades_executed.map((trade: any, index: number) => {
- const currencySymbol = getCurrencySymbol(selectedDecision.market_type || 'US');
-
- return (
-
-
-
-
- {trade.action === 'BUY' ? '买入' : trade.action === 'SELL' ? '卖出' : trade.action || '未知'}
-
-
- {trade.stock_code || trade.stock || trade.ticker || '未知股票'}
-
-
- {trade.price && (
-
- {currencySymbol}{trade.price.toFixed(2)}
-
- )}
-
-
- {trade.quantity && (
-
-
- 数量: {trade.quantity} 股
- {trade.price && (
-
-
- 总额: {currencySymbol}{((trade.price || 0) * (trade.quantity || 0)).toFixed(2)}
-
- )}
-
- )}
-
- {(trade.reason || trade.description) && (
-
-
- {trade.reason || trade.description}
-
- )}
-
- );
- })}
-
-
- )}
-
- {/* 分析的持仓 */}
- {selectedDecision.positions_analyzed && selectedDecision.positions_analyzed.length > 0 && (
-
-
-
- 分析的持仓 ({selectedDecision.positions_analyzed.length})
-
-
-
- {selectedDecision.positions_analyzed.map((code: string, index: number) => (
-
- {code}
-
- ))}
-
-
-
- )}
-
- {/* 决策报告 */}
- {selectedDecision.decision_report && (
-
-
-
- 决策报告
-
-
-
-
(
-
- ),
- h2: ({node, ...props}) => (
-
- ),
- h3: ({node, ...props}) => (
-
- ),
- h4: ({node, ...props}) => (
-
- ),
- h5: ({node, ...props}) => (
-
- ),
- h6: ({node, ...props}) => (
-
- ),
- p: ({node, ...props}) => (
-
- ),
- ul: ({node, ...props}) => (
-
- ),
- ol: ({node, ...props}) => (
-
- ),
- li: ({node, ...props}) => (
-
- ),
- strong: ({node, ...props}) => (
-
- ),
- em: ({node, ...props}) => (
-
- ),
- code: ({node, inline, ...props}: any) =>
- inline
- ?
- : ,
- pre: ({node, ...props}) => (
-
- ),
- blockquote: ({node, ...props}) => (
-
- ),
- table: ({node, ...props}) => (
-
- ),
- thead: ({node, ...props}) => (
-
- ),
- tbody: ({node, ...props}) => (
-
- ),
- tr: ({node, ...props}) => (
-
- ),
- th: ({node, ...props}) => (
-
- ),
- td: ({node, ...props}) => (
-
- ),
- a: ({node, ...props}) => (
-
- ),
- hr: ({node, ...props}) => (
-
- ),
- img: ({node, ...props}) => (
-
- ),
- }}
- >
- {selectedDecision.decision_report}
-
-
-
-
- )}
-
-
- >
- )}
- >
- );
-}
diff --git a/web/frontend/src/components/leaderboard/UserPositionsPanel.tsx b/web/frontend/src/components/leaderboard/UserPositionsPanel.tsx
deleted file mode 100644
index 77be941..0000000
--- a/web/frontend/src/components/leaderboard/UserPositionsPanel.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-'use client';
-
-import React from 'react';
-import { formatAmount } from '@/utils/marketCurrency';
-import { openFutuStockPage } from '@/utils/futuLink';
-
-interface Position {
- stock_code: string;
- stock_name?: string;
- market_type: string;
- quantity: number;
- cost_price?: number;
- current_price?: number;
- market_value?: number;
- unrealized_pnl?: number;
- pnl_percentage?: number;
- first_open_time?: string;
- holding_days?: number;
-}
-
-interface UserPositionsPanelProps {
- userId: number;
- username: string;
- positions: Position[] | null;
-}
-
-export function UserPositionsPanel({ userId, username, positions }: UserPositionsPanelProps) {
- return (
-
-
-
-
- 持仓详情
-
- {username}
-
-
- {!positions ? (
-
- ) : positions.length === 0 ? (
-
- ) : (
-
- {positions.map((position, index) => (
-
- {/* 头部:股票代码、公司名称、市场、数量 */}
-
-
- openFutuStockPage(position.stock_code, position.market_type)}
- className="font-semibold text-accent-primary flex-shrink-0 hover:underline transition-opacity hover:opacity-80"
- title="点击查看富途股票详情"
- >
- {position.stock_code}
-
- {position.stock_name && (
-
- {position.stock_name}
-
- )}
-
- {position.market_type}
-
-
-
- {position.quantity.toLocaleString()} 股
-
-
-
- {/* 持仓时长和开仓时间 */}
- {(position.holding_days !== undefined || position.first_open_time) && (
-
- {position.holding_days !== undefined && (
-
-
- 持仓 {position.holding_days} 天
-
- )}
- {position.first_open_time && (
-
-
-
- 开仓: {new Date(position.first_open_time).toLocaleDateString('zh-CN', {
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit'
- })}
-
-
- )}
-
- )}
-
- {/* 价格和盈亏信息 */}
- {position.current_price && (
-
-
-
成本价
-
- {formatAmount(position.cost_price || 0, position.market_type, 2)}
-
-
-
-
当前价
-
- {formatAmount(position.current_price, position.market_type, 2)}
-
-
-
-
市值
-
- {formatAmount(position.market_value || 0, position.market_type, 0)}
-
-
-
-
盈亏
-
= 0
- ? 'text-success-500'
- : 'text-danger-500'
- }`}
- >
- {position.unrealized_pnl && position.unrealized_pnl >= 0 ? '+' : ''}
- {formatAmount(position.unrealized_pnl || 0, position.market_type, 0)}
- {position.pnl_percentage !== undefined && (
-
- ({position.pnl_percentage > 0 ? '+' : ''}
- {position.pnl_percentage.toFixed(2)}%)
-
- )}
-
-
-
- )}
-
- ))}
-
- )}
-
- );
-}
diff --git a/web/frontend/src/hooks/useConversationWebSocket.ts b/web/frontend/src/hooks/useConversationWebSocket.ts
new file mode 100644
index 0000000..07b731a
--- /dev/null
+++ b/web/frontend/src/hooks/useConversationWebSocket.ts
@@ -0,0 +1,64 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { ConversationWebSocket } from '@/lib/conversation';
+import type { ConversationWsEvent } from '@/types/conversation';
+
+interface UseConversationWsOptions {
+ sessionId: string | null;
+ onEvent: (event: ConversationWsEvent) => void;
+ enabled?: boolean;
+}
+
+interface UseConversationWsResult {
+ isConnected: boolean;
+ connect: () => void;
+ disconnect: () => void;
+ stop: () => void;
+ retryStage: (stage: string) => void;
+}
+
+/**
+ * React wrapper around ConversationWebSocket for the chat-driven analysis stream.
+ * Reconnects automatically (logic lives in ConversationWebSocket) and exposes
+ * high-level controls to the workbench.
+ */
+export function useConversationWebSocket({
+ sessionId,
+ onEvent,
+ enabled = true,
+}: UseConversationWsOptions): UseConversationWsResult {
+ const [isConnected, setIsConnected] = useState(false);
+ const wsRef = useRef(null);
+ const onEventRef = useRef(onEvent);
+ onEventRef.current = onEvent;
+
+ const connect = useCallback(() => {
+ if (!sessionId || !enabled) return;
+ if (wsRef.current) return;
+ const ws = new ConversationWebSocket(sessionId, {
+ onEvent: (e) => onEventRef.current(e),
+ onOpen: () => setIsConnected(true),
+ onClose: () => setIsConnected(false),
+ });
+ wsRef.current = ws;
+ ws.connect();
+ }, [sessionId, enabled]);
+
+ const disconnect = useCallback(() => {
+ wsRef.current?.disconnect();
+ wsRef.current = null;
+ setIsConnected(false);
+ }, []);
+
+ const stop = useCallback(() => wsRef.current?.stop(), []);
+ const retryStage = useCallback((stage: string) => wsRef.current?.retryStage(stage), []);
+
+ // Auto connect when session becomes active; disconnect on cleanup / session change.
+ useEffect(() => {
+ if (sessionId && enabled) connect();
+ return () => disconnect();
+ }, [sessionId, enabled, connect, disconnect]);
+
+ return { isConnected, connect, disconnect, stop, retryStage };
+}
diff --git a/web/frontend/src/hooks/useIntradayTrading.ts b/web/frontend/src/hooks/useIntradayTrading.ts
deleted file mode 100644
index 00d0735..0000000
--- a/web/frontend/src/hooks/useIntradayTrading.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-/**
- * React Query hooks for intraday trading system
- */
-
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { intradayTradingAPI } from '@/lib/apiClient';
-
-// Query keys
-export const intradayTradingKeys = {
- all: ['intraday-trading'] as const,
- scheduler: () => [...intradayTradingKeys.all, 'scheduler'] as const,
- schedulerStatus: () => [...intradayTradingKeys.scheduler(), 'status'] as const,
- schedulerConfig: () => [...intradayTradingKeys.scheduler(), 'config'] as const,
- account: () => [...intradayTradingKeys.all, 'account'] as const,
- positions: () => [...intradayTradingKeys.all, 'positions'] as const,
- decisions: () => [...intradayTradingKeys.all, 'decisions'] as const,
- decisionsList: (page: number, limit: number) => [...intradayTradingKeys.decisions(), { page, limit }] as const,
- decision: (id: number) => [...intradayTradingKeys.decisions(), id] as const,
- orders: () => [...intradayTradingKeys.all, 'orders'] as const,
-};
-
-// Hook to get scheduler status
-// NOTE: This hook no longer fetches from API, it only reads from cache
-// The status is populated by WebSocket 'scheduler_status_sync' message on connection
-export function useSchedulerStatus() {
- return useQuery({
- queryKey: intradayTradingKeys.schedulerStatus(),
- queryFn: () => {
- // This should never be called as enabled is false
- // Data is populated by WebSocket only
- throw new Error('Scheduler status should be populated by WebSocket');
- },
- enabled: false, // Never fetch from API, only use WebSocket data
- staleTime: Infinity,
- gcTime: 10 * 60 * 1000,
- });
-}
-
-// Hook to get scheduler config
-export function useSchedulerConfig() {
- return useQuery({
- queryKey: intradayTradingKeys.schedulerConfig(),
- queryFn: () => intradayTradingAPI.getConfig(),
- // Config rarely changes, only refetch on mount
- staleTime: Infinity,
- refetchOnWindowFocus: false,
- refetchOnReconnect: false,
- });
-}
-
-// Hook to control scheduler
-export function useSchedulerControl() {
- const queryClient = useQueryClient();
-
- const start = useMutation({
- mutationFn: () => intradayTradingAPI.startScheduler(),
- // Don't invalidate - WebSocket will update the status
- });
-
- const stop = useMutation({
- mutationFn: () => intradayTradingAPI.stopScheduler(),
- // Don't invalidate - WebSocket will update the status
- });
-
- const updateConfig = useMutation({
- mutationFn: (config: {
- futu_api_url?: string;
- futu_api_key?: string;
- interval_minutes?: number;
- market_type?: string;
- llm_provider?: string;
- llm_api_key?: string;
- }) => intradayTradingAPI.updateConfig(config),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: intradayTradingKeys.schedulerStatus() });
- queryClient.invalidateQueries({ queryKey: intradayTradingKeys.schedulerConfig() });
- },
- });
-
- return { start, stop, updateConfig };
-}
-
-// Hook to get account info
-export function useAccountInfo(market: string = 'US') {
- return useQuery({
- queryKey: [...intradayTradingKeys.account(), market],
- queryFn: () => intradayTradingAPI.getAccountInfo(market),
- // Disable all auto-refetch, rely on WebSocket for updates
- refetchInterval: false,
- refetchOnWindowFocus: false,
- refetchOnMount: false,
- refetchOnReconnect: false,
- staleTime: Infinity, // Never consider stale, only update via WebSocket
- });
-}
-
-// Hook to get positions
-export function usePositions(market: string = 'US') {
- return useQuery({
- queryKey: [...intradayTradingKeys.positions(), market],
- queryFn: () => intradayTradingAPI.getPositions(market),
- // Disable all auto-refetch, rely on WebSocket for updates
- refetchInterval: false,
- refetchOnWindowFocus: false,
- refetchOnMount: false,
- refetchOnReconnect: false,
- staleTime: Infinity, // Never consider stale, only update via WebSocket
- });
-}
-
-// Hook to get decisions list
-// NOTE: This hook no longer fetches from API, it only reads from cache
-// The decisions list is populated by WebSocket 'decisions_initial' message on connection
-// and updated by 'intraday_session_complete' message
-export function useDecisions(page: number = 1, limit: number = 20) {
- return useQuery({
- queryKey: intradayTradingKeys.decisionsList(page, limit),
- queryFn: () => {
- // This should never be called as enabled is false
- // Data is populated by WebSocket only
- throw new Error('Decisions list should be populated by WebSocket');
- },
- enabled: false, // Never fetch from API, only use WebSocket data
- staleTime: Infinity,
- gcTime: 10 * 60 * 1000,
- });
-}
-
-// Hook to get single decision
-export function useDecision(id: number) {
- return useQuery({
- queryKey: intradayTradingKeys.decision(id),
- queryFn: () => intradayTradingAPI.getDecision(id),
- enabled: !!id,
- staleTime: 30 * 1000,
- });
-}
-
-// Hook to get orders
-export function useOrders(market: string = 'US', filterStatus: number = 0) {
- return useQuery({
- queryKey: [...intradayTradingKeys.orders(), market, filterStatus],
- queryFn: () => intradayTradingAPI.getOrders(market, filterStatus),
- // Disable all auto-refetch, rely on manual refresh when switching market
- refetchInterval: false,
- refetchOnWindowFocus: false,
- refetchOnMount: false,
- refetchOnReconnect: false,
- staleTime: Infinity, // Never consider stale, only update manually
- });
-}
-
-// Hook to cancel order
-export function useCancelOrder() {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: ({ orderId, stockCode, marketType }: { orderId: string; stockCode: string; marketType: string }) =>
- intradayTradingAPI.cancelOrder(orderId, stockCode),
- onSuccess: (_, variables) => {
- // Invalidate orders query to refresh the list
- queryClient.invalidateQueries({ queryKey: [...intradayTradingKeys.orders(), variables.marketType] });
- },
- });
-}
diff --git a/web/frontend/src/hooks/useIntradayWebSocket.ts b/web/frontend/src/hooks/useIntradayWebSocket.ts
deleted file mode 100644
index 66da0f7..0000000
--- a/web/frontend/src/hooks/useIntradayWebSocket.ts
+++ /dev/null
@@ -1,171 +0,0 @@
-/**
- * WebSocket hook for intraday trading real-time updates
- */
-
-import { useEffect, useRef, useCallback, useState } from 'react';
-import { buildWebSocketUrl } from '@/utils/api';
-
-interface WebSocketMessage {
- type: string;
- timestamp: string;
- message?: string;
- [key: string]: any;
-}
-
-interface UseIntradayWebSocketOptions {
- onMessage?: (message: WebSocketMessage) => void;
- onStatusChange?: (status: 'connecting' | 'connected' | 'disconnected' | 'error') => void;
- autoConnect?: boolean;
-}
-
-export function useIntradayWebSocket(
- sessionId: string | null,
- options: UseIntradayWebSocketOptions = {}
-) {
- const { onMessage, onStatusChange, autoConnect = true } = options;
-
- const wsRef = useRef(null);
- const reconnectTimeoutRef = useRef(null);
- const pingIntervalRef = useRef(null);
- const reconnectAttemptsRef = useRef(0);
- const isManualDisconnectRef = useRef(false); // Track manual disconnect
- const maxReconnectAttempts = 5;
- const reconnectDelay = 2000;
-
- const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('disconnected');
- const [lastMessage, setLastMessage] = useState(null);
-
- const updateStatus = useCallback((newStatus: typeof status) => {
- setStatus(newStatus);
- onStatusChange?.(newStatus);
- }, [onStatusChange]);
-
- const startPingInterval = useCallback(() => {
- if (pingIntervalRef.current) {
- clearInterval(pingIntervalRef.current);
- }
-
- pingIntervalRef.current = setInterval(() => {
- if (wsRef.current?.readyState === WebSocket.OPEN) {
- wsRef.current.send(JSON.stringify({ type: 'ping' }));
- }
- }, 30000); // Ping every 30 seconds
- }, []);
-
- const stopPingInterval = useCallback(() => {
- if (pingIntervalRef.current) {
- clearInterval(pingIntervalRef.current);
- pingIntervalRef.current = null;
- }
- }, []);
-
- const connect = useCallback(() => {
- if (!sessionId) {
- return;
- }
-
- if (wsRef.current?.readyState === WebSocket.OPEN) {
- return;
- }
-
- try {
- isManualDisconnectRef.current = false; // Reset manual disconnect flag
- updateStatus('connecting');
-
- const baseUrl = buildWebSocketUrl(`/ws/intraday/${sessionId}`); // sessionId is actually user_id
- const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
- const subprotocol = token ? `jwt.${token}` : undefined;
-
- const ws = subprotocol ? new WebSocket(baseUrl, [subprotocol]) : new WebSocket(baseUrl);
- wsRef.current = ws;
-
- ws.onopen = () => {
- updateStatus('connected');
- reconnectAttemptsRef.current = 0;
- startPingInterval();
- };
-
- ws.onmessage = (event) => {
- try {
- const data = JSON.parse(event.data) as WebSocketMessage;
-
- // Ignore ping responses
- if (data.type === 'pong') {
- return;
- }
-
- setLastMessage(data);
- onMessage?.(data);
- } catch (error) {
- console.error('Failed to parse WebSocket message:', error);
- }
- };
-
- ws.onerror = () => {
- updateStatus('error');
- };
-
- ws.onclose = () => {
- stopPingInterval();
- updateStatus('disconnected');
-
- // Only attempt reconnect if not manually disconnected
- if (!isManualDisconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
- reconnectAttemptsRef.current++;
-
- reconnectTimeoutRef.current = setTimeout(() => {
- connect();
- }, reconnectDelay * reconnectAttemptsRef.current);
- }
- };
- } catch (error) {
- console.error('Failed to create WebSocket connection:', error);
- updateStatus('error');
- }
- }, [sessionId, onMessage, updateStatus, startPingInterval, stopPingInterval]);
-
- const disconnect = useCallback(() => {
- isManualDisconnectRef.current = true; // Mark as manual disconnect
- stopPingInterval();
-
- if (reconnectTimeoutRef.current) {
- clearTimeout(reconnectTimeoutRef.current);
- reconnectTimeoutRef.current = null;
- }
-
- if (wsRef.current) {
- wsRef.current.close();
- wsRef.current = null;
- }
-
- updateStatus('disconnected');
- }, [stopPingInterval, updateStatus]);
-
- const send = useCallback((data: any) => {
- if (wsRef.current?.readyState === WebSocket.OPEN) {
- wsRef.current.send(JSON.stringify(data));
- }
- }, []);
-
- // Auto-connect on mount if enabled
- useEffect(() => {
- if (autoConnect && sessionId) {
- connect();
- }
-
- // Cleanup: disconnect when component unmounts or sessionId changes
- return () => {
- disconnect();
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [sessionId, autoConnect]); // Only reconnect when sessionId or autoConnect changes
-
- return {
- status,
- lastMessage,
- connect,
- disconnect,
- send,
- isConnected: status === 'connected',
- };
-}
diff --git a/web/frontend/src/hooks/useLeaderboardWebSocket.ts b/web/frontend/src/hooks/useLeaderboardWebSocket.ts
deleted file mode 100644
index 3252993..0000000
--- a/web/frontend/src/hooks/useLeaderboardWebSocket.ts
+++ /dev/null
@@ -1,303 +0,0 @@
-'use client'
-
-import { useState, useEffect, useRef, useCallback } from 'react'
-import { buildApiUrl } from '@/utils/api'
-
-// Get base URL for WebSocket connections
-const getWebSocketUrl = (endpoint: string): string => {
- // Use the API base URL from environment or window location
- const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || (typeof window !== 'undefined' ? window.location.origin : '')
- const protocol = apiBaseUrl.startsWith('https') ? 'wss:' : 'ws:'
- const host = apiBaseUrl.replace(/^https?:\/\//, '')
- return `${protocol}//${host}${endpoint}`
-}
-
-// WebSocket更新频率:5分钟
-const UPDATE_INTERVAL = 5 * 60 * 1000; // 5 minutes in milliseconds
-
-interface LeaderboardUser {
- user_id: number
- username: string
- market_type: string
- total_assets: number
- latest_snapshot_date: string
-}
-
-interface LeaderboardUpdate {
- type: 'leaderboard_update' | 'user_update' | 'initial_data'
- timestamp: string
- data?: {
- users?: LeaderboardUser[]
- user?: LeaderboardUser
- }
-}
-
-interface UseLeaderboardWebSocketOptions {
- token?: string
- market?: string
- reconnectAttempts?: number
- reconnectInterval?: number
-}
-
-interface UseLeaderboardWebSocketReturn {
- isConnected: boolean
- users: LeaderboardUser[]
- error: string | null
- lastUpdate: string | null
- connect: () => void
- disconnect: () => void
-}
-
-export function useLeaderboardWebSocket(options: UseLeaderboardWebSocketOptions = {}): UseLeaderboardWebSocketReturn {
- const { token, market, reconnectAttempts = 5, reconnectInterval = 3000 } = options
-
- const [isConnected, setIsConnected] = useState(false)
- const [users, setUsers] = useState([])
- const [error, setError] = useState(null)
- const [lastUpdate, setLastUpdate] = useState(null)
-
- const wsRef = useRef(null)
- const reconnectCountRef = useRef(0)
- const reconnectTimeoutRef = useRef(null)
- const heartbeatIntervalRef = useRef(null)
- const updateRequestIntervalRef = useRef(null)
-
- const connect = useCallback(() => {
- // Clean up existing connection first
- if (wsRef.current) {
- if (wsRef.current.readyState === WebSocket.OPEN) {
- console.log('✅ WebSocket already connected')
- return
- }
-
- // Close any existing connection that's not open
- try {
- if (wsRef.current.readyState !== WebSocket.CLOSED) {
- wsRef.current.close()
- }
- } catch (err) {
- console.warn('⚠️ Error closing existing WebSocket:', err)
- }
- wsRef.current = null
- }
-
- try {
- // Build WebSocket URL for leaderboard
- const wsUrl = getWebSocketUrl('/ws/leaderboard')
- console.log('🔌 Connecting to WebSocket:', wsUrl)
-
- const ws = new WebSocket(wsUrl)
- wsRef.current = ws
-
- // Set up connection timeout
- const connectionTimeout = setTimeout(() => {
- if (ws.readyState === WebSocket.CONNECTING) {
- ws.close()
- setError('连接超时 - 无法连接到服务器,请检查后端服务是否运行')
- }
- }, 10000) // 10 seconds timeout
-
- ws.onopen = () => {
- clearTimeout(connectionTimeout)
- console.log('✅ Leaderboard WebSocket connected successfully')
- setIsConnected(true)
- setError(null)
- reconnectCountRef.current = 0
-
- // Start heartbeat (every 30 seconds to keep connection alive)
- heartbeatIntervalRef.current = setInterval(() => {
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify({ type: 'ping' }))
- }
- }, 30000)
-
- // Request initial data immediately
- console.log('📤 Requesting initial leaderboard data...')
- ws.send(JSON.stringify({ type: 'get_initial_data' }))
-
- // Request data updates every 5 minutes
- updateRequestIntervalRef.current = setInterval(() => {
- if (ws.readyState === WebSocket.OPEN) {
- console.log('🔄 Requesting leaderboard data update (5-minute interval)')
- ws.send(JSON.stringify({ type: 'get_initial_data' }))
- }
- }, UPDATE_INTERVAL)
- }
-
- ws.onmessage = (event) => {
- try {
- const message: LeaderboardUpdate = JSON.parse(event.data)
-
- switch (message.type) {
- case 'initial_data':
- if (message.data?.users) {
- console.log(`📊 Received initial data: ${message.data.users.length} users`)
- setUsers(message.data.users)
- setLastUpdate(message.timestamp)
- }
- break
- case 'leaderboard_update':
- if (message.data?.users) {
- console.log(`🔄 Leaderboard update: ${message.data.users.length} users`)
- setUsers(message.data.users)
- setLastUpdate(message.timestamp)
- }
- break
- case 'user_update':
- if (message.data?.user) {
- console.log(`👤 User update: ${message.data.user.username}`)
- setUsers(prev => {
- const updatedUsers = [...prev]
- const userIndex = updatedUsers.findIndex(u => u.user_id === message.data!.user.user_id)
- if (userIndex >= 0) {
- updatedUsers[userIndex] = message.data!.user
- } else {
- updatedUsers.push(message.data!.user)
- }
- // Sort by total_assets descending
- updatedUsers.sort((a, b) => b.total_assets - a.total_assets)
- return updatedUsers
- })
- setLastUpdate(message.timestamp)
- }
- break
- case 'pong':
- // Heartbeat response
- break
- case 'error':
- console.error('❌ Server error:', message.data?.message)
- setError(message.data?.message || 'Leaderboard WebSocket error')
- break
- default:
- console.log('⚠️ Unknown leaderboard message type:', message.type)
- }
- } catch (err) {
- console.error('❌ Error parsing leaderboard WebSocket message:', err)
- }
- }
-
- ws.onclose = (event) => {
- clearTimeout(connectionTimeout)
-
- // Only process close event if this is still the current WebSocket
- if (wsRef.current === ws) {
- console.log(`🔌 Leaderboard WebSocket closed: code=${event.code}, reason=${event.reason || 'No reason'}`)
- setIsConnected(false)
- wsRef.current = null
-
- // Clear intervals
- if (heartbeatIntervalRef.current) {
- clearInterval(heartbeatIntervalRef.current)
- heartbeatIntervalRef.current = null
- }
- if (updateRequestIntervalRef.current) {
- clearInterval(updateRequestIntervalRef.current)
- updateRequestIntervalRef.current = null
- }
-
- // Attempt to reconnect if not manually closed
- if (event.code !== 1000 && reconnectCountRef.current < reconnectAttempts) {
- reconnectCountRef.current++
- console.log(`🔄 Attempting to reconnect leaderboard (${reconnectCountRef.current}/${reconnectAttempts})...`)
- setError(`连接断开,正在重连 (${reconnectCountRef.current}/${reconnectAttempts})...`)
-
- reconnectTimeoutRef.current = setTimeout(() => {
- connect()
- }, reconnectInterval)
- } else if (reconnectCountRef.current >= reconnectAttempts) {
- console.error('❌ Max reconnection attempts reached')
- setError('连接失败,已达到最大重连次数,请刷新页面重试')
- }
- } else {
- console.log('⚠️ Ignoring close event from old WebSocket connection')
- }
- }
-
- ws.onerror = (error) => {
- clearTimeout(connectionTimeout)
-
- // Only log error if this is still the current WebSocket
- if (wsRef.current === ws) {
- console.error('❌ Leaderboard WebSocket error:', error)
- console.error('📍 WebSocket URL:', wsUrl)
- console.error('📊 WebSocket state:', ws.readyState, '(0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED)')
- console.error('🔧 API Base URL:', process.env.NEXT_PUBLIC_API_BASE_URL)
- console.error('🌐 Window origin:', typeof window !== 'undefined' ? window.location.origin : 'N/A')
-
- // 提供更详细的错误信息
- let errorMessage = 'WebSocket连接失败';
-
- if (ws.readyState === WebSocket.CONNECTING) {
- errorMessage = '正在连接WebSocket服务器...';
- } else if (ws.readyState === WebSocket.CLOSED) {
- errorMessage = 'WebSocket连接已关闭,尝试重新连接...';
- } else if (ws.readyState === WebSocket.CLOSING) {
- errorMessage = 'WebSocket连接正在关闭...';
- }
-
- setError(errorMessage)
- } else {
- console.log('⚠️ Ignoring error from old WebSocket connection')
- }
- }
-
- } catch (err) {
- console.error('Failed to create leaderboard WebSocket connection:', err)
- setError('Failed to create connection')
- }
- }, [token, reconnectAttempts, reconnectInterval])
-
- const disconnect = useCallback(() => {
- console.log('🔌 Disconnecting leaderboard WebSocket...')
-
- // Clear all timers
- if (reconnectTimeoutRef.current) {
- clearTimeout(reconnectTimeoutRef.current)
- reconnectTimeoutRef.current = null
- }
-
- if (heartbeatIntervalRef.current) {
- clearInterval(heartbeatIntervalRef.current)
- heartbeatIntervalRef.current = null
- }
-
- if (updateRequestIntervalRef.current) {
- clearInterval(updateRequestIntervalRef.current)
- updateRequestIntervalRef.current = null
- }
-
- // Close WebSocket connection
- if (wsRef.current) {
- try {
- // Only close if not already closed
- if (wsRef.current.readyState !== WebSocket.CLOSED) {
- wsRef.current.close(1000, 'Manual disconnect')
- }
- } catch (err) {
- console.warn('⚠️ Error closing WebSocket:', err)
- }
- wsRef.current = null
- }
-
- setIsConnected(false)
- setError(null)
- reconnectCountRef.current = 0
-
- console.log('✅ Leaderboard WebSocket disconnected')
- }, [])
-
- useEffect(() => {
- return () => {
- disconnect()
- }
- }, [disconnect])
-
- return {
- isConnected,
- users,
- error,
- lastUpdate,
- connect,
- disconnect,
- }
-}
\ No newline at end of file
diff --git a/web/frontend/src/lib/conversation-context.tsx b/web/frontend/src/lib/conversation-context.tsx
new file mode 100644
index 0000000..ef93f37
--- /dev/null
+++ b/web/frontend/src/lib/conversation-context.tsx
@@ -0,0 +1,428 @@
+'use client';
+
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useReducer,
+ useRef,
+ useState,
+} from 'react';
+import {
+ conversationAPI,
+ messageAPI,
+ skillsAPI,
+ type ConversationWebSocket,
+} from '@/lib/conversation';
+import { useConversationWebSocket } from '@/hooks/useConversationWebSocket';
+import type {
+ Message,
+ Session,
+ SkillHealth,
+ StageStatus,
+ ContentBlock,
+ Report,
+ ConversationWsEvent,
+} from '@/types/conversation';
+import { useAuth } from '@/lib/auth';
+
+// ---- streaming message assembly ----
+
+interface StreamState {
+ messages: Message[];
+ activeAssistantId: string | null;
+ isStreaming: boolean;
+ streamingError: string | null;
+ reports: Record; // report_id -> report (for rendering cards)
+}
+
+type StreamAction =
+ | { type: 'SET_MESSAGES'; messages: Message[] }
+ | { type: 'INIT_ASSISTANT'; id: string }
+ | { type: 'TOKEN'; id: string; content: string }
+ | { type: 'STAGE_START'; stageId: string; stageName: string }
+ | { type: 'STAGE_UPDATE'; stageId: string; summary: string }
+ | { type: 'STAGE_COMPLETE'; stageId: string; completedAt: string }
+ | { type: 'STAGE_WARNING'; stageId: string; message: string }
+ | { type: 'STAGE_ERROR'; stageId: string; message: string }
+ | { type: 'REPORT_READY'; report: Report; messageId: string }
+ | { type: 'STREAM_END' }
+ | { type: 'STREAM_ERROR'; message: string };
+
+function upsertStageBlock(blocks: ContentBlock[], stageId: string, patch: Partial>): ContentBlock[] {
+ const idx = blocks.findIndex((b) => b.type === 'stage_progress' && b.stage_id === stageId);
+ if (idx === -1) {
+ return [
+ ...blocks,
+ {
+ type: 'stage_progress',
+ stage_id: stageId,
+ stage_name: patch.stage_name ?? stageId,
+ status: patch.status ?? 'active',
+ summary: patch.summary,
+ started_at: patch.started_at,
+ completed_at: patch.completed_at,
+ } as Extract,
+ ];
+ }
+ const next = blocks.slice();
+ const cur = next[idx] as Extract;
+ next[idx] = { ...cur, ...patch };
+ return next;
+}
+
+function streamReducer(state: StreamState, action: StreamAction): StreamState {
+ switch (action.type) {
+ case 'SET_MESSAGES':
+ return { ...state, messages: action.messages, activeAssistantId: null, isStreaming: false };
+ case 'INIT_ASSISTANT': {
+ if (state.messages.some((m) => m.id === action.id)) {
+ return { ...state, activeAssistantId: action.id };
+ }
+ const msg: Message = {
+ id: action.id,
+ session_id: '',
+ role: 'assistant',
+ content: '',
+ content_blocks: [],
+ created_at: new Date().toISOString(),
+ };
+ return { ...state, messages: [...state.messages, msg], activeAssistantId: action.id };
+ }
+ case 'TOKEN': {
+ const id = action.id;
+ const messages = state.messages.map((m) => {
+ if (m.id !== id) return m;
+ const blocks = m.content_blocks ? m.content_blocks.slice() : [];
+ const lastText = blocks[blocks.length - 1];
+ if (lastText && lastText.type === 'text') {
+ blocks[blocks.length - 1] = { type: 'text', content: lastText.content + action.content };
+ } else {
+ blocks.push({ type: 'text', content: action.content });
+ }
+ return { ...m, content: m.content + action.content, content_blocks: blocks };
+ });
+ return { ...state, messages, activeAssistantId: id };
+ }
+ case 'STAGE_START': {
+ const id = state.activeAssistantId;
+ if (!id) return state;
+ return {
+ ...state,
+ messages: state.messages.map((m) =>
+ m.id === id
+ ? { ...m, content_blocks: upsertStageBlock(m.content_blocks ?? [], action.stageId, { stage_name: action.stageName, status: 'active' as StageStatus, started_at: new Date().toISOString() }) }
+ : m
+ ),
+ };
+ }
+ case 'STAGE_UPDATE': {
+ const id = state.activeAssistantId;
+ if (!id) return state;
+ return {
+ ...state,
+ messages: state.messages.map((m) =>
+ m.id === id
+ ? { ...m, content_blocks: upsertStageBlock(m.content_blocks ?? [], action.stageId, { summary: action.summary }) }
+ : m
+ ),
+ };
+ }
+ case 'STAGE_COMPLETE': {
+ const id = state.activeAssistantId;
+ if (!id) return state;
+ return {
+ ...state,
+ messages: state.messages.map((m) =>
+ m.id === id
+ ? { ...m, content_blocks: upsertStageBlock(m.content_blocks ?? [], action.stageId, { status: 'complete' as StageStatus, completed_at: action.completedAt }) }
+ : m
+ ),
+ };
+ }
+ case 'STAGE_WARNING': {
+ const id = state.activeAssistantId;
+ if (!id) return state;
+ return {
+ ...state,
+ messages: state.messages.map((m) =>
+ m.id === id
+ ? { ...m, content_blocks: upsertStageBlock(m.content_blocks ?? [], action.stageId, { status: 'warning' as StageStatus, summary: action.message }) }
+ : m
+ ),
+ };
+ }
+ case 'STAGE_ERROR': {
+ const id = state.activeAssistantId;
+ if (!id) return state;
+ return {
+ ...state,
+ messages: state.messages.map((m) =>
+ m.id === id
+ ? { ...m, content_blocks: upsertStageBlock(m.content_blocks ?? [], action.stageId, { status: 'error' as StageStatus, summary: action.message }) }
+ : m
+ ),
+ };
+ }
+ case 'REPORT_READY': {
+ const report = action.report;
+ const messages = state.messages.map((m) => {
+ if (m.id !== action.messageId) return m;
+ const blocks = m.content_blocks ? m.content_blocks.slice() : [];
+ const exists = blocks.some((b) => b.type === 'report' && b.report_id === report.id);
+ if (!exists) {
+ blocks.push({
+ type: 'report',
+ report_id: report.id,
+ report_preview: { ticker: report.ticker, rating: report.conclusion.rating, summary: report.conclusion.summary },
+ });
+ }
+ return { ...m, content_blocks: blocks };
+ });
+ return { ...state, messages, reports: { ...state.reports, [report.id]: report }, isStreaming: false };
+ }
+ case 'STREAM_END':
+ return { ...state, isStreaming: false };
+ case 'STREAM_ERROR':
+ return { ...state, isStreaming: false, streamingError: action.message };
+ default:
+ return state;
+ }
+}
+
+// ---- context shape ----
+
+interface ConversationContextValue {
+ sessions: Session[];
+ activeSessionId: string | null;
+ messages: Message[];
+ isStreaming: boolean;
+ streamingError: string | null;
+ reports: Record;
+ skillsHealth: SkillHealth[];
+ isConnected: boolean;
+ loadingSessions: boolean;
+ loadSessions: () => Promise;
+ createSession: () => Promise;
+ selectSession: (id: string) => Promise;
+ renameSession: (id: string, title: string) => Promise;
+ deleteSession: (id: string) => Promise;
+ sendMessage: (content: string) => Promise;
+ stopAnalysis: () => void;
+ retryStage: (stage: string) => void;
+ refreshSkills: () => Promise;
+}
+
+const ConversationContext = createContext(undefined);
+
+export function ConversationProvider({ children }: { children: React.ReactNode }) {
+ const { user } = useAuth();
+ const [sessions, setSessions] = useState([]);
+ const [activeSessionId, setActiveSessionId] = useState(null);
+ const [skillsHealth, setSkillsHealth] = useState([]);
+ const [loadingSessions, setLoadingSessions] = useState(false);
+ const [stream, dispatch] = useReducer(streamReducer, {
+ messages: [],
+ activeAssistantId: null,
+ isStreaming: false,
+ streamingError: null,
+ reports: {},
+ });
+ const wsRef = useRef(null);
+
+ const loadSessions = useCallback(async () => {
+ if (!user) {
+ setSessions([]);
+ return;
+ }
+ setLoadingSessions(true);
+ try {
+ const res = await conversationAPI.list({ limit: 50 });
+ setSessions(res.data);
+ } catch {
+ // ignore; surface via UI if needed
+ } finally {
+ setLoadingSessions(false);
+ }
+ }, [user]);
+
+ const selectSession = useCallback(async (id: string) => {
+ setActiveSessionId(id);
+ try {
+ const res = await messageAPI.list(id, { limit: 50 });
+ dispatch({ type: 'SET_MESSAGES', messages: res.data });
+ } catch {
+ dispatch({ type: 'SET_MESSAGES', messages: [] });
+ }
+ }, []);
+
+ const createSession = useCallback(async (): Promise => {
+ const res = await conversationAPI.create({ title: '新对话' });
+ const session = res.data;
+ setSessions((prev) => [session, ...prev]);
+ setActiveSessionId(session.id);
+ dispatch({ type: 'SET_MESSAGES', messages: [] });
+ return session.id;
+ }, []);
+
+ const renameSession = useCallback(async (id: string, title: string) => {
+ try {
+ const res = await conversationAPI.update(id, { title });
+ setSessions((prev) => prev.map((s) => (s.id === id ? res.data : s)));
+ } catch {
+ /* noop */
+ }
+ }, []);
+
+ const deleteSession = useCallback(
+ async (id: string) => {
+ try {
+ await conversationAPI.remove(id);
+ setSessions((prev) => prev.filter((s) => s.id !== id));
+ if (activeSessionId === id) {
+ setActiveSessionId(null);
+ dispatch({ type: 'SET_MESSAGES', messages: [] });
+ }
+ } catch {
+ /* noop */
+ }
+ },
+ [activeSessionId]
+ );
+
+ // ---- WS event handler ----
+ const handleEvent = useCallback((event: ConversationWsEvent) => {
+ switch (event.type) {
+ case 'token':
+ dispatch({ type: 'INIT_ASSISTANT', id: event.data.message_id });
+ dispatch({ type: 'TOKEN', id: event.data.message_id, content: event.data.content });
+ break;
+ case 'stage_start':
+ dispatch({ type: 'STAGE_START', stageId: event.data.stage_id, stageName: event.data.stage_name });
+ break;
+ case 'stage_update':
+ dispatch({ type: 'STAGE_UPDATE', stageId: event.data.stage_id, summary: event.data.summary });
+ break;
+ case 'stage_complete':
+ dispatch({ type: 'STAGE_COMPLETE', stageId: event.data.stage_id, completedAt: event.data.completed_at });
+ break;
+ case 'stage_warning':
+ dispatch({ type: 'STAGE_WARNING', stageId: event.data.stage_id, message: event.data.message });
+ break;
+ case 'stage_error':
+ dispatch({ type: 'STAGE_ERROR', stageId: event.data.stage_id, message: event.data.message });
+ break;
+ case 'analysis_complete':
+ dispatch({ type: 'STREAM_END' });
+ break;
+ case 'report_ready':
+ dispatch({ type: 'REPORT_READY', report: event.data.report, messageId: event.data.message_id });
+ break;
+ case 'stop_ack':
+ dispatch({ type: 'STREAM_END' });
+ break;
+ case 'error':
+ dispatch({ type: 'STREAM_ERROR', message: event.data.message });
+ break;
+ default:
+ break;
+ }
+ }, []);
+
+ const { isConnected, stop, retryStage } = useConversationWebSocket({
+ sessionId: activeSessionId,
+ onEvent: handleEvent,
+ enabled: !!user && !!activeSessionId,
+ });
+
+ const sendMessage = useCallback(
+ async (content: string) => {
+ const text = content.trim();
+ if (!text || !user) return;
+ let sessionId = activeSessionId;
+ if (!sessionId) {
+ sessionId = await createSession();
+ }
+ const clientMessageId =
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
+ ? crypto.randomUUID()
+ : `c_${Date.now()}_${Math.random().toString(36).slice(2)}`;
+ // optimistic user message
+ const userMsg: Message = {
+ id: clientMessageId,
+ session_id: sessionId,
+ role: 'user',
+ content: text,
+ content_blocks: [{ type: 'text', content: text }],
+ created_at: new Date().toISOString(),
+ };
+ dispatch({ type: 'SET_MESSAGES', messages: [...stream.messages, userMsg] });
+ dispatch({ type: 'INIT_ASSISTANT', id: `pending-${sessionId}-${Date.now()}` });
+ try {
+ const res = await messageAPI.send(sessionId, { content: text, client_message_id: clientMessageId });
+ // replace optimistic user message with server-confirmed one
+ dispatch({
+ type: 'SET_MESSAGES',
+ messages: [
+ ...stream.messages.filter((m) => m.id !== clientMessageId),
+ { ...res.data, content_blocks: res.data.content_blocks ?? [{ type: 'text', content: res.data.content }] },
+ ],
+ });
+ } catch (err) {
+ dispatch({ type: 'STREAM_ERROR', message: err instanceof Error ? err.message : '发送失败' });
+ }
+ },
+ [activeSessionId, user, createSession, stream.messages]
+ );
+
+ const refreshSkills = useCallback(async () => {
+ try {
+ const res = await skillsAPI.health();
+ setSkillsHealth(res.data.skills);
+ } catch {
+ /* noop */
+ }
+ }, []);
+
+ useEffect(() => {
+ if (user) {
+ loadSessions();
+ refreshSkills();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [user]);
+
+ const value = useMemo(
+ () => ({
+ sessions,
+ activeSessionId,
+ messages: stream.messages,
+ isStreaming: stream.isStreaming,
+ streamingError: stream.streamingError,
+ reports: stream.reports,
+ skillsHealth,
+ isConnected,
+ loadingSessions,
+ loadSessions,
+ createSession,
+ selectSession,
+ renameSession,
+ deleteSession,
+ sendMessage,
+ stopAnalysis: stop,
+ retryStage,
+ refreshSkills,
+ }),
+ [sessions, activeSessionId, stream, skillsHealth, isConnected, loadingSessions, loadSessions, createSession, selectSession, renameSession, deleteSession, sendMessage, stop, retryStage, refreshSkills]
+ );
+
+ return {children} ;
+}
+
+export function useConversation() {
+ const ctx = useContext(ConversationContext);
+ if (!ctx) throw new Error('useConversation must be used within ConversationProvider');
+ return ctx;
+}
diff --git a/web/frontend/src/lib/conversation.ts b/web/frontend/src/lib/conversation.ts
new file mode 100644
index 0000000..75d9296
--- /dev/null
+++ b/web/frontend/src/lib/conversation.ts
@@ -0,0 +1,230 @@
+// Conversation / Report / Skills API client.
+// Mirrors the existing auth/api pattern (Bearer token from localStorage 'access_token').
+// Aligned with frontend-agent/api-contract.md.
+
+import { buildApiUrl, buildWebSocketUrl } from '@/utils/api';
+import type {
+ Session,
+ SessionListResponse,
+ Message,
+ MessageListResponse,
+ Report,
+ ReportPreview,
+ ReportListResponse,
+ SkillHealth,
+ SkillsHealthResponse,
+ ConversationWsEvent,
+} from '@/types/conversation';
+
+const getAuthToken = (): string | null => {
+ if (typeof window === 'undefined') return null;
+ return localStorage.getItem('access_token');
+};
+
+async function apiRequest(endpoint: string, options: RequestInit = {}): Promise {
+ const token = getAuthToken();
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ ...(options.headers as Record),
+ };
+ if (token) headers['Authorization'] = `Bearer ${token}`;
+
+ const response = await fetch(buildApiUrl(endpoint), { ...options, headers });
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ if (typeof window !== 'undefined') {
+ localStorage.removeItem('access_token');
+ const publicPages = ['/', '/login', '/register', '/auth'];
+ if (!publicPages.includes(window.location.pathname)) {
+ window.location.href = '/login';
+ }
+ }
+ throw new Error('无法验证凭据');
+ }
+ const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
+ throw new Error((error as any).detail || `HTTP ${response.status}`);
+ }
+ return response.json() as Promise;
+}
+
+export const conversationAPI = {
+ list: (params?: { page?: number; limit?: number }) => {
+ const q = new URLSearchParams();
+ if (params?.page) q.append('page', String(params.page));
+ if (params?.limit) q.append('limit', String(params.limit ?? 50));
+ return apiRequest(`/api/conversations?${q.toString()}`);
+ },
+ create: (data: { title?: string }) =>
+ apiRequest<{ data: Session }>('/api/conversations', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ }),
+ get: (id: string) => apiRequest<{ data: Session }>(`/api/conversations/${id}`),
+ update: (id: string, data: { title: string }) =>
+ apiRequest<{ data: Session }>(`/api/conversations/${id}`, {
+ method: 'PATCH',
+ body: JSON.stringify(data),
+ }),
+ remove: (id: string) =>
+ apiRequest<{ data: { deleted: boolean; id: string } }>(`/api/conversations/${id}`, {
+ method: 'DELETE',
+ }),
+};
+
+export const messageAPI = {
+ list: (sessionId: string, params?: { before_id?: string; limit?: number }) => {
+ const q = new URLSearchParams();
+ if (params?.before_id) q.append('before_id', params.before_id);
+ if (params?.limit) q.append('limit', String(params.limit));
+ return apiRequest(
+ `/api/conversations/${sessionId}/messages?${q.toString()}`
+ );
+ },
+ send: (sessionId: string, data: { content: string; client_message_id: string }) =>
+ apiRequest<{ data: Message }>(`/api/conversations/${sessionId}/messages`, {
+ method: 'POST',
+ body: JSON.stringify(data),
+ }),
+ followUp: (
+ sessionId: string,
+ messageId: string,
+ data: { action: 'retry_stage' | 'expand_section' | 'ask_followup'; stage?: string; section?: string; content?: string }
+ ) =>
+ apiRequest<{ data: Message }>(
+ `/api/conversations/${sessionId}/messages/${messageId}/follow-up`,
+ { method: 'POST', body: JSON.stringify(data) }
+ ),
+};
+
+export const reportAPI = {
+ list: (params?: Record) => {
+ const q = new URLSearchParams();
+ Object.entries(params ?? {}).forEach(([k, v]) => {
+ if (v !== undefined && v !== '') q.append(k, String(v));
+ });
+ return apiRequest(`/api/reports?${q.toString()}`);
+ },
+ get: (id: string) => apiRequest<{ data: Report }>(`/api/reports/${id}`),
+ // Returns a direct download URL (backend may respond with the file stream or a signed url).
+ exportUrl: (id: string, format: 'md' | 'json' | 'pdf') =>
+ buildApiUrl(`/api/reports/${id}/export?format=${format}`),
+};
+
+export const skillsAPI = {
+ health: () => apiRequest('/api/skills/health'),
+};
+
+// ---- WebSocket client for conversation streaming (api-contract §5.1) ----
+
+export type ConversationWsHandlers = {
+ onEvent: (event: ConversationWsEvent) => void;
+ onOpen?: () => void;
+ onClose?: () => void;
+ onError?: (err: Event) => void;
+};
+
+export class ConversationWebSocket {
+ private ws: WebSocket | null = null;
+ private sessionId: string;
+ private handlers: ConversationWsHandlers;
+ private reconnectAttempts = 0;
+ private maxReconnect = 5;
+ private reconnectDelay = 1000;
+ private heartbeatTimer: ReturnType | null = null;
+ private closedByUser = false;
+ private messageQueue: string[] = [];
+
+ constructor(sessionId: string, handlers: ConversationWsHandlers) {
+ this.sessionId = sessionId;
+ this.handlers = handlers;
+ }
+
+ connect() {
+ if (typeof window === 'undefined') return;
+ const token = getAuthToken();
+ const base = buildWebSocketUrl(`/ws/conversation/${this.sessionId}`);
+ const url = token ? `${base}?token=${token}` : base;
+ this.ws = new WebSocket(url);
+
+ this.ws.onopen = () => {
+ this.reconnectAttempts = 0;
+ if (token) this.sendRaw({ type: 'auth', token });
+ this.startHeartbeat();
+ // flush queued client messages
+ this.messageQueue.forEach((m) => this.sendRaw(JSON.parse(m)));
+ this.messageQueue = [];
+ this.handlers.onOpen?.();
+ };
+
+ this.ws.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data) as ConversationWsEvent;
+ if (data.type === 'ping') {
+ this.sendRaw({ type: 'pong' });
+ return;
+ }
+ this.handlers.onEvent(data);
+ } catch {
+ // ignore malformed
+ }
+ };
+
+ this.ws.onerror = (err) => this.handlers.onError?.(err);
+ this.ws.onclose = () => {
+ this.stopHeartbeat();
+ this.handlers.onClose?.();
+ if (!this.closedByUser && this.reconnectAttempts < this.maxReconnect) {
+ this.reconnectAttempts++;
+ setTimeout(() => this.connect(), this.reconnectDelay * this.reconnectAttempts);
+ }
+ };
+ }
+
+ private sendRaw(obj: unknown) {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(obj));
+ }
+ }
+
+ send(obj: Record) {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.sendRaw(obj);
+ } else {
+ // queue until open
+ this.messageQueue.push(JSON.stringify(obj));
+ }
+ }
+
+ stop() {
+ this.send({ type: 'stop' });
+ }
+
+ retryStage(stage: string) {
+ this.send({ type: 'retry_stage', stage });
+ }
+
+ private startHeartbeat() {
+ this.heartbeatTimer = setInterval(() => {
+ if (this.ws?.readyState === WebSocket.OPEN) this.sendRaw({ type: 'ping' });
+ }, 30000);
+ }
+
+ private stopHeartbeat() {
+ if (this.heartbeatTimer) {
+ clearInterval(this.heartbeatTimer);
+ this.heartbeatTimer = null;
+ }
+ }
+
+ disconnect() {
+ this.closedByUser = true;
+ this.stopHeartbeat();
+ if (this.ws) {
+ this.ws.close(1000, 'Manual disconnect');
+ this.ws = null;
+ }
+ }
+}
+
+export type { SkillHealth };
diff --git a/web/frontend/src/types/conversation.ts b/web/frontend/src/types/conversation.ts
new file mode 100644
index 0000000..a68ce5b
--- /dev/null
+++ b/web/frontend/src/types/conversation.ts
@@ -0,0 +1,277 @@
+// Conversation / Analysis domain types for the chat-driven workbench.
+// Aligned with frontend-agent/api-contract.md (Stage 3 deliverable).
+
+export type Market = 'US' | 'HK' | 'CN';
+
+export type StageStatus =
+ | 'pending'
+ | 'active'
+ | 'complete'
+ | 'warning'
+ | 'error'
+ | 'stopped';
+
+// Ordered canonical stage ids used by the backend streaming protocol.
+export const STAGE_ORDER: string[] = [
+ 'intent_recognition',
+ 'market_identification',
+ 'market_analysis',
+ 'fundamentals_analysis',
+ 'sentiment_analysis',
+ 'news_analysis',
+ 'bull_bear_research',
+ 'risk_assessment',
+ 'report_assembly',
+];
+
+export interface Session {
+ id: string;
+ title: string;
+ last_message_preview: string | null;
+ message_count: number;
+ has_active_analysis: boolean;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface SessionListResponse {
+ data: Session[];
+ meta: { page: number; limit: number; total: number; has_next: boolean };
+}
+
+// ---- Message & content blocks ----
+
+export type MessageRole = 'user' | 'assistant' | 'system';
+
+export interface TextBlock {
+ type: 'text';
+ content: string;
+}
+
+export interface StageProgressBlock {
+ type: 'stage_progress';
+ stage_id: string;
+ stage_name: string;
+ status: StageStatus;
+ summary?: string;
+ started_at?: string | null;
+ completed_at?: string | null;
+}
+
+export interface ReportPreviewBlock {
+ type: 'report';
+ report_id: string;
+ report_preview: {
+ ticker: string;
+ rating: number;
+ summary: string;
+ };
+}
+
+export type ContentBlock = TextBlock | StageProgressBlock | ReportPreviewBlock;
+
+export interface Message {
+ id: string;
+ session_id: string;
+ role: MessageRole;
+ content: string;
+ content_blocks?: ContentBlock[];
+ created_at: string;
+}
+
+export interface MessageListResponse {
+ data: Message[];
+ meta: { has_more: boolean; oldest_message_id?: string };
+}
+
+// ---- Report ----
+
+export interface ReportConclusion {
+ rating: number; // 1-5
+ rating_label: string;
+ summary: string;
+ key_points: string[];
+}
+
+export interface ReportIndicator {
+ name: string;
+ value: string;
+ trend: 'up' | 'down' | 'flat';
+}
+
+export interface DataSource {
+ name: string;
+ snapshot_time: string;
+}
+
+export interface NewsSource {
+ title: string;
+ url: string;
+ published_at: string;
+}
+
+export interface ReportFinancials {
+ market_cap?: string;
+ pe_ratio?: number | null;
+ pb_ratio?: number | null;
+ revenue_growth?: string | null;
+}
+
+export type SectionKey =
+ | 'market_technical'
+ | 'fundamentals'
+ | 'sentiment'
+ | 'news_macro'
+ | 'risk';
+
+export interface ReportSection {
+ key: SectionKey;
+ title: string;
+ summary: string;
+ content: string; // Markdown
+ indicators?: ReportIndicator[];
+ financials?: ReportFinancials;
+ news_sources?: NewsSource[];
+ risk_factors?: string[];
+ grounded_evidence?: string;
+ data_sources?: DataSource[];
+}
+
+export interface ReportSource {
+ type: 'conversation' | 'scheduled_task';
+ session_id?: string | null;
+ task_id?: number | null;
+}
+
+export interface StageLogEntry {
+ stage_id: string;
+ stage_name: string;
+ status: 'complete' | 'warning' | 'error';
+ started_at?: string;
+ completed_at?: string;
+ duration_ms?: number;
+ error?: string | null;
+}
+
+export interface ReportReflection {
+ previous_decisions?: string | null;
+ alpha_vs_benchmark?: string | null;
+}
+
+export interface Report {
+ id: string;
+ ticker: string;
+ company_name?: string;
+ market: Market;
+ source: ReportSource;
+ conclusion: ReportConclusion;
+ sections: ReportSection[];
+ stage_log?: StageLogEntry[];
+ reflection?: ReportReflection;
+ status: 'completed' | 'failed' | 'partial';
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ReportPreview {
+ id: string;
+ ticker: string;
+ company_name?: string;
+ market: Market;
+ rating: number;
+ rating_label: string;
+ summary: string;
+ section_summaries: Record;
+ source: ReportSource;
+ status: 'completed' | 'failed' | 'partial';
+ created_at: string;
+}
+
+export interface ReportListResponse {
+ data: ReportPreview[];
+ meta: { page: number; limit: number; total: number; has_next: boolean };
+}
+
+export interface SkillHealth {
+ name: string;
+ display_name: string;
+ description?: string;
+ status: 'healthy' | 'degraded' | 'unavailable';
+ primary_source?: string | null;
+ fallback_source?: string | null;
+ markets?: Market[];
+ last_error?: string | null;
+ last_checked_at?: string;
+}
+
+export interface SkillsHealthResponse {
+ data: { skills: SkillHealth[]; updated_at: string };
+}
+
+// ---- WebSocket streaming events (api-contract §5) ----
+
+export interface TokenEvent {
+ type: 'token';
+ data: { content: string; message_id: string };
+}
+export interface StageStartEvent {
+ type: 'stage_start';
+ data: { stage_id: string; stage_name: string; display_name?: string };
+}
+export interface StageUpdateEvent {
+ type: 'stage_update';
+ data: { stage_id: string; summary: string };
+}
+export interface StageCompleteEvent {
+ type: 'stage_complete';
+ data: { stage_id: string; completed_at: string; duration_ms: number };
+}
+export interface StageWarningEvent {
+ type: 'stage_warning';
+ data: { stage_id: string; message: string; can_continue: boolean };
+}
+export interface StageErrorEvent {
+ type: 'stage_error';
+ data: { stage_id: string; message: string; retryable: boolean };
+}
+export interface AnalysisCompleteEvent {
+ type: 'analysis_complete';
+ data: { message_id: string; duration_ms: number; stages_completed: number; stages_total: number };
+}
+export interface ReportReadyEvent {
+ type: 'report_ready';
+ data: { report_id: string; message_id: string; report: Report };
+}
+export interface StopAckEvent {
+ type: 'stop_ack';
+ data: { message_id: string; stopped_at: string; completed_stages: string[]; partial_content: string };
+}
+export interface GlobalErrorEvent {
+ type: 'error';
+ data: { code: string; message: string; stage_id?: string | null };
+}
+export interface PongEvent {
+ type: 'pong';
+}
+export interface PingEvent {
+ type: 'ping';
+}
+
+export type ConversationWsEvent =
+ | TokenEvent
+ | StageStartEvent
+ | StageUpdateEvent
+ | StageCompleteEvent
+ | StageWarningEvent
+ | StageErrorEvent
+ | AnalysisCompleteEvent
+ | ReportReadyEvent
+ | StopAckEvent
+ | GlobalErrorEvent
+ | PongEvent
+ | PingEvent;
+
+export interface SkillsHealthEvent {
+ type: 'health_update';
+ data: { skills: SkillHealth[]; updated_at: string };
+}