Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,9 @@ BRAVE_API_KEYS=
# 留空时默认自动从 searx.space 拉取公共实例;若不希望访问公共实例,可将下方开关设为 false
SEARXNG_BASE_URLS=
SEARXNG_PUBLIC_INSTANCES_ENABLED=true
# 东方财富妙想 API Key(金融新闻搜索 + 智能选股,支持多个,逗号分隔)
# 获取: https://mkapi2.dfcfs.com/
# MX_APIKEY=your_miaoxiang_key_here

# ===================================
# Social Sentiment Intelligence (US stocks only)
Expand Down
2 changes: 2 additions & 0 deletions api/v1/endpoints/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
agent,
usage,
portfolio,
screen,
)
__all__ = [
"health",
Expand All @@ -31,4 +32,5 @@
"agent",
"usage",
"portfolio",
"screen",
]
81 changes: 81 additions & 0 deletions api/v1/endpoints/screen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
"""
===================================
AI 选股 API 端点(基于东方财富妙想智能选股)
===================================

职责:
1. 接收自然语言选股条件
2. 复用 miaoxiang_tools 中的 smart_stock_screen 实现
3. 返回结构化选股结果
"""

from __future__ import annotations

import logging
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field

from src.agent.tools.miaoxiang_tools import _handle_smart_stock_screen

logger = logging.getLogger(__name__)

router = APIRouter()


class ScreenQueryRequest(BaseModel):
"""AI 选股请求"""
query: str = Field(..., min_length=1, max_length=500, description="自然语言选股条件,如 '今天涨幅超过5%的A股'")


class ScreenQueryResponse(BaseModel):
"""AI 选股响应"""
success: bool
query: str
results_count: int = 0
returned_count: int = 0
data_source: Optional[str] = None
results: List[Dict[str, Any]] = Field(default_factory=list)
message: Optional[str] = None
error: Optional[str] = None


@router.post(
"/query",
response_model=ScreenQueryResponse,
summary="AI 智能选股",
description="通过自然语言条件调用东方财富妙想 API 筛选股票。需配置 MX_APIKEY。",
)
def screen_query(payload: ScreenQueryRequest) -> ScreenQueryResponse:
query = payload.query.strip()
if not query:
raise HTTPException(status_code=400, detail="选股条件不能为空")

logger.info("AI screen query: %s", query)
result = _handle_smart_stock_screen(query)

# Error case: MX_APIKEY not configured or all keys failed
if "error" in result:
err_msg = result["error"]
if "not configured" in err_msg or "MX_APIKEY" in err_msg:
raise HTTPException(
status_code=503,
detail="AI 选股功能未启用:请在设置页配置 MX_APIKEY(东方财富妙想 API Key)。",
)
return ScreenQueryResponse(
success=False,
query=query,
error=err_msg,
)

return ScreenQueryResponse(
success=bool(result.get("success", False)),
query=result.get("query", query),
results_count=int(result.get("results_count", 0) or 0),
returned_count=int(result.get("returned_count", 0) or 0),
data_source=result.get("data_source"),
results=result.get("results", []) or [],
message=result.get("message"),
)
8 changes: 7 additions & 1 deletion api/v1/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from fastapi import APIRouter

from api.v1.endpoints import analysis, auth, history, stocks, backtest, system_config, agent, usage, portfolio
from api.v1.endpoints import analysis, auth, history, stocks, backtest, system_config, agent, usage, portfolio, screen

# 创建 v1 版本主路由
router = APIRouter(prefix="/api/v1")
Expand Down Expand Up @@ -69,3 +69,9 @@
prefix="/portfolio",
tags=["Portfolio"]
)

