Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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