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:
xucheng 2025-11-05 17:00:32 +08:00
parent 3475138419
commit edfd51b0a7
16 changed files with 575 additions and 130 deletions

View File

@ -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}")

View File

@ -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"]

View File

@ -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():

View File

@ -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返回原始万元单位值
- 估值pepb
- 股息率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
View 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}

View File

@ -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

View File

@ -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

View File

@ -270,3 +270,5 @@ A:

View File

@ -16,3 +16,5 @@

View File

@ -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();
@ -272,6 +275,21 @@ export default function ReportPage() {
}; };
}, []); }, []);
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;
if (analysisRecords.some(r => r.status === 'running')) return true; if (analysisRecords.some(r => r.status === 'running')) 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 (

View File

@ -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>
) )

View File

@ -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,
}
);
}

View File

@ -38,3 +38,5 @@ if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

View File

@ -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; // 万元
}
// ============================================================================ // ============================================================================
// 表格相关类型 // 表格相关类型
// ============================================================================ // ============================================================================

View File

@ -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