mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
fix: preserve dashboard snapshot integrity after main sync
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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-a(capital_heat,请求挂起),再点 run-b(dual_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-2c71d8af:handleSubmit 必须解除自动恢复门闩并作废飞行中的恢复请求,
|
||||
// 否则新任务的轮询会被阻塞到旧请求超时,页面假死在提交进度。
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 实际消费的搜索工具结果,而非分析结束后为持久化情报而补打的查询。
|
||||
|
||||
@@ -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 合规资讯源配置、测试、拉取、去重、存储、查询与安全边界 |
|
||||
|
||||
@@ -46,6 +46,7 @@ This is the entry point for project documentation. The README covers the project
|
||||
| [Bot Platform Docs](bot/) <sub><sub></sub></sub> (Chinese-only) | Feishu, DingTalk, Discord, and related Bot configuration screenshots and notes |
|
||||
| [Real-Time Alert Center](alerts.md) <sub><sub></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></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></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></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></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></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
@@ -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
41
docs/stock-profile-api.md
Normal 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 入口接受 4–6 位 `.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、测试和文档。没有数据库迁移、配置变更或数据清理步骤。
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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}:
|
||||
|
||||
474
src/services/stock_profile_service.py
Normal file
474
src/services/stock_profile_service.py
Normal 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)),
|
||||
}
|
||||
@@ -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}),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
708
tests/test_stock_profile_api.py
Normal file
708
tests/test_stock_profile_api.py
Normal 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]
|
||||
Reference in New Issue
Block a user