feat: 昨日快照API与前端卡片;注册orgs路由;多项优化
- backend(financial): 新增 /china/{ts_code}/snapshot API,返回昨日交易日的收盘价/市值/PE/PB/股息率等
- backend(schemas): 新增 TodaySnapshotResponse
- backend(main): 注册 orgs 路由 /api/v1/orgs
- backend(providers:finnhub): 归一化财报字段并计算 gross_margin/net_margin/ROA/ROE
- backend(providers:tushare): 股东户数报告期与财报期对齐
- backend(routers/financial): years 默认改为 10(最大 10)
- config: analysis-config.json 切换到 qwen-flash-2025-07-28
- frontend(report/[symbol]): 新增“昨日快照”卡片、限制展示期数为10、优化增长与阈值高亮、修正类名与标题处理
- frontend(reports/[id]): 统一 period 变量与计算,修正表格 key
- frontend(hooks): 新增 useChinaSnapshot 钩子与类型
- scripts: dev.sh 增加调试输出
This commit is contained in:
parent
3475138419
commit
edfd51b0a7
@ -102,7 +102,38 @@ class FinnhubProvider(BaseDataProvider):
|
|||||||
# e.g. 'AssetsTotal' -> 'total_assets'
|
# e.g. 'AssetsTotal' -> 'total_assets'
|
||||||
# This is a complex task and depends on the desired final schema.
|
# This is a complex task and depends on the desired final schema.
|
||||||
|
|
||||||
return all_reports
|
# We will now normalize and calculate derived metrics
|
||||||
|
normalized_reports = []
|
||||||
|
for report in all_reports:
|
||||||
|
normalized_report = {
|
||||||
|
"ts_code": report.get("ts_code"),
|
||||||
|
"end_date": report.get("end_date"),
|
||||||
|
# Balance Sheet
|
||||||
|
"total_assets": report.get("AssetsTotal"),
|
||||||
|
"total_liabilities": report.get("LiabilitiesTotal"),
|
||||||
|
"equity": report.get("StockholdersEquityTotal"),
|
||||||
|
# Income Statement
|
||||||
|
"revenue": report.get("RevenuesTotal"),
|
||||||
|
"net_income": report.get("NetIncomeLoss"),
|
||||||
|
"gross_profit": report.get("GrossProfit"),
|
||||||
|
# Cash Flow
|
||||||
|
"net_cash_flow_operating": report.get("NetCashFlowOperating"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate derived metrics
|
||||||
|
if normalized_report["revenue"] and normalized_report["revenue"] > 0:
|
||||||
|
normalized_report["gross_margin"] = (normalized_report["gross_profit"] / normalized_report["revenue"]) if normalized_report["gross_profit"] else None
|
||||||
|
normalized_report["net_margin"] = (normalized_report["net_income"] / normalized_report["revenue"]) if normalized_report["net_income"] else None
|
||||||
|
|
||||||
|
if normalized_report["total_assets"] and normalized_report["total_assets"] > 0:
|
||||||
|
normalized_report["roa"] = (normalized_report["net_income"] / normalized_report["total_assets"]) if normalized_report["net_income"] else None
|
||||||
|
|
||||||
|
if normalized_report["equity"] and normalized_report["equity"] > 0:
|
||||||
|
normalized_report["roe"] = (normalized_report["net_income"] / normalized_report["equity"]) if normalized_report["net_income"] else None
|
||||||
|
|
||||||
|
normalized_reports.append(normalized_report)
|
||||||
|
|
||||||
|
return normalized_reports
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Finnhub get_financial_statements failed for {stock_code}: {e}")
|
logger.error(f"Finnhub get_financial_statements failed for {stock_code}: {e}")
|
||||||
|
|||||||
@ -669,29 +669,10 @@ class TushareProvider(BaseDataProvider):
|
|||||||
"latest_ann_date": ann_date
|
"latest_ann_date": ann_date
|
||||||
}
|
}
|
||||||
|
|
||||||
# 筛选报告期:只取今年的最后一个报告期和往年的所有年报(12月31日)
|
# 使用与财务报表相同的报告期筛选逻辑
|
||||||
holder_available_dates = sorted(holder_by_period.keys(), reverse=True)
|
# 股东户数应该与财务报表的报告期时间点对应
|
||||||
|
|
||||||
holder_wanted_dates = []
|
|
||||||
|
|
||||||
# 今年的最新报告期
|
|
||||||
latest_current_year_holder = None
|
|
||||||
for d in holder_available_dates:
|
|
||||||
if d.startswith(current_year):
|
|
||||||
latest_current_year_holder = d
|
|
||||||
break
|
|
||||||
if latest_current_year_holder:
|
|
||||||
holder_wanted_dates.append(latest_current_year_holder)
|
|
||||||
|
|
||||||
# 往年的所有年报(12月31日)
|
|
||||||
previous_years_holder_reports = [
|
|
||||||
d for d in holder_available_dates if d.endswith("1231") and not d.startswith(current_year)
|
|
||||||
]
|
|
||||||
holder_wanted_dates.extend(previous_years_holder_reports)
|
|
||||||
|
|
||||||
# 生成系列数据,只包含筛选后的报告期
|
|
||||||
holder_series = []
|
holder_series = []
|
||||||
for end_date in sorted(holder_wanted_dates):
|
for end_date in wanted_dates:
|
||||||
if end_date in holder_by_period:
|
if end_date in holder_by_period:
|
||||||
data = holder_by_period[end_date]
|
data = holder_by_period[end_date]
|
||||||
holder_num = data["holder_num"]
|
holder_num = data["holder_num"]
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.routers.config import router as config_router
|
from app.routers.config import router as config_router
|
||||||
from app.routers.financial import router as financial_router
|
from app.routers.financial import router as financial_router
|
||||||
|
from app.routers.orgs import router as orgs_router
|
||||||
|
|
||||||
# Configure logging to ensure our app logs show up in development
|
# Configure logging to ensure our app logs show up in development
|
||||||
import sys
|
import sys
|
||||||
@ -50,6 +51,7 @@ app.add_middleware(
|
|||||||
# Routers
|
# Routers
|
||||||
app.include_router(config_router, prefix=f"{settings.API_V1_STR}/config", tags=["config"])
|
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.include_router(financial_router, prefix=f"{settings.API_V1_STR}/financials", tags=["financials"])
|
||||||
|
app.include_router(orgs_router, prefix=f"{settings.API_V1_STR}/orgs", tags=["orgs"])
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
|
|||||||
@ -18,7 +18,8 @@ from app.schemas.financial import (
|
|||||||
StepRecord,
|
StepRecord,
|
||||||
CompanyProfileResponse,
|
CompanyProfileResponse,
|
||||||
AnalysisResponse,
|
AnalysisResponse,
|
||||||
AnalysisConfigResponse
|
AnalysisConfigResponse,
|
||||||
|
TodaySnapshotResponse,
|
||||||
)
|
)
|
||||||
from app.services.company_profile_client import CompanyProfileClient
|
from app.services.company_profile_client import CompanyProfileClient
|
||||||
from app.services.analysis_client import AnalysisClient, load_analysis_config, get_analysis_config
|
from app.services.analysis_client import AnalysisClient, load_analysis_config, get_analysis_config
|
||||||
@ -259,7 +260,7 @@ async def get_financial_config():
|
|||||||
@router.get("/china/{ts_code}", response_model=BatchFinancialDataResponse)
|
@router.get("/china/{ts_code}", response_model=BatchFinancialDataResponse)
|
||||||
async def get_china_financials(
|
async def get_china_financials(
|
||||||
ts_code: str,
|
ts_code: str,
|
||||||
years: int = Query(5, ge=1, le=15),
|
years: int = Query(10, ge=1, le=10),
|
||||||
):
|
):
|
||||||
# Load metric config
|
# Load metric config
|
||||||
fin_cfg = _load_json(FINANCIAL_CONFIG_PATH)
|
fin_cfg = _load_json(FINANCIAL_CONFIG_PATH)
|
||||||
@ -723,6 +724,82 @@ async def generate_analysis(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/china/{ts_code}/snapshot", response_model=TodaySnapshotResponse)
|
||||||
|
async def get_today_snapshot(ts_code: str):
|
||||||
|
"""
|
||||||
|
获取“昨日快照”(以上一个自然日为基准,映射为不晚于该日的最近交易日)的市场数据:
|
||||||
|
- 日期(trade_date)
|
||||||
|
- 收盘价(close)
|
||||||
|
- 市值(total_mv,返回原始万元单位值)
|
||||||
|
- 估值(pe、pb)
|
||||||
|
- 股息率(dv_ratio,单位%)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 优先取公司名称(可选)
|
||||||
|
company_name = None
|
||||||
|
try:
|
||||||
|
basic = await get_dm().get_stock_basic(stock_code=ts_code)
|
||||||
|
if basic:
|
||||||
|
company_name = basic.get("name")
|
||||||
|
except Exception:
|
||||||
|
company_name = None
|
||||||
|
|
||||||
|
# 以“昨天”为查询日期,provider 内部会解析为“不晚于该日的最近交易日”
|
||||||
|
base_dt = (datetime.now() - timedelta(days=1)).date()
|
||||||
|
base_str = base_dt.strftime("%Y%m%d")
|
||||||
|
|
||||||
|
# 从 daily_basic 取主要字段,包含 close、pe、pb、dv_ratio、total_mv
|
||||||
|
rows = await get_dm().get_data(
|
||||||
|
'get_daily_basic_points',
|
||||||
|
stock_code=ts_code,
|
||||||
|
trade_dates=[base_str]
|
||||||
|
)
|
||||||
|
row = None
|
||||||
|
if isinstance(rows, list) and rows:
|
||||||
|
# get_daily_basic_points 返回每个交易日一条记录
|
||||||
|
row = rows[0]
|
||||||
|
|
||||||
|
trade_date = None
|
||||||
|
close = None
|
||||||
|
pe = None
|
||||||
|
pb = None
|
||||||
|
dv_ratio = None
|
||||||
|
total_mv = None
|
||||||
|
|
||||||
|
if isinstance(row, dict):
|
||||||
|
trade_date = str(row.get('trade_date') or row.get('trade_dt') or row.get('date') or base_str)
|
||||||
|
close = row.get('close')
|
||||||
|
pe = row.get('pe')
|
||||||
|
pb = row.get('pb')
|
||||||
|
dv_ratio = row.get('dv_ratio')
|
||||||
|
total_mv = row.get('total_mv')
|
||||||
|
|
||||||
|
# 若 close 缺失,兜底从 daily 取收盘价
|
||||||
|
if close is None:
|
||||||
|
d_rows = await get_dm().get_data('get_daily_points', stock_code=ts_code, trade_dates=[base_str])
|
||||||
|
if isinstance(d_rows, list) and d_rows:
|
||||||
|
d = d_rows[0]
|
||||||
|
close = d.get('close')
|
||||||
|
if trade_date is None:
|
||||||
|
trade_date = str(d.get('trade_date') or d.get('trade_dt') or d.get('date') or base_str)
|
||||||
|
|
||||||
|
if trade_date is None:
|
||||||
|
trade_date = base_str
|
||||||
|
|
||||||
|
return TodaySnapshotResponse(
|
||||||
|
ts_code=ts_code,
|
||||||
|
trade_date=trade_date,
|
||||||
|
name=company_name,
|
||||||
|
close=close,
|
||||||
|
pe=pe,
|
||||||
|
pb=pb,
|
||||||
|
dv_ratio=dv_ratio,
|
||||||
|
total_mv=total_mv,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to fetch snapshot: {e}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/china/{ts_code}/analysis/{analysis_type}/stream")
|
@router.get("/china/{ts_code}/analysis/{analysis_type}/stream")
|
||||||
async def stream_analysis(
|
async def stream_analysis(
|
||||||
ts_code: str,
|
ts_code: str,
|
||||||
|
|||||||
143
backend/app/routers/orgs.py
Normal file
143
backend/app/routers/orgs.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from typing import Dict
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||||
|
|
||||||
|
# Lazy loader for DataManager
|
||||||
|
_dm = None
|
||||||
|
def get_dm():
|
||||||
|
global _dm
|
||||||
|
if _dm is not None:
|
||||||
|
return _dm
|
||||||
|
try:
|
||||||
|
from app.data_manager import data_manager as real_dm
|
||||||
|
_dm = real_dm
|
||||||
|
return _dm
|
||||||
|
except Exception:
|
||||||
|
# Return a stub if the real one fails to import
|
||||||
|
class _StubDM:
|
||||||
|
async def get_stock_basic(self, stock_code: str): return None
|
||||||
|
async def get_financial_statements(self, stock_code: str, report_dates): return []
|
||||||
|
_dm = _StubDM()
|
||||||
|
return _dm
|
||||||
|
|
||||||
|
from app.services.analysis_client import AnalysisClient, load_analysis_config
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Constants for config paths
|
||||||
|
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||||
|
BASE_CONFIG_PATH = os.path.join(REPO_ROOT, "config", "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 {}
|
||||||
|
|
||||||
|
async def run_full_analysis(org_id: str):
|
||||||
|
"""
|
||||||
|
Asynchronous task to run a full analysis for a given stock.
|
||||||
|
This function is market-agnostic and relies on DataManager.
|
||||||
|
"""
|
||||||
|
logger.info(f"Starting full analysis task for {org_id}")
|
||||||
|
|
||||||
|
# 1. Load 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 key for {llm_provider} not configured. Aborting analysis for {org_id}.")
|
||||||
|
return
|
||||||
|
|
||||||
|
analysis_config_full = load_analysis_config()
|
||||||
|
modules_config = analysis_config_full.get("analysis_modules", {})
|
||||||
|
if not modules_config:
|
||||||
|
logger.error(f"Analysis modules configuration not found. Aborting analysis for {org_id}.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Fetch basic company info (name)
|
||||||
|
try:
|
||||||
|
basic_data = await get_dm().get_stock_basic(stock_code=org_id)
|
||||||
|
company_name = basic_data.get("name", org_id) if basic_data else org_id
|
||||||
|
logger.info(f"Got company name for {org_id}: {company_name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get company name for {org_id}. Using org_id as name. Error: {e}")
|
||||||
|
company_name = org_id
|
||||||
|
|
||||||
|
# 3. Fetch financial data
|
||||||
|
financial_data = None
|
||||||
|
try:
|
||||||
|
# You might want to make the date range configurable
|
||||||
|
from datetime import datetime
|
||||||
|
current_year = datetime.now().year
|
||||||
|
report_dates = [f"{year}1231" for year in range(current_year - 5, current_year)]
|
||||||
|
|
||||||
|
financial_statements = await get_dm().get_financial_statements(stock_code=org_id, report_dates=report_dates)
|
||||||
|
if financial_statements:
|
||||||
|
financial_data = {"series": financial_statements}
|
||||||
|
logger.info(f"Successfully fetched financial statements for {org_id}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Could not fetch financial statements for {org_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching financial data for {org_id}: {e}")
|
||||||
|
|
||||||
|
# 4. Execute analysis modules in order (simplified, assumes no complex dependencies for now)
|
||||||
|
# Note: A full implementation would need the topological sort from the financial router.
|
||||||
|
analysis_results = {}
|
||||||
|
for module_type, module_config in modules_config.items():
|
||||||
|
logger.info(f"Running analysis module: {module_type} for {org_id}")
|
||||||
|
client = AnalysisClient(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=base_url,
|
||||||
|
model=module_config.get("model", "gemini-1.5-flash")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simplified context: use results from all previously completed modules
|
||||||
|
context = analysis_results.copy()
|
||||||
|
|
||||||
|
result = await client.generate_analysis(
|
||||||
|
analysis_type=module_type,
|
||||||
|
company_name=company_name,
|
||||||
|
ts_code=org_id,
|
||||||
|
prompt_template=module_config.get("prompt_template", ""),
|
||||||
|
financial_data=financial_data,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
analysis_results[module_type] = result.get("content", "")
|
||||||
|
logger.info(f"Module {module_type} for {org_id} completed successfully.")
|
||||||
|
else:
|
||||||
|
logger.error(f"Module {module_type} for {org_id} failed: {result.get('error')}")
|
||||||
|
# Store error message to avoid breaking dependencies that might handle missing data
|
||||||
|
analysis_results[module_type] = f"Error: Analysis for {module_type} failed."
|
||||||
|
|
||||||
|
# 5. Save the final report
|
||||||
|
# TODO: Implement database logic to save the `analysis_results` to the report record.
|
||||||
|
logger.info(f"Full analysis for {org_id} finished. Results: {json.dumps(analysis_results, indent=2, ensure_ascii=False)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{market}/{org_id}/reports/generate")
|
||||||
|
async def trigger_report_generation(market: str, org_id: str, background_tasks: BackgroundTasks):
|
||||||
|
"""
|
||||||
|
Triggers a background task to generate a full financial report.
|
||||||
|
This endpoint is now market-agnostic.
|
||||||
|
"""
|
||||||
|
logger.info(f"Received report generation request for {org_id} in {market} market.")
|
||||||
|
|
||||||
|
# TODO: Create a report record in the database with "generating" status here.
|
||||||
|
|
||||||
|
background_tasks.add_task(run_full_analysis, org_id)
|
||||||
|
|
||||||
|
logger.info(f"Queued analysis task for {org_id}.")
|
||||||
|
return {"queued": True, "market": market, "org_id": org_id}
|
||||||
@ -71,3 +71,14 @@ class AnalysisResponse(BaseModel):
|
|||||||
|
|
||||||
class AnalysisConfigResponse(BaseModel):
|
class AnalysisConfigResponse(BaseModel):
|
||||||
analysis_modules: Dict[str, Dict]
|
analysis_modules: Dict[str, Dict]
|
||||||
|
|
||||||
|
|
||||||
|
class TodaySnapshotResponse(BaseModel):
|
||||||
|
ts_code: str
|
||||||
|
trade_date: str
|
||||||
|
name: Optional[str] = None
|
||||||
|
close: Optional[float] = None
|
||||||
|
pe: Optional[float] = None
|
||||||
|
pb: Optional[float] = None
|
||||||
|
dv_ratio: Optional[float] = None
|
||||||
|
total_mv: Optional[float] = None
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -22,8 +22,8 @@ markets:
|
|||||||
- yfinance # yfinance can be a fallback
|
- yfinance # yfinance can be a fallback
|
||||||
US: # US Market
|
US: # US Market
|
||||||
priority:
|
priority:
|
||||||
- yfinance
|
|
||||||
- finnhub
|
- finnhub
|
||||||
|
- yfinance
|
||||||
HK: # Hong Kong Market
|
HK: # Hong Kong Market
|
||||||
priority:
|
priority:
|
||||||
- yfinance
|
- yfinance
|
||||||
|
|||||||
@ -270,3 +270,5 @@ A:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -16,3 +16,5 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useParams, useSearchParams } from 'next/navigation';
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
import { useChinaFinancials, useFinancialConfig, useAnalysisConfig, generateFullAnalysis } from '@/hooks/useApi';
|
import { useChinaFinancials, useFinancialConfig, useAnalysisConfig, generateFullAnalysis, useChinaSnapshot } from '@/hooks/useApi';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CheckCircle, XCircle, RotateCw } from 'lucide-react';
|
import { CheckCircle, XCircle, RotateCw } from 'lucide-react';
|
||||||
@ -22,6 +22,8 @@ export default function ReportPage() {
|
|||||||
const symbol = params.symbol as string;
|
const symbol = params.symbol as string;
|
||||||
const market = (searchParams.get('market') || '').toLowerCase();
|
const market = (searchParams.get('market') || '').toLowerCase();
|
||||||
|
|
||||||
|
const displayMarket = market === 'china' ? '中国' : market;
|
||||||
|
|
||||||
const isChina = market === 'china' || market === 'cn';
|
const isChina = market === 'china' || market === 'cn';
|
||||||
|
|
||||||
// 规范化中国市场 ts_code:若为6位数字或无后缀,自动推断交易所
|
// 规范化中国市场 ts_code:若为6位数字或无后缀,自动推断交易所
|
||||||
@ -39,6 +41,7 @@ export default function ReportPage() {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
const { data: financials, error, isLoading } = useChinaFinancials(isChina ? normalizedTsCode : undefined, 10);
|
const { data: financials, error, isLoading } = useChinaFinancials(isChina ? normalizedTsCode : undefined, 10);
|
||||||
|
const { data: snapshot, error: snapshotError, isLoading: snapshotLoading } = useChinaSnapshot(isChina ? normalizedTsCode : undefined);
|
||||||
const { data: financialConfig } = useFinancialConfig();
|
const { data: financialConfig } = useFinancialConfig();
|
||||||
const { data: analysisConfig } = useAnalysisConfig();
|
const { data: analysisConfig } = useAnalysisConfig();
|
||||||
|
|
||||||
@ -271,6 +274,21 @@ export default function ReportPage() {
|
|||||||
return text;
|
return text;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const removeTitleFromContent = useMemo(() => {
|
||||||
|
return (content: string, title: string): string => {
|
||||||
|
if (!content || !title) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
const lines = content.split('\n');
|
||||||
|
// Trim and remove markdown from first line
|
||||||
|
const firstLine = (lines[0] || '').trim().replace(/^(#+\s*|\*\*|__)/, '').replace(/(\*\*|__)$/, '').trim();
|
||||||
|
if (firstLine === title) {
|
||||||
|
return lines.slice(1).join('\n').trim();
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const hasRunningTask = useMemo(() => {
|
const hasRunningTask = useMemo(() => {
|
||||||
if (currentAnalysisTask !== null) return true;
|
if (currentAnalysisTask !== null) return true;
|
||||||
@ -589,7 +607,7 @@ export default function ReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground min-w-20">交易市场:</span>
|
<span className="text-muted-foreground min-w-20">交易市场:</span>
|
||||||
<span className="font-medium">{market}</span>
|
<span className="font-medium">{displayMarket}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground min-w-20">公司名称:</span>
|
<span className="text-muted-foreground min-w-20">公司名称:</span>
|
||||||
@ -609,6 +627,94 @@ export default function ReportPage() {
|
|||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
{isChina && (
|
||||||
|
<Card className="w-80 flex-shrink-0">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">昨日快照</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm">
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground min-w-8">日期:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{snapshotLoading ? (
|
||||||
|
<span className="flex items-center gap-1"><Spinner className="size-3" /><span className="text-muted-foreground">加载中...</span></span>
|
||||||
|
) : snapshot?.trade_date ? (
|
||||||
|
`${snapshot.trade_date.slice(0,4)}-${snapshot.trade_date.slice(4,6)}-${snapshot.trade_date.slice(6,8)}`
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground min-w-8">PB:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{snapshotLoading ? (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
) : snapshot?.pb != null ? (
|
||||||
|
`${Number(snapshot.pb).toFixed(2)}`
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground min-w-8">股价:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{snapshotLoading ? (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
) : snapshot?.close != null ? (
|
||||||
|
`${Number(snapshot.close).toFixed(2)}`
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground min-w-8">PE:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{snapshotLoading ? (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
) : snapshot?.pe != null ? (
|
||||||
|
`${Number(snapshot.pe).toFixed(2)}`
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground min-w-8">市值:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{snapshotLoading ? (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
) : snapshot?.total_mv != null ? (
|
||||||
|
`${Math.round((snapshot.total_mv as number) / 10000).toLocaleString('zh-CN')} 亿元`
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground min-w-8">股息率:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{snapshotLoading ? (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
) : snapshot?.dv_ratio != null ? (
|
||||||
|
`${Number(snapshot.dv_ratio).toFixed(2)}%`
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{isChina && (
|
{isChina && (
|
||||||
<Card className="w-40 flex-shrink-0">
|
<Card className="w-40 flex-shrink-0">
|
||||||
<CardContent className="flex flex-col gap-2">
|
<CardContent className="flex flex-col gap-2">
|
||||||
@ -740,9 +846,27 @@ export default function ReportPage() {
|
|||||||
if (p.year) return `${p.year}1231`;
|
if (p.year) return `${p.year}1231`;
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const displayedKeys = [
|
||||||
|
'roe', 'roa', 'roic', 'grossprofit_margin', 'netprofit_margin', 'revenue', 'tr_yoy', 'n_income',
|
||||||
|
'dt_netprofit_yoy', 'n_cashflow_act', 'c_pay_acq_const_fiolta', '__free_cash_flow',
|
||||||
|
'dividend_amount', 'repurchase_amount', 'total_assets', 'total_hldr_eqy_exc_min_int', 'goodwill',
|
||||||
|
'__sell_rate', '__admin_rate', '__rd_rate', '__other_fee_rate', '__tax_rate', '__depr_ratio',
|
||||||
|
'__money_cap_ratio', '__inventories_ratio', '__ar_ratio', '__prepay_ratio', '__fix_assets_ratio',
|
||||||
|
'__lt_invest_ratio', '__goodwill_ratio', '__other_assets_ratio', '__ap_ratio', '__adv_ratio',
|
||||||
|
'__st_borr_ratio', '__lt_borr_ratio', '__operating_assets_ratio', '__interest_bearing_debt_ratio',
|
||||||
|
'invturn_days', 'arturn_days', 'payturn_days', 'fa_turn', 'assets_turn',
|
||||||
|
'employees', '__rev_per_emp', '__profit_per_emp', '__salary_per_emp',
|
||||||
|
'close', 'total_mv', 'pe', 'pb', 'holder_num'
|
||||||
|
];
|
||||||
|
|
||||||
|
const displayedSeries = Object.entries(series)
|
||||||
|
.filter(([key]) => displayedKeys.includes(key))
|
||||||
|
.map(([, value]) => value);
|
||||||
|
|
||||||
const allPeriods = Array.from(
|
const allPeriods = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
(Object.values(series).flat() as any[])
|
(displayedSeries.flat() as any[])
|
||||||
.map((p) => toPeriod(p))
|
.map((p) => toPeriod(p))
|
||||||
.filter((v): v is string => Boolean(v))
|
.filter((v): v is string => Boolean(v))
|
||||||
)
|
)
|
||||||
@ -751,7 +875,7 @@ export default function ReportPage() {
|
|||||||
if (allPeriods.length === 0) {
|
if (allPeriods.length === 0) {
|
||||||
return <p className="text-sm text-muted-foreground">暂无可展示的数据</p>;
|
return <p className="text-sm text-muted-foreground">暂无可展示的数据</p>;
|
||||||
}
|
}
|
||||||
const periods = allPeriods;
|
const periods = allPeriods.slice(0, 10);
|
||||||
|
|
||||||
const getValueByPeriod = (points: any[] | undefined, period: string): number | null => {
|
const getValueByPeriod = (points: any[] | undefined, period: string): number | null => {
|
||||||
if (!points) return null;
|
if (!points) return null;
|
||||||
@ -835,9 +959,40 @@ export default function ReportPage() {
|
|||||||
const isGrowthRow = key === 'tr_yoy' || key === 'dt_netprofit_yoy';
|
const isGrowthRow = key === 'tr_yoy' || key === 'dt_netprofit_yoy';
|
||||||
if (isGrowthRow) {
|
if (isGrowthRow) {
|
||||||
const isNeg = typeof perc === 'number' && perc < 0;
|
const isNeg = typeof perc === 'number' && perc < 0;
|
||||||
|
const isHighGrowth = typeof perc === 'number' && perc > 30;
|
||||||
|
|
||||||
|
let content = `${text}%`;
|
||||||
|
if (key === 'dt_netprofit_yoy' && typeof perc === 'number' && perc > 1000) {
|
||||||
|
content = `${(perc / 100).toFixed(1)}x`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tableCellClassName = 'text-right p-2';
|
||||||
|
let spanClassName = 'italic';
|
||||||
|
|
||||||
|
if (isNeg) {
|
||||||
|
tableCellClassName += ' bg-red-100';
|
||||||
|
spanClassName += ' text-red-600';
|
||||||
|
} else if (isHighGrowth) {
|
||||||
|
tableCellClassName += ' bg-green-100';
|
||||||
|
spanClassName += ' text-green-800 font-bold';
|
||||||
|
} else {
|
||||||
|
spanClassName += ' text-blue-600';
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableCell key={p} className="text-right p-2">
|
<TableCell key={p} className={tableCellClassName}>
|
||||||
<span className={isNeg ? 'text-red-600 bg-red-100 italic' : 'text-blue-600 italic'}>{text}%</span>
|
<span className={spanClassName}>{content}</span>
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const isHighlighted = (key === 'roe' && typeof perc === 'number' && perc > 12.5) ||
|
||||||
|
(key === 'grossprofit_margin' && typeof perc === 'number' && perc > 35) ||
|
||||||
|
(key === 'netprofit_margin' && typeof perc === 'number' && perc > 15);
|
||||||
|
|
||||||
|
if (isHighlighted) {
|
||||||
|
return (
|
||||||
|
<TableCell key={p} className="text-right p-2 bg-green-100 text-green-800 font-bold">
|
||||||
|
{`${text}%`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -926,9 +1081,18 @@ export default function ReportPage() {
|
|||||||
}
|
}
|
||||||
const text = numberFormatter.format(value);
|
const text = numberFormatter.format(value);
|
||||||
const isNegative = value < 0;
|
const isNegative = value < 0;
|
||||||
|
const isHighRatio = value > 30;
|
||||||
|
|
||||||
|
let cellClassName = "text-right p-2";
|
||||||
|
if (isHighRatio) {
|
||||||
|
cellClassName += " bg-red-100";
|
||||||
|
} else if (isNegative) {
|
||||||
|
cellClassName += " bg-red-100";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableCell key={keyStr} className="text-right p-2">
|
<TableCell key={keyStr} className={cellClassName}>
|
||||||
{isNegative ? <span className="text-red-600 bg-red-100">{text}%</span> : `${text}%`}
|
{isNegative ? <span className="text-red-600">{text}%</span> : `${text}%`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -1147,7 +1311,7 @@ export default function ReportPage() {
|
|||||||
),
|
),
|
||||||
// 股东户数
|
// 股东户数
|
||||||
(
|
(
|
||||||
<TableRow key="__holder_num_row" className="hover:bg紫-100 hover:bg-purple-100">
|
<TableRow key="__holder_num_row" className="hover:bg-purple-100">
|
||||||
<TableCell className="p-2 text-muted-foreground">股东户数</TableCell>
|
<TableCell className="p-2 text-muted-foreground">股东户数</TableCell>
|
||||||
{periods.map((p) => {
|
{periods.map((p) => {
|
||||||
const points = series['holder_num'] as any[] | undefined;
|
const points = series['holder_num'] as any[] | undefined;
|
||||||
@ -1174,10 +1338,11 @@ export default function ReportPage() {
|
|||||||
{/* 动态生成各个分析的TabsContent */}
|
{/* 动态生成各个分析的TabsContent */}
|
||||||
{analysisTypes.map(analysisType => {
|
{analysisTypes.map(analysisType => {
|
||||||
const state = analysisStates[analysisType] || { content: '', loading: false, error: null };
|
const state = analysisStates[analysisType] || { content: '', loading: false, error: null };
|
||||||
const normalizedContent = normalizeMarkdown(state.content);
|
|
||||||
const analysisName = analysisType === 'company_profile'
|
const analysisName = analysisType === 'company_profile'
|
||||||
? '公司简介'
|
? '公司简介'
|
||||||
: (analysisConfig?.analysis_modules[analysisType]?.name || analysisType);
|
: (analysisConfig?.analysis_modules[analysisType]?.name || analysisType);
|
||||||
|
const contentWithoutTitle = removeTitleFromContent(state.content, analysisName);
|
||||||
|
const normalizedContent = normalizeMarkdown(contentWithoutTitle);
|
||||||
const modelName = analysisConfig?.analysis_modules[analysisType]?.model;
|
const modelName = analysisConfig?.analysis_modules[analysisType]?.model;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -162,7 +162,7 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
|||||||
c_pay_acq_const_fiolta: 'cashflow',
|
c_pay_acq_const_fiolta: 'cashflow',
|
||||||
}
|
}
|
||||||
|
|
||||||
if (years.length === 0) {
|
if (periods.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
暂无保存的财务数据。下次保存报告时会一并保存财务数据。
|
暂无保存的财务数据。下次保存报告时会一并保存财务数据。
|
||||||
@ -250,7 +250,7 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
|||||||
if (isGrowthRow) {
|
if (isGrowthRow) {
|
||||||
const isNeg = typeof perc === 'number' && perc < 0
|
const isNeg = typeof perc === 'number' && perc < 0
|
||||||
return (
|
return (
|
||||||
<TableCell key={y} className="text-right p-2">
|
<TableCell key={p} className="text-right p-2">
|
||||||
<span className={isNeg ? 'text-red-600 bg-red-100 italic' : 'text-blue-600 italic'}>{text}%</span>
|
<span className={isNeg ? 'text-red-600 bg-red-100 italic' : 'text-blue-600 italic'}>{text}%</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)
|
)
|
||||||
@ -258,10 +258,10 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
|||||||
if (key === 'roe' || key === 'roic') {
|
if (key === 'roe' || key === 'roic') {
|
||||||
const highlight = typeof perc === 'number' && perc > 12
|
const highlight = typeof perc === 'number' && perc > 12
|
||||||
return (
|
return (
|
||||||
<TableCell key={y} className={`text-right p-2 ${highlight ? 'bg-green-200' : ''}`}>{`${text}%`}</TableCell>
|
<TableCell key={p} className={`text-right p-2 ${highlight ? 'bg-green-200' : ''}`}>{`${text}%`}</TableCell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <TableCell key={y} className="text-right p-2">{`${text}%`}</TableCell>
|
return <TableCell key={p} className="text-right p-2">{`${text}%`}</TableCell>
|
||||||
} else {
|
} else {
|
||||||
const isFinGroup = groupName === 'income' || groupName === 'balancesheet' || groupName === 'cashflow'
|
const isFinGroup = groupName === 'income' || groupName === 'balancesheet' || groupName === 'cashflow'
|
||||||
const scaled = key === 'total_mv' ? rawNum / 10000 : (isFinGroup || isComputed ? rawNum / 1e8 : rawNum)
|
const scaled = key === 'total_mv' ? rawNum / 10000 : (isFinGroup || isComputed ? rawNum / 1e8 : rawNum)
|
||||||
@ -270,10 +270,10 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
|||||||
if (key === '__free_cash_flow') {
|
if (key === '__free_cash_flow') {
|
||||||
const isNeg = typeof scaled === 'number' && scaled < 0
|
const isNeg = typeof scaled === 'number' && scaled < 0
|
||||||
return (
|
return (
|
||||||
<TableCell key={y} className="text-right p-2">{isNeg ? <span className="text-red-600 bg-red-100">{text}</span> : text}</TableCell>
|
<TableCell key={p} className="text-right p-2">{isNeg ? <span className="text-red-600 bg-red-100">{text}</span> : text}</TableCell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <TableCell key={y} className="text-right p-2">{text}</TableCell>
|
return <TableCell key={p} className="text-right p-2">{text}</TableCell>
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -333,8 +333,8 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
|||||||
rate = gp - np - sellRate - adminRate - rdRate
|
rate = gp - np - sellRate - adminRate - rdRate
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const numerator = getVal(num, y)
|
const numerator = getVal(num, p)
|
||||||
const denominator = getVal(den, y)
|
const denominator = getVal(den, p)
|
||||||
if (numerator == null || denominator == null || denominator === 0) {
|
if (numerator == null || denominator == null || denominator === 0) {
|
||||||
rate = null
|
rate = null
|
||||||
} else {
|
} else {
|
||||||
@ -342,12 +342,12 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (rate == null || !Number.isFinite(rate)) {
|
if (rate == null || !Number.isFinite(rate)) {
|
||||||
return <TableCell key={y} className="text-right p-2">-</TableCell>
|
return <TableCell key={p} className="text-right p-2">-</TableCell>
|
||||||
}
|
}
|
||||||
const rateText = numberFormatter.format(rate)
|
const rateText = numberFormatter.format(rate)
|
||||||
const isNegative = rate < 0
|
const isNegative = rate < 0
|
||||||
return (
|
return (
|
||||||
<TableCell key={y} className="text-right p-2">
|
<TableCell key={p} className="text-right p-2">
|
||||||
{isNegative ? <span className="text-red-600 bg-red-100">{rateText}%</span> : `${rateText}%`}
|
{isNegative ? <span className="text-red-600 bg-red-100">{rateText}%</span> : `${rateText}%`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)
|
)
|
||||||
@ -377,101 +377,101 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const assetRows = [
|
const assetRows = [
|
||||||
{ key: '__money_cap_ratio', label: '现金占比', calc: (y: string) => {
|
{ key: '__money_cap_ratio', label: '现金占比', calc: (p: string) => {
|
||||||
const num = getVal(series['money_cap'] as any, y)
|
const num = getVal(series['money_cap'] as any, p)
|
||||||
const den = getVal(series['total_assets'] as any, y)
|
const den = getVal(series['total_assets'] as any, p)
|
||||||
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
||||||
} },
|
} },
|
||||||
{ key: '__inventories_ratio', label: '库存占比', calc: (y: string) => {
|
{ key: '__inventories_ratio', label: '库存占比', calc: (p: string) => {
|
||||||
const num = getVal(series['inventories'] as any, y)
|
const num = getVal(series['inventories'] as any, p)
|
||||||
const den = getVal(series['total_assets'] as any, y)
|
const den = getVal(series['total_assets'] as any, p)
|
||||||
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
||||||
} },
|
} },
|
||||||
{ key: '__ar_ratio', label: '应收款占比', calc: (y: string) => {
|
{ key: '__ar_ratio', label: '应收款占比', calc: (p: string) => {
|
||||||
const num = getVal(series['accounts_receiv_bill'] as any, y)
|
const num = getVal(series['accounts_receiv_bill'] as any, p)
|
||||||
const den = getVal(series['total_assets'] as any, y)
|
const den = getVal(series['total_assets'] as any, p)
|
||||||
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
||||||
} },
|
} },
|
||||||
{ key: '__prepay_ratio', label: '预付款占比', calc: (y: string) => {
|
{ key: '__prepay_ratio', label: '预付款占比', calc: (p: string) => {
|
||||||
const num = getVal(series['prepayment'] as any, y)
|
const num = getVal(series['prepayment'] as any, p)
|
||||||
const den = getVal(series['total_assets'] as any, y)
|
const den = getVal(series['total_assets'] as any, p)
|
||||||
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
||||||
} },
|
} },
|
||||||
{ key: '__fix_assets_ratio', label: '固定资产占比', calc: (y: string) => {
|
{ key: '__fix_assets_ratio', label: '固定资产占比', calc: (p: string) => {
|
||||||
const num = getVal(series['fix_assets'] as any, y)
|
const num = getVal(series['fix_assets'] as any, p)
|
||||||
const den = getVal(series['total_assets'] as any, y)
|
const den = getVal(series['total_assets'] as any, p)
|
||||||
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
||||||
} },
|
} },
|
||||||
{ key: '__lt_invest_ratio', label: '长期投资占比', calc: (y: string) => {
|
{ key: '__lt_invest_ratio', label: '长期投资占比', calc: (p: string) => {
|
||||||
const num = getVal(series['lt_eqt_invest'] as any, y)
|
const num = getVal(series['lt_eqt_invest'] as any, p)
|
||||||
const den = getVal(series['total_assets'] as any, y)
|
const den = getVal(series['total_assets'] as any, p)
|
||||||
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
||||||
} },
|
} },
|
||||||
{ key: '__goodwill_ratio', label: '商誉占比', calc: (y: string) => {
|
{ key: '__goodwill_ratio', label: '商誉占比', calc: (p: string) => {
|
||||||
const num = getVal(series['goodwill'] as any, y)
|
const num = getVal(series['goodwill'] as any, p)
|
||||||
const den = getVal(series['total_assets'] as any, y)
|
const den = getVal(series['total_assets'] as any, p)
|
||||||
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
||||||
} },
|
} },
|
||||||
{ key: '__other_assets_ratio', label: '其他资产占比', calc: (y: string) => {
|
{ key: '__other_assets_ratio', label: '其他资产占比', calc: (p: string) => {
|
||||||
const total = getVal(series['total_assets'] as any, y)
|
const total = getVal(series['total_assets'] as any, p)
|
||||||
if (total == null || total === 0) return null
|
if (total == null || total === 0) return null
|
||||||
const parts = [
|
const parts = [
|
||||||
getVal(series['money_cap'] as any, y) || 0,
|
getVal(series['money_cap'] as any, p) || 0,
|
||||||
getVal(series['inventories'] as any, y) || 0,
|
getVal(series['inventories'] as any, p) || 0,
|
||||||
getVal(series['accounts_receiv_bill'] as any, y) || 0,
|
getVal(series['accounts_receiv_bill'] as any, p) || 0,
|
||||||
getVal(series['prepayment'] as any, y) || 0,
|
getVal(series['prepayment'] as any, p) || 0,
|
||||||
getVal(series['fix_assets'] as any, y) || 0,
|
getVal(series['fix_assets'] as any, p) || 0,
|
||||||
getVal(series['lt_eqt_invest'] as any, y) || 0,
|
getVal(series['lt_eqt_invest'] as any, p) || 0,
|
||||||
getVal(series['goodwill'] as any, y) || 0,
|
getVal(series['goodwill'] as any, p) || 0,
|
||||||
]
|
]
|
||||||
const sumKnown = parts.reduce((acc: number, v: number) => acc + v, 0)
|
const sumKnown = parts.reduce((acc: number, v: number) => acc + v, 0)
|
||||||
return ((total - sumKnown) / total) * 100
|
return ((total - sumKnown) / total) * 100
|
||||||
} },
|
} },
|
||||||
{ key: '__ap_ratio', label: '应付款占比', calc: (y: string) => {
|
{ key: '__ap_ratio', label: '应付款占比', calc: (p: string) => {
|
||||||
const num = getVal(series['accounts_pay'] as any, y)
|
const num = getVal(series['accounts_pay'] as any, p)
|
||||||
const den = getVal(series['total_assets'] as any, y)
|
const den = getVal(series['total_assets'] as any, p)
|
||||||
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
||||||
} },
|
} },
|
||||||
{ key: '__adv_ratio', label: '预收款占比', calc: (y: string) => {
|
{ key: '__adv_ratio', label: '预收款占比', calc: (p: string) => {
|
||||||
const adv = getVal(series['adv_receipts'] as any, y) || 0
|
const adv = getVal(series['adv_receipts'] as any, p) || 0
|
||||||
const contractLiab = getVal(series['contract_liab'] as any, y) || 0
|
const contractLiab = getVal(series['contract_liab'] as any, p) || 0
|
||||||
const num = adv + contractLiab
|
const num = adv + contractLiab
|
||||||
const den = getVal(series['total_assets'] as any, y)
|
const den = getVal(series['total_assets'] as any, p)
|
||||||
return den == null || den === 0 ? null : (num / den) * 100
|
return den == null || den === 0 ? null : (num / den) * 100
|
||||||
} },
|
} },
|
||||||
{ key: '__st_borr_ratio', label: '短期借款占比', calc: (y: string) => {
|
{ key: '__st_borr_ratio', label: '短期借款占比', calc: (p: string) => {
|
||||||
const num = getVal(series['st_borr'] as any, y)
|
const num = getVal(series['st_borr'] as any, p)
|
||||||
const den = getVal(series['total_assets'] as any, y)
|
const den = getVal(series['total_assets'] as any, p)
|
||||||
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
||||||
} },
|
} },
|
||||||
{ key: '__lt_borr_ratio', label: '长期借款占比', calc: (y: string) => {
|
{ key: '__lt_borr_ratio', label: '长期借款占比', calc: (p: string) => {
|
||||||
const num = getVal(series['lt_borr'] as any, y)
|
const num = getVal(series['lt_borr'] as any, p)
|
||||||
const den = getVal(series['total_assets'] as any, y)
|
const den = getVal(series['total_assets'] as any, p)
|
||||||
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
return num == null || den == null || den === 0 ? null : (num / den) * 100
|
||||||
} },
|
} },
|
||||||
{ key: '__interest_bearing_debt_ratio', label: '有息负债率', calc: (y: string) => {
|
{ key: '__interest_bearing_debt_ratio', label: '有息负债率', calc: (p: string) => {
|
||||||
const total = getVal(series['total_assets'] as any, y)
|
const total = getVal(series['total_assets'] as any, p)
|
||||||
if (total == null || total === 0) return null
|
if (total == null || total === 0) return null
|
||||||
const st = getVal(series['st_borr'] as any, y) || 0
|
const st = getVal(series['st_borr'] as any, p) || 0
|
||||||
const lt = getVal(series['lt_borr'] as any, y) || 0
|
const lt = getVal(series['lt_borr'] as any, p) || 0
|
||||||
return ((st + lt) / total) * 100
|
return ((st + lt) / total) * 100
|
||||||
} },
|
} },
|
||||||
{ key: '__operating_assets_ratio', label: '运营资产占比', calc: (y: string) => {
|
{ key: '__operating_assets_ratio', label: '运营资产占比', calc: (p: string) => {
|
||||||
const total = getVal(series['total_assets'] as any, y)
|
const total = getVal(series['total_assets'] as any, p)
|
||||||
if (total == null || total === 0) return null
|
if (total == null || total === 0) return null
|
||||||
const inv = getVal(series['inventories'] as any, y) || 0
|
const inv = getVal(series['inventories'] as any, p) || 0
|
||||||
const ar = getVal(series['accounts_receiv_bill'] as any, y) || 0
|
const ar = getVal(series['accounts_receiv_bill'] as any, p) || 0
|
||||||
const pre = getVal(series['prepayment'] as any, y) || 0
|
const pre = getVal(series['prepayment'] as any, p) || 0
|
||||||
const ap = getVal(series['accounts_pay'] as any, y) || 0
|
const ap = getVal(series['accounts_pay'] as any, p) || 0
|
||||||
const adv = getVal(series['adv_receipts'] as any, y) || 0
|
const adv = getVal(series['adv_receipts'] as any, p) || 0
|
||||||
const contractLiab = getVal(series['contract_liab'] as any, y) || 0
|
const contractLiab = getVal(series['contract_liab'] as any, p) || 0
|
||||||
const operating = inv + ar + pre - ap - adv - contractLiab
|
const operating = inv + ar + pre - ap - adv - contractLiab
|
||||||
return (operating / total) * 100
|
return (operating / total) * 100
|
||||||
} },
|
} },
|
||||||
].map(({ key, label, calc }) => (
|
].map(({ key, label, calc }) => (
|
||||||
<TableRow key={key} className={`hover:bg-purple-100 ${key === '__other_assets_ratio' ? 'bg-yellow-50' : ''}`}>
|
<TableRow key={key} className={`hover:bg-purple-100 ${key === '__other_assets_ratio' ? 'bg-yellow-50' : ''}`}>
|
||||||
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
|
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
|
||||||
{years.map((y) => ratioCell(calc(y), y))}
|
{periods.map((p) => ratioCell(calc(p), p))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|
||||||
@ -570,54 +570,54 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
|||||||
const employeesRow = (
|
const employeesRow = (
|
||||||
<TableRow key="__employees_row" className="hover:bg-purple-100">
|
<TableRow key="__employees_row" className="hover:bg-purple-100">
|
||||||
<TableCell className="p-2 text-muted-foreground">员工人数</TableCell>
|
<TableCell className="p-2 text-muted-foreground">员工人数</TableCell>
|
||||||
{years.map((y) => {
|
{periods.map((p) => {
|
||||||
const v = getVal(series['employees'] as any, y)
|
const v = getVal(series['employees'] as any, p)
|
||||||
if (v == null || !Number.isFinite(v)) {
|
if (v == null || !Number.isFinite(v)) {
|
||||||
return <TableCell key={y} className="text-right p-2">-</TableCell>
|
return <TableCell key={p} className="text-right p-2">-</TableCell>
|
||||||
}
|
}
|
||||||
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(v))}</TableCell>
|
return <TableCell key={p} className="text-right p-2">{integerFormatter.format(Math.round(v))}</TableCell>
|
||||||
})}
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
const revPerEmpRow = (
|
const revPerEmpRow = (
|
||||||
<TableRow key="__rev_per_emp_row" className="hover:bg-purple-100">
|
<TableRow key="__rev_per_emp_row" className="hover:bg-purple-100">
|
||||||
<TableCell className="p-2 text-muted-foreground">人均创收(万元)</TableCell>
|
<TableCell className="p-2 text-muted-foreground">人均创收(万元)</TableCell>
|
||||||
{years.map((y) => {
|
{periods.map((p) => {
|
||||||
const rev = getVal(series['revenue'] as any, y)
|
const rev = getVal(series['revenue'] as any, p)
|
||||||
const emp = getVal(series['employees'] as any, y)
|
const emp = getVal(series['employees'] as any, p)
|
||||||
if (rev == null || emp == null || emp === 0) {
|
if (rev == null || emp == null || emp === 0) {
|
||||||
return <TableCell key={y} className="text-right p-2">-</TableCell>
|
return <TableCell key={p} className="text-right p-2">-</TableCell>
|
||||||
}
|
}
|
||||||
const val = (rev / emp) / 10000
|
const val = (rev / emp) / 10000
|
||||||
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(val)}</TableCell>
|
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(val)}</TableCell>
|
||||||
})}
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
const profitPerEmpRow = (
|
const profitPerEmpRow = (
|
||||||
<TableRow key="__profit_per_emp_row" className="hover:bg-purple-100">
|
<TableRow key="__profit_per_emp_row" className="hover:bg-purple-100">
|
||||||
<TableCell className="p-2 text-muted-foreground">人均创利(万元)</TableCell>
|
<TableCell className="p-2 text-muted-foreground">人均创利(万元)</TableCell>
|
||||||
{years.map((y) => {
|
{periods.map((p) => {
|
||||||
const prof = getVal(series['n_income'] as any, y)
|
const prof = getVal(series['n_income'] as any, p)
|
||||||
const emp = getVal(series['employees'] as any, y)
|
const emp = getVal(series['employees'] as any, p)
|
||||||
if (prof == null || emp == null || emp === 0) {
|
if (prof == null || emp == null || emp === 0) {
|
||||||
return <TableCell key={y} className="text-right p-2">-</TableCell>
|
return <TableCell key={p} className="text-right p-2">-</TableCell>
|
||||||
}
|
}
|
||||||
const val = (prof / emp) / 10000
|
const val = (prof / emp) / 10000
|
||||||
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(val)}</TableCell>
|
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(val)}</TableCell>
|
||||||
})}
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
const salaryPerEmpRow = (
|
const salaryPerEmpRow = (
|
||||||
<TableRow key="__salary_per_emp_row" className="hover:bg-purple-100">
|
<TableRow key="__salary_per_emp_row" className="hover:bg-purple-100">
|
||||||
<TableCell className="p-2 text-muted-foreground">人均工资(万元)</TableCell>
|
<TableCell className="p-2 text-muted-foreground">人均工资(万元)</TableCell>
|
||||||
{years.map((y) => {
|
{periods.map((p) => {
|
||||||
const salaryPaid = getVal(series['c_paid_to_for_empl'] as any, y)
|
const salaryPaid = getVal(series['c_paid_to_for_empl'] as any, p)
|
||||||
const emp = getVal(series['employees'] as any, y)
|
const emp = getVal(series['employees'] as any, p)
|
||||||
if (salaryPaid == null || emp == null || emp === 0) {
|
if (salaryPaid == null || emp == null || emp === 0) {
|
||||||
return <TableCell key={y} className="text-right p-2">-</TableCell>
|
return <TableCell key={p} className="text-right p-2">-</TableCell>
|
||||||
}
|
}
|
||||||
const val = (salaryPaid / emp) / 10000
|
const val = (salaryPaid / emp) / 10000
|
||||||
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(val)}</TableCell>
|
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(val)}</TableCell>
|
||||||
})}
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { BatchFinancialDataResponse, FinancialConfigResponse, AnalysisConfigResponse } from '@/types';
|
import { BatchFinancialDataResponse, FinancialConfigResponse, AnalysisConfigResponse, TodaySnapshotResponse } from '@/types';
|
||||||
|
|
||||||
const fetcher = async (url: string) => {
|
const fetcher = async (url: string) => {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
@ -111,3 +111,16 @@ export async function generateFullAnalysis(tsCode: string, companyName: string)
|
|||||||
throw new Error('Invalid JSON response from server.');
|
throw new Error('Invalid JSON response from server.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useChinaSnapshot(ts_code?: string) {
|
||||||
|
return useSWR<TodaySnapshotResponse>(
|
||||||
|
ts_code ? `/api/financials/china/${encodeURIComponent(ts_code)}/snapshot` : null,
|
||||||
|
fetcher,
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateOnReconnect: false,
|
||||||
|
dedupingInterval: 120000, // 2分钟
|
||||||
|
errorRetryCount: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -38,3 +38,5 @@ if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -195,6 +195,20 @@ export interface AnalysisConfigResponse {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 今日快照响应接口
|
||||||
|
*/
|
||||||
|
export interface TodaySnapshotResponse {
|
||||||
|
ts_code: string;
|
||||||
|
trade_date: string; // YYYYMMDD
|
||||||
|
name?: string;
|
||||||
|
close?: number | null;
|
||||||
|
pe?: number | null;
|
||||||
|
pb?: number | null;
|
||||||
|
dv_ratio?: number | null; // %
|
||||||
|
total_mv?: number | null; // 万元
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 表格相关类型
|
// 表格相关类型
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -23,7 +23,9 @@ FRONTEND_PORT=3001
|
|||||||
# Kill process using specified port
|
# Kill process using specified port
|
||||||
kill_port() {
|
kill_port() {
|
||||||
local port=$1
|
local port=$1
|
||||||
|
echo -e "${YELLOW}[DEBUG]${RESET} Checking port $port..."
|
||||||
local pids=$(lsof -nP -ti tcp:"$port" 2>/dev/null || true)
|
local pids=$(lsof -nP -ti tcp:"$port" 2>/dev/null || true)
|
||||||
|
echo -e "${YELLOW}[DEBUG]${RESET} Done checking port $port. PIDs: '$pids'"
|
||||||
if [[ -n "$pids" ]]; then
|
if [[ -n "$pids" ]]; then
|
||||||
echo -e "${YELLOW}[CLEANUP]${RESET} Killing process(es) using port $port: $pids"
|
echo -e "${YELLOW}[CLEANUP]${RESET} Killing process(es) using port $port: $pids"
|
||||||
echo "$pids" | xargs kill -9 2>/dev/null || true
|
echo "$pids" | xargs kill -9 2>/dev/null || true
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user