fix: preserve dashboard snapshot integrity after main sync

This commit is contained in:
ZhuLinsen
2026-08-30 21:01:54 +08:00
26 changed files with 10593 additions and 7268 deletions

View File

@@ -5,9 +5,17 @@ API v1 模块初始化
===================================
职责:
1. 导出 v1 版本 API 的路由
1. 延迟导出 v1 版本 API 的路由,避免 schema import 触发整棵 endpoint 导入树
"""
from api.v1.router import router as api_v1_router
from typing import Any
__all__ = ["api_v1_router"]
def __getattr__(name: str) -> Any:
if name != "api_v1_router":
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from api.v1.router import router
return router

View File

@@ -24,6 +24,7 @@ from api.v1.schemas.stocks import (
ExtractItem,
KLineData,
StockHistoryResponse,
StockProfileResponse,
StockQuote,
)
from api.v1.schemas.history import WatchlistRequest, WatchlistResponse
@@ -39,6 +40,8 @@ from src.services.import_parser import (
parse_import_from_text,
)
from src.services.stock_service import StockService
from src.services.stock_profile_service import InvalidStockProfileCode, StockProfileService
from src.services.run_diagnostics import sanitize_diagnostic_text
from src.services.stock_list_parser import split_stock_list
from src.services.system_config_service import SystemConfigService
from data_provider.base import normalize_stock_code
@@ -82,6 +85,9 @@ _STOCK_CODE_RE = re.compile(
r"|\d{1,5}\.HK" # HK suffix format
r"|HK\d{1,5}" # HK prefix format
r"|\d{5}" # bare 5-digit HK code
r"|\d{4,5}\.T" # Japan Yahoo suffix format
r"|\d{6}\.(?:KS|KQ)" # Korea Yahoo suffix format
r"|\d{4,6}\.(?:TW|TWO)" # Taiwan Yahoo suffix format
r"|[A-Z]{1,5}(?:\.(?:US|[A-Z]))?" # US ticker
r")$",
re.IGNORECASE,
@@ -405,6 +411,40 @@ def remove_from_watchlist(
)
@router.get(
"/{stock_code}/profile",
response_model=StockProfileResponse,
responses={
400: {"description": "股票代码无效", "model": ErrorResponse},
500: {"description": "服务器错误", "model": ErrorResponse},
},
summary="获取个股研究聚合档案",
description="按独立质量状态聚合行情、历史、研究产物、资讯、持仓关系和监控规则。",
)
def get_stock_profile(
stock_code: str,
history_days: int = Query(60, ge=1, le=365, description="日线历史天数"),
) -> StockProfileResponse:
"""Return partial profile data without failing on one optional block."""
_validate_and_normalize_stock_code(stock_code)
try:
return StockProfileResponse(
**StockProfileService().get_profile(stock_code, history_days=history_days)
)
except InvalidStockProfileCode:
raise HTTPException(
status_code=400,
detail={"error": "invalid_stock_code", "message": "股票代码与交易所不匹配"},
)
except Exception as exc:
sanitized = sanitize_diagnostic_text(str(exc), max_length=300) or "internal profile error"
logger.error("获取个股研究聚合档案失败: %s", sanitized)
raise HTTPException(
status_code=500,
detail={"error": "internal_error", "message": "获取个股研究聚合档案失败"},
)
@router.get(
"/{stock_code}/quote",
response_model=StockQuote,

View File

@@ -48,6 +48,7 @@ from api.v1.schemas.stocks import (
StockQuote,
StockHistoryResponse,
KLineData,
StockProfileResponse,
)
from api.v1.schemas.backtest import (
BacktestRunRequest,
@@ -185,6 +186,7 @@ __all__ = [
"StockQuote",
"StockHistoryResponse",
"KLineData",
"StockProfileResponse",
# backtest
"BacktestRunRequest",
"BacktestRunResponse",

View File

@@ -9,10 +9,16 @@
2. 定义历史 K 线数据模型
"""
from typing import Optional, List
from typing import Dict, Literal, Optional, List
from pydantic import BaseModel, ConfigDict, Field
from api.v1.schemas.history import HistoryItem
from api.v1.schemas.intelligence import IntelligenceItem
from api.v1.schemas.research_artifact import ResearchArtifact
StockProfileStatus = Literal["fresh", "partial", "unavailable"]
class StockQuote(BaseModel):
"""股票实时行情"""
@@ -106,3 +112,77 @@ class StockHistoryResponse(BaseModel):
"data": []
}
})
class StockProfileQuoteBlock(BaseModel):
status: StockProfileStatus
data: Optional[StockQuote] = None
limitations: List[str] = Field(default_factory=list)
class StockProfileHistoryBlock(BaseModel):
status: StockProfileStatus
period: Literal["daily"] = "daily"
data: List[KLineData] = Field(default_factory=list)
limitations: List[str] = Field(default_factory=list)
class StockProfileResearchData(BaseModel):
latest_report: Optional[HistoryItem] = None
recent_reports: List[HistoryItem] = Field(default_factory=list)
structured_report: Optional[ResearchArtifact] = None
class StockProfileResearchBlock(BaseModel):
status: StockProfileStatus
data: StockProfileResearchData = Field(default_factory=StockProfileResearchData)
limitations: List[str] = Field(default_factory=list)
class StockProfileIntelligenceBlock(BaseModel):
status: StockProfileStatus
items: List[IntelligenceItem] = Field(default_factory=list)
limitations: List[str] = Field(default_factory=list)
class StockProfilePortfolioRelation(BaseModel):
held: bool = False
matched_markets: List[str] = Field(default_factory=list)
class StockProfilePortfolioBlock(BaseModel):
status: StockProfileStatus
data: StockProfilePortfolioRelation = Field(default_factory=StockProfilePortfolioRelation)
limitations: List[str] = Field(default_factory=list)
class StockProfileMonitorData(BaseModel):
total_rule_count: int = 0
enabled_rule_count: int = 0
rule_ids: List[int] = Field(default_factory=list)
class StockProfileMonitorBlock(BaseModel):
status: StockProfileStatus
data: StockProfileMonitorData = Field(default_factory=StockProfileMonitorData)
limitations: List[str] = Field(default_factory=list)
class StockProfileEvidenceQuality(BaseModel):
status: StockProfileStatus
blocks: Dict[str, StockProfileStatus] = Field(default_factory=dict)
limitations: List[str] = Field(default_factory=list)
class StockProfileResponse(BaseModel):
requested_code: str
canonical_code: str
market: Literal["cn", "hk", "us", "jp", "kr", "tw"]
as_of: str
quote: StockProfileQuoteBlock
history: StockProfileHistoryBlock
research: StockProfileResearchBlock
intelligence: StockProfileIntelligenceBlock
portfolio: StockProfilePortfolioBlock
monitors: StockProfileMonitorBlock
evidence_quality: StockProfileEvidenceQuality

View File

