feat(data): 接入 TickFlow 核心 A 股数据源 (#1790)

* feat(data): integrate TickFlow core A-share provider

* fix(data): harden TickFlow daily kline contract

* fix(web): localize TickFlow settings and run-flow coverage

* fix(web): clarify TickFlow priority settings

* chore(deps): require verified TickFlow SDK

---------

Co-authored-by: timeance <timeance@users.noreply.github.com>
This commit is contained in:
zariba
2026-06-28 17:09:28 +08:00
committed by GitHub
parent ade3b4cb6e
commit 94cd2a4d96
22 changed files with 2069 additions and 355 deletions

View File

@@ -22,8 +22,11 @@ ANSPIRE_API_KEYS=
# 数据源配置
# Tushare Pro Token可选从 https://tushare.pro/weborder/#/login?reg=834638 获取)
TUSHARE_TOKEN=
# TickFlow API Key可选用于 A 股大盘复盘指数增强;若套餐支持标的池查询,也可增强市场统计
# TickFlow API Key可选用于 A 股日 K、实时行情、股票列表/名称与大盘复盘增强,权限不足自动回退
# TICKFLOW_API_KEY=
# TICKFLOW_KLINE_ADJUST=none # none/forward/backward/forward_additive/backward_additive
# TICKFLOW_BATCH_DAILY_ENABLED=true # 有权限时通过 TickFlow 批量预取日 K
# TICKFLOW_BATCH_SIZE=100 # TickFlow 单次批量请求的最大标的数
# AlphaSift 选股集成(默认关闭;通常由 Web“开启选股”按钮维护
ALPHASIFT_ENABLED=false
@@ -822,6 +825,7 @@ ADMIN_AUTH_ENABLED=false
# # when eastmoney hosts are unreachable. Default: 30
# AKSHARE_PRIORITY=1 # AkShare (China) - default: 1
# TUSHARE_PRIORITY=2 # Tushare Pro (China) - default: 2
# TICKFLOW_PRIORITY=2 # TickFlowA 股)- 默认2可选需配置 TICKFLOW_API_KEY
# PYTDX_PRIORITY=2 # Tongdaxin (China) - default: 2
#
# Pytdx custom server (for intranet/deploy): use custom host instead of built-in public servers
@@ -844,10 +848,12 @@ ADMIN_AUTH_ENABLED=false
# - akshare_sina: 新浪财经,基本行情,无量比,但非常稳定
# - efinance: 东财(efinance库),有量比,全量拉取易被封
# - akshare_em: 东财(akshare库),数据最全,全量拉取易被封
# - tickflow: TickFlow可选排在优先级前两位时支持按当前标的批量预取
# - tushare: Tushare Pro需要2000积分数据全面付费用户推荐
#
# 默认优先级tencent > akshare_sina > efinance > akshare_em
# 如果有 Tushare Pro 高积分账号,可将 tushare 放在首位:
# REALTIME_SOURCE_PRIORITY=tickflow,tencent,akshare_sina,efinance,akshare_em
# REALTIME_SOURCE_PRIORITY=tushare,tencent,akshare_sina,efinance,akshare_em
# REALTIME_SOURCE_PRIORITY=tencent,akshare_sina,efinance,akshare_em

View File

@@ -352,6 +352,92 @@ describe('RunFlowPanel', () => {
expect(screen.getByRole('button', { name: '收起尝试' })).toBeInTheDocument();
});
it('renders TickFlow realtime fallback attempts through generic provider groups', async () => {
const tickFlowProviderAttemptSnapshot: RunFlowSnapshot = {
...snapshot,
nodes: [
{
id: 'task_queue',
lane: 'entry',
kind: 'queue',
label: 'Task queue',
status: 'success',
},
{
id: 'provider_realtime_quote_tickflowfetcher_1',
lane: 'data_source',
kind: 'data_source',
label: '实时行情 · TickFlowFetcher',
provider: 'TickFlowFetcher',
status: 'failed',
durationMs: 892,
metadata: { data_type: 'realtime_quote', attempt: 1 },
},
{
id: 'provider_realtime_quote_aksharefetcher_2',
lane: 'data_source',
kind: 'data_source',
label: '实时行情 · AkshareFetcher',
provider: 'AkshareFetcher',
status: 'success',
durationMs: 8700,
recordCount: 1,
metadata: { data_type: 'realtime_quote', attempt: 2 },
},
{
id: 'context_pack',
lane: 'analysis',
kind: 'analysis',
label: 'ContextPack',
status: 'success',
},
],
edges: [
{
id: 'queue-quote-1',
from: 'task_queue',
to: 'provider_realtime_quote_tickflowfetcher_1',
kind: 'control',
status: 'failed',
},
{
id: 'quote-1-quote-2',
from: 'provider_realtime_quote_tickflowfetcher_1',
to: 'provider_realtime_quote_aksharefetcher_2',
kind: 'fallback',
status: 'success',
},
{
id: 'quote-context',
from: 'provider_realtime_quote_aksharefetcher_2',
to: 'context_pack',
kind: 'data',
status: 'success',
},
],
events: [],
};
vi.mocked(analysisApi.getTaskFlow).mockResolvedValue(tickFlowProviderAttemptSnapshot);
render(<RunFlowPanel source={{ type: 'task', taskId: 'task-1' }} />);
const group = await screen.findByTestId('run-flow-node-topology_data_realtime_quote');
expect(group).toHaveTextContent('TickFlowFetcher -> AkshareFetcher');
expect(screen.queryByTestId('run-flow-node-provider_realtime_quote_tickflowfetcher_1')).not.toBeInTheDocument();
const details = await screen.findByTestId('run-flow-node-details');
expect(details).toHaveTextContent('TickFlowFetcher -> AkshareFetcher');
expect(details).toHaveTextContent('TickFlowFetcher');
expect(details).toHaveTextContent('AkshareFetcher');
fireEvent.click(screen.getByTestId('run-flow-node-topology_data_realtime_quote-toggle'));
expect(await screen.findByTestId('run-flow-node-provider_realtime_quote_tickflowfetcher_1')).toBeInTheDocument();
expect(await screen.findByTestId('run-flow-node-provider_realtime_quote_aksharefetcher_2')).toBeInTheDocument();
expect(screen.getByTestId('run-flow-node-provider_realtime_quote_tickflowfetcher_1')).toHaveTextContent('TickFlowFetcher');
expect(screen.getByTestId('run-flow-node-provider_realtime_quote_aksharefetcher_2')).toHaveTextContent('AkshareFetcher');
});
it('hides topology summary metadata from aggregated node details', async () => {
vi.mocked(analysisApi.getTaskFlow).mockResolvedValue(providerAttemptSnapshot);

View File

@@ -247,6 +247,83 @@ describe('buildRunFlowTopologyModel', () => {
expect(model.events.find((event) => event.id === 'evt-normalized-block')?.nodeId).toBe('context_pack');
});
it('groups TickFlow realtime fallback attempts without provider-specific UI branches', () => {
const tickFlowSnapshot: RunFlowSnapshot = {
...baseSnapshot,
nodes: [
baseSnapshot.nodes[0],
{
id: 'provider_realtime_quote_tickflowfetcher_1',
lane: 'data_source',
kind: 'data_source',
label: '实时行情 · TickFlowFetcher',
status: 'failed',
provider: 'TickFlowFetcher',
durationMs: 892,
metadata: { data_type: 'realtime_quote', attempt: 1 },
},
{
id: 'provider_realtime_quote_aksharefetcher_2',
lane: 'data_source',
kind: 'data_source',
label: '实时行情 · AkshareFetcher',
status: 'success',
provider: 'AkshareFetcher',
durationMs: 8700,
recordCount: 1,
metadata: { data_type: 'realtime_quote', attempt: 2 },
},
{
id: 'context_pack',
lane: 'analysis',
kind: 'analysis',
label: 'ContextPack',
status: 'success',
},
],
edges: [
{
id: 'tickflow-akshare-fallback',
from: 'provider_realtime_quote_tickflowfetcher_1',
to: 'provider_realtime_quote_aksharefetcher_2',
kind: 'fallback',
status: 'success',
},
],
events: [],
};
const collapsed = buildRunFlowTopologyModel(tickFlowSnapshot);
const quoteGroup = collapsed.nodes.find((node) => node.id === 'topology_data_realtime_quote');
expect(quoteGroup).toMatchObject({
label: '实时行情',
status: 'fallback',
provider: 'TickFlowFetcher -> AkshareFetcher',
attempts: 2,
recordCount: 1,
});
expect(quoteGroup?.metadata).toMatchObject({
data_type: 'realtime_quote',
fallback_count: 1,
success_count: 1,
failed_count: 1,
});
const expanded = buildRunFlowTopologyModel(tickFlowSnapshot, {
expandedGroupIds: new Set(['topology_data_realtime_quote']),
});
expect(expanded.nodes.map((node) => node.id)).toContain('provider_realtime_quote_tickflowfetcher_1');
expect(expanded.nodes.map((node) => node.id)).toContain('provider_realtime_quote_aksharefetcher_2');
expect(expanded.nodes.find((node) => node.id === 'provider_realtime_quote_tickflowfetcher_1')).toMatchObject({
label: '实时行情 · TickFlowFetcher',
provider: 'TickFlowFetcher',
metadata: expect.objectContaining({
topologyRole: 'provider_attempt',
topologyParentId: 'topology_data_realtime_quote',
}),
});
});
it('keeps retry-only provider groups successful when every attempt succeeds', () => {
const retryOnlySnapshot: RunFlowSnapshot = {
...baseSnapshot,

View File

@@ -224,11 +224,14 @@ export const SettingsField: React.FC<SettingsFieldProps> = ({
const schema = item.schema;
const isMultiValue = isMultiValueField(item);
const helpContent = getSettingsHelpContent(schema?.helpKey, schema?.description, language);
const localizationKey = schema?.key ?? item.key;
const fallbackTitle = schema?.title ?? item.key;
const title = language === 'zh' ? getFieldTitleZh(item.key, fallbackTitle) : fallbackTitle;
const title = language === 'zh'
? getFieldTitleZh(localizationKey, getFieldTitleZh(item.key, fallbackTitle))
: fallbackTitle;
const description = language === 'en'
? helpContent?.summary ?? schema?.description ?? ''
: getFieldDescriptionZh(item.key, schema?.description);
: getFieldDescriptionZh(localizationKey, getFieldDescriptionZh(item.key, schema?.description));
const hasError = issues.some((issue) => issue.severity === 'error');
const [isPasswordEditable, setIsPasswordEditable] = useState(false);
const controlId = `setting-${item.key}`;
@@ -247,7 +250,7 @@ export const SettingsField: React.FC<SettingsFieldProps> = ({
{title}
</label>
<SettingsHelpButton
fieldKey={item.key}
fieldKey={localizationKey}
title={title}
schema={schema}
description={description}

View File

@@ -2,6 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import { UiLanguageProvider, useUiLanguage } from '../../../contexts/UiLanguageContext';
import { getFieldDescriptionZh, getFieldTitleZh } from '../../../utils/systemConfigI18n';
import { UI_LANGUAGE_STORAGE_KEY } from '../../../utils/uiLanguage';
import { SettingsField } from '../SettingsField';
@@ -37,6 +38,73 @@ describe('SettingsField', () => {
expect(screen.queryByLabelText('Stock List')).not.toBeInTheDocument();
});
it('localizes TickFlow field descriptions instead of falling back to backend English schema', () => {
render(
<SettingsField
item={{
key: 'TICKFLOW_PRIORITY',
value: '2',
rawValueExists: false,
isMasked: false,
schema: {
key: 'TICKFLOW_PRIORITY',
title: 'TickFlow Priority',
description: 'Priority for TickFlow daily K-line fetcher. Lower numbers are tried earlier.',
category: 'data_source',
dataType: 'integer',
uiControl: 'number',
isSensitive: false,
isRequired: false,
isEditable: true,
options: [],
validation: { min: 0, max: 99 },
displayOrder: 16,
helpKey: 'settings.data_source.TICKFLOW_PRIORITY',
},
}}
value="2"
onChange={vi.fn()}
/>
);
expect(screen.getByLabelText('TickFlow 日 K 优先级')).toBeInTheDocument();
expect(screen.getByText(/控制 TickFlow 在 A 股日 K 数据源回退链中的尝试顺序/)).toBeInTheDocument();
expect(screen.queryByText(/Priority for TickFlow daily K-line fetcher/)).not.toBeInTheDocument();
});
it('uses schema key for TickFlow localization when the runtime item key differs', () => {
render(
<SettingsField
item={{
key: 'runtime.tickflow.priority',
value: '2',
rawValueExists: false,
isMasked: false,
schema: {
key: 'TICKFLOW_PRIORITY',
title: 'TickFlow Priority',
description: 'Priority for TickFlow daily K-line fetcher. Lower numbers are tried earlier.',
category: 'data_source',
dataType: 'integer',
uiControl: 'number',
isSensitive: false,
isRequired: false,
isEditable: true,
options: [],
validation: { min: 0, max: 99 },
displayOrder: 16,
helpKey: 'settings.data_source.TICKFLOW_PRIORITY',
},
}}
value="2"
onChange={vi.fn()}
/>
);
expect(screen.getByLabelText(getFieldTitleZh('TICKFLOW_PRIORITY', ''))).toBeInTheDocument();
expect(screen.getByText(getFieldDescriptionZh('TICKFLOW_PRIORITY', ''))).toBeInTheDocument();
expect(screen.queryByLabelText('TickFlow Priority')).not.toBeInTheDocument();
expect(screen.queryByText(/Priority for TickFlow daily K-line fetcher/)).not.toBeInTheDocument();
});
it('renders sensitive field metadata and validation errors', () => {
const onChange = vi.fn();

View File

@@ -266,12 +266,44 @@ const settingsHelpZhCN: SettingsHelpMap = {
},
'settings.data_source.TICKFLOW_API_KEY': {
title: 'TickFlow API Key',
summary: '用于增强大盘复盘中的指数、市场统计等数据。',
summary: '用于启用 TickFlow A 股日 K、实时行情、股票列表/名称与大盘复盘增强数据。',
usage: '在 TickFlow 获取 API Key 后填入;未配置时系统会继续使用其他可用数据源和降级路径。',
valueNotes: ['该 Key 是可选增强项,不是运行主分析流程的必填项。'],
impact: ['影响大盘复盘和市场统计增强数据覆盖度。'],
valueNotes: ['该 Key 是可选增强项,不是运行主分析流程的必填项。', '不同 TickFlow 套餐的批量日 K、实时行情、除权因子和深度权限可能不同。'],
impact: ['影响 A 股日线回退链、实时行情、股票名称/列表与大盘复盘数据覆盖度。'],
notes: ['不要在 issue、日志或截图中暴露真实 Key。'],
},
'settings.data_source.TICKFLOW_PRIORITY': {
title: 'TickFlow 日 K 优先级',
summary: '控制 TickFlow 在 A 股日 K 数据源回退链中的位置。',
usage: '填写整数;数字越小越早尝试,默认 2。未配置 TICKFLOW_API_KEY 时该优先级不会生效。',
valueNotes: ['该设置只影响日 K 等通用数据源回退链,不控制实时行情源顺序。'],
impact: ['影响 A 股日 K 获取的数据源尝试顺序;实时行情仍由 REALTIME_SOURCE_PRIORITY 单独决定。'],
notes: ['如果希望优先使用 TickFlow 日 K可以适当调低该值如果希望实时行情优先使用 TickFlow请在 REALTIME_SOURCE_PRIORITY 中显式加入 tickflow。'],
},
'settings.data_source.TICKFLOW_KLINE_ADJUST': {
title: 'TickFlow 日 K 复权模式',
summary: '控制 TickFlow 日 K 线的复权口径。',
usage: '可选 none、forward、backward、forward_additive 或 backward_additive。默认 none。',
valueNotes: ['none 保持与现有未复权技术指标口径一致。'],
impact: ['影响基于 TickFlow 日 K 计算的均线、涨跌幅和其他技术指标口径。'],
notes: ['在没有统一全部数据源复权口径前,建议保持默认 none。'],
},
'settings.data_source.TICKFLOW_BATCH_DAILY_ENABLED': {
title: 'TickFlow 批量日 K 预取',
summary: '控制批量分析时是否先用 TickFlow 批量接口预热日 K 缓存。',
usage: '默认开启。如果当前套餐没有批量日 K 权限,系统会短期记住失败状态并继续回退。',
valueNotes: ['该开关不改变 get_daily_data 的对外调用方式,只是提前填充进程内缓存。'],
impact: ['有批量权限时可减少多只 A 股分析的重复日 K 请求。'],
notes: ['权限不足时会 fail-open不会阻断原有数据源链路。'],
},
'settings.data_source.TICKFLOW_BATCH_SIZE': {
title: 'TickFlow 批量大小',
summary: '控制 TickFlow 日 K 和实时行情批量请求的单批最大标的数。',
usage: '填写正整数,默认 100。标的数超过该值时系统会拆分多批请求。',
valueNotes: ['过大的批量可能受套餐或服务端限制影响,通常保持默认值即可。'],
impact: ['影响 TickFlow 批量预取的请求次数和单次请求压力。'],
notes: ['该配置仅影响 TickFlow 批量路径。'],
},
'settings.data_source.stock_index_remote': {
title: '股票索引远程更新',
summary: '从 GitHub main 分支获取最新股票自动补全索引,并缓存到本地。',
@@ -1376,12 +1408,44 @@ const settingsHelpEnUS: SettingsHelpMap = {
},
'settings.data_source.TICKFLOW_API_KEY': {
title: 'TickFlow API Key',
summary: 'Enhances market review with index and market-statistics data.',
summary: 'Enables optional TickFlow A-share daily K-lines, realtime quotes, stock list/name lookup, and market-review data.',
usage: 'Paste a TickFlow API key here. When empty, the system continues with other data sources and fallback paths.',
valueNotes: ['This key is an optional enhancement, not required for the main analysis flow.'],
impact: ['Affects market-review and market-statistics coverage.'],
valueNotes: ['This key is optional and not required for the main analysis flow.', 'Batch daily K-line, realtime quote, ex-factor, and depth entitlements may differ by TickFlow plan.'],
impact: ['Affects A-share daily-data fallback, realtime quotes, stock list/name lookup, and market-review coverage.'],
notes: ['Do not expose real keys in issues, logs, or screenshots.'],
},
'settings.data_source.TICKFLOW_PRIORITY': {
title: 'TickFlow Daily K-line Priority',
summary: 'Controls where TickFlow sits in the A-share daily K-line provider fallback chain.',
usage: 'Use an integer. Lower numbers are tried earlier. The default is 2. This has no effect unless TICKFLOW_API_KEY is configured.',
valueNotes: ['This setting only affects the daily K-line/general data-source fallback chain; it does not control realtime quote provider order.'],
impact: ['Affects provider order for A-share daily K-line fetching. Realtime quotes are still controlled separately by REALTIME_SOURCE_PRIORITY.'],
notes: ['Lower this value only if you want TickFlow daily K-lines to be tried earlier. Add tickflow to REALTIME_SOURCE_PRIORITY when you want TickFlow realtime quotes in the realtime fallback chain.'],
},
'settings.data_source.TICKFLOW_KLINE_ADJUST': {
title: 'TickFlow K-line Adjustment',
summary: 'Controls the adjustment mode for TickFlow daily K-lines.',
usage: 'Allowed values are none, forward, backward, forward_additive, or backward_additive. The default is none.',
valueNotes: ['none preserves the existing unadjusted technical-indicator baseline.'],
impact: ['Affects moving averages, price changes, and other technical indicators calculated from TickFlow daily K-lines.'],
notes: ['Keep the default none unless you intentionally want adjusted daily K-line inputs.'],
},
'settings.data_source.TICKFLOW_BATCH_DAILY_ENABLED': {
title: 'TickFlow Batch Daily Prefetch',
summary: 'Controls whether batch analysis warms daily K-line cache through the TickFlow batch API first.',
usage: 'Enabled by default. If the current plan lacks batch daily entitlement, the system negative-caches that failure briefly and continues fallback.',
valueNotes: ['This does not change the public get_daily_data call path; it only warms process-local cache before per-stock calls.'],
impact: ['Can reduce repeated daily K-line requests when analyzing multiple A-share symbols with batch entitlement.'],
notes: ['Entitlement failures fail open and do not block existing provider fallback.'],
},
'settings.data_source.TICKFLOW_BATCH_SIZE': {
title: 'TickFlow Batch Size',
summary: 'Controls the maximum symbols per TickFlow batch request for daily K-lines and realtime quotes.',
usage: 'Use a positive integer. The default is 100. Larger symbol lists are split into multiple requests.',
valueNotes: ['Very large batches may hit plan or server limits; the default is usually appropriate.'],
impact: ['Affects request count and per-request pressure for TickFlow batch prefetch.'],
notes: ['This setting only affects TickFlow batch paths.'],
},
'settings.data_source.stock_index_remote': {
title: 'Remote Stock Index',
summary: 'Fetches the latest stock autocomplete index from GitHub main and caches it locally.',

View File

@@ -63,6 +63,10 @@ const fieldTitleMap: Record<string, string> = {
NEWS_MAX_AGE_DAYS: '新闻最大时效(天)',
REALTIME_SOURCE_PRIORITY: '实时数据源优先级',
TICKFLOW_API_KEY: 'TickFlow API Key',
TICKFLOW_PRIORITY: 'TickFlow 日 K 优先级',
TICKFLOW_KLINE_ADJUST: 'TickFlow 日 K 复权模式',
TICKFLOW_BATCH_DAILY_ENABLED: 'TickFlow 批量日 K 预取',
TICKFLOW_BATCH_SIZE: 'TickFlow 批量大小',
ENABLE_REALTIME_QUOTE: '启用实时行情',
ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',
ENABLE_CHIP_DISTRIBUTION: '启用筹码分布分析',
@@ -224,6 +228,10 @@ const fieldDescriptionMap: Record<string, string> = {
NEWS_MAX_AGE_DAYS: '新闻最大时效上限。实际窗口 = min(策略档位天数, NEWS_MAX_AGE_DAYS)。例如 ultra_short + 7 仍为 1 天。',
REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',
TICKFLOW_API_KEY: '用于接入 TickFlow 数据服务的 API 密钥。',
TICKFLOW_PRIORITY: '控制 TickFlow 在 A 股日 K 数据源回退链中的尝试顺序;不控制实时行情,实时行情顺序由 REALTIME_SOURCE_PRIORITY 决定。',
TICKFLOW_KLINE_ADJUST: '控制 TickFlow 日 K 线的复权口径,默认 none 保持未复权技术指标基线。',
TICKFLOW_BATCH_DAILY_ENABLED: '批量分析前是否使用 TickFlow 批量日 K 接口预热缓存;权限不足时会继续回退。',
TICKFLOW_BATCH_SIZE: '控制 TickFlow 日 K 和实时行情批量请求的单批最大标的数。',
ENABLE_REALTIME_QUOTE: '控制是否启用实时行情数据获取。',
ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列Issue #234关闭则用昨日收盘。',
ENABLE_CHIP_DISTRIBUTION: '控制是否启用筹码分布分析;关闭后可减少外部请求和失败噪音。',

View File

@@ -4,6 +4,10 @@ import { getFieldDescriptionZh, getFieldOptionLabelZh, getFieldTitleZh } from '.
const requiredLocalizedKeys = [
'TICKFLOW_API_KEY',
'TICKFLOW_PRIORITY',
'TICKFLOW_KLINE_ADJUST',
'TICKFLOW_BATCH_DAILY_ENABLED',
'TICKFLOW_BATCH_SIZE',
'STOCK_INDEX_REMOTE_UPDATE_ENABLED',
'SEARXNG_BASE_URLS',
'ENABLE_REALTIME_QUOTE',

View File

@@ -616,6 +616,7 @@ class DataFetcherManager:
"TencentFetcher": {"cn"},
"AkshareFetcher": {"cn", "hk"},
"TushareFetcher": {"cn", "hk"},
"TickFlowFetcher": {"cn"},
"PytdxFetcher": {"cn"},
"BaostockFetcher": {"cn"},
"YfinanceFetcher": {"cn", "hk", "us", "jp", "kr", "tw"},
@@ -866,6 +867,10 @@ class DataFetcherManager:
self._tickflow_api_key = None
return None
configured_fetcher = self._get_fetcher_by_name("TickFlowFetcher")
if configured_fetcher is not None:
return configured_fetcher
if current_fetcher is not None and current_key == api_key:
return current_fetcher
@@ -878,7 +883,13 @@ class DataFetcherManager:
try:
from .tickflow_fetcher import TickFlowFetcher
fetcher = TickFlowFetcher(api_key=api_key)
fetcher = TickFlowFetcher(
api_key=api_key,
kline_adjust=getattr(config, "tickflow_kline_adjust", "none"),
batch_daily_enabled=getattr(config, "tickflow_batch_daily_enabled", True),
batch_size=getattr(config, "tickflow_batch_size", 100),
priority=getattr(config, "tickflow_priority", 2),
)
self._tickflow_fetcher = fetcher
self._tickflow_api_key = api_key
return fetcher
@@ -1144,6 +1155,7 @@ class DataFetcherManager:
from .tencent_fetcher import TencentFetcher
from .akshare_fetcher import AkshareFetcher
from .tushare_fetcher import TushareFetcher
from .tickflow_fetcher import TickFlowFetcher
from .pytdx_fetcher import PytdxFetcher
from .baostock_fetcher import BaostockFetcher
from .yfinance_fetcher import YfinanceFetcher
@@ -1164,6 +1176,20 @@ class DataFetcherManager:
else:
logger.debug("[数据源初始化] 跳过未配置的 TushareFetcher")
tickflow_api_key = (getattr(config, "tickflow_api_key", None) or "").strip()
if tickflow_api_key:
optional_fetchers.append(
TickFlowFetcher(
api_key=tickflow_api_key,
kline_adjust=getattr(config, "tickflow_kline_adjust", "none"),
batch_daily_enabled=getattr(config, "tickflow_batch_daily_enabled", True),
batch_size=getattr(config, "tickflow_batch_size", 100),
priority=getattr(config, "tickflow_priority", 2),
)
)
else:
logger.debug("[data source init] skip TickFlowFetcher because TICKFLOW_API_KEY is not configured")
if LongbridgeFetcher.has_configured_credentials(config):
optional_fetchers.append(LongbridgeFetcher()) # 长桥(美股/港股兜底,懒加载)
else:
@@ -1472,13 +1498,14 @@ class DataFetcherManager:
批量预取实时行情数据(在分析开始前调用)
策略:
1. 检查优先级中是否包含全量拉取数据源efinance/akshare_em
1. 检查优先级中是否包含适合预取的数据源efinance/akshare_em/tushare/tickflow
2. 如果不包含,跳过预取(新浪/腾讯是单股票查询,无需预取)
3. 如果自选股数量 >= 5 且使用全量数据源,则预取填充缓存
3. 如果自选股数量 >= 5 且使用可预取数据源,则预取填充缓存
这样做的好处:
- 使用新浪/腾讯时:每只股票独立查询,无全量拉取问题
- 使用 efinance/东财时:预取一次,后续缓存命中
- 使用 efinance/东财/Tushare 时:预取一次,后续缓存命中
- 使用 TickFlow 时:按当前自选股批量预取,避免逐股重复请求
Args:
stock_codes: 待分析的股票代码列表
@@ -1503,25 +1530,25 @@ class DataFetcherManager:
logger.debug("[预取] component=realtime_prefetch action=skip reason=realtime_quote_disabled")
return 0
# 检查优先级中是否包含全量拉取数据源
# 注意:新增全量接口(如 tushare_realtime时需同步更新此列表
# 全量接口特征:一次 API 调用拉取全市场 5000+ 股票数据
# 检查优先级中是否包含适合批量预取的数据源
# efinance/akshare_em/tushare 通过一次调用填充全市场缓存;
# tickflow 通过 symbols 批量接口预取当前自选股缓存。
priority = config.realtime_source_priority.lower()
bulk_sources = ['efinance', 'akshare_em', 'tushare'] # 全量接口列表
prefetch_sources = ['efinance', 'akshare_em', 'tushare', 'tickflow']
# 如果优先级中前两个都不是全量数据源,跳过预取
# 如果优先级中前两个都不是可预取数据源,跳过预取
# 因为新浪/腾讯是单股票查询,不需要预取
priority_list = [s.strip() for s in priority.split(',')]
first_bulk_source_index = None
first_prefetch_source_index = None
for i, source in enumerate(priority_list):
if source in bulk_sources:
first_bulk_source_index = i
if source in prefetch_sources:
first_prefetch_source_index = i
break
# 如果没有全量数据源,或者全量数据源排在第 3 位之后,跳过预取
if first_bulk_source_index is None or first_bulk_source_index >= 2:
# 如果没有可预取数据源,或者排在第 3 位之后,跳过预取
if first_prefetch_source_index is None or first_prefetch_source_index >= 2:
logger.info(
"[预取] component=realtime_prefetch action=skip reason=no_early_bulk_source priority=%s",
"[预取] component=realtime_prefetch action=skip reason=no_early_prefetch_source priority=%s",
priority,
)
return 0
@@ -1530,22 +1557,42 @@ class DataFetcherManager:
if len(stock_codes) < 5:
logger.info(
"[预取] component=realtime_prefetch action=skip reason=small_batch "
"stock_count=%d threshold=5 bulk_source=%s",
"stock_count=%d threshold=5 prefetch_source=%s",
len(stock_codes),
priority_list[first_bulk_source_index],
priority_list[first_prefetch_source_index],
)
return 0
bulk_source = priority_list[first_bulk_source_index]
prefetch_source = priority_list[first_prefetch_source_index]
logger.info(
"[预取] component=realtime_prefetch action=start stock_count=%d bulk_source=%s first_code=%s",
"[预取] component=realtime_prefetch action=start stock_count=%d prefetch_source=%s first_code=%s",
len(stock_codes),
bulk_source,
prefetch_source,
stock_codes[0],
)
# 尝试通过 efinance 或 akshare 预取
# 只需要调用一次 get_realtime_quote缓存机制会自动拉取全市场数据
# TickFlow 使用 symbols 批量接口;其他可预取源通过首次查询触发自身缓存。
if prefetch_source == "tickflow":
fetcher = self._get_fetcher_by_name("TickFlowFetcher", capability="realtime_quote")
if fetcher is None or not hasattr(fetcher, "prefetch_realtime_quotes"):
logger.info(
"[prefetch] component=realtime_prefetch action=skip reason=tickflow_unavailable"
)
return 0
try:
return int(
self._call_fetcher_method(
fetcher,
"prefetch_realtime_quotes",
stock_codes,
batch_size=getattr(config, "tickflow_batch_size", 100),
)
or 0
)
except Exception as exc:
logger.warning("[TickFlowFetcher] realtime prefetch failed: %s", exc)
return 0
try:
# 用第一只股票触发全量拉取
first_code = stock_codes[0]
@@ -1554,30 +1601,50 @@ class DataFetcherManager:
if quote:
logger.info(
"[预取] component=realtime_prefetch action=complete status=success "
"stock_count=%d bulk_source=%s",
"stock_count=%d prefetch_source=%s",
len(stock_codes),
bulk_source,
prefetch_source,
)
return len(stock_codes)
else:
logger.warning(
"[预取] component=realtime_prefetch action=complete status=failed "
"stock_count=%d bulk_source=%s fallback=per_stock",
"stock_count=%d prefetch_source=%s fallback=per_stock",
len(stock_codes),
bulk_source,
prefetch_source,
)
return 0
except Exception as e:
logger.error(
"[预取] component=realtime_prefetch action=complete status=error "
"stock_count=%d bulk_source=%s error=%s",
"stock_count=%d prefetch_source=%s error=%s",
len(stock_codes),
bulk_source,
prefetch_source,
e,
)
return 0
def prefetch_daily_klines(self, stock_codes: List[str], days: int = 30) -> int:
"""Batch-prefetch TickFlow daily K-lines without changing per-stock callers."""
fetcher = self._get_fetcher_by_name("TickFlowFetcher", capability="daily_data")
if fetcher is None or not hasattr(fetcher, "prefetch_daily_klines"):
return 0
try:
return int(
self._call_fetcher_method(
fetcher,
"prefetch_daily_klines",
stock_codes,
days=days,
)
or 0
)
except Exception as exc:
logger.warning("[TickFlowFetcher] daily K-line prefetch failed: %s", exc)
return 0
@staticmethod
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@@ -1821,6 +1888,16 @@ class DataFetcherManager:
)
quote = self._call_fetcher_method(fetcher, 'get_realtime_quote', raw_stock_code or stock_code)
elif source == "tickflow":
fetcher = self._get_fetcher_by_name("TickFlowFetcher", capability="realtime_quote")
if fetcher is not None and hasattr(fetcher, 'get_realtime_quote'):
record_provider_run_started(
data_type="realtime_quote",
provider=fetcher.name,
operation="get_realtime_quote",
)
quote = self._call_fetcher_method(fetcher, 'get_realtime_quote', raw_stock_code or stock_code)
provider_name = fetcher.name if fetcher is not None else source
if quote is not None and quote.has_basic_data():
@@ -2392,6 +2469,8 @@ class DataFetcherManager:
logger.warning(f"[TickFlowFetcher] 获取指数行情失败: {e}")
for fetcher in self._fetchers:
if region == "cn" and fetcher.name == "TickFlowFetcher":
continue
try:
data = fetcher.get_main_indices(region=region)
if data:
@@ -2436,6 +2515,8 @@ class DataFetcherManager:
)
for fetcher in self._fetchers:
if fetcher.name == "TickFlowFetcher":
continue
started_at = time.monotonic()
try:
data = fetcher.get_market_stats()

View File

@@ -98,6 +98,7 @@ class RealtimeSource(Enum):
AKSHARE_SINA = "akshare_sina" # 新浪财经
AKSHARE_QQ = "akshare_qq" # 腾讯财经
TUSHARE = "tushare" # Tushare Pro
TICKFLOW = "tickflow" # TickFlow
TENCENT = "tencent" # 腾讯直连
SINA = "sina" # 新浪直连
STOOQ = "stooq" # Stooq 美股兜底

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
- [改进] TickFlow 扩展为可选 A 股日 K、实时行情、股票列表/名称数据源,并为日 K 请求增加 count、完整性校验和批量预取缓存保护。
- [修复] API 异步批量分析共享概念板块排行缓存,避免同批多股重复拉取全市场概念排行。
- [文档] 补齐概念板块排行字段契约与通知报告行业/概念类型列展示说明。
- [新功能] #1742 新增信号归因分析功能dashboard.signal_attribution解释推荐理由的构成技术指标、新闻舆情、基本面、市场环境的贡献度以及最强看多/看空信号)。支持默认通知报告和 Jinja2 模板渲染,包含中英文国际化标签。归一化函数在 _parse_response() 和 parse_dashboard_json() 中显式调用,确保有效非零贡献度归一化到 100all-zero 保留为 0表示无有效信号

View File

@@ -383,7 +383,11 @@ daily_stock_analysis/
| 变量名 | 说明 | 默认值 | 必填 |
|--------|------|--------|:----:|
| `TUSHARE_TOKEN` | Tushare Pro Token | - | 可选 |
| `TICKFLOW_API_KEY` | TickFlow API Key配置后 A 股大盘复盘指数优先尝试 TickFlow若套餐支持标的池查询则市场统计也会优先尝试 TickFlow | - | 可选 |
| `TICKFLOW_API_KEY` | TickFlow API Key可选,用于 A 股日 K、实时行情、股票列表/名称与大盘复盘增强;失败或权限不足时自动回退。 | - | 可选 |
| `TICKFLOW_PRIORITY` | TickFlow 日 K 数据源优先级;数字越小越早尝试,默认 `2`;未配置 API Key 时不启用;不影响实时行情,实时行情顺序由 `REALTIME_SOURCE_PRIORITY` 控制。 | `2` | 可选 |
| `TICKFLOW_KLINE_ADJUST` | TickFlow 日 K 复权模式:`none``forward``backward``forward_additive``backward_additive`。 | `none` | 可选 |
| `TICKFLOW_BATCH_DAILY_ENABLED` | 是否启用 TickFlow 批量日 K 预取;权限不足会短期缓存失败状态,并继续走常规回退。 | `true` | 可选 |
| `TICKFLOW_BATCH_SIZE` | TickFlow 日 K 与实时行情批量请求的单批最大标的数。 | `100` | 可选 |
| `LONGBRIDGE_OAUTH_CLIENT_ID` | Longbridge OAuth client_id留空且无 Legacy Access Token 时会兼容使用 `LONGBRIDGE_APP_KEY` | - | 可选 |
| `LONGBRIDGE_OAUTH_TOKEN_CACHE_B64` | OAuth token 缓存文件的 base64 内容,供 GitHub Actions / Docker 等 headless 环境使用 | - | 可选 |
| `LONGBRIDGE_APP_KEY` | Longbridge Legacy App Key`LONGBRIDGE_ACCESS_TOKEN` 时也可作为 OAuth client_id 兼容别名 | - | 可选 |
@@ -394,7 +398,7 @@ daily_stock_analysis/
| `ENABLE_REALTIME_TECHNICAL_INDICATORS` | 盘中实时技术面:启用时用实时价计算 MA5/MA10/MA20 与多头排列Issue #234);关闭则用昨日收盘 | `true` | 可选 |
| `ENABLE_CHIP_DISTRIBUTION` | 启用筹码分布分析该接口不稳定云端部署建议关闭。GitHub Actions 用户需在 Repository Variables 中设置 `ENABLE_CHIP_DISTRIBUTION=true` 方可启用workflow 默认关闭。 | `true` | 可选 |
| `ENABLE_EASTMONEY_PATCH` | 东财接口补丁:东财接口频繁失败(如 RemoteDisconnected、连接被关闭时建议设为 `true`,注入 NID 令牌与随机 User-Agent 以降低被限流概率 | `false` | 可选 |
| `REALTIME_SOURCE_PRIORITY` | 实时行情数据源优先级逗号分隔,如 `tencent,akshare_sina,efinance,akshare_em` | 见 .env.example | 可选 |
| `REALTIME_SOURCE_PRIORITY` | 实时行情源优先级逗号分隔,`tencent,akshare_sina,efinance,akshare_em`;需要显式加入 `tickflow` 才会使用 TickFlow 实时行情。 | 见 `.env.example` | 可选 |
| `ENABLE_FUNDAMENTAL_PIPELINE` | 基本面聚合总开关;关闭时仅返回 `not_supported` 块,不改变原分析链路 | `true` | 可选 |
| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | 基本面阶段总时延预算(秒) | `8.0` | 可选 |
| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | 单能力源调用超时(秒) | `3.0` | 可选 |
@@ -408,9 +412,12 @@ daily_stock_analysis/
> - 美股/港股:通过 yfinance 适配器返回 `valuation/growth/earnings/belong_boards`(来源 `info.sector`/`industry``institution/capital_flow/dragon_tiger/boards` 暂无对应数据源仍标记 `not_supported`yfinance 不可用或字段缺失时整体降级回 `not_supported`,仍走 fail-open
> - 日股/韩股:当前仅走 Yfinance 基础路径获取日线与实时行情;`institution`、`capital_flow`、`dragon_tiger`、`boards` 等依赖 A 股专属源/离岸完整版的能力会降级为 `not_supported`(详见 [市场支持与边界](market-support.md)
> - 任何异常走 fail-open仅记录错误不影响技术面/新闻/筹码主链路。
> - 配置 `TICKFLOW_API_KEY` 后,仅 A 股大盘复盘会额外优先尝试 TickFlow 的主要指数行情;若当前套餐支持标的池查询,市场涨跌统计也会优先尝试 TickFlow。个股链路和实时行情优先级不变
> - 配置 `TICKFLOW_API_KEY` 后,TickFlow 会作为可选 A 股日 K 数据源和大盘复盘增强源实例化;`TICKFLOW_PRIORITY` 只影响日 K/通用数据源回退链。实时行情优先级由 `REALTIME_SOURCE_PRIORITY` 单独控制,只有显式包含 `tickflow` 时才会使用 TickFlow 实时行情。`REALTIME_SOURCE_PRIORITY` 中排在 `tickflow` 前面的数据源会先被尝试
> - TickFlow 日 K 默认 `TICKFLOW_KLINE_ADJUST=none`;日线 `volume` 从手统一转为股,`amount` 保持元口径。
> - TickFlow 日 K 区间请求会显式传入 `start_time` / `end_time` / `count`;官方 quickstart 明确说明时间范围查询仍受 `count` 限制。若返回非空但行数打满 `count` 且首个返回交易日晚于请求起始交易日,系统会判定为疑似截断,不写入缓存并让 manager 继续回退。
> - 批量分析时,`prefetch_daily_klines()` 会在逐股 `get_daily_data()` 之前预热进程内缓存,不改变对外调用路径。
> - TickFlow 能力按套餐权限分层:有限权限套餐仍可使用主指数查询;支持 `CN_Equity_A` 标的池查询的套餐才会启用 TickFlow 市场统计。
> - 官方 quickstart 已文档化 `quotes.get(universes=["CN_Equity_A"])`,但线上 smoke test 进一步确认:`TICKFLOW_API_KEY` 不等于一定具备该权限,且 `quotes.get(symbols=[...])` 单次存在标的数量限制
> - TickFlow 官方 quickstart 提供了 `quotes.get(universes=["CN_Equity_A"])` 用法,但不同 API Key 不一定拥有对应权限;批量日 K、深度和财务等能力也按权限 fail-open
> - TickFlow 实际返回的 `change_pct` / `amplitude` 为比例值;系统已在接入层统一转换为百分比值,确保与现有数据源字段语义一致。
> - A 股大盘复盘报告采用盘后工作台式结构:固定包含盘面信号、指数明细、板块 Top 表、近三日市场线索、明日交易计划和风险提示;盘面信号以 `66/100偏暖可进攻` 这类纯文本分数表达,避免色块进度条在不同终端显示不一致;近三日市场线索只列标题、来源和链接,不再展示搜索摘要片段;若部分数据源缺失,则保留可用区块并在对应位置降级展示。
> - 字段契约:

View File

@@ -153,7 +153,7 @@ Go to your forked repo → `Settings` → `Secrets and variables` → `Actions`
| `SEARXNG_BASE_URLS` | SearXNG self-hosted instances (quota-free fallback, enable format: json in settings.yml); when empty the app auto-discovers public instances | Optional |
| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |
| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |
| `TICKFLOW_API_KEY` | [TickFlow](https://tickflow.org) API key for CN market review index enhancement; market breadth also uses TickFlow when the plan supports universe queries | Optional |
| `TICKFLOW_API_KEY` | [TickFlow](https://tickflow.org) API key for optional A-share daily K-lines, realtime quotes, stock list/name lookup, and CN market review enhancement; permission or entitlement failures fall back to existing providers | Optional |
#### ✅ Minimum Configuration Example
@@ -318,12 +318,16 @@ For the notification baseline, diagnostics, and deployment notes, see [Notificat
| Variable | Description | Default | Required |
|--------|------|--------|:----:|
| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |
| `TICKFLOW_API_KEY` | TickFlow API key; CN market review indices prefer TickFlow when configured, and market breadth does so only when the plan supports universe queries | - | Optional |
| `TICKFLOW_API_KEY` | TickFlow API key; enables optional A-share daily K-lines, realtime quotes, stock list/name lookup, and CN market review enhancement. Permission failures fall back to existing providers. | - | Optional |
| `TICKFLOW_PRIORITY` | TickFlow daily K-line provider priority; lower values are tried earlier. No effect unless `TICKFLOW_API_KEY` is configured. Does not affect realtime quotes, which are ordered by `REALTIME_SOURCE_PRIORITY`. | `2` | Optional |
| `TICKFLOW_KLINE_ADJUST` | TickFlow daily K-line adjustment mode: `none`, `forward`, `backward`, `forward_additive`, or `backward_additive`. | `none` | Optional |
| `TICKFLOW_BATCH_DAILY_ENABLED` | Enable TickFlow batch daily K-line prefetch when the current plan supports it; permission failures are negative-cached and fall back to per-stock providers. | `true` | Optional |
| `TICKFLOW_BATCH_SIZE` | Maximum symbols per TickFlow batch request for daily K-lines and realtime quotes. | `100` | Optional |
| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |
| `ENABLE_REALTIME_TECHNICAL_INDICATORS` | Intraday real-time technicals: Calculate MA5/MA10/MA20 and bull trends using real-time prices when enabled (Issue #234); uses yesterday's close if disabled. | `true` | Optional |
| `ENABLE_CHIP_DISTRIBUTION` | Enable chip distribution analysis (this API is unstable, recommended to disable for cloud deployment). GitHub Actions users must set `ENABLE_CHIP_DISTRIBUTION=true` in Repository Variables to enable; disabled by default in workflows. | `true` | Optional |
| `ENABLE_EASTMONEY_PATCH` | Eastmoney API patch: Recommended to set to `true` when Eastmoney APIs fail frequently (e.g., RemoteDisconnected, connection closed). Injects NID tokens and random User-Agents to reduce rate limiting probability. | `false` | Optional |
| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |
| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em`; add `tickflow` explicitly to use TickFlow realtime quotes | See .env.example | Optional |
| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |
| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional |
| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional |
@@ -374,10 +378,13 @@ For the notification baseline, diagnostics, and deployment notes, see [Notificat
| `SAVE_CONTEXT_SNAPSHOT` | Persist analysis-history `context_snapshot`. When false, new history records do not save enhanced_context, market_phase_summary, AnalysisContextPack overview, or diagnostic snapshots, but current-run prompt summaries remain enabled | `true` |
> Behavior notes:
> - When `TICKFLOW_API_KEY` is configured, CN market review first tries TickFlow for main indices. Market breadth also tries TickFlow only when the current TickFlow plan supports universe queries.
> - TickFlow behavior is capability-based rather than just key-based: limited plans can still enhance main CN indices, while plans with `CN_Equity_A` universe query support also enhance market breadth.
> - When `TICKFLOW_API_KEY` is configured, TickFlow is instantiated as an optional A-share daily K-line data source and CN market-review enhancer. `TICKFLOW_PRIORITY` only affects the daily K-line/general provider fallback chain. Realtime quote priority is controlled separately by `REALTIME_SOURCE_PRIORITY`; TickFlow realtime quotes are used only when that list explicitly includes `tickflow`, and any source listed before `tickflow` is tried first.
> - TickFlow daily K-lines default to `TICKFLOW_KLINE_ADJUST=none`; daily `volume` is converted from lots to shares, while `amount` remains in yuan.
> - TickFlow daily K-line range requests pass explicit `start_time` / `end_time` / `count`. Because the official quickstart documents that time-range queries are still limited by `count`, non-empty count-capped responses whose first returned trading date is later than the requested start trading date are rejected before normalization or cache writes, allowing manager fallback to continue.
> - Batch analysis can warm the per-process TickFlow daily K-line cache through `prefetch_daily_klines()` before per-stock `get_daily_data()` calls. Only validated frames are cached; batch permission failures are negative-cached and degrade to single-stock requests or existing providers.
> - TickFlow behavior is capability-based rather than just key-based: limited plans can still enhance main CN indices, while plans with `CN_Equity_A` universe query support also enhance market breadth and stock-list/name lookups.
> - The official quickstart documents `quotes.get(universes=["CN_Equity_A"])`, but online smoke tests confirmed two additional real-world constraints: universe access depends on plan permissions, and `quotes.get(symbols=[...])` has a per-request symbol limit.
> - TickFlow currently returns `change_pct` / `amplitude` as ratio values; this integration normalizes them to the project's percent convention so they match AkShare / Tushare / efinance semantics.
> - TickFlow currently returns `change_pct` / `amplitude` / `turnover_rate` as ratio values; this integration normalizes them to the project's percent convention so they match AkShare / Tushare / efinance semantics.
> - In scheduler mode, if runtime env explicitly sets `RUN_IMMEDIATELY` but does not set `SCHEDULE_RUN_IMMEDIATELY`, the scheduler keeps inheriting the legacy runtime override instead of being pulled back to a persisted `.env` alias value.
> - CN market review reports now use a post-market workstation layout with market signal, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. The market signal uses a plain-text score such as `66/100 (constructive, risk-on)` instead of block bars so it renders consistently across terminals and notification clients. News catalysts list only headline, source, and link instead of search snippets to reduce mixed-language noise. Missing data sources degrade by omitting or simplifying only the affected block.
> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.

View File

@@ -17,7 +17,7 @@ pytdx>=1.72 # Priority 2: Tongdaxin quote servers
baostock>=0.8.0 # Priority 3: Baostock data
yfinance>=0.2.0 # Priority 4: Yahoo Finance (Fallback)
longbridge==0.2.75 # Priority 5: Longbridge OpenAPI fallback for US/HK stocks; OAuth capability checked at runtime
tickflow>=0.1.0 # TickFlow official SDK (Issue #632, market review enhancement)
tickflow>=0.1.24 # TickFlow official SDK; 0.1.24 verified for klines/get batch quotes/universes
# Built-in optional AlphaSift screening engine
git+https://github.com/ZhuLinsen/alphasift.git@377049857cc04175dc3cca62121ee41adec6cdb8#egg=alphasift

View File

@@ -99,6 +99,7 @@ _MANAGED_LITELLM_KEY_PROVIDERS = {"gemini", "vertex_ai", "anthropic", "openai",
SUPPORTED_LLM_CHANNEL_PROTOCOLS = ("openai", "anthropic", "gemini", "vertex_ai", "deepseek", "ollama")
_FALSEY_ENV_VALUES = {"0", "false", "no", "off"}
PROMPT_CACHE_DIAGNOSTICS_LEVELS = {"off", "basic", "debug"}
TICKFLOW_KLINE_ADJUST_VALUES = {"none", "forward", "backward", "forward_additive", "backward_additive"}
# Fallback defaults used when ANSPIRE_API_KEYS is reused as legacy OpenAI-compatible source.
# These are compatibility examples; actual availability should be validated by Anspire console/model entitlement.
ANSPIRE_LLM_BASE_URL_DEFAULT = "https://open-gateway.anspire.cn/v6"
@@ -130,6 +131,18 @@ def _has_gotify_base_url(value: Optional[str]) -> bool:
return not (path_segments and path_segments[-1].lower() == "message")
def normalize_tickflow_kline_adjust(value: Optional[str]) -> str:
"""Normalize TickFlow daily K-line adjustment mode."""
normalized = (value or "none").strip().lower()
if normalized in TICKFLOW_KLINE_ADJUST_VALUES:
return normalized
logger.warning(
"Invalid TICKFLOW_KLINE_ADJUST=%r; falling back to none",
value,
)
return "none"
def parse_prompt_cache_diagnostics_level(value: Optional[str]) -> str:
"""Parse prompt-cache diagnostics level with a conservative fallback."""
normalized = (value or "off").strip().lower()
@@ -703,6 +716,10 @@ class Config:
# === 数据源 API Token ===
tushare_token: Optional[str] = None
tickflow_api_key: Optional[str] = None
tickflow_kline_adjust: str = "none"
tickflow_priority: int = 2
tickflow_batch_daily_enabled: bool = True
tickflow_batch_size: int = 100
finnhub_api_key: Optional[str] = None
alphavantage_api_key: Optional[str] = None
longbridge_app_key: Optional[str] = None
@@ -1589,6 +1606,10 @@ class Config:
feishu_folder_token=os.getenv('FEISHU_FOLDER_TOKEN'),
tushare_token=os.getenv('TUSHARE_TOKEN'),
tickflow_api_key=os.getenv('TICKFLOW_API_KEY'),
tickflow_kline_adjust=normalize_tickflow_kline_adjust(os.getenv('TICKFLOW_KLINE_ADJUST')),
tickflow_priority=parse_env_int(os.getenv('TICKFLOW_PRIORITY'), 2, field_name='TICKFLOW_PRIORITY', minimum=0),
tickflow_batch_daily_enabled=parse_env_bool(os.getenv('TICKFLOW_BATCH_DAILY_ENABLED'), default=True),
tickflow_batch_size=parse_env_int(os.getenv('TICKFLOW_BATCH_SIZE'), 100, field_name='TICKFLOW_BATCH_SIZE', minimum=1),
finnhub_api_key=os.getenv('FINNHUB_API_KEY') or None,
alphavantage_api_key=os.getenv('ALPHAVANTAGE_API_KEY') or None,
longbridge_app_key=os.getenv('LONGBRIDGE_APP_KEY') or None,

View File

@@ -748,7 +748,7 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
},
"TICKFLOW_API_KEY": {
"title": "TickFlow API Key",
"description": "API key for TickFlow market review enhancement (A-share indices, plus market stats when universe queries are enabled).",
"description": "API key for optional TickFlow A-share daily K-lines, realtime quotes, stock list/name lookup, and market review enhancement. Permission failures fail open to existing providers.",
"category": "data_source",
"data_type": "string",
"ui_control": "password",
@@ -760,6 +760,62 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"validation": {},
"display_order": 15,
},
"TICKFLOW_PRIORITY": {
"title": "TickFlow Daily K-line Priority",
"description": "Priority for TickFlow daily K-line fetcher. Lower numbers are tried earlier; realtime quote order is controlled separately by REALTIME_SOURCE_PRIORITY.",
"category": "data_source",
"data_type": "integer",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "2",
"options": [],
"validation": {"min": 0, "max": 99},
"display_order": 16,
},
"TICKFLOW_KLINE_ADJUST": {
"title": "TickFlow K-line Adjust",
"description": "Adjustment mode for TickFlow daily K-lines. Default none preserves the existing unadjusted technical-indicator baseline.",
"category": "data_source",
"data_type": "string",
"ui_control": "select",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "none",
"options": ["none", "forward", "backward", "forward_additive", "backward_additive"],
"validation": {},
"display_order": 17,
},
"TICKFLOW_BATCH_DAILY_ENABLED": {
"title": "TickFlow Batch Daily Enabled",
"description": "Enable TickFlow batch daily K-line prefetch when the current plan allows it. Permission failures fail open and fall back to per-stock providers.",
"category": "data_source",
"data_type": "boolean",
"ui_control": "switch",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "true",
"options": [],
"validation": {},
"display_order": 18,
},
"TICKFLOW_BATCH_SIZE": {
"title": "TickFlow Batch Size",
"description": "Maximum symbols per TickFlow batch request for daily K-lines and realtime quotes.",
"category": "data_source",
"data_type": "integer",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "100",
"options": [],
"validation": {"min": 1, "max": 500},
"display_order": 19,
},
"STOCK_INDEX_REMOTE_UPDATE_ENABLED": {
"title": "Remote Stock Index Updates",
"description": "Automatically refresh the local stock autocomplete index from the built-in GitHub main source.",
@@ -4432,6 +4488,26 @@ _FIELD_HELP_METADATA: Dict[str, Dict[str, Any]] = {
"docs": _DOC_FULL_GUIDE_DATA_SOURCE,
"warning_codes": ["secret_value"],
},
"TICKFLOW_PRIORITY": {
"help_key": "settings.data_source.TICKFLOW_PRIORITY",
"examples": ["TICKFLOW_PRIORITY=2"],
"docs": _DOC_FULL_GUIDE_DATA_SOURCE,
},
"TICKFLOW_KLINE_ADJUST": {
"help_key": "settings.data_source.TICKFLOW_KLINE_ADJUST",
"examples": ["TICKFLOW_KLINE_ADJUST=none"],
"docs": _DOC_FULL_GUIDE_DATA_SOURCE,
},
"TICKFLOW_BATCH_DAILY_ENABLED": {
"help_key": "settings.data_source.TICKFLOW_BATCH_DAILY_ENABLED",
"examples": ["TICKFLOW_BATCH_DAILY_ENABLED=true"],
"docs": _DOC_FULL_GUIDE_DATA_SOURCE,
},
"TICKFLOW_BATCH_SIZE": {
"help_key": "settings.data_source.TICKFLOW_BATCH_SIZE",
"examples": ["TICKFLOW_BATCH_SIZE=100"],
"docs": _DOC_FULL_GUIDE_DATA_SOURCE,
},
"SERPAPI_API_KEYS": {
"help_key": "settings.data_source.search_api_keys",
"examples": [

View File

@@ -2878,6 +2878,15 @@ class StockAnalysisPipeline:
# === 批量预取实时行情(优化:避免每只股票都触发全量拉取)===
# 只有股票数量 >= 5 时才进行预取,少量股票直接逐个查询更高效
if len(stock_codes) >= 5:
daily_prefetch_count = self.fetcher_manager.prefetch_daily_klines(stock_codes, days=30)
if daily_prefetch_count > 0:
logger.info(
"[prefetch] component=daily_kline_prefetch action=complete "
"provider=TickFlowFetcher cached=%d stock_count=%d",
daily_prefetch_count,
len(stock_codes),
)
prefetch_count = self.fetcher_manager.prefetch_realtime_quotes(stock_codes)
if prefetch_count > 0:
logger.info(f"已启用批量预取架构:一次拉取全市场数据,{len(stock_codes)} 只股票共享缓存")

View File

@@ -732,6 +732,106 @@ class RunFlowTestCase(unittest.TestCase):
any(event.type == "provider_run" and event.severity == "warning" for event in snapshot.events)
)
def test_tickflow_provider_runs_map_to_run_flow_nodes_and_fallback_edges(self) -> None:
context_snapshot = {
"diagnostics": {
"trace_id": "trace-tickflow",
"task_id": "task-tickflow",
"query_id": "query-tickflow",
"stock_code": "600519",
"trigger_source": "api",
"provider_runs": [
{
"trace_id": "trace-tickflow",
"data_type": "daily_data",
"provider": "TickFlowFetcher",
"operation": "get_daily_data",
"success": True,
"latency_ms": 504,
"record_count": 30,
"cache_hit": True,
"created_at": "2026-06-08T10:00:01",
},
{
"trace_id": "trace-tickflow",
"data_type": "realtime_quote",
"provider": "TickFlowFetcher",
"operation": "get_realtime_quote",
"success": False,
"latency_ms": 892,
"error_type": "DataFetchError",
"fallback_to": "AkshareFetcher",
"created_at": "2026-06-08T10:00:02",
},
{
"trace_id": "trace-tickflow",
"data_type": "realtime_quote",
"provider": "AkshareFetcher",
"operation": "get_realtime_quote",
"success": True,
"latency_ms": 8700,
"record_count": 1,
"fallback_from": "TickFlowFetcher",
"created_at": "2026-06-08T10:00:11",
},
],
},
"analysis_context_pack_overview": _overview(
blocks=[
{
"key": "daily_bars",
"label": "日线",
"status": "available",
"source": "TickFlowFetcher",
"warnings": [],
"missing_reasons": [],
},
{
"key": "quote",
"label": "行情",
"status": "fallback",
"source": "AkshareFetcher",
"warnings": ["tickflow_realtime_fallback"],
"missing_reasons": [],
},
]
),
}
snapshot = build_history_run_flow_snapshot(_history_record(context_snapshot=context_snapshot))
nodes = {node.id: node for node in snapshot.nodes}
edges = [edge.model_dump(by_alias=True) for edge in snapshot.edges]
self.assertEqual(snapshot.status, "degraded")
self.assertEqual(snapshot.summary.fallback_count, 1)
self.assertIn("provider_daily_data_tickflowfetcher_1", nodes)
self.assertEqual(nodes["provider_daily_data_tickflowfetcher_1"].provider, "TickFlowFetcher")
self.assertEqual(nodes["provider_daily_data_tickflowfetcher_1"].status, "success")
self.assertEqual(nodes["provider_daily_data_tickflowfetcher_1"].record_count, 30)
self.assertEqual(
nodes["provider_daily_data_tickflowfetcher_1"].metadata.get("cache_hit"),
True,
)
self.assertIn("provider_realtime_quote_tickflowfetcher_1", nodes)
self.assertEqual(nodes["provider_realtime_quote_tickflowfetcher_1"].status, "failed")
self.assertIn("provider_realtime_quote_aksharefetcher_2", nodes)
self.assertEqual(nodes["provider_realtime_quote_aksharefetcher_2"].status, "fallback")
self.assertTrue(
any(
edge["from"] == "provider_realtime_quote_tickflowfetcher_1"
and edge["to"] == "provider_realtime_quote_aksharefetcher_2"
and edge["kind"] == "fallback"
for edge in edges
)
)
self.assertTrue(
any(
event.type == "provider_run"
and event.node_id == "provider_realtime_quote_tickflowfetcher_1"
and event.severity == "warning"
for event in snapshot.events
)
)
def test_news_search_provider_runs_map_to_run_flow_nodes(self) -> None:
context_snapshot = {
"diagnostics": {

View File

@@ -1,17 +1,27 @@
# -*- coding: utf-8 -*-
"""Unit tests for TickFlow market-review-only fetcher."""
"""Unit tests for the TickFlow fetcher."""
import os
import sys
import unittest
from unittest.mock import patch
import pandas as pd
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from data_provider.tickflow_fetcher import (
TickFlowFetcher,
_UNIVERSE_PERMISSION_NEGATIVE_CACHE_TTL_SECONDS,
)
from data_provider.base import DataFetchError
from data_provider.realtime_types import RealtimeSource
from data_provider.tickflow_fetcher import TickFlowFetcher
class _PermissionLikeError(Exception):
def __init__(self, message="forbidden", *, status_code=403, code="FORBIDDEN"):
super().__init__(message)
self.message = message
self.status_code = status_code
self.code = code
class _FakeQuotesResource:
@@ -21,9 +31,7 @@ class _FakeQuotesResource:
self.calls = []
def get(self, *, symbols=None, universes=None, as_dataframe=False):
self.calls.append(
{"symbols": symbols, "universes": universes, "as_dataframe": as_dataframe}
)
self.calls.append({"symbols": symbols, "universes": universes, "as_dataframe": as_dataframe})
if symbols is not None:
if isinstance(self._symbols_data, dict):
return self._symbols_data.get(tuple(symbols), [])
@@ -35,282 +43,328 @@ class _FakeQuotesResource:
return []
class _FakeKlinesResource:
def __init__(self, daily_data=None, batch_data=None, batch_error=None, intraday_data=None, ex_factors_data=None):
self.daily_data = daily_data if daily_data is not None else pd.DataFrame()
self.batch_data = batch_data if batch_data is not None else {}
self.batch_error = batch_error
self.intraday_data = intraday_data if intraday_data is not None else pd.DataFrame()
self.ex_factors_data = ex_factors_data if ex_factors_data is not None else pd.DataFrame()
self.get_calls = []
self.batch_calls = []
self.intraday_calls = []
self.ex_factors_calls = []
def get(self, symbol, **kwargs):
self.get_calls.append({"symbol": symbol, **kwargs})
return self.daily_data
def batch(self, symbols, **kwargs):
symbols = list(symbols)
self.batch_calls.append({"symbols": symbols, **kwargs})
if self.batch_error:
raise self.batch_error
if isinstance(self.batch_data, dict):
return {symbol: self.batch_data[symbol] for symbol in symbols if symbol in self.batch_data}
return self.batch_data
def intraday(self, symbol, **kwargs):
self.intraday_calls.append({"symbol": symbol, **kwargs})
return self.intraday_data
def intraday_batch(self, symbols, **kwargs):
return {symbol: self.intraday_data for symbol in symbols}
def ex_factors(self, symbols, **kwargs):
self.ex_factors_calls.append({"symbols": list(symbols), **kwargs})
return self.ex_factors_data
class _FakeUniverseResource:
def __init__(self, data=None):
self.data = data if data is not None else {"symbols": []}
self.calls = []
def get(self, universe_id):
self.calls.append(universe_id)
if isinstance(self.data, Exception):
raise self.data
return self.data
class _FakeInstrumentsResource:
def get(self, symbol):
return {"symbol": symbol, "name": "InstrumentName"}
class _FakeClient:
def __init__(self, symbols_data=None, universe_data=None):
def __init__(self, symbols_data=None, universe_data=None, daily_data=None, batch_data=None, batch_error=None):
self.quotes = _FakeQuotesResource(symbols_data, universe_data)
self.klines = _FakeKlinesResource(daily_data=daily_data, batch_data=batch_data, batch_error=batch_error)
self.universes = _FakeUniverseResource(universe_data)
self.instruments = _FakeInstrumentsResource()
self.closed = False
def close(self):
self.closed = True
return None
class _PermissionLikeError(Exception):
def __init__(self, message, *, status_code=403, code="FORBIDDEN"):
super().__init__(message)
self.message = message
self.status_code = status_code
self.code = code
self.details = None
def _daily_rows(symbol="600519.SH"):
return pd.DataFrame(
[
{"symbol": symbol, "timestamp": 1704067200000, "open": 10, "high": 11, "low": 9, "close": 10, "volume": 100, "amount": 1000},
{"symbol": symbol, "timestamp": 1704153600000, "open": 10, "high": 12, "low": 10, "close": 11, "volume": 200, "amount": 2500},
]
)
def _quote(
symbol,
*,
last_price,
prev_close,
amount,
name="",
change_pct=None,
change_amount=None,
amplitude=None,
):
ext = {}
def _dated_daily_rows(start, periods, symbol="600519.SH"):
return pd.DataFrame(
[
{
"symbol": symbol,
"trade_date": day.strftime("%Y-%m-%d"),
"open": 10,
"high": 11,
"low": 9,
"close": 10 + index,
"volume": 100 + index,
"amount": 1000 + index,
}
for index, day in enumerate(pd.bdate_range(start, periods=periods))
]
)
def _quote(symbol, *, last_price=11.0, prev_close=10.0, amount=1000.0, volume=100, name="", change_pct=0.1, amplitude=0.2, turnover_rate=0.03):
ext = {"change_pct": change_pct, "amplitude": amplitude, "turnover_rate": turnover_rate}
if name:
ext["name"] = name
if change_pct is not None:
ext["change_pct"] = change_pct
if change_amount is not None:
ext["change_amount"] = change_amount
if amplitude is not None:
ext["amplitude"] = amplitude
return {
"symbol": symbol,
"last_price": last_price,
"prev_close": prev_close,
"open": last_price,
"high": last_price,
"low": last_price,
"volume": 1000,
"open": 10.0,
"high": 12.0,
"low": 9.0,
"volume": volume,
"amount": amount,
"timestamp": 0,
"region": "CN",
"timestamp": 1704153600000,
"ext": ext,
}
class TestTickFlowFetcher(unittest.TestCase):
def test_get_main_indices_maps_cn_quotes(self):
def test_daily_kline_normalizes_units_and_pct_change(self):
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(daily_data=_daily_rows())
df = fetcher.get_daily_data("600519", start_date="2024-01-01", end_date="2024-01-03")
self.assertEqual(fetcher._client.klines.get_calls[0]["symbol"], "600519.SH")
self.assertEqual(fetcher._client.klines.get_calls[0]["period"], "1d")
self.assertEqual(fetcher._client.klines.get_calls[0]["count"], 30)
self.assertEqual(fetcher._client.klines.get_calls[0]["adjust"], "none")
self.assertEqual(df.iloc[0]["volume"], 10000)
self.assertEqual(df.iloc[1]["volume"], 20000)
self.assertEqual(df.iloc[1]["amount"], 2500)
self.assertAlmostEqual(df.iloc[1]["pct_chg"], 10.0)
def test_daily_pct_chg_column_keeps_percent_values_below_one(self):
rows = _daily_rows()
rows["pct_chg"] = [0.0, 0.5]
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(daily_data=rows)
df = fetcher.get_daily_data("600519", start_date="2024-01-01", end_date="2024-01-03")
self.assertAlmostEqual(df.iloc[1]["pct_chg"], 0.5)
def test_daily_change_pct_column_is_treated_as_ratio(self):
rows = _daily_rows()
rows["change_pct"] = [0.0, 0.005]
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(daily_data=rows)
df = fetcher.get_daily_data("600519", start_date="2024-01-01", end_date="2024-01-03")
self.assertAlmostEqual(df.iloc[1]["pct_chg"], 0.5)
def test_coerce_frame_wraps_scalar_dict_as_one_row(self):
df = TickFlowFetcher._coerce_frame({"symbol": "600519.SH", "revenue": 1.0})
self.assertEqual(len(df), 1)
self.assertEqual(df.iloc[0]["symbol"], "600519.SH")
self.assertEqual(df.iloc[0]["revenue"], 1.0)
def test_realtime_quote_maps_ratios_to_percent_and_lots_to_shares(self):
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(symbols_data=[_quote("600519.SH", name="Kweichow")])
quote = fetcher.get_realtime_quote("600519")
self.assertEqual(quote.source, RealtimeSource.TICKFLOW)
self.assertEqual(quote.code, "600519")
self.assertEqual(quote.name, "Kweichow")
self.assertEqual(quote.volume, 10000)
self.assertAlmostEqual(quote.change_pct, 10.0)
self.assertAlmostEqual(quote.amplitude, 20.0)
self.assertAlmostEqual(quote.turnover_rate, 3.0)
def test_batch_daily_prefetch_warms_cache_for_followup_single_call(self):
batch_data = {"600519.SH": _daily_rows("600519.SH")}
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(daily_data=pd.DataFrame(), batch_data=batch_data)
cached = fetcher.prefetch_daily_klines(["600519"], start_date="2024-01-01", end_date="2024-01-03")
df = fetcher.get_daily_data("600519", start_date="2024-01-01", end_date="2024-01-03")
self.assertEqual(cached, 1)
self.assertEqual(len(fetcher._client.klines.batch_calls), 1)
self.assertEqual(fetcher._client.klines.get_calls, [])
self.assertEqual(len(df), 2)
def test_daily_kline_request_passes_count_and_rejects_capped_incomplete_history(self):
request_count = TickFlowFetcher._daily_kline_count("2020-01-01", "2026-05-10")
rows = _dated_daily_rows("2023-01-03", request_count)
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(daily_data=rows)
with self.assertRaises(DataFetchError):
fetcher.get_daily_data("600519", start_date="2020-01-01", end_date="2026-05-10")
call = fetcher._client.klines.get_calls[0]
self.assertEqual(call["count"], request_count)
self.assertEqual(call["period"], "1d")
self.assertIn("start_time", call)
self.assertIn("end_time", call)
def test_daily_kline_keeps_short_history_when_count_cap_not_hit(self):
request_count = TickFlowFetcher._daily_kline_count("2020-01-01", "2026-05-10")
rows = _dated_daily_rows("2023-01-03", 2)
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(daily_data=rows)
df = fetcher.get_daily_data("600519", start_date="2020-01-01", end_date="2026-05-10")
self.assertEqual(len(df), 2)
self.assertEqual(fetcher._client.klines.get_calls[0]["count"], request_count)
def test_daily_kline_keeps_capped_history_when_requested_start_is_weekend(self):
request_count = TickFlowFetcher._daily_kline_count("2024-03-02", "2027-05-10")
rows = _dated_daily_rows("2024-03-04", request_count)
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(daily_data=rows)
df = fetcher.get_daily_data("600519", start_date="2024-03-02", end_date="2027-05-10")
self.assertGreater(len(df), 0)
self.assertEqual(pd.Timestamp(df.iloc[0]["date"]).strftime("%Y-%m-%d"), "2024-03-04")
self.assertLessEqual(pd.Timestamp(df.iloc[-1]["date"]).strftime("%Y-%m-%d"), "2027-05-10")
def test_batch_daily_prefetch_passes_count_and_skips_truncated_cache(self):
request_count = TickFlowFetcher._daily_kline_count("2020-01-01", "2026-05-10")
batch_data = {"600519.SH": _dated_daily_rows("2023-01-03", request_count, "600519.SH")}
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(daily_data=_daily_rows(), batch_data=batch_data)
cached = fetcher.prefetch_daily_klines(["600519"], start_date="2020-01-01", end_date="2026-05-10")
df = fetcher.get_daily_data("600519", start_date="2020-01-01", end_date="2026-05-10")
self.assertEqual(cached, 0)
batch_call = fetcher._client.klines.batch_calls[0]
self.assertEqual(batch_call["count"], request_count)
self.assertEqual(len(fetcher._client.klines.get_calls), 1)
self.assertEqual(len(df), 2)
def test_batch_daily_prefetch_batches_and_logs_summary(self):
batch_data = {
"600519.SH": _daily_rows("600519.SH"),
"000001.SZ": _daily_rows("000001.SZ"),
}
fetcher = TickFlowFetcher(api_key="sk-test", batch_size=1)
fetcher._client = _FakeClient(daily_data=pd.DataFrame(), batch_data=batch_data)
with self.assertLogs("data_provider.tickflow_fetcher", level="INFO") as logs:
cached = fetcher.prefetch_daily_klines(
["600519", "000001"],
start_date="2024-01-01",
end_date="2024-01-03",
)
self.assertEqual(cached, 2)
self.assertEqual(
[call["symbols"] for call in fetcher._client.klines.batch_calls],
[["600519.SH"], ["000001.SZ"]],
)
self.assertEqual([call["count"] for call in fetcher._client.klines.batch_calls], [30, 30])
self.assertIn("cached=2 total=2 batches=2", "\n".join(logs.output))
def test_batch_daily_permission_failure_negative_caches_and_single_fallback_still_works(self):
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(
symbols_data={
(
"000001.SH",
"399001.SZ",
"399006.SZ",
"000688.SH",
"000016.SH",
): [
_quote(
"000001.SH",
last_price=3200.0,
prev_close=3180.0,
amount=1.2e11,
name="忽略远端名称",
change_pct=0.0063,
change_amount=20.0,
amplitude=0.014,
),
_quote(
"399001.SZ",
last_price=10000.0,
prev_close=9900.0,
amount=9.5e10,
change_pct=0.0101,
change_amount=100.0,
amplitude=0.0200,
),
_quote(
"399006.SZ",
last_price=2000.0,
prev_close=1980.0,
amount=5.0e10,
change_pct=0.0101,
change_amount=20.0,
amplitude=0.0150,
),
_quote(
"000688.SH",
last_price=900.0,
prev_close=890.0,
amount=3.0e10,
change_pct=0.0112,
change_amount=10.0,
amplitude=0.0180,
),
_quote(
"000016.SH",
last_price=2500.0,
prev_close=2480.0,
amount=4.0e10,
change_pct=0.0081,
change_amount=20.0,
amplitude=0.0130,
),
],
("000300.SH",): [
_quote(
"000300.SH",
last_price=3800.0,
prev_close=3780.0,
amount=6.0e10,
change_pct=0.0053,
change_amount=20.0,
amplitude=0.0110,
)
],
}
daily_data=_daily_rows(),
batch_error=_PermissionLikeError("batch permission denied"),
)
self.assertEqual(fetcher.prefetch_daily_klines(["600519"], start_date="2024-01-01", end_date="2024-01-03"), 0)
self.assertEqual(fetcher.prefetch_daily_klines(["600519"], start_date="2024-01-01", end_date="2024-01-03"), 0)
self.assertEqual(len(fetcher._client.klines.batch_calls), 1)
df = fetcher.get_daily_data("600519", start_date="2024-01-01", end_date="2024-01-03")
self.assertEqual(len(df), 2)
self.assertEqual(len(fetcher._client.klines.get_calls), 1)
def test_realtime_prefetch_uses_quote_cache(self):
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(symbols_data=[_quote("600519.SH")])
self.assertEqual(fetcher.prefetch_realtime_quotes(["600519"]), 1)
quote = fetcher.get_realtime_quote("600519")
self.assertIsNotNone(quote)
self.assertEqual(len(fetcher._client.quotes.calls), 1)
def test_stock_list_uses_universe_and_keeps_missing_optional_fields_blank(self):
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(universe_data={"symbols": [{"symbol": "600519.SH", "name": "\u8d35\u5dde\u8305\u53f0"}, {"code": "000001.SZ", "short_name": "\u5e73\u5b89\u94f6\u884c"}, "AAPL"]})
df = fetcher.get_stock_list()
self.assertEqual(list(df["code"]), ["600519", "000001"])
self.assertEqual(list(df["name"]), ["\u8d35\u5dde\u8305\u53f0", "\u5e73\u5b89\u94f6\u884c"])
self.assertEqual(list(df["industry"]), ["", ""])
self.assertEqual(list(df["area"]), ["", ""])
def test_get_main_indices_maps_cn_quotes(self):
symbols = ["000001.SH", "399001.SZ", "399006.SZ", "000688.SH", "000016.SH", "000300.SH"]
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(symbols_data=[_quote(symbol, last_price=10, prev_close=9) for symbol in symbols])
data = fetcher.get_main_indices(region="cn")
self.assertEqual(
fetcher._client.quotes.calls[0]["symbols"],
[
"000001.SH",
"399001.SZ",
"399006.SZ",
"000688.SH",
"000016.SH",
],
)
self.assertEqual(fetcher._client.quotes.calls[1]["symbols"], ["000300.SH"])
self.assertEqual(data[0]["code"], "000001")
self.assertEqual(data[0]["name"], "上证指数")
self.assertAlmostEqual(data[0]["change_pct"], 0.63)
self.assertAlmostEqual(data[0]["amplitude"], 1.4)
self.assertEqual(data[1]["code"], "399001")
self.assertEqual(data[0]["name"], "\u4e0a\u8bc1\u6307\u6570")
self.assertAlmostEqual(data[0]["change_pct"], 10.0)
def test_get_main_indices_returns_none_for_non_cn_region(self):
def test_get_market_stats_permission_failure_is_negative_cached(self):
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(symbols_data=[_quote("000001.SH", last_price=1, prev_close=1, amount=1)])
self.assertIsNone(fetcher.get_main_indices(region="us"))
self.assertEqual(fetcher._client.quotes.calls, [])
def test_get_main_indices_returns_none_when_quotes_incomplete(self):
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(
symbols_data=[
_quote("000001.SH", last_price=3200.0, prev_close=3180.0, amount=1.2e11),
_quote("399001.SZ", last_price=10000.0, prev_close=9900.0, amount=9.5e10),
]
)
self.assertIsNone(fetcher.get_main_indices(region="cn"))
def test_get_market_stats_calculates_a_share_rules(self):
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(
universe_data=[
_quote("600000.SH", last_price=11.0, prev_close=10.0, amount=1e8, name="浦发银行"),
_quote("300750.SZ", last_price=12.0, prev_close=10.0, amount=1e8, name="宁德时代"),
_quote("688001.SH", last_price=8.0, prev_close=10.0, amount=1e8, name="科创测试"),
_quote("920001.BJ", last_price=13.0, prev_close=10.0, amount=1e8, name="北交测试"),
_quote("600001.SH", last_price=10.5, prev_close=10.0, amount=1e8, name="*ST示例"),
_quote("600002.SH", last_price=10.0, prev_close=10.0, amount=1e8, name="平盘示例"),
_quote("600003.SH", last_price=11.0, prev_close=10.0, amount=0.0, name="零成交额"),
_quote("600004.SH", last_price=11.0, prev_close=None, amount=1e8, name="缺昨收"),
]
)
stats = fetcher.get_market_stats()
self.assertEqual(fetcher._client.quotes.calls[0]["universes"], ["CN_Equity_A"])
self.assertEqual(stats["up_count"], 4)
self.assertEqual(stats["down_count"], 1)
self.assertEqual(stats["flat_count"], 1)
self.assertEqual(stats["limit_up_count"], 4)
self.assertEqual(stats["limit_down_count"], 1)
self.assertAlmostEqual(stats["total_amount"], 7.0)
def test_get_market_stats_counts_amount_even_when_price_stats_skip_row(self):
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(
universe_data=[
_quote("600000.SH", last_price=11.0, prev_close=10.0, amount=1e8, name="浦发银行"),
_quote("600004.SH", last_price=11.0, prev_close=None, amount=1e8, name="缺昨收"),
]
)
stats = fetcher.get_market_stats()
self.assertEqual(stats["up_count"], 1)
self.assertEqual(stats["down_count"], 0)
self.assertEqual(stats["flat_count"], 0)
self.assertAlmostEqual(stats["total_amount"], 2.0)
def test_get_market_stats_returns_none_for_empty_quotes(self):
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(universe_data=[])
fetcher._client = _FakeClient(universe_data=_PermissionLikeError("universe forbidden"))
self.assertIsNone(fetcher.get_market_stats())
def test_get_market_stats_returns_none_when_universe_query_not_supported(self):
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(
universe_data=RuntimeError("当前套餐不支持标的池查询,请升级或使用 symbols 参数")
)
self.assertIsNone(fetcher.get_market_stats())
self.assertFalse(fetcher._universe_query_supported)
self.assertIsNone(fetcher.get_market_stats())
self.assertEqual(len(fetcher._client.quotes.calls), 1)
def test_get_market_stats_retries_permission_probe_after_negative_cache_ttl(self):
def test_capability_negative_cache_retries_after_ttl(self):
fetcher = TickFlowFetcher(api_key="sk-test")
fetcher._client = _FakeClient(
universe_data=_PermissionLikeError("forbidden", status_code=403)
)
fetcher._client = _FakeClient(universe_data=_PermissionLikeError("universe forbidden"))
with patch(
"data_provider.tickflow_fetcher.monotonic",
side_effect=[
100.0,
100.0 + _UNIVERSE_PERMISSION_NEGATIVE_CACHE_TTL_SECONDS + 1,
],
):
with patch("data_provider.tickflow_fetcher.monotonic", side_effect=[100.0, 100.0, 1001.0, 1001.0]):
self.assertIsNone(fetcher.get_market_stats())
self.assertIsNone(fetcher.get_market_stats())
self.assertEqual(len(fetcher._client.quotes.calls), 2)
def test_close_resets_client_and_universe_probe_state(self):
fetcher = TickFlowFetcher(api_key="sk-test")
client = _FakeClient(
universe_data=_PermissionLikeError("forbidden", status_code=403)
)
fetcher._client = client
self.assertIsNone(fetcher.get_market_stats())
self.assertFalse(fetcher._universe_query_supported)
fetcher.close()
self.assertTrue(client.closed)
self.assertIsNone(fetcher._client)
self.assertIsNone(fetcher._universe_query_supported)
self.assertIsNone(fetcher._universe_query_checked_at)
def test_is_universe_permission_error_handles_multiple_error_shapes(self):
cases = [
(_PermissionLikeError("blocked", status_code=403, code=""), True),
(
_PermissionLikeError(
"denied", status_code=400, code="PERMISSION_DENIED"
),
True,
),
(RuntimeError("Universe permission is forbidden"), True),
(RuntimeError("network timeout"), False),
]
for exc, expected in cases:
with self.subTest(exc=repr(exc), expected=expected):
self.assertEqual(
TickFlowFetcher._is_universe_permission_error(exc), expected
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,165 @@
# -*- coding: utf-8 -*-
"""Manager-level routing tests for TickFlow integration."""
import os
import sys
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import pandas as pd
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from data_provider.base import DataFetcherManager
from data_provider.realtime_types import RealtimeSource, UnifiedRealtimeQuote
from src.config import Config
class _FakeTickFlowFetcher:
name = "TickFlowFetcher"
priority = 2
def __init__(self):
self.quote_calls = []
self.prefetch_quote_calls = []
self.prefetch_daily_calls = []
def get_realtime_quote(self, stock_code):
self.quote_calls.append(stock_code)
return UnifiedRealtimeQuote(
code="600519",
name="TickFlowName",
price=10.0,
change_pct=1.0,
source=RealtimeSource.TICKFLOW,
)
def prefetch_realtime_quotes(self, stock_codes, batch_size=None):
self.prefetch_quote_calls.append((list(stock_codes), batch_size))
return len(stock_codes)
def prefetch_daily_klines(self, stock_codes, days=30):
self.prefetch_daily_calls.append((list(stock_codes), days))
return len(stock_codes)
class _FailingDailyFetcher:
name = "TickFlowFetcher"
priority = 0
def get_daily_data(self, stock_code, start_date=None, end_date=None, days=30):
from data_provider.base import DataFetchError
raise DataFetchError("TickFlow daily K-line response may be truncated by count")
class _FallbackDailyFetcher:
name = "FallbackFetcher"
priority = 1
def __init__(self):
self.calls = []
def get_daily_data(self, stock_code, start_date=None, end_date=None, days=30):
self.calls.append((stock_code, start_date, end_date, days))
return pd.DataFrame(
[
{
"code": stock_code,
"date": "2024-01-02",
"open": 10.0,
"high": 11.0,
"low": 9.0,
"close": 10.5,
"volume": 1000.0,
"amount": 10000.0,
"pct_chg": 0.0,
}
]
)
class TestTickFlowManagerRouting(unittest.TestCase):
def _manager(self, fetcher):
return DataFetcherManager(fetchers=[fetcher])
def test_realtime_priority_tickflow_routes_to_tickflow_fetcher(self):
fetcher = _FakeTickFlowFetcher()
manager = self._manager(fetcher)
config = SimpleNamespace(
enable_realtime_quote=True,
realtime_source_priority="tickflow,tencent",
realtime_cache_ttl=600,
)
with patch("src.config.get_config", return_value=config):
quote = manager.get_realtime_quote("600519")
self.assertEqual(quote.source, RealtimeSource.TICKFLOW)
self.assertEqual(fetcher.quote_calls, ["600519"])
def test_realtime_prefetch_uses_tickflow_only_when_early_priority(self):
fetcher = _FakeTickFlowFetcher()
manager = self._manager(fetcher)
config = SimpleNamespace(
prefetch_realtime_quotes=True,
enable_realtime_quote=True,
realtime_source_priority="tickflow,tencent,akshare_sina",
tickflow_batch_size=50,
)
with patch("src.config.get_config", return_value=config):
count = manager.prefetch_realtime_quotes(["600519", "000001", "300750", "000858", "601318"])
self.assertEqual(count, 5)
self.assertEqual(fetcher.prefetch_quote_calls[0][1], 50)
def test_daily_prefetch_delegates_to_tickflow_fetcher(self):
fetcher = _FakeTickFlowFetcher()
manager = self._manager(fetcher)
count = manager.prefetch_daily_klines(["600519", "000001"], days=30)
self.assertEqual(count, 2)
self.assertEqual(fetcher.prefetch_daily_calls, [(["600519", "000001"], 30)])
def test_daily_data_falls_back_when_tickflow_reports_incomplete_nonempty_data(self):
tickflow = _FailingDailyFetcher()
fallback = _FallbackDailyFetcher()
manager = DataFetcherManager(fetchers=[tickflow, fallback])
df, source = manager.get_daily_data("600519", start_date="2020-01-01", end_date="2026-05-10")
self.assertEqual(source, "FallbackFetcher")
self.assertEqual(len(df), 1)
self.assertEqual(len(fallback.calls), 1)
def test_tickflow_priority_is_read_for_new_instances_after_module_import(self):
from data_provider.tickflow_fetcher import TickFlowFetcher
with patch.dict(os.environ, {"TICKFLOW_PRIORITY": "7"}, clear=False):
first = TickFlowFetcher(api_key="sk-test")
with patch.dict(os.environ, {"TICKFLOW_PRIORITY": "1"}, clear=False):
second = TickFlowFetcher(api_key="sk-test")
self.assertEqual(first.priority, 7)
self.assertEqual(second.priority, 1)
def test_config_loads_tickflow_priority(self):
with patch.dict(os.environ, {"TICKFLOW_PRIORITY": "0"}, clear=True):
config = Config._load_from_env()
self.assertEqual(config.tickflow_priority, 0)
def test_tickflow_api_key_does_not_auto_inject_realtime_priority(self):
with patch.dict(os.environ, {"TICKFLOW_API_KEY": "tk-test"}, clear=True):
self.assertEqual(
Config._resolve_realtime_source_priority(),
"tencent,akshare_sina,efinance,akshare_em",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""Pipeline-level regression tests for TickFlow batch prefetch wiring."""
import os
import sys
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from tests.litellm_stub import ensure_litellm_stub
ensure_litellm_stub()
from src.analyzer import AnalysisResult
from src.core.pipeline import StockAnalysisPipeline
def _make_result(code: str) -> AnalysisResult:
return AnalysisResult(
code=code,
name=f"Stock{code}",
sentiment_score=80,
trend_prediction="bullish",
operation_advice="hold",
analysis_summary="ok",
success=True,
)
class _TrackingFetcherManager:
def __init__(self, events):
self.events = events
def prefetch_daily_klines(self, stock_codes, days=30):
self.events.append(("daily_prefetch", list(stock_codes), days))
return len(stock_codes)
def prefetch_realtime_quotes(self, stock_codes):
self.events.append(("realtime_prefetch", list(stock_codes)))
return len(stock_codes)
def prefetch_stock_names(self, stock_codes, use_bulk=False):
self.events.append(("name_prefetch", list(stock_codes), use_bulk))
return len(stock_codes)
class TestTickFlowPipelinePrefetch(unittest.TestCase):
def test_run_prefetches_daily_klines_before_realtime_and_stock_processing(self):
events = []
pipeline = StockAnalysisPipeline.__new__(StockAnalysisPipeline)
pipeline.max_workers = 1
pipeline.fetcher_manager = _TrackingFetcherManager(events)
pipeline._save_local_report = MagicMock()
pipeline._send_notifications = MagicMock()
pipeline.config = SimpleNamespace(
stock_list=[],
refresh_stock_list=lambda: None,
single_stock_notify=False,
report_type="simple",
analysis_delay=0,
)
def _process(code, skip_analysis=False, single_stock_notify=False, report_type=None, analysis_query_id=None, current_time=None):
events.append(("process", code))
return _make_result(code)
pipeline.process_single_stock = MagicMock(side_effect=_process)
results = pipeline.run(
stock_codes=["600519", "000001", "300750", "000858", "601318"],
dry_run=False,
send_notification=False,
)
self.assertEqual(len(results), 5)
self.assertEqual(events[0][0], "daily_prefetch")
self.assertEqual(events[0][2], 30)
self.assertEqual(events[1][0], "realtime_prefetch")
self.assertEqual(events[2][0], "name_prefetch")
self.assertTrue(all(event[0] != "process" for event in events[:3]))
self.assertEqual([event[0] for event in events[3:]], ["process"] * 5)
if __name__ == "__main__":
unittest.main()