router.include_router(
screen.router,
prefix="/screen",
tags=["Screen"]
)
2 changes: 2 additions & 0 deletions apps/dsa-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import LoginPage from './pages/LoginPage';
import NotFoundPage from './pages/NotFoundPage';
import ChatPage from './pages/ChatPage';
import PortfolioPage from './pages/PortfolioPage';
import ScreenPage from './pages/ScreenPage';
import { ApiErrorAlert, Shell } from './components/common';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { useAgentChatStore } from './stores/agentChatStore';
Expand Down Expand Up @@ -62,6 +63,7 @@ const AppContent: React.FC = () => {
<Routes>
<Route element={<Shell />}>
<Route path="/" element={<HomePage />} />
<Route path="/screen" element={<ScreenPage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/portfolio" element={<PortfolioPage />} />
<Route path="/backtest" element={<BacktestPage />} />
Expand Down
36 changes: 36 additions & 0 deletions apps/dsa-web/src/api/screen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import apiClient from './index';
import { toCamelCase } from './utils';

// ============ Types ============

/** A single stock row returned by the AI screen endpoint. */
export type ScreenResultRow = Record<string, string>;

export interface ScreenResponse {
success: boolean;
query: string;
resultsCount: number;
returnedCount: number;
dataSource?: string;
results: ScreenResultRow[];
message?: string;
error?: string;
}

// ============ API ============

export const screenApi = {
/**
* Trigger an AI-powered stock screen using natural language conditions.
* @param query Natural language screening query (e.g. "今天涨幅超过5%的A股")
*/
query: async (query: string): Promise<ScreenResponse> => {
const response = await apiClient.post<Record<string, unknown>>(
'/api/v1/screen/query',
{ query },
);
return toCamelCase<ScreenResponse>(response.data);
},
};

export default screenApi;
1 change: 1 addition & 0 deletions apps/dsa-web/src/components/layout/ShellHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type ShellHeaderProps = {

const TITLES: Record<string, { title: string; description: string }> = {
'/': { title: '首页', description: '股票分析与历史报告工作台' },
'/screen': { title: 'AI选股', description: '自然语言智能选股' },
'/chat': { title: '问股', description: '多轮策略问答与历史会话管理' },
'/backtest': { title: '回测', description: '回测任务与结果浏览' },
'/settings': { title: '设置', description: '系统配置、模型与认证管理' },
Expand Down
3 changes: 2 additions & 1 deletion apps/dsa-web/src/components/layout/SidebarNav.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { motion } from 'motion/react';
import { BarChart3, BriefcaseBusiness, Home, LogOut, MessageSquareQuote, Settings2 } from 'lucide-react';
import { BarChart3, BriefcaseBusiness, Home, LogOut, MessageSquareQuote, ScanSearch, Settings2 } from 'lucide-react';
import { NavLink } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useAgentChatStore } from '../../stores/agentChatStore';
Expand All @@ -25,6 +25,7 @@ type NavItem = {

const NAV_ITEMS: NavItem[] = [
{ key: 'home', label: '首页', to: '/', icon: Home, exact: true },
{ key: 'screen', label: 'AI选股', to: '/screen', icon: ScanSearch },
{ key: 'chat', label: '问股', to: '/chat', icon: MessageSquareQuote, badge: 'completion' },
{ key: 'portfolio', label: '持仓', to: '/portfolio', icon: BriefcaseBusiness },
{ key: 'backtest', label: '回测', to: '/backtest', icon: BarChart3 },
Expand Down
42 changes: 40 additions & 2 deletions apps/dsa-web/src/hooks/useDashboardLifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useCallback } from 'react';
import { analysisApi } from '../api/analysis';
import type { TaskInfo } from '../types/analysis';
import { useTaskStream } from './useTaskStream';

Expand All @@ -23,13 +24,50 @@ export function useDashboardLifecycle({
}: UseDashboardLifecycleOptions): void {
const removalTimeoutsRef = useRef<number[]>([]);

// Sync active tasks from the API to reconcile stale store state.
// This handles the case where tasks completed while the component was unmounted.
const syncActiveTasksFromApi = useCallback(async () => {
try {
const response = await analysisApi.getTasks({ limit: 50 });
const serverTasks = response.tasks ?? [];
const serverActiveIds = new Set<string>();

for (const task of serverTasks) {
if (task.status === 'pending' || task.status === 'processing') {
serverActiveIds.add(task.taskId);
// Ensure task exists in store with latest state
syncTaskCreated(task);
syncTaskUpdated(task);
} else if (task.status === 'completed') {
// Task completed while we were away - remove it from store
removeTask(task.taskId);
} else if (task.status === 'failed') {
removeTask(task.taskId);
}
}

// Remove tasks from store that are no longer in the server response
// (they completed/failed while the component was unmounted)
const { useStockPoolStore } = await import('../stores/stockPoolStore');
const { activeTasks } = useStockPoolStore.getState();
for (const storeTask of activeTasks) {
if (!serverActiveIds.has(storeTask.taskId)) {
removeTask(storeTask.taskId);
Comment on lines +53 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reconcile store only against a complete active-task snapshot

analysisApi.getTasks({ limit: 50 }) returns only the newest tasks regardless of status, so serverActiveIds can be incomplete when many recent completed/failed tasks exist. In that case this loop removes still-running tasks from activeTasks, and removeTask marks them dismissed in stockPoolStore, so later SSE task_progress/task_started updates are ignored and the UI can permanently lose in-flight task progress after channel switches. Restrict this reconciliation to a server response that is guaranteed to include all active tasks (e.g. status filter for pending/processing without truncating them).

Useful? React with 👍 / 👎.

}
}
} catch {
// Silently ignore - SSE will eventually sync state
}
}, [syncTaskCreated, syncTaskUpdated, removeTask]);

useEffect(() => {
if (!enabled) {
return;
}

void loadInitialHistory();
}, [enabled, loadInitialHistory]);
void syncActiveTasksFromApi();
}, [enabled, loadInitialHistory, syncActiveTasksFromApi]);

useEffect(() => {
if (!enabled) {
Expand Down
Loading