@@ -33,6 +33,7 @@ import {
type ScreeningHotspotDetail,
type ScreeningHotspot,
type ScreeningHotspotsResponse,
type ScreeningRunSummary,
type ScreeningScreenResponse,
type ScreeningScreenTaskStatus,
type ScreeningStrategy,
@@ -64,11 +65,39 @@ const formatStrategyCategory = (value?: string) => {
type PersistedScreenTask = {
taskId: string;
runId?: string;
market: string;
strategy: string;
maxResults: number;
};
const formatRunCreatedAt = (value: string | null | undefined) => {
if (!value) {
return '时间未知';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return parsed.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
};
// 历史条目里展示筛选条件:策略 ID → 中文名(找不到时回退到原始 ID
const formatHistoryStrategyName = (strategyId: string, strategies: ScreeningStrategy[]): string => {
const matched = strategies.find((item) => item.id === strategyId);
return matched?.name || matched?.title || strategyId || '未知策略';
};
// 历史条目里展示筛选条件:市场 ID → 中文标签
const formatHistoryMarketLabel = (marketId: string | null | undefined): string =>
MARKETS.find((item) => item.id === marketId)?.label || marketId || 'cn';
const readPersistedScreenTask = (): PersistedScreenTask | null => {
if (typeof window === 'undefined') {
return null;
@@ -85,6 +114,7 @@ const readPersistedScreenTask = (): PersistedScreenTask | null => {
const restoredMaxResults = Number(parsed.maxResults);
return {
taskId: parsed.taskId,
runId: typeof parsed.runId === 'string' && parsed.runId.trim() ? parsed.runId : undefined,
market: typeof parsed.market === 'string' && parsed.market.trim() ? parsed.market : 'cn',
strategy: typeof parsed.strategy === 'string' && parsed.strategy.trim() ? parsed.strategy : 'dual_low',
maxResults: Number.isFinite(restoredMaxResults) ? Math.min(100, Math.max(1, restoredMaxResults)) : 3,
@@ -818,6 +848,7 @@ const StockScreeningPage: React.FC = () => {
const selectedHotspotTopicRef = useRef<string | null>(null);
const hotspotDetailRequestIdRef = useRef(0);
const hotspotDetailsByTopicRef = useRef<Record<string, ScreeningHotspotDetail>>({});
const historyRunRequestIdRef = useRef(0);
const [hotspotDetail, setHotspotDetail] = useState<ScreeningHotspotDetail | null>(null);
const [loadingHotspotDetail, setLoadingHotspotDetail] = useState(false);
const [searchingHotspotNews, setSearchingHotspotNews] = useState(false);
@@ -828,6 +859,14 @@ const StockScreeningPage: React.FC = () => {
const [expandedCode, setExpandedCode] = useState<string | null>(null);
const [loading, setLoading] = useState(Boolean(restoredTask?.taskId));
const [enabling, setEnabling] = useState(false);
const [historyRuns, setHistoryRuns] = useState<ScreeningRunSummary[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState('');
const [restoreResolved, setRestoreResolved] = useState(() => !restoredTask?.runId);
// 标记当前 strategy 是否来自历史 run刷新自动恢复或手动历史选择的上下文同步。
// 为 true 时 loadStrategies 跳过“不在列表则回退第一项”的归一化,
// 防止迟到的 /strategies 响应把历史上下文改写回默认策略。
const historyContextStrategyRef = useRef(false);
const [loadingStrategies, setLoadingStrategies] = useState(false);
const [error, setError] = useState('');
const [strategyLoadError, setStrategyLoadError] = useState('');
@@ -863,6 +902,74 @@ const StockScreeningPage: React.FC = () => {
setExpandedCode(nextCandidates[0]?.code ?? null);
}, []);
const loadHistory = useCallback(async () => {
setHistoryLoading(true);
setHistoryError('');
try {
const result = await screeningApi.getHistory({ limit: 10 });
setHistoryRuns(result.runs || []);
} catch (err) {
setHistoryError(toApiErrorMessage(err, '历史记录加载失败'));
} finally {
setHistoryLoading(false);
}
}, []);
const handleHistoryRunSelect = useCallback(async (runId: string) => {
// 竞态防护:快速切换历史条目时,只应用最新一次请求的响应
const requestId = historyRunRequestIdRef.current + 1;
historyRunRequestIdRef.current = requestId;
const isCurrentRequest = () => historyRunRequestIdRef.current === requestId;
// 与运行中的选股任务互斥:手动选择历史记录后,暂停/取消后台任务轮询,
// 避免任务完成后把当前任务的候选结果回写到历史上下文中。
setActiveTaskId(null);
setHistoryError('');
setLoading(true);
try {
const detail = await screeningApi.getRun(runId);
if (!isCurrentRequest()) {
return;
}
if (detail?.result) {
applyScreenResult(detail.result);
historyContextStrategyRef.current = true;
// 同步持久化恢复指针:刷新后应恢复用户刚选中的历史 run
// 而不是停留在更早的 task/run。历史详情不携带 taskId以 runId
// 作为占位——正常路径刷新走 getRun(runId) 恢复、不会触发轮询;
// 若该 run 恢复失败,占位轮询会命中不可恢复错误并清理过期指针。
persistScreenTask({
taskId: runId,
runId,
market: detail.market || market,
strategy: detail.strategy || strategy,
maxResults,
});
// 同步历史 run 的策略与市场上下文,确保结果区文案和后续深度分析
// 使用该历史 run 对应的 strategy/market而不是当前表单的选择
if (detail.strategy) {
setStrategy(detail.strategy);
}
if (detail.market) {
setMarket(detail.market);
}
setError('');
setTaskProgress(100);
setTaskMessage('已加载历史选股结果');
} else {
setError('历史记录中未找到该次运行的结果。');
}
} catch (err) {
if (!isCurrentRequest()) {
return;
}
setError(toApiErrorMessage(err, '历史结果加载失败'));
} finally {
if (isCurrentRequest()) {
setLoading(false);
}
}
}, [applyScreenResult, market, maxResults, strategy]);
const clearScreeningResults = () => {
setCandidates([]);
setScreenMeta(null);
@@ -942,7 +1049,9 @@ const StockScreeningPage: React.FC = () => {
const result = await screeningApi.getStrategies();
const loadedStrategies = result.strategies || [];
setStrategies(loadedStrategies);
if (loadedStrategies.length > 0) {
// 历史 run 的自定义/下线策略不在当前列表属预期行为,不做“回退第一项”的归一化,
// 否则迟到的策略列表会把刚恢复好的上下文改写回默认策略。
if (loadedStrategies.length > 0 && !historyContextStrategyRef.current) {
setStrategy((currentStrategy) =>
loadedStrategies.some((item) => item.id === currentStrategy) ? currentStrategy : loadedStrategies[0].id,
);
@@ -1083,6 +1192,7 @@ const StockScreeningPage: React.FC = () => {
if (status.enabled && status.available) {
void loadStrategies();
void loadHotspots(false);
void loadHistory();
}
})
.catch(() => {
@@ -1096,8 +1206,67 @@ const StockScreeningPage: React.FC = () => {
};
}, [loadHotspots, loadStrategies]);
// 刷新后优先从 history API 按 run_id 恢复结果;恢复失败再回退到 task 轮询
useEffect(() => {
if (!activeTaskId) {
const runId = restoredTask?.runId;
if (!runId) {
setRestoreResolved(true);
return undefined;
}
let active = true;
setLoading(true);
// 记录自动恢复的请求基准:若在自动恢复返回前用户手动点开了历史记录
// historyRunRequestIdRef 被 handleHistoryRunSelect 递增),则放弃本次自动恢复响应,
// 避免较晚返回的自动恢复把页面切回旧 run覆盖用户最新一次的历史选择。
const restoreRequestBase = historyRunRequestIdRef.current;
screeningApi
.getRun(runId)
.then((detail) => {
if (!active || historyRunRequestIdRef.current !== restoreRequestBase) {
return;
}
if (detail?.result) {
applyScreenResult(detail.result);
historyContextStrategyRef.current = true;
// 同步恢复该历史 run 的策略与市场上下文,避免结果区展示和
// 深度分析沿用当前表单策略(与 handleHistoryRunSelect 一致)
if (detail.strategy) {
setStrategy(detail.strategy);
}
if (detail.market) {
setMarket(detail.market);
}
setError('');
setTaskProgress(100);
setTaskMessage('已从历史记录恢复上次选股结果');
setActiveTaskId(null);
}
})
.catch(() => {
// 历史记录恢复失败run 不存在或服务重启),回退到 task 轮询;
// 若用户已手动选择了历史记录,则不再回退,保持用户的选择。
if (active && historyRunRequestIdRef.current === restoreRequestBase) {
setActiveTaskId(restoredTask?.taskId ?? null);
}
})
.finally(() => {
// 无论结果是否过期都要解除自动恢复门闩,否则新任务的轮询会被阻塞到旧请求超时。
if (active) {
setRestoreResolved(true);
}
// 过期的自动恢复不得触碰共享 loading用户手动选择的历史详情请求可能仍在飞行
// 提前清掉会重新放开“运行选股”入口,随后迟到的历史响应会覆盖新任务状态。
if (active && historyRunRequestIdRef.current === restoreRequestBase) {
setLoading(false);
}
});
return () => {
active = false;
};
}, [applyScreenResult, restoredTask]);
useEffect(() => {
if (!activeTaskId || !restoreResolved) {
return undefined;
}
@@ -1106,7 +1275,6 @@ const StockScreeningPage: React.FC = () => {
let timer: ReturnType<typeof window.setTimeout> | undefined;
function finishTask() {
clearPersistedScreenTask();
setActiveTaskId(null);
setLoading(false);
}
@@ -1120,6 +1288,17 @@ const StockScreeningPage: React.FC = () => {
if (task.result) {
applyScreenResult(task.result);
setError('');
// 持久化 runId刷新后优先从 history API 恢复结果,而非依赖内存 task
const completedRunId = task.result.runId || screenMeta?.runId;
if (completedRunId) {
persistScreenTask({
taskId: pollingTaskId,
runId: completedRunId,
market,
strategy,
maxResults,
});
}
} else {
setError('选股任务已完成,但服务端未返回候选结果。');
setCandidates([]);
@@ -1134,6 +1313,7 @@ const StockScreeningPage: React.FC = () => {
setScreenMeta(null);
setExpandedCode(null);
setError(formatScreenTaskFailure(task.error || task.message));
clearPersistedScreenTask();
finishTask();
return;
}
@@ -1145,6 +1325,7 @@ const StockScreeningPage: React.FC = () => {
}
setError(`选股任务返回未知状态:${task.status || 'unknown'}`);
clearPersistedScreenTask();
finishTask();
}
@@ -1164,6 +1345,7 @@ const StockScreeningPage: React.FC = () => {
setError(formatParsedApiError(parsedError) || '选股任务不可恢复,请重新提交。');
setCandidates([]);
setScreenMeta(null);
clearPersistedScreenTask();
finishTask();
return;
}
@@ -1181,7 +1363,7 @@ const StockScreeningPage: React.FC = () => {
window.clearTimeout(timer);
}
};
}, [activeTaskId, applyScreenResult]);
}, [activeTaskId, applyScreenResult, restoreResolved]);
const handleEnable = async () => {
setEnabling(true);
@@ -1228,6 +1410,11 @@ const StockScreeningPage: React.FC = () => {
};
const handleSubmit = async () => {
// 新任务提交即代表用户放弃当前历史/恢复上下文:
// 递增请求代号作废飞行中的历史详情与自动恢复响应;
// 解除自动恢复门闩,保证刚提交的任务轮询立即可启动。
historyRunRequestIdRef.current += 1;
setRestoreResolved(true);
setLoading(true);
setError('');
setScreenMeta(null);
@@ -1880,6 +2067,59 @@ const StockScreeningPage: React.FC = () => {
)}
</section>
) : null}
<section className="rounded-2xl border border-border/80 bg-card/95 p-4 shadow-soft-card">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Clock3 className="h-4 w-4 text-cyan" />
</div>
<button
type="button"
className="text-xs font-medium text-cyan transition-colors hover:text-foreground"
onClick={() => void loadHistory()}
disabled={historyLoading}
>
{historyLoading ? '加载中...' : '刷新'}
</button>
</div>
{historyError ? (
<p className="mb-3 text-xs text-danger">{historyError}</p>
) : null}
{historyRuns.length === 0 ? (
<p className="py-3 text-center text-xs text-secondary-text">
{historyLoading ? '正在加载历史记录...' : '暂无历史选股记录'}
</p>
) : (
<div className="divide-y divide-border/70">
{historyRuns.map((run) => (
<button
key={run.runId}
type="button"
className="flex w-full items-center justify-between gap-3 py-2.5 text-left transition-colors hover:bg-hover/50"
onClick={() => void handleHistoryRunSelect(run.runId)}
>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-foreground">
{formatHistoryStrategyName(run.strategy, strategies)}
<span className="ml-2 text-xs font-normal text-secondary-text">
{formatHistoryMarketLabel(run.market)}
</span>
</span>
<span className="mt-0.5 block text-xs text-secondary-text">
{run.candidateCount ?? 0}
{run.snapshotCount != null ? ` · 快照 ${run.snapshotCount}` : ''}
{run.llmRanked ? ' · 智能重排' : ''}
</span>
</span>
<span className="shrink-0 text-xs text-secondary-text">
{formatRunCreatedAt(run.createdAt)}
</span>
</button>
))}
</div>
)}
</section>
</AppPage>
);
};

View File

@@ -5,6 +5,8 @@ import StockScreeningPage from '../StockScreeningPage';
const {
enableScreening,
getHistory,
getRun,
getScreeningStatus,
getHotspotDetail,
getHotspots,
@@ -42,6 +44,8 @@ const {
});
return {
enableScreening: vi.fn(),
getHistory: vi.fn(),
getRun: vi.fn(),
getScreeningStatus: vi.fn(),
getHotspotDetail: vi.fn(),
getHotspots: vi.fn(),
@@ -70,6 +74,8 @@ vi.mock('../../api/screening', () => ({
getStatus: () => getScreeningStatus(),
getHotspotDetail: (payload: unknown) => getHotspotDetail(payload),
getHotspots: (payload: unknown) => getHotspots(payload),
getHistory: (payload: unknown) => getHistory(payload),
getRun: (runId: string) => getRun(runId),
getStrategies: () => getStrategies(),
getScreenTask: (taskId: string) => getScreenTask(taskId),
screen: (payload: unknown) => screenStocks(payload),
@@ -107,6 +113,8 @@ function createDeferred<T>() {
describe('StockScreeningPage', () => {
beforeEach(() => {
enableScreening.mockReset();
getHistory.mockReset();
getRun.mockReset();
getScreeningStatus.mockReset();
getHotspotDetail.mockReset();
getHotspots.mockReset();
@@ -143,6 +151,15 @@ describe('StockScreeningPage', () => {
stockCount: 1,
});
getHotspots.mockResolvedValue({ enabled: true, provider: 'akshare', hotspots: [], hotspotCount: 0 });
getHistory.mockResolvedValue({ runs: [] });
getRun.mockRejectedValue(Object.assign(new Error('run not found'), {
parsedError: {
title: '选股任务不可恢复',
message: '服务端没有找到这次选股任务,可能后端已重启或任务记录已清理,请重新运行选股。',
rawMessage: 'screening_screen_task_not_found',
category: 'http_error',
},
}));
window.sessionStorage.clear();
});
@@ -1156,7 +1173,8 @@ describe('StockScreeningPage', () => {
expect(await screen.findByText('恢复后的候选')).toBeInTheDocument();
expect(screen.getByText('选股完成')).toBeInTheDocument();
expect(window.sessionStorage.getItem('dsa.screening.activeScreenTask.v1')).toBeNull();
// 方案A任务完成后保留 runId而非清空刷新后可从 history API 恢复
expect(window.sessionStorage.getItem('dsa.screening.activeScreenTask.v1')).toContain('screen-task-1');
});
it('keeps a restored screening task recoverable when status polling times out', async () => {
@@ -1183,6 +1201,463 @@ describe('StockScreeningPage', () => {
expect(window.sessionStorage.getItem('dsa.screening.activeScreenTask.v1')).toContain('screen-task-1');
});
it('clears the persisted recovery state when a restored task becomes unrecoverable', async () => {
getScreeningStatus.mockResolvedValue({
enabled: true,
available: true,
});
window.sessionStorage.setItem('dsa.screening.activeScreenTask.v1', JSON.stringify({
taskId: 'screen-task-1',
market: 'cn',
strategy: 'dual_low',
maxResults: 3,
}));
getScreenTask.mockRejectedValueOnce(Object.assign(new Error('选股任务不可恢复'), {
parsedError: {
title: '选股任务不可恢复',
message: '服务端没有找到这次选股任务,可能后端已重启或任务记录已清理,请重新运行选股。',
rawMessage: 'screening_screen_task_not_found',
category: 'http_error',
},
}));
render(<StockScreeningPage />);
await waitFor(() => expect(getScreenTask).toHaveBeenCalledTimes(1));
expect(screen.getByText(/选股任务不可恢复/)).toBeInTheDocument();
expect(screen.queryByText('选股运行中')).not.toBeInTheDocument();
// 不可恢复分支必须清理持久化恢复状态,避免刷新后反复恢复同一条失效任务
expect(window.sessionStorage.getItem('dsa.screening.activeScreenTask.v1')).toBeNull();
});
it('keeps the persisted recovery state and history visible after filters change', async () => {
getScreeningStatus.mockResolvedValue({
enabled: true,
available: true,
});
screenStocks.mockResolvedValueOnce({
enabled: true,
candidates: [
{
rank: 1,
code: '000001',
name: '旧筛选条件下的候选',
score: 88.5,
reason: 'old filter result',
raw: {},
},
],
candidateCount: 1,
runId: 'run-1',
});
render(<StockScreeningPage />);
expect(await screen.findByText('选股已开启')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /运行选股/ }));
expect(await screen.findByText('旧筛选条件下的候选')).toBeInTheDocument();
expect(window.sessionStorage.getItem('dsa.screening.activeScreenTask.v1')).toContain('run-1');
// 修改筛选条件(返回数量 3 -> 5当前结果视图清空但持久化恢复状态必须保留
fireEvent.change(screen.getByRole('spinbutton', { name: /返回数量/ }), { target: { value: '5' } });
expect(screen.queryByText('旧筛选条件下的候选')).not.toBeInTheDocument();
expect(window.sessionStorage.getItem('dsa.screening.activeScreenTask.v1')).toContain('run-1');
// 历史记录区块仍然可见(筛选条件切换不影响历史可获取性)
expect(screen.getByText('历史记录')).toBeInTheDocument();
});
it('shows the screening conditions on each history entry', async () => {
getScreeningStatus.mockResolvedValue({
enabled: true,
available: true,
});
getHistory.mockResolvedValue({
enabled: true,
runs: [
{
runId: 'run-1',
strategy: 'dual_low',
market: 'cn',
candidateCount: 3,
snapshotCount: 50,
llmRanked: true,
createdAt: '2026-08-05T10:00:00Z',
},
],
runCount: 1,
});
render(<StockScreeningPage />);
// 历史条目展示筛选条件:策略中文名 + 市场标签 + 返回数量(该次 run 实际候选数)
expect(await screen.findByText(/返回 3 只/)).toBeInTheDocument();
expect(screen.getAllByText('Dual Low').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('A 股').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(/快照 50/)).toBeInTheDocument();
expect(screen.getByText(/智能重排/)).toBeInTheDocument();
});
it('syncs strategy and market context when opening a history run', async () => {
getScreeningStatus.mockResolvedValue({
enabled: true,
available: true,
});
getHistory.mockResolvedValue({
enabled: true,
runs: [
{
runId: 'run-1',
strategy: 'capital_heat',
market: 'cn',
candidateCount: 3,
snapshotCount: 50,
llmRanked: true,
createdAt: '2026-08-05T10:00:00Z',
},
],
runCount: 1,
});
getRun.mockResolvedValue({
runId: 'run-1',
strategy: 'capital_heat',
market: 'cn',
candidateCount: 3,
enabled: true,
result: {
enabled: true,
candidates: [
{
rank: 1,
code: '00700',
name: '腾讯控股',
score: 88.5,
reason: '热度因子领先',
amount: 1042000000,
factorScores: { heat: 92 },
raw: {},
},
],
candidateCount: 1,
snapshotCount: 50,
afterFilterCount: 10,
llmRanked: true,
},
});
render(<StockScreeningPage />);
// 点击历史记录中的 run 条目(策略 capital_heat与当前表单默认 dual_low 不同)
fireEvent.click(await screen.findByText('capital_heat'));
// 结果区上下文同步为该历史 run 的策略与市场
expect(await screen.findByText(/自定义策略 \(capital_heat\) · A 股/)).toBeInTheDocument();
expect(getRun).toHaveBeenCalledWith('run-1');
});
it('ignores stale history-detail responses when switching runs quickly', async () => {
getScreeningStatus.mockResolvedValue({
enabled: true,
available: true,
});
getHistory.mockResolvedValue({
enabled: true,
runs: [
{
runId: 'run-a',
strategy: 'capital_heat',
market: 'cn',
candidateCount: 1,
createdAt: '2026-08-05T10:00:00Z',
},
{
runId: 'run-b',
strategy: 'dual_low',
market: 'cn',
candidateCount: 1,
createdAt: '2026-08-06T10:00:00Z',
},
],
runCount: 2,
});
const runADetail = createDeferred<unknown>();
const runBDetail = createDeferred<unknown>();
getRun.mockImplementation((runId: string) => {
if (runId === 'run-a') {
return runADetail.promise;
}
if (runId === 'run-b') {
return runBDetail.promise;
}
return Promise.reject(new Error(`unexpected runId: ${runId}`));
});
render(<StockScreeningPage />);
// 先点 run-acapital_heat请求挂起再点 run-bdual_low
fireEvent.click(await screen.findByText('capital_heat'));
fireEvent.click(screen.getByRole('button', { name: /Dual Low/ }));
await waitFor(() => expect(getRun).toHaveBeenLastCalledWith('run-b'));
// run-b 先返回:结果区展示 dual_low 上下文
await act(async () => {
runBDetail.resolve({
runId: 'run-b',
strategy: 'dual_low',
market: 'cn',
candidateCount: 1,
enabled: true,
result: {
enabled: true,
candidates: [
{
rank: 1,
code: '000001',
name: '平安银行',
score: 88.5,
reason: '双低策略',
amount: 1042000000,
factorScores: { value: 92 },
raw: {},
},
],
candidateCount: 1,
snapshotCount: 50,
afterFilterCount: 10,
llmRanked: true,
},
});
});
expect(await screen.findByText(/Dual Low · A 股/)).toBeInTheDocument();
// run-a 迟到返回:不得覆盖 run-b 的结果与上下文
await act(async () => {
runADetail.resolve({
runId: 'run-a',
strategy: 'capital_heat',
market: 'cn',
candidateCount: 1,
enabled: true,
result: {
enabled: true,
candidates: [
{
rank: 1,
code: '00700',
name: '腾讯控股',
score: 90,
reason: '热度因子领先',
amount: 1042000000,
factorScores: { heat: 92 },
raw: {},
},
],
candidateCount: 1,
snapshotCount: 50,
afterFilterCount: 10,
llmRanked: true,
},
});
});
expect(screen.getByText(/Dual Low · A 股/)).toBeInTheDocument();
expect(screen.queryByText(/自定义策略 \(capital_heat\)/)).not.toBeInTheDocument();
expect(screen.queryByText('腾讯控股')).not.toBeInTheDocument();
});
it('ignores late auto-restore responses after the user manually picks a history run', async () => {
getScreeningStatus.mockResolvedValue({
enabled: true,
available: true,
});
// 持久化 run-a挂载时会触发自动恢复的 getRun('run-a')
window.sessionStorage.setItem('dsa.screening.activeScreenTask.v1', JSON.stringify({
taskId: 'task-a',
runId: 'run-a',
strategy: 'capital_heat',
market: 'cn',
maxResults: 3,
}));
getHistory.mockResolvedValue({
enabled: true,
runs: [
{
runId: 'run-b',
strategy: 'dual_low',
market: 'cn',
candidateCount: 1,
createdAt: '2026-08-05T10:00:00Z',
},
],
runCount: 1,
});
const runA = createDeferred();
getRun.mockImplementation((runId: string) => {
if (runId === 'run-a') {
return runA.promise;
}
return Promise.resolve({
runId: 'run-b',
strategy: 'dual_low',
market: 'cn',
candidateCount: 1,
enabled: true,
result: {
enabled: true,
candidates: [
{
rank: 1,
code: '600000',
name: '浦发银行',
score: 88,
reason: '低估值',
amount: 1042000000,
factorScores: { value: 87 },
raw: {},
},
],
candidateCount: 1,
snapshotCount: 50,
afterFilterCount: 10,
llmRanked: true,
},
});
});
render(<StockScreeningPage />);
// 自动恢复 run-a 还在挂起时,用户手动点开历史里的 run-b策略 dual_low → 显示 Dual Low
fireEvent.click(await screen.findByText(/返回 1 只/));
expect(await screen.findByText(/Dual Low · A 股/)).toBeInTheDocument();
expect(screen.getByText('浦发银行')).toBeInTheDocument();
// run-a 较晚返回:不得覆盖用户手动选择的 run-b
await act(async () => {
runA.resolve({
runId: 'run-a',
strategy: 'capital_heat',
market: 'cn',
candidateCount: 1,
enabled: true,
result: {
enabled: true,
candidates: [
{
rank: 1,
code: '00700',
name: '腾讯控股',
score: 90,
reason: '热度因子领先',
amount: 1042000000,
factorScores: { heat: 92 },
raw: {},
},
],
candidateCount: 1,
snapshotCount: 50,
afterFilterCount: 10,
llmRanked: true,
},
});
});
expect(screen.getByText(/Dual Low · A 股/)).toBeInTheDocument();
expect(screen.getByText('浦发银行')).toBeInTheDocument();
expect(screen.queryByText(/自定义策略 \(capital_heat\)/)).not.toBeInTheDocument();
expect(screen.queryByText('腾讯控股')).not.toBeInTheDocument();
});
it('cancels an in-flight screening task when a history run is selected', async () => {
getScreeningStatus.mockResolvedValue({
enabled: true,
available: true,
});
getHistory.mockResolvedValue({
enabled: true,
runs: [
{
runId: 'run-hist',
strategy: 'capital_heat',
market: 'cn',
candidateCount: 1,
createdAt: '2026-08-05T10:00:00Z',
},
],
runCount: 1,
});
// 提交一个未完成的任务pending进入后台轮询
getScreenTask.mockResolvedValueOnce({
taskId: 'screen-task-1',
traceId: 'screen-task-1',
status: 'running',
progress: 40,
message: 'Screening 正在分析...',
result: null,
});
screenStocks.mockResolvedValueOnce({
enabled: true,
candidates: [
{
rank: 1,
code: '600000',
name: '任务候选',
score: 80,
reason: '任务原因',
amount: 100,
factorScores: { value: 80 },
raw: {},
},
],
candidateCount: 1,
snapshotCount: 50,
afterFilterCount: 10,
llmRanked: true,
});
getRun.mockResolvedValueOnce({
runId: 'run-hist',
strategy: 'capital_heat',
market: 'cn',
candidateCount: 1,
enabled: true,
result: {
enabled: true,
candidates: [
{
rank: 1,
code: '00700',
name: '历史候选',
score: 90,
reason: '历史原因',
amount: 1042000000,
factorScores: { heat: 92 },
raw: {},
},
],
candidateCount: 1,
snapshotCount: 50,
afterFilterCount: 10,
llmRanked: true,
},
});
render(<StockScreeningPage />);
// 等待选股开启后,提交选股任务进入轮询
expect(await screen.findByText('选股已开启')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /运行选股/ }));
await waitFor(() => expect(startScreenTask).toHaveBeenCalled());
await waitFor(() => expect(getScreenTask).toHaveBeenCalled());
// 任务仍在轮询时,点击历史记录 run策略 capital_heat
fireEvent.click(await screen.findByText('capital_heat'));
expect(await screen.findByText(/自定义策略 \(capital_heat\)/)).toBeInTheDocument();
expect(screen.getByText('历史候选')).toBeInTheDocument();
// 后台任务随后完成,也不得把任务候选回写到历史上下文中
expect(screen.queryByText('任务候选')).not.toBeInTheDocument();
expect(screen.getByText(/自定义策略 \(capital_heat\)/)).toBeInTheDocument();
});
it('surfaces Screening LLM fallback instead of showing empty LLM fields as normal', async () => {
getScreeningStatus.mockResolvedValueOnce({
enabled: true,
@@ -1347,4 +1822,329 @@ describe('StockScreeningPage', () => {
expect(screen.getByText('数据补充提示')).toBeInTheDocument();
expect(screen.getByText('stock_news_unavailable')).toBeInTheDocument();
});
it('keeps the shared loading held when a stale auto-restore finishes while a manual history request is in flight', async () => {
// 回归 OR-COR-9b1f8c4e过期的自动恢复请求不得在 finally 中无条件清掉共享 loading
// 否则手动历史详情仍在飞行时“运行选股”会被提前放开。
getScreeningStatus.mockResolvedValue({
enabled: true,
available: true,
});
window.sessionStorage.setItem('dsa.screening.activeScreenTask.v1', JSON.stringify({
taskId: 'task-a',
runId: 'run-a',
strategy: 'capital_heat',
market: 'cn',
maxResults: 3,
}));
let resolveRunA: (value: unknown) => void = () => {};
let resolveRunB: (value: unknown) => void = () => {};
getRun.mockImplementation((runId: string) => {
if (runId === 'run-a') {
return new Promise((resolve) => {
resolveRunA = resolve;
});
}
if (runId === 'run-b') {
return new Promise((resolve) => {
resolveRunB = resolve;
});
}
return Promise.reject(new Error('run not found'));
});
getHistory.mockResolvedValue({
enabled: true,
runs: [
{
runId: 'run-b',
strategy: 'dual_low',
market: 'cn',
candidateCount: 1,
createdAt: '2026-08-05T10:00:00Z',
},
],
runCount: 1,
});
render(<StockScreeningPage />);
expect(await screen.findByText('选股已开启')).toBeInTheDocument();
// 自动恢复 run-a 仍在飞行时,用户点开历史里的 run-b
fireEvent.click(await screen.findByRole('button', { name: /Dual Low/ }));
// 先让过期的自动恢复 run-a 结束(其响应已被 request-id 判定为过期)
resolveRunA({
runId: 'run-a',
strategy: 'capital_heat',
market: 'cn',
candidateCount: 1,
enabled: true,
result: {
enabled: true,
candidates: [
{
rank: 1,
code: '600519',
name: '贵州茅台',
score: 90,
reason: '热度',
raw: {},
},
],
candidateCount: 1,
snapshotCount: 50,
afterFilterCount: 10,
llmRanked: true,
},
});
await act(async () => {});
// 过期 finally 不清共享 loading表单仍处于禁用态按钮在 loading 时渲染为 spinner
// 故用市场下拉框断言),页面也未切到任何历史结果
expect(screen.getByLabelText('市场')).toBeDisabled();
expect(screen.queryByText(/Dual Low · A 股/)).not.toBeInTheDocument();
expect(screen.queryByText(/自定义策略 \(capital_heat\)/)).not.toBeInTheDocument();
// 随后 run-b 正常返回结果恢复loading 由本次请求自己收口
resolveRunB({
runId: 'run-b',
strategy: 'dual_low',
market: 'cn',
candidateCount: 1,
enabled: true,
result: {
enabled: true,
candidates: [
{
rank: 1,
code: '600000',
name: '浦发银行',
score: 88,
reason: '低估值',
raw: {},
},
],
candidateCount: 1,
snapshotCount: 50,
afterFilterCount: 10,
llmRanked: true,
},
});
expect(await screen.findByText(/Dual Low · A 股/)).toBeInTheDocument();
await waitFor(() => expect(screen.getByLabelText('市场')).toBeEnabled());
});
it('polls a newly submitted task immediately while a stale auto-restore request is still pending', async () => {
// 回归 OR-COR-2c71d8afhandleSubmit 必须解除自动恢复门闩并作废飞行中的恢复请求,
// 否则新任务的轮询会被阻塞到旧请求超时,页面假死在提交进度。
getScreeningStatus.mockResolvedValue({
enabled: true,
available: true,
});
window.sessionStorage.setItem('dsa.screening.activeScreenTask.v1', JSON.stringify({
taskId: 'task-a',
runId: 'run-a',
strategy: 'capital_heat',
market: 'cn',
maxResults: 3,
}));
// 自动恢复 run-a 永远挂起(模拟卡死的旧请求)
getRun.mockImplementation((runId: string) => {
if (runId === 'run-a') {
return new Promise(() => {});
}
return Promise.resolve({
runId: 'run-b',
strategy: 'dual_low',
market: 'cn',
candidateCount: 1,
enabled: true,
result: {
enabled: true,
candidates: [
{
rank: 1,
code: '600000',
name: '历史候选',
score: 80,
reason: '历史',
raw: {},
},
],
candidateCount: 1,
snapshotCount: 50,
afterFilterCount: 10,
llmRanked: true,
},
});
});
getHistory.mockResolvedValue({
enabled: true,
runs: [
{
runId: 'run-b',
strategy: 'dual_low',
market: 'cn',
candidateCount: 1,
createdAt: '2026-08-05T10:00:00Z',
},
],
runCount: 1,
});
screenStocks.mockResolvedValue({
enabled: true,
candidates: [
{
rank: 1,
code: '000001',
name: '新任务候选',
score: 88,
reason: '新任务',
raw: {},
},
],
candidateCount: 1,
});
render(<StockScreeningPage />);
expect(await screen.findByText('选股已开启')).toBeInTheDocument();
// 自动恢复挂起时,用户先打开历史 run-b正常返回并由该请求自身收口 loading
fireEvent.click(await screen.findByRole('button', { name: /Dual Low/ }));
expect(await screen.findByText(/Dual Low · A 股/)).toBeInTheDocument();
// 随即发起新任务:轮询必须立即启动,不被仍挂起的自动恢复门闩阻塞
fireEvent.click(screen.getByRole('button', { name: /运行选股/ }));
await waitFor(() => expect(getScreenTask).toHaveBeenCalledTimes(1));
expect(await screen.findByText(/新任务候选/)).toBeInTheDocument();
});
it('does not rewrite a restored custom strategy when the strategy list arrives late', async () => {
// 回归:迟到的 /strategies 响应不得把历史/恢复上下文中的自定义策略改写回默认策略。
let resolveStrategies: (value: unknown) => void = () => {};
getStrategies.mockImplementation(
() => new Promise((resolve) => {
resolveStrategies = resolve;
}),
);
getScreeningStatus.mockResolvedValue({
enabled: true,
available: true,
});
window.sessionStorage.setItem('dsa.screening.activeScreenTask.v1', JSON.stringify({
taskId: 'task-a',
runId: 'run-a',
strategy: 'capital_heat',
market: 'cn',
maxResults: 3,
}));
getRun.mockResolvedValue({
runId: 'run-a',
strategy: 'capital_heat',
market: 'cn',
candidateCount: 1,
enabled: true,
result: {
enabled: true,
candidates: [
{
rank: 1,
code: '600519',
name: '贵州茅台',
score: 90,
reason: '热度',
raw: {},
},
],
candidateCount: 1,
snapshotCount: 50,
afterFilterCount: 10,
llmRanked: true,
},
});
getHistory.mockResolvedValue({ enabled: true, runs: [], runCount: 0 });
render(<StockScreeningPage />);
expect(await screen.findByText('选股已开启')).toBeInTheDocument();
// 自动恢复先应用历史上下文(此时策略列表请求仍挂起)
expect(await screen.findByText(/自定义策略 \(capital_heat\) · A 股/)).toBeInTheDocument();
// 迟到的策略列表不包含该历史策略
resolveStrategies({
enabled: true,
strategies: [
{ id: 'dual_low', name: '双低', description: 'desc', category: '价值' },
],
strategyCount: 1,
});
await act(async () => {});
// 归一化被跳过:表单下拉切到自定义项、输入框保留原始 ID、结果区标题不变
expect(screen.getByLabelText('策略')).toHaveValue('__custom_strategy__');
expect(screen.getByLabelText('自定义策略 ID')).toHaveValue('capital_heat');
expect(screen.getByText(/自定义策略 \(capital_heat\) · A 股/)).toBeInTheDocument();
});
it('persists the selected history run so a refresh restores that run instead of the stale task', async () => {
// 回归 OR-COR-4d1a7e90手动打开历史记录后必须同步持久化恢复指针
// 刷新后应恢复用户刚选中的历史 run而不是停留在更早的 task。
getScreeningStatus.mockResolvedValue({
enabled: true,
available: true,
});
window.sessionStorage.setItem('dsa.screening.activeScreenTask.v1', JSON.stringify({
taskId: 'task-a',
strategy: 'dual_low',
market: 'cn',
maxResults: 3,
}));
getHistory.mockResolvedValue({
enabled: true,
runs: [
{
runId: 'run-b',
strategy: 'capital_heat',
market: 'cn',
candidateCount: 1,
createdAt: '2026-08-05T10:00:00Z',
},
],
runCount: 1,
});
getRun.mockResolvedValue({
runId: 'run-b',
strategy: 'capital_heat',
market: 'cn',
candidateCount: 1,
enabled: true,
result: {
enabled: true,
candidates: [
{
rank: 1,
code: '600519',
name: '贵州茅台',
score: 90,
reason: '热度',
raw: {},
},
],
candidateCount: 1,
snapshotCount: 50,
afterFilterCount: 10,
llmRanked: true,
},
});
getScreenTask.mockResolvedValue({
taskId: 'task-a',
traceId: 'task-a',
status: 'processing',
progress: 10,
message: '正在执行 Screening 选股',
result: null,
});
const first = render(<StockScreeningPage />);
expect(await screen.findByText('选股已开启')).toBeInTheDocument();
// 手动选中历史记录 run-b策略 capital_heat
fireEvent.click(await screen.findByText('capital_heat'));
expect(await screen.findByText(/自定义策略 \(capital_heat\) · A 股/)).toBeInTheDocument();
// 持久化指针已切换到刚选中的历史 run
const stored = JSON.parse(
window.sessionStorage.getItem('dsa.screening.activeScreenTask.v1') || '{}',
);
expect(stored.runId).toBe('run-b');
expect(stored.taskId).toBe('run-b');
// 刷新(重新挂载)后按新指针恢复 run-b而不是旧 task-a
first.unmount();
render(<StockScreeningPage />);
await waitFor(() => expect(getRun).toHaveBeenLastCalledWith('run-b'));
expect(await screen.findByText(/自定义策略 \(capital_heat\) · A 股/)).toBeInTheDocument();
// 正常恢复成功时不应对占位 taskId 触发轮询回退
expect(getScreenTask).not.toHaveBeenCalledWith('run-b');
});
});

View File

@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] 美股日线路由现按各数据源当前优先级排序,单项 `*_PRIORITY` 配置(如 `YFINANCE_PRIORITY=0`)对美股即时生效;指数固定首选与 Longbridge preferred 语义保持不变
- [新功能] 新增只读 Dashboard Overview API按 market/personal/activity/system/what_changed 分块返回来源与质量,并基于持久化快照计算变化、使用后端 total 提供精确计数。
- [新功能] 新增个股研究聚合 API以统一 canonical code 返回行情、历史、研究产物、资讯、缓存持仓关系和监控规则,并对每个块独立标记 fresh/partial/unavailable。
- [修复] 个股研究聚合拒绝交易所冲突的股票身份,在市场限定后无历史候选时保持空结果,兼容市场限定裸码与混合大小写旧数据,并从独立基本面快照补齐 ResearchArtifact 的财报与分红证据。
- [新功能] 支持通过 `main.py --stocks` 一次性分析已登记板块指数,自动使用指数适用的数据与分析能力,并保持报告、历史和决策信号兼容。
- [修复] `main.py --stocks` 在解析股票列表前先 best-effort 刷新股票索引注册表,保证首次运行能吃到刷新后的指数 alias/身份;刷新失败、超时或禁用不阻断分析。
@@ -62,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [改进] AIHubMix 注册与引流链接统一使用 inferera.com改善中国大陆网络直连体验。
- [修复] 单股推送模式在未配置通知渠道时仍会落盘本地个股报告CLI 启动分析若因空股票列表、个股结果全失败或本地报告保存失败而未生成报告,会显式返回失败并记录原因。
- [修复] 合并推送模式下即使个股汇总报告落盘失败,仍会先发送已有的合并通知;仅启用大盘复盘但最终未生成任何复盘内容时,分析任务会显式返回失败。
- [修复] 选股结果持久化恢复:选股页新增历史记录区块,任务完成后保留 run_id刷新页面可从历史 API 恢复上次选股结果(此前刷新后结果丢失)。
- [修复] Web/API runtime scheduler 使用跨平台独立进程执行分析,并在默认 45 分钟硬超时或服务停止后清理进程树;停止返回后不再派发新的自动任务,避免一次卡死阻断后续调度。
- [修复] SearXNG 公共实例发现的默认值由启用改为关闭:公共实例普遍存在限流、下线或不返回 JSON 的情况,默认开启会让未配置搜索 key 的用户每次分析多耗 30~60 秒且新闻面最终为空。运行时默认值、配置模板、中英文档与工作流诊断同步调整;显式设为 true 的用户行为不变。
- [改进] 新闻检索未执行或零命中时,报告中如实标注结论未纳入新闻面证据:零命中与「未配置搜索渠道」使用各自独立的文案,覆盖日报 / dashboard / brief / 个股 / 企业微信与模板渲染的详细与摘要分支、历史报告与分享导出、报告详情 API 与 Web 报告详情页,并按 `zh` / `en` / `ko` 分别本地化。此前该情况下消息面章节直接消失,读者无从区分「确实没有新闻」与「检索静默失败」。披露以本次分析实际收到的消息面证据为准,涵盖实时检索、社交情绪与本地已落库的资讯池三路来源;搜索命中数仅用于在确无证据时说明原因(未配置渠道 / 检索零命中避免把已用到本地或社交证据的分析误报成「未纳入新闻面证据」。Agent 模式的命中数取自 Agent 实际消费的搜索工具结果,而非分析结束后为持久化情报而补打的查询。

View File

@@ -46,6 +46,7 @@
| [Bot 平台配置](bot/) | 飞书、钉钉、Discord 等 Bot 配置截图和补充说明 |
| [实时告警中心](alerts.md) | EventMonitor 基线、Web 规则管理、通知结果、冷却状态和 Phase 边界 |
| [Dashboard Overview API](dashboard-overview-api.md) | 首页分块聚合、精确计数、持久化 What Changed 与只读刷新边界 |
| [个股研究聚合 API](stock-profile-api.md) | 单一 stock-profile 契约、分块质量状态、代码归一与后续 Web 边界 |
| [DecisionSignal 决策信号专题](decision-signals.md) | AI 建议池字段语义、API、Web 展示、告警/通知/组合风险联动、后验评估、脱敏、迁移与回滚 |
| [ResearchArtifact 结构化研究产物](research-artifact.md) | structured_report 字段、Thesis / Evidence / Invalidation / Next Action / Data Quality 契约和旧报告兼容边界 |
| [资讯 / 情报源](intelligence-sources.md) | RSS/Atom 合规资讯源配置、测试、拉取、去重、存储、查询与安全边界 |

View File

@@ -46,6 +46,7 @@ This is the entry point for project documentation. The README covers the project
| [Bot Platform Docs](bot/) <sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> (Chinese-only) | Feishu, DingTalk, Discord, and related Bot configuration screenshots and notes |
| [Real-Time Alert Center](alerts.md) <sub><sub>![P4 Badge](https://img.shields.io/badge/P4-yellow?style=flat)</sub></sub> (Chinese-only) | EventMonitor baseline, Web rule management, notification attempts, cooldown state, and phase boundaries |
| [Dashboard Overview API](dashboard-overview-api.md) <sub><sub>![P1 Badge](https://img.shields.io/badge/P1-yellow?style=flat)</sub></sub> (Chinese-only) | Grouped overview, exact totals, persisted What Changed comparisons, and read-only refresh boundaries |
| [Stock Profile API](stock-profile-api.md) <sub><sub>![P1 Badge](https://img.shields.io/badge/P1-yellow?style=flat)</sub></sub> (Chinese-only) | Single stock-profile contract, per-block quality status, code normalization, and Web phase boundaries |
| [DecisionSignal Topic](decision-signals.md) <sub><sub>![P7 Badge](https://img.shields.io/badge/P7-orange?style=flat)</sub></sub> (Chinese-only) | AI signal fields, API, Web display, alert/notification/portfolio-risk linkage, outcome evaluation, redaction, migration, and rollback |
| [ResearchArtifact Contract](research-artifact.md) <sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> (Chinese-only) | Structured thesis, evidence, invalidation conditions, deterministic fallback identity, and the boundary before persistence/API integration |
| [Analysis Context Pack Contract, Runtime Consumption, And Visibility](analysis-context-pack.md) <sub><sub>![P6 Badge](https://img.shields.io/badge/P6-orange?style=flat)</sub></sub> (Chinese-only) | AnalysisContextPack first-scope boundaries, field quality states, P1/P2 internal contracts, P3 prompt-summary consumption, P4 history/API/Web low-sensitivity visibility, P5 data-quality scoring, and P6 migration/rollback notes, plus source anchors; the full guide adds #1386 market-phase analysis, migration, and rollback entry points |

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@
- red/yellow/green 状态变化输出 `market.<region>.status`
- change item 的 quality 取 current/previous 两份快照中较差的一侧;任一侧 partial 会降低整个 what_changed 块,任一侧 unavailable 不生成可靠变化项。
- 没有第二份有效快照时,返回 `previous_completed_snapshot_unavailable`;部分 region 缺基线时整个块标记为 `partial`,并追加 `previous_completed_snapshot_unavailable:<region>`,避免把“缺少基线”误解为“没有变化”。不会临时拉行情或生成一份“当前”快照冒充基线。
- 历史详情读取失败、投影出的 `context_snapshot` 不是 JSON object,或其中已存在的 `market_light_snapshots` 不是 object例如损坏的 JSON 字符串/数组)时,不再把其余更旧记录提升为 current/latest对应快照和变化对比保持不可用并返回 limitation。
- 历史详情读取失败、投影出的 `context_snapshot` 不是 JSON object其中已存在的 `market_light_snapshots` 不是 object例如损坏的 JSON 字符串/数组),或已声明 review scope 的记录完全缺少/只保存空的 snapshot container 时,不再把其余更旧记录提升为 current/latest对应快照和变化对比保持不可用并返回 limitation。没有可识别 scope 的旧记录仍按 legacy 无快照记录处理,避免它们无差别阻断所有市场。
- 快照校验失败时仍保留其 `trade_date` 的目标位置:最新日期无有效快照时不回退到更旧 current最近更早日期无有效快照时不越过它继续寻找更旧 previous。同一目标日期存在其他有效重跑快照时仍可使用该日期的有效版本。
- 外层市场键必须与快照内部 `region` 一致;例如 `cn` 键下的 `region=us` 快照会按无效快照处理,不能进入 A 股的 current/previous 或变化项。
- `market_light_snapshots` object 中任何已出现的 region 条目都必须仍是 object例如最新记录里的 `cn: []` 会把 CN current 标为不可用,不能被过滤后再让旧 CN 快照冒充最新。

41
docs/stock-profile-api.md Normal file
View File

@@ -0,0 +1,41 @@
# 个股研究聚合 API
本文档说明 Issue #2279 的后端契约阶段。目标是让后续 `/stocks/:code` 工作台通过单一端点消费数据,而不是在浏览器并发拼装行情、历史、报告、资讯、持仓和监控接口。
## 端点
`GET /api/v1/stocks/{stock_code}/profile?history_days=60`
端点先把输入统一为 canonical code。实时行情、历史和报告链使用 canonical code对可能由旧入口按别名持久化的 intelligence 与 monitor 记录,读取时会展开仓库既有等价代码集合并按 ID 去重:
- A 股:`SH600519``600519.SH` 等收敛为 `600519`。入口已经显式携带 `SH` / `SZ` / `BJ` 时,后续报告、资讯和监控别名展开会持续保留该市场身份;即使股票索引存在同形日韩代码,也不会重新按裸数字推断为其他市场。显式交易所与代码规则冲突(如 `600519.SZ`)时返回 400不会剥离交易所后查询另一标的。
- 日股:`7203.T` 保留 Yahoo canonical suffix并返回 `market=jp`
- 韩股:`005930.KS``035720.KQ` 保留 Yahoo canonical suffix并返回 `market=kr`;股票索引唯一识别出的旧裸代码也沿用解析后的韩国市场身份。缓存持仓中的旧裸六位韩股在 market 已明确为 `kr` 时按解析身份与档案 suffix 的数字主体比较,不会再次按无 market 的 A 股规则解释。
- 台股profile 入口接受 46 位 `.TW` / `.TWO` Yahoo suffix包括六位 ETF并返回 `market=tw`。市场限定的资讯查询兼容旧裸数字 scope`global` 查询继续排除该歧义别名;缓存持仓已明确 `market=tw` 时,旧裸代码按同市场数字主体与档案匹配。
- 港股:`00700``00700.HK``HK00700` 收敛为 `HK00700`;缓存持仓已明确 `market=hk` 时,`700` 等旧短格式也会补零后参与身份比较。
- 美股 ticker 统一为裸大写形式,例如 `aapl``AAPL.US` 都收敛为 `AAPL`;持久化读取仍查询裸 ticker 与 `.US` 等价别名。
响应包含 `quote``history``research``intelligence``portfolio``monitors` 六个独立块,以及顶层 `evidence_quality` 汇总。每个块的 `status` 只允许:
| 状态 | 语义 |
| --- | --- |
| `fresh` | 本次请求成功取得可用数据;不代表所有外部来源具有同一时区或刷新频率 |
| `partial` | 核心信息仍可用,但存在明确限制,例如只有报告列表、缺少详情,或持仓关系只来自缓存 |
| `unavailable` | 本次没有可用数据;原因以稳定的 `limitations` code 返回,不暴露原始异常或密钥 |
任一可选块失败不会让其他块消失。例如 quote 失败时,历史报告、结构化 ResearchArtifact、symbol intelligence 和监控规则仍可返回;最新报告详情失败时,`research.recent_reports` 仍保留,`structured_report``null` 并标记 `latest_report_detail_unavailable`
## 数据来源与边界
- quote/history复用 `StockService`,不新增数据获取器。
- research复用 `HistoryService`#2291`ResearchArtifact` builder。报告查询把档案入口已经解析出的 market hint 传到 `HistoryService` 的候选生成层:只保留能在无提示解析时仍确认属于当前市场的裸数字别名;例如 `600519` 可继续兼容 A 股旧记录,`005930` 可兼容已唯一登记为韩股的旧报告,而与韩股同形的显式 A 股 `SZ000660` 不会查询裸 `000660`。日韩台查询保留交易所后缀及其他已确认同市场的旧裸数字代码但不展开可能命中其他市场历史记录的别名市场限定后候选为空时直接返回空分页不会退化成无过滤查询。artifact 同时复用历史详情的上下文、raw result 和独立基本面快照 fallback保留财报、分红与市场结构专项证据及其 source count。
- service 导入边界:`api.v1` 的 router 改为延迟导出,因此 `StockProfileService -> ResearchArtifact schema` 不会反向触发全部 API endpoints。CLI、独立脚本和单元测试可在没有预先导入 `api.app` 的情况下直接导入 profile service。
- intelligence复用 `IntelligenceService` 的 symbol scope 查询,并兼容 canonical、交易所前后缀、港股前后缀、混合大小写及日韩台市场限定的裸数字历史别名无法无提示解析回当前市场的歧义裸码不会进入 `global` 查询。查询别名按 casefold identity 去重,由 repository 的 symbol-only 不区分大小写过滤兼容历史大小写,避免重复 count/select。任一别名/市场查询失败时块保持 partial limitation即使其余查询成功但为空也不误报为已确认无资讯。
- portfolio只读 `PortfolioRepository.list_cached_position_identities()`,并使用每条缓存持仓自己的 market 解析旧裸代码;只有 market 与档案身份一致时才算持有。不为了打开个股页触发实时估值或写 snapshot所以状态固定为 `partial` 并包含 `cached_positions_only`
- monitors复用 `AlertService.list_rules()`,以不区分大小写的 symbol 过滤分页汇总并去重 canonical code 及等价历史别名下的 `single_symbol` 规则。由于现有告警目标没有独立 market 字段,日韩台档案不会查询可能与 A/HK 同形的裸数字别名,避免跨市场规则误归属。
本阶段不新增 Web 路由/页面、不接线 Home/Watchlist/Screening/Portfolio/Report 入口,也不包含日历事件。后续 Web PR 必须消费本端点并分别渲染块状态;不得重新恢复多请求页面聚合。日历事件在 #2307 契约合入后再作为独立块扩展。
## 回滚
Revert 本 PR 即可移除 profile schema、service、endpoint、测试和文档。没有数据库迁移、配置变更或数据清理步骤。

View File

@@ -79,7 +79,10 @@ class AlertRepository:
if target_scope:
conditions.append(AlertRuleRecord.target_scope == target_scope)
if target:
conditions.append(AlertRuleRecord.target == target)
if target_scope == "single_symbol":
conditions.append(func.lower(AlertRuleRecord.target) == target.strip().lower())
else:
conditions.append(AlertRuleRecord.target == target)
if source:
conditions.append(AlertRuleRecord.source == source)

View File

@@ -165,7 +165,11 @@ class IntelligenceRepository:
if scope_type:
conditions.append(IntelligenceItem.scope_type == scope_type)
if scope_value:
conditions.append(IntelligenceItem.scope_value == self._normalize_scope_value(scope_value))
normalized_scope = self._normalize_scope_value(scope_value)
if scope_type == "symbol":
conditions.append(func.lower(IntelligenceItem.scope_value) == normalized_scope.lower())
else:
conditions.append(IntelligenceItem.scope_value == normalized_scope)
if market:
conditions.append(IntelligenceItem.market == market)
if query:

View File

@@ -450,7 +450,7 @@ class TavilySearchProvider(BaseSearchProvider):
# 执行搜索优化使用advanced深度、限制最近几天
search_kwargs: Dict[str, Any] = {
"query": query,
"search_depth": "advanced", # advanced 获取更多结果
"search_depth": "basic", # 为控制credit使用将advanced修改成basic
"max_results": max_results,
"include_answer": False,
"include_raw_content": False,

View File

@@ -114,6 +114,16 @@ class DashboardOverviewService:
register_detail_failure(review, context_snapshot, review_rank)
continue
snapshot_container = context_snapshot.get("market_light_snapshots")
if snapshot_container is None or snapshot_container == {}:
# Legacy reviews without an identifiable market scope may
# legitimately predate Market Light persistence. Once a
# review declares its scope, however, an omitted/empty
# container is a failed snapshot source for that scope and
# must not allow an older trade date to become "current".
if self._review_regions(review, context_snapshot):
detail_failure_count += 1
register_detail_failure(review, context_snapshot, review_rank)
continue
if snapshot_container is not None and not isinstance(snapshot_container, dict):
detail_failure_count += 1
register_detail_failure(review, context_snapshot, review_rank)

View File

@@ -19,6 +19,7 @@ from typing import Optional, Dict, Any, List, Tuple, TYPE_CHECKING
from src.config import get_config, resolve_news_window_days
from src.formatters import markdown_to_plain_text
from src.data.stock_index_loader import resolve_index_stock_code
from src.services.stock_code_utils import resolve_daily_stock_identity
from src.report_language import (
get_bias_status_emoji,
get_localized_stock_name,
@@ -104,7 +105,11 @@ class HistoryService:
return value.astimezone().isoformat()
@staticmethod
def _history_code_filter_candidates(stock_code: str) -> List[str]:
def _history_code_filter_candidates(
stock_code: str,
*,
market_hint: Optional[str] = None,
) -> List[str]:
raw_code = str(stock_code or "").strip()
if not raw_code:
return []
@@ -116,6 +121,20 @@ class HistoryService:
if candidate and candidate not in candidates:
candidates.append(candidate)
trusted_market = str(market_hint or "").strip().lower()
if trusted_market:
identity = resolve_daily_stock_identity(raw_code, market_hint=trusted_market)
if identity is None or identity.market != trusted_market:
return []
for candidate in identity.code_candidates:
candidate_text = str(candidate or "").strip()
if candidate_text.isdigit():
unhinted_identity = resolve_daily_stock_identity(candidate_text)
if unhinted_identity is None or unhinted_identity.market != trusted_market:
continue
add(candidate)
return candidates
try:
from data_provider.base import (
canonical_stock_code,
@@ -221,6 +240,8 @@ class HistoryService:
page: int = 1,
limit: int = 20,
include_context_snapshot: bool = False,
include_ambiguous_numeric_aliases: bool = True,
market_hint: Optional[str] = None,
) -> Dict[str, Any]:
"""
Get history analysis list.
@@ -234,13 +255,24 @@ class HistoryService:
limit: Items per page
include_context_snapshot: Include the persisted context snapshot in each list item.
Internal aggregation callers use this to avoid per-record detail queries.
include_ambiguous_numeric_aliases: Whether to include bare numeric
aliases that may collide across offshore markets.
market_hint: Trusted market identity for candidate expansion. When
present, candidates from other indexed markets are excluded.
Returns:
Dictionary containing total count and items
"""
try:
if stock_code:
stock_code = self._history_code_filter_candidates(stock_code)
stock_code = self._history_code_filter_candidates(
stock_code,
market_hint=market_hint,
)
if not include_ambiguous_numeric_aliases and not market_hint:
stock_code = [candidate for candidate in stock_code if not candidate.isdigit()]
if not stock_code:
return {"total": 0, "items": []}
# Parse date parameters
start_dt = None
@@ -581,6 +613,18 @@ class HistoryService:
logger.error(f"根据 ID 查询历史详情失败: {e}", exc_info=True)
return None
def get_latest_fundamental_snapshot(
self,
*,
query_id: str,
stock_code: str,
) -> Optional[Dict[str, Any]]:
"""Read the same persisted fundamental fallback used by history detail APIs."""
return self.db.get_latest_fundamental_snapshot(
query_id=query_id,
code=stock_code,
)
@staticmethod
def _normalize_display_sniper_value(value: Any) -> Optional[str]:
"""Normalize sniper point values for history display."""

View File

@@ -352,7 +352,9 @@ def resolve_daily_stock_identity(
identity_code = raw_code
trusted_market = str(market_hint or "").strip().lower()
if raw_code.isdigit() and len(raw_code) in {4, 5, 6}:
if trusted_market == "hk" and raw_code.isdigit() and 1 <= len(raw_code) <= 3:
identity_code = raw_code.zfill(5)
elif raw_code.isdigit() and len(raw_code) in {4, 5, 6}:
from src.data.stock_index_loader import resolve_index_stock_code_candidates
indexed_candidates = resolve_index_stock_code_candidates(raw_code)
@@ -363,9 +365,9 @@ def resolve_daily_stock_identity(
indexed_offshore = [
(candidate, market)
for candidate, market in indexed_identities
if market in {"jp", "kr"}
if market in {"jp", "kr", "tw"}
]
if trusted_market in {"jp", "kr"}:
if trusted_market in {"jp", "kr", "tw"}:
matching_candidates = [
candidate
for candidate, market in indexed_offshore
@@ -373,6 +375,13 @@ def resolve_daily_stock_identity(
]
if len(matching_candidates) == 1:
identity_code = matching_candidates[0]
elif trusted_market == "tw" and len(raw_code) in {4, 5, 6}:
return DailyStockIdentity(
normalized_code=raw_code,
market="tw",
refill_code="",
code_candidates=(raw_code,),
)
elif indexed_candidates:
return None
elif trusted_market == "jp" and len(raw_code) in {4, 5}:

View File

@@ -0,0 +1,474 @@
# -*- coding: utf-8 -*-
"""Aggregate existing stock research capabilities behind one partial-data contract."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from data_provider.base import canonical_stock_code
from src.analysis_context_pack_overview import extract_analysis_context_pack_overview
from src.repositories.portfolio_repo import PortfolioRepository
from src.services.alert_service import AlertService
from src.services.history_service import HistoryService
from src.services.intelligence_service import IntelligenceService
from src.services.market_symbol_utils import get_suffix_market
from src.services.research_artifact_service import build_research_artifact
from src.services.stock_code_utils import resolve_daily_stock_identity
from src.services.stock_service import StockService
from src.utils.data_processing import (
extract_fundamental_detail_fields,
extract_market_structure_detail_field,
)
_BLOCK_NAMES = ("quote", "history", "research", "intelligence", "portfolio", "monitors")
class InvalidStockProfileCode(ValueError):
"""Raised when a stock profile request has no unambiguous shared identity."""
class StockProfileService:
"""Build a stock profile while isolating optional-source failures by block."""
def __init__(
self,
*,
stock_service: Optional[StockService] = None,
history_service: Optional[HistoryService] = None,
intelligence_service: Optional[IntelligenceService] = None,
portfolio_repository: Optional[PortfolioRepository] = None,
alert_service: Optional[AlertService] = None,
):
self.stock_service = stock_service
self.history_service = history_service
self.intelligence_service = intelligence_service
self.portfolio_repository = portfolio_repository
self.alert_service = alert_service
def get_profile(self, requested_code: str, *, history_days: int = 60) -> Dict[str, Any]:
canonical_code = self.canonicalize_code(requested_code)
market = self.market_for_code(canonical_code)
blocks = {
"quote": self._quote_block(canonical_code),
"history": self._history_block(canonical_code, history_days=history_days),
"research": self._research_block(canonical_code, market=market),
"intelligence": self._intelligence_block(canonical_code, market=market),
"portfolio": self._portfolio_block(canonical_code, market=market),
"monitors": self._monitor_block(canonical_code, market=market),
}
return {
"requested_code": str(requested_code).strip(),
"canonical_code": canonical_code,
"market": market,
"as_of": datetime.now().astimezone().isoformat(),
**blocks,
"evidence_quality": self._evidence_quality(blocks),
}
@staticmethod
def canonicalize_code(value: str) -> str:
raw = str(value or "").strip()
identity = resolve_daily_stock_identity(raw)
if identity is None:
raise InvalidStockProfileCode("stock code has no unambiguous market identity")
normalized = canonical_stock_code(identity.refill_code or identity.normalized_code)
if normalized.isdigit() and len(normalized) == 5:
return f"HK{normalized.zfill(5)}"
return normalized.upper()
@staticmethod
def market_for_code(canonical_code: str) -> str:
if canonical_code.startswith("HK"):
return "hk"
suffix_market = get_suffix_market(canonical_code)
if suffix_market:
return suffix_market
if canonical_code.isdigit() and len(canonical_code) == 6:
return "cn"
return "us"
def _quote_block(self, code: str) -> Dict[str, Any]:
try:
quote = self._stock_service().get_realtime_quote(code)
except Exception:
quote = None
if not quote:
return self._unavailable("quote_unavailable", data=None)
return {"status": "fresh", "data": quote, "limitations": []}
def _history_block(self, code: str, *, history_days: int) -> Dict[str, Any]:
try:
result = self._stock_service().get_history_data(code, period="daily", days=history_days)
rows = list(result.get("data") or [])
except Exception:
rows = []
if not rows:
return {
"status": "unavailable",
"period": "daily",
"data": [],
"limitations": ["history_unavailable"],
}
return {"status": "fresh", "period": "daily", "data": rows, "limitations": []}
def _research_block(self, code: str, *, market: str) -> Dict[str, Any]:
empty_data = {"latest_report": None, "recent_reports": [], "structured_report": None}
try:
query_options: Dict[str, Any] = {
"stock_code": code,
"page": 1,
"limit": 5,
"market_hint": market,
}
if market in {"jp", "kr", "tw"}:
query_options["include_ambiguous_numeric_aliases"] = False
result = self._history_service().get_history_list(**query_options)
reports = list(result.get("items") or [])
except Exception:
return self._unavailable("report_list_unavailable", data=empty_data)
if not reports:
return self._unavailable("no_reports", data=empty_data)
latest = reports[0]
record_id = latest.get("id")
if record_id is None:
return {
"status": "partial",
"data": {
"latest_report": latest,
"recent_reports": reports,
"structured_report": None,
},
"limitations": ["latest_report_id_unavailable"],
}
try:
detail = self._history_service().get_history_detail_by_id(int(record_id))
except Exception:
detail = None
if not detail:
return {
"status": "partial",
"data": {
"latest_report": latest,
"recent_reports": reports,
"structured_report": None,
},
"limitations": ["latest_report_detail_unavailable"],
}
try:
artifact = build_research_artifact(self._artifact_input(detail))
except Exception:
artifact = None
return {
"status": "fresh" if artifact else "partial",
"data": {
"latest_report": latest,
"recent_reports": reports,
"structured_report": artifact,
},
"limitations": [] if artifact else ["structured_report_unavailable"],
}
def _intelligence_block(self, code: str, *, market: str) -> Dict[str, Any]:
items_by_id: Dict[Any, Dict[str, Any]] = {}
successful_queries = 0
failed_queries = 0
markets = [market] if market == "global" else [market, "global"]
aliases = self._code_aliases(code, market_hint=market)
safe_global_aliases = set(
self._code_aliases(
code,
market_hint=market,
include_ambiguous_numeric=False,
)
)
for alias in aliases:
for query_market in markets:
if query_market == "global" and alias not in safe_global_aliases:
continue
try:
result = self._intelligence_service().list_items(
scope_type="symbol",
scope_value=alias,
market=query_market,
page=1,
page_size=10,
)
successful_queries += 1
except Exception:
failed_queries += 1
continue
for item in result.get("items") or []:
key = item.get("id")
if key is None:
key = (item.get("source_type"), item.get("url"), item.get("title"))
items_by_id.setdefault(key, item)
if successful_queries == 0:
return self._unavailable("intelligence_query_failed", items=[])
items = sorted(
items_by_id.values(),
key=lambda item: (
str(item.get("published_at") or item.get("fetched_at") or item.get("created_at") or ""),
int(item.get("id") or 0),
),
reverse=True,
)[:10]
if not items:
if failed_queries:
return {
"status": "partial",
"items": [],
"limitations": ["intelligence_alias_query_partial"],
}
return self._unavailable("no_symbol_intelligence", items=[])
return {
"status": "partial" if failed_queries else "fresh",
"items": items,
"limitations": ["intelligence_alias_query_partial"] if failed_queries else [],
}
def _portfolio_block(self, code: str, *, market: str) -> Dict[str, Any]:
try:
profile_identity = resolve_daily_stock_identity(code, market_hint=market)
identities = self._portfolio_repository().list_cached_position_identities()
matches = []
for position_market, symbol in identities:
normalized_market = str(position_market or "").strip().lower()
identity = resolve_daily_stock_identity(symbol, market_hint=normalized_market)
if identity is None or identity.market != normalized_market or identity.market != market:
continue
if self._same_profile_identity(identity, profile_identity):
matches.append(normalized_market)
except Exception:
return self._unavailable(
"portfolio_relation_unavailable",
data={"held": False, "matched_markets": []},
)
return {
"status": "partial",
"data": {"held": bool(matches), "matched_markets": list(dict.fromkeys(matches))},
"limitations": ["cached_positions_only"],
}
@staticmethod
def _same_profile_identity(position_identity: Any, profile_identity: Any) -> bool:
if profile_identity is None:
return False
position_codes = {
str(position_identity.normalized_code or "").strip().upper(),
str(position_identity.refill_code or "").strip().upper(),
} - {""}
profile_codes = {
str(profile_identity.normalized_code or "").strip().upper(),
str(profile_identity.refill_code or "").strip().upper(),
} - {""}
if position_codes & profile_codes:
return True
if position_identity.market in {"kr", "tw"} and not position_identity.refill_code:
profile_base = str(profile_identity.normalized_code or "").split(".", 1)[0]
return position_identity.normalized_code == profile_base
return False
def _monitor_block(self, code: str, *, market: str) -> Dict[str, Any]:
rules_by_id: Dict[Any, Dict[str, Any]] = {}
successful_queries = 0
failed_queries = 0
for alias in self._code_aliases(
code,
market_hint=market,
include_ambiguous_numeric=False,
):
page = 1
scanned = 0
try:
while True:
result = self._alert_service().list_rules(
target_scope="single_symbol",
target=alias,
page=page,
page_size=100,
)
successful_queries += 1
rules = list(result.get("items") or [])
for rule in rules:
key = rule.get("id")
if key is None:
key = (alias, rule.get("name"), rule.get("alert_type"))
rules_by_id.setdefault(key, rule)
scanned += len(rules)
total = int(result.get("total") or 0)
if scanned >= total or not rules:
break
page += 1
except Exception:
failed_queries += 1
if successful_queries == 0:
return self._unavailable(
"monitor_query_failed",
data={"total_rule_count": 0, "enabled_rule_count": 0, "rule_ids": []},
)
rules = list(rules_by_id.values())
return {
"status": "partial" if failed_queries else "fresh",
"data": {
"total_rule_count": len(rules),
"enabled_rule_count": sum(1 for rule in rules if rule.get("enabled")),
"rule_ids": [int(rule["id"]) for rule in rules if rule.get("id") is not None],
},
"limitations": ["monitor_alias_query_partial"] if failed_queries else [],
}
@staticmethod
def _code_aliases(
code: str,
*,
market_hint: Optional[str] = None,
include_ambiguous_numeric: bool = True,
) -> List[str]:
market = str(market_hint or StockProfileService.market_for_code(code)).strip().lower()
identity = resolve_daily_stock_identity(code, market_hint=market)
candidates = list(identity.code_candidates) if identity is not None and identity.market == market else [code]
if include_ambiguous_numeric and market in {"jp", "kr", "tw"} and "." in code:
numeric_base = code.split(".", 1)[0]
if numeric_base.isdigit():
candidates.append(numeric_base)
aliases: List[str] = []
seen_aliases = set()
for candidate in candidates or [code]:
candidate_text = str(candidate).strip()
if not include_ambiguous_numeric and candidate_text.isdigit():
unhinted_identity = resolve_daily_stock_identity(candidate_text)
same_market = unhinted_identity is not None and unhinted_identity.market == market
legacy_short_hk = (
market == "hk"
and len(candidate_text) <= 3
and (
hinted_identity := resolve_daily_stock_identity(
candidate_text,
market_hint="hk",
)
) is not None
and hinted_identity.market == "hk"
)
if not same_market and not legacy_short_hk:
continue
alias_key = candidate_text.casefold()
if candidate_text and alias_key not in seen_aliases:
seen_aliases.add(alias_key)
aliases.append(candidate_text)
return aliases
def _artifact_input(self, detail: Dict[str, Any]) -> Dict[str, Any]:
context_snapshot = detail.get("context_snapshot")
raw_result = detail.get("raw_result")
raw_fundamental = (
raw_result.get("fundamental_context") or raw_result
if isinstance(raw_result, dict)
else None
)
extracted_fundamental = extract_fundamental_detail_fields(
context_snapshot,
raw_fundamental,
)
try:
persisted_fundamental = self._history_service().get_latest_fundamental_snapshot(
query_id=str(detail.get("query_id") or "").strip(),
stock_code=str(
detail.get("storage_stock_code") or detail.get("stock_code") or ""
).strip(),
)
except Exception:
persisted_fundamental = None
persisted_fields = extract_fundamental_detail_fields(
None,
persisted_fundamental,
)
context_overview = extract_analysis_context_pack_overview(context_snapshot)
market_structure = extract_market_structure_detail_field(
context_snapshot,
raw_result,
)
return {
"meta": {
"id": detail.get("id"),
"query_id": detail.get("query_id"),
"stock_code": detail.get("stock_code"),
"stock_name": detail.get("stock_name"),
"created_at": detail.get("created_at"),
},
"summary": {
"analysis_summary": detail.get("analysis_summary"),
"operation_advice": detail.get("operation_advice"),
"action": detail.get("action"),
"action_label": detail.get("action_label"),
"trend_prediction": detail.get("trend_prediction"),
"sentiment_score": detail.get("sentiment_score"),
},
"strategy": {
"ideal_buy": detail.get("ideal_buy"),
"secondary_buy": detail.get("secondary_buy"),
"stop_loss": detail.get("stop_loss"),
"take_profit": detail.get("take_profit"),
},
"details": {
"news_content": detail.get("news_content"),
"empty_news_disclosure": detail.get("empty_news_disclosure"),
"analysis_context_pack_overview": context_overview,
"financial_report": detail.get("financial_report")
or extracted_fundamental.get("financial_report")
or persisted_fields.get("financial_report"),
"dividend_metrics": detail.get("dividend_metrics")
or extracted_fundamental.get("dividend_metrics")
or persisted_fields.get("dividend_metrics"),
"market_structure": detail.get("market_structure") or market_structure,
},
}
def _stock_service(self) -> StockService:
if self.stock_service is None:
self.stock_service = StockService()
return self.stock_service
def _history_service(self) -> HistoryService:
if self.history_service is None:
self.history_service = HistoryService()
return self.history_service
def _intelligence_service(self) -> IntelligenceService:
if self.intelligence_service is None:
self.intelligence_service = IntelligenceService()
return self.intelligence_service
def _portfolio_repository(self) -> PortfolioRepository:
if self.portfolio_repository is None:
self.portfolio_repository = PortfolioRepository()
return self.portfolio_repository
def _alert_service(self) -> AlertService:
if self.alert_service is None:
self.alert_service = AlertService()
return self.alert_service
@staticmethod
def _unavailable(limitation: str, **payload: Any) -> Dict[str, Any]:
return {"status": "unavailable", **payload, "limitations": [limitation]}
@staticmethod
def _evidence_quality(blocks: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
statuses = {name: str(blocks[name]["status"]) for name in _BLOCK_NAMES}
status_values = set(statuses.values())
if status_values == {"fresh"}:
overall = "fresh"
elif status_values == {"unavailable"}:
overall = "unavailable"
else:
overall = "partial"
limitations = []
for name in _BLOCK_NAMES:
limitations.extend(str(item) for item in blocks[name].get("limitations") or [])
return {
"status": overall,
"blocks": statuses,
"limitations": list(dict.fromkeys(limitations)),
}

View File

@@ -248,6 +248,19 @@ class AlertApiTestCase(unittest.TestCase):
self.assertEqual(payload["items"][0]["target"], "300750")
self.assertEqual(payload["items"][0]["parameters"]["change_pct"], 3.5)
def test_single_symbol_filter_is_case_insensitive_for_legacy_targets(self) -> None:
created = self._create_rule({"target": "Brk.B"})
resp = self.client.get(
"/api/v1/alerts/rules",
params={"target_scope": "single_symbol", "target": "BRK.B"},
)
self.assertEqual(resp.status_code, 200, resp.text)
self.assertEqual(resp.json()["total"], 1)
self.assertEqual(resp.json()["items"][0]["id"], created["id"])
self.assertEqual(resp.json()["items"][0]["target"], "Brk.B")
def test_create_p5_technical_indicator_rules(self) -> None:
cases = [
("ma_price_cross", {"direction": "above", "window": 20}),

View File

@@ -82,6 +82,66 @@ class TestHistoryCsiCandidateConvergence(unittest.TestCase):
candidates = HistoryService._history_code_filter_candidates("csi930956")
self.assertNotIn("csi930956", candidates)
def test_market_aware_offshore_lookup_keeps_same_market_bare_numeric_alias(self):
db = MagicMock()
db.get_analysis_history_paginated.return_value = ([], 0)
HistoryService(db).get_history_list(
stock_code="005930.KS",
page=1,
limit=5,
include_ambiguous_numeric_aliases=False,
market_hint="kr",
)
queried_codes = db.get_analysis_history_paginated.call_args.kwargs["code"]
self.assertIn("005930.KS", queried_codes)
self.assertIn("005930", queried_codes)
def test_market_hint_blocks_indexed_cross_market_reexpansion(self):
db = MagicMock()
db.get_analysis_history_paginated.return_value = ([], 0)
HistoryService(db).get_history_list(
stock_code="000660",
page=1,
limit=5,
market_hint="cn",
)
queried_codes = db.get_analysis_history_paginated.call_args.kwargs["code"]
self.assertIn("SZ000660", queried_codes)
self.assertIn("000660.SZ", queried_codes)
self.assertNotIn("000660", queried_codes)
self.assertNotIn("000660.KS", queried_codes)
def test_market_hint_keeps_unambiguous_same_market_bare_numeric_alias(self):
db = MagicMock()
db.get_analysis_history_paginated.return_value = ([], 0)
HistoryService(db).get_history_list(
stock_code="600519",
page=1,
limit=5,
market_hint="cn",
)
queried_codes = db.get_analysis_history_paginated.call_args.kwargs["code"]
self.assertIn("600519", queried_codes)
def test_empty_market_qualified_candidate_set_fails_closed(self):
db = MagicMock()
result = HistoryService(db).get_history_list(
stock_code="AAPL",
page=1,
limit=5,
market_hint="cn",
)
self.assertEqual(result, {"total": 0, "items": []})
db.get_analysis_history_paginated.assert_not_called()
def _analysis_context_pack_overview() -> dict:
return {

View File

@@ -422,6 +422,50 @@ def test_malformed_scoped_container_does_not_hide_other_regions() -> None:
assert "latest_completed_snapshot_unavailable:us" not in limitations
@pytest.mark.parametrize("include_empty_container", [False, True])
def test_scoped_review_without_snapshots_does_not_promote_older_current(
include_empty_container: bool,
) -> None:
dependencies = _dependencies()
reviews = [
_review(1, "2026-08-30T09:00:00+08:00"),
_review(2, "2026-08-29T09:00:00+08:00"),
_review(3, "2026-08-28T09:00:00+08:00"),
]
dependencies["history_service"].get_history_list.side_effect = lambda **kwargs: (
_history_page(dependencies["history_service"], reviews, kwargs)
if kwargs.get("report_type") == "market_review"
else {"items": [], "total": 0}
)
def detail(record_id: int) -> dict:
if record_id == 1:
context_snapshot = {"market_review_region": "cn"}
if include_empty_container:
context_snapshot["market_light_snapshots"] = {}
return {"context_snapshot": context_snapshot}
trade_date = "2026-08-29" if record_id == 2 else "2026-08-28"
return {
"context_snapshot": {
"market_light_snapshots": {
"cn": _snapshot("cn", trade_date, 60, "yellow")
}
}
}
dependencies["history_service"].get_history_detail_by_id.side_effect = detail
payload = DashboardOverviewService(**dependencies).get_overview()
assert payload["market"]["data"]["latest_snapshots"] == {}
assert payload["what_changed"]["data"]["current_trade_dates"] == {}
assert payload["what_changed"]["data"]["previous_trade_dates"] == {}
assert payload["what_changed"]["data"]["items"] == []
limitations = payload["market"]["meta"]["limitations"]
assert "market_review_detail_partial" in limitations
assert "latest_completed_snapshot_unavailable:cn" in limitations
def test_malformed_multi_region_container_blocks_each_scoped_region() -> None:
dependencies = _dependencies()
combined_review = _review(1, "2026-08-29T09:00:00+08:00")

View File

@@ -157,6 +157,34 @@ class IntelligenceServiceTestCase(unittest.TestCase):
self.assertEqual(items["items"][0]["scope_type"], "market")
self.assertTrue(items["items"][0]["url"].startswith("https://news.example.com/"))
def test_symbol_scope_filter_is_case_insensitive_for_legacy_items(self) -> None:
now = datetime.now()
saved = self.service.repo.upsert_items([
{
"source_name": "legacy-symbol-feed",
"source_type": "rss",
"title": "Mixed-case symbol item",
"summary": "Legacy symbol identity",
"url": "https://news.example.com/mixed-symbol",
"source": "legacy-symbol-feed",
"published_at": now,
"fetched_at": now,
"scope_type": "symbol",
"scope_value": "AaPl",
"market": "us",
}
])
items = self.service.list_items(
scope_type="symbol",
scope_value="AAPL",
market="us",
)
self.assertEqual(saved, 1)
self.assertEqual(items["total"], 1)
self.assertEqual(items["items"][0]["scope_value"], "AaPl")
def test_fetch_http_error_does_not_expose_source_query_secret(self) -> None:
secret_url = "https://feeds.example.com/rss.xml?token=super-secret"
source = self.service.create_source({

View File

@@ -76,7 +76,7 @@ class TestTavilySearchProvider(unittest.TestCase):
self.assertEqual(_FakeTavilyClient.search_calls[0]["topic"], "news")
self.assertEqual(_FakeTavilyClient.search_calls[0]["days"], 3)
self.assertEqual(_FakeTavilyClient.search_calls[0]["max_results"], 5)
self.assertEqual(_FakeTavilyClient.search_calls[0]["search_depth"], "advanced")
self.assertEqual(_FakeTavilyClient.search_calls[0]["search_depth"], "basic")
self.assertEqual(len(resp.results), 1)
self.assertEqual(resp.results[0].published_date, published_text)
self.assertEqual(resp.results[0].url, "https://example.com/alibaba-earnings")

View File

@@ -94,8 +94,14 @@ class TestBuildDailyCodeCandidates:
assert identity.market == "kr"
assert identity.code_candidates == ("005930.KS", "005930")
def test_bare_code_with_unsupported_market_hint_fails_closed(self):
assert resolve_daily_stock_identity("005930", market_hint="tw") is None
def test_bare_taiwan_code_uses_explicit_market_hint(self):
identity = resolve_daily_stock_identity("005930", market_hint="tw")
assert identity is not None
assert identity.normalized_code == "005930"
assert identity.market == "tw"
assert identity.refill_code == ""
assert identity.code_candidates == ("005930",)
def test_cross_market_bare_code_without_hint_fails_closed(self):
assert resolve_daily_stock_identity("8035") is None

View File

@@ -0,0 +1,708 @@
# -*- coding: utf-8 -*-
"""Contract tests for the stock profile aggregate endpoint."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
import pytest
import src.auth as auth
from api.app import create_app
from src.config import Config
from src.services.stock_profile_service import InvalidStockProfileCode, StockProfileService
from src.storage import DatabaseManager
def test_stock_profile_service_import_is_independent_of_api_bootstrap() -> None:
result = subprocess.run(
[
sys.executable,
"-c",
"from src.services.stock_profile_service import StockProfileService; "
"assert StockProfileService.__name__ == 'StockProfileService'",
],
cwd=Path(__file__).resolve().parents[1],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
def _quote(code: str = "AAPL") -> dict:
return {
"stock_code": code,
"stock_name": "Apple",
"current_price": 200.0,
"change": 1.0,
"change_percent": 0.5,
"update_time": "2026-08-29T10:00:00+08:00",
}
def _history() -> dict:
return {
"data": [
{
"date": "2026-08-28",
"open": 198.0,
"high": 202.0,
"low": 197.0,
"close": 200.0,
"volume": 1000.0,
}
]
}
def _report_list() -> dict:
return {
"items": [
{
"id": 12,
"query_id": "query-12",
"stock_code": "AAPL",
"stock_name": "Apple",
"analysis_summary": "Demand remains resilient",
"sentiment_score": 70,
"created_at": "2026-08-28T12:00:00+08:00",
}
],
"total": 1,
}
def _report_detail() -> dict:
return {
"id": 12,
"query_id": "query-12",
"stock_code": "AAPL",
"stock_name": "Apple",
"analysis_summary": "Demand remains resilient",
"operation_advice": "Watch",
"action": "watch",
"action_label": "Watch",
"trend_prediction": "Neutral",
"sentiment_score": 70,
"stop_loss": "180",
"created_at": "2026-08-28T12:00:00+08:00",
"context_snapshot": None,
}
def _intelligence(code: str = "AAPL", market: str = "us") -> dict:
return {
"items": [
{
"id": 5,
"source_type": "rss",
"title": "Company update",
"url": "https://example.com/update",
"scope_type": "symbol",
"scope_value": code,
"market": market,
}
],
"total": 1,
}
def _service(**overrides: object) -> tuple[StockProfileService, dict[str, MagicMock]]:
dependencies = {
"stock_service": MagicMock(),
"history_service": MagicMock(),
"intelligence_service": MagicMock(),
"portfolio_repository": MagicMock(),
"alert_service": MagicMock(),
}
dependencies.update(overrides)
dependencies["stock_service"].get_realtime_quote.return_value = _quote()
dependencies["stock_service"].get_history_data.return_value = _history()
dependencies["history_service"].get_history_list.return_value = _report_list()
dependencies["history_service"].get_history_detail_by_id.return_value = _report_detail()
dependencies["history_service"].get_latest_fundamental_snapshot.return_value = None
dependencies["intelligence_service"].list_items.return_value = _intelligence()
dependencies["portfolio_repository"].list_cached_position_identities.return_value = [("us", "aapl")]
dependencies["alert_service"].list_rules.return_value = {
"items": [{"id": 8, "enabled": True}, {"id": 9, "enabled": False}],
"total": 2,
}
return StockProfileService(**dependencies), dependencies
def test_profile_uses_one_canonical_code_and_returns_structured_research() -> None:
service, dependencies = _service()
payload = service.get_profile("aapl", history_days=45)
assert payload["canonical_code"] == "AAPL"
assert payload["market"] == "us"
assert payload["quote"]["status"] == "fresh"
assert payload["history"]["status"] == "fresh"
assert payload["research"]["status"] == "fresh"
assert payload["research"]["data"]["structured_report"]["artifact_id"] == "report:12"
assert payload["portfolio"]["data"] == {"held": True, "matched_markets": ["us"]}
assert payload["monitors"]["data"] == {
"total_rule_count": 2,
"enabled_rule_count": 1,
"rule_ids": [8, 9],
}
assert payload["evidence_quality"]["status"] == "partial"
dependencies["stock_service"].get_realtime_quote.assert_called_once_with("AAPL")
dependencies["stock_service"].get_history_data.assert_called_once_with(
"AAPL", period="daily", days=45
)
dependencies["history_service"].get_history_list.assert_called_once_with(
stock_code="AAPL", page=1, limit=5, market_hint="us"
)
assert {call.kwargs["scope_value"] for call in dependencies["intelligence_service"].list_items.call_args_list} == {
"AAPL",
"AAPL.US",
}
assert {call.kwargs["target"] for call in dependencies["alert_service"].list_rules.call_args_list} == {
"AAPL",
"AAPL.US",
}
assert dependencies["intelligence_service"].list_items.call_count == 4
assert dependencies["alert_service"].list_rules.call_count == 2
def test_profile_alias_queries_are_unique_by_casefolded_identity() -> None:
aliases = StockProfileService._code_aliases("600519", market_hint="cn")
assert len(aliases) == len({alias.casefold() for alias in aliases})
@pytest.mark.parametrize("code", ["600519.SZ", "000001.SH", "920748.SH"])
def test_profile_rejects_exchange_conflicts_before_downstream_queries(code: str) -> None:
service, dependencies = _service()
with pytest.raises(InvalidStockProfileCode):
service.get_profile(code)
dependencies["stock_service"].get_realtime_quote.assert_not_called()
dependencies["history_service"].get_history_list.assert_not_called()
def test_profile_research_preserves_specialized_report_evidence() -> None:
service, dependencies = _service()
detail = _report_detail()
detail["context_snapshot"] = {
"fundamental_context": {
"earnings": {
"data": {
"financial_report": {"report_date": "2026-06-30"},
"dividend": {"ttm_cash_dividend_per_share": 1.2},
}
}
}
}
detail["raw_result"] = {
"market_structure_context": {
"schema_version": "market-structure-v1",
"status": "available",
"market_theme_context": {"schema_version": "market-theme-v1"},
"stock_market_position": {"schema_version": "stock-market-position-v1"},
}
}
dependencies["history_service"].get_history_detail_by_id.return_value = detail
payload = service.get_profile("AAPL")
artifact = payload["research"]["data"]["structured_report"]
evidence_ids = {item["id"] for item in artifact["evidence"]}
assert {
"fundamental:financial_report",
"fundamental:dividend_metrics",
"market:structure",
} <= evidence_ids
assert artifact["data_quality"]["source_count"] == len(artifact["evidence"])
def test_profile_research_reads_independent_fundamental_snapshot() -> None:
service, dependencies = _service()
detail = _report_detail()
detail["storage_stock_code"] = "AAPL.US"
dependencies["history_service"].get_history_detail_by_id.return_value = detail
dependencies["history_service"].get_latest_fundamental_snapshot.return_value = {
"earnings": {
"data": {
"financial_report": {"report_date": "2026-06-30"},
"dividend": {"ttm_cash_dividend_per_share": 1.2},
}
}
}
payload = service.get_profile("AAPL")
artifact = payload["research"]["data"]["structured_report"]
evidence_ids = {item["id"] for item in artifact["evidence"]}
assert {
"fundamental:financial_report",
"fundamental:dividend_metrics",
} <= evidence_ids
dependencies["history_service"].get_latest_fundamental_snapshot.assert_called_once_with(
query_id="query-12",
stock_code="AAPL.US",
)
def test_hk_alias_is_canonicalized_before_every_downstream_query() -> None:
service, dependencies = _service()
dependencies["stock_service"].get_realtime_quote.return_value = _quote("HK00700")
dependencies["history_service"].get_history_list.return_value = {"items": [], "total": 0}
dependencies["intelligence_service"].list_items.return_value = _intelligence("HK00700", "hk")
payload = service.get_profile("00700.HK")
assert payload["canonical_code"] == "HK00700"
assert payload["market"] == "hk"
dependencies["stock_service"].get_realtime_quote.assert_called_once_with("HK00700")
dependencies["history_service"].get_history_list.assert_called_once_with(
stock_code="HK00700", page=1, limit=5, market_hint="hk"
)
assert "00700.HK" in {
call.kwargs["scope_value"]
for call in dependencies["intelligence_service"].list_items.call_args_list
}
assert "00700.HK" in {
call.kwargs["target"] for call in dependencies["alert_service"].list_rules.call_args_list
}
def test_jp_and_kr_codes_preserve_shared_market_identity() -> None:
jp_service, _ = _service()
kr_service, kr_dependencies = _service()
jp_payload = jp_service.get_profile("7203.T")
kr_payload = kr_service.get_profile("005930")
assert jp_payload["canonical_code"] == "7203.T"
assert jp_payload["market"] == "jp"
assert kr_payload["canonical_code"] == "005930.KS"
assert kr_payload["market"] == "kr"
assert {call.kwargs["market"] for call in kr_dependencies["intelligence_service"].list_items.call_args_list} == {
"kr",
"global",
}
def test_portfolio_identity_uses_cached_market_hint_and_requires_same_market() -> None:
jp_service, jp_dependencies = _service()
jp_dependencies["portfolio_repository"].list_cached_position_identities.return_value = [
("jp", "8035"),
]
kr_service, kr_dependencies = _service()
kr_dependencies["portfolio_repository"].list_cached_position_identities.return_value = [
("cn", "005930"),
]
jp_payload = jp_service.get_profile("8035.T")
kr_payload = kr_service.get_profile("005930.KS")
assert jp_payload["portfolio"]["data"] == {"held": True, "matched_markets": ["jp"]}
assert kr_payload["portfolio"]["data"] == {"held": False, "matched_markets": []}
def test_legacy_bare_korean_position_keeps_market_hint_during_profile_match() -> None:
service, dependencies = _service()
dependencies["portfolio_repository"].list_cached_position_identities.return_value = [
("kr", "123456"),
]
payload = service.get_profile("123456.KS")
assert payload["portfolio"]["data"] == {"held": True, "matched_markets": ["kr"]}
def test_short_hk_cached_position_uses_its_market_hint() -> None:
service, dependencies = _service()
dependencies["portfolio_repository"].list_cached_position_identities.return_value = [
("hk", "700"),
]
payload = service.get_profile("HK00700")
assert payload["portfolio"]["data"] == {"held": True, "matched_markets": ["hk"]}
def test_bare_taiwan_cached_position_uses_its_market_hint() -> None:
service, dependencies = _service()
dependencies["portfolio_repository"].list_cached_position_identities.return_value = [
("tw", "2330"),
]
payload = service.get_profile("2330.TW")
assert payload["portfolio"]["data"] == {"held": True, "matched_markets": ["tw"]}
def test_taiwan_intelligence_reads_bare_alias_only_with_market_scope() -> None:
service, dependencies = _service()
def intelligence_by_alias(**kwargs: object) -> dict:
if kwargs.get("scope_value") == "2330" and kwargs.get("market") == "tw":
return _intelligence("2330", "tw")
return {"items": [], "total": 0}
dependencies["intelligence_service"].list_items.side_effect = intelligence_by_alias
payload = service.get_profile("2330.TW")
calls = {
(call.kwargs["scope_value"], call.kwargs["market"])
for call in dependencies["intelligence_service"].list_items.call_args_list
}
assert ("2330", "tw") in calls
assert ("2330", "global") not in calls
assert payload["intelligence"]["items"][0]["scope_value"] == "2330"
def test_japan_intelligence_reads_bare_alias_only_with_market_scope() -> None:
service, dependencies = _service()
def intelligence_by_alias(**kwargs: object) -> dict:
if kwargs.get("scope_value") == "8035" and kwargs.get("market") == "jp":
return _intelligence("8035", "jp")
return {"items": [], "total": 0}
dependencies["intelligence_service"].list_items.side_effect = intelligence_by_alias
payload = service.get_profile("8035.T")
calls = {
(call.kwargs["scope_value"], call.kwargs["market"])
for call in dependencies["intelligence_service"].list_items.call_args_list
}
assert ("8035", "jp") in calls
assert ("8035", "global") not in calls
assert payload["intelligence"]["items"][0]["scope_value"] == "8035"
def test_offshore_research_lookup_excludes_cross_market_bare_numeric_aliases() -> None:
service, dependencies = _service()
dependencies["history_service"].get_history_list.return_value = {"items": [], "total": 0}
payload = service.get_profile("8035.T")
assert payload["research"]["status"] == "unavailable"
dependencies["history_service"].get_history_list.assert_called_once_with(
stock_code="8035.T",
page=1,
limit=5,
market_hint="jp",
include_ambiguous_numeric_aliases=False,
)
def test_offshore_monitor_lookup_keeps_market_unique_bare_numeric_alias() -> None:
service, dependencies = _service()
def rules_by_alias(**kwargs: object) -> dict:
if kwargs.get("target") == "005930":
return {"items": [{"id": 99, "enabled": True}], "total": 1}
return {"items": [], "total": 0}
dependencies["alert_service"].list_rules.side_effect = rules_by_alias
payload = service.get_profile("005930.KS")
queried_targets = {
call.kwargs["target"] for call in dependencies["alert_service"].list_rules.call_args_list
}
assert "005930" in queried_targets
assert "005930.KS" in queried_targets
assert payload["monitors"]["data"]["total_rule_count"] == 1
def test_hk_monitor_lookup_keeps_unpadded_legacy_target() -> None:
service, dependencies = _service()
def rules_by_alias(**kwargs: object) -> dict:
if kwargs.get("target") == "700":
return {"items": [{"id": 77, "enabled": True}], "total": 1}
return {"items": [], "total": 0}
dependencies["alert_service"].list_rules.side_effect = rules_by_alias
payload = service.get_profile("HK00700")
queried_targets = {
call.kwargs["target"] for call in dependencies["alert_service"].list_rules.call_args_list
}
assert "700" in queried_targets
assert payload["monitors"]["data"]["rule_ids"] == [77]
def test_profile_collects_intelligence_and_monitors_saved_under_legacy_aliases() -> None:
service, dependencies = _service()
def intelligence_by_alias(**kwargs: object) -> dict:
if kwargs.get("scope_value") == "600519.SH":
return _intelligence("600519.SH", "cn")
return {"items": [], "total": 0}
def rules_by_alias(**kwargs: object) -> dict:
if kwargs.get("target") == "SH600519":
return {"items": [{"id": 88, "enabled": True}], "total": 1}
return {"items": [], "total": 0}
dependencies["intelligence_service"].list_items.side_effect = intelligence_by_alias
dependencies["alert_service"].list_rules.side_effect = rules_by_alias
payload = service.get_profile("600519")
assert payload["intelligence"]["status"] == "fresh"
assert payload["intelligence"]["items"][0]["scope_value"] == "600519.SH"
assert payload["monitors"]["data"] == {
"total_rule_count": 1,
"enabled_rule_count": 1,
"rule_ids": [88],
}
def test_explicit_cn_identity_never_reexpands_through_a_colliding_kr_alias() -> None:
service, dependencies = _service()
dependencies["history_service"].get_history_list.return_value = {"items": [], "total": 0}
payload = service.get_profile("SZ000660")
assert payload["canonical_code"] == "000660"
assert payload["market"] == "cn"
dependencies["history_service"].get_history_list.assert_called_once_with(
stock_code="000660",
page=1,
limit=5,
market_hint="cn",
)
intelligence_aliases = {
call.kwargs["scope_value"]
for call in dependencies["intelligence_service"].list_items.call_args_list
}
monitor_aliases = {
call.kwargs["target"] for call in dependencies["alert_service"].list_rules.call_args_list
}
intelligence_calls = {
(call.kwargs["scope_value"], call.kwargs["market"])
for call in dependencies["intelligence_service"].list_items.call_args_list
}
assert "000660.KS" not in intelligence_aliases
assert "000660.KS" not in monitor_aliases
assert ("000660", "cn") in intelligence_calls
assert ("000660", "global") not in intelligence_calls
assert {"SZ000660", "000660.SZ"} <= intelligence_aliases
assert {"SZ000660", "000660.SZ"} <= monitor_aliases
def test_unambiguous_cn_bare_code_remains_available_to_global_and_monitor_queries() -> None:
service, dependencies = _service()
service.get_profile("600519")
intelligence_calls = {
(call.kwargs["scope_value"], call.kwargs["market"])
for call in dependencies["intelligence_service"].list_items.call_args_list
}
monitor_targets = {
call.kwargs["target"] for call in dependencies["alert_service"].list_rules.call_args_list
}
assert ("600519", "global") in intelligence_calls
assert "600519" in monitor_targets
def test_us_suffix_converges_to_bare_ticker_and_queries_legacy_aliases() -> None:
service, dependencies = _service()
payload = service.get_profile("AAPL.US")
assert payload["canonical_code"] == "AAPL"
assert payload["market"] == "us"
assert payload["portfolio"]["data"]["held"] is True
dependencies["stock_service"].get_realtime_quote.assert_called_once_with("AAPL")
dependencies["history_service"].get_history_list.assert_called_once_with(
stock_code="AAPL", page=1, limit=5, market_hint="us"
)
assert {"AAPL", "AAPL.US"} <= {
call.kwargs["scope_value"]
for call in dependencies["intelligence_service"].list_items.call_args_list
}
assert {"AAPL", "AAPL.US"} <= {
call.kwargs["target"] for call in dependencies["alert_service"].list_rules.call_args_list
}
def test_profile_includes_global_symbol_intelligence() -> None:
service, dependencies = _service()
def intelligence_by_market(**kwargs: object) -> dict:
if kwargs.get("scope_value") == "AAPL" and kwargs.get("market") == "global":
return _intelligence("AAPL", "global")
return {"items": [], "total": 0}
dependencies["intelligence_service"].list_items.side_effect = intelligence_by_market
payload = service.get_profile("AAPL")
assert payload["intelligence"]["status"] == "fresh"
assert payload["intelligence"]["items"][0]["market"] == "global"
def test_empty_intelligence_keeps_partial_status_when_any_alias_query_fails() -> None:
service, dependencies = _service()
def partially_failing_query(**kwargs: object) -> dict:
if kwargs.get("scope_value") == "AAPL.US" and kwargs.get("market") == "us":
raise RuntimeError("transient query failure")
return {"items": [], "total": 0}
dependencies["intelligence_service"].list_items.side_effect = partially_failing_query
payload = service.get_profile("AAPL")
assert payload["intelligence"] == {
"status": "partial",
"items": [],
"limitations": ["intelligence_alias_query_partial"],
}
def test_optional_block_failures_remain_partial_and_do_not_hide_monitor_data() -> None:
service, dependencies = _service()
dependencies["stock_service"].get_realtime_quote.side_effect = RuntimeError("quote failed")
dependencies["stock_service"].get_history_data.return_value = {"data": []}
dependencies["history_service"].get_history_detail_by_id.return_value = None
dependencies["intelligence_service"].list_items.side_effect = RuntimeError("intel failed")
dependencies["portfolio_repository"].list_cached_position_identities.side_effect = RuntimeError("db failed")
payload = service.get_profile("AAPL")
assert payload["quote"]["status"] == "unavailable"
assert payload["history"]["status"] == "unavailable"
assert payload["research"]["status"] == "partial"
assert payload["research"]["data"]["recent_reports"][0]["id"] == 12
assert payload["intelligence"]["status"] == "unavailable"
assert payload["portfolio"]["status"] == "unavailable"
assert payload["monitors"]["status"] == "fresh"
assert payload["evidence_quality"]["status"] == "partial"
assert "latest_report_detail_unavailable" in payload["evidence_quality"]["limitations"]
def test_all_dependency_failures_return_unavailable_profile_instead_of_raising() -> None:
service, dependencies = _service()
for dependency, method in (
("stock_service", "get_realtime_quote"),
("stock_service", "get_history_data"),
("history_service", "get_history_list"),
("intelligence_service", "list_items"),
("portfolio_repository", "list_cached_position_identities"),
("alert_service", "list_rules"),
):
getattr(dependencies[dependency], method).side_effect = RuntimeError("offline")
payload = service.get_profile("600519")
assert payload["market"] == "cn"
assert payload["evidence_quality"]["status"] == "unavailable"
assert set(payload["evidence_quality"]["blocks"].values()) == {"unavailable"}
def _reset_auth_globals() -> None:
auth._auth_enabled = None
auth._session_secret = None
auth._password_hash_salt = None
auth._password_hash_stored = None
auth._rate_limit = {}
def _endpoint_payload() -> dict:
service, _ = _service()
return service.get_profile("AAPL")
def test_profile_endpoint_validates_code_and_exposes_contract() -> None:
_reset_auth_globals()
with tempfile.TemporaryDirectory() as temp_dir:
try:
os.environ["DATABASE_PATH"] = str(Path(temp_dir) / "profile.db")
os.environ["ADMIN_AUTH_ENABLED"] = "false"
Config.reset_instance()
DatabaseManager.reset_instance()
app = create_app(static_dir=Path(temp_dir) / "empty-static")
client = TestClient(app)
with patch(
"api.v1.endpoints.stocks.StockProfileService.get_profile",
return_value=_endpoint_payload(),
) as get_profile:
response = client.get("/api/v1/stocks/AAPL/profile", params={"history_days": 90})
jp = client.get("/api/v1/stocks/7203.T/profile")
kr = client.get("/api/v1/stocks/005930.KS/profile")
tw = client.get("/api/v1/stocks/2330.TW/profile")
two = client.get("/api/v1/stocks/6505.TWO/profile")
tw_etf = client.get("/api/v1/stocks/006208.TW/profile")
invalid = client.get("/api/v1/stocks/invalid-code/profile")
conflicts = [
client.get(f"/api/v1/stocks/{code}/profile")
for code in ("600519.SZ", "000001.SH", "920748.SH")
]
assert response.status_code == 200, response.text
assert response.json()["canonical_code"] == "AAPL"
assert [item.args for item in get_profile.call_args_list] == [
("AAPL",),
("7203.T",),
("005930.KS",),
("2330.TW",),
("6505.TWO",),
("006208.TW",),
]
assert [item.kwargs for item in get_profile.call_args_list] == [
{"history_days": 90},
{"history_days": 60},
{"history_days": 60},
{"history_days": 60},
{"history_days": 60},
{"history_days": 60},
]
assert invalid.status_code == 400
assert [item.status_code for item in conflicts] == [400, 400, 400]
assert {
item.json()["error"]
for item in conflicts
} == {"invalid_stock_code"}
assert jp.status_code == 200
assert kr.status_code == 200
assert tw.status_code == 200
assert two.status_code == 200
assert tw_etf.status_code == 200
finally:
DatabaseManager.reset_instance()
Config.reset_instance()
os.environ.pop("DATABASE_PATH", None)
os.environ.pop("ADMIN_AUTH_ENABLED", None)
_reset_auth_globals()
def test_static_openapi_matches_stock_profile_runtime_contract() -> None:
static_spec = json.loads(
(Path(__file__).resolve().parents[1] / "docs" / "architecture" / "api_spec.json").read_text(
encoding="utf-8"
)
)
runtime_spec = create_app().openapi()
api_path = "/api/v1/stocks/{stock_code}/profile"
assert static_spec["paths"][api_path] == runtime_spec["paths"][api_path]
schema_names = [
name for name in runtime_spec["components"]["schemas"] if name.startswith("StockProfile")
]
assert schema_names
for schema_name in schema_names:
assert static_spec["components"]["schemas"][schema_name] == runtime_spec["components"]["schemas"][schema_name]