Compare commits

..

No commits in common. "e01d57c217203d857ae6733e38eefb401b4aee57" and "ce6cc1ddb8e8183472ae72b570e7ab784e7c5e27" have entirely different histories.

61 changed files with 1047 additions and 7652 deletions

View File

@ -1,147 +0,0 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = postgresql+asyncpg://value:Value609!@192.168.3.195:5432/fundamental
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -1 +0,0 @@
Generic single-database configuration.

View File

@ -1,83 +0,0 @@
from logging.config import fileConfig
import os
import sys
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Add app directory to sys.path
sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), '..')))
# Import Base from your models
from app.models import Base
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url").replace("+asyncpg", "")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
section = config.get_section(config.config_ini_section, {})
section['sqlalchemy.url'] = section['sqlalchemy.url'].replace("+asyncpg", "")
connectable = engine_from_config(
section,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -1,28 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@ -1,79 +0,0 @@
"""Initial migration
Revision ID: 65b0d87d025a
Revises:
Create Date: 2025-10-22 09:03:08.353806
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '65b0d87d025a'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('reports',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('symbol', sa.String(), nullable=False),
sa.Column('market', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_reports_market'), 'reports', ['market'], unique=False)
op.create_index(op.f('ix_reports_status'), 'reports', ['status'], unique=False)
op.create_index(op.f('ix_reports_symbol'), 'reports', ['symbol'], unique=False)
op.create_table('analysis_modules',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('report_id', sa.UUID(), nullable=False),
sa.Column('module_type', sa.String(), nullable=False),
sa.Column('content', sa.JSON(), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('error_message', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['report_id'], ['reports.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_analysis_modules_report_id'), 'analysis_modules', ['report_id'], unique=False)
op.create_index(op.f('ix_analysis_modules_status'), 'analysis_modules', ['status'], unique=False)
op.create_table('progress_tracking',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('report_id', sa.UUID(), nullable=False),
sa.Column('step_name', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('started_at', sa.DateTime(), nullable=True),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('duration_ms', sa.Integer(), nullable=True),
sa.Column('token_usage', sa.Integer(), nullable=True),
sa.Column('error_message', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['report_id'], ['reports.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_progress_tracking_report_id'), 'progress_tracking', ['report_id'], unique=False)
op.create_index(op.f('ix_progress_tracking_status'), 'progress_tracking', ['status'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_progress_tracking_status'), table_name='progress_tracking')
op.drop_index(op.f('ix_progress_tracking_report_id'), table_name='progress_tracking')
op.drop_table('progress_tracking')
op.drop_index(op.f('ix_analysis_modules_status'), table_name='analysis_modules')
op.drop_index(op.f('ix_analysis_modules_report_id'), table_name='analysis_modules')
op.drop_table('analysis_modules')
op.drop_index(op.f('ix_reports_symbol'), table_name='reports')
op.drop_index(op.f('ix_reports_status'), table_name='reports')
op.drop_index(op.f('ix_reports_market'), table_name='reports')
op.drop_table('reports')
# ### end Alembic commands ###

View File

@ -1,31 +0,0 @@
"""
Application settings management using Pydantic
"""
from typing import List, Optional
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
APP_NAME: str = "Fundamental Analysis System"
APP_VERSION: str = "1.0.0"
DEBUG: bool = False
# Default database URL switched to SQLite (async) to avoid optional driver issues.
# You can override via config or env to PostgreSQL later.
DATABASE_URL: str = "sqlite+aiosqlite:///./app.db"
DATABASE_ECHO: bool = False
# API settings
API_V1_STR: str = "/api"
ALLOWED_ORIGINS: List[str] = ["http://localhost:3000", "http://127.0.0.1:3000"]
# External service credentials, can be overridden
GEMINI_API_KEY: Optional[str] = None
TUSHARE_TOKEN: Optional[str] = None
class Config:
env_file = ".env"
case_sensitive = True
# Global settings instance
settings = Settings()

View File

@ -1,9 +0,0 @@
"""
SQLAlchemy async database session factory
"""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DATABASE_ECHO, future=True)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)

View File

@ -1,18 +0,0 @@
"""
Application dependencies and providers
"""
from typing import AsyncGenerator
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import AsyncSessionLocal
from app.services.config_manager import ConfigManager
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
"""Provides a database session to the application."""
async with AsyncSessionLocal() as session:
yield session
def get_config_manager(db_session: AsyncSession = Depends(get_db_session)) -> ConfigManager:
"""Dependency to get the configuration manager."""
return ConfigManager(db_session=db_session)

View File

@ -1,36 +0,0 @@
"""
FastAPI application entrypoint
"""
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.routers.config import router as config_router
from app.routers.financial import router as financial_router
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s: %(message)s',
datefmt='%H:%M:%S'
)
app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routers
app.include_router(config_router, prefix=f"{settings.API_V1_STR}/config", tags=["config"])
app.include_router(financial_router, prefix=f"{settings.API_V1_STR}/financials", tags=["financials"])
@app.get("/")
async def root():
return {"status": "ok", "name": settings.APP_NAME, "version": settings.APP_VERSION}

View File

@ -1,7 +0,0 @@
from .base import Base
from .system_config import SystemConfig
from .report import Report
from .analysis_module import AnalysisModule
from .progress_tracking import ProgressTracking
__all__ = ["Base", "SystemConfig", "Report", "AnalysisModule", "ProgressTracking"]

View File

@ -1,20 +0,0 @@
"""
Analysis Module Model
"""
import uuid
from sqlalchemy import Column, String, JSON, ForeignKey, DateTime, func
from sqlalchemy.dialects.postgresql import UUID as pgUUID
from sqlalchemy.orm import relationship
from .base import Base
class AnalysisModule(Base):
__tablename__ = 'analysis_modules'
id = Column(pgUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
report_id = Column(pgUUID(as_uuid=True), ForeignKey('reports.id'), nullable=False, index=True)
module_type = Column(String, nullable=False)
content = Column(JSON)
status = Column(String, nullable=False, default='pending', index=True)
error_message = Column(String)
report = relationship("Report", back_populates="analysis_modules")

View File

@ -1,3 +0,0 @@
from sqlalchemy.orm import declarative_base
Base = declarative_base()

View File

@ -1,23 +0,0 @@
"""
Progress Tracking Model
"""
import uuid
from sqlalchemy import Column, String, Integer, DateTime, func, ForeignKey
from sqlalchemy.dialects.postgresql import UUID as pgUUID
from sqlalchemy.orm import relationship
from .base import Base
class ProgressTracking(Base):
__tablename__ = 'progress_tracking'
id = Column(pgUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
report_id = Column(pgUUID(as_uuid=True), ForeignKey('reports.id'), nullable=False, index=True)
step_name = Column(String, nullable=False)
status = Column(String, nullable=False, default='pending', index=True)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
duration_ms = Column(Integer)
token_usage = Column(Integer)
error_message = Column(String)
report = relationship("Report", back_populates="progress_tracking")

View File

@ -1,21 +0,0 @@
"""
Report Model
"""
import uuid
from sqlalchemy import Column, String, DateTime, func
from sqlalchemy.dialects.postgresql import UUID as pgUUID
from sqlalchemy.orm import relationship
from .base import Base
class Report(Base):
__tablename__ = 'reports'
id = Column(pgUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
symbol = Column(String, nullable=False, index=True)
market = Column(String, nullable=False, index=True)
status = Column(String, nullable=False, default='generating', index=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
analysis_modules = relationship("AnalysisModule", back_populates="report", cascade="all, delete-orphan")
progress_tracking = relationship("ProgressTracking", back_populates="report", cascade="all, delete-orphan")

View File

@ -1,13 +0,0 @@
"""
System Configuration Model
"""
from sqlalchemy import Column, String, JSON
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class SystemConfig(Base):
__tablename__ = 'system_config'
config_key = Column(String, primary_key=True, index=True)
config_value = Column(JSON, nullable=False)

View File

@ -1,41 +0,0 @@
"""
API router for configuration management
"""
from fastapi import APIRouter, Depends, HTTPException
from app.core.dependencies import get_config_manager
from app.schemas.config import ConfigResponse, ConfigUpdateRequest, ConfigTestRequest, ConfigTestResponse
from app.services.config_manager import ConfigManager
router = APIRouter()
@router.get("/", response_model=ConfigResponse)
async def get_config(config_manager: ConfigManager = Depends(get_config_manager)):
"""Retrieve the current system configuration."""
return await config_manager.get_config()
@router.put("/", response_model=ConfigResponse)
async def update_config(
config_update: ConfigUpdateRequest,
config_manager: ConfigManager = Depends(get_config_manager)
):
"""Update system configuration."""
return await config_manager.update_config(config_update)
@router.post("/test", response_model=ConfigTestResponse)
async def test_config(
test_request: ConfigTestRequest,
config_manager: ConfigManager = Depends(get_config_manager)
):
"""Test a specific configuration (e.g., database connection)."""
try:
test_result = await config_manager.test_config(
test_request.config_type,
test_request.config_data
)
return test_result
except Exception as e:
return ConfigTestResponse(
success=False,
message=f"测试失败: {str(e)}"
)

View File

@ -1,693 +0,0 @@
"""
API router for financial data (Tushare for China market)
"""
import json
import os
import time
from datetime import datetime, timezone, timedelta
from typing import Dict, List
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
import os
from app.core.config import settings
from app.schemas.financial import (
BatchFinancialDataResponse,
FinancialConfigResponse,
FinancialMeta,
StepRecord,
CompanyProfileResponse,
AnalysisResponse,
AnalysisConfigResponse
)
from app.services.tushare_client import TushareClient
from app.services.company_profile_client import CompanyProfileClient
from app.services.analysis_client import AnalysisClient, load_analysis_config, get_analysis_config
router = APIRouter()
# Load metric config from file (project root is repo root, not backend/)
# routers/ -> app/ -> backend/ -> repo root
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
FINANCIAL_CONFIG_PATH = os.path.join(REPO_ROOT, "config", "financial-tushare.json")
BASE_CONFIG_PATH = os.path.join(REPO_ROOT, "config", "config.json")
ANALYSIS_CONFIG_PATH = os.path.join(REPO_ROOT, "config", "analysis-config.json")
def _load_json(path: str) -> Dict:
if not os.path.exists(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
@router.post("/china/{ts_code}/analysis", response_model=List[AnalysisResponse])
async def generate_full_analysis(
ts_code: str,
company_name: str = Query(None, description="Company name for better context"),
):
"""
Generate a full analysis report by orchestrating multiple analysis modules
based on dependencies defined in the configuration.
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[API] Full analysis requested for {ts_code}")
# Load base and analysis configurations
base_cfg = _load_json(BASE_CONFIG_PATH)
llm_provider = base_cfg.get("llm", {}).get("provider", "gemini")
llm_config = base_cfg.get("llm", {}).get(llm_provider, {})
api_key = llm_config.get("api_key")
base_url = llm_config.get("base_url")
if not api_key:
logger.error(f"[API] API key for {llm_provider} not configured")
raise HTTPException(
status_code=500,
detail=f"API key for {llm_provider} not configured."
)
analysis_config_full = load_analysis_config()
modules_config = analysis_config_full.get("analysis_modules", {})
if not modules_config:
raise HTTPException(status_code=404, detail="Analysis modules configuration not found.")
# --- Dependency Resolution (Topological Sort) ---
def topological_sort(graph):
in_degree = {u: 0 for u in graph}
for u in graph:
for v in graph[u]:
in_degree[v] += 1
queue = [u for u in graph if in_degree[u] == 0]
sorted_order = []
while queue:
u = queue.pop(0)
sorted_order.append(u)
for v in graph.get(u, []):
in_degree[v] -= 1
if in_degree[v] == 0:
queue.append(v)
if len(sorted_order) == len(graph):
return sorted_order
else:
# Detect cycles and provide a meaningful error
cycles = []
visited = set()
path = []
def find_cycle_util(node):
visited.add(node)
path.append(node)
for neighbor in graph.get(node, []):
if neighbor in path:
cycle_start_index = path.index(neighbor)
cycles.append(path[cycle_start_index:] + [neighbor])
return
if neighbor not in visited:
find_cycle_util(neighbor)
path.pop()
for node in graph:
if node not in visited:
find_cycle_util(node)
return None, cycles
# Build dependency graph
dependency_graph = {
name: config.get("dependencies", [])
for name, config in modules_config.items()
}
# Invert graph for topological sort (from dependency to dependent)
adj_list = {u: [] for u in dependency_graph}
for u, dependencies in dependency_graph.items():
for dep in dependencies:
if dep in adj_list:
adj_list[dep].append(u)
sorted_modules, cycle = topological_sort(adj_list)
if not sorted_modules:
raise HTTPException(
status_code=400,
detail=f"Circular dependency detected in analysis modules configuration. Cycle: {cycle}"
)
# --- Fetch common data (company name, financial data) ---
# This logic is duplicated, could be refactored into a helper
financial_data = None
if not company_name:
logger.info(f"[API] Fetching company name for {ts_code}")
try:
token = base_cfg.get("data_sources", {}).get("tushare", {}).get("api_key")
if token:
tushare_client = TushareClient(token=token)
basic_data = await tushare_client.query(api_name="stock_basic", params={"ts_code": ts_code}, fields="ts_code,name")
if basic_data:
company_name = basic_data[0].get("name", ts_code)
logger.info(f"[API] Got company name: {company_name}")
except Exception as e:
logger.warning(f"Failed to get company name, proceeding with ts_code. Error: {e}")
company_name = ts_code
# --- Execute modules in order ---
analysis_results = []
completed_modules_content = {}
for module_type in sorted_modules:
module_config = modules_config[module_type]
logger.info(f"[Orchestrator] Starting analysis for module: {module_type}")
client = AnalysisClient(
api_key=api_key,
base_url=base_url,
model=module_config.get("model", "gemini-1.5-flash")
)
# Gather context from completed dependencies
context = {
dep: completed_modules_content.get(dep, "")
for dep in module_config.get("dependencies", [])
}
result = await client.generate_analysis(
analysis_type=module_type,
company_name=company_name,
ts_code=ts_code,
prompt_template=module_config.get("prompt_template", ""),
financial_data=financial_data,
context=context,
)
response = AnalysisResponse(
ts_code=ts_code,
company_name=company_name,
analysis_type=module_type,
content=result.get("content", ""),
model=result.get("model", module_config.get("model")),
tokens=result.get("tokens", {}),
elapsed_ms=result.get("elapsed_ms", 0),
success=result.get("success", False),
error=result.get("error")
)
analysis_results.append(response)
if response.success:
completed_modules_content[module_type] = response.content
else:
# If a module fails, subsequent dependent modules will get an empty string for its context.
# This prevents total failure but may affect quality.
completed_modules_content[module_type] = f"Error: Analysis for {module_type} failed."
logger.error(f"[Orchestrator] Module {module_type} failed: {response.error}")
logger.info(f"[API] Full analysis for {ts_code} completed.")
return analysis_results
@router.get("/config", response_model=FinancialConfigResponse)
async def get_financial_config():
data = _load_json(FINANCIAL_CONFIG_PATH)
api_groups = data.get("api_groups", {})
return FinancialConfigResponse(api_groups=api_groups)
@router.get("/china/{ts_code}", response_model=BatchFinancialDataResponse)
async def get_china_financials(
ts_code: str,
years: int = Query(5, ge=1, le=15),
):
# Load Tushare token
base_cfg = _load_json(BASE_CONFIG_PATH)
token = (
os.environ.get("TUSHARE_TOKEN")
or settings.TUSHARE_TOKEN
or base_cfg.get("data_sources", {}).get("tushare", {}).get("api_key")
)
if not token:
raise HTTPException(status_code=500, detail="Tushare API token not configured. Set TUSHARE_TOKEN env or config/config.json data_sources.tushare.api_key")
# Load metric config
fin_cfg = _load_json(FINANCIAL_CONFIG_PATH)
api_groups: Dict[str, List[Dict]] = fin_cfg.get("api_groups", {})
client = TushareClient(token=token)
# Meta tracking
started_real = datetime.now(timezone.utc)
started = time.perf_counter_ns()
api_calls_total = 0
api_calls_by_group: Dict[str, int] = {}
steps: List[StepRecord] = []
current_action = "初始化"
# Get company name from stock_basic API
company_name = None
try:
basic_data = await client.query(api_name="stock_basic", params={"ts_code": ts_code}, fields="ts_code,name")
api_calls_total += 1
if basic_data and len(basic_data) > 0:
company_name = basic_data[0].get("name")
except Exception:
# If getting company name fails, continue without it
pass
# Collect series per metric key
series: Dict[str, List[Dict]] = {}
# Helper to store year-value pairs while keeping most recent per year
def _merge_year_value(key: str, year: str, value, month: int = None):
arr = series.setdefault(key, [])
# upsert by year
for item in arr:
if item["year"] == year:
item["value"] = value
if month is not None:
item["month"] = month
return
arr.append({"year": year, "value": value, "month": month})
# Query each API group we care
errors: Dict[str, str] = {}
for group_name, metrics in api_groups.items():
step = StepRecord(
name=f"拉取 {group_name}",
start_ts=started_real.isoformat(),
status="running",
)
steps.append(step)
current_action = step.name
if not metrics:
continue
# 按 API 分组 metrics处理 unknown 组中有多个不同 API 的情况)
api_groups_dict: Dict[str, List[Dict]] = {}
for metric in metrics:
api = metric.get("api") or group_name
if api: # 跳过空 API
if api not in api_groups_dict:
api_groups_dict[api] = []
api_groups_dict[api].append(metric)
# 对每个 API 分别处理
for api_name, api_metrics in api_groups_dict.items():
fields = [m.get("tushareParam") for m in api_metrics if m.get("tushareParam")]
if not fields:
continue
date_field = "end_date" if group_name in ("fina_indicator", "income", "balancesheet", "cashflow") else "trade_date"
# 构建 API 参数
params = {"ts_code": ts_code, "limit": 5000}
# 对于需要日期范围的 API如 stk_holdernumber添加日期参数
if api_name == "stk_holdernumber":
# 计算日期范围:从 years 年前到现在
end_date = datetime.now().strftime("%Y%m%d")
start_date = (datetime.now() - timedelta(days=years * 365)).strftime("%Y%m%d")
params["start_date"] = start_date
params["end_date"] = end_date
# stk_holdernumber 返回的日期字段通常是 end_date
date_field = "end_date"
# 对于非时间序列 API如 stock_company标记为静态数据
is_static_data = api_name == "stock_company"
# 构建 fields 字符串:包含日期字段和所有需要的指标字段
# 确保日期字段存在,因为我们需要用它来确定年份
fields_list = list(fields)
if date_field not in fields_list:
fields_list.insert(0, date_field)
# 对于 fina_indicator 等 API通常还需要 ts_code 和 ann_date
if api_name in ("fina_indicator", "income", "balancesheet", "cashflow"):
for req_field in ["ts_code", "ann_date"]:
if req_field not in fields_list:
fields_list.insert(0, req_field)
fields_str = ",".join(fields_list)
try:
data_rows = await client.query(api_name=api_name, params=params, fields=fields_str)
api_calls_total += 1
api_calls_by_group[group_name] = api_calls_by_group.get(group_name, 0) + 1
except Exception as e:
# 记录错误但继续处理其他 API
error_key = f"{group_name}_{api_name}"
errors[error_key] = str(e)
continue
tmp: Dict[str, Dict] = {}
current_year = datetime.now().strftime("%Y")
for row in data_rows:
if is_static_data:
# 对于静态数据(如 stock_company使用当前年份
# 只处理第一行数据,因为静态数据通常只有一行
if current_year not in tmp:
year = current_year
month = None
tmp[year] = row
tmp[year]['_month'] = month
# 跳过后续行
continue
else:
# 对于时间序列数据,按日期字段处理
date_val = row.get(date_field)
if not date_val:
continue
year = str(date_val)[:4]
month = int(str(date_val)[4:6]) if len(str(date_val)) >= 6 else None
existing = tmp.get(year)
if existing is None or str(row.get(date_field)) > str(existing.get(date_field)):
tmp[year] = row
tmp[year]['_month'] = month
for metric in api_metrics:
key = metric.get("tushareParam")
if not key:
continue
for year, row in tmp.items():
month = row.get('_month')
_merge_year_value(key, year, row.get(key), month)
step.status = "done"
step.end_ts = datetime.now(timezone.utc).isoformat()
step.duration_ms = int((time.perf_counter_ns() - started) / 1_000_000)
finished_real = datetime.now(timezone.utc)
elapsed_ms = int((time.perf_counter_ns() - started) / 1_000_000)
if not series:
# If nothing succeeded, expose partial error info
raise HTTPException(status_code=502, detail={"message": "No data returned from Tushare", "errors": errors})
# Truncate years and sort
for key, arr in series.items():
# Deduplicate and sort desc by year, then cut to requested years, and return asc
uniq = {item["year"]: item for item in arr}
arr_sorted_desc = sorted(uniq.values(), key=lambda x: x["year"], reverse=True)
arr_limited = arr_sorted_desc[:years]
arr_sorted = sorted(arr_limited, key=lambda x: x["year"]) # ascending by year
series[key] = arr_sorted
meta = FinancialMeta(
started_at=started_real.isoformat(),
finished_at=finished_real.isoformat(),
elapsed_ms=elapsed_ms,
api_calls_total=api_calls_total,
api_calls_by_group=api_calls_by_group,
current_action=None,
steps=steps,
)
return BatchFinancialDataResponse(ts_code=ts_code, name=company_name, series=series, meta=meta)
@router.get("/china/{ts_code}/company-profile", response_model=CompanyProfileResponse)
async def get_company_profile(
ts_code: str,
company_name: str = Query(None, description="Company name for better context"),
):
"""
Get company profile for a company using Gemini AI (non-streaming, single response)
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[API] Company profile requested for {ts_code}")
# Load config
base_cfg = _load_json(BASE_CONFIG_PATH)
llm_provider = base_cfg.get("llm", {}).get("provider", "gemini")
llm_config = base_cfg.get("llm", {}).get(llm_provider, {})
api_key = llm_config.get("api_key")
base_url = llm_config.get("base_url") # Will be None if not set, handled by client
if not api_key:
logger.error(f"[API] API key for {llm_provider} not configured")
raise HTTPException(
status_code=500,
detail=f"API key for {llm_provider} not configured."
)
client = CompanyProfileClient(
api_key=api_key,
base_url=base_url,
model="gemini-1.5-flash"
)
# Get company name from ts_code if not provided
if not company_name:
logger.info(f"[API] Fetching company name for {ts_code}")
# Try to get from stock_basic API
try:
base_cfg = _load_json(BASE_CONFIG_PATH)
token = (
os.environ.get("TUSHARE_TOKEN")
or settings.TUSHARE_TOKEN
or base_cfg.get("data_sources", {}).get("tushare", {}).get("api_key")
)
if token:
from app.services.tushare_client import TushareClient
tushare_client = TushareClient(token=token)
basic_data = await tushare_client.query(api_name="stock_basic", params={"ts_code": ts_code}, fields="ts_code,name")
if basic_data and len(basic_data) > 0:
company_name = basic_data[0].get("name", ts_code)
logger.info(f"[API] Got company name: {company_name}")
else:
company_name = ts_code
else:
company_name = ts_code
except Exception as e:
logger.warning(f"[API] Failed to get company name: {e}")
company_name = ts_code
logger.info(f"[API] Generating profile for {company_name}")
# Generate profile using non-streaming API
result = await client.generate_profile(
company_name=company_name,
ts_code=ts_code,
financial_data=None
)
logger.info(f"[API] Profile generation completed, success={result.get('success')}")
return CompanyProfileResponse(
ts_code=ts_code,
company_name=company_name,
content=result.get("content", ""),
model=result.get("model", "gemini-2.5-flash"),
tokens=result.get("tokens", {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}),
elapsed_ms=result.get("elapsed_ms", 0),
success=result.get("success", False),
error=result.get("error")
)
@router.get("/analysis-config", response_model=AnalysisConfigResponse)
async def get_analysis_config_endpoint():
"""Get analysis configuration"""
config = load_analysis_config()
return AnalysisConfigResponse(analysis_modules=config.get("analysis_modules", {}))
@router.put("/analysis-config", response_model=AnalysisConfigResponse)
async def update_analysis_config_endpoint(analysis_config: AnalysisConfigResponse):
"""Update analysis configuration"""
import logging
logger = logging.getLogger(__name__)
try:
# 保存到文件
config_data = {
"analysis_modules": analysis_config.analysis_modules
}
with open(ANALYSIS_CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config_data, f, ensure_ascii=False, indent=2)
logger.info(f"[API] Analysis config updated successfully")
return AnalysisConfigResponse(analysis_modules=analysis_config.analysis_modules)
except Exception as e:
logger.error(f"[API] Failed to update analysis config: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to update analysis config: {str(e)}"
)
@router.get("/china/{ts_code}/analysis/{analysis_type}", response_model=AnalysisResponse)
async def generate_analysis(
ts_code: str,
analysis_type: str,
company_name: str = Query(None, description="Company name for better context"),
):
"""
Generate analysis for a company using Gemini AI
Supported analysis types:
- fundamental_analysis (基本面分析)
- bull_case (看涨分析)
- bear_case (看跌分析)
- market_analysis (市场分析)
- news_analysis (新闻分析)
- trading_analysis (交易分析)
- insider_institutional (内部人与机构动向分析)
- final_conclusion (最终结论)
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[API] Analysis requested for {ts_code}, type: {analysis_type}")
# Load config
base_cfg = _load_json(BASE_CONFIG_PATH)
llm_provider = base_cfg.get("llm", {}).get("provider", "gemini")
llm_config = base_cfg.get("llm", {}).get(llm_provider, {})
api_key = llm_config.get("api_key")
base_url = llm_config.get("base_url")
if not api_key:
logger.error(f"[API] API key for {llm_provider} not configured")
raise HTTPException(
status_code=500,
detail=f"API key for {llm_provider} not configured."
)
# Get analysis configuration
analysis_cfg = get_analysis_config(analysis_type)
if not analysis_cfg:
raise HTTPException(
status_code=404,
detail=f"Analysis type '{analysis_type}' not found in configuration"
)
model = analysis_cfg.get("model", "gemini-2.5-flash")
prompt_template = analysis_cfg.get("prompt_template", "")
if not prompt_template:
raise HTTPException(
status_code=500,
detail=f"Prompt template not found for analysis type '{analysis_type}'"
)
# Get company name from ts_code if not provided
financial_data = None
if not company_name:
logger.info(f"[API] Fetching company name and financial data for {ts_code}")
try:
token = (
os.environ.get("TUSHARE_TOKEN")
or settings.TUSHARE_TOKEN
or base_cfg.get("data_sources", {}).get("tushare", {}).get("api_key")
)
if token:
tushare_client = TushareClient(token=token)
basic_data = await tushare_client.query(api_name="stock_basic", params={"ts_code": ts_code}, fields="ts_code,name")
if basic_data and len(basic_data) > 0:
company_name = basic_data[0].get("name", ts_code)
logger.info(f"[API] Got company name: {company_name}")
# Try to get financial data for context
try:
fin_cfg = _load_json(FINANCIAL_CONFIG_PATH)
api_groups = fin_cfg.get("api_groups", {})
# Get financial data summary for context
series: Dict[str, List[Dict]] = {}
for group_name, metrics in api_groups.items():
if not metrics:
continue
api_groups_dict: Dict[str, List[Dict]] = {}
for metric in metrics:
api = metric.get("api") or group_name
if api:
if api not in api_groups_dict:
api_groups_dict[api] = []
api_groups_dict[api].append(metric)
for api_name, api_metrics in api_groups_dict.items():
fields = [m.get("tushareParam") for m in api_metrics if m.get("tushareParam")]
if not fields:
continue
date_field = "end_date" if group_name in ("fina_indicator", "income", "balancesheet", "cashflow") else "trade_date"
params = {"ts_code": ts_code, "limit": 500}
fields_list = list(fields)
if date_field not in fields_list:
fields_list.insert(0, date_field)
if api_name in ("fina_indicator", "income", "balancesheet", "cashflow"):
for req_field in ["ts_code", "ann_date"]:
if req_field not in fields_list:
fields_list.insert(0, req_field)
fields_str = ",".join(fields_list)
try:
data_rows = await tushare_client.query(api_name=api_name, params=params, fields=fields_str)
if data_rows:
# Get latest year's data
latest_row = data_rows[0] if data_rows else {}
for metric in api_metrics:
key = metric.get("tushareParam")
if key and key in latest_row:
if key not in series:
series[key] = []
series[key].append({
"year": latest_row.get(date_field, "")[:4] if latest_row.get(date_field) else str(datetime.now().year),
"value": latest_row.get(key)
})
except Exception:
pass
financial_data = {"series": series}
except Exception as e:
logger.warning(f"[API] Failed to get financial data: {e}")
financial_data = None
else:
company_name = ts_code
else:
company_name = ts_code
except Exception as e:
logger.warning(f"[API] Failed to get company name: {e}")
company_name = ts_code
logger.info(f"[API] Generating {analysis_type} for {company_name}")
# Initialize analysis client with configured model
client = AnalysisClient(api_key=api_key, base_url=base_url, model=model)
# Generate analysis
result = await client.generate_analysis(
analysis_type=analysis_type,
company_name=company_name,
ts_code=ts_code,
prompt_template=prompt_template,
financial_data=financial_data
)
logger.info(f"[API] Analysis generation completed, success={result.get('success')}")
return AnalysisResponse(
ts_code=ts_code,
company_name=company_name,
analysis_type=analysis_type,
content=result.get("content", ""),
model=result.get("model", model),
tokens=result.get("tokens", {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}),
elapsed_ms=result.get("elapsed_ms", 0),
success=result.get("success", False),
error=result.get("error")
)

View File

@ -1,33 +0,0 @@
"""
Configuration-related Pydantic schemas
"""
from typing import Dict, Optional, Any
from pydantic import BaseModel, Field
class DatabaseConfig(BaseModel):
url: str = Field(..., description="数据库连接URL")
class NewApiConfig(BaseModel):
api_key: str = Field(..., description="New API Key")
base_url: Optional[str] = None
class DataSourceConfig(BaseModel):
api_key: str = Field(..., description="数据源API Key")
class ConfigResponse(BaseModel):
database: DatabaseConfig
new_api: NewApiConfig
data_sources: Dict[str, DataSourceConfig]
class ConfigUpdateRequest(BaseModel):
database: Optional[DatabaseConfig] = None
new_api: Optional[NewApiConfig] = None
data_sources: Optional[Dict[str, DataSourceConfig]] = None
class ConfigTestRequest(BaseModel):
config_type: str
config_data: Dict[str, Any]
class ConfigTestResponse(BaseModel):
success: bool
message: str

View File

@ -1,74 +0,0 @@
"""
Pydantic schemas for financial APIs
"""
from typing import Dict, List, Optional
from pydantic import BaseModel
class YearDataPoint(BaseModel):
year: str
value: Optional[float]
month: Optional[int] = None # 月份信息,用于确定季度
class StepRecord(BaseModel):
name: str
start_ts: str # ISO8601
end_ts: Optional[str] = None
duration_ms: Optional[int] = None
status: str # running|done|error
error: Optional[str] = None
class FinancialMeta(BaseModel):
started_at: str # ISO8601
finished_at: Optional[str] = None
elapsed_ms: Optional[int] = None
api_calls_total: int = 0
api_calls_by_group: Dict[str, int] = {}
current_action: Optional[str] = None
steps: List[StepRecord] = []
class BatchFinancialDataResponse(BaseModel):
ts_code: str
name: Optional[str] = None
series: Dict[str, List[YearDataPoint]]
meta: Optional[FinancialMeta] = None
class FinancialConfigResponse(BaseModel):
api_groups: Dict[str, List[dict]]
class TokenUsage(BaseModel):
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
class CompanyProfileResponse(BaseModel):
ts_code: str
company_name: Optional[str] = None
content: str
model: str
tokens: TokenUsage
elapsed_ms: int
success: bool = True
error: Optional[str] = None
class AnalysisResponse(BaseModel):
ts_code: str
company_name: Optional[str] = None
analysis_type: str
content: str
model: str
tokens: TokenUsage
elapsed_ms: int
success: bool = True
error: Optional[str] = None
class AnalysisConfigResponse(BaseModel):
analysis_modules: Dict[str, Dict]

View File

@ -1,155 +0,0 @@
"""
Generic Analysis Client for various analysis types using an OpenAI-compatible API
"""
import time
import json
import os
from typing import Dict, Optional
import openai
import string
class AnalysisClient:
"""Generic client for generating various types of analysis using an OpenAI-compatible API"""
def __init__(self, api_key: str, base_url: str, model: str):
"""Initialize OpenAI client with API key, base URL, and model"""
self.client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url)
self.model_name = model
async def generate_analysis(
self,
analysis_type: str,
company_name: str,
ts_code: str,
prompt_template: str,
financial_data: Optional[Dict] = None,
context: Optional[Dict] = None
) -> Dict:
"""
Generate analysis using OpenAI-compatible API (non-streaming)
Args:
analysis_type: Type of analysis (e.g., "fundamental_analysis")
company_name: Company name
ts_code: Stock code
prompt_template: Prompt template with placeholders
financial_data: Optional financial data for context
context: Optional dictionary with results from previous analyses
Returns:
Dict with analysis content and metadata
"""
start_time = time.perf_counter_ns()
# Build prompt from template
prompt = self._build_prompt(
prompt_template,
company_name,
ts_code,
financial_data,
context
)
# Call OpenAI-compatible API
try:
response = await self.client.chat.completions.create(
model=self.model_name,
messages=[{"role": "user", "content": prompt}],
)
content = response.choices[0].message.content if response.choices else ""
usage = response.usage
elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000)
return {
"content": content,
"model": self.model_name,
"tokens": {
"prompt_tokens": usage.prompt_tokens if usage else 0,
"completion_tokens": usage.completion_tokens if usage else 0,
"total_tokens": usage.total_tokens if usage else 0,
} if usage else {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
"elapsed_ms": elapsed_ms,
"success": True,
"analysis_type": analysis_type,
}
except Exception as e:
elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000)
return {
"content": "",
"model": self.model_name,
"tokens": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
"elapsed_ms": elapsed_ms,
"success": False,
"error": str(e),
"analysis_type": analysis_type,
}
def _build_prompt(
self,
prompt_template: str,
company_name: str,
ts_code: str,
financial_data: Optional[Dict] = None,
context: Optional[Dict] = None
) -> str:
"""Build prompt from template by replacing placeholders"""
# Start with base placeholders
placeholders = {
"company_name": company_name,
"ts_code": ts_code,
}
# Add financial data if provided
financial_data_str = ""
if financial_data:
try:
financial_data_str = json.dumps(financial_data, ensure_ascii=False, indent=2)
except Exception:
financial_data_str = str(financial_data)
placeholders["financial_data"] = financial_data_str
# Add context from previous analysis steps
if context:
placeholders.update(context)
# Replace placeholders in template
# Use a custom formatter to handle missing keys gracefully
class SafeFormatter(string.Formatter):
def get_value(self, key, args, kwargs):
if isinstance(key, str):
return kwargs.get(key, f"{{{key}}}")
else:
return super().get_value(key, args, kwargs)
formatter = SafeFormatter()
prompt = formatter.format(prompt_template, **placeholders)
return prompt
def load_analysis_config() -> Dict:
"""Load analysis configuration from JSON file"""
# Get project root: backend/app/services -> project_root/config/analysis-config.json
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
config_path = os.path.join(project_root, "config", "analysis-config.json")
if not os.path.exists(config_path):
return {}
try:
with open(config_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def get_analysis_config(analysis_type: str) -> Optional[Dict]:
"""Get configuration for a specific analysis type"""
config = load_analysis_config()
modules = config.get("analysis_modules", {})
return modules.get(analysis_type)

View File

@ -1,159 +0,0 @@
"""
OpenAI-compatible API Client for company profile generation
"""
import time
from typing import Dict, List, Optional
import openai
class CompanyProfileClient:
def __init__(self, api_key: str, base_url: str, model: str = "gemini-1.5-flash"):
"""Initialize OpenAI client with API key, base_url and model"""
self.client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url)
self.model_name = model
async def generate_profile(
self,
company_name: str,
ts_code: str,
financial_data: Optional[Dict] = None
) -> Dict:
"""
Generate company profile using OpenAI-compatible API (non-streaming)
Args:
company_name: Company name
ts_code: Stock code
financial_data: Optional financial data for context
Returns:
Dict with profile content and metadata
"""
start_time = time.perf_counter_ns()
# Build prompt
prompt = self._build_prompt(company_name, ts_code, financial_data)
# Call OpenAI-compatible API
try:
response = await self.client.chat.completions.create(
model=self.model_name,
messages=[{"role": "user", "content": prompt}],
)
content = response.choices[0].message.content if response.choices else ""
usage = response.usage
elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000)
return {
"content": content,
"model": self.model_name,
"tokens": {
"prompt_tokens": usage.prompt_tokens if usage else 0,
"completion_tokens": usage.completion_tokens if usage else 0,
"total_tokens": usage.total_tokens if usage else 0,
} if usage else {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
"elapsed_ms": elapsed_ms,
"success": True,
}
except Exception as e:
elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000)
return {
"content": "",
"model": self.model_name,
"tokens": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
"elapsed_ms": elapsed_ms,
"success": False,
"error": str(e),
}
def generate_profile_stream(
self,
company_name: str,
ts_code: str,
financial_data: Optional[Dict] = None
):
"""
Generate company profile using Gemini API with streaming
Args:
company_name: Company name
ts_code: Stock code
financial_data: Optional financial data for context
Yields:
Chunks of generated content
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[CompanyProfile] Starting stream generation for {company_name} ({ts_code})")
# Build prompt
prompt = self._build_prompt(company_name, ts_code, financial_data)
logger.info(f"[CompanyProfile] Prompt built, length: {len(prompt)} chars")
# Call Gemini API with streaming
try:
logger.info("[CompanyProfile] Calling Gemini API with stream=True")
# Generate streaming response (sync call, but yields chunks)
response_stream = self.model.generate_content(prompt, stream=True)
logger.info("[CompanyProfile] Gemini API stream object created")
chunk_count = 0
# Stream chunks
logger.info("[CompanyProfile] Starting to iterate response stream")
for chunk in response_stream:
logger.info(f"[CompanyProfile] Received chunk from Gemini, has text: {hasattr(chunk, 'text')}")
if hasattr(chunk, 'text') and chunk.text:
chunk_count += 1
text_len = len(chunk.text)
logger.info(f"[CompanyProfile] Chunk {chunk_count}: {text_len} chars")
yield chunk.text
else:
logger.warning(f"[CompanyProfile] Chunk has no text attribute or empty, chunk: {chunk}")
logger.info(f"[CompanyProfile] Stream iteration completed. Total chunks: {chunk_count}")
except Exception as e:
logger.error(f"[CompanyProfile] Error during streaming: {type(e).__name__}: {str(e)}", exc_info=True)
yield f"\n\n---\n\n**错误**: {type(e).__name__}: {str(e)}"
def _build_prompt(self, company_name: str, ts_code: str, financial_data: Optional[Dict] = None) -> str:
"""Build prompt for company profile generation"""
prompt = f"""您是一位专业的证券市场分析师。请为公司 {company_name} (股票代码: {ts_code}) 生成一份详细且专业的公司介绍。开头不要自我介绍直接开始正文。正文用MarkDown输出尽量说明信息来源用斜体显示信息来源。在生成内容时请严格遵循以下要求并采用清晰、结构化的格式
1. **公司概览**:
* 简要介绍公司的性质核心业务领域及其在行业中的定位
* 提炼并阐述公司的核心价值理念
2. **主营业务**:
* 详细描述公司主要的**产品或服务**
* **重要提示**如果能获取到公司最新的官方**年报****财务报告**请从中提取各主要产品/服务线的**收入金额**和其占公司总收入的**百分比****明确标注数据来源**例如"数据来源于XX年年度报告"
* **严格禁止**编造或估算任何财务数据若无法找到公开准确的财务数据**不要**在这一点中提及具体金额或比例仅描述业务内容
3. **发展历程**:
* 以时间线或关键事件的形式概述公司自成立以来的主要**里程碑事件**重大发展阶段战略转型或重要成就
4. **核心团队**:
* 介绍公司**主要管理层和核心技术团队成员**
* 对于每位核心成员提供其**职务主要工作履历教育背景**
* 如果公开可查可补充其**出生年份**
5. **供应链**:
* 描述公司的**主要原材料部件或服务来源**
* 如果公开信息中包含请列出**主要供应商名称****明确其在总采购金额中的大致占比**若无此数据则仅描述采购模式
6. **主要客户及销售模式**:
* 阐明公司的**销售模式**例如直销经销线上销售代理等
* 列出公司的**主要客户群体****代表性大客户**
* 如果公开信息中包含请标明**主要客户或前五大客户的销售额占公司总销售额的比例**若无此数据则仅描述客户类型
7. **未来展望**:
* 基于公司**公开的官方声明管理层访谈或战略规划**总结公司未来的发展方向战略目标重点项目或市场预期请确保此部分内容有可靠的信息来源支持"""
if financial_data:
prompt += f"\n\n参考财务数据:\n{financial_data}"
return prompt

View File

@ -1,304 +0,0 @@
"""
Configuration Management Service
"""
import json
import os
import asyncio
from typing import Any, Dict
import asyncpg
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.models.system_config import SystemConfig
from app.schemas.config import ConfigResponse, ConfigUpdateRequest, DatabaseConfig, NewApiConfig, DataSourceConfig, ConfigTestResponse
class ConfigManager:
"""Manages system configuration by merging a static JSON file with dynamic settings from the database."""
def __init__(self, db_session: AsyncSession, config_path: str = None):
self.db = db_session
if config_path is None:
# Default path: backend/app/services -> project_root/config/config.json
# __file__ = backend/app/services/config_manager.py
# go up three levels to project root
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
self.config_path = os.path.join(project_root, "config", "config.json")
else:
self.config_path = config_path
def _load_base_config_from_file(self) -> Dict[str, Any]:
"""Loads the base configuration from the JSON file."""
if not os.path.exists(self.config_path):
return {}
try:
with open(self.config_path, "r", encoding="utf-8") as f:
return json.load(f)
except (IOError, json.JSONDecodeError):
return {}
async def _load_dynamic_config_from_db(self) -> Dict[str, Any]:
"""Loads dynamic configuration overrides from the database.
当数据库表尚未创建如开发环境未运行迁移优雅降级为返回空覆盖配置避免接口 500
"""
try:
db_configs: Dict[str, Any] = {}
result = await self.db.execute(select(SystemConfig))
for record in result.scalars().all():
db_configs[record.config_key] = record.config_value
return db_configs
except Exception:
# 表不存在或其他数据库错误时,忽略动态配置覆盖
return {}
def _merge_configs(self, base: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]:
"""Deeply merges the override config into the base config."""
for key, value in overrides.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
base[key] = self._merge_configs(base[key], value)
else:
base[key] = value
return base
async def get_config(self) -> ConfigResponse:
"""Gets the final, merged configuration."""
base_config = self._load_base_config_from_file()
db_config = await self._load_dynamic_config_from_db()
merged_config = self._merge_configs(base_config, db_config)
# 兼容两种位置:优先使用 new_api其次回退到 llm.new_api
new_api_src = merged_config.get("new_api") or merged_config.get("llm", {}).get("new_api", {})
return ConfigResponse(
database=DatabaseConfig(**merged_config.get("database", {})),
new_api=NewApiConfig(**(new_api_src or {})),
data_sources={
k: DataSourceConfig(**v)
for k, v in merged_config.get("data_sources", {}).items()
}
)
async def update_config(self, config_update: ConfigUpdateRequest) -> ConfigResponse:
"""Updates configuration in the database and returns the new merged config."""
try:
update_dict = config_update.dict(exclude_unset=True)
# 验证配置数据
self._validate_config_data(update_dict)
for key, value in update_dict.items():
existing_config = await self.db.get(SystemConfig, key)
if existing_config:
# Merge with existing DB value before updating
if isinstance(existing_config.config_value, dict) and isinstance(value, dict):
merged_value = self._merge_configs(existing_config.config_value, value)
existing_config.config_value = merged_value
else:
existing_config.config_value = value
else:
new_config = SystemConfig(config_key=key, config_value=value)
self.db.add(new_config)
await self.db.commit()
return await self.get_config()
except Exception as e:
await self.db.rollback()
raise e
def _validate_config_data(self, config_data: Dict[str, Any]) -> None:
"""Validate configuration data before saving."""
if "database" in config_data:
db_config = config_data["database"]
if "url" in db_config:
url = db_config["url"]
if not url.startswith(("postgresql://", "postgresql+asyncpg://")):
raise ValueError("数据库URL必须以 postgresql:// 或 postgresql+asyncpg:// 开头")
if "new_api" in config_data:
new_api_config = config_data["new_api"]
if "api_key" in new_api_config and len(new_api_config["api_key"]) < 10:
raise ValueError("New API Key长度不能少于10个字符")
if "base_url" in new_api_config and new_api_config["base_url"]:
base_url = new_api_config["base_url"]
if not base_url.startswith(("http://", "https://")):
raise ValueError("New API Base URL必须以 http:// 或 https:// 开头")
if "data_sources" in config_data:
for source_name, source_config in config_data["data_sources"].items():
if "api_key" in source_config and len(source_config["api_key"]) < 10:
raise ValueError(f"{source_name} API Key长度不能少于10个字符")
async def test_config(self, config_type: str, config_data: Dict[str, Any]) -> ConfigTestResponse:
"""Test a specific configuration."""
try:
if config_type == "database":
return await self._test_database(config_data)
elif config_type == "new_api":
return await self._test_new_api(config_data)
elif config_type == "tushare":
return await self._test_tushare(config_data)
elif config_type == "finnhub":
return await self._test_finnhub(config_data)
else:
return ConfigTestResponse(
success=False,
message=f"不支持的配置类型: {config_type}"
)
except Exception as e:
return ConfigTestResponse(
success=False,
message=f"测试失败: {str(e)}"
)
async def _test_database(self, config_data: Dict[str, Any]) -> ConfigTestResponse:
"""Test database connection."""
db_url = config_data.get("url")
if not db_url:
return ConfigTestResponse(
success=False,
message="数据库URL不能为空"
)
try:
# 解析数据库URL
if db_url.startswith("postgresql+asyncpg://"):
db_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
# 测试连接
conn = await asyncpg.connect(db_url)
await conn.close()
return ConfigTestResponse(
success=True,
message="数据库连接成功"
)
except Exception as e:
return ConfigTestResponse(
success=False,
message=f"数据库连接失败: {str(e)}"
)
async def _test_new_api(self, config_data: Dict[str, Any]) -> ConfigTestResponse:
"""Test New API (OpenAI-compatible) connection."""
api_key = config_data.get("api_key")
base_url = config_data.get("base_url")
if not api_key or not base_url:
return ConfigTestResponse(
success=False,
message="New API Key和Base URL均不能为空"
)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# Test API availability by listing models
response = await client.get(
f"{base_url.rstrip('/')}/models",
headers={"Authorization": f"Bearer {api_key}"}
)
if response.status_code == 200:
return ConfigTestResponse(
success=True,
message="New API连接成功"
)
else:
return ConfigTestResponse(
success=False,
message=f"New API测试失败: HTTP {response.status_code} - {response.text}"
)
except Exception as e:
return ConfigTestResponse(
success=False,
message=f"New API连接失败: {str(e)}"
)
async def _test_tushare(self, config_data: Dict[str, Any]) -> ConfigTestResponse:
"""Test Tushare API connection."""
api_key = config_data.get("api_key")
if not api_key:
return ConfigTestResponse(
success=False,
message="Tushare API Key不能为空"
)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# 测试API可用性
response = await client.post(
"http://api.tushare.pro",
json={
"api_name": "stock_basic",
"token": api_key,
"params": {"list_status": "L"},
"fields": "ts_code"
}
)
if response.status_code == 200:
data = response.json()
if data.get("code") == 0:
return ConfigTestResponse(
success=True,
message="Tushare API连接成功"
)
else:
return ConfigTestResponse(
success=False,
message=f"Tushare API错误: {data.get('msg', '未知错误')}"
)
else:
return ConfigTestResponse(
success=False,
message=f"Tushare API测试失败: HTTP {response.status_code}"
)
except Exception as e:
return ConfigTestResponse(
success=False,
message=f"Tushare API连接失败: {str(e)}"
)
async def _test_finnhub(self, config_data: Dict[str, Any]) -> ConfigTestResponse:
"""Test Finnhub API connection."""
api_key = config_data.get("api_key")
if not api_key:
return ConfigTestResponse(
success=False,
message="Finnhub API Key不能为空"
)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# 测试API可用性
response = await client.get(
f"https://finnhub.io/api/v1/quote",
params={"symbol": "AAPL", "token": api_key}
)
if response.status_code == 200:
data = response.json()
if "c" in data: # 检查是否有价格数据
return ConfigTestResponse(
success=True,
message="Finnhub API连接成功"
)
else:
return ConfigTestResponse(
success=False,
message="Finnhub API响应格式错误"
)
else:
return ConfigTestResponse(
success=False,
message=f"Finnhub API测试失败: HTTP {response.status_code}"
)
except Exception as e:
return ConfigTestResponse(
success=False,
message=f"Finnhub API连接失败: {str(e)}"
)

View File

@ -1,52 +0,0 @@
"""
Minimal async client for Tushare Pro API
"""
from typing import Any, Dict, List, Optional
import httpx
TUSHARE_PRO_URL = "https://api.tushare.pro"
class TushareClient:
def __init__(self, token: str):
self.token = token
self._client = httpx.AsyncClient(timeout=30)
async def query(
self,
api_name: str,
params: Optional[Dict[str, Any]] = None,
fields: Optional[str] = None,
) -> List[Dict[str, Any]]:
payload = {
"api_name": api_name,
"token": self.token,
"params": params or {},
}
# default larger page size if not provided
if "limit" not in payload["params"]:
payload["params"]["limit"] = 5000
if fields:
payload["fields"] = fields
resp = await self._client.post(TUSHARE_PRO_URL, json=payload)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
err = data.get("msg") or "Tushare error"
raise RuntimeError(f"{api_name}: {err}")
fields_def = data.get("data", {}).get("fields", [])
items = data.get("data", {}).get("items", [])
rows: List[Dict[str, Any]] = []
for it in items:
row = {fields_def[i]: it[i] for i in range(len(fields_def))}
rows.append(row)
return rows
async def aclose(self):
await self._client.aclose()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
await self.aclose()

View File

@ -1,9 +0,0 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
httpx==0.27.0
pydantic-settings==2.5.2
SQLAlchemy==2.0.36
aiosqlite==0.20.0
alembic==1.13.3
openai==1.37.0
asyncpg

File diff suppressed because one or more lines are too long

View File

@ -1,13 +1,9 @@
{
"llm": {
"provider": "new_api",
"provider": "gemini",
"gemini": {
"base_url": "",
"api_key": "YOUR_GEMINI_API_KEY"
},
"new_api": {
"base_url": "http://192.168.3.214:3000/v1",
"api_key": "sk-DdTTQ5fdU1aFW6gnYxSNYDgFsVQg938zUcmY4vaB7oPtcNs7"
"api_key": "AIzaSyCe4KpiRWFU3hnP-iwWvDR28ZCEzFnN0x0"
}
},
"data_sources": {

View File

@ -41,8 +41,7 @@
"cashflow": [
{ "displayText": "经营净现金流", "tushareParam": "n_cashflow_act", "api": "cashflow" },
{ "displayText": "资本开支", "tushareParam": "c_pay_acq_const_fiolta", "api": "cashflow" },
{ "displayText": "折旧费用", "tushareParam": "depr_fa_coga_dpba", "api": "cashflow" },
{ "displayText": "支付给职工以及为职工支付的现金", "tushareParam": "c_paid_to_for_empl", "api": "cashflow" }
{ "displayText": "折旧费用", "tushareParam": "depr_fa_coga_dpba", "api": "cashflow" }
],
"daily_basic": [
{ "displayText": "PB", "tushareParam": "pb", "api": "daily_basic" },

View File

@ -22,13 +22,12 @@
### 2.1. 架构概述
系统采用前后端分离的现代化Web架构并通过前端API代理到后端
系统采用前后端分离的现代化Web架构
- **前端 (Frontend)**:基于 React (Next.js App Router) 的应用,负责用户界面与交互。前端通过 Next.js 内置的 API 路由作为代理,转发请求到后端(`NEXT_PUBLIC_BACKEND_URL` 可配置,默认 `http://127.0.0.1:8000/api`)。
- **前端 API 代理**`/frontend/src/app/api/**` 下的路由将前端请求转发至后端对应路径,统一处理`Content-Type`与状态码。
- **后端 (Backend)**:基于 Python FastAPI 的异步 API 服务负责财务数据聚合、AI生成、配置管理与分析配置管理。
- **数据库 (Database)**:使用 PostgreSQL已具备模型与迁移脚手架。当前 MVP 未落地“报告持久化”,主要以即时查询/生成返回为主;后续迭代将启用。
- **异步任务**:当前 MVP 版本以“同步请求-响应”为主不使用流式SSE或队列后续可评估以 SSE/队列增强体验。
- **前端 (Frontend)**基于React (Next.js) 的单页面应用 (SPA)负责用户界面和交互逻辑。它通过RESTful API与后端通信。
- **后端 (Backend)**基于Python FastAPI框架的异步API服务负责处理所有业务逻辑、数据操作和与外部服务的集成。
- **数据库 (Database)**采用PostgreSQL作为关系型数据库存储所有持久化数据。
- **异步任务队列**: 利用FastAPI的`BackgroundTasks`处理耗时的报告生成任务避免阻塞API请求并允许实时进度追踪。
![System Architecture Diagram](https://i.imgur.com/example.png) <!-- Placeholder for a real diagram -->
@ -36,14 +35,12 @@
| 层次 | 技术 | 理由 |
| :--- | :--- | :--- |
| **前端** | React (Next.js App Router), TypeScript, Shadcn/UI, SWR | App Router 简化路由与服务能力SWR 负责数据获取与缓存。 |
| **前端** | React (Next.js), TypeScript, Shadcn/UI | 提供优秀的开发体验、类型安全、高性能的服务端渲染(SSR)和丰富的UI组件库。 |
| **后端** | Python, FastAPI, SQLAlchemy (Async) | 异步框架带来高并发性能Python拥有强大的数据处理和AI生态SQLAlchemy提供强大的ORM能力。 |
| **数据库** | PostgreSQL | 功能强大、稳定可靠的开源关系型数据库支持JSONB等高级数据类型适合存储结构化报告数据。 |
| **数据源** | Tushare API, Yahoo Finance | Tushare提供全面的中国市场数据Yahoo Finance作为其他市场的补充易于集成。 |
| **AI模型** | Google Gemini | 强大的大语言模型,能够根据指令生成高质量的业务分析内容。 |
注:当前实现通过 `config/financial-tushare.json` 配置需要拉取的 Tushare 指标分组,通过 `config/analysis-config.json` 配置各分析模块名称、模型与 Prompt 模板。
## 3. 后端设计 (Backend Design)
### 3.1. 核心服务设计
@ -57,44 +54,32 @@
- **ConfigManager (配置管理器)**: 负责从`config.json`或数据库中加载、更新和验证系统配置如API密钥、数据库URL
- **ProgressTracker (进度追踪器)**: 负责在报告生成的每个阶段更新状态、耗时和Token使用情况并提供给前端查询。
### 3.2. 任务执行模型MVP
### 3.2. 异步任务处理
当前 MVP 以“按需生成/查询”的同步调用为主:
报告生成是一个耗时操作将通过FastAPI的`BackgroundTasks`在后台执行。当用户请求生成新报告时API会立即返回一个报告ID和“生成中”的状态并将生成任务添加到后台队列。前端可以通过该ID轮询或使用WebSocket/SSE来获取实时进度。
- 财务数据请求到达后端即刻聚合最新数据并返回包含元信息耗时、步骤、API 调用统计)。
- 公司简介与各分析模块:到达后端实时调用 Gemini 生成,返回内容与 Token/耗时指标。
- 前端在同一页面内顺序执行“公司简介 → 各分析模块”,并以本地状态记录执行轨迹,不依赖 SSE/队列。
### 3.3. API 端点设计
### 3.3. API 端点设计(当前实现)
后端 FastAPI 实现的主要端点(被前端 Next.js API 代理转发):
后端将提供以下RESTful API端点
| Method | Endpoint | 描述 | 请求体/参数 | 响应体 |
| :--- | :--- | :--- | :--- | :--- |
| `GET` | `/api/financials/config` | 获取财务指标分组配置 | - | `FinancialConfigResponse` |
| `GET` | `/api/financials/china/{ts_code}` | 聚合中国市场财务数据(按年汇总,含元信息与步骤) | `years` | `BatchFinancialDataResponse` |
| `GET` | `/api/financials/china/{ts_code}/company-profile` | 生成公司简介Gemini同步 | `company_name?` | `CompanyProfileResponse` |
| `GET` | `/api/financials/analysis-config` | 获取分析模块配置 | - | `AnalysisConfigResponse` |
| `PUT` | `/api/financials/analysis-config` | 更新分析模块配置 | `AnalysisConfigResponse` | `AnalysisConfigResponse` |
| `POST` | `/api/financials/china/{ts_code}/analysis` | 生成完整的分析报告(根据依赖关系编排) | `company_name?` | `List[AnalysisResponse]` |
| `GET` | `/api/config` | 获取系统配置 | - | `ConfigResponse` |
| `PUT` | `/api/config` | 更新系统配置 | `ConfigUpdateRequest` | `ConfigResponse` |
| `POST` | `/api/config/test` | 测试配置有效性(数据库/Gemini/Tushare 等) | `ConfigTestRequest` | `ConfigTestResponse` |
说明:前端对应的代理路径如下(示例):
- 前端请求 `/api/financials/china/...` → 代理到后端 `${BACKEND_BASE}/financials/china/...`
- 前端请求 `/api/config`、`/api/config/test` → 代理到后端 `${BACKEND_BASE}/config`、`${BACKEND_BASE}/config/test`
| `POST` | `/api/reports` | 创建或获取报告。如果报告已存在,返回现有报告;否则,启动后台任务生成新报告。 | `symbol`, `market` | `ReportResponse` |
| `POST` | `/api/reports/regenerate` | 强制重新生成报告。 | `symbol`, `market` | `ReportResponse` |
| `GET` | `/api/reports/{report_id}` | 获取特定报告的详细内容,包括所有分析模块。 | `report_id` (UUID) | `ReportResponse` |
| `GET` | `/api/reports` | 获取报告列表,支持分页和筛选。 | `skip`, `limit`, `status` | `List[ReportResponse]` |
| `GET` | `/api/progress/stream/{report_id}` | (SSE) 实时流式传输报告生成进度。 | `report_id` (UUID) | `ProgressResponse` (Stream) |
| `GET` | `/api/config` | 获取当前系统所有配置。 | - | `ConfigResponse` |
| `PUT` | `/api/config` | 更新系统配置。 | `ConfigUpdateRequest` | `ConfigResponse` |
| `POST`| `/api/config/test` | 测试特定配置的有效性(如数据库连接)。 | `ConfigTestRequest` | `ConfigTestResponse` |
## 4. 数据库设计
### 4.1. 数据模型 (Schema)
【规划中】以下表结构为后续“报告持久化与历史管理”功能的设计草案,当前 MVP 未启用:
**1. `reports` (报告表)**
用于存储报告的元数据。
存储报告的元数据。
| 字段名 | 类型 | 描述 | 示例 |
| :--- | :--- | :--- | :--- |
@ -150,76 +135,24 @@
## 5. 前端设计 (Frontend Design)
### 5.1. 组件设计(当前实现)
### 5.1. 组件设计
- **`ReportPage` (`frontend/src/app/report/[symbol]/page.tsx`)**:报告主页面,基于 Tabs 展示:股价图表、财务数据表格、公司简介、各分析模块、执行详情。
- 中国市场 `ts_code` 规范化支持纯6位数字自动推断交易所0/3→SZ6→SH
- “公司简介 → 各分析模块”按顺序串行执行,带状态、耗时与 Token 统计,可单项重试。
- 财务数据表按年列展示,包含主要指标、费用率、资产占比、周转能力、人均效率、市场表现等分组;含多项计算型指标与高亮规则(如 ROE/ROIC>12%)。
- **`TradingViewWidget`**:嵌入式股价图表,展示传入 `symbol` 的行情。
- **`ConfigPage` (`frontend/src/app/config/page.tsx`)**:配置中心,支持数据库/Gemini/Tushare/Finnhub 配置查看、保存与测试支持分析模块名称、模型、Prompt 模板的在线编辑与保存。
- **前端 API 代理**`/frontend/src/app/api/**` 路由用于转发到后端,解耦部署与本地开发。
- **`StockInputForm`**: 首页的核心组件,包含证券代码输入框和交易市场选择器。
- **`ReportPage`**: 报告的主页面,根据报告状态显示历史报告、进度追踪器或完整的分析模块。
- **`ProgressTracker`**: 实时进度组件通过订阅SSE或定时轮询来展示报告生成的步骤、状态和耗时。
- **`ModuleNavigator`**: 报告页面的侧边栏或顶部导航,允许用户在不同的分析模块间切换。
- **`ModuleViewer`**: 用于展示单个分析模块内容的组件,能渲染从`content` (JSONB)字段解析出的文本、图表和表格。
- **`ConfigPage`**: 系统配置页面提供表单来修改和测试数据库、API密钥等配置。
### 5.2. 页面与路由(当前实现)
### 5.2. 页面与路由
- `/`: 入口页
- `/report/[symbol]?market=china|cn|...`: 报告页面(当前主要支持中国市场)
- `/config`: 配置中心
- `/docs`, `/logs`, `/reports`, `/query`: 辅助页面(如存在)
- `/`: 首页,展示`StockInputForm`
- `/report/{symbol}`: 报告页面,动态路由,根据查询参数(如`market`)加载`ReportPage`
- `/report/{symbol}/{moduleId}`: 模块详情页,展示特定分析模块的内容
- `/config`: 系统配置页面,展示`ConfigPage`
### 5.3. 状态管理(当前实现)
### 5.3. 状态管理
- **数据获取**:使用 SWR`useSWR`)封装于 `frontend/src/hooks/useApi.ts` 中,提供 `useChinaFinancials`、`useFinancialConfig`、`useAnalysisConfig`、`useConfig` 等。
- **全局配置**:使用 Zustand `useConfigStore` 存放系统配置与加载状态。
- **页面局部状态**`ReportPage` 使用 `useState/useMemo/useRef` 管理任务队列、当前任务、计时器、执行记录与各模块的内容/错误/耗时/Token。
注:当前无 SSE进度条和“执行详情”来自本地状态与后端返回的元信息财务数据接口含步骤与耗时统计
### 5.4. 指标与显示规范(要点)
- 百分比字段支持 01 与百分数两种输入,自动规范化显示为 `%`
- 金额类(损益/资产负债/现金流)按“亿元/亿股”等进行缩放显示,市值按“亿元”整数化展示。
- 计算项示例:自由现金流 = 经营现金流 资本开支;费用率=费用/收入;其他费用率=毛利率−净利率−销售−管理−研发。
- 高亮规则:例如 ROE/ROIC>12% 标绿色背景,增长率为负标红等。
### 5.5. 分析模块编排
系统的核心能力之一是允许分析模块之间传递信息,即一个模块可以利用前序模块的分析结果作为上下文。这通过模块间的**依赖关系Dependencies**实现。
- **编排方式**
- 前端不再独立、依次请求每个分析模块,而是通过调用一个统一的编排端点 `POST /api/financials/china/{ts_code}/analysis` 来发起一次完整的报告生成任务。
- 后端编排器会读取 `config/analysis-config.json` 中定义的模块依赖关系,通过**拓扑排序**算法智能地计算出最优执行顺序,确保被依赖的模块总是先于依赖它的模块执行。
- **依赖配置与上下文注入**
- **如何配置**:在前端的“配置中心”页面,可以为每个分析模块通过复选框勾选其需要依赖的其他模块。保存后,这将在 `analysis-config.json` 对应模块下生成一个 `dependencies` 数组,例如:
```json
"bull_case": {
"name": "看涨分析",
"dependencies": ["fundamental_analysis"],
"prompt_template": "基于以下基本面分析:\n{fundamental_analysis}\n\n请生成看涨分析报告。"
}
```
- **如何工作**:当后端执行时,它会先运行 `fundamental_analysis` 模块,然后将其生成的**全部文本结果**,完整地替换掉 `bull_case` 模块提示词模板中的 `{fundamental_analysis}` 占位符。这样AI在执行看涨分析时就已经获得了充分的上下文。
- **重要:未配置依赖但使用占位符的后果**
- 如果您**没有**在配置中为一个模块(如 `bull_case`)勾选其对另一个模块(如 `fundamental_analysis`)的依赖,但依然在其提示词模板中使用了对应的占位符(`{fundamental_analysis}`),系统**不会报错**,但会发生以下情况:
1. **执行顺序不保证**:编排器认为这两个模块独立,`fundamental_analysis` 不一定会在 `bull_case` 之前运行。
2. **上下文不会被注入**:即使前序模块先运行完,由于 `dependencies` 列表为空,系统也不会进行占位符替换。
- **最终结果**:该占位符将作为**纯文本**(例如字符串 `"{fundamental_analysis}"`)被原封不动地包含在提示词中并发送给大语言模型。这通常会导致分析质量下降,因为模型会收到一个它无法理解的指令片段,但得益于后端的安全机制(`SafeFormatter`),整个流程不会因缺少数据而崩溃。
- **结论****必须通过配置页面的复选框明确声明依赖关系**,才能激活模块间的上下文传递。
- **失败处理**
- 当前的编排流程中,如果一个模块执行失败,依赖于它的后续模块在生成提示词时,会收到一段表示错误的文本(例如 `"Error: Analysis for a_module_failed."`)作为上下文,而不是空字符串。这可以防止后续模块因缺少信息而生成质量过低的内容,同时也为调试提供了线索。
- 原有的单项重试功能已由全局的“重新运行完整分析”按钮取代,以适应新的编排模式。
### 5.6. 股票代码规范化(中国市场)
- 输入为 6 位数字时自动添加交易所后缀:首位 `6`→`.SH``0/3`→`.SZ`;已有后缀将转为大写保留。
## 6. 与最初规划的差异与后续计划
- **报告持久化/历史列表/再生成功能**:目前未实现,仍按需生成并即时展示;后续将启用数据库表 `reports`、`analysis_modules` 与 `progress_tracking` 完成闭环。
- **SSE/队列化执行**:当前为前端串行请求 + 本地状态记录;后续可引入 SSE 或任务队列以提供更丝滑的进度与并发控制。
- **多市场支持**当前聚焦中国市场Tushare后续将补充美股等数据源与规则。
- **权限与用户体系**MVP 暂无;后续纳入登录、权限与审计。
本设计文档已对“当前实现”与“后续规划”进行了清晰标注,便于开发与验收。
- 使用Zustand或React Context进行全局状态管理主要管理用户信息、系统配置和当前的报告状态。
- 组件内部状态将使用React的`useState`和`useReducer`。
- 使用React Query或SWR来管理API数据获取、缓存和同步简化数据获取逻辑并提升用户体验。

View File

@ -16,9 +16,9 @@
此阶段专注于实现数据库模型和系统配置API为上层业务逻辑提供基础。
- [x] **T2.1 [Backend/DB]**: 根据设计文档使用SQLAlchemy ORM定义`Report`, `AnalysisModule`, `ProgressTracking`, `SystemConfig`四个核心数据模型。 **[完成 - 2025-10-21]**
- [x] **T2.2 [Backend/DB]**: 创建第一个Alembic迁移脚本在数据库中生成上述四张表。 **[完成 - 2025-10-21]**
- [x] **T2.3 [Backend]**: 实现`ConfigManager`服务,完成从`config.json`加载配置并与数据库配置合并的逻辑。 **[完成 - 2025-10-21]**
- **T2.1 [Backend/DB]**: 根据设计文档使用SQLAlchemy ORM定义`Report`, `AnalysisModule`, `ProgressTracking`, `SystemConfig`四个核心数据模型。
- **T2.2 [Backend/DB]**: 创建第一个Alembic迁移脚本在数据库中生成上述四张表。
- **T2.3 [Backend]**: 实现`ConfigManager`服务,完成从`config.json`加载配置并与数据库配置合并的逻辑。
- **T2.4 [Backend/API]**: 创建Pydantic Schema用于配置接口的请求和响应 (`ConfigResponse`, `ConfigUpdateRequest`, `ConfigTestRequest`, `ConfigTestResponse`)。
- **T2.5 [Backend/API]**: 实现`/api/config`的`GET`和`PUT`端点,用于读取和更新系统配置。
- **T2.6 [Backend/API]**: 实现`/api/config/test`的`POST`端点,用于验证数据库连接等配置的有效性。
@ -26,14 +26,13 @@
## Phase 3: 前端基础与配置页面 (P1)
此阶段完成前端项目的基本设置,并开发出第一个功能页面——系统配置管理。
**[完成 - 2025-10-21]**
- [x] **T3.1 [Frontend]**: 集成并配置UI组件库 (Shadcn/UI)。
- [x] **T3.2 [Frontend]**: 设置前端路由,创建`/`, `/report/[symbol]`, 和 `/config`等页面骨架。
- [x] **T3.3 [Frontend]**: 引入状态管理库 (Zustand) 和数据请求库 (SWR/React-Query)。
- [x] **T3.4 [Frontend/UI]**: 开发`ConfigPage`组件包含用于数据库、Gemini API和数据源配置的表单。
- [x] **T3.5 [Frontend/API]**: 编写API客户端函数用于调用后端的`/api/config`系列接口。
- [x] **T3.6 [Frontend/Feature]**: 将API客户端与`ConfigPage`组件集成,实现前端对系统配置的读取、更新和测试功能。
- **T3.1 [Frontend]**: 集成并配置UI组件库 (Shadcn/UI)。
- **T3.2 [Frontend]**: 设置前端路由,创建`/`, `/report/[symbol]`, 和 `/config`等页面骨架。
- **T3.3 [Frontend]**: 引入状态管理库 (Zustand) 和数据请求库 (SWR/React-Query)。
- **T3.4 [Frontend/UI]**: 开发`ConfigPage`组件包含用于数据库、Gemini API和数据源配置的表单。
- **T3.5 [Frontend/API]**: 编写API客户端函数用于调用后端的`/api/config`系列接口。
- **T3.6 [Frontend/Feature]**: 将API客户端与`ConfigPage`组件集成,实现前端对系统配置的读取、更新和测试功能。
## Phase 4: 核心功能 - 报告生成与进度追踪 (P1)

View File

@ -1,18 +1,5 @@
import { fileURLToPath } from 'url';
import path from 'path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** @type {import('next').NextConfig} */
const nextConfig = {
// Explicitly set Turbopack root to this frontend directory to silence multi-lockfile warning
turbopack: {
root: __dirname,
},
// Increase server timeout for long-running AI requests
experimental: {
proxyTimeout: 120000, // 120 seconds
},
async rewrites() {
return [
{

File diff suppressed because it is too large Load Diff

View File

@ -9,23 +9,17 @@
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.545.0",
"next": "15.5.5",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-markdown": "^10.1.0",
"recharts": "^3.3.0",
"remark-gfm": "^4.0.1",
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.8"
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

View File

@ -1,20 +0,0 @@
import { NextRequest } from 'next/server';
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://127.0.0.1:8000/api';
export async function GET() {
const resp = await fetch(`${BACKEND_BASE}/config`);
const text = await resp.text();
return new Response(text, { status: resp.status, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' } });
}
export async function PUT(req: NextRequest) {
const body = await req.text();
const resp = await fetch(`${BACKEND_BASE}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body,
});
const text = await resp.text();
return new Response(text, { status: resp.status, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' } });
}

View File

@ -1,12 +0,0 @@
import { NextRequest } from 'next/server';
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://127.0.0.1:8000/api';
export async function GET(req: NextRequest, { params }: { params: { slug: string[] } }) {
const url = new URL(req.url);
const path = params.slug.join('/');
const target = `${BACKEND_BASE}/financials/${path}${url.search}`;
const resp = await fetch(target, { headers: { 'Content-Type': 'application/json' } });
const text = await resp.text();
return new Response(text, { status: resp.status, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' } });
}

View File

@ -1,653 +1,229 @@
'use client';
"use client";
import { useState, useEffect } from 'react';
import { useConfig, updateConfig, testConfig, useAnalysisConfig, updateAnalysisConfig } from '@/hooks/useApi';
import { useConfigStore, SystemConfig } from '@/stores/useConfigStore';
import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import type { AnalysisConfigResponse } from '@/types';
type Config = {
llm?: {
provider?: "gemini" | "openai";
gemini?: { api_key?: string; base_url?: string };
openai?: { api_key?: string; base_url?: string };
};
data_sources?: {
tushare?: { api_key?: string };
finnhub?: { api_key?: string };
jp_source?: { api_key?: string };
};
database?: { url?: string };
prompts?: { info?: string; finance?: string };
};
export default function ConfigPage() {
// 从 Zustand store 获取全局状态
const { config, loading, error, setConfig } = useConfigStore();
// 使用 SWR hook 加载初始配置
useConfig();
// 加载分析配置
const { data: analysisConfig, mutate: mutateAnalysisConfig } = useAnalysisConfig();
// 本地表单状态
const [dbUrl, setDbUrl] = useState('');
const [newApiApiKey, setNewApiApiKey] = useState('');
const [newApiBaseUrl, setNewApiBaseUrl] = useState('');
const [tushareApiKey, setTushareApiKey] = useState('');
const [finnhubApiKey, setFinnhubApiKey] = useState('');
// 分析配置的本地状态
const [localAnalysisConfig, setLocalAnalysisConfig] = useState<Record<string, {
name: string;
model: string;
prompt_template: string;
dependencies?: string[];
}>>({});
// 分析配置保存状态
const [savingAnalysis, setSavingAnalysis] = useState(false);
const [analysisSaveMessage, setAnalysisSaveMessage] = useState('');
// 测试结果状态
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string } | null>>({});
// 保存状态
const [cfg, setCfg] = useState<Config | null>(null);
const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
// 初始化分析配置的本地状态
useEffect(() => {
if (analysisConfig?.analysis_modules) {
setLocalAnalysisConfig(analysisConfig.analysis_modules);
}
}, [analysisConfig]);
// 更新分析配置中的某个字段
const updateAnalysisField = (type: string, field: 'name' | 'model' | 'prompt_template', value: string) => {
setLocalAnalysisConfig(prev => ({
...prev,
[type]: {
...prev[type],
[field]: value
}
}));
};
// 更新分析模块的依赖
const updateAnalysisDependencies = (type: string, dependency: string, checked: boolean) => {
setLocalAnalysisConfig(prev => {
const currentConfig = prev[type];
const currentDeps = currentConfig.dependencies || [];
const newDeps = checked
? [...currentDeps, dependency]
// 移除依赖,并去重
: currentDeps.filter(d => d !== dependency);
return {
...prev,
[type]: {
...currentConfig,
dependencies: [...new Set(newDeps)] // 确保唯一性
}
};
});
};
// 保存分析配置
const handleSaveAnalysisConfig = async () => {
setSavingAnalysis(true);
setAnalysisSaveMessage('保存中...');
const [msg, setMsg] = useState<string | null>(null);
const [health, setHealth] = useState<string>("unknown");
// form inputs (敏感字段不回显,留空表示保持现有值)
const [provider, setProvider] = useState<"gemini" | "openai">("gemini");
const [geminiBaseUrl, setGeminiBaseUrl] = useState("");
const [geminiKey, setGeminiKey] = useState(""); // 留空则保留
const [openaiBaseUrl, setOpenaiBaseUrl] = useState("");
const [openaiKey, setOpenaiKey] = useState(""); // 留空则保留
const [tushareKey, setTushareKey] = useState(""); // 留空则保留
const [finnhubKey, setFinnhubKey] = useState(""); // 留空则保留
const [jpKey, setJpKey] = useState(""); // 留空则保留
const [dbUrl, setDbUrl] = useState("");
const [promptInfo, setPromptInfo] = useState("");
const [promptFinance, setPromptFinance] = useState("");
async function loadConfig() {
try {
const updated = await updateAnalysisConfig({
analysis_modules: localAnalysisConfig
});
await mutateAnalysisConfig(updated);
setAnalysisSaveMessage('保存成功!');
} catch (e: any) {
setAnalysisSaveMessage(`保存失败: ${e.message}`);
} finally {
setSavingAnalysis(false);
setTimeout(() => setAnalysisSaveMessage(''), 5000);
}
};
const validateConfig = () => {
const errors: string[] = [];
// 验证数据库URL格式
if (dbUrl && !dbUrl.match(/^postgresql(\+asyncpg)?:\/\/.+/)) {
errors.push('数据库URL格式不正确应为 postgresql://user:pass@host:port/dbname');
}
// 验证New API Base URL格式
if (newApiBaseUrl && !newApiBaseUrl.match(/^https?:\/\/.+/)) {
errors.push('New API Base URL格式不正确应为 http:// 或 https:// 开头');
}
// 验证API Key长度基本检查
if (newApiApiKey && newApiApiKey.length < 10) {
errors.push('New API Key长度过短');
}
if (tushareApiKey && tushareApiKey.length < 10) {
errors.push('Tushare API Key长度过短');
}
if (finnhubApiKey && finnhubApiKey.length < 10) {
errors.push('Finnhub API Key长度过短');
}
return errors;
};
const handleSave = async () => {
// 验证配置
const validationErrors = validateConfig();
if (validationErrors.length > 0) {
setSaveMessage(`配置验证失败: ${validationErrors.join(', ')}`);
return;
const res = await fetch("/api/config");
const data: Config = await res.json();
setCfg(data);
// 非敏感字段可回显
setProvider((data.llm?.provider as any) ?? "gemini");
setGeminiBaseUrl(data.llm?.gemini?.base_url ?? "");
setOpenaiBaseUrl(data.llm?.openai?.base_url ?? "");
setDbUrl(data.database?.url ?? "");
setPromptInfo(data.prompts?.info ?? "");
setPromptFinance(data.prompts?.finance ?? "");
} catch {
setMsg("加载配置失败");
}
}
async function saveConfig() {
if (!cfg) return;
setSaving(true);
setSaveMessage('保存中...');
const newConfig: Partial<SystemConfig> = {};
// 只更新有值的字段
if (dbUrl) {
newConfig.database = { url: dbUrl };
}
if (newApiApiKey || newApiBaseUrl) {
newConfig.new_api = {
api_key: newApiApiKey || config?.new_api?.api_key || '',
base_url: newApiBaseUrl || config?.new_api?.base_url || undefined,
};
}
if (tushareApiKey || finnhubApiKey) {
newConfig.data_sources = {
...config?.data_sources,
...(tushareApiKey && { tushare: { api_key: tushareApiKey } }),
...(finnhubApiKey && { finnhub: { api_key: finnhubApiKey } }),
};
}
setMsg(null);
try {
const updated = await updateConfig(newConfig);
setConfig(updated); // 更新全局状态
setSaveMessage('保存成功!');
// 清空敏感字段输入
setNewApiApiKey('');
setTushareApiKey('');
setFinnhubApiKey('');
} catch (e: any) {
setSaveMessage(`保存失败: ${e.message}`);
// 构造覆盖配置:敏感字段若为空则沿用现有值
const next: Config = {
llm: {
provider,
gemini: {
base_url: geminiBaseUrl,
api_key: geminiKey || cfg.llm?.gemini?.api_key || undefined,
},
openai: {
base_url: openaiBaseUrl,
api_key: openaiKey || cfg.llm?.openai?.api_key || undefined,
},
},
data_sources: {
tushare: { api_key: tushareKey || cfg.data_sources?.tushare?.api_key || undefined },
finnhub: { api_key: finnhubKey || cfg.data_sources?.finnhub?.api_key || undefined },
jp_source: { api_key: jpKey || cfg.data_sources?.jp_source?.api_key || undefined },
},
database: { url: dbUrl },
prompts: { info: promptInfo, finance: promptFinance },
};
const res = await fetch("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(next),
});
const ok = await res.json();
if (ok?.status === "ok") {
setMsg("保存成功");
await loadConfig();
// 清空敏感输入(避免页面存储)
setGeminiKey("");
setOpenaiKey("");
setTushareKey("");
setFinnhubKey("");
setJpKey("");
} else {
setMsg("保存失败");
}
} catch {
setMsg("保存失败");
} finally {
setSaving(false);
setTimeout(() => setSaveMessage(''), 5000);
}
};
}
const handleTest = async (type: string, data: any) => {
async function testHealth() {
try {
const result = await testConfig(type, data);
setTestResults(prev => ({ ...prev, [type]: result }));
} catch (e: any) {
setTestResults(prev => ({
...prev,
[type]: { success: false, message: e.message }
}));
const res = await fetch("/health");
const h = await res.json();
setHealth(h?.status ?? "unknown");
} catch {
setHealth("error");
}
};
}
const handleTestDb = () => {
handleTest('database', { url: dbUrl });
};
const handleTestNewApi = () => {
handleTest('new_api', {
api_key: newApiApiKey || config?.new_api?.api_key,
base_url: newApiBaseUrl || config?.new_api?.base_url
});
};
const handleTestTushare = () => {
handleTest('tushare', { api_key: tushareApiKey || config?.data_sources?.tushare?.api_key });
};
const handleTestFinnhub = () => {
handleTest('finnhub', { api_key: finnhubApiKey || config?.data_sources?.finnhub?.api_key });
};
const handleReset = () => {
setDbUrl('');
setNewApiApiKey('');
setNewApiBaseUrl('');
setTushareApiKey('');
setFinnhubApiKey('');
setTestResults({});
setSaveMessage('');
};
const handleExportConfig = () => {
if (!config) return;
const configToExport = {
database: config.database,
new_api: config.new_api,
data_sources: config.data_sources,
export_time: new Date().toISOString(),
version: "1.0"
};
const blob = new Blob([JSON.stringify(configToExport, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `config-backup-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleImportConfig = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedConfig = JSON.parse(e.target?.result as string);
// 验证导入的配置格式
if (importedConfig.database?.url) {
setDbUrl(importedConfig.database.url);
}
if (importedConfig.new_api?.base_url) {
setNewApiBaseUrl(importedConfig.new_api.base_url);
}
setSaveMessage('配置导入成功,请检查并保存');
} catch (error) {
setSaveMessage('配置文件格式错误,导入失败');
}
};
reader.readAsText(file);
};
if (loading) return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">...</p>
</div>
</div>
);
if (error) return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="text-red-500 text-lg mb-2"></div>
<p className="text-red-600">: {error}</p>
</div>
</div>
);
useEffect(() => {
loadConfig();
testHealth();
}, []);
return (
<div className="container mx-auto py-6 space-y-6">
<div className="space-y-6">
<header className="space-y-2">
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground">
API密钥等
<h1 className="text-2xl font-semibold"></h1>
<p className="text-sm text-muted-foreground">
LLM
</p>
</header>
<Tabs defaultValue="database" className="space-y-6">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="database"></TabsTrigger>
<TabsTrigger value="ai">AI服务</TabsTrigger>
<TabsTrigger value="data-sources"></TabsTrigger>
<TabsTrigger value="analysis"></TabsTrigger>
<TabsTrigger value="system"></TabsTrigger>
</TabsList>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>GET /health</CardDescription>
</CardHeader>
<CardContent className="flex items-center gap-2">
<Badge variant={health === "ok" ? "secondary" : "outline"}>{health}</Badge>
<Button variant="outline" onClick={testHealth}></Button>
</CardContent>
</Card>
<TabsContent value="database" className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>PostgreSQL </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="db-url">URL</Label>
<div className="flex gap-2">
<Input
id="db-url"
type="text"
value={dbUrl}
onChange={(e) => setDbUrl(e.target.value)}
placeholder="postgresql+asyncpg://user:password@host:port/database"
className="flex-1"
/>
<Button onClick={handleTestDb} variant="outline">
</Button>
</div>
{testResults.database && (
<Badge variant={testResults.database.success ? 'default' : 'destructive'}>
{testResults.database.message}
</Badge>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<Card>
<CardHeader>
<CardTitle>LLM </CardTitle>
<CardDescription>Gemini / OpenAI</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2">
<label className="text-sm w-28">Provider</label>
<select
className="border rounded px-2 py-1 bg-background"
value={provider}
onChange={(e) => setProvider(e.target.value as any)}
>
<option value="gemini">Gemini</option>
<option value="openai">OpenAI</option>
</select>
</div>
<div className="flex gap-2">
<label className="text-sm w-28">Gemini Base URL</label>
<Input placeholder="可留空" value={geminiBaseUrl} onChange={(e) => setGeminiBaseUrl(e.target.value)} />
</div>
<div className="flex gap-2 items-center">
<label className="text-sm w-28">Gemini Key</label>
<Input type="password" placeholder="留空=保持现值" value={geminiKey} onChange={(e) => setGeminiKey(e.target.value)} />
</div>
<div className="flex gap-2">
<label className="text-sm w-28">OpenAI Base URL</label>
<Input placeholder="可留空" value={openaiBaseUrl} onChange={(e) => setOpenaiBaseUrl(e.target.value)} />
</div>
<div className="flex gap-2 items-center">
<label className="text-sm w-28">OpenAI Key</label>
<Input type="password" placeholder="留空=保持现值" value={openaiKey} onChange={(e) => setOpenaiKey(e.target.value)} />
</div>
</CardContent>
</Card>
<TabsContent value="ai" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>AI </CardTitle>
<CardDescription>New API ( OpenAI )</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-api-key">API Key</Label>
<div className="flex gap-2">
<Input
id="new-api-key"
type="password"
value={newApiApiKey}
onChange={(e) => setNewApiApiKey(e.target.value)}
placeholder="留空表示保持当前值"
className="flex-1"
/>
<Button onClick={handleTestNewApi} variant="outline">
</Button>
</div>
{testResults.new_api && (
<Badge variant={testResults.new_api.success ? 'default' : 'destructive'}>
{testResults.new_api.message}
</Badge>
)}
</div>
<div className="space-y-2">
<Label htmlFor="new-api-base-url">Base URL</Label>
<Input
id="new-api-base-url"
type="text"
value={newApiBaseUrl}
onChange={(e) => setNewApiBaseUrl(e.target.value)}
placeholder="例如: http://localhost:3000/v1"
className="flex-1"
/>
</div>
</CardContent>
</Card>
</TabsContent>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>TuShare / Finnhub / JP</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2 items-center">
<label className="text-sm w-28">TuShare</label>
<Input type="password" placeholder="留空=保持现值" value={tushareKey} onChange={(e) => setTushareKey(e.target.value)} />
</div>
<div className="flex gap-2 items-center">
<label className="text-sm w-28">Finnhub</label>
<Input type="password" placeholder="留空=保持现值" value={finnhubKey} onChange={(e) => setFinnhubKey(e.target.value)} />
</div>
<div className="flex gap-2 items-center">
<label className="text-sm w-28">JP Source</label>
<Input type="password" placeholder="留空=保持现值" value={jpKey} onChange={(e) => setJpKey(e.target.value)} />
</div>
</CardContent>
</Card>
<TabsContent value="data-sources" className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> API </CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div>
<Label className="text-base font-medium">Tushare</Label>
<p className="text-sm text-muted-foreground mb-2"></p>
<div className="flex gap-2">
<Input
type="password"
value={tushareApiKey}
onChange={(e) => setTushareApiKey(e.target.value)}
placeholder="留空表示保持当前值"
className="flex-1"
/>
<Button onClick={handleTestTushare} variant="outline">
</Button>
</div>
{testResults.tushare && (
<Badge variant={testResults.tushare.success ? 'default' : 'destructive'} className="mt-2">
{testResults.tushare.message}
</Badge>
)}
</div>
<Separator />
<div>
<Label className="text-base font-medium">Finnhub</Label>
<p className="text-sm text-muted-foreground mb-2"></p>
<div className="flex gap-2">
<Input
type="password"
value={finnhubApiKey}
onChange={(e) => setFinnhubApiKey(e.target.value)}
placeholder="留空表示保持当前值"
className="flex-1"
/>
<Button onClick={handleTestFinnhub} variant="outline">
</Button>
</div>
{testResults.finnhub && (
<Badge variant={testResults.finnhub.success ? 'default' : 'destructive'} className="mt-2">
{testResults.finnhub.message}
</Badge>
)}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="analysis" className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{Object.entries(localAnalysisConfig).map(([type, config]) => {
const otherModuleKeys = Object.keys(localAnalysisConfig).filter(key => key !== type);
return (
<div key={type} className="space-y-4 p-4 border rounded-lg">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{config.name || type}</h3>
<Badge variant="secondary">{type}</Badge>
</div>
<div className="space-y-2">
<Label htmlFor={`${type}-name`}></Label>
<Input
id={`${type}-name`}
value={config.name || ''}
onChange={(e) => updateAnalysisField(type, 'name', e.target.value)}
placeholder="分析模块显示名称"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${type}-model`}></Label>
<Input
id={`${type}-model`}
value={config.model || ''}
onChange={(e) => updateAnalysisField(type, 'model', e.target.value)}
placeholder="例如: gemini-1.5-pro"
/>
<p className="text-xs text-muted-foreground">
AI
</p>
</div>
<div className="space-y-2">
<Label></Label>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 rounded-lg border p-4">
{otherModuleKeys.map(depKey => (
<div key={depKey} className="flex items-center space-x-2">
<Checkbox
id={`${type}-dep-${depKey}`}
checked={config.dependencies?.includes(depKey)}
onCheckedChange={(checked) => {
updateAnalysisDependencies(type, depKey, !!checked);
}}
/>
<label
htmlFor={`${type}-dep-${depKey}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{localAnalysisConfig[depKey]?.name || depKey}
</label>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`${type}-prompt`}></Label>
<Textarea
id={`${type}-prompt`}
value={config.prompt_template || ''}
onChange={(e) => updateAnalysisField(type, 'prompt_template', e.target.value)}
placeholder="提示词模板,支持 {company_name}, {ts_code}, {financial_data} 占位符"
rows={10}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
: <code>{`{company_name}`}</code>, <code>{`{ts_code}`}</code>, <code>{`{financial_data}`}</code>.
<br />
:{' '}
{otherModuleKeys.length > 0
? otherModuleKeys.map((key, index) => (
<span key={key}>
<code>{`{${key}}`}</code>
{index < otherModuleKeys.length - 1 ? ', ' : ''}
</span>
))
: '无'}
</p>
</div>
<Separator />
</div>
);
})}
<div className="flex items-center gap-4 pt-4">
<Button
onClick={handleSaveAnalysisConfig}
disabled={savingAnalysis}
size="lg"
>
{savingAnalysis ? '保存中...' : '保存分析配置'}
</Button>
{analysisSaveMessage && (
<span className={`text-sm ${analysisSaveMessage.includes('成功') ? 'text-green-600' : 'text-red-600'}`}>
{analysisSaveMessage}
</span>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="system" className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Badge variant={config?.database?.url ? 'default' : 'secondary'}>
{config?.database?.url ? '已配置' : '未配置'}
</Badge>
</div>
<div className="space-y-2">
<Label>New API</Label>
<Badge variant={config?.new_api?.api_key ? 'default' : 'secondary'}>
{config?.new_api?.api_key ? '已配置' : '未配置'}
</Badge>
</div>
<div className="space-y-2">
<Label>Tushare API</Label>
<Badge variant={config?.data_sources?.tushare?.api_key ? 'default' : 'secondary'}>
{config?.data_sources?.tushare?.api_key ? '已配置' : '未配置'}
</Badge>
</div>
<div className="space-y-2">
<Label>Finnhub API</Label>
<Badge variant={config?.data_sources?.finnhub?.api_key ? 'default' : 'secondary'}>
{config?.data_sources?.finnhub?.api_key ? '已配置' : '未配置'}
</Badge>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<Button onClick={handleExportConfig} variant="outline" className="flex-1">
📤
</Button>
<div className="flex-1">
<input
type="file"
accept=".json"
onChange={handleImportConfig}
className="hidden"
id="import-config"
/>
<Button
variant="outline"
className="w-full"
onClick={() => document.getElementById('import-config')?.click()}
>
📥
</Button>
</div>
</div>
<div className="text-sm text-muted-foreground">
<p> </p>
<p> </p>
<p> </p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<div className="flex items-center justify-between pt-6 border-t">
<div className="flex items-center gap-4">
<Button onClick={handleSave} disabled={saving} size="lg">
{saving ? '保存中...' : '保存所有配置'}
</Button>
<Button onClick={handleReset} variant="outline" size="lg">
</Button>
{saveMessage && (
<span className={`text-sm ${saveMessage.includes('成功') ? 'text-green-600' : 'text-red-600'}`}>
{saveMessage}
</span>
)}
</div>
<div className="text-sm text-muted-foreground">
: {new Date().toLocaleString()}
</div>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2">
<label className="text-sm w-28">DB URL</label>
<Input placeholder="postgresql+asyncpg://..." value={dbUrl} onChange={(e) => setDbUrl(e.target.value)} />
</div>
<div className="flex gap-2">
<label className="text-sm w-28">Prompt Info</label>
<Input placeholder="模板info" value={promptInfo} onChange={(e) => setPromptInfo(e.target.value)} />
</div>
<div className="flex gap-2">
<label className="text-sm w-28">Prompt Finance</label>
<Input placeholder="模板finance" value={promptFinance} onChange={(e) => setPromptFinance(e.target.value)} />
</div>
<div className="flex gap-2">
<Button onClick={saveConfig} disabled={saving}>{saving ? "保存中…" : "保存配置"}</Button>
{msg && <span className="text-xs text-muted-foreground">{msg}</span>}
</div>
</CardContent>
</Card>
</div>
);
}
}

View File

@ -1,67 +1,24 @@
import { promises as fs } from 'fs';
import path from 'path';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
async function getMarkdownContent() {
// process.cwd() is the root of the Next.js project (the 'frontend' directory)
const mdPath = path.join(process.cwd(), '..', 'docs', 'design.md');
try {
const content = await fs.readFile(mdPath, 'utf8');
return content;
} catch (error) {
console.error("Failed to read design.md:", error);
return "# 文档加载失败\n\n无法读取 `docs/design.md` 文件。请检查文件是否存在以及服务器权限。";
}
}
export default async function DocsPage() {
const content = await getMarkdownContent();
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function DocsPage() {
return (
<div className="container mx-auto py-6 space-y-6">
<div className="space-y-6">
<header className="space-y-2">
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground">
</p>
<h1 className="text-2xl font-semibold"></h1>
<p className="text-sm text-muted-foreground">使</p>
</header>
<Card>
<CardContent className="p-6">
<article className="prose prose-zinc max-w-none dark:prose-invert">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({node, ...props}) => <h1 className="text-3xl font-bold mb-4 mt-8 border-b pb-2" {...props} />,
h2: ({node, ...props}) => <h2 className="text-2xl font-bold mb-3 mt-6 border-b pb-2" {...props} />,
h3: ({node, ...props}) => <h3 className="text-xl font-semibold mb-2 mt-4" {...props} />,
p: ({node, ...props}) => <p className="mb-4 leading-7" {...props} />,
ul: ({node, ...props}) => <ul className="list-disc list-inside mb-4 space-y-2" {...props} />,
ol: ({node, ...props}) => <ol className="list-decimal list-inside mb-4 space-y-2" {...props} />,
li: ({node, ...props}) => <li className="ml-4" {...props} />,
code: ({node, inline, className, children, ...props}: any) => {
const match = /language-(\w+)/.exec(className || '');
return !inline ? (
<code className={className} {...props}>
{children}
</code>
) : (
<code className="bg-muted px-1.5 py-1 rounded text-sm font-mono" {...props}>
{children}
</code>
);
},
pre: ({children}) => <pre className="bg-muted p-4 rounded my-4 overflow-x-auto">{children}</pre>,
table: ({node, ...props}) => <div className="overflow-x-auto my-4"><table className="border-collapse border border-border w-full" {...props} /></div>,
th: ({node, ...props}) => <th className="border border-border px-4 py-2 bg-muted font-semibold text-left" {...props} />,
td: ({node, ...props}) => <td className="border border-border px-4 py-2" {...props} />,
a: ({node, ...props}) => <a className="text-primary underline hover:text-primary/80" {...props} />,
}}
>
{content}
</ReactMarkdown>
</article>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="prose prose-sm dark:prose-invert">
<ol className="list-decimal pl-5 space-y-1">
<li> npm run dev </li>
<li> src/app </li>
<li>使 shadcn/ui </li>
</ol>
</CardContent>
</Card>
</div>

View File

@ -45,9 +45,6 @@ export default function RootLayout({
<NavigationMenuItem>
<NavigationMenuLink href="/docs" className="px-3 py-2"></NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="/config" className="px-3 py-2"></NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>

View File

@ -1,56 +1,674 @@
'use client';
"use client";
import { useState } from 'react';
import { useRouter } from 'next/navigation';
/**
* -
*
*
* -
* -
* -
* -
* -
*/
import { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { StatusBar, useStatusBar } from "@/components/ui/status-bar";
import { createDefaultStepManager } from "@/lib/execution-step-manager";
import { RowSettingsPanel } from "@/components/ui/row-settings";
import { useRowConfig } from "@/hooks/use-row-config";
import { EnhancedTable } from "@/components/ui/enhanced-table";
import { Notification } from "@/components/ui/notification";
import {
normalizeTsCode,
flattenApiGroups,
enhanceErrorMessage,
isRetryableError,
formatFinancialValue,
getMetricUnit
} from "@/lib/financial-utils";
import type {
MarketType,
ChartType,
CompanyInfo,
CompanySuggestion,
RevenueDataPoint,
FinancialMetricConfig,
FinancialDataSeries,
ExecutionStep,
BatchFinancialDataResponse,
FinancialConfigResponse,
SearchApiResponse,
BatchDataRequest
} from "@/types";
import {
ResponsiveContainer,
BarChart,
Bar,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from "recharts";
export default function StockInputForm() {
const [symbol, setSymbol] = useState('');
const [market, setMarket] = useState('china');
const router = useRouter();
export default function Home() {
// ============================================================================
// 基础状态管理
// ============================================================================
const [market, setMarket] = useState<MarketType>("cn");
const [query, setQuery] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [chartType, setChartType] = useState<ChartType>("bar");
const handleSearch = () => {
if (symbol.trim()) {
router.push(`/report/${symbol.trim()}?market=${market}`);
// ============================================================================
// 数据状态管理
// ============================================================================
const [items, setItems] = useState<RevenueDataPoint[]>([]);
const [selected, setSelected] = useState<CompanyInfo | null>(null);
const [configItems, setConfigItems] = useState<FinancialMetricConfig[]>([]);
const [metricSeries, setMetricSeries] = useState<FinancialDataSeries>({});
const [selectedMetric, setSelectedMetric] = useState<string>("revenue");
const [selectedMetricName, setSelectedMetricName] = useState<string>("营业收入");
const [paramToGroup, setParamToGroup] = useState<Record<string, string>>({});
const [paramToApi, setParamToApi] = useState<Record<string, string>>({});
// ============================================================================
// 搜索相关状态
// ============================================================================
const [suggestions, setSuggestions] = useState<CompanySuggestion[]>([]);
const [typingTimer, setTypingTimer] = useState<NodeJS.Timeout | null>(null);
// ============================================================================
// 状态栏管理
// ============================================================================
const {
statusBarState,
showStatusBar,
showSuccess,
showError,
hideStatusBar
} = useStatusBar();
// ============================================================================
// 执行步骤管理
// ============================================================================
const [stepManager] = useState(() => {
return createDefaultStepManager({
onStepStart: (step: ExecutionStep, index: number, total: number) => {
showStatusBar(step, index, total);
},
onStepComplete: (_step: ExecutionStep, index: number, total: number) => {
// If there are more steps, update to next step
if (index < total - 1) {
// This will be handled by the next step start
} else {
// All steps completed
showSuccess();
}
},
onStepError: (_step: ExecutionStep, _index: number, _total: number, error: Error) => {
// 判断错误是否可重试
const isRetryable = isRetryableError(error) || stepManager.canRetry();
showError(error.message, isRetryable);
},
onComplete: () => {
showSuccess();
},
onError: (error: Error) => {
// 判断错误是否可重试
const isRetryable = isRetryableError(error) || stepManager.canRetry();
showError(error.message, isRetryable);
}
});
});
// ============================================================================
// 表格行配置管理
// ============================================================================
const [isRowSettingsPanelOpen, setIsRowSettingsPanelOpen] = useState(false);
// Row configuration management - memoize to prevent infinite re-renders
const rowIds = useMemo(() =>
configItems.map(item => item.tushareParam || '').filter(Boolean),
[configItems]
);
const {
rowConfigs,
customRows,
updateRowConfig,
saveStatus,
clearSaveStatus,
addCustomRow,
deleteCustomRow,
updateRowOrder
} = useRowConfig(selected?.ts_code || null, rowIds);
const rowDisplayTexts = useMemo(() => {
const texts = configItems.reduce((acc, item) => {
if (item.tushareParam) {
acc[item.tushareParam] = item.displayText;
}
return acc;
}, {} as Record<string, string>);
// 添加自定义行的显示文本
Object.entries(customRows).forEach(([rowId, customRow]) => {
texts[rowId] = customRow.displayText;
});
return texts;
}, [configItems, customRows]);
// ============================================================================
// 搜索建议功能
// ============================================================================
/**
*
* @param text -
*/
async function fetchSuggestions(text: string): Promise<void> {
if (market !== "cn") {
setSuggestions([]);
return;
}
const searchQuery = (text || "").trim();
if (!searchQuery) {
setSuggestions([]);
return;
}
try {
const response = await fetch(
`http://localhost:8000/api/search?query=${encodeURIComponent(searchQuery)}&limit=8`
);
const data: SearchApiResponse = await response.json();
const suggestions = Array.isArray(data?.items) ? data.items : [];
setSuggestions(suggestions);
} catch (error) {
console.warn('Failed to fetch suggestions:', error);
setSuggestions([]);
}
}
// ============================================================================
// 搜索处理功能
// ============================================================================
/**
*
*/
const retrySearch = async (): Promise<void> => {
if (stepManager.canRetry()) {
try {
await stepManager.retry();
} catch {
// 错误已经在stepManager中处理
}
} else {
// 如果不能重试,重新执行搜索
await handleSearch();
}
};
/**
*
*/
async function handleSearch(): Promise<void> {
// 重置状态
setError("");
setItems([]);
setMetricSeries({});
setConfigItems([]);
setSelectedMetric("revenue");
// 验证市场支持
if (market !== "cn") {
setError("目前仅支持 A 股查询,请选择\"中国\"市场。");
return;
}
// 获取并验证股票代码
const tsCode = selected?.ts_code || normalizeTsCode(query);
if (!tsCode) {
setError("请输入有效的 A 股股票代码(如 600519 / 000001 或带后缀 600519.SH。");
return;
}
setLoading(true);
try {
// 创建搜索执行步骤
const searchStep: ExecutionStep = {
id: 'fetch_financial_data',
name: '正在读取财务数据',
description: '从Tushare API获取公司财务指标数据',
execute: async () => {
await executeSearchStep(tsCode);
}
};
// 清空之前的步骤并添加新的搜索步骤
stepManager.clearSteps();
stepManager.addStep(searchStep);
// 执行搜索步骤
await stepManager.execute();
} catch (error) {
const errorMsg = enhanceErrorMessage(error);
setError(errorMsg);
// 错误处理已经在stepManager的回调中处理
} finally {
setLoading(false);
}
}
/**
*
* @param tsCode -
*/
async function executeSearchStep(tsCode: string): Promise<void> {
// 1) 获取配置tushare专用解析 api_groups -> 扁平 items
const configResponse = await fetch(`http://localhost:8000/api/financial-config`);
const configData: FinancialConfigResponse = await configResponse.json();
const groups = configData?.api_groups || {};
const { items, groupMap, apiMap } = flattenApiGroups(groups);
setConfigItems(items);
setParamToGroup(groupMap);
setParamToApi(apiMap);
// 2) 批量请求年度序列同API字段合并读取
const years = 10;
const metrics = Array.from(new Set(["revenue", ...items.map(i => i.tushareParam)]));
const batchRequest: BatchDataRequest = {
ts_code: tsCode,
years,
metrics
};
const batchResponse = await fetch(
`http://localhost:8000/api/financialdata/${encodeURIComponent(market)}/batch`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(batchRequest),
}
);
const batchData: BatchFinancialDataResponse = await batchResponse.json();
const seriesObj = batchData?.series || {};
// 处理数据系列
const processedSeries: FinancialDataSeries = {};
for (const metric of metrics) {
const series = Array.isArray(seriesObj[metric]) ? seriesObj[metric] : [];
processedSeries[metric] = [...series].sort((a, b) => Number(a.year) - Number(b.year));
}
setMetricSeries(processedSeries);
// 3) 设置选中公司与默认图表序列(收入)
setSelected({
ts_code: batchData?.ts_code || tsCode,
name: batchData?.name
});
const revenueSeries = processedSeries["revenue"] || [];
setItems(revenueSeries.map(d => ({ year: d.year, revenue: d.value })));
const revenueName = items.find(i => i.tushareParam === "revenue")?.displayText || "营业收入";
setSelectedMetricName(revenueName);
if (revenueSeries.length === 0) {
throw new Error("未查询到数据,请确认代码或稍后重试。");
}
}
// ============================================================================
// 渲染组件
// ============================================================================
return (
<div className="flex justify-center items-center h-full">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label></label>
<Input
placeholder="例如: 600519.SH 或 AAPL"
value={symbol}
onChange={(e) => setSymbol(e.target.value)}
/>
</div>
<div className="space-y-2">
<label></label>
<Select value={market} onValueChange={setMarket}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="china"></SelectItem>
<SelectItem value="hongkong"></SelectItem>
<SelectItem value="usa"></SelectItem>
<SelectItem value="japan"></SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleSearch} className="w-full"></Button>
</CardContent>
</Card>
<div className="space-y-8">
{/* StatusBar Component */}
<StatusBar
isVisible={statusBarState.isVisible}
currentStep={statusBarState.currentStep}
stepIndex={statusBarState.stepIndex}
totalSteps={statusBarState.totalSteps}
status={statusBarState.status}
errorMessage={statusBarState.errorMessage}
onDismiss={hideStatusBar}
onRetry={retrySearch}
retryable={statusBarState.retryable}
/>
{/* Configuration Save Status Notification */}
{saveStatus.status !== 'idle' && (
<Notification
message={saveStatus.message || ''}
type={saveStatus.status === 'success' ? 'success' : saveStatus.status === 'error' ? 'error' : 'info'}
isVisible={true}
onDismiss={clearSaveStatus}
position="bottom-right"
autoHide={saveStatus.status === 'success'}
autoHideDelay={2000}
/>
)}
{/* Row Settings Panel */}
<RowSettingsPanel
isOpen={isRowSettingsPanelOpen}
onClose={() => setIsRowSettingsPanelOpen(false)}
rowConfigs={rowConfigs}
rowDisplayTexts={rowDisplayTexts}
onConfigChange={updateRowConfig}
onRowOrderChange={updateRowOrder}
onDeleteCustomRow={deleteCustomRow}
enableRowReordering={true}
/>
<section className="space-y-3">
<h1 className="text-2xl font-semibold"></h1>
<p className="text-sm text-muted-foreground">
使 Next.js + shadcn/ui
</p>
<div className="flex gap-2 max-w-xl relative">
<Select value={market} onValueChange={(v) => setMarket(v as MarketType)}>
<SelectTrigger className="w-28 sm:w-40" aria-label="选择市场">
<SelectValue placeholder="选择市场" />
</SelectTrigger>
<SelectContent>
<SelectItem value="cn"></SelectItem>
<SelectItem value="us"></SelectItem>
<SelectItem value="hk"></SelectItem>
<SelectItem value="jp"></SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
<Input
placeholder="输入股票代码或公司名,例如 600519 / 000001 / 贵州茅台"
value={query}
onChange={(e) => {
const v = e.target.value;
setQuery(v);
setSelected(null);
if (typingTimer) clearTimeout(typingTimer);
const t = setTimeout(() => fetchSuggestions(v), 250);
setTypingTimer(t);
}}
/>
{/* 下拉建议 */}
{suggestions.length > 0 && (
<div className="absolute top-12 left-0 right-0 z-10 bg-white border rounded shadow">
{suggestions.map((s, i) => (
<div
key={s.ts_code + i}
className="px-3 py-2 hover:bg-gray-100 cursor-pointer flex justify-between"
onClick={() => {
setQuery(`${s.ts_code} ${s.name}`);
setSelected({ ts_code: s.ts_code, name: s.name });
setSuggestions([]);
}}
>
<span>{s.name}</span>
<span className="text-muted-foreground">{s.ts_code}</span>
</div>
))}
</div>
)}
<Button onClick={handleSearch} disabled={loading}>
{loading ? "查询中..." : "搜索"}
</Button>
</div>
{error && <Badge variant="secondary">{error}</Badge>}
</section>
<section>
{items.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle>10A股</CardTitle>
<CardDescription> Tushare{selected && selected.name ? `${selected.name} (${selected.ts_code})` : selected?.ts_code}</CardDescription>
</div>
<div className="mt-1">
<Select value={chartType} onValueChange={(v) => setChartType(v as ChartType)}>
<SelectTrigger className="w-40" aria-label="选择图表类型">
<SelectValue placeholder="选择图表类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="bar"></SelectItem>
<SelectItem value="line">线</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* 使用 Recharts 渲染图表(动态单位) */}
{(() => {
// 获取当前选中指标的信息
const currentMetricInfo = configItems.find(ci => (ci.tushareParam || "") === selectedMetric);
const metricGroup = currentMetricInfo?.group;
const metricApi = currentMetricInfo?.api;
const metricUnit = getMetricUnit(metricGroup, metricApi, selectedMetric);
// 构建完整的图例名称
const legendName = `${selectedMetricName}${metricUnit}`;
// 根据指标类型确定数据缩放和单位
const shouldScaleToYi = (
metricGroup === "income" ||
metricGroup === "balancesheet" ||
metricGroup === "cashflow" ||
selectedMetric === "total_mv"
);
const chartData = items.map((d) => {
let scaledValue = typeof d.revenue === "number" ? d.revenue : 0;
if (shouldScaleToYi) {
// 对于财务报表数据,转换为亿元
if (selectedMetric === "total_mv") {
// 市值从万元转为亿元
scaledValue = scaledValue / 1e4;
} else {
// 其他财务数据从元转为亿元
scaledValue = scaledValue / 1e8;
}
}
return {
year: d.year,
metricValue: scaledValue,
};
});
return (
<div className="w-full h-[320px]">
<ResponsiveContainer width="100%" height="100%">
{chartType === "bar" ? (
<BarChart data={chartData} margin={{ top: 16, right: 16, left: 8, bottom: 16 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis tickFormatter={(v) => `${Math.round(v)}`} />
<Tooltip formatter={(value) => {
const v = Number(value);
const nf1 = new Intl.NumberFormat("zh-CN", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const nf0 = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 });
if (shouldScaleToYi) {
if (selectedMetric === "total_mv") {
return [`${nf0.format(Math.round(v))} 亿元`, selectedMetricName];
} else {
return [`${nf1.format(v)} 亿元`, selectedMetricName];
}
} else {
return [`${nf1.format(v)}`, selectedMetricName];
}
}} />
<Legend />
<Bar dataKey="metricValue" name={legendName} fill="#4f46e5" />
</BarChart>
) : (
<LineChart data={chartData} margin={{ top: 16, right: 16, left: 8, bottom: 16 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis tickFormatter={(v) => `${Math.round(v)}`} />
<Tooltip formatter={(value) => {
const v = Number(value);
const nf1 = new Intl.NumberFormat("zh-CN", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const nf0 = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 });
if (shouldScaleToYi) {
if (selectedMetric === "total_mv") {
return [`${nf0.format(Math.round(v))} 亿元`, selectedMetricName];
} else {
return [`${nf1.format(v)} 亿元`, selectedMetricName];
}
} else {
return [`${nf1.format(v)}`, selectedMetricName];
}
}} />
<Legend />
<Line type="monotone" dataKey="metricValue" name={legendName} stroke="#4f46e5" dot />
</LineChart>
)}
</ResponsiveContainer>
</div>
);
})()}
{/* 增强数据表格 */}
{(() => {
const series = metricSeries;
const allYears = Object.values(series)
.flat()
.map(d => d.year);
if (allYears.length === 0) return null;
const yearsDesc = Array.from(new Set(allYears)).sort((a, b) => Number(b) - Number(a));
const columns = yearsDesc;
function valueOf(m: string, year: string): number | null | undefined {
const s = series[m] || [];
const f = s.find(d => d.year === year);
return f ? f.value : undefined;
}
function fmtCell(m: string, y: string): string {
const v = valueOf(m, y);
const group = paramToGroup[m] || "";
const api = paramToApi[m] || "";
return formatFinancialValue(v, group, api, m);
}
// 行点击切换图表数据源
function onRowClick(m: string) {
setSelectedMetric(m);
// 指标中文名传给图表
const rowInfo = configItems.find(ci => (ci.tushareParam || "") === m);
setSelectedMetricName(rowInfo?.displayText || m);
const s = series[m] || [];
setItems(s.map(d => ({ year: d.year, revenue: d.value })));
}
// 准备表格数据
const baseTableData = configItems.map((row, idx) => {
const m = (row.tushareParam || "").trim();
const values: Record<string, string> = {};
if (m) {
columns.forEach(year => {
values[year] = fmtCell(m, year);
});
} else {
columns.forEach(year => {
values[year] = "-";
});
}
return {
id: m || `row_${idx}`,
displayText: row.displayText + getMetricUnit(row.group, row.api, row.tushareParam),
values,
group: row.group,
api: row.api,
tushareParam: row.tushareParam,
isCustomRow: false
};
});
// 添加自定义行数据(仅分隔线)
const customRowData = Object.entries(customRows)
.filter(([, customRow]) => customRow.customRowType === 'separator')
.map(([rowId, customRow]) => {
const values: Record<string, string> = {};
columns.forEach(year => {
values[year] = "-"; // 分隔线不显示数据
});
return {
id: rowId,
displayText: customRow.displayText,
values,
isCustomRow: true,
customRowType: customRow.customRowType
};
});
// 合并基础数据和自定义行数据
const tableData = [...baseTableData, ...customRowData];
return (
<EnhancedTable
data={tableData}
columns={columns}
rowConfigs={rowConfigs}
selectedRowId={selectedMetric}
onRowClick={onRowClick}
onRowConfigChange={updateRowConfig}
onOpenSettings={() => setIsRowSettingsPanelOpen(true)}
onAddCustomRow={addCustomRow}
onDeleteCustomRow={deleteCustomRow}
onRowOrderChange={updateRowOrder}
enableAnimations={true}
animationDuration={300}
enableVirtualization={configItems.length > 50}
maxVisibleRows={50}
enableRowDragging={true}
/>
);
})()}
</CardContent>
</Card>
)}
</section>
</div>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,147 +0,0 @@
'use client';
import { useEffect, useRef } from 'react';
interface TradingViewWidgetProps {
symbol: string;
market?: string;
height?: number;
width?: string;
}
declare global {
interface Window {
TradingView: any;
}
}
export function TradingViewWidget({
symbol,
market = 'china',
height = 400,
width = '100%'
}: TradingViewWidgetProps) {
const containerRef = useRef<HTMLDivElement>(null);
// 将中国股票代码转换为TradingView格式
const getTradingViewSymbol = (symbol: string, market: string) => {
if (market === 'china' || market === 'cn') {
// 处理中国股票代码
if (symbol.includes('.')) {
const [code, exchange] = symbol.split('.');
if (exchange === 'SH') {
return `SSE:${code}`;
} else if (exchange === 'SZ') {
return `SZSE:${code}`;
}
}
// 如果没有后缀,尝试推断
const onlyDigits = symbol.replace(/\D/g, '');
if (onlyDigits.length === 6) {
const first = onlyDigits[0];
if (first === '6') {
return `SSE:${onlyDigits}`;
} else if (first === '0' || first === '3') {
return `SZSE:${onlyDigits}`;
}
}
return symbol;
}
return symbol;
};
useEffect(() => {
if (typeof window === 'undefined') return;
if (!symbol) return;
const tradingViewSymbol = getTradingViewSymbol(symbol, market);
const script = document.createElement('script');
script.src = 'https://s3.tradingview.com/external-embedding/embed-widget-advanced-chart.js';
script.async = true;
script.innerHTML = JSON.stringify({
autosize: true,
symbol: tradingViewSymbol,
interval: 'D',
timezone: 'Asia/Shanghai',
theme: 'light',
style: '1',
locale: 'zh_CN',
toolbar_bg: '#f1f3f6',
enable_publishing: false,
hide_top_toolbar: false,
hide_legend: false,
save_image: false,
container_id: `tradingview_${symbol}`,
studies: [],
show_popup_button: false,
no_referrer_id: true,
referrer_id: 'fundamental-analysis',
// 强制启用对数坐标
logarithmic: true,
disabled_features: [
'use_localstorage_for_settings',
'volume_force_overlay',
'create_volume_indicator_by_default'
],
enabled_features: [
'side_toolbar_in_fullscreen_mode',
'header_in_fullscreen_mode'
],
overrides: {
'paneProperties.background': '#ffffff',
'paneProperties.vertGridProperties.color': '#e1e3e6',
'paneProperties.horzGridProperties.color': '#e1e3e6',
'symbolWatermarkProperties.transparency': 90,
'scalesProperties.textColor': '#333333',
// 对数坐标设置
'scalesProperties.logarithmic': true,
'rightPriceScale.mode': 1,
'leftPriceScale.mode': 1,
'paneProperties.priceScaleProperties.log': true,
'paneProperties.priceScaleProperties.mode': 1
},
// 强制启用对数坐标
studies_overrides: {
'volume.volume.color.0': '#00bcd4',
'volume.volume.color.1': '#ff9800',
'volume.volume.transparency': 70
}
});
const container = containerRef.current;
if (container) {
// 避免重复挂载与 Next 热更新多次执行导致的报错
container.innerHTML = '';
// 延迟到下一帧,确保容器已插入并可获取 iframe.contentWindow
requestAnimationFrame(() => {
try {
if (container.isConnected) {
container.appendChild(script);
}
} catch {
// 忽略偶发性 contentWindow 不可用的报错
}
});
}
return () => {
const c = containerRef.current;
if (c) {
try {
c.innerHTML = '';
} catch {}
}
};
}, [symbol, market]);
return (
<div className="w-full">
<div
ref={containerRef}
id={`tradingview_${symbol}`}
style={{ height: `${height}px`, width }}
className="border rounded-lg overflow-hidden"
/>
</div>
);
}

View File

@ -1,32 +0,0 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -1,21 +0,0 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
))
Label.displayName = "Label"
export { Label }

View File

@ -1,29 +0,0 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical"
decorative?: boolean
}
const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<div
ref={ref}
role={decorative ? "none" : "separator"}
aria-orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = "Separator"
export { Separator }

View File

@ -1,16 +0,0 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View File

@ -1,66 +0,0 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -1,25 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -1,113 +0,0 @@
import useSWR from 'swr';
import { useConfigStore } from '@/stores/useConfigStore';
import { BatchFinancialDataResponse, FinancialConfigResponse, AnalysisConfigResponse } from '@/types';
const fetcher = async (url: string) => {
const res = await fetch(url);
const contentType = res.headers.get('Content-Type') || '';
const text = await res.text();
// 尝试解析JSON
const tryParseJson = () => {
try { return JSON.parse(text); } catch { return null; }
};
const data = contentType.includes('application/json') ? tryParseJson() : tryParseJson();
if (!res.ok) {
// 后端可能返回纯文本错误,统一抛出可读错误
const message = data && data.detail ? data.detail : (text || `Request failed: ${res.status}`);
throw new Error(message);
}
if (data === null) {
throw new Error('无效的服务器响应非JSON');
}
return data;
};
export function useConfig() {
const { setConfig, setError } = useConfigStore();
const { data, error, isLoading } = useSWR('/api/config', fetcher, {
onSuccess: (data) => setConfig(data),
onError: (err) => setError(err.message),
});
return { data, error, isLoading };
}
export async function updateConfig(newConfig: any) {
const res = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newConfig),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function testConfig(type: string, data: any) {
const res = await fetch('/api/config/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config_type: type, config_data: data }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export function useFinancialConfig() {
return useSWR<FinancialConfigResponse>('/api/financials/config', fetcher);
}
export function useChinaFinancials(ts_code?: string, years: number = 10) {
return useSWR<BatchFinancialDataResponse>(
ts_code ? `/api/financials/china/${encodeURIComponent(ts_code)}?years=${encodeURIComponent(String(years))}` : null,
fetcher,
{
revalidateOnFocus: false, // 不在窗口聚焦时重新验证
revalidateOnReconnect: false, // 不在网络重连时重新验证
dedupingInterval: 300000, // 5分钟内去重
errorRetryCount: 1, // 错误重试1次
}
);
}
export function useAnalysisConfig() {
return useSWR<AnalysisConfigResponse>('/api/financials/analysis-config', fetcher);
}
export async function updateAnalysisConfig(config: AnalysisConfigResponse) {
const res = await fetch('/api/financials/analysis-config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function generateFullAnalysis(tsCode: string, companyName: string) {
const url = `/api/financials/china/${encodeURIComponent(tsCode)}/analysis?company_name=${encodeURIComponent(companyName)}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const text = await res.text();
if (!res.ok) {
try {
const errorJson = JSON.parse(text);
throw new Error(errorJson.detail || text);
} catch {
throw new Error(text || `Request failed: ${res.status}`);
}
}
try {
return JSON.parse(text);
} catch {
throw new Error('Invalid JSON response from server.');
}
}

View File

@ -1,39 +0,0 @@
import { create } from 'zustand';
// 根据设计文档定义配置项的类型
export interface DatabaseConfig {
url: string;
}
export interface NewApiConfig {
api_key: string;
base_url?: string;
}
export interface DataSourceConfig {
[key: string]: { api_key: string };
}
export interface SystemConfig {
database: DatabaseConfig;
new_api: NewApiConfig;
data_sources: DataSourceConfig;
}
interface ConfigState {
config: SystemConfig | null;
loading: boolean;
error: string | null;
setConfig: (config: SystemConfig) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
}
export const useConfigStore = create<ConfigState>((set) => ({
config: null,
loading: true,
error: null,
setConfig: (config) => set({ config, loading: false, error: null }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error, loading: false }),
}));

View File

@ -49,8 +49,6 @@ export interface YearDataPoint {
year: string;
/** 数值 (可为null表示无数据) */
value: number | null;
/** 月份信息,用于确定季度 */
month?: number | null;
}
/**
@ -87,25 +85,6 @@ export interface FinancialDataSeries {
/**
*
*/
export interface StepRecord {
name: string;
start_ts: string;
end_ts?: string;
duration_ms?: number;
status: 'running' | 'done' | 'error';
error?: string;
}
export interface FinancialMeta {
started_at: string;
finished_at?: string;
elapsed_ms?: number;
api_calls_total: number;
api_calls_by_group: Record<string, number>;
current_action?: string | null;
steps: StepRecord[];
}
export interface BatchFinancialDataResponse {
/** 股票代码 */
ts_code: string;
@ -113,8 +92,6 @@ export interface BatchFinancialDataResponse {
name?: string;
/** 数据系列 */
series: FinancialDataSeries;
/** 元数据耗时、API调用次数、步骤 */
meta?: FinancialMeta;
}
/**
@ -127,76 +104,6 @@ export interface FinancialConfigResponse {
};
}
/**
* Token使用情况接口
*/
export interface TokenUsage {
/** 提示词token数 */
prompt_tokens: number;
/** 完成token数 */
completion_tokens: number;
/** 总token数 */
total_tokens: number;
}
/**
*
*/
export interface CompanyProfileResponse {
/** 股票代码 */
ts_code: string;
/** 公司名称 */
company_name?: string;
/** 分析内容 */
content: string;
/** 使用的模型 */
model: string;
/** Token使用情况 */
tokens: TokenUsage;
/** 耗时(毫秒) */
elapsed_ms: number;
/** 是否成功 */
success: boolean;
/** 错误信息 */
error?: string;
}
/**
*
*/
export interface AnalysisResponse {
/** 股票代码 */
ts_code: string;
/** 公司名称 */
company_name?: string;
/** 分析类型 */
analysis_type: string;
/** 分析内容 */
content: string;
/** 使用的模型 */
model: string;
/** Token使用情况 */
tokens: TokenUsage;
/** 耗时(毫秒) */
elapsed_ms: number;
/** 是否成功 */
success: boolean;
/** 错误信息 */
error?: string;
}
/**
*
*/
export interface AnalysisConfigResponse {
/** 分析模块配置 */
analysis_modules: Record<string, {
name: string;
model: string;
prompt_template: string;
}>;
}
// ============================================================================
// 表格相关类型
// ============================================================================

83
package-lock.json generated
View File

@ -1,83 +0,0 @@
{
"name": "Fundamental_Analysis",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"swr": "^2.3.6",
"zustand": "^5.0.8"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/swr": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
"integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@ -1,6 +0,0 @@
{
"dependencies": {
"swr": "^2.3.6",
"zustand": "^5.0.8"
}
}

View File

@ -1,121 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Colors
RESET="\033[0m"
GREEN="\033[32m"
CYAN="\033[36m"
YELLOW="\033[33m"
RED="\033[31m"
REPO_ROOT="$(cd "$(dirname "$0")"/.. && pwd)"
BACKEND_DIR="$REPO_ROOT/backend"
FRONTEND_DIR="$REPO_ROOT/frontend"
CONFIG_FILE="$REPO_ROOT/config/config.json"
# Port configuration
BACKEND_PORT=8000
FRONTEND_PORT=3000
# Kill process using specified port
kill_port() {
local port=$1
local pids=$(lsof -nP -ti tcp:"$port" 2>/dev/null || true)
if [[ -n "$pids" ]]; then
echo -e "${YELLOW}[CLEANUP]${RESET} Killing process(es) using port $port: $pids"
echo "$pids" | xargs kill -9 2>/dev/null || true
sleep 1
fi
}
ensure_backend() {
cd "$BACKEND_DIR"
if [[ ! -d .venv ]]; then
echo -e "${YELLOW}[SETUP]${RESET} Creating Python venv and installing backend requirements..."
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
else
source .venv/bin/activate
fi
# Export TUSHARE_TOKEN from config if available (prefer jq, fallback to node)
if [[ -f "$CONFIG_FILE" ]]; then
if command -v jq >/dev/null 2>&1; then
TUSHARE_TOKEN_VAL=$(jq -r '.data_sources.tushare.api_key // empty' "$CONFIG_FILE" 2>/dev/null || true)
else
TUSHARE_TOKEN_VAL=$(node -e "const fs=require('fs');try{const c=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));const v=c?.data_sources?.tushare?.api_key||'';if(v)process.stdout.write(v)}catch{}" "$CONFIG_FILE" 2>/dev/null || true)
fi
if [[ -n "${TUSHARE_TOKEN_VAL:-}" ]]; then
export TUSHARE_TOKEN="$TUSHARE_TOKEN_VAL"
fi
fi
}
run_backend() {
ensure_backend
cd "$BACKEND_DIR"
# Run and colorize output (avoid stdbuf on macOS)
UVICORN_CMD=(uvicorn app.main:app --reload --port "$BACKEND_PORT")
"${UVICORN_CMD[@]}" 2>&1 | awk -v p="[BACKEND]" -v color="$GREEN" -v reset="$RESET" '{print color p " " $0 reset}'
}
ensure_frontend() {
cd "$FRONTEND_DIR"
if [[ ! -d node_modules ]]; then
echo -e "${YELLOW}[SETUP]${RESET} Installing frontend dependencies (npm install)..."
npm install --no-fund --no-audit
fi
}
run_frontend() {
ensure_frontend
cd "$FRONTEND_DIR"
npm run dev 2>&1 | awk -v p="[FRONTEND]" -v color="$CYAN" -v reset="$RESET" '{print color p " " $0 reset}'
}
cleanup() {
echo -e "\n${YELLOW}[CLEANUP]${RESET} Stopping services..."
# Kill process groups to ensure all child processes are terminated
if [[ -n "${BACKEND_PID:-}" ]]; then
kill -TERM -"$BACKEND_PID" 2>/dev/null || kill "$BACKEND_PID" 2>/dev/null || true
fi
if [[ -n "${FRONTEND_PID:-}" ]]; then
kill -TERM -"$FRONTEND_PID" 2>/dev/null || kill "$FRONTEND_PID" 2>/dev/null || true
fi
sleep 1
# Force kill any remaining processes on these ports
kill_port "$BACKEND_PORT"
kill_port "$FRONTEND_PORT"
wait 2>/dev/null || true
echo -e "${GREEN}[CLEANUP]${RESET} All services stopped."
}
main() {
echo -e "${CYAN}Dev Launcher${RESET}: Starting Backend ($BACKEND_PORT) and Frontend ($FRONTEND_PORT)\n"
# Clean up ports before starting
kill_port "$BACKEND_PORT"
kill_port "$FRONTEND_PORT"
echo -e "${GREEN}[BACKEND]${RESET} API: http://127.0.0.1:$BACKEND_PORT"
echo -e "${CYAN}[FRONTEND]${RESET} APP: http://127.0.0.1:$FRONTEND_PORT\n"
run_backend & BACKEND_PID=$!
run_frontend & FRONTEND_PID=$!
trap cleanup INT TERM EXIT
# Wait on both; if either exits, we exit (portable)
while true; do
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then break; fi
if ! kill -0 "$FRONTEND_PID" 2>/dev/null; then break; fi
sleep 1
done
}
main "$@"

13
scripts/setup_all.sh Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")"/.. && pwd)"
# Forward CLEAN and USE_SYSTEM_PYTHON to sub-scripts
export CLEAN="${CLEAN:-0}"
export USE_SYSTEM_PYTHON="${USE_SYSTEM_PYTHON:-0}"
"$REPO_ROOT/scripts/setup_backend.sh"
"$REPO_ROOT/scripts/setup_frontend.sh"
echo "[all] environments setup completed."

66
scripts/setup_backend.sh Executable file
View File

@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")"/.. && pwd)"
BACKEND_DIR="$REPO_ROOT/backend"
VENV_DIR="$REPO_ROOT/.venv"
PYTHON_BIN="python3"
# Allow override: USE_SYSTEM_PYTHON=1 to skip venv
USE_SYSTEM_PYTHON="${USE_SYSTEM_PYTHON:-0}"
# CLEAN=1 to recreate venv
CLEAN="${CLEAN:-0}"
echo "[backend] repo: $REPO_ROOT"
echo "[backend] dir: $BACKEND_DIR"
echo "[backend] checking Python..."
if command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python3)"
elif command -v python >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python)"
else
echo "[backend] ERROR: python3/python not found" >&2
exit 1
fi
echo "[backend] python: $PYTHON_BIN ($("$PYTHON_BIN" -V))"
if [[ "$USE_SYSTEM_PYTHON" == "1" ]]; then
echo "[backend] using system Python (no venv)"
else
if [[ "$CLEAN" == "1" && -d "$VENV_DIR" ]]; then
echo "[backend] CLEAN=1 -> removing existing venv: $VENV_DIR"
rm -rf "$VENV_DIR"
fi
if [[ ! -d "$VENV_DIR" ]]; then
echo "[backend] creating venv: $VENV_DIR"
"$PYTHON_BIN" -m venv "$VENV_DIR"
fi
# Activate venv
# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"
echo "[backend] venv activated: $VIRTUAL_ENV"
echo "[backend] upgrading pip/setuptools/wheel"
python -m pip install --upgrade pip setuptools wheel
fi
REQ_FILE="$BACKEND_DIR/requirements.txt"
if [[ ! -f "$REQ_FILE" ]]; then
echo "[backend] ERROR: requirements.txt not found at $REQ_FILE" >&2
exit 1
fi
echo "[backend] installing requirements: $REQ_FILE"
pip install -r "$REQ_FILE"
# Show versions
echo "[backend] python version: $(python -V)"
echo "[backend] pip version: $(python -m pip -V)"
if command -v uvicorn >/dev/null 2>&1; then
echo "[backend] uvicorn version: $(python -c 'import uvicorn,sys; print(uvicorn.__version__)' || echo unknown)"
else
echo "[backend] uvicorn not found; you may need it for dev server"
fi
echo "[backend] setup completed."

37
scripts/setup_frontend.sh Executable file
View File

@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")"/.. && pwd)"
FRONTEND_DIR="$REPO_ROOT/frontend"
CLEAN="${CLEAN:-0}"
cd "$FRONTEND_DIR"
echo "[frontend] repo: $REPO_ROOT"
echo "[frontend] dir: $FRONTEND_DIR"
if ! command -v node >/dev/null 2>&1; then
echo "[frontend] ERROR: node not found" >&2
exit 1
fi
if ! command -v npm >/dev/null 2>&1; then
echo "[frontend] ERROR: npm not found" >&2
exit 1
fi
echo "[frontend] node: $(node -v)"
echo "[frontend] npm: $(npm -v)"
if [[ "$CLEAN" == "1" ]]; then
echo "[frontend] CLEAN=1 -> removing node_modules and .next/.turbo"
rm -rf node_modules .next .turbo
fi
if [[ -f package-lock.json ]]; then
echo "[frontend] detected package-lock.json -> using npm ci"
npm ci
else
echo "[frontend] no lockfile -> using npm install"
npm install
fi
echo "[frontend] setup completed."

View File

@ -1,56 +0,0 @@
"""
测试脚本通过后端 API 检查是否能获取 300750.SZ tax_to_ebt 数据
"""
import requests
import json
def test_api():
# 假设后端运行在默认端口
url = "http://localhost:8000/api/financials/china/300750.SZ?years=5"
try:
print(f"正在请求 API: {url}")
response = requests.get(url, timeout=30)
if response.status_code == 200:
data = response.json()
print(f"\n✅ API 请求成功")
print(f"股票代码: {data.get('ts_code')}")
print(f"公司名称: {data.get('name')}")
# 检查 series 中是否有 tax_to_ebt
series = data.get('series', {})
if 'tax_to_ebt' in series:
print(f"\n✅ 找到 tax_to_ebt 数据!")
tax_data = series['tax_to_ebt']
print(f"数据条数: {len(tax_data)}")
print(f"\n最近几年的 tax_to_ebt 值:")
for item in tax_data[-5:]: # 显示最近5年
year = item.get('year')
value = item.get('value')
month = item.get('month')
month_str = f"Q{((month or 12) - 1) // 3 + 1}" if month else ""
print(f" {year}{month_str}: {value}")
else:
print(f"\n❌ 未找到 tax_to_ebt 数据")
print(f"可用字段: {list(series.keys())[:20]}...")
# 检查是否有其他税率相关字段
tax_keys = [k for k in series.keys() if 'tax' in k.lower()]
if tax_keys:
print(f"\n包含 'tax' 的字段: {tax_keys}")
else:
print(f"❌ API 请求失败: {response.status_code}")
print(f"响应内容: {response.text}")
except requests.exceptions.ConnectionError:
print("❌ 无法连接到后端服务,请确保后端正在运行(例如运行 python dev.py")
except Exception as e:
print(f"❌ 请求出错: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
test_api()

View File

@ -1,122 +0,0 @@
#!/usr/bin/env python3
"""
配置页面功能测试脚本
"""
import asyncio
import json
import sys
import os
# 添加项目根目录到Python路径
sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'backend'))
from app.services.config_manager import ConfigManager
from app.schemas.config import ConfigUpdateRequest, DatabaseConfig, GeminiConfig, DataSourceConfig
async def test_config_manager():
"""测试配置管理器功能"""
print("🧪 开始测试配置管理器...")
# 这里需要实际的数据库会话,暂时跳过
print("⚠️ 需要数据库连接,跳过实际测试")
print("✅ 配置管理器代码结构正确")
def test_config_validation():
"""测试配置验证功能"""
print("\n🔍 测试配置验证...")
# 测试数据库URL验证
valid_urls = [
"postgresql://user:pass@host:port/db",
"postgresql+asyncpg://user:pass@host:port/db"
]
invalid_urls = [
"mysql://user:pass@host:port/db",
"invalid-url",
""
]
for url in valid_urls:
if url.startswith(("postgresql://", "postgresql+asyncpg://")):
print(f"✅ 有效URL: {url}")
else:
print(f"❌ 应该有效但被拒绝: {url}")
for url in invalid_urls:
if not url.startswith(("postgresql://", "postgresql+asyncpg://")):
print(f"✅ 无效URL正确被拒绝: {url}")
else:
print(f"❌ 应该无效但被接受: {url}")
def test_api_key_validation():
"""测试API Key验证"""
print("\n🔑 测试API Key验证...")
valid_keys = ["1234567890", "abcdefghijklmnop"]
invalid_keys = ["123", "short", ""]
for key in valid_keys:
if len(key) >= 10:
print(f"✅ 有效API Key: {key[:10]}...")
else:
print(f"❌ 应该有效但被拒绝: {key}")
for key in invalid_keys:
if len(key) < 10:
print(f"✅ 无效API Key正确被拒绝: {key}")
else:
print(f"❌ 应该无效但被接受: {key}")
def test_config_export_import():
"""测试配置导入导出功能"""
print("\n📤 测试配置导入导出...")
# 模拟配置数据
config_data = {
"database": {"url": "postgresql://test:test@localhost:5432/test"},
"gemini_api": {"api_key": "test_key_1234567890", "base_url": "https://api.example.com"},
"data_sources": {
"tushare": {"api_key": "tushare_key_1234567890"},
"finnhub": {"api_key": "finnhub_key_1234567890"}
}
}
# 测试JSON序列化
try:
json_str = json.dumps(config_data, indent=2)
parsed = json.loads(json_str)
print("✅ 配置JSON序列化/反序列化正常")
# 验证必需字段
required_fields = ["database", "gemini_api", "data_sources"]
for field in required_fields:
if field in parsed:
print(f"✅ 包含必需字段: {field}")
else:
print(f"❌ 缺少必需字段: {field}")
except Exception as e:
print(f"❌ JSON处理失败: {e}")
def main():
"""主测试函数"""
print("🚀 配置页面功能测试")
print("=" * 50)
test_config_validation()
test_api_key_validation()
test_config_export_import()
print("\n" + "=" * 50)
print("✅ 所有测试完成!")
print("\n📋 测试总结:")
print("• 配置验证逻辑正确")
print("• API Key验证工作正常")
print("• 配置导入导出功能正常")
print("• 前端UI组件已创建")
print("• 后端API接口已实现")
print("• 错误处理机制已添加")
if __name__ == "__main__":
main()

View File

@ -1,82 +0,0 @@
#!/usr/bin/env python3
"""
测试员工数数据获取功能
"""
import asyncio
import sys
import os
import json
# 添加项目根目录到Python路径
sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'backend'))
from app.services.tushare_client import TushareClient
async def test_employees_data():
"""测试获取员工数数据"""
print("🧪 测试员工数数据获取...")
print("=" * 50)
# 从环境变量或配置文件读取 token
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
config_path = os.path.join(base_dir, 'config', 'config.json')
token = os.environ.get('TUSHARE_TOKEN')
if not token and os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
token = config.get('data_sources', {}).get('tushare', {}).get('api_key')
if not token:
print("❌ 未找到 Tushare token")
print("请设置环境变量 TUSHARE_TOKEN 或在 config/config.json 中配置")
return
print(f"✅ Token 已加载: {token[:10]}...")
# 测试股票代码
test_ts_code = "000001.SZ" # 平安银行
async with TushareClient(token=token) as client:
try:
print(f"\n📊 查询股票: {test_ts_code}")
print("调用 stock_company API...")
# 调用 stock_company API
data = await client.query(
api_name="stock_company",
params={"ts_code": test_ts_code, "limit": 10}
)
if data:
print(f"✅ 成功获取 {len(data)} 条记录")
print("\n返回的数据字段:")
if data:
for key in data[0].keys():
print(f" - {key}")
print("\n员工数相关字段:")
for row in data:
if 'employees' in row:
print(f" ✅ employees: {row.get('employees')}")
if 'employee' in row:
print(f" ✅ employee: {row.get('employee')}")
print("\n完整数据示例:")
print(json.dumps(data[0], indent=2, ensure_ascii=False))
else:
print("⚠️ 未返回数据")
except Exception as e:
print(f"❌ 错误: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
print("🚀 开始测试员工数数据获取功能\n")
asyncio.run(test_employees_data())
print("\n" + "=" * 50)
print("✅ 测试完成")

View File

@ -1,104 +0,0 @@
#!/usr/bin/env python3
"""
测试股东数数据获取功能
"""
import asyncio
import sys
import os
import json
from datetime import datetime, timedelta
# 添加项目根目录到Python路径
sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'backend'))
from app.services.tushare_client import TushareClient
async def test_holder_number_data():
"""测试获取股东数数据"""
print("🧪 测试股东数数据获取...")
print("=" * 50)
# 从环境变量或配置文件读取 token
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
config_path = os.path.join(base_dir, 'config', 'config.json')
token = os.environ.get('TUSHARE_TOKEN')
if not token and os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
token = config.get('data_sources', {}).get('tushare', {}).get('api_key')
if not token:
print("❌ 未找到 Tushare token")
print("请设置环境变量 TUSHARE_TOKEN 或在 config/config.json 中配置")
return
print(f"✅ Token 已加载: {token[:10]}...")
# 测试股票代码
test_ts_code = "000001.SZ" # 平安银行
years = 5 # 查询最近5年的数据
# 计算日期范围
end_date = datetime.now().strftime("%Y%m%d")
start_date = (datetime.now() - timedelta(days=years * 365)).strftime("%Y%m%d")
async with TushareClient(token=token) as client:
try:
print(f"\n📊 查询股票: {test_ts_code}")
print(f"📅 日期范围: {start_date}{end_date}")
print("调用 stk_holdernumber API...")
# 调用 stk_holdernumber API
data = await client.query(
api_name="stk_holdernumber",
params={
"ts_code": test_ts_code,
"start_date": start_date,
"end_date": end_date,
"limit": 5000
}
)
if data:
print(f"✅ 成功获取 {len(data)} 条记录")
print("\n返回的数据字段:")
if data:
for key in data[0].keys():
print(f" - {key}")
print("\n股东数数据:")
print("-" * 60)
for row in data[:10]: # 只显示前10条
end_date_val = row.get('end_date', 'N/A')
holder_num = row.get('holder_num', 'N/A')
print(f" 日期: {end_date_val}, 股东数: {holder_num}")
if len(data) > 10:
print(f" ... 还有 {len(data) - 10} 条记录")
print("\n完整数据示例(第一条):")
print(json.dumps(data[0], indent=2, ensure_ascii=False))
# 检查是否有 holder_num 字段
if data and 'holder_num' in data[0]:
print("\n✅ 成功获取 holder_num 字段数据")
else:
print("\n⚠️ 未找到 holder_num 字段")
else:
print("⚠️ 未返回数据")
except Exception as e:
print(f"❌ 错误: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
print("🚀 开始测试股东数数据获取功能\n")
asyncio.run(test_holder_number_data())
print("\n" + "=" * 50)
print("✅ 测试完成")

View File

@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""
测试股东数数据处理逻辑
"""
import asyncio
import sys
import os
import json
from datetime import datetime, timedelta
# 添加项目根目录到Python路径
sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'backend'))
from app.services.tushare_client import TushareClient
async def test_holder_num_processing():
"""测试股东数数据处理逻辑"""
print("🧪 测试股东数数据处理逻辑...")
print("=" * 50)
# 从环境变量或配置文件读取 token
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
config_path = os.path.join(base_dir, 'config', 'config.json')
token = os.environ.get('TUSHARE_TOKEN')
if not token and os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
token = config.get('data_sources', {}).get('tushare', {}).get('api_key')
if not token:
print("❌ 未找到 Tushare token")
return
ts_code = '000001.SZ'
years = 5
async with TushareClient(token=token) as client:
# 模拟后端处理逻辑
end_date = datetime.now().strftime('%Y%m%d')
start_date = (datetime.now() - timedelta(days=years * 365)).strftime('%Y%m%d')
print(f"📊 查询股票: {ts_code}")
print(f"📅 日期范围: {start_date}{end_date}")
data_rows = await client.query(
api_name='stk_holdernumber',
params={'ts_code': ts_code, 'start_date': start_date, 'end_date': end_date, 'limit': 5000}
)
print(f'\n✅ 获取到 {len(data_rows)} 条原始数据')
if data_rows:
print('\n原始数据示例前3条:')
for i, row in enumerate(data_rows[:3]):
print(f"{i+1}条: {json.dumps(row, indent=4, ensure_ascii=False)}")
# 模拟后端处理逻辑
series = {}
tmp = {}
date_field = 'end_date'
print('\n📝 开始处理数据...')
for row in data_rows:
date_val = row.get(date_field)
if not date_val:
print(f" ⚠️ 跳过无日期字段的行: {row}")
continue
year = str(date_val)[:4]
month = int(str(date_val)[4:6]) if len(str(date_val)) >= 6 else None
existing = tmp.get(year)
if existing is None or str(row.get(date_field)) > str(existing.get(date_field)):
tmp[year] = row
tmp[year]['_month'] = month
print(f'\n✅ 处理后共有 {len(tmp)} 个年份的数据')
print('按年份分组的数据:')
for year, row in sorted(tmp.items(), key=lambda x: x[0], reverse=True):
print(f" {year}: holder_num={row.get('holder_num')}, end_date={row.get('end_date')}")
# 提取 holder_num 字段
key = 'holder_num'
for year, row in tmp.items():
month = row.get('_month')
value = row.get(key)
arr = series.setdefault(key, [])
arr.append({'year': year, 'value': value, 'month': month})
print('\n📊 提取后的 series 数据:')
print(json.dumps(series, indent=2, ensure_ascii=False))
# 排序(模拟后端逻辑)
for key, arr in series.items():
uniq = {item['year']: item for item in arr}
arr_sorted_desc = sorted(uniq.values(), key=lambda x: x['year'], reverse=True)
arr_limited = arr_sorted_desc[:years]
arr_sorted = sorted(arr_limited, key=lambda x: x['year']) # ascending
series[key] = arr_sorted
print('\n✅ 最终排序后的数据(按年份升序):')
print(json.dumps(series, indent=2, ensure_ascii=False))
# 验证年份格式
print('\n🔍 验证年份格式:')
for item in series.get('holder_num', []):
year_str = item.get('year')
print(f" 年份: '{year_str}' (类型: {type(year_str).__name__}, 长度: {len(str(year_str))})")
if __name__ == "__main__":
asyncio.run(test_holder_num_processing())

