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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
# 深市:000xxx, 002xxx, 300xxx
STOCK_LIST=600519,300750,002594

# 国内商品期货分析(可选)
# FUTURES_ENABLED=true 开启期货配置;FUTURES_LIST 使用品种代码、具体合约代码或已支持中文别名,逗号分隔
# 示例:RB=螺纹钢主力连续, 焦煤2609=JM2609, I=铁矿石, AU=沪金, AG=沪银, CU=沪铜
FUTURES_ENABLED=false
FUTURES_LIST=RB,I,AU,JM2609

# Anspire Open API Keys(支持多个,逗号分隔)
# 获取: https://open.anspire.cn/?share_code=QFBC0FYC
# 在未配置更高优先级 OpenAI-compatible 来源时,满足条件可复用该 key 给 Anspire 大模型网关与新闻搜索。
Expand Down
19 changes: 16 additions & 3 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,20 @@ def _resolve_asset_path(assets_dir: Path, asset_path: str) -> Optional[Path]:
def _missing_asset_media_type(asset_path: str) -> str:
"""Return a safe media type for a missing asset response."""
content_type, _ = mimetypes.guess_type(asset_path)
if content_type == "application/javascript":
return "text/javascript"
if content_type in _SAFE_MISSING_ASSET_MEDIA_TYPES:
return content_type
return "text/plain"


def _asset_media_type(asset_path: str) -> Optional[str]:
"""Return browser-compatible media types for built frontend assets."""
content_type, _ = mimetypes.guess_type(asset_path)
if content_type == "application/javascript":
return "text/javascript"
return content_type

from api.v1 import api_v1_router
from api.middlewares.auth import add_auth_middleware
from api.middlewares.error_handler import add_error_handlers
Expand Down Expand Up @@ -288,7 +298,11 @@ async def serve_asset(request: Request, asset_path: str):
)
if file_path.is_file():
relative_path = file_path.relative_to(assets_root).as_posix()
return await assets_static_files.get_response(relative_path, request.scope)
response = await assets_static_files.get_response(relative_path, request.scope)
media_type = _asset_media_type(relative_path)
if media_type:
response.headers["content-type"] = media_type
return response
return Response(
content="asset not found",
status_code=404,
Expand All @@ -314,8 +328,7 @@ async def serve_spa(request: Request, full_path: str):
if file_path is not None and file_path.is_file():
# Issue #520: Explicitly resolve MIME type to avoid
# browsers rejecting JS modules served as text/plain.
content_type, _ = mimetypes.guess_type(str(file_path))
return FileResponse(file_path, media_type=content_type)
return FileResponse(file_path, media_type=_asset_media_type(str(file_path)))

return FileResponse(static_dir / "index.html")

Expand Down
25 changes: 21 additions & 4 deletions api/v1/endpoints/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
ReportDetails,
)
from data_provider.base import canonical_stock_code, normalize_stock_code
from src.data.futures_mapping import normalize_futures_symbol
from src.config import Config
from src.report_language import get_localized_stock_name, normalize_report_language
from src.services.name_to_code_resolver import resolve_name_to_code
Expand Down Expand Up @@ -94,7 +95,7 @@ def _is_obviously_invalid_analysis_input(text: str) -> bool:
return has_letters and has_digits