View File

@ -1,110 +0,0 @@
"""
测试脚本检查是否能获取 300750.SZ tax_to_ebt 数据
"""
import asyncio
import sys
import os
import json
# 添加 backend 目录到 Python 路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
from app.services.tushare_client import TushareClient
async def test_tax_to_ebt():
# 读取配置获取 token
config_path = os.path.join(os.path.dirname(__file__), "..", "config", "config.json")
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
token = config.get("data_sources", {}).get("tushare", {}).get("api_key")
if not token:
print("错误:未找到 Tushare token")
return
client = TushareClient(token=token)
ts_code = "300750.SZ"
try:
print(f"正在查询 {ts_code} 的财务指标数据...")
# 先尝试不指定 fields获取所有字段
print("\n=== 测试1: 不指定 fields 参数 ===")
data = await client.query(
api_name="fina_indicator",
params={"ts_code": ts_code, "limit": 10}
)
# 再尝试明确指定 fields包含 tax_to_ebt
print("\n=== 测试2: 明确指定 fields 参数(包含 tax_to_ebt ===")
data_with_fields = await client.query(
api_name="fina_indicator",
params={"ts_code": ts_code, "limit": 10},
fields="ts_code,ann_date,end_date,tax_to_ebt,roe,roa"
)
print(f"\n获取到 {len(data)} 条记录")
if data:
# 检查第一条记录的字段
first_record = data[0]
print(f"\n第一条记录的字段:")
print(f" ts_code: {first_record.get('ts_code')}")
print(f" end_date: {first_record.get('end_date')}")
print(f" ann_date: {first_record.get('ann_date')}")
# 检查是否有 tax_to_ebt 字段
if 'tax_to_ebt' in first_record:
tax_value = first_record.get('tax_to_ebt')
print(f"\n✅ 找到 tax_to_ebt 字段!")
print(f" tax_to_ebt 值: {tax_value}")
print(f" tax_to_ebt 类型: {type(tax_value)}")
else:
print(f"\n❌ 未找到 tax_to_ebt 字段")
print(f"可用字段列表: {list(first_record.keys())[:20]}...") # 只显示前20个字段
# 打印所有包含 tax 的字段
tax_fields = [k for k in first_record.keys() if 'tax' in k.lower()]
if tax_fields:
print(f"\n包含 'tax' 的字段:")
for field in tax_fields:
print(f" {field}: {first_record.get(field)}")
# 显示最近几条记录的 tax_to_ebt 值
print(f"\n最近几条记录的 tax_to_ebt 值测试1:")
for i, record in enumerate(data[:5]):
end_date = record.get('end_date', 'N/A')
tax_value = record.get('tax_to_ebt', 'N/A')
print(f" {i+1}. {end_date}: tax_to_ebt = {tax_value}")
else:
print("❌ 未获取到任何数据测试1")
# 测试2检查明确指定 fields 的结果
if data_with_fields:
print(f"\n测试2获取到 {len(data_with_fields)} 条记录")
first_record2 = data_with_fields[0]
if 'tax_to_ebt' in first_record2:
print(f"✅ 测试2找到 tax_to_ebt 字段!")
print(f" tax_to_ebt 值: {first_record2.get('tax_to_ebt')}")
else:
print(f"❌ 测试2也未找到 tax_to_ebt 字段")
print(f"可用字段: {list(first_record2.keys())}")
print(f"\n最近几条记录的 tax_to_ebt 值测试2:")
for i, record in enumerate(data_with_fields[:5]):
end_date = record.get('end_date', 'N/A')
tax_value = record.get('tax_to_ebt', 'N/A')
print(f" {i+1}. {end_date}: tax_to_ebt = {tax_value}")
else:
print("❌ 未获取到任何数据测试2")
except Exception as e:
print(f"❌ 查询出错: {e}")
import traceback
traceback.print_exc()
finally:
await client.aclose()
if __name__ == "__main__":
asyncio.run(test_tax_to_ebt())