def _resolve_and_normalize_input(raw_value: str) -> str:
def _resolve_and_normalize_input(raw_value: str, asset_type: str = "stock") -> str:
"""
Resolve and normalize a stock input for analysis requests.

Expand All @@ -106,6 +107,12 @@ def _resolve_and_normalize_input(raw_value: str) -> str:
if not text:
return ""

if (asset_type or "stock").lower() == "futures":
normalized = normalize_futures_symbol(text)
if normalized:
return normalized
raise _invalid_analysis_input_error()

if is_code_like(text):
return canonical_stock_code(text)

Expand Down Expand Up @@ -183,15 +190,16 @@ def trigger_analysis(
)

# Normalize and de-duplicate inputs while preserving compatibility.
resolved = [_resolve_and_normalize_input(c) for c in stock_codes]
asset_type = getattr(request, "asset_type", "stock") or "stock"
resolved = [_resolve_and_normalize_input(c, asset_type=asset_type) for c in stock_codes]

seen = set()
unique_codes = []
for code in resolved:
if not code:
continue
# Use normalize_stock_code to ensure '600519' and '600519.SH' are merged
norm = normalize_stock_code(code)
norm = normalize_futures_symbol(code) if asset_type == "futures" else normalize_stock_code(code)
if norm not in seen:
seen.add(norm)
unique_codes.append(code)
Expand Down Expand Up @@ -263,6 +271,9 @@ def _handle_async_analysis_batch(
force_refresh=request.force_refresh,
notify=notify,
)
asset_type = getattr(request, "asset_type", "stock") or "stock"
if asset_type != "stock":
submit_kwargs["asset_type"] = asset_type

accepted_tasks, duplicate_errors = task_queue.submit_tasks_batch(**submit_kwargs)

Expand Down Expand Up @@ -338,13 +349,17 @@ def _handle_sync_analysis(

try:
service = AnalysisService()
result = service.analyze_stock(
analyze_kwargs = dict(
stock_code=stock_code,
report_type=request.report_type,
force_refresh=request.force_refresh,
query_id=query_id,
send_notification=getattr(request, "notify", True),
)
asset_type = getattr(request, "asset_type", "stock") or "stock"
if asset_type != "stock":
analyze_kwargs["asset_type"] = asset_type
result = service.analyze_stock(**analyze_kwargs)

if result is None:
error_message = service.last_error or f"分析股票 {stock_code} 失败"
Expand Down Expand Up @@ -441,6 +456,7 @@ def get_task_list(
task_id=t.task_id,
stock_code=t.stock_code,
stock_name=t.stock_name,
asset_type=t.asset_type,
status=t.status.value,
progress=t.progress,
message=t.message,
Expand Down Expand Up @@ -588,6 +604,7 @@ def get_analysis_status(task_id: str) -> TaskStatus:
progress=task.progress,
result=None, # In-progress tasks do not carry a result payload.
error=task.error,
asset_type=task.asset_type,
stock_name=task.stock_name,
original_query=task.original_query,
selection_source=task.selection_source,
Expand Down
20 changes: 20 additions & 0 deletions api/v1/endpoints/stocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from api.v1.schemas.stocks import (
ExtractFromImageResponse,
ExtractItem,
FuturesIndexResponse,
KLineData,
StockHistoryResponse,
StockQuote,
Expand All @@ -35,6 +36,7 @@
parse_import_from_text,
)
from src.services.stock_service import StockService
from src.services.futures_index_service import get_futures_index_items

logger = logging.getLogger(__name__)

Expand All @@ -44,6 +46,24 @@
ALLOWED_MIME_STR = ", ".join(ALLOWED_MIME)


@router.get(
"/futures-index",
response_model=FuturesIndexResponse,
summary="获取国内期货搜索候选",
description="返回当前可交易的国内期货品种、主力连续和具体合约候选,用于 Web 期货搜索框。",
)
def get_futures_index() -> FuturesIndexResponse:
"""Return domestic futures autocomplete candidates."""
try:
return FuturesIndexResponse(items=get_futures_index_items())
except Exception as exc:
logger.error("[futures-index] 获取期货候选失败: %s", exc, exc_info=True)
raise HTTPException(
status_code=503,
detail={"error": "futures_index_unavailable", "message": "获取期货候选失败"},
)


@router.post(
"/extract-from-image",
response_model=ExtractFromImageResponse,
Expand Down
11 changes: 11 additions & 0 deletions api/v1/schemas/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ class TaskStatusEnum(str, Enum):

class AnalyzeRequest(BaseModel):
"""Analysis request parameters"""

asset_type: str = Field(
"stock",
description="资产类型:stock(股票) / futures(国内期货主力合约)",
pattern="^(stock|futures)$",
example="stock",
)

stock_code: Optional[str] = Field(
None,
Expand Down Expand Up @@ -227,6 +234,7 @@ class TaskStatus(BaseModel):
None,
description="错误信息(仅在 failed 时存在)"
)
asset_type: str = Field("stock", description="资产类型:stock/futures")
stock_name: Optional[str] = Field(None, description="股票名称")
original_query: Optional[str] = Field(None, description="用户原始输入")
selection_source: Optional[str] = Field(
Expand All @@ -243,6 +251,7 @@ class Config:
"progress": 100,
"result": None,
"error": None,
"asset_type": "stock",
"stock_name": "贵州茅台",
"original_query": "茅台",
"selection_source": "autocomplete"
Expand All @@ -260,6 +269,7 @@ class TaskInfo(BaseModel):
task_id: str = Field(..., description="任务 ID")
stock_code: str = Field(..., description="股票代码")
stock_name: Optional[str] = Field(None, description="股票名称")
asset_type: str = Field("stock", description="资产类型:stock/futures")
status: TaskStatusEnum = Field(..., description="任务状态")
progress: int = Field(0, description="进度百分比 (0-100)", ge=0, le=100)
message: Optional[str] = Field(None, description="状态消息")
Expand All @@ -281,6 +291,7 @@ class Config:
"task_id": "abc123def456",
"stock_code": "600519",
"stock_name": "贵州茅台",
"asset_type": "stock",
"status": "processing",
"progress": 50,
"message": "正在分析中...",
Expand Down
20 changes: 20 additions & 0 deletions api/v1/schemas/stocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,26 @@ class ExtractFromImageResponse(BaseModel):
raw_text: Optional[str] = Field(None, description="原始 LLM 响应(调试用)")


class FuturesIndexItem(BaseModel):
"""期货搜索候选项"""

canonical_code: str = Field(..., description="标准合约代码")
display_code: str = Field(..., description="展示代码")
name_zh: str = Field(..., description="中文名称")
aliases: List[str] = Field(default_factory=list, description="搜索别名")
market: str = Field("FUTURES", description="市场类型")
asset_type: str = Field("futures", description="资产类型")
exchange: Optional[str] = Field(None, description="交易所")
active: bool = Field(True, description="是否可用")
popularity: Optional[int] = Field(None, description="排序权重")


class FuturesIndexResponse(BaseModel):
"""期货搜索候选响应"""

items: List[FuturesIndexItem] = Field(default_factory=list, description="期货候选项")


class StockHistoryResponse(BaseModel):
"""股票历史行情响应"""

Expand Down
2 changes: 2 additions & 0 deletions apps/dsa-web/src/api/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const analysisApi = {
const requestData = {
stock_code: data.stockCode,
stock_codes: data.stockCodes,
asset_type: data.assetType || 'stock',
report_type: data.reportType || 'detailed',
force_refresh: data.forceRefresh || false,
async_mode: data.asyncMode || false,
Expand Down Expand Up @@ -55,6 +56,7 @@ export const analysisApi = {
const requestData = {
stock_code: data.stockCode,
stock_codes: data.stockCodes,
asset_type: data.assetType || 'stock',
report_type: data.reportType || 'detailed',
force_refresh: data.forceRefresh || false,
async_mode: true,
Expand Down
10 changes: 10 additions & 0 deletions apps/dsa-web/src/api/stocks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import apiClient from './index';
import { toCamelCase } from './utils';
import type { StockIndexItem } from '../types/stockIndex';

export type ExtractItem = {
code?: string | null;
Expand All @@ -13,6 +15,14 @@ export type ExtractFromImageResponse = {
};

export const stocksApi = {
async getFuturesIndex(): Promise<StockIndexItem[]> {
const response = await apiClient.get<Record<string, unknown>>('/api/v1/stocks/futures-index', {
timeout: 60000,
});
const data = toCamelCase<{ items?: StockIndexItem[] }>(response.data);
return data.items ?? [];
},

async extractFromImage(file: File): Promise<ExtractFromImageResponse> {
const formData = new FormData();
formData.append('file', file);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
* Supports keyboard navigation, IME input method, graceful degradation
*/

import { Component, useRef, useEffect, useState } from 'react';
import { Component, useRef, useEffect, useMemo, useState } from 'react';
import type { KeyboardEvent } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useStockIndex } from '../../hooks/useStockIndex';
import { useFuturesIndex } from '../../hooks/useFuturesIndex';
import { useAutocomplete } from '../../hooks/useAutocomplete';
import { SuggestionsList } from './SuggestionsList';
import { cn } from '../../utils/cn';
import type { AssetType } from '../../types/analysis';

const AUTOCOMPLETE_INPUT_CLASS =
'input-surface input-focus-glow h-11 w-full rounded-xl border bg-transparent px-4 text-sm transition-all focus:outline-none disabled:cursor-not-allowed disabled:opacity-60';
Expand All @@ -30,6 +32,8 @@ export interface StockAutocompleteProps {
placeholder?: string;
/** Additional CSS class name */
className?: string;
/** Instrument universe to search */
assetType?: AssetType;
}

function FallbackInput({
Expand Down Expand Up @@ -98,8 +102,15 @@ function StockAutocompleteInner({
disabled = false,
placeholder = '输入股票代码或名称',
className,
assetType = 'stock',
}: StockAutocompleteProps) {
const { index, loading, fallback } = useStockIndex();
const stockIndexState = useStockIndex();
const futuresIndexState = useFuturesIndex(assetType === 'futures');
const activeIndexState = assetType === 'futures' ? futuresIndexState : stockIndexState;
const searchIndex = useMemo(
() => activeIndexState.index,
[activeIndexState.index],
);
const {
// query,
setQuery,
Expand All @@ -115,7 +126,7 @@ function StockAutocompleteInner({
setIsComposing,
runtimeFallback,
error: autocompleteError,
} = useAutocomplete(index);
} = useAutocomplete(searchIndex);

const inputRef = useRef<HTMLInputElement>(null);
const prevValueRef = useRef(value);
Expand Down Expand Up @@ -222,7 +233,7 @@ function StockAutocompleteInner({
};

// Fallback mode: use normal input
if (fallback || loading || runtimeFallback) {
if ((assetType === 'stock' && (activeIndexState.fallback || activeIndexState.loading)) || runtimeFallback) {
return (
<FallbackInput
value={value}
Expand Down Expand Up @@ -266,7 +277,7 @@ function StockAutocompleteInner({
/>

{/* Loading indicator */}
{loading && (
{activeIndexState.loading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="w-4 h-4 border-2 border-cyan/20 border-t-cyan rounded-full animate-spin" />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ const MARKET_BADGE_CONFIG = {
INDEX: { label: '指数', className: 'border-purple/25 bg-purple/10 text-purple' },
ETF: { label: 'ETF', className: 'border-warning/25 bg-warning/10 text-warning' },
BSE: { label: '北交所', className: 'border-orange-500/25 bg-orange-500/10 text-orange-500' },
FUTURES: { label: '期货', className: 'border-cyan/25 bg-cyan/10 text-cyan' },
} as const;

function MarketBadge({ market }: { market: string }) {
Expand Down
Loading