Files

1 line
2.3 MiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "chrisyer/dolu", "files": {"apps/dsa-web/src/utils/systemConfigI18n.ts": {"e8305ba738f90086d2bd7e19474054decb796526": {"t": 1785745605.1256611, "n": 8578, "h": "0ae65f3ec2cf88f5", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型服务、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、策略与多 Agent 编排配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n ANSPIRE_API_KEYS: 'Anspire API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: 'SearXNG 公共实例自动发现',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n NEWS_STRATEGY_PROFILE: '新闻策略窗口档位',\n NEWS_MAX_AGE_DAYS: '新闻最大时效(天)',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n AGENT_LITELLM_MODEL: 'Agent 主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: '高级模型路由配置',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n ANSPIRE_LLM_ENABLED: '启用 Anspire 大模型',\n ANSPIRE_LLM_BASE_URL: 'Anspire 大模型网关',\n ANSPIRE_LLM_MODEL: 'Anspire 默认模型',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n FEISHU_WEBHOOK_URL: '飞书群机器人 Webhook',\n FEISHU_WEBHOOK_SECRET: '飞书 Webhook 签名密钥',\n FEISHU_WEBHOOK_KEYWORD: '飞书 Webhook 关键词',\n FEISHU_APP_ID: '飞书应用 App ID',\n FEISHU_APP_SECRET: '飞书应用 App Secret',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n CUSTOM_WEBHOOK_URLS: '自定义 Webhook 地址',\n CUSTOM_WEBHOOK_BODY_TEMPLATE: '自定义 Webhook Body 模板',\n NTFY_URL: 'ntfy URL',\n NTFY_TOKEN: 'ntfy Token',\n GOTIFY_URL: 'Gotify URL',\n GOTIFY_TOKEN: 'Gotify Token',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n REPORT_SHOW_LLM_MODEL: '显示分析模型',\n MAX_WORKERS: '最大并发线程数',\n MARKET_REVIEW_COLOR_SCHEME: '大盘复盘涨跌颜色',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 策略问股',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活策略',\n AGENT_SKILL_DIR: 'Agent 策略目录',\n AGENT_ARCH: 'Agent 架构模式',\n AGENT_ORCHESTRATOR_MODE: '编排模式',\n AGENT_ORCHESTRATOR_TIMEOUT_S: 'Agent 超时(秒)',\n AGENT_RISK_OVERRIDE: '风控 Agent 否决',\n AGENT_SKILL_AUTOWEIGHT: '策略自动加权',\n AGENT_SKILL_ROUTING: '策略路由模式',\n AGENT_MEMORY_ENABLED: '记忆与校准',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n ANSPIRE_API_KEYS: 'Anspire Open 密钥,支持逗号分隔多个;默认同时用于大模型网关和新闻检索。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: '当未配置 SearXNG 自建实例时,自动从 searx.space 获取公共实例并轮询使用;设为 false 可禁用该默认行为。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n NEWS_STRATEGY_PROFILE: '新闻窗口档位:ultra_short=1天,short=3天,medium=7天,long=30天。',\n NEWS_MAX_AGE_DAYS: '新闻最大时效上限。实际窗口 = min(策略档位天数, NEWS_MAX_AGE_DAYS)。例如 ultra_short + 7 仍为 1 天。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n AGENT_LITELLM_MODEL: 'Agent 专用主模型。留空时继承主模型;无 provider 前缀时会按 openai/<model> 解析。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: '高级模型路由 YAML 配置文件路径(高级用法)。仅在 YAML 可解析且产出 model_list 时优先于渠道与旧配置,否则会回退。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n ANSPIRE_LLM_ENABLED: '开启后,未配置更高优先级 LLM 渠道或 OpenAI 兼容密钥时,ANSPIRE_API_KEYS 会自动作为 Anspire 大模型密钥使用。',\n ANSPIRE_LLM_BASE_URL: 'Anspire OpenAI 兼容网关地址,默认 https://open-gateway.anspire.cn/v6,海外可用 https://open-gateway.anspire.ai/v6。',\n ANSPIRE_LLM_MODEL: '仅填写 ANSPIRE_API_KEYS 时自动使用的模型,默认 Doubao-Seed-2.0-lite。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n FEISHU_WEBHOOK_URL: '用于群消息推送的飞书自定义机器人 Webhook。它才是通知发送入口,和 FEISHU_APP_ID / FEISHU_APP_SECRET 不是一回事。',\n FEISHU_WEBHOOK_SECRET: '飞书群机器人安全设置里的“签名校验”密钥。开启签名后必须同步填写,否则会推送失败。',\n FEISHU_WEBHOOK_KEYWORD: '飞书群机器人安全设置里的“关键词”。填写后,系统会在每条飞书消息前自动补上该关键词。',\n FEISHU_APP_ID: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n FEISHU_APP_SECRET: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n CUSTOM_WEBHOOK_URLS: '自定义通知端点,多个用英文逗号分隔;Bark 可直接填写 https://api.day.app/YOUR_BARK_KEY。',\n CUSTOM_WEBHOOK_BODY_TEMPLATE: '全局 JSON body 模板,会先于 Bark、Slack、Discord 等自动 payload 生效;推荐使用 $content_json / $title_json,裸 $content / $title 不做 JSON 转义。',\n NTFY_URL: 'ntfy 完整 topic endpoint,必须包含 topic path,例如 https://ntfy.sh/my-topic。',\n NTFY_TOKEN: 'ntfy Bearer Token;公开 topic 可留空。',\n GOTIFY_URL: 'Gotify server base URL,不包含 /message;系统发送时会自动拼接 /message。',\n GOTIFY_TOKEN: 'Gotify application token,系统通过 X-Gotify-Key Header 发送,不会放进 URL。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n REPORT_SHOW_LLM_MODEL: '在通知报告底部显示本次分析使用的 LLM 模型名称;关闭后隐藏运行时模型信息。仅影响展示,不会影响 provider/model/Base URL、运行时模型保存、迁移或清理。',\n MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',\n MARKET_REVIEW_COLOR_SCHEME: '控制大盘复盘指数涨跌幅图标颜色:green_up 为绿涨红跌,red_up 为红涨绿跌。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 策略问股。对外文案仍叫“策略”,内部配置字段统一使用 skill。',\n AGENT_MAX_STEPS: 'Agent 最大推理步数上限。保持默认 10 时,各子 Agent 按自身预设步数运行;调高到高于默认值时,所有子 Agent 统一采用该值;调低到低于某子 Agent 默认值时,该 Agent 会被封顶。',\n AGENT_SKILLS: '逗号分隔的交易策略列表。留空时使用 metadata 里声明的主默认策略 skill(内置默认是 bull_trend);也可填写 all 启用全部策略。',\n AGENT_SKILL_DIR: '存放 Agent 策略定义文件的目录路径,支持 YAML 与 SKILL.md bundle。',\n AGENT_ARCH: \"选择 Agent 执行架构。single 为经典单 Agent;multi 为多 Agent 编排(实验性)。\",\n AGENT_ORCHESTRATOR_MODE: \"Multi-Agent 编排深度。quick(技术→决策)、standard(技术→情报→决策)、full(含风控)、specialist(含策略专家评估)。\",\n AGENT_ORCHESTRATOR_TIMEOUT_S: \"Agent 执行总超时预算(秒)。single-agent 用作整体 ReAct 循环预算,multi-agent 用作协作编排预算;0 表示不限制。\",\n AGENT_RISK_OVERRIDE: \"允许风控 Agent 在发现关键风险时否决买入信号。\",\n AGENT_SKILL_AUTOWEIGHT: \"根据回测表现自动调整策略权重。\",\n AGENT_SKILL_ROUTING: \"策略选择方式。auto 按市场状态自动选择,manual 使用 AGENT_SKILLS 列表。\",\n AGENT_MEMORY_ENABLED: \"启用记忆与校准系统,追踪历史分析准确率并自动调节置信度。\",\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "e407b5bf1461fe2437e408b9beb7cfd9b5972145": {"t": 1785754702.3520687, "n": 8578, "h": "0ae65f3ec2cf88f5", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型服务、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、策略与多 Agent 编排配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n ANSPIRE_API_KEYS: 'Anspire API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: 'SearXNG 公共实例自动发现',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n NEWS_STRATEGY_PROFILE: '新闻策略窗口档位',\n NEWS_MAX_AGE_DAYS: '新闻最大时效(天)',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n AGENT_LITELLM_MODEL: 'Agent 主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: '高级模型路由配置',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n ANSPIRE_LLM_ENABLED: '启用 Anspire 大模型',\n ANSPIRE_LLM_BASE_URL: 'Anspire 大模型网关',\n ANSPIRE_LLM_MODEL: 'Anspire 默认模型',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n FEISHU_WEBHOOK_URL: '飞书群机器人 Webhook',\n FEISHU_WEBHOOK_SECRET: '飞书 Webhook 签名密钥',\n FEISHU_WEBHOOK_KEYWORD: '飞书 Webhook 关键词',\n FEISHU_APP_ID: '飞书应用 App ID',\n FEISHU_APP_SECRET: '飞书应用 App Secret',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n CUSTOM_WEBHOOK_URLS: '自定义 Webhook 地址',\n CUSTOM_WEBHOOK_BODY_TEMPLATE: '自定义 Webhook Body 模板',\n NTFY_URL: 'ntfy URL',\n NTFY_TOKEN: 'ntfy Token',\n GOTIFY_URL: 'Gotify URL',\n GOTIFY_TOKEN: 'Gotify Token',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n REPORT_SHOW_LLM_MODEL: '显示分析模型',\n MAX_WORKERS: '最大并发线程数',\n MARKET_REVIEW_COLOR_SCHEME: '大盘复盘涨跌颜色',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 策略问股',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活策略',\n AGENT_SKILL_DIR: 'Agent 策略目录',\n AGENT_ARCH: 'Agent 架构模式',\n AGENT_ORCHESTRATOR_MODE: '编排模式',\n AGENT_ORCHESTRATOR_TIMEOUT_S: 'Agent 超时(秒)',\n AGENT_RISK_OVERRIDE: '风控 Agent 否决',\n AGENT_SKILL_AUTOWEIGHT: '策略自动加权',\n AGENT_SKILL_ROUTING: '策略路由模式',\n AGENT_MEMORY_ENABLED: '记忆与校准',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n ANSPIRE_API_KEYS: 'Anspire Open 密钥,支持逗号分隔多个;默认同时用于大模型网关和新闻检索。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: '当未配置 SearXNG 自建实例时,自动从 searx.space 获取公共实例并轮询使用;设为 false 可禁用该默认行为。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n NEWS_STRATEGY_PROFILE: '新闻窗口档位:ultra_short=1天,short=3天,medium=7天,long=30天。',\n NEWS_MAX_AGE_DAYS: '新闻最大时效上限。实际窗口 = min(策略档位天数, NEWS_MAX_AGE_DAYS)。例如 ultra_short + 7 仍为 1 天。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n AGENT_LITELLM_MODEL: 'Agent 专用主模型。留空时继承主模型;无 provider 前缀时会按 openai/<model> 解析。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: '高级模型路由 YAML 配置文件路径(高级用法)。仅在 YAML 可解析且产出 model_list 时优先于渠道与旧配置,否则会回退。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n ANSPIRE_LLM_ENABLED: '开启后,未配置更高优先级 LLM 渠道或 OpenAI 兼容密钥时,ANSPIRE_API_KEYS 会自动作为 Anspire 大模型密钥使用。',\n ANSPIRE_LLM_BASE_URL: 'Anspire OpenAI 兼容网关地址,默认 https://open-gateway.anspire.cn/v6,海外可用 https://open-gateway.anspire.ai/v6。',\n ANSPIRE_LLM_MODEL: '仅填写 ANSPIRE_API_KEYS 时自动使用的模型,默认 Doubao-Seed-2.0-lite。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n FEISHU_WEBHOOK_URL: '用于群消息推送的飞书自定义机器人 Webhook。它才是通知发送入口,和 FEISHU_APP_ID / FEISHU_APP_SECRET 不是一回事。',\n FEISHU_WEBHOOK_SECRET: '飞书群机器人安全设置里的“签名校验”密钥。开启签名后必须同步填写,否则会推送失败。',\n FEISHU_WEBHOOK_KEYWORD: '飞书群机器人安全设置里的“关键词”。填写后,系统会在每条飞书消息前自动补上该关键词。',\n FEISHU_APP_ID: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n FEISHU_APP_SECRET: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n CUSTOM_WEBHOOK_URLS: '自定义通知端点,多个用英文逗号分隔;Bark 可直接填写 https://api.day.app/YOUR_BARK_KEY。',\n CUSTOM_WEBHOOK_BODY_TEMPLATE: '全局 JSON body 模板,会先于 Bark、Slack、Discord 等自动 payload 生效;推荐使用 $content_json / $title_json,裸 $content / $title 不做 JSON 转义。',\n NTFY_URL: 'ntfy 完整 topic endpoint,必须包含 topic path,例如 https://ntfy.sh/my-topic。',\n NTFY_TOKEN: 'ntfy Bearer Token;公开 topic 可留空。',\n GOTIFY_URL: 'Gotify server base URL,不包含 /message;系统发送时会自动拼接 /message。',\n GOTIFY_TOKEN: 'Gotify application token,系统通过 X-Gotify-Key Header 发送,不会放进 URL。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n REPORT_SHOW_LLM_MODEL: '在通知报告底部显示本次分析使用的 LLM 模型名称;关闭后隐藏运行时模型信息。仅影响展示,不会影响 provider/model/Base URL、运行时模型保存、迁移或清理。',\n MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',\n MARKET_REVIEW_COLOR_SCHEME: '控制大盘复盘指数涨跌幅图标颜色:green_up 为绿涨红跌,red_up 为红涨绿跌。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 策略问股。对外文案仍叫“策略”,内部配置字段统一使用 skill。',\n AGENT_MAX_STEPS: 'Agent 最大推理步数上限。保持默认 10 时,各子 Agent 按自身预设步数运行;调高到高于默认值时,所有子 Agent 统一采用该值;调低到低于某子 Agent 默认值时,该 Agent 会被封顶。',\n AGENT_SKILLS: '逗号分隔的交易策略列表。留空时使用 metadata 里声明的主默认策略 skill(内置默认是 bull_trend);也可填写 all 启用全部策略。',\n AGENT_SKILL_DIR: '存放 Agent 策略定义文件的目录路径,支持 YAML 与 SKILL.md bundle。',\n AGENT_ARCH: \"选择 Agent 执行架构。single 为经典单 Agent;multi 为多 Agent 编排(实验性)。\",\n AGENT_ORCHESTRATOR_MODE: \"Multi-Agent 编排深度。quick(技术→决策)、standard(技术→情报→决策)、full(含风控)、specialist(含策略专家评估)。\",\n AGENT_ORCHESTRATOR_TIMEOUT_S: \"Agent 执行总超时预算(秒)。single-agent 用作整体 ReAct 循环预算,multi-agent 用作协作编排预算;0 表示不限制。\",\n AGENT_RISK_OVERRIDE: \"允许风控 Agent 在发现关键风险时否决买入信号。\",\n AGENT_SKILL_AUTOWEIGHT: \"根据回测表现自动调整策略权重。\",\n AGENT_SKILL_ROUTING: \"策略选择方式。auto 按市场状态自动选择,manual 使用 AGENT_SKILLS 列表。\",\n AGENT_MEMORY_ENABLED: \"启用记忆与校准系统,追踪历史分析准确率并自动调节置信度。\",\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "f8458c582a30fb5f98daa6b5e39328d00ac49f64": {"t": 1785754705.6794233, "n": 8426, "h": "539e20c82cb37872", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型服务、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、策略与多 Agent 编排配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n ANSPIRE_API_KEYS: 'Anspire API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: 'SearXNG 公共实例自动发现',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n NEWS_STRATEGY_PROFILE: '新闻策略窗口档位',\n NEWS_MAX_AGE_DAYS: '新闻最大时效(天)',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n AGENT_LITELLM_MODEL: 'Agent 主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: '高级模型路由配置',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n ANSPIRE_LLM_ENABLED: '启用 Anspire 大模型',\n ANSPIRE_LLM_BASE_URL: 'Anspire 大模型网关',\n ANSPIRE_LLM_MODEL: 'Anspire 默认模型',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n FEISHU_WEBHOOK_URL: '飞书群机器人 Webhook',\n FEISHU_WEBHOOK_SECRET: '飞书 Webhook 签名密钥',\n FEISHU_WEBHOOK_KEYWORD: '飞书 Webhook 关键词',\n FEISHU_APP_ID: '飞书应用 App ID',\n FEISHU_APP_SECRET: '飞书应用 App Secret',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n CUSTOM_WEBHOOK_URLS: '自定义 Webhook 地址',\n CUSTOM_WEBHOOK_BODY_TEMPLATE: '自定义 Webhook Body 模板',\n NTFY_URL: 'ntfy URL',\n NTFY_TOKEN: 'ntfy Token',\n GOTIFY_URL: 'Gotify URL',\n GOTIFY_TOKEN: 'Gotify Token',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n MAX_WORKERS: '最大并发线程数',\n MARKET_REVIEW_COLOR_SCHEME: '大盘复盘涨跌颜色',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 策略问股',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活策略',\n AGENT_SKILL_DIR: 'Agent 策略目录',\n AGENT_ARCH: 'Agent 架构模式',\n AGENT_ORCHESTRATOR_MODE: '编排模式',\n AGENT_ORCHESTRATOR_TIMEOUT_S: 'Agent 超时(秒)',\n AGENT_RISK_OVERRIDE: '风控 Agent 否决',\n AGENT_SKILL_AUTOWEIGHT: '策略自动加权',\n AGENT_SKILL_ROUTING: '策略路由模式',\n AGENT_MEMORY_ENABLED: '记忆与校准',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n ANSPIRE_API_KEYS: 'Anspire Open 密钥,支持逗号分隔多个;默认同时用于大模型网关和新闻检索。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: '当未配置 SearXNG 自建实例时,自动从 searx.space 获取公共实例并轮询使用;设为 false 可禁用该默认行为。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n NEWS_STRATEGY_PROFILE: '新闻窗口档位:ultra_short=1天,short=3天,medium=7天,long=30天。',\n NEWS_MAX_AGE_DAYS: '新闻最大时效上限。实际窗口 = min(策略档位天数, NEWS_MAX_AGE_DAYS)。例如 ultra_short + 7 仍为 1 天。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n AGENT_LITELLM_MODEL: 'Agent 专用主模型。留空时继承主模型;无 provider 前缀时会按 openai/<model> 解析。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: '高级模型路由 YAML 配置文件路径(高级用法)。仅在 YAML 可解析且产出 model_list 时优先于渠道与旧配置,否则会回退。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n ANSPIRE_LLM_ENABLED: '开启后,未配置更高优先级 LLM 渠道或 OpenAI 兼容密钥时,ANSPIRE_API_KEYS 会自动作为 Anspire 大模型密钥使用。',\n ANSPIRE_LLM_BASE_URL: 'Anspire OpenAI 兼容网关地址,默认 https://open-gateway.anspire.cn/v6,海外可用 https://open-gateway.anspire.ai/v6。',\n ANSPIRE_LLM_MODEL: '仅填写 ANSPIRE_API_KEYS 时自动使用的模型,默认 Doubao-Seed-2.0-lite。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n FEISHU_WEBHOOK_URL: '用于群消息推送的飞书自定义机器人 Webhook。它才是通知发送入口,和 FEISHU_APP_ID / FEISHU_APP_SECRET 不是一回事。',\n FEISHU_WEBHOOK_SECRET: '飞书群机器人安全设置里的“签名校验”密钥。开启签名后必须同步填写,否则会推送失败。',\n FEISHU_WEBHOOK_KEYWORD: '飞书群机器人安全设置里的“关键词”。填写后,系统会在每条飞书消息前自动补上该关键词。',\n FEISHU_APP_ID: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n FEISHU_APP_SECRET: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n CUSTOM_WEBHOOK_URLS: '自定义通知端点,多个用英文逗号分隔;Bark 可直接填写 https://api.day.app/YOUR_BARK_KEY。',\n CUSTOM_WEBHOOK_BODY_TEMPLATE: '全局 JSON body 模板,会先于 Bark、Slack、Discord 等自动 payload 生效;推荐使用 $content_json / $title_json,裸 $content / $title 不做 JSON 转义。',\n NTFY_URL: 'ntfy 完整 topic endpoint,必须包含 topic path,例如 https://ntfy.sh/my-topic。',\n NTFY_TOKEN: 'ntfy Bearer Token;公开 topic 可留空。',\n GOTIFY_URL: 'Gotify server base URL,不包含 /message;系统发送时会自动拼接 /message。',\n GOTIFY_TOKEN: 'Gotify application token,系统通过 X-Gotify-Key Header 发送,不会放进 URL。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',\n MARKET_REVIEW_COLOR_SCHEME: '控制大盘复盘指数涨跌幅图标颜色:green_up 为绿涨红跌,red_up 为红涨绿跌。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 策略问股。对外文案仍叫“策略”,内部配置字段统一使用 skill。',\n AGENT_MAX_STEPS: 'Agent 最大推理步数上限。保持默认 10 时,各子 Agent 按自身预设步数运行;调高到高于默认值时,所有子 Agent 统一采用该值;调低到低于某子 Agent 默认值时,该 Agent 会被封顶。',\n AGENT_SKILLS: '逗号分隔的交易策略列表。留空时使用 metadata 里声明的主默认策略 skill(内置默认是 bull_trend);也可填写 all 启用全部策略。',\n AGENT_SKILL_DIR: '存放 Agent 策略定义文件的目录路径,支持 YAML 与 SKILL.md bundle。',\n AGENT_ARCH: \"选择 Agent 执行架构。single 为经典单 Agent;multi 为多 Agent 编排(实验性)。\",\n AGENT_ORCHESTRATOR_MODE: \"Multi-Agent 编排深度。quick(技术→决策)、standard(技术→情报→决策)、full(含风控)、specialist(含策略专家评估)。\",\n AGENT_ORCHESTRATOR_TIMEOUT_S: \"Agent 执行总超时预算(秒)。single-agent 用作整体 ReAct 循环预算,multi-agent 用作协作编排预算;0 表示不限制。\",\n AGENT_RISK_OVERRIDE: \"允许风控 Agent 在发现关键风险时否决买入信号。\",\n AGENT_SKILL_AUTOWEIGHT: \"根据回测表现自动调整策略权重。\",\n AGENT_SKILL_ROUTING: \"策略选择方式。auto 按市场状态自动选择,manual 使用 AGENT_SKILLS 列表。\",\n AGENT_MEMORY_ENABLED: \"启用记忆与校准系统,追踪历史分析准确率并自动调节置信度。\",\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "a75a0c502e06a49f08439275aaeb7be241c5befe": {"t": 1785754706.9543898, "n": 8306, "h": "5682600e1ed9cb96", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型服务、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、策略与多 Agent 编排配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n ANSPIRE_API_KEYS: 'Anspire API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: 'SearXNG 公共实例自动发现',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n NEWS_STRATEGY_PROFILE: '新闻策略窗口档位',\n NEWS_MAX_AGE_DAYS: '新闻最大时效(天)',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n AGENT_LITELLM_MODEL: 'Agent 主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: '高级模型路由配置',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n ANSPIRE_LLM_ENABLED: '启用 Anspire 大模型',\n ANSPIRE_LLM_BASE_URL: 'Anspire 大模型网关',\n ANSPIRE_LLM_MODEL: 'Anspire 默认模型',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n FEISHU_WEBHOOK_URL: '飞书群机器人 Webhook',\n FEISHU_WEBHOOK_SECRET: '飞书 Webhook 签名密钥',\n FEISHU_WEBHOOK_KEYWORD: '飞书 Webhook 关键词',\n FEISHU_APP_ID: '飞书应用 App ID',\n FEISHU_APP_SECRET: '飞书应用 App Secret',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n CUSTOM_WEBHOOK_URLS: '自定义 Webhook 地址',\n CUSTOM_WEBHOOK_BODY_TEMPLATE: '自定义 Webhook Body 模板',\n NTFY_URL: 'ntfy URL',\n NTFY_TOKEN: 'ntfy Token',\n GOTIFY_URL: 'Gotify URL',\n GOTIFY_TOKEN: 'Gotify Token',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n MAX_WORKERS: '最大并发线程数',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 策略问股',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活策略',\n AGENT_SKILL_DIR: 'Agent 策略目录',\n AGENT_ARCH: 'Agent 架构模式',\n AGENT_ORCHESTRATOR_MODE: '编排模式',\n AGENT_ORCHESTRATOR_TIMEOUT_S: 'Agent 超时(秒)',\n AGENT_RISK_OVERRIDE: '风控 Agent 否决',\n AGENT_SKILL_AUTOWEIGHT: '策略自动加权',\n AGENT_SKILL_ROUTING: '策略路由模式',\n AGENT_MEMORY_ENABLED: '记忆与校准',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n ANSPIRE_API_KEYS: 'Anspire Open 密钥,支持逗号分隔多个;默认同时用于大模型网关和新闻检索。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: '当未配置 SearXNG 自建实例时,自动从 searx.space 获取公共实例并轮询使用;设为 false 可禁用该默认行为。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n NEWS_STRATEGY_PROFILE: '新闻窗口档位:ultra_short=1天,short=3天,medium=7天,long=30天。',\n NEWS_MAX_AGE_DAYS: '新闻最大时效上限。实际窗口 = min(策略档位天数, NEWS_MAX_AGE_DAYS)。例如 ultra_short + 7 仍为 1 天。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n AGENT_LITELLM_MODEL: 'Agent 专用主模型。留空时继承主模型;无 provider 前缀时会按 openai/<model> 解析。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: '高级模型路由 YAML 配置文件路径(高级用法)。仅在 YAML 可解析且产出 model_list 时优先于渠道与旧配置,否则会回退。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n ANSPIRE_LLM_ENABLED: '开启后,未配置更高优先级 LLM 渠道或 OpenAI 兼容密钥时,ANSPIRE_API_KEYS 会自动作为 Anspire 大模型密钥使用。',\n ANSPIRE_LLM_BASE_URL: 'Anspire OpenAI 兼容网关地址,默认 https://open-gateway.anspire.cn/v6,海外可用 https://open-gateway.anspire.ai/v6。',\n ANSPIRE_LLM_MODEL: '仅填写 ANSPIRE_API_KEYS 时自动使用的模型,默认 Doubao-Seed-2.0-lite。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n FEISHU_WEBHOOK_URL: '用于群消息推送的飞书自定义机器人 Webhook。它才是通知发送入口,和 FEISHU_APP_ID / FEISHU_APP_SECRET 不是一回事。',\n FEISHU_WEBHOOK_SECRET: '飞书群机器人安全设置里的“签名校验”密钥。开启签名后必须同步填写,否则会推送失败。',\n FEISHU_WEBHOOK_KEYWORD: '飞书群机器人安全设置里的“关键词”。填写后,系统会在每条飞书消息前自动补上该关键词。',\n FEISHU_APP_ID: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n FEISHU_APP_SECRET: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n CUSTOM_WEBHOOK_URLS: '自定义通知端点,多个用英文逗号分隔;Bark 可直接填写 https://api.day.app/YOUR_BARK_KEY。',\n CUSTOM_WEBHOOK_BODY_TEMPLATE: '全局 JSON body 模板,会先于 Bark、Slack、Discord 等自动 payload 生效;推荐使用 $content_json / $title_json,裸 $content / $title 不做 JSON 转义。',\n NTFY_URL: 'ntfy 完整 topic endpoint,必须包含 topic path,例如 https://ntfy.sh/my-topic。',\n NTFY_TOKEN: 'ntfy Bearer Token;公开 topic 可留空。',\n GOTIFY_URL: 'Gotify server base URL,不包含 /message;系统发送时会自动拼接 /message。',\n GOTIFY_TOKEN: 'Gotify application token,系统通过 X-Gotify-Key Header 发送,不会放进 URL。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 策略问股。对外文案仍叫“策略”,内部配置字段统一使用 skill。',\n AGENT_MAX_STEPS: 'Agent 最大推理步数上限。保持默认 10 时,各子 Agent 按自身预设步数运行;调高到高于默认值时,所有子 Agent 统一采用该值;调低到低于某子 Agent 默认值时,该 Agent 会被封顶。',\n AGENT_SKILLS: '逗号分隔的交易策略列表。留空时使用 metadata 里声明的主默认策略 skill(内置默认是 bull_trend);也可填写 all 启用全部策略。',\n AGENT_SKILL_DIR: '存放 Agent 策略定义文件的目录路径,支持 YAML 与 SKILL.md bundle。',\n AGENT_ARCH: \"选择 Agent 执行架构。single 为经典单 Agent;multi 为多 Agent 编排(实验性)。\",\n AGENT_ORCHESTRATOR_MODE: \"Multi-Agent 编排深度。quick(技术→决策)、standard(技术→情报→决策)、full(含风控)、specialist(含策略专家评估)。\",\n AGENT_ORCHESTRATOR_TIMEOUT_S: \"Agent 执行总超时预算(秒)。single-agent 用作整体 ReAct 循环预算,multi-agent 用作协作编排预算;0 表示不限制。\",\n AGENT_RISK_OVERRIDE: \"允许风控 Agent 在发现关键风险时否决买入信号。\",\n AGENT_SKILL_AUTOWEIGHT: \"根据回测表现自动调整策略权重。\",\n AGENT_SKILL_ROUTING: \"策略选择方式。auto 按市场状态自动选择,manual 使用 AGENT_SKILLS 列表。\",\n AGENT_MEMORY_ENABLED: \"启用记忆与校准系统,追踪历史分析准确率并自动调节置信度。\",\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "8eb5fe4f2868c43a1f0dd3e8111ddb6fcfa02e0e": {"t": 1785754708.3772285, "n": 7775, "h": "4e3a78ffd7918b00", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型服务、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、策略与多 Agent 编排配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n ANSPIRE_API_KEYS: 'Anspire API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: 'SearXNG 公共实例自动发现',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n NEWS_STRATEGY_PROFILE: '新闻策略窗口档位',\n NEWS_MAX_AGE_DAYS: '新闻最大时效(天)',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n AGENT_LITELLM_MODEL: 'Agent 主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: '高级模型路由配置',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n ANSPIRE_LLM_ENABLED: '启用 Anspire 大模型',\n ANSPIRE_LLM_BASE_URL: 'Anspire 大模型网关',\n ANSPIRE_LLM_MODEL: 'Anspire 默认模型',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n FEISHU_WEBHOOK_URL: '飞书群机器人 Webhook',\n FEISHU_WEBHOOK_SECRET: '飞书 Webhook 签名密钥',\n FEISHU_WEBHOOK_KEYWORD: '飞书 Webhook 关键词',\n FEISHU_APP_ID: '飞书应用 App ID',\n FEISHU_APP_SECRET: '飞书应用 App Secret',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n CUSTOM_WEBHOOK_BODY_TEMPLATE: '自定义 Webhook Body 模板',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n MAX_WORKERS: '最大并发线程数',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 策略问股',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活策略',\n AGENT_SKILL_DIR: 'Agent 策略目录',\n AGENT_ARCH: 'Agent 架构模式',\n AGENT_ORCHESTRATOR_MODE: '编排模式',\n AGENT_ORCHESTRATOR_TIMEOUT_S: 'Agent 超时(秒)',\n AGENT_RISK_OVERRIDE: '风控 Agent 否决',\n AGENT_SKILL_AUTOWEIGHT: '策略自动加权',\n AGENT_SKILL_ROUTING: '策略路由模式',\n AGENT_MEMORY_ENABLED: '记忆与校准',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n ANSPIRE_API_KEYS: 'Anspire Open 密钥,支持逗号分隔多个;默认同时用于大模型网关和新闻检索。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: '当未配置 SearXNG 自建实例时,自动从 searx.space 获取公共实例并轮询使用;设为 false 可禁用该默认行为。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n NEWS_STRATEGY_PROFILE: '新闻窗口档位:ultra_short=1天,short=3天,medium=7天,long=30天。',\n NEWS_MAX_AGE_DAYS: '新闻最大时效上限。实际窗口 = min(策略档位天数, NEWS_MAX_AGE_DAYS)。例如 ultra_short + 7 仍为 1 天。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n AGENT_LITELLM_MODEL: 'Agent 专用主模型。留空时继承主模型;无 provider 前缀时会按 openai/<model> 解析。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: '高级模型路由 YAML 配置文件路径(高级用法)。仅在 YAML 可解析且产出 model_list 时优先于渠道与旧配置,否则会回退。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n ANSPIRE_LLM_ENABLED: '开启后,未配置更高优先级 LLM 渠道或 OpenAI 兼容密钥时,ANSPIRE_API_KEYS 会自动作为 Anspire 大模型密钥使用。',\n ANSPIRE_LLM_BASE_URL: 'Anspire OpenAI 兼容网关地址,默认 https://open-gateway.anspire.cn/v6,海外可用 https://open-gateway.anspire.ai/v6。',\n ANSPIRE_LLM_MODEL: '仅填写 ANSPIRE_API_KEYS 时自动使用的模型,默认 Doubao-Seed-2.0-lite。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n FEISHU_WEBHOOK_URL: '用于群消息推送的飞书自定义机器人 Webhook。它才是通知发送入口,和 FEISHU_APP_ID / FEISHU_APP_SECRET 不是一回事。',\n FEISHU_WEBHOOK_SECRET: '飞书群机器人安全设置里的“签名校验”密钥。开启签名后必须同步填写,否则会推送失败。',\n FEISHU_WEBHOOK_KEYWORD: '飞书群机器人安全设置里的“关键词”。填写后,系统会在每条飞书消息前自动补上该关键词。',\n FEISHU_APP_ID: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n FEISHU_APP_SECRET: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n CUSTOM_WEBHOOK_BODY_TEMPLATE: '全局 JSON body 模板,会先于 Bark、Slack、Discord 等自动 payload 生效;推荐使用 $content_json / $title_json,裸 $content / $title 不做 JSON 转义。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 策略问股。对外文案仍叫“策略”,内部配置字段统一使用 skill。',\n AGENT_MAX_STEPS: 'Agent 最大推理步数上限。保持默认 10 时,各子 Agent 按自身预设步数运行;调高到高于默认值时,所有子 Agent 统一采用该值;调低到低于某子 Agent 默认值时,该 Agent 会被封顶。',\n AGENT_SKILLS: '逗号分隔的交易策略列表。留空时使用 metadata 里声明的主默认策略 skill(内置默认是 bull_trend);也可填写 all 启用全部策略。',\n AGENT_SKILL_DIR: '存放 Agent 策略定义文件的目录路径,支持 YAML 与 SKILL.md bundle。',\n AGENT_ARCH: \"选择 Agent 执行架构。single 为经典单 Agent;multi 为多 Agent 编排(实验性)。\",\n AGENT_ORCHESTRATOR_MODE: \"Multi-Agent 编排深度。quick(技术→决策)、standard(技术→情报→决策)、full(含风控)、specialist(含策略专家评估)。\",\n AGENT_ORCHESTRATOR_TIMEOUT_S: \"Agent 执行总超时预算(秒)。single-agent 用作整体 ReAct 循环预算,multi-agent 用作协作编排预算;0 表示不限制。\",\n AGENT_RISK_OVERRIDE: \"允许风控 Agent 在发现关键风险时否决买入信号。\",\n AGENT_SKILL_AUTOWEIGHT: \"根据回测表现自动调整策略权重。\",\n AGENT_SKILL_ROUTING: \"策略选择方式。auto 按市场状态自动选择,manual 使用 AGENT_SKILLS 列表。\",\n AGENT_MEMORY_ENABLED: \"启用记忆与校准系统,追踪历史分析准确率并自动调节置信度。\",\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "695365b3d8bc4f9e897e276021b916b5023f1132": {"t": 1785754709.8782167, "n": 7566, "h": "9354403aac45490e", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型服务、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、策略与多 Agent 编排配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n ANSPIRE_API_KEYS: 'Anspire API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: 'SearXNG 公共实例自动发现',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n NEWS_STRATEGY_PROFILE: '新闻策略窗口档位',\n NEWS_MAX_AGE_DAYS: '新闻最大时效(天)',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n AGENT_LITELLM_MODEL: 'Agent 主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: '高级模型路由配置',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n ANSPIRE_LLM_ENABLED: '启用 Anspire 大模型',\n ANSPIRE_LLM_BASE_URL: 'Anspire 大模型网关',\n ANSPIRE_LLM_MODEL: 'Anspire 默认模型',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n FEISHU_WEBHOOK_URL: '飞书群机器人 Webhook',\n FEISHU_WEBHOOK_SECRET: '飞书 Webhook 签名密钥',\n FEISHU_WEBHOOK_KEYWORD: '飞书 Webhook 关键词',\n FEISHU_APP_ID: '飞书应用 App ID',\n FEISHU_APP_SECRET: '飞书应用 App Secret',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n MAX_WORKERS: '最大并发线程数',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 策略问股',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活策略',\n AGENT_SKILL_DIR: 'Agent 策略目录',\n AGENT_ARCH: 'Agent 架构模式',\n AGENT_ORCHESTRATOR_MODE: '编排模式',\n AGENT_ORCHESTRATOR_TIMEOUT_S: 'Agent 超时(秒)',\n AGENT_RISK_OVERRIDE: '风控 Agent 否决',\n AGENT_SKILL_AUTOWEIGHT: '策略自动加权',\n AGENT_SKILL_ROUTING: '策略路由模式',\n AGENT_MEMORY_ENABLED: '记忆与校准',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n ANSPIRE_API_KEYS: 'Anspire Open 密钥,支持逗号分隔多个;默认同时用于大模型网关和新闻检索。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: '当未配置 SearXNG 自建实例时,自动从 searx.space 获取公共实例并轮询使用;设为 false 可禁用该默认行为。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n NEWS_STRATEGY_PROFILE: '新闻窗口档位:ultra_short=1天,short=3天,medium=7天,long=30天。',\n NEWS_MAX_AGE_DAYS: '新闻最大时效上限。实际窗口 = min(策略档位天数, NEWS_MAX_AGE_DAYS)。例如 ultra_short + 7 仍为 1 天。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n AGENT_LITELLM_MODEL: 'Agent 专用主模型。留空时继承主模型;无 provider 前缀时会按 openai/<model> 解析。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: '高级模型路由 YAML 配置文件路径(高级用法)。仅在 YAML 可解析且产出 model_list 时优先于渠道与旧配置,否则会回退。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n ANSPIRE_LLM_ENABLED: '开启后,未配置更高优先级 LLM 渠道或 OpenAI 兼容密钥时,ANSPIRE_API_KEYS 会自动作为 Anspire 大模型密钥使用。',\n ANSPIRE_LLM_BASE_URL: 'Anspire OpenAI 兼容网关地址,默认 https://open-gateway.anspire.cn/v6,海外可用 https://open-gateway.anspire.ai/v6。',\n ANSPIRE_LLM_MODEL: '仅填写 ANSPIRE_API_KEYS 时自动使用的模型,默认 Doubao-Seed-2.0-lite。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n FEISHU_WEBHOOK_URL: '用于群消息推送的飞书自定义机器人 Webhook。它才是通知发送入口,和 FEISHU_APP_ID / FEISHU_APP_SECRET 不是一回事。',\n FEISHU_WEBHOOK_SECRET: '飞书群机器人安全设置里的“签名校验”密钥。开启签名后必须同步填写,否则会推送失败。',\n FEISHU_WEBHOOK_KEYWORD: '飞书群机器人安全设置里的“关键词”。填写后,系统会在每条飞书消息前自动补上该关键词。',\n FEISHU_APP_ID: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n FEISHU_APP_SECRET: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 策略问股。对外文案仍叫“策略”,内部配置字段统一使用 skill。',\n AGENT_MAX_STEPS: 'Agent 最大推理步数上限。保持默认 10 时,各子 Agent 按自身预设步数运行;调高到高于默认值时,所有子 Agent 统一采用该值;调低到低于某子 Agent 默认值时,该 Agent 会被封顶。',\n AGENT_SKILLS: '逗号分隔的交易策略列表。留空时使用 metadata 里声明的主默认策略 skill(内置默认是 bull_trend);也可填写 all 启用全部策略。',\n AGENT_SKILL_DIR: '存放 Agent 策略定义文件的目录路径,支持 YAML 与 SKILL.md bundle。',\n AGENT_ARCH: \"选择 Agent 执行架构。single 为经典单 Agent;multi 为多 Agent 编排(实验性)。\",\n AGENT_ORCHESTRATOR_MODE: \"Multi-Agent 编排深度。quick(技术→决策)、standard(技术→情报→决策)、full(含风控)、specialist(含策略专家评估)。\",\n AGENT_ORCHESTRATOR_TIMEOUT_S: \"Agent 执行总超时预算(秒)。single-agent 用作整体 ReAct 循环预算,multi-agent 用作协作编排预算;0 表示不限制。\",\n AGENT_RISK_OVERRIDE: \"允许风控 Agent 在发现关键风险时否决买入信号。\",\n AGENT_SKILL_AUTOWEIGHT: \"根据回测表现自动调整策略权重。\",\n AGENT_SKILL_ROUTING: \"策略选择方式。auto 按市场状态自动选择,manual 使用 AGENT_SKILLS 列表。\",\n AGENT_MEMORY_ENABLED: \"启用记忆与校准系统,追踪历史分析准确率并自动调节置信度。\",\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "3dceb1eca8487e5763b5995f78dd9d723213423d": {"t": 1785754711.2206168, "n": 7127, "h": "663b36c54b0cd3af", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、策略与多 Agent 编排配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n ANSPIRE_API_KEYS: 'Anspire API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: 'SearXNG 公共实例自动发现',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n NEWS_STRATEGY_PROFILE: '新闻策略窗口档位',\n NEWS_MAX_AGE_DAYS: '新闻最大时效(天)',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n AGENT_LITELLM_MODEL: 'Agent 主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: '高级模型路由配置',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n FEISHU_WEBHOOK_URL: '飞书群机器人 Webhook',\n FEISHU_WEBHOOK_SECRET: '飞书 Webhook 签名密钥',\n FEISHU_WEBHOOK_KEYWORD: '飞书 Webhook 关键词',\n FEISHU_APP_ID: '飞书应用 App ID',\n FEISHU_APP_SECRET: '飞书应用 App Secret',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n MAX_WORKERS: '最大并发线程数',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 策略问股',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活策略',\n AGENT_SKILL_DIR: 'Agent 策略目录',\n AGENT_ARCH: 'Agent 架构模式',\n AGENT_ORCHESTRATOR_MODE: '编排模式',\n AGENT_ORCHESTRATOR_TIMEOUT_S: 'Agent 超时(秒)',\n AGENT_RISK_OVERRIDE: '风控 Agent 否决',\n AGENT_SKILL_AUTOWEIGHT: '策略自动加权',\n AGENT_SKILL_ROUTING: '策略路由模式',\n AGENT_MEMORY_ENABLED: '记忆与校准',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n ANSPIRE_API_KEYS: '用于新闻检索的 Anspire 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: '当未配置 SearXNG 自建实例时,自动从 searx.space 获取公共实例并轮询使用;设为 false 可禁用该默认行为。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n NEWS_STRATEGY_PROFILE: '新闻窗口档位:ultra_short=1天,short=3天,medium=7天,long=30天。',\n NEWS_MAX_AGE_DAYS: '新闻最大时效上限。实际窗口 = min(策略档位天数, NEWS_MAX_AGE_DAYS)。例如 ultra_short + 7 仍为 1 天。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n AGENT_LITELLM_MODEL: 'Agent 专用主模型。留空时继承主模型;无 provider 前缀时会按 openai/<model> 解析。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: '高级模型路由 YAML 配置文件路径(高级用法)。仅在 YAML 可解析且产出 model_list 时优先于渠道与旧配置,否则会回退。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n FEISHU_WEBHOOK_URL: '用于群消息推送的飞书自定义机器人 Webhook。它才是通知发送入口,和 FEISHU_APP_ID / FEISHU_APP_SECRET 不是一回事。',\n FEISHU_WEBHOOK_SECRET: '飞书群机器人安全设置里的“签名校验”密钥。开启签名后必须同步填写,否则会推送失败。',\n FEISHU_WEBHOOK_KEYWORD: '飞书群机器人安全设置里的“关键词”。填写后,系统会在每条飞书消息前自动补上该关键词。',\n FEISHU_APP_ID: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n FEISHU_APP_SECRET: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 策略问股。对外文案仍叫“策略”,内部配置字段统一使用 skill。',\n AGENT_MAX_STEPS: 'Agent 最大推理步数上限。保持默认 10 时,各子 Agent 按自身预设步数运行;调高到高于默认值时,所有子 Agent 统一采用该值;调低到低于某子 Agent 默认值时,该 Agent 会被封顶。',\n AGENT_SKILLS: '逗号分隔的交易策略列表。留空时使用 metadata 里声明的主默认策略 skill(内置默认是 bull_trend);也可填写 all 启用全部策略。',\n AGENT_SKILL_DIR: '存放 Agent 策略定义文件的目录路径,支持 YAML 与 SKILL.md bundle。',\n AGENT_ARCH: \"选择 Agent 执行架构。single 为经典单 Agent;multi 为多 Agent 编排(实验性)。\",\n AGENT_ORCHESTRATOR_MODE: \"Multi-Agent 编排深度。quick(技术→决策)、standard(技术→情报→决策)、full(含风控)、specialist(含策略专家评估)。\",\n AGENT_ORCHESTRATOR_TIMEOUT_S: \"Agent 执行总超时预算(秒)。single-agent 用作整体 ReAct 循环预算,multi-agent 用作协作编排预算;0 表示不限制。\",\n AGENT_RISK_OVERRIDE: \"允许风控 Agent 在发现关键风险时否决买入信号。\",\n AGENT_SKILL_AUTOWEIGHT: \"根据回测表现自动调整策略权重。\",\n AGENT_SKILL_ROUTING: \"策略选择方式。auto 按市场状态自动选择,manual 使用 AGENT_SKILLS 列表。\",\n AGENT_MEMORY_ENABLED: \"启用记忆与校准系统,追踪历史分析准确率并自动调节置信度。\",\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "abde35bbf9ada6a53238874e111160b488472e84": {"t": 1785754714.9878607, "n": 7043, "h": "b4ea5d13a15f6066", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、策略与多 Agent 编排配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n ANSPIRE_API_KEYS: 'Anspire API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: 'SearXNG 公共实例自动发现',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n NEWS_STRATEGY_PROFILE: '新闻策略窗口档位',\n NEWS_MAX_AGE_DAYS: '新闻最大时效(天)',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n AGENT_LITELLM_MODEL: 'Agent 主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: '高级模型路由配置',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n FEISHU_WEBHOOK_URL: '飞书群机器人 Webhook',\n FEISHU_WEBHOOK_SECRET: '飞书 Webhook 签名密钥',\n FEISHU_WEBHOOK_KEYWORD: '飞书 Webhook 关键词',\n FEISHU_APP_ID: '飞书应用 App ID',\n FEISHU_APP_SECRET: '飞书应用 App Secret',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n MAX_WORKERS: '最大并发线程数',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 策略问股',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活策略',\n AGENT_SKILL_DIR: 'Agent 策略目录',\n AGENT_ARCH: 'Agent 架构模式',\n AGENT_ORCHESTRATOR_MODE: '编排模式',\n AGENT_ORCHESTRATOR_TIMEOUT_S: 'Agent 超时(秒)',\n AGENT_RISK_OVERRIDE: '风控 Agent 否决',\n AGENT_SKILL_AUTOWEIGHT: '策略自动加权',\n AGENT_SKILL_ROUTING: '策略路由模式',\n AGENT_MEMORY_ENABLED: '记忆与校准',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n ANSPIRE_API_KEYS: '用于新闻检索的 Anspire 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: '当未配置 SearXNG 自建实例时,自动从 searx.space 获取公共实例并轮询使用;设为 false 可禁用该默认行为。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n NEWS_STRATEGY_PROFILE: '新闻窗口档位:ultra_short=1天,short=3天,medium=7天,long=30天。',\n NEWS_MAX_AGE_DAYS: '新闻最大时效上限。实际窗口 = min(策略档位天数, NEWS_MAX_AGE_DAYS)。例如 ultra_short + 7 仍为 1 天。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n AGENT_LITELLM_MODEL: 'Agent 专用主模型。留空时继承主模型;无 provider 前缀时会按 openai/<model> 解析。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: '高级模型路由 YAML 配置文件路径(高级用法)。仅在 YAML 可解析且产出 model_list 时优先于渠道与旧配置,否则会回退。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n FEISHU_WEBHOOK_URL: '用于群消息推送的飞书自定义机器人 Webhook。它才是通知发送入口,和 FEISHU_APP_ID / FEISHU_APP_SECRET 不是一回事。',\n FEISHU_WEBHOOK_SECRET: '飞书群机器人安全设置里的“签名校验”密钥。开启签名后必须同步填写,否则会推送失败。',\n FEISHU_WEBHOOK_KEYWORD: '飞书群机器人安全设置里的“关键词”。填写后,系统会在每条飞书消息前自动补上该关键词。',\n FEISHU_APP_ID: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n FEISHU_APP_SECRET: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 策略问股。对外文案仍叫“策略”,内部配置字段统一使用 skill。',\n AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',\n AGENT_SKILLS: '逗号分隔的交易策略列表。留空时使用 metadata 里声明的主默认策略 skill(内置默认是 bull_trend);也可填写 all 启用全部策略。',\n AGENT_SKILL_DIR: '存放 Agent 策略定义文件的目录路径,支持 YAML 与 SKILL.md bundle。',\n AGENT_ARCH: \"选择 Agent 执行架构。single 为经典单 Agent;multi 为多 Agent 编排(实验性)。\",\n AGENT_ORCHESTRATOR_MODE: \"Multi-Agent 编排深度。quick(技术→决策)、standard(技术→情报→决策)、full(含风控)、specialist(含策略专家评估)。\",\n AGENT_ORCHESTRATOR_TIMEOUT_S: \"Agent 执行总超时预算(秒)。single-agent 用作整体 ReAct 循环预算,multi-agent 用作协作编排预算;0 表示不限制。\",\n AGENT_RISK_OVERRIDE: \"允许风控 Agent 在发现关键风险时否决买入信号。\",\n AGENT_SKILL_AUTOWEIGHT: \"根据回测表现自动调整策略权重。\",\n AGENT_SKILL_ROUTING: \"策略选择方式。auto 按市场状态自动选择,manual 使用 AGENT_SKILLS 列表。\",\n AGENT_MEMORY_ENABLED: \"启用记忆与校准系统,追踪历史分析准确率并自动调节置信度。\",\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "672ed9b245197fe50272c720e93771540913b795": {"t": 1785754717.3349998, "n": 6951, "h": "b9eb8570848524af", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、策略与多 Agent 编排配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: 'SearXNG 公共实例自动发现',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n NEWS_STRATEGY_PROFILE: '新闻策略窗口档位',\n NEWS_MAX_AGE_DAYS: '新闻最大时效(天)',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n AGENT_LITELLM_MODEL: 'Agent 主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: '高级模型路由配置',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n FEISHU_WEBHOOK_URL: '飞书群机器人 Webhook',\n FEISHU_WEBHOOK_SECRET: '飞书 Webhook 签名密钥',\n FEISHU_WEBHOOK_KEYWORD: '飞书 Webhook 关键词',\n FEISHU_APP_ID: '飞书应用 App ID',\n FEISHU_APP_SECRET: '飞书应用 App Secret',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n MAX_WORKERS: '最大并发线程数',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 策略问股',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活策略',\n AGENT_SKILL_DIR: 'Agent 策略目录',\n AGENT_ARCH: 'Agent 架构模式',\n AGENT_ORCHESTRATOR_MODE: '编排模式',\n AGENT_ORCHESTRATOR_TIMEOUT_S: 'Agent 超时(秒)',\n AGENT_RISK_OVERRIDE: '风控 Agent 否决',\n AGENT_SKILL_AUTOWEIGHT: '策略自动加权',\n AGENT_SKILL_ROUTING: '策略路由模式',\n AGENT_MEMORY_ENABLED: '记忆与校准',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: '当未配置 SearXNG 自建实例时,自动从 searx.space 获取公共实例并轮询使用;设为 false 可禁用该默认行为。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n NEWS_STRATEGY_PROFILE: '新闻窗口档位:ultra_short=1天,short=3天,medium=7天,long=30天。',\n NEWS_MAX_AGE_DAYS: '新闻最大时效上限。实际窗口 = min(策略档位天数, NEWS_MAX_AGE_DAYS)。例如 ultra_short + 7 仍为 1 天。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n AGENT_LITELLM_MODEL: 'Agent 专用主模型。留空时继承主模型;无 provider 前缀时会按 openai/<model> 解析。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: '高级模型路由 YAML 配置文件路径(高级用法)。仅在 YAML 可解析且产出 model_list 时优先于渠道与旧配置,否则会回退。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n FEISHU_WEBHOOK_URL: '用于群消息推送的飞书自定义机器人 Webhook。它才是通知发送入口,和 FEISHU_APP_ID / FEISHU_APP_SECRET 不是一回事。',\n FEISHU_WEBHOOK_SECRET: '飞书群机器人安全设置里的“签名校验”密钥。开启签名后必须同步填写,否则会推送失败。',\n FEISHU_WEBHOOK_KEYWORD: '飞书群机器人安全设置里的“关键词”。填写后,系统会在每条飞书消息前自动补上该关键词。',\n FEISHU_APP_ID: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n FEISHU_APP_SECRET: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 策略问股。对外文案仍叫“策略”,内部配置字段统一使用 skill。',\n AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',\n AGENT_SKILLS: '逗号分隔的交易策略列表。留空时使用 metadata 里声明的主默认策略 skill(内置默认是 bull_trend);也可填写 all 启用全部策略。',\n AGENT_SKILL_DIR: '存放 Agent 策略定义文件的目录路径,支持 YAML 与 SKILL.md bundle。',\n AGENT_ARCH: \"选择 Agent 执行架构。single 为经典单 Agent;multi 为多 Agent 编排(实验性)。\",\n AGENT_ORCHESTRATOR_MODE: \"Multi-Agent 编排深度。quick(技术→决策)、standard(技术→情报→决策)、full(含风控)、specialist(含策略专家评估)。\",\n AGENT_ORCHESTRATOR_TIMEOUT_S: \"Agent 执行总超时预算(秒)。single-agent 用作整体 ReAct 循环预算,multi-agent 用作协作编排预算;0 表示不限制。\",\n AGENT_RISK_OVERRIDE: \"允许风控 Agent 在发现关键风险时否决买入信号。\",\n AGENT_SKILL_AUTOWEIGHT: \"根据回测表现自动调整策略权重。\",\n AGENT_SKILL_ROUTING: \"策略选择方式。auto 按市场状态自动选择,manual 使用 AGENT_SKILLS 列表。\",\n AGENT_MEMORY_ENABLED: \"启用记忆与校准系统,追踪历史分析准确率并自动调节置信度。\",\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "65fe0446aa9ea855822b6803fe2278ae60b1d869": {"t": 1785754718.5698972, "n": 6352, "h": "f05d946b8f3ee671", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、策略与多 Agent 编排配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: 'SearXNG 公共实例自动发现',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n NEWS_STRATEGY_PROFILE: '新闻策略窗口档位',\n NEWS_MAX_AGE_DAYS: '新闻最大时效(天)',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n AGENT_LITELLM_MODEL: 'Agent 主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: '高级模型路由配置',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n MAX_WORKERS: '最大并发线程数',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 策略问股',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活策略',\n AGENT_SKILL_DIR: 'Agent 策略目录',\n AGENT_ARCH: 'Agent 架构模式',\n AGENT_ORCHESTRATOR_MODE: '编排模式',\n AGENT_ORCHESTRATOR_TIMEOUT_S: 'Agent 超时(秒)',\n AGENT_RISK_OVERRIDE: '风控 Agent 否决',\n AGENT_SKILL_AUTOWEIGHT: '策略自动加权',\n AGENT_SKILL_ROUTING: '策略路由模式',\n AGENT_MEMORY_ENABLED: '记忆与校准',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: '当未配置 SearXNG 自建实例时,自动从 searx.space 获取公共实例并轮询使用;设为 false 可禁用该默认行为。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n NEWS_STRATEGY_PROFILE: '新闻窗口档位:ultra_short=1天,short=3天,medium=7天,long=30天。',\n NEWS_MAX_AGE_DAYS: '新闻最大时效上限。实际窗口 = min(策略档位天数, NEWS_MAX_AGE_DAYS)。例如 ultra_short + 7 仍为 1 天。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n AGENT_LITELLM_MODEL: 'Agent 专用主模型。留空时继承主模型;无 provider 前缀时会按 openai/<model> 解析。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: '高级模型路由 YAML 配置文件路径(高级用法)。仅在 YAML 可解析且产出 model_list 时优先于渠道与旧配置,否则会回退。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 策略问股。对外文案仍叫“策略”,内部配置字段统一使用 skill。',\n AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',\n AGENT_SKILLS: '逗号分隔的交易策略列表。留空时使用 metadata 里声明的主默认策略 skill(内置默认是 bull_trend);也可填写 all 启用全部策略。',\n AGENT_SKILL_DIR: '存放 Agent 策略定义文件的目录路径,支持 YAML 与 SKILL.md bundle。',\n AGENT_ARCH: \"选择 Agent 执行架构。single 为经典单 Agent;multi 为多 Agent 编排(实验性)。\",\n AGENT_ORCHESTRATOR_MODE: \"Multi-Agent 编排深度。quick(技术→决策)、standard(技术→情报→决策)、full(含风控)、specialist(含策略专家评估)。\",\n AGENT_ORCHESTRATOR_TIMEOUT_S: \"Agent 执行总超时预算(秒)。single-agent 用作整体 ReAct 循环预算,multi-agent 用作协作编排预算;0 表示不限制。\",\n AGENT_RISK_OVERRIDE: \"允许风控 Agent 在发现关键风险时否决买入信号。\",\n AGENT_SKILL_AUTOWEIGHT: \"根据回测表现自动调整策略权重。\",\n AGENT_SKILL_ROUTING: \"策略选择方式。auto 按市场状态自动选择,manual 使用 AGENT_SKILLS 列表。\",\n AGENT_MEMORY_ENABLED: \"启用记忆与校准系统,追踪历史分析准确率并自动调节置信度。\",\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "6bef88046556c60dccb39cbf53d3e09db0e03923": {"t": 1785754719.862368, "n": 6320, "h": "341a1bff4f628b4a", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、策略与多 Agent 编排配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: 'SearXNG 公共实例自动发现',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n NEWS_STRATEGY_PROFILE: '新闻策略窗口档位',\n NEWS_MAX_AGE_DAYS: '新闻最大时效(天)',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n AGENT_LITELLM_MODEL: 'Agent 主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: 'LiteLLM 配置文件',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n MAX_WORKERS: '最大并发线程数',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 策略问股',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活策略',\n AGENT_SKILL_DIR: 'Agent 策略目录',\n AGENT_ARCH: 'Agent 架构模式',\n AGENT_ORCHESTRATOR_MODE: '编排模式',\n AGENT_ORCHESTRATOR_TIMEOUT_S: 'Agent 超时(秒)',\n AGENT_RISK_OVERRIDE: '风控 Agent 否决',\n AGENT_SKILL_AUTOWEIGHT: '策略自动加权',\n AGENT_SKILL_ROUTING: '策略路由模式',\n AGENT_MEMORY_ENABLED: '记忆与校准',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: '当未配置 SearXNG 自建实例时,自动从 searx.space 获取公共实例并轮询使用;设为 false 可禁用该默认行为。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n NEWS_STRATEGY_PROFILE: '新闻窗口档位:ultra_short=1天,short=3天,medium=7天,long=30天。',\n NEWS_MAX_AGE_DAYS: '新闻最大时效上限。实际窗口 = min(策略档位天数, NEWS_MAX_AGE_DAYS)。例如 ultra_short + 7 仍为 1 天。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n AGENT_LITELLM_MODEL: 'Agent 专用主模型。留空时继承主模型;无 provider 前缀时会按 openai/<model> 解析。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: 'LiteLLM YAML 配置文件路径(高级用法),优先级最高。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 策略问股。对外文案仍叫“策略”,内部配置字段统一使用 skill。',\n AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',\n AGENT_SKILLS: '逗号分隔的交易策略列表。留空时使用 metadata 里声明的主默认策略 skill(内置默认是 bull_trend);也可填写 all 启用全部策略。',\n AGENT_SKILL_DIR: '存放 Agent 策略定义文件的目录路径,支持 YAML 与 SKILL.md bundle。',\n AGENT_ARCH: \"选择 Agent 执行架构。single 为经典单 Agent;multi 为多 Agent 编排(实验性)。\",\n AGENT_ORCHESTRATOR_MODE: \"Multi-Agent 编排深度。quick(技术→决策)、standard(技术→情报→决策)、full(含风控)、specialist(含策略专家评估)。\",\n AGENT_ORCHESTRATOR_TIMEOUT_S: \"Agent 执行总超时预算(秒)。single-agent 用作整体 ReAct 循环预算,multi-agent 用作协作编排预算;0 表示不限制。\",\n AGENT_RISK_OVERRIDE: \"允许风控 Agent 在发现关键风险时否决买入信号。\",\n AGENT_SKILL_AUTOWEIGHT: \"根据回测表现自动调整策略权重。\",\n AGENT_SKILL_ROUTING: \"策略选择方式。auto 按市场状态自动选择,manual 使用 AGENT_SKILLS 列表。\",\n AGENT_MEMORY_ENABLED: \"启用记忆与校准系统,追踪历史分析准确率并自动调节置信度。\",\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "9bca31304c52a4e9dcc94e5d43b016fdc09c53ec": {"t": 1785754721.5746922, "n": 6265, "h": "f14a847c65cdda9a", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、策略与多 Agent 编排配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: 'SearXNG 公共实例自动发现',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n NEWS_STRATEGY_PROFILE: '新闻策略窗口档位',\n NEWS_MAX_AGE_DAYS: '新闻最大时效(天)',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n AGENT_LITELLM_MODEL: 'Agent 主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: 'LiteLLM 配置文件',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n MAX_WORKERS: '最大并发线程数',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 模式',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活策略',\n AGENT_STRATEGY_DIR: 'Agent 策略目录',\n AGENT_ARCH: 'Agent 架构模式',\n AGENT_ORCHESTRATOR_MODE: '编排模式',\n AGENT_ORCHESTRATOR_TIMEOUT_S: 'Agent 超时(秒)',\n AGENT_RISK_OVERRIDE: '风控 Agent 否决',\n AGENT_STRATEGY_AUTOWEIGHT: '策略自动加权',\n AGENT_STRATEGY_ROUTING: '策略路由模式',\n AGENT_MEMORY_ENABLED: '记忆与校准',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: '当未配置 SearXNG 自建实例时,自动从 searx.space 获取公共实例并轮询使用;设为 false 可禁用该默认行为。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n NEWS_STRATEGY_PROFILE: '新闻窗口档位:ultra_short=1天,short=3天,medium=7天,long=30天。',\n NEWS_MAX_AGE_DAYS: '新闻最大时效上限。实际窗口 = min(策略档位天数, NEWS_MAX_AGE_DAYS)。例如 ultra_short + 7 仍为 1 天。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n AGENT_LITELLM_MODEL: 'Agent 专用主模型。留空时继承主模型;无 provider 前缀时会按 openai/<model> 解析。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: 'LiteLLM YAML 配置文件路径(高级用法),优先级最高。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 进行股票分析。',\n AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',\n AGENT_SKILLS: '逗号分隔的交易策略列表,例如:bull_trend,ma_golden_cross,shrink_pullback。',\n AGENT_STRATEGY_DIR: '存放 Agent 策略 YAML 文件的目录路径。',\n AGENT_ARCH: \"选择 Agent 执行架构。single 为经典单 Agent;multi 为多 Agent 编排(实验性)。\",\n AGENT_ORCHESTRATOR_MODE: \"Multi-Agent 编排深度。quick(技术→决策)、standard(技术→情报→决策)、full(含风控)、strategy(含策略评估)。\",\n AGENT_ORCHESTRATOR_TIMEOUT_S: \"Agent 执行总超时预算(秒)。single-agent 用作整体 ReAct 循环预算,multi-agent 用作协作编排预算;0 表示不限制。\",\n AGENT_RISK_OVERRIDE: \"允许风控 Agent 在发现关键风险时否决买入信号。\",\n AGENT_STRATEGY_AUTOWEIGHT: \"根据回测表现自动调整策略权重。\",\n AGENT_STRATEGY_ROUTING: \"策略选择方式。auto 按市场状态自动选择,manual 使用 AGENT_SKILLS 列表。\",\n AGENT_MEMORY_ENABLED: \"启用记忆与校准系统,追踪历史分析准确率并自动调节置信度。\",\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "3eabeda06ce232d04c9b4a8ccf4a92b52f2ea7d6": {"t": 1785754722.7738318, "n": 6217, "h": "af29ebe0d052eb67", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、策略与多 Agent 编排配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: 'SearXNG 公共实例自动发现',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n NEWS_STRATEGY_PROFILE: '新闻策略窗口档位',\n NEWS_MAX_AGE_DAYS: '新闻最大时效(天)',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n AGENT_LITELLM_MODEL: 'Agent 主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: 'LiteLLM 配置文件',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n MAX_WORKERS: '最大并发线程数',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 模式',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活策略',\n AGENT_STRATEGY_DIR: 'Agent 策略目录',\n AGENT_ARCH: 'Agent 架构模式',\n AGENT_ORCHESTRATOR_MODE: '编排模式',\n AGENT_ORCHESTRATOR_TIMEOUT_S: '编排超时(秒)',\n AGENT_RISK_OVERRIDE: '风控 Agent 否决',\n AGENT_STRATEGY_AUTOWEIGHT: '策略自动加权',\n AGENT_STRATEGY_ROUTING: '策略路由模式',\n AGENT_MEMORY_ENABLED: '记忆与校准',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n SEARXNG_PUBLIC_INSTANCES_ENABLED: '当未配置 SearXNG 自建实例时,自动从 searx.space 获取公共实例并轮询使用;设为 false 可禁用该默认行为。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n NEWS_STRATEGY_PROFILE: '新闻窗口档位:ultra_short=1天,short=3天,medium=7天,long=30天。',\n NEWS_MAX_AGE_DAYS: '新闻最大时效上限。实际窗口 = min(策略档位天数, NEWS_MAX_AGE_DAYS)。例如 ultra_short + 7 仍为 1 天。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n AGENT_LITELLM_MODEL: 'Agent 专用主模型。留空时继承主模型;无 provider 前缀时会按 openai/<model> 解析。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: 'LiteLLM YAML 配置文件路径(高级用法),优先级最高。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 进行股票分析。',\n AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',\n AGENT_SKILLS: '逗号分隔的交易策略列表,例如:bull_trend,ma_golden_cross,shrink_pullback。',\n AGENT_STRATEGY_DIR: '存放 Agent 策略 YAML 文件的目录路径。',\n AGENT_ARCH: \"选择 Agent 执行架构。single 为经典单 Agent;multi 为多 Agent 编排(实验性)。\",\n AGENT_ORCHESTRATOR_MODE: \"Multi-Agent 编排深度。quick(技术→决策)、standard(技术→情报→决策)、full(含风控)、strategy(含策略评估)。\",\n AGENT_ORCHESTRATOR_TIMEOUT_S: \"Multi-Agent 编排总超时预算(秒),0 表示不限制。\",\n AGENT_RISK_OVERRIDE: \"允许风控 Agent 在发现关键风险时否决买入信号。\",\n AGENT_STRATEGY_AUTOWEIGHT: \"根据回测表现自动调整策略权重。\",\n AGENT_STRATEGY_ROUTING: \"策略选择方式。auto 按市场状态自动选择,manual 使用 AGENT_SKILLS 列表。\",\n AGENT_MEMORY_ENABLED: \"启用记忆与校准系统,追踪历史分析准确率并自动调节置信度。\",\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "216a77f6a27aa6d2637ecdd16258b6bc1b813474": {"t": 1785754724.1079812, "n": 6056, "h": "697bc3a49f05d7e3", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、策略与多 Agent 编排配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n NEWS_STRATEGY_PROFILE: '新闻策略窗口档位',\n NEWS_MAX_AGE_DAYS: '新闻最大时效(天)',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n AGENT_LITELLM_MODEL: 'Agent 主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: 'LiteLLM 配置文件',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n MAX_WORKERS: '最大并发线程数',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 模式',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活策略',\n AGENT_STRATEGY_DIR: 'Agent 策略目录',\n AGENT_ARCH: 'Agent 架构模式',\n AGENT_ORCHESTRATOR_MODE: '编排模式',\n AGENT_ORCHESTRATOR_TIMEOUT_S: '编排超时(秒)',\n AGENT_RISK_OVERRIDE: '风控 Agent 否决',\n AGENT_STRATEGY_AUTOWEIGHT: '策略自动加权',\n AGENT_STRATEGY_ROUTING: '策略路由模式',\n AGENT_MEMORY_ENABLED: '记忆与校准',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n NEWS_STRATEGY_PROFILE: '新闻窗口档位:ultra_short=1天,short=3天,medium=7天,long=30天。',\n NEWS_MAX_AGE_DAYS: '新闻最大时效上限。实际窗口 = min(策略档位天数, NEWS_MAX_AGE_DAYS)。例如 ultra_short + 7 仍为 1 天。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n AGENT_LITELLM_MODEL: 'Agent 专用主模型。留空时继承主模型;无 provider 前缀时会按 openai/<model> 解析。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: 'LiteLLM YAML 配置文件路径(高级用法),优先级最高。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 进行股票分析。',\n AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',\n AGENT_SKILLS: '逗号分隔的交易策略列表,例如:bull_trend,ma_golden_cross,shrink_pullback。',\n AGENT_STRATEGY_DIR: '存放 Agent 策略 YAML 文件的目录路径。',\n AGENT_ARCH: \"选择 Agent 执行架构。single 为经典单 Agent;multi 为多 Agent 编排(实验性)。\",\n AGENT_ORCHESTRATOR_MODE: \"Multi-Agent 编排深度。quick(技术→决策)、standard(技术→情报→决策)、full(含风控)、strategy(含策略评估)。\",\n AGENT_ORCHESTRATOR_TIMEOUT_S: \"Multi-Agent 编排总超时预算(秒),0 表示不限制。\",\n AGENT_RISK_OVERRIDE: \"允许风控 Agent 在发现关键风险时否决买入信号。\",\n AGENT_STRATEGY_AUTOWEIGHT: \"根据回测表现自动调整策略权重。\",\n AGENT_STRATEGY_ROUTING: \"策略选择方式。auto 按市场状态自动选择,manual 使用 AGENT_SKILLS 列表。\",\n AGENT_MEMORY_ENABLED: \"启用记忆与校准系统,追踪历史分析准确率并自动调节置信度。\",\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "70bd99c112a5f7417907fd19f846fa8c6a4d0462": {"t": 1785754725.3956623, "n": 5937, "h": "546c1a8bc973290c", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、策略与多 Agent 编排配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n NEWS_STRATEGY_PROFILE: '新闻策略窗口档位',\n NEWS_MAX_AGE_DAYS: '新闻最大时效(天)',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: 'LiteLLM 配置文件',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n MAX_WORKERS: '最大并发线程数',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 模式',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活策略',\n AGENT_STRATEGY_DIR: 'Agent 策略目录',\n AGENT_ARCH: 'Agent 架构模式',\n AGENT_ORCHESTRATOR_MODE: '编排模式',\n AGENT_ORCHESTRATOR_TIMEOUT_S: '编排超时(秒)',\n AGENT_RISK_OVERRIDE: '风控 Agent 否决',\n AGENT_STRATEGY_AUTOWEIGHT: '策略自动加权',\n AGENT_STRATEGY_ROUTING: '策略路由模式',\n AGENT_MEMORY_ENABLED: '记忆与校准',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n NEWS_STRATEGY_PROFILE: '新闻窗口档位:ultra_short=1天,short=3天,medium=7天,long=30天。',\n NEWS_MAX_AGE_DAYS: '新闻最大时效上限。实际窗口 = min(策略档位天数, NEWS_MAX_AGE_DAYS)。例如 ultra_short + 7 仍为 1 天。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: 'LiteLLM YAML 配置文件路径(高级用法),优先级最高。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 进行股票分析。',\n AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',\n AGENT_SKILLS: '逗号分隔的交易策略列表,例如:bull_trend,ma_golden_cross,shrink_pullback。',\n AGENT_STRATEGY_DIR: '存放 Agent 策略 YAML 文件的目录路径。',\n AGENT_ARCH: \"选择 Agent 执行架构。single 为经典单 Agent;multi 为多 Agent 编排(实验性)。\",\n AGENT_ORCHESTRATOR_MODE: \"Multi-Agent 编排深度。quick(技术→决策)、standard(技术→情报→决策)、full(含风控)、strategy(含策略评估)。\",\n AGENT_ORCHESTRATOR_TIMEOUT_S: \"Multi-Agent 编排总超时预算(秒),0 表示不限制。\",\n AGENT_RISK_OVERRIDE: \"允许风控 Agent 在发现关键风险时否决买入信号。\",\n AGENT_STRATEGY_AUTOWEIGHT: \"根据回测表现自动调整策略权重。\",\n AGENT_STRATEGY_ROUTING: \"策略选择方式。auto 按市场状态自动选择,manual 使用 AGENT_SKILLS 列表。\",\n AGENT_MEMORY_ENABLED: \"启用记忆与校准系统,追踪历史分析准确率并自动调节置信度。\",\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "d1ec2c8b5f1ee429e5eeccf4289fa6eea7e15c65": {"t": 1785754726.8956182, "n": 5607, "h": "af7cf5c4b66cf37a", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、策略与多 Agent 编排配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: 'LiteLLM 配置文件',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 模式',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活策略',\n AGENT_STRATEGY_DIR: 'Agent 策略目录',\n AGENT_ARCH: 'Agent 架构模式',\n AGENT_ORCHESTRATOR_MODE: '编排模式',\n AGENT_ORCHESTRATOR_TIMEOUT_S: '编排超时(秒)',\n AGENT_RISK_OVERRIDE: '风控 Agent 否决',\n AGENT_STRATEGY_AUTOWEIGHT: '策略自动加权',\n AGENT_STRATEGY_ROUTING: '策略路由模式',\n AGENT_MEMORY_ENABLED: '记忆与校准',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: 'LiteLLM YAML 配置文件路径(高级用法),优先级最高。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 进行股票分析。',\n AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',\n AGENT_SKILLS: '逗号分隔的交易策略列表,例如:bull_trend,ma_golden_cross,shrink_pullback。',\n AGENT_STRATEGY_DIR: '存放 Agent 策略 YAML 文件的目录路径。',\n AGENT_ARCH: \"选择 Agent 执行架构。single 为经典单 Agent;multi 为多 Agent 编排(实验性)。\",\n AGENT_ORCHESTRATOR_MODE: \"Multi-Agent 编排深度。quick(技术→决策)、standard(技术→情报→决策)、full(含风控)、strategy(含策略评估)。\",\n AGENT_ORCHESTRATOR_TIMEOUT_S: \"Multi-Agent 编排总超时预算(秒),0 表示不限制。\",\n AGENT_RISK_OVERRIDE: \"允许风控 Agent 在发现关键风险时否决买入信号。\",\n AGENT_STRATEGY_AUTOWEIGHT: \"根据回测表现自动调整策略权重。\",\n AGENT_STRATEGY_ROUTING: \"策略选择方式。auto 按市场状态自动选择,manual 使用 AGENT_SKILLS 列表。\",\n AGENT_MEMORY_ENABLED: \"启用记忆与校准系统,追踪历史分析准确率并自动调节置信度。\",\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "a97f41637b0e24ffff4ffa2c78af45cf58dc8fb7": {"t": 1785754728.3715239, "n": 4858, "h": "a7932957868f0a80", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、技能与策略配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: 'LiteLLM 配置文件',\n LLM_CHANNELS: 'LLM 渠道列表',\n LLM_TEMPERATURE: '采样温度',\n AIHUBMIX_KEY: 'AIHubmix Key',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 模式',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活技能',\n AGENT_STRATEGY_DIR: 'Agent 策略目录',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: 'LiteLLM YAML 配置文件路径(高级用法),优先级最高。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n LLM_TEMPERATURE: '控制模型输出随机性,0 为确定性输出,2 为最大随机性,推荐 0.7。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 进行股票分析。',\n AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',\n AGENT_SKILLS: '逗号分隔的激活技能/策略列表,例如:trend_following,value_investing。',\n AGENT_STRATEGY_DIR: '存放 Agent 策略 YAML 文件的目录路径。',\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "4608f8f3a6a3f771e6e533305b32479f6a41bf8f": {"t": 1785754730.0503147, "n": 4773, "h": "0997aebf3b1acab6", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、技能与策略配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n SEARXNG_BASE_URLS: 'SearXNG Base URLs',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: 'LiteLLM 配置文件',\n LLM_CHANNELS: 'LLM 渠道列表',\n AIHUBMIX_KEY: 'AIHubmix Key',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 模式',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活技能',\n AGENT_STRATEGY_DIR: 'Agent 策略目录',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n SEARXNG_BASE_URLS: 'SearXNG 自建实例地址(逗号分隔,无配额兜底,需在 settings.yml 启用 format: json)。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: 'LiteLLM YAML 配置文件路径(高级用法),优先级最高。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 进行股票分析。',\n AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',\n AGENT_SKILLS: '逗号分隔的激活技能/策略列表,例如:trend_following,value_investing。',\n AGENT_STRATEGY_DIR: '存放 Agent 策略 YAML 文件的目录路径。',\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "049e7e8f59c355ca86370a0ac4a8f46e24e9bd03": {"t": 1785754731.6105125, "n": 4647, "h": "efdefe206c09a33e", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、技能与策略配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n BOCHA_API_KEYS: 'Bocha API Keys',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n MINIMAX_API_KEYS: 'MiniMax API Keys',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: 'LiteLLM 配置文件',\n LLM_CHANNELS: 'LLM 渠道列表',\n AIHUBMIX_KEY: 'AIHubmix Key',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 模式',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活技能',\n AGENT_STRATEGY_DIR: 'Agent 策略目录',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n MINIMAX_API_KEYS: '用于新闻检索的 MiniMax 密钥,支持逗号分隔多个(最低优先级)。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: 'LiteLLM YAML 配置文件路径(高级用法),优先级最高。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 进行股票分析。',\n AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',\n AGENT_SKILLS: '逗号分隔的激活技能/策略列表,例如:trend_following,value_investing。',\n AGENT_STRATEGY_DIR: '存放 Agent 策略 YAML 文件的目录路径。',\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "e642f776c8f72526a5871d9c8554b8bdb0eacf72": {"t": 1785754734.780738, "n": 4457, "h": "12e66ebac730124a", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、技能与策略配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n LITELLM_MODEL: '主模型',\n LITELLM_FALLBACK_MODELS: '备选模型',\n LITELLM_CONFIG: 'LiteLLM 配置文件',\n LLM_CHANNELS: 'LLM 渠道列表',\n AIHUBMIX_KEY: 'AIHubmix Key',\n DEEPSEEK_API_KEY: 'DeepSeek API Key',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 模式',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活技能',\n AGENT_STRATEGY_DIR: 'Agent 策略目录',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n LITELLM_MODEL: '主模型,格式 provider/model(如 gemini/gemini-2.5-flash)。配置渠道后自动推断。',\n LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',\n LITELLM_CONFIG: 'LiteLLM YAML 配置文件路径(高级用法),优先级最高。',\n LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',\n AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',\n DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 进行股票分析。',\n AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',\n AGENT_SKILLS: '逗号分隔的激活技能/策略列表,例如:trend_following,value_investing。',\n AGENT_STRATEGY_DIR: '存放 Agent 策略 YAML 文件的目录路径。',\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "c66fd3cf13b5b2e86791a5e2f50a124194d7ed83": {"t": 1785754736.023339, "n": 3907, "h": "6ccfcecbd7bc2e28", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、技能与策略配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 模式',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活技能',\n AGENT_STRATEGY_DIR: 'Agent 策略目录',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 进行股票分析。',\n AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',\n AGENT_SKILLS: '逗号分隔的激活技能/策略列表,例如:trend_following,value_investing。',\n AGENT_STRATEGY_DIR: '存放 Agent 策略 YAML 文件的目录路径。',\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "cddb6baeda02d383d47aa5dc68ca1567b16e7dd8": {"t": 1785754737.3666751, "n": 3759, "h": "a74d729b82889113", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '其他',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、技能与策略配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '其他未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 模式',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活技能',\n AGENT_STRATEGY_DIR: 'Agent 策略目录',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 进行股票分析。',\n AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',\n AGENT_SKILLS: '逗号分隔的激活技能/策略列表,例如:trend_following,value_investing。',\n AGENT_STRATEGY_DIR: '存放 Agent 策略 YAML 文件的目录路径。',\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "e780f840a33c1adc78e92a8392789029ac37fb87": {"t": 1785754740.1089318, "n": 3759, "h": "a2ac4a16ec7db31b", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n agent: 'Agent 设置',\n backtest: '回测配置',\n uncategorized: '未分类',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n agent: '管理 Agent 模式、技能与策略配置。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '暂未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n AGENT_MODE: '启用 Agent 模式',\n AGENT_MAX_STEPS: 'Agent 最大步数',\n AGENT_SKILLS: 'Agent 激活技能',\n AGENT_STRATEGY_DIR: 'Agent 策略目录',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n AGENT_MODE: '是否启用 ReAct Agent 进行股票分析。',\n AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',\n AGENT_SKILLS: '逗号分隔的激活技能/策略列表,例如:trend_following,value_investing。',\n AGENT_STRATEGY_DIR: '存放 Agent 策略 YAML 文件的目录路径。',\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "5414a6de80442388b1c7b0eb0ae3a8aba3d7dfa1": {"t": 1785754741.5291522, "n": 3372, "h": "1c6bc324d4a5a994", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n backtest: '回测配置',\n uncategorized: '未分类',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '暂未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n REPORT_SUMMARY_ONLY: '仅分析结果摘要',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}, "50b75b6db5674eb705e252f78ced702ded6f090f": {"t": 1785754743.7071197, "n": 3283, "h": "65afb02adc893fbd", "c": "import type { SystemConfigCategory } from '../types/systemConfig';\n\nconst categoryTitleMap: Record<SystemConfigCategory, string> = {\n base: '基础设置',\n data_source: '数据源',\n ai_model: 'AI 模型',\n notification: '通知渠道',\n system: '系统设置',\n backtest: '回测配置',\n uncategorized: '未分类',\n};\n\nconst categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {\n base: '管理自选股与基础运行参数。',\n data_source: '管理行情数据源与优先级策略。',\n ai_model: '管理模型供应商、模型名称与推理参数。',\n notification: '管理机器人、Webhook 和消息推送配置。',\n system: '管理调度、日志、端口等系统级参数。',\n backtest: '管理回测开关、评估窗口和引擎参数。',\n uncategorized: '暂未归类的配置项。',\n};\n\nconst fieldTitleMap: Record<string, string> = {\n STOCK_LIST: '自选股列表',\n TUSHARE_TOKEN: 'Tushare Token',\n TAVILY_API_KEYS: 'Tavily API Keys',\n SERPAPI_API_KEYS: 'SerpAPI API Keys',\n BRAVE_API_KEYS: 'Brave API Keys',\n REALTIME_SOURCE_PRIORITY: '实时数据源优先级',\n GEMINI_API_KEY: 'Gemini API Key',\n GEMINI_MODEL: 'Gemini 模型',\n GEMINI_TEMPERATURE: 'Gemini 温度参数',\n OPENAI_API_KEY: 'OpenAI API Key',\n OPENAI_BASE_URL: 'OpenAI Base URL',\n OPENAI_MODEL: 'OpenAI 模型',\n WECHAT_WEBHOOK_URL: '企业微信 Webhook',\n DINGTALK_APP_KEY: '钉钉 App Key',\n DINGTALK_APP_SECRET: '钉钉 App Secret',\n PUSHPLUS_TOKEN: 'PushPlus Token',\n SCHEDULE_TIME: '定时任务时间',\n HTTP_PROXY: 'HTTP 代理',\n LOG_LEVEL: '日志级别',\n WEBUI_PORT: 'WebUI 端口',\n BACKTEST_ENABLED: '启用回测',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',\n BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',\n BACKTEST_ENGINE_VERSION: '回测引擎版本',\n BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%)',\n};\n\nconst fieldDescriptionMap: Record<string, string> = {\n STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。',\n TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',\n TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',\n SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',\n BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',\n REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',\n GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',\n GEMINI_MODEL: '设置 Gemini 分析模型名称。',\n GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',\n OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',\n OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',\n OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',\n WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',\n DINGTALK_APP_KEY: '钉钉应用模式 App Key。',\n DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',\n PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',\n SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',\n HTTP_PROXY: '网络代理地址,可留空。',\n LOG_LEVEL: '设置日志输出级别。',\n WEBUI_PORT: 'Web 页面服务监听端口。',\n BACKTEST_ENABLED: '是否启用回测功能(true/false)。',\n BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',\n BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',\n BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',\n BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',\n};\n\nexport function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryTitleMap[category] || fallback || category;\n}\n\nexport function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {\n return categoryDescriptionMap[category] || fallback || '';\n}\n\nexport function getFieldTitleZh(key: string, fallback?: string): string {\n return fieldTitleMap[key] || fallback || key;\n}\n\nexport function getFieldDescriptionZh(key: string, fallback?: string): string {\n return fieldDescriptionMap[key] || fallback || '';\n}\n"}}, "docs/full-guide_EN.md": {"e8305ba738f90086d2bd7e19474054decb796526": {"t": 1785746419.5446339, "n": 74622, "h": "5880e978078223c7", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification channel matrix, minimal/advanced key split, generated Actions mapping, `--check-notify` CLI behavior, Web one-click notification test, and local / Docker / GitHub Actions / Desktop setup notes are tracked in [Notification Guide](notifications.md).\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `00-daily-analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `00-daily-analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_SHOW_LLM_MODEL` | Whether notification report footers show the LLM model used for analysis. Defaults to `true`; set to `false` to hide runtime model metadata. This switch only affects presentation and does not change provider/model/Base URL, LiteLLM routing, or runtime model save/migration/cleanup behavior. | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n> Compatibility note: `REPORT_SHOW_LLM_MODEL` keeps the previous default-visible behavior (`true`) and only changes report footer rendering. It does not alter provider/model/Base URL, LiteLLM routing, or runtime model persistence/migration/cleanup semantics. Rollback is to remove the variable or set it back to `true`.\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n> Compatibility note for Issue #1306: this change only persists and exposes existing market-review output via history paths, and does not alter model name, provider, base URL, LiteLLM cleanup rules, or `.env` runtime migration semantics. Rollback is to revert this change set. Runtime compatibility references are `requirements.txt` (`litellm` constraints), `docs/LLM_CONFIG_GUIDE_EN.md`, and regression tests in `tests/test_analysis_api_contract.py`, `tests/test_analysis_history.py`, `tests/test_market_review.py`; official references: [LiteLLM OpenAI-compatible](https://docs.litellm.ai/docs/providers/openai_compatible), [OpenAI Chat Completion API](https://platform.openai.com/docs/api-reference/chat).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the notification baseline, diagnostics, and deployment notes, see [Notification Guide](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `00-daily-analysis.yml` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `valuation/growth/earnings/belong_boards` (sourced from `info.sector`/`info.industry`) via the yfinance adapter; `institution/capital_flow/dragon_tiger/boards` stay `not_supported` because no offshore data feed exists today. Falls back to a full `not_supported` block if yfinance is unavailable or returns empty payloads. Still fail-open.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock; A-shares are sourced from AkShare board membership, US/HK from yfinance `info.sector`/`info.industry`, `[]` when unavailable;\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`; not provided for US/HK today);\n> - `fundamental_context.earnings.data.financial_report.currency` = financial statement currency (`info.financialCurrency`; HK ADRs commonly report CNY here);\n> - `fundamental_context.earnings.data.dividend.currency` = trading / dividend currency (`info.currency`; HK ADRs use HKD here even when the statement currency is CNY). The renderer reads each block's own currency rather than assuming a single global currency;\n> - `fundamental_context.earnings.data.dividend.ttm_dividend_yield_pct` = `ttm_cash_dividend_per_share / latest_price * 100`, both sides in the trading currency. Falls back to `info.trailingAnnualDividendYield` (decimal) or `info.dividendYield` (already-percent passthrough) only when TTM cash or latest price is unavailable;\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=8.0` indicates the target budget for the newly added fundamental stage, not a strict hard SLA; Windows, Docker, or rate-limited free data sources can raise it to `12-15s`.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `MARKET_REVIEW_COLOR_SCHEME` | Index change color style in market reviews: `green_up` = green gains/red losses (default), `red_up` = red gains/green losses | `green_up` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep startup environment injection separate from runtime file writes:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- Runtime config writes: do not bind-mount the host `.env` as a single file over the container's `.env` path. Docker treats the target as a mount point, so the `os.replace()` atomic update used during config saves can fail with `Device or resource busy`; fallback in-place writes can also fail on permissions.\n\nThe default Compose and `docker run` examples only use `env_file` / `--env-file` for startup config injection and no longer mount the host `.env` file into the container. Runtime config saved from the WebUI is written to the container-local config file by default and is not the same as writing back to the host `.env`; after deleting or recreating the container, startup still uses the injected `.env` file. If you need persistent runtime config, point `ENV_FILE` at a writable data volume file such as `/app/data/runtime.env` instead of using a single-file `.env` bind mount.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/00-daily-analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe notification channel matrix and `--check-notify` CLI details are documented in [Notification Guide](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `00-daily-analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **Strategy selection** - The Home page supports explicitly selecting analysis strategy skills; when `skills` is omitted, analysis uses the server default strategy so legacy clients keep existing behavior\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Market review history replay** - Market review results are persisted with `report_type=market_review` and can be reopened from history list/detail or Markdown endpoints directly, without re-triggering a fresh analysis run.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/analyze` accepts `skills` as an array of strategy IDs; if omitted, server defaults are used. The legacy field `strategies` is still accepted for backward compatibility.\n> Note: The Web Home page exposes an explicit strategy selector. When users do not pick one, `skills` is not sent and legacy behavior is preserved; when selected, it is passed through to this endpoint and persisted in task status/history snapshots.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: Once `/api/v1/analysis/market-review` completes, the report is persisted with `report_type=market_review`; open `/api/v1/history` and `/api/v1/history/{record_id}` (or Markdown history endpoints) to view it directly without re-running analysis.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# pass strategy list (optional)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\", \"skills\": [\"bull_trend\", \"growth_quality\"]}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot tries realtime quotes first, then falls back to the latest historical close on or before `as_of` when the realtime quote is unavailable or non-positive. Historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode runs the alert worker every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes. The worker reads enabled rules created through the Alert API and continues to support legacy rules in `AGENT_EVENT_ALERT_RULES_JSON`; triggered alerts still go through the existing notification channels. Alert API / Web persisted rules support price, change-percent, volume, daily technical indicators, and the `watchlist`, `portfolio_holdings`, and `portfolio_account` target scopes; legacy JSON still supports only the three basic rule types.\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Legacy JSON is not automatically migrated, deleted, or rewritten. To roll back the background alert worker, clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n| `ma_price_cross` | `above` / `below` | `window` | Daily close edge-crosses MA(window) |\n| `rsi_threshold` | `above` / `below` | `period`, `threshold` | RSI edge-crosses a threshold |\n| `macd_cross` | `bullish_cross` / `bearish_cross` | `fast_period`, `slow_period`, `signal_period` | DIF/DEA edge golden/death cross |\n| `kdj_cross` | `bullish_cross` / `bearish_cross` | `period`, `k_period`, `d_period` | K/D edge golden/death cross |\n| `cci_threshold` | `above` / `below` | `period`, `threshold` | CCI edge-crosses a threshold |\n| `portfolio_stop_loss` | `mode=near|breach` | - | Account-level stop-loss proximity or breach |\n| `portfolio_concentration` | - | - | Account-level symbol concentration |\n| `portfolio_drawdown` | - | - | Account-level maximum drawdown alert |\n| `portfolio_price_stale` | - | - | Stale or missing portfolio prices |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\nThe worker writes `triggered`, `skipped`, `degraded`, and `failed` rows to `alert_triggers` as evaluation history; normal non-triggered checks do not write history. For DB-persisted rules, `triggered` history is best-effort deduplicated by `rule_id + target + data_source + data_timestamp`: repeated hits for the same data point reuse the earliest trigger row, while records without `data_timestamp` are not deduplicated. Real triggers write per-channel attempts to `alert_notifications`, and Alert API persisted rules write business cooldown state to `alert_cooldowns`; if the persisted cooldown read fails, the worker temporarily falls back to the in-process fingerprint guard to avoid repeated notifications during the DB failure. Legacy `AGENT_EVENT_ALERT_RULES_JSON` rules continue to use the in-process fingerprint suppressor and do not write persisted cooldown state; the notification infrastructure `notification_noise.py` guard remains independent. The Web rule list uses the backend-provided `cooldown_active` flag instead of browser-local timezone parsing to decide whether a rule is cooling down.\n\nTechnical indicator rules use daily-close edge triggers only. Partial-bar handling is a server-local-time + 16:00 heuristic and does not implement market-calendar precision. `watchlist` rules refresh and expand `STOCK_LIST` each worker run, `portfolio_holdings` expands non-zero snapshot positions with symbol de-duplication, and `portfolio_account` reuses the portfolio risk service for account-level aggregate evaluation. The WebUI \"Alerts\" page can manage persisted rules, run one-shot dry-run tests, and view trigger history, notification attempts, and read-only cooldown state; cooldown on batch rules is a parent-rule summary, while child-target cooldown details are visible through trigger history. See [Real-Time Alert Center](alerts.md) for detailed boundaries.\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "2caa1292e9be40c1f1e31b3810f7bf831808c13a": {"t": 1785754726.5525033, "n": 72869, "h": "5aa884052dda457e", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification channel matrix, minimal/advanced key split, generated Actions mapping, `--check-notify` CLI behavior, Web one-click notification test, and local / Docker / GitHub Actions / Desktop setup notes are tracked in [Notification Guide](notifications.md).\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `00-daily-analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `00-daily-analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_SHOW_LLM_MODEL` | Whether notification report footers show the LLM model used for analysis. Defaults to `true`; set to `false` to hide runtime model metadata. This switch only affects presentation and does not change provider/model/Base URL, LiteLLM routing, or runtime model save/migration/cleanup behavior. | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n> Compatibility note: `REPORT_SHOW_LLM_MODEL` keeps the previous default-visible behavior (`true`) and only changes report footer rendering. It does not alter provider/model/Base URL, LiteLLM routing, or runtime model persistence/migration/cleanup semantics. Rollback is to remove the variable or set it back to `true`.\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n> Compatibility note for Issue #1306: this change only persists and exposes existing market-review output via history paths, and does not alter model name, provider, base URL, LiteLLM cleanup rules, or `.env` runtime migration semantics. Rollback is to revert this change set. Runtime compatibility references are `requirements.txt` (`litellm` constraints), `docs/LLM_CONFIG_GUIDE_EN.md`, and regression tests in `tests/test_analysis_api_contract.py`, `tests/test_analysis_history.py`, `tests/test_market_review.py`; official references: [LiteLLM OpenAI-compatible](https://docs.litellm.ai/docs/providers/openai_compatible), [OpenAI Chat Completion API](https://platform.openai.com/docs/api-reference/chat).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the notification baseline, diagnostics, and deployment notes, see [Notification Guide](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `00-daily-analysis.yml` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=8.0` indicates the target budget for the newly added fundamental stage, not a strict hard SLA; Windows, Docker, or rate-limited free data sources can raise it to `12-15s`.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `MARKET_REVIEW_COLOR_SCHEME` | Index change color style in market reviews: `green_up` = green gains/red losses (default), `red_up` = red gains/green losses | `green_up` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/00-daily-analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe notification channel matrix and `--check-notify` CLI details are documented in [Notification Guide](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `00-daily-analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **Strategy selection** - The Home page supports explicitly selecting analysis strategy skills; when `skills` is omitted, analysis uses the server default strategy so legacy clients keep existing behavior\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Market review history replay** - Market review results are persisted with `report_type=market_review` and can be reopened from history list/detail or Markdown endpoints directly, without re-triggering a fresh analysis run.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/analyze` accepts `skills` as an array of strategy IDs; if omitted, server defaults are used. The legacy field `strategies` is still accepted for backward compatibility.\n> Note: The Web Home page exposes an explicit strategy selector. When users do not pick one, `skills` is not sent and legacy behavior is preserved; when selected, it is passed through to this endpoint and persisted in task status/history snapshots.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: Once `/api/v1/analysis/market-review` completes, the report is persisted with `report_type=market_review`; open `/api/v1/history` and `/api/v1/history/{record_id}` (or Markdown history endpoints) to view it directly without re-running analysis.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# pass strategy list (optional)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\", \"skills\": [\"bull_trend\", \"growth_quality\"]}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot tries realtime quotes first, then falls back to the latest historical close on or before `as_of` when the realtime quote is unavailable or non-positive. Historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode runs the alert worker every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes. The worker reads enabled rules created through the Alert API and continues to support legacy rules in `AGENT_EVENT_ALERT_RULES_JSON`; triggered alerts still go through the existing notification channels. Alert API / Web persisted rules support price, change-percent, volume, daily technical indicators, and the `watchlist`, `portfolio_holdings`, and `portfolio_account` target scopes; legacy JSON still supports only the three basic rule types.\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Legacy JSON is not automatically migrated, deleted, or rewritten. To roll back the background alert worker, clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n| `ma_price_cross` | `above` / `below` | `window` | Daily close edge-crosses MA(window) |\n| `rsi_threshold` | `above` / `below` | `period`, `threshold` | RSI edge-crosses a threshold |\n| `macd_cross` | `bullish_cross` / `bearish_cross` | `fast_period`, `slow_period`, `signal_period` | DIF/DEA edge golden/death cross |\n| `kdj_cross` | `bullish_cross` / `bearish_cross` | `period`, `k_period`, `d_period` | K/D edge golden/death cross |\n| `cci_threshold` | `above` / `below` | `period`, `threshold` | CCI edge-crosses a threshold |\n| `portfolio_stop_loss` | `mode=near|breach` | - | Account-level stop-loss proximity or breach |\n| `portfolio_concentration` | - | - | Account-level symbol concentration |\n| `portfolio_drawdown` | - | - | Account-level maximum drawdown alert |\n| `portfolio_price_stale` | - | - | Stale or missing portfolio prices |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\nThe worker writes `triggered`, `skipped`, `degraded`, and `failed` rows to `alert_triggers` as evaluation history; normal non-triggered checks do not write history. For DB-persisted rules, `triggered` history is best-effort deduplicated by `rule_id + target + data_source + data_timestamp`: repeated hits for the same data point reuse the earliest trigger row, while records without `data_timestamp` are not deduplicated. Real triggers write per-channel attempts to `alert_notifications`, and Alert API persisted rules write business cooldown state to `alert_cooldowns`; if the persisted cooldown read fails, the worker temporarily falls back to the in-process fingerprint guard to avoid repeated notifications during the DB failure. Legacy `AGENT_EVENT_ALERT_RULES_JSON` rules continue to use the in-process fingerprint suppressor and do not write persisted cooldown state; the notification infrastructure `notification_noise.py` guard remains independent. The Web rule list uses the backend-provided `cooldown_active` flag instead of browser-local timezone parsing to decide whether a rule is cooling down.\n\nTechnical indicator rules use daily-close edge triggers only. Partial-bar handling is a server-local-time + 16:00 heuristic and does not implement market-calendar precision. `watchlist` rules refresh and expand `STOCK_LIST` each worker run, `portfolio_holdings` expands non-zero snapshot positions with symbol de-duplication, and `portfolio_account` reuses the portfolio risk service for account-level aggregate evaluation. The WebUI \"Alerts\" page can manage persisted rules, run one-shot dry-run tests, and view trigger history, notification attempts, and read-only cooldown state; cooldown on batch rules is a parent-rule summary, while child-target cooldown details are visible through trigger history. See [Real-Time Alert Center](alerts.md) for detailed boundaries.\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "6684e327ddc505390800e6f7f55fce2f04266892": {"t": 1785754729.6678925, "n": 72107, "h": "00a2b0fa7a2b4957", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification channel matrix, minimal/advanced key split, generated Actions mapping, `--check-notify` CLI behavior, Web one-click notification test, and local / Docker / GitHub Actions / Desktop setup notes are tracked in [Notification Guide](notifications.md).\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `00-daily-analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `00-daily-analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_SHOW_LLM_MODEL` | Whether notification report footers show the LLM model used for analysis. Defaults to `true`; set to `false` to hide runtime model metadata. This switch only affects presentation and does not change provider/model/Base URL, LiteLLM routing, or runtime model save/migration/cleanup behavior. | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n> Compatibility note: `REPORT_SHOW_LLM_MODEL` keeps the previous default-visible behavior (`true`) and only changes report footer rendering. It does not alter provider/model/Base URL, LiteLLM routing, or runtime model persistence/migration/cleanup semantics. Rollback is to remove the variable or set it back to `true`.\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n> Compatibility note for Issue #1306: this change only persists and exposes existing market-review output via history paths, and does not alter model name, provider, base URL, LiteLLM cleanup rules, or `.env` runtime migration semantics. Rollback is to revert this change set. Runtime compatibility references are `requirements.txt` (`litellm` constraints), `docs/LLM_CONFIG_GUIDE_EN.md`, and regression tests in `tests/test_analysis_api_contract.py`, `tests/test_analysis_history.py`, `tests/test_market_review.py`; official references: [LiteLLM OpenAI-compatible](https://docs.litellm.ai/docs/providers/openai_compatible), [OpenAI Chat Completion API](https://platform.openai.com/docs/api-reference/chat).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the notification baseline, diagnostics, and deployment notes, see [Notification Guide](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `00-daily-analysis.yml` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=8.0` indicates the target budget for the newly added fundamental stage, not a strict hard SLA; Windows, Docker, or rate-limited free data sources can raise it to `12-15s`.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `MARKET_REVIEW_COLOR_SCHEME` | Index change color style in market reviews: `green_up` = green gains/red losses (default), `red_up` = red gains/green losses | `green_up` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/00-daily-analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe notification channel matrix and `--check-notify` CLI details are documented in [Notification Guide](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `00-daily-analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **Strategy selection** - The Home page supports explicitly selecting analysis strategy skills; when `skills` is omitted, analysis uses the server default strategy so legacy clients keep existing behavior\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Market review history replay** - Market review results are persisted with `report_type=market_review` and can be reopened from history list/detail or Markdown endpoints directly, without re-triggering a fresh analysis run.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/analyze` accepts `skills` as an array of strategy IDs; if omitted, server defaults are used. The legacy field `strategies` is still accepted for backward compatibility.\n> Note: The Web Home page exposes an explicit strategy selector. When users do not pick one, `skills` is not sent and legacy behavior is preserved; when selected, it is passed through to this endpoint and persisted in task status/history snapshots.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: Once `/api/v1/analysis/market-review` completes, the report is persisted with `report_type=market_review`; open `/api/v1/history` and `/api/v1/history/{record_id}` (or Markdown history endpoints) to view it directly without re-running analysis.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# pass strategy list (optional)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\", \"skills\": [\"bull_trend\", \"growth_quality\"]}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot tries realtime quotes first, then falls back to the latest historical close on or before `as_of` when the realtime quote is unavailable or non-positive. Historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode runs the alert worker every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes. The worker reads enabled rules created through the Alert API and continues to support legacy rules in `AGENT_EVENT_ALERT_RULES_JSON`; triggered alerts still go through the existing notification channels. Alert API / Web persisted rules support price, change-percent, volume, and daily technical indicator rules; legacy JSON still supports only the three basic rule types.\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Legacy JSON is not automatically migrated, deleted, or rewritten. To roll back the background alert worker, clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n| `ma_price_cross` | `above` / `below` | `window` | Daily close edge-crosses MA(window) |\n| `rsi_threshold` | `above` / `below` | `period`, `threshold` | RSI edge-crosses a threshold |\n| `macd_cross` | `bullish_cross` / `bearish_cross` | `fast_period`, `slow_period`, `signal_period` | DIF/DEA edge golden/death cross |\n| `kdj_cross` | `bullish_cross` / `bearish_cross` | `period`, `k_period`, `d_period` | K/D edge golden/death cross |\n| `cci_threshold` | `above` / `below` | `period`, `threshold` | CCI edge-crosses a threshold |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\nThe worker writes `triggered`, `skipped`, `degraded`, and `failed` rows to `alert_triggers` as evaluation history; normal non-triggered checks do not write history. For DB-persisted rules, `triggered` history is best-effort deduplicated by `rule_id + target + data_source + data_timestamp`: repeated hits for the same data point reuse the earliest trigger row, while records without `data_timestamp` are not deduplicated. Real triggers write per-channel attempts to `alert_notifications`, and Alert API persisted rules write business cooldown state to `alert_cooldowns`; if the persisted cooldown read fails, the worker temporarily falls back to the in-process fingerprint guard to avoid repeated notifications during the DB failure. Legacy `AGENT_EVENT_ALERT_RULES_JSON` rules continue to use the in-process fingerprint suppressor and do not write persisted cooldown state; the notification infrastructure `notification_noise.py` guard remains independent. The Web rule list uses the backend-provided `cooldown_active` flag instead of browser-local timezone parsing to decide whether a rule is cooling down.\n\nTechnical indicator rules use daily-close edge triggers only. Partial-bar handling is a server-local-time + 16:00 heuristic and does not implement market-calendar precision. The WebUI \"Alerts\" page can manage persisted rules, run one-shot dry-run tests, and view trigger history, notification attempts, and read-only cooldown state. See [Real-Time Alert Center](alerts.md) for detailed boundaries.\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "3881de0ac92d32a4a7b5803d7eedd0fc1313c2c9": {"t": 1785754731.410646, "n": 72088, "h": "5386edbf111821da", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification channel matrix, minimal/advanced key split, generated Actions mapping, `--check-notify` CLI behavior, Web one-click notification test, and local / Docker / GitHub Actions / Desktop setup notes are tracked in [Notification Guide](notifications.md).\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_SHOW_LLM_MODEL` | Whether notification report footers show the LLM model used for analysis. Defaults to `true`; set to `false` to hide runtime model metadata. This switch only affects presentation and does not change provider/model/Base URL, LiteLLM routing, or runtime model save/migration/cleanup behavior. | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n> Compatibility note: `REPORT_SHOW_LLM_MODEL` keeps the previous default-visible behavior (`true`) and only changes report footer rendering. It does not alter provider/model/Base URL, LiteLLM routing, or runtime model persistence/migration/cleanup semantics. Rollback is to remove the variable or set it back to `true`.\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n> Compatibility note for Issue #1306: this change only persists and exposes existing market-review output via history paths, and does not alter model name, provider, base URL, LiteLLM cleanup rules, or `.env` runtime migration semantics. Rollback is to revert this change set. Runtime compatibility references are `requirements.txt` (`litellm` constraints), `docs/LLM_CONFIG_GUIDE_EN.md`, and regression tests in `tests/test_analysis_api_contract.py`, `tests/test_analysis_history.py`, `tests/test_market_review.py`; official references: [LiteLLM OpenAI-compatible](https://docs.litellm.ai/docs/providers/openai_compatible), [OpenAI Chat Completion API](https://platform.openai.com/docs/api-reference/chat).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the notification baseline, diagnostics, and deployment notes, see [Notification Guide](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=8.0` indicates the target budget for the newly added fundamental stage, not a strict hard SLA; Windows, Docker, or rate-limited free data sources can raise it to `12-15s`.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `MARKET_REVIEW_COLOR_SCHEME` | Index change color style in market reviews: `green_up` = green gains/red losses (default), `red_up` = red gains/green losses | `green_up` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe notification channel matrix and `--check-notify` CLI details are documented in [Notification Guide](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **Strategy selection** - The Home page supports explicitly selecting analysis strategy skills; when `skills` is omitted, analysis uses the server default strategy so legacy clients keep existing behavior\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Market review history replay** - Market review results are persisted with `report_type=market_review` and can be reopened from history list/detail or Markdown endpoints directly, without re-triggering a fresh analysis run.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/analyze` accepts `skills` as an array of strategy IDs; if omitted, server defaults are used. The legacy field `strategies` is still accepted for backward compatibility.\n> Note: The Web Home page exposes an explicit strategy selector. When users do not pick one, `skills` is not sent and legacy behavior is preserved; when selected, it is passed through to this endpoint and persisted in task status/history snapshots.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: Once `/api/v1/analysis/market-review` completes, the report is persisted with `report_type=market_review`; open `/api/v1/history` and `/api/v1/history/{record_id}` (or Markdown history endpoints) to view it directly without re-running analysis.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# pass strategy list (optional)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\", \"skills\": [\"bull_trend\", \"growth_quality\"]}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot tries realtime quotes first, then falls back to the latest historical close on or before `as_of` when the realtime quote is unavailable or non-positive. Historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode runs the alert worker every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes. The worker reads enabled rules created through the Alert API and continues to support legacy rules in `AGENT_EVENT_ALERT_RULES_JSON`; triggered alerts still go through the existing notification channels. Alert API / Web persisted rules support price, change-percent, volume, and daily technical indicator rules; legacy JSON still supports only the three basic rule types.\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Legacy JSON is not automatically migrated, deleted, or rewritten. To roll back the background alert worker, clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n| `ma_price_cross` | `above` / `below` | `window` | Daily close edge-crosses MA(window) |\n| `rsi_threshold` | `above` / `below` | `period`, `threshold` | RSI edge-crosses a threshold |\n| `macd_cross` | `bullish_cross` / `bearish_cross` | `fast_period`, `slow_period`, `signal_period` | DIF/DEA edge golden/death cross |\n| `kdj_cross` | `bullish_cross` / `bearish_cross` | `period`, `k_period`, `d_period` | K/D edge golden/death cross |\n| `cci_threshold` | `above` / `below` | `period`, `threshold` | CCI edge-crosses a threshold |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\nThe worker writes `triggered`, `skipped`, `degraded`, and `failed` rows to `alert_triggers` as evaluation history; normal non-triggered checks do not write history. For DB-persisted rules, `triggered` history is best-effort deduplicated by `rule_id + target + data_source + data_timestamp`: repeated hits for the same data point reuse the earliest trigger row, while records without `data_timestamp` are not deduplicated. Real triggers write per-channel attempts to `alert_notifications`, and Alert API persisted rules write business cooldown state to `alert_cooldowns`; if the persisted cooldown read fails, the worker temporarily falls back to the in-process fingerprint guard to avoid repeated notifications during the DB failure. Legacy `AGENT_EVENT_ALERT_RULES_JSON` rules continue to use the in-process fingerprint suppressor and do not write persisted cooldown state; the notification infrastructure `notification_noise.py` guard remains independent. The Web rule list uses the backend-provided `cooldown_active` flag instead of browser-local timezone parsing to decide whether a rule is cooling down.\n\nTechnical indicator rules use daily-close edge triggers only. Partial-bar handling is a server-local-time + 16:00 heuristic and does not implement market-calendar precision. The WebUI \"Alerts\" page can manage persisted rules, run one-shot dry-run tests, and view trigger history, notification attempts, and read-only cooldown state. See [Real-Time Alert Center](alerts.md) for detailed boundaries.\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "57cb614d82464bc538520f76612939c5bc615d31": {"t": 1785754734.8670883, "n": 71831, "h": "6189d9c6a87e8084", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification channel matrix, minimal/advanced key split, generated Actions mapping, `--check-notify` CLI behavior, Web one-click notification test, and local / Docker / GitHub Actions / Desktop setup notes are tracked in [Notification Guide](notifications.md).\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_SHOW_LLM_MODEL` | Whether notification report footers show the LLM model used for analysis. Defaults to `true`; set to `false` to hide runtime model metadata. This switch only affects presentation and does not change provider/model/Base URL, LiteLLM routing, or runtime model save/migration/cleanup behavior. | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n> Compatibility note: `REPORT_SHOW_LLM_MODEL` keeps the previous default-visible behavior (`true`) and only changes report footer rendering. It does not alter provider/model/Base URL, LiteLLM routing, or runtime model persistence/migration/cleanup semantics. Rollback is to remove the variable or set it back to `true`.\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n> Compatibility note for Issue #1306: this change only persists and exposes existing market-review output via history paths, and does not alter model name, provider, base URL, LiteLLM cleanup rules, or `.env` runtime migration semantics. Rollback is to revert this change set. Runtime compatibility references are `requirements.txt` (`litellm` constraints), `docs/LLM_CONFIG_GUIDE_EN.md`, and regression tests in `tests/test_analysis_api_contract.py`, `tests/test_analysis_history.py`, `tests/test_market_review.py`; official references: [LiteLLM OpenAI-compatible](https://docs.litellm.ai/docs/providers/openai_compatible), [OpenAI Chat Completion API](https://platform.openai.com/docs/api-reference/chat).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the notification baseline, diagnostics, and deployment notes, see [Notification Guide](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=8.0` indicates the target budget for the newly added fundamental stage, not a strict hard SLA; Windows, Docker, or rate-limited free data sources can raise it to `12-15s`.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `MARKET_REVIEW_COLOR_SCHEME` | Index change color style in market reviews: `green_up` = green gains/red losses (default), `red_up` = red gains/green losses | `green_up` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe notification channel matrix and `--check-notify` CLI details are documented in [Notification Guide](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **Strategy selection** - The Home page supports explicitly selecting analysis strategy skills; when `skills` is omitted, analysis uses the server default strategy so legacy clients keep existing behavior\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Market review history replay** - Market review results are persisted with `report_type=market_review` and can be reopened from history list/detail or Markdown endpoints directly, without re-triggering a fresh analysis run.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/analyze` accepts `skills` as an array of strategy IDs; if omitted, server defaults are used. The legacy field `strategies` is still accepted for backward compatibility.\n> Note: The Web Home page exposes an explicit strategy selector. When users do not pick one, `skills` is not sent and legacy behavior is preserved; when selected, it is passed through to this endpoint and persisted in task status/history snapshots.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: Once `/api/v1/analysis/market-review` completes, the report is persisted with `report_type=market_review`; open `/api/v1/history` and `/api/v1/history/{record_id}` (or Markdown history endpoints) to view it directly without re-running analysis.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# pass strategy list (optional)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\", \"skills\": [\"bull_trend\", \"growth_quality\"]}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot tries realtime quotes first, then falls back to the latest historical close on or before `as_of` when the realtime quote is unavailable or non-positive. Historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode runs the alert worker every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes. The worker reads enabled rules created through the Alert API and continues to support legacy rules in `AGENT_EVENT_ALERT_RULES_JSON`; triggered alerts still go through the existing notification channels. Alert API / Web persisted rules support price, change-percent, volume, and daily technical indicator rules; legacy JSON still supports only the three basic rule types.\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Legacy JSON is not automatically migrated, deleted, or rewritten. To roll back the background alert worker, clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n| `ma_price_cross` | `above` / `below` | `window` | Daily close edge-crosses MA(window) |\n| `rsi_threshold` | `above` / `below` | `period`, `threshold` | RSI edge-crosses a threshold |\n| `macd_cross` | `bullish_cross` / `bearish_cross` | `fast_period`, `slow_period`, `signal_period` | DIF/DEA edge golden/death cross |\n| `kdj_cross` | `bullish_cross` / `bearish_cross` | `period`, `k_period`, `d_period` | K/D edge golden/death cross |\n| `cci_threshold` | `above` / `below` | `period`, `threshold` | CCI edge-crosses a threshold |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\nThe worker writes `triggered`, `skipped`, `degraded`, and `failed` rows to `alert_triggers` as evaluation history; normal non-triggered checks do not write history. Real triggers write per-channel attempts to `alert_notifications`, and Alert API persisted rules write business cooldown state to `alert_cooldowns`; if the persisted cooldown read fails, the worker temporarily falls back to the in-process fingerprint guard to avoid repeated notifications during the DB failure. Legacy `AGENT_EVENT_ALERT_RULES_JSON` rules continue to use the in-process fingerprint suppressor and do not write persisted cooldown state; the notification infrastructure `notification_noise.py` guard remains independent. The Web rule list uses the backend-provided `cooldown_active` flag instead of browser-local timezone parsing to decide whether a rule is cooling down.\n\nTechnical indicator rules use daily-close edge triggers only. Partial-bar handling is a server-local-time + 16:00 heuristic and does not implement market-calendar precision. The WebUI \"Alerts\" page can manage persisted rules, run one-shot dry-run tests, and view trigger history, notification attempts, and read-only cooldown state. See [Real-Time Alert Center](alerts.md) for detailed boundaries.\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "c65972642c9a7b39a8e635f813081299b663ad74": {"t": 1785754736.8003743, "n": 71570, "h": "e1f94fbc5212260c", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification channel matrix, minimal/advanced key split, generated Actions mapping, `--check-notify` CLI behavior, Web one-click notification test, and local / Docker / GitHub Actions / Desktop setup notes are tracked in [Notification Guide](notifications.md).\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_SHOW_LLM_MODEL` | Whether notification report footers show the LLM model used for analysis. Defaults to `true`; set to `false` to hide runtime model metadata. This switch only affects presentation and does not change provider/model/Base URL, LiteLLM routing, or runtime model save/migration/cleanup behavior. | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n> Compatibility note: `REPORT_SHOW_LLM_MODEL` keeps the previous default-visible behavior (`true`) and only changes report footer rendering. It does not alter provider/model/Base URL, LiteLLM routing, or runtime model persistence/migration/cleanup semantics. Rollback is to remove the variable or set it back to `true`.\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n> Compatibility note for Issue #1306: this change only persists and exposes existing market-review output via history paths, and does not alter model name, provider, base URL, LiteLLM cleanup rules, or `.env` runtime migration semantics. Rollback is to revert this change set. Runtime compatibility references are `requirements.txt` (`litellm` constraints), `docs/LLM_CONFIG_GUIDE_EN.md`, and regression tests in `tests/test_analysis_api_contract.py`, `tests/test_analysis_history.py`, `tests/test_market_review.py`; official references: [LiteLLM OpenAI-compatible](https://docs.litellm.ai/docs/providers/openai_compatible), [OpenAI Chat Completion API](https://platform.openai.com/docs/api-reference/chat).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the notification baseline, diagnostics, and deployment notes, see [Notification Guide](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=8.0` indicates the target budget for the newly added fundamental stage, not a strict hard SLA; Windows, Docker, or rate-limited free data sources can raise it to `12-15s`.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `MARKET_REVIEW_COLOR_SCHEME` | Index change color style in market reviews: `green_up` = green gains/red losses (default), `red_up` = red gains/green losses | `green_up` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe notification channel matrix and `--check-notify` CLI details are documented in [Notification Guide](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **Strategy selection** - The Home page supports explicitly selecting analysis strategy skills; when `skills` is omitted, analysis uses the server default strategy so legacy clients keep existing behavior\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Market review history replay** - Market review results are persisted with `report_type=market_review` and can be reopened from history list/detail or Markdown endpoints directly, without re-triggering a fresh analysis run.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/analyze` accepts `skills` as an array of strategy IDs; if omitted, server defaults are used. The legacy field `strategies` is still accepted for backward compatibility.\n> Note: The Web Home page exposes an explicit strategy selector. When users do not pick one, `skills` is not sent and legacy behavior is preserved; when selected, it is passed through to this endpoint and persisted in task status/history snapshots.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: Once `/api/v1/analysis/market-review` completes, the report is persisted with `report_type=market_review`; open `/api/v1/history` and `/api/v1/history/{record_id}` (or Markdown history endpoints) to view it directly without re-running analysis.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# pass strategy list (optional)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\", \"skills\": [\"bull_trend\", \"growth_quality\"]}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot tries realtime quotes first, then falls back to the latest historical close on or before `as_of` when the realtime quote is unavailable or non-positive. Historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode runs the alert worker every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes. The worker reads enabled rules created through the Alert API and continues to support legacy rules in `AGENT_EVENT_ALERT_RULES_JSON`; triggered alerts still go through the existing notification channels. Alert API / Web persisted rules support price, change-percent, volume, and daily technical indicator rules; legacy JSON still supports only the three basic rule types.\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Legacy JSON is not automatically migrated, deleted, or rewritten. To roll back the background alert worker, clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n| `ma_price_cross` | `above` / `below` | `window` | Daily close edge-crosses MA(window) |\n| `rsi_threshold` | `above` / `below` | `period`, `threshold` | RSI edge-crosses a threshold |\n| `macd_cross` | `bullish_cross` / `bearish_cross` | `fast_period`, `slow_period`, `signal_period` | DIF/DEA edge golden/death cross |\n| `kdj_cross` | `bullish_cross` / `bearish_cross` | `period`, `k_period`, `d_period` | K/D edge golden/death cross |\n| `cci_threshold` | `above` / `below` | `period`, `threshold` | CCI edge-crosses a threshold |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\nThe worker writes `triggered`, `skipped`, `degraded`, and `failed` rows to `alert_triggers` as evaluation history; normal non-triggered checks do not write history. Real triggers write per-channel attempts to `alert_notifications`, and Alert API persisted rules write business cooldown state to `alert_cooldowns`; if the persisted cooldown read fails, the worker temporarily falls back to the in-process fingerprint guard to avoid repeated notifications during the DB failure. Legacy `AGENT_EVENT_ALERT_RULES_JSON` rules continue to use the in-process fingerprint suppressor and do not write persisted cooldown state; the notification infrastructure `notification_noise.py` guard remains independent. The Web rule list uses the backend-provided `cooldown_active` flag instead of browser-local timezone parsing to decide whether a rule is cooling down.\n\nTechnical indicator rules use daily-close edge triggers only. Partial-bar handling is a server-local-time + 16:00 heuristic and does not implement market-calendar precision. The WebUI \"Alerts\" page can manage persisted rules, run one-shot dry-run tests, and view trigger history, notification attempts, and read-only cooldown state. See [Real-Time Alert Center](alerts.md) for detailed boundaries.\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "296f0f1746ea8942234b8c00010394bd53bbe495": {"t": 1785754738.3148894, "n": 71520, "h": "01811f17347e302e", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification channel matrix, minimal/advanced key split, generated Actions mapping, `--check-notify` CLI behavior, Web one-click notification test, and local / Docker / GitHub Actions / Desktop setup notes are tracked in [Notification Guide](notifications.md).\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_SHOW_LLM_MODEL` | Whether notification report footers show the LLM model used for analysis. Defaults to `true`; set to `false` to hide runtime model metadata. This switch only affects presentation and does not change provider/model/Base URL, LiteLLM routing, or runtime model save/migration/cleanup behavior. | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n> Compatibility note: `REPORT_SHOW_LLM_MODEL` keeps the previous default-visible behavior (`true`) and only changes report footer rendering. It does not alter provider/model/Base URL, LiteLLM routing, or runtime model persistence/migration/cleanup semantics. Rollback is to remove the variable or set it back to `true`.\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n> Compatibility note for Issue #1306: this change only persists and exposes existing market-review output via history paths, and does not alter model name, provider, base URL, LiteLLM cleanup rules, or `.env` runtime migration semantics. Rollback is to revert this change set. Runtime compatibility references are `requirements.txt` (`litellm` constraints), `docs/LLM_CONFIG_GUIDE_EN.md`, and regression tests in `tests/test_analysis_api_contract.py`, `tests/test_analysis_history.py`, `tests/test_market_review.py`; official references: [LiteLLM OpenAI-compatible](https://docs.litellm.ai/docs/providers/openai_compatible), [OpenAI Chat Completion API](https://platform.openai.com/docs/api-reference/chat).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the notification baseline, diagnostics, and deployment notes, see [Notification Guide](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=8.0` indicates the target budget for the newly added fundamental stage, not a strict hard SLA; Windows, Docker, or rate-limited free data sources can raise it to `12-15s`.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `MARKET_REVIEW_COLOR_SCHEME` | Index change color style in market reviews: `green_up` = green gains/red losses (default), `red_up` = red gains/green losses | `green_up` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe notification channel matrix and `--check-notify` CLI details are documented in [Notification Guide](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **Strategy selection** - The Home page supports explicitly selecting analysis strategy skills; when `skills` is omitted, analysis uses the server default strategy so legacy clients keep existing behavior\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Market review history replay** - Market review results are persisted with `report_type=market_review` and can be reopened from history list/detail or Markdown endpoints directly, without re-triggering a fresh analysis run.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/analyze` accepts `skills` as an array of strategy IDs; if omitted, server defaults are used. The legacy field `strategies` is still accepted for backward compatibility.\n> Note: The Web Home page exposes an explicit strategy selector. When users do not pick one, `skills` is not sent and legacy behavior is preserved; when selected, it is passed through to this endpoint and persisted in task status/history snapshots.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: Once `/api/v1/analysis/market-review` completes, the report is persisted with `report_type=market_review`; open `/api/v1/history` and `/api/v1/history/{record_id}` (or Markdown history endpoints) to view it directly without re-running analysis.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# pass strategy list (optional)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\", \"skills\": [\"bull_trend\", \"growth_quality\"]}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode runs the alert worker every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes. The worker reads enabled rules created through the Alert API and continues to support legacy rules in `AGENT_EVENT_ALERT_RULES_JSON`; triggered alerts still go through the existing notification channels. Alert API / Web persisted rules support price, change-percent, volume, and daily technical indicator rules; legacy JSON still supports only the three basic rule types.\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Legacy JSON is not automatically migrated, deleted, or rewritten. To roll back the background alert worker, clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n| `ma_price_cross` | `above` / `below` | `window` | Daily close edge-crosses MA(window) |\n| `rsi_threshold` | `above` / `below` | `period`, `threshold` | RSI edge-crosses a threshold |\n| `macd_cross` | `bullish_cross` / `bearish_cross` | `fast_period`, `slow_period`, `signal_period` | DIF/DEA edge golden/death cross |\n| `kdj_cross` | `bullish_cross` / `bearish_cross` | `period`, `k_period`, `d_period` | K/D edge golden/death cross |\n| `cci_threshold` | `above` / `below` | `period`, `threshold` | CCI edge-crosses a threshold |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\nThe worker writes `triggered`, `skipped`, `degraded`, and `failed` rows to `alert_triggers` as evaluation history; normal non-triggered checks do not write history. Real triggers write per-channel attempts to `alert_notifications`, and Alert API persisted rules write business cooldown state to `alert_cooldowns`; if the persisted cooldown read fails, the worker temporarily falls back to the in-process fingerprint guard to avoid repeated notifications during the DB failure. Legacy `AGENT_EVENT_ALERT_RULES_JSON` rules continue to use the in-process fingerprint suppressor and do not write persisted cooldown state; the notification infrastructure `notification_noise.py` guard remains independent. The Web rule list uses the backend-provided `cooldown_active` flag instead of browser-local timezone parsing to decide whether a rule is cooling down.\n\nTechnical indicator rules use daily-close edge triggers only. Partial-bar handling is a server-local-time + 16:00 heuristic and does not implement market-calendar precision. The WebUI \"Alerts\" page can manage persisted rules, run one-shot dry-run tests, and view trigger history, notification attempts, and read-only cooldown state. See [Real-Time Alert Center](alerts.md) for detailed boundaries.\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "b43d636df12be8889a08baf7c3f64a9fbf13c59e": {"t": 1785754740.4582918, "n": 70718, "h": "5f8946f1d327030a", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification channel matrix, minimal/advanced key split, generated Actions mapping, `--check-notify` CLI behavior, Web one-click notification test, and local / Docker / GitHub Actions / Desktop setup notes are tracked in [Notification Guide](notifications.md).\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_SHOW_LLM_MODEL` | Whether notification report footers show the LLM model used for analysis. Defaults to `true`; set to `false` to hide runtime model metadata. This switch only affects presentation and does not change provider/model/Base URL, LiteLLM routing, or runtime model save/migration/cleanup behavior. | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n> Compatibility note: `REPORT_SHOW_LLM_MODEL` keeps the previous default-visible behavior (`true`) and only changes report footer rendering. It does not alter provider/model/Base URL, LiteLLM routing, or runtime model persistence/migration/cleanup semantics. Rollback is to remove the variable or set it back to `true`.\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n> Compatibility note for Issue #1306: this change only persists and exposes existing market-review output via history paths, and does not alter model name, provider, base URL, LiteLLM cleanup rules, or `.env` runtime migration semantics. Rollback is to revert this change set. Runtime compatibility references are `requirements.txt` (`litellm` constraints), `docs/LLM_CONFIG_GUIDE_EN.md`, and regression tests in `tests/test_analysis_api_contract.py`, `tests/test_analysis_history.py`, `tests/test_market_review.py`; official references: [LiteLLM OpenAI-compatible](https://docs.litellm.ai/docs/providers/openai_compatible), [OpenAI Chat Completion API](https://platform.openai.com/docs/api-reference/chat).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the notification baseline, diagnostics, and deployment notes, see [Notification Guide](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=8.0` indicates the target budget for the newly added fundamental stage, not a strict hard SLA; Windows, Docker, or rate-limited free data sources can raise it to `12-15s`.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `MARKET_REVIEW_COLOR_SCHEME` | Index change color style in market reviews: `green_up` = green gains/red losses (default), `red_up` = red gains/green losses | `green_up` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe notification channel matrix and `--check-notify` CLI details are documented in [Notification Guide](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **Strategy selection** - The Home page supports explicitly selecting analysis strategy skills; when `skills` is omitted, analysis uses the server default strategy so legacy clients keep existing behavior\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Market review history replay** - Market review results are persisted with `report_type=market_review` and can be reopened from history list/detail or Markdown endpoints directly, without re-triggering a fresh analysis run.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/analyze` accepts `skills` as an array of strategy IDs; if omitted, server defaults are used. The legacy field `strategies` is still accepted for backward compatibility.\n> Note: The Web Home page exposes an explicit strategy selector. When users do not pick one, `skills` is not sent and legacy behavior is preserved; when selected, it is passed through to this endpoint and persisted in task status/history snapshots.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: Once `/api/v1/analysis/market-review` completes, the report is persisted with `report_type=market_review`; open `/api/v1/history` and `/api/v1/history/{record_id}` (or Markdown history endpoints) to view it directly without re-running analysis.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# pass strategy list (optional)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\", \"skills\": [\"bull_trend\", \"growth_quality\"]}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode runs the alert worker every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes. The worker reads enabled rules created through the Alert API and continues to support legacy rules in `AGENT_EVENT_ALERT_RULES_JSON`; triggered alerts still go through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Legacy JSON is not automatically migrated, deleted, or rewritten. To roll back the background alert worker, clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\nThe worker writes `triggered`, `skipped`, `degraded`, and `failed` rows to `alert_triggers` as evaluation history; normal non-triggered checks do not write history. Real triggers write per-channel attempts to `alert_notifications`, and Alert API persisted rules write business cooldown state to `alert_cooldowns`; if the persisted cooldown read fails, the worker temporarily falls back to the in-process fingerprint guard to avoid repeated notifications during the DB failure. Legacy `AGENT_EVENT_ALERT_RULES_JSON` rules continue to use the in-process fingerprint suppressor and do not write persisted cooldown state; the notification infrastructure `notification_noise.py` guard remains independent. The Web rule list uses the backend-provided `cooldown_active` flag instead of browser-local timezone parsing to decide whether a rule is cooling down.\n\nThe WebUI \"Alerts\" page can manage the current three persisted rule types, run one-shot dry-run tests, and view trigger history, notification attempts, and read-only cooldown state. See [Real-Time Alert Center](alerts.md) for detailed boundaries.\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "a872a9e03ee2c16f333c8f8b7c9bc7e14cc66b12": {"t": 1785754743.4251554, "n": 70093, "h": "e873f323e9e9ab12", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification channel matrix, minimal/advanced key split, generated Actions mapping, `--check-notify` CLI behavior, Web one-click notification test, and local / Docker / GitHub Actions / Desktop setup notes are tracked in [Notification Guide](notifications.md).\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_SHOW_LLM_MODEL` | Whether notification report footers show the LLM model used for analysis. Defaults to `true`; set to `false` to hide runtime model metadata. This switch only affects presentation and does not change provider/model/Base URL, LiteLLM routing, or runtime model save/migration/cleanup behavior. | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n> Compatibility note: `REPORT_SHOW_LLM_MODEL` keeps the previous default-visible behavior (`true`) and only changes report footer rendering. It does not alter provider/model/Base URL, LiteLLM routing, or runtime model persistence/migration/cleanup semantics. Rollback is to remove the variable or set it back to `true`.\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n> Compatibility note for Issue #1306: this change only persists and exposes existing market-review output via history paths, and does not alter model name, provider, base URL, LiteLLM cleanup rules, or `.env` runtime migration semantics. Rollback is to revert this change set. Runtime compatibility references are `requirements.txt` (`litellm` constraints), `docs/LLM_CONFIG_GUIDE_EN.md`, and regression tests in `tests/test_analysis_api_contract.py`, `tests/test_analysis_history.py`, `tests/test_market_review.py`; official references: [LiteLLM OpenAI-compatible](https://docs.litellm.ai/docs/providers/openai_compatible), [OpenAI Chat Completion API](https://platform.openai.com/docs/api-reference/chat).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the notification baseline, diagnostics, and deployment notes, see [Notification Guide](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=8.0` indicates the target budget for the newly added fundamental stage, not a strict hard SLA; Windows, Docker, or rate-limited free data sources can raise it to `12-15s`.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `MARKET_REVIEW_COLOR_SCHEME` | Index change color style in market reviews: `green_up` = green gains/red losses (default), `red_up` = red gains/green losses | `green_up` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe notification channel matrix and `--check-notify` CLI details are documented in [Notification Guide](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **Strategy selection** - The Home page supports explicitly selecting analysis strategy skills; when `skills` is omitted, analysis uses the server default strategy so legacy clients keep existing behavior\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Market review history replay** - Market review results are persisted with `report_type=market_review` and can be reopened from history list/detail or Markdown endpoints directly, without re-triggering a fresh analysis run.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/analyze` accepts `skills` as an array of strategy IDs; if omitted, server defaults are used. The legacy field `strategies` is still accepted for backward compatibility.\n> Note: The Web Home page exposes an explicit strategy selector. When users do not pick one, `skills` is not sent and legacy behavior is preserved; when selected, it is passed through to this endpoint and persisted in task status/history snapshots.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: Once `/api/v1/analysis/market-review` completes, the report is persisted with `report_type=market_review`; open `/api/v1/history` and `/api/v1/history/{record_id}` (or Markdown history endpoints) to view it directly without re-running analysis.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# pass strategy list (optional)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\", \"skills\": [\"bull_trend\", \"growth_quality\"]}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode runs the alert worker every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes. The worker reads enabled rules created through the Alert API and continues to support legacy rules in `AGENT_EVENT_ALERT_RULES_JSON`; triggered alerts still go through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Legacy JSON is not automatically migrated, deleted, or rewritten. To roll back the background alert worker, clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\nThe P2 worker writes `triggered`, `skipped`, `degraded`, and `failed` rows to `alert_triggers` as minimal evaluation history; normal non-triggered checks do not write history. P2 does not write `alert_notifications` and does not execute `cooldown_policy` / `notification_policy`.\n\nThe WebUI \"Alerts\" page can manage the current three persisted rule types, run one-shot dry-run tests, and view trigger history. See [Real-Time Alert Center](alerts.md) for detailed boundaries.\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "0a987086d3b574d5bdeecbf153e4d9abd8edc07d": {"t": 1785754745.1593168, "n": 69898, "h": "87f50c962c76b880", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification channel matrix, minimal/advanced key split, generated Actions mapping, `--check-notify` CLI behavior, Web one-click notification test, and local / Docker / GitHub Actions / Desktop setup notes are tracked in [Notification Guide](notifications.md).\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_SHOW_LLM_MODEL` | Whether notification report footers show the LLM model used for analysis. Defaults to `true`; set to `false` to hide runtime model metadata. This switch only affects presentation and does not change provider/model/Base URL, LiteLLM routing, or runtime model save/migration/cleanup behavior. | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n> Compatibility note: `REPORT_SHOW_LLM_MODEL` keeps the previous default-visible behavior (`true`) and only changes report footer rendering. It does not alter provider/model/Base URL, LiteLLM routing, or runtime model persistence/migration/cleanup semantics. Rollback is to remove the variable or set it back to `true`.\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n> Compatibility note for Issue #1306: this change only persists and exposes existing market-review output via history paths, and does not alter model name, provider, base URL, LiteLLM cleanup rules, or `.env` runtime migration semantics. Rollback is to revert this change set. Runtime compatibility references are `requirements.txt` (`litellm` constraints), `docs/LLM_CONFIG_GUIDE_EN.md`, and regression tests in `tests/test_analysis_api_contract.py`, `tests/test_analysis_history.py`, `tests/test_market_review.py`; official references: [LiteLLM OpenAI-compatible](https://docs.litellm.ai/docs/providers/openai_compatible), [OpenAI Chat Completion API](https://platform.openai.com/docs/api-reference/chat).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the notification baseline, diagnostics, and deployment notes, see [Notification Guide](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=8.0` indicates the target budget for the newly added fundamental stage, not a strict hard SLA; Windows, Docker, or rate-limited free data sources can raise it to `12-15s`.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `MARKET_REVIEW_COLOR_SCHEME` | Index change color style in market reviews: `green_up` = green gains/red losses (default), `red_up` = red gains/green losses | `green_up` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe notification channel matrix and `--check-notify` CLI details are documented in [Notification Guide](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **Strategy selection** - The Home page supports explicitly selecting analysis strategy skills; when `skills` is omitted, analysis uses the server default strategy so legacy clients keep existing behavior\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Market review history replay** - Market review results are persisted with `report_type=market_review` and can be reopened from history list/detail or Markdown endpoints directly, without re-triggering a fresh analysis run.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/analyze` accepts `skills` as an array of strategy IDs; if omitted, server defaults are used. The legacy field `strategies` is still accepted for backward compatibility.\n> Note: The Web Home page exposes an explicit strategy selector. When users do not pick one, `skills` is not sent and legacy behavior is preserved; when selected, it is passed through to this endpoint and persisted in task status/history snapshots.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: Once `/api/v1/analysis/market-review` completes, the report is persisted with `report_type=market_review`; open `/api/v1/history` and `/api/v1/history/{record_id}` (or Markdown history endpoints) to view it directly without re-running analysis.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# pass strategy list (optional)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\", \"skills\": [\"bull_trend\", \"growth_quality\"]}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode runs the alert worker every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes. The worker reads enabled rules created through the Alert API and continues to support legacy rules in `AGENT_EVENT_ALERT_RULES_JSON`; triggered alerts still go through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Legacy JSON is not automatically migrated, deleted, or rewritten. To roll back the background alert worker, clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\nThe P2 worker writes `triggered`, `skipped`, `degraded`, and `failed` rows to `alert_triggers` as minimal evaluation history; normal non-triggered checks do not write history. P2 does not write `alert_notifications` and does not execute `cooldown_policy` / `notification_policy`.\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "adaf6cbfbec2599eb0be9f21dc3ef4a068358490": {"t": 1785754747.0198958, "n": 69033, "h": "32c23071c0ea8a9e", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification channel matrix, minimal/advanced key split, generated Actions mapping, `--check-notify` CLI behavior, Web one-click notification test, and local / Docker / GitHub Actions / Desktop setup notes are tracked in [Notification Guide](notifications.md).\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_SHOW_LLM_MODEL` | Whether notification report footers show the LLM model used for analysis. Defaults to `true`; set to `false` to hide runtime model metadata. This switch only affects presentation and does not change provider/model/Base URL, LiteLLM routing, or runtime model save/migration/cleanup behavior. | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n> Compatibility note: `REPORT_SHOW_LLM_MODEL` keeps the previous default-visible behavior (`true`) and only changes report footer rendering. It does not alter provider/model/Base URL, LiteLLM routing, or runtime model persistence/migration/cleanup semantics. Rollback is to remove the variable or set it back to `true`.\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n> Compatibility note for Issue #1306: this change only persists and exposes existing market-review output via history paths, and does not alter model name, provider, base URL, LiteLLM cleanup rules, or `.env` runtime migration semantics. Rollback is to revert this change set. Runtime compatibility references are `requirements.txt` (`litellm` constraints), `docs/LLM_CONFIG_GUIDE_EN.md`, and regression tests in `tests/test_analysis_api_contract.py`, `tests/test_analysis_history.py`, `tests/test_market_review.py`; official references: [LiteLLM OpenAI-compatible](https://docs.litellm.ai/docs/providers/openai_compatible), [OpenAI Chat Completion API](https://platform.openai.com/docs/api-reference/chat).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the notification baseline, diagnostics, and deployment notes, see [Notification Guide](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=8.0` indicates the target budget for the newly added fundamental stage, not a strict hard SLA; Windows, Docker, or rate-limited free data sources can raise it to `12-15s`.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `MARKET_REVIEW_COLOR_SCHEME` | Index change color style in market reviews: `green_up` = green gains/red losses (default), `red_up` = red gains/green losses | `green_up` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe notification channel matrix and `--check-notify` CLI details are documented in [Notification Guide](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Market review history replay** - Market review results are persisted with `report_type=market_review` and can be reopened from history list/detail or Markdown endpoints directly, without re-triggering a fresh analysis run.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: Once `/api/v1/analysis/market-review` completes, the report is persisted with `report_type=market_review`; open `/api/v1/history` and `/api/v1/history/{record_id}` (or Markdown history endpoints) to view it directly without re-running analysis.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode runs the alert worker every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes. The worker reads enabled rules created through the Alert API and continues to support legacy rules in `AGENT_EVENT_ALERT_RULES_JSON`; triggered alerts still go through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Legacy JSON is not automatically migrated, deleted, or rewritten. To roll back the background alert worker, clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\nThe P2 worker writes `triggered`, `skipped`, `degraded`, and `failed` rows to `alert_triggers` as minimal evaluation history; normal non-triggered checks do not write history. P2 does not write `alert_notifications` and does not execute `cooldown_policy` / `notification_policy`.\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "e407b5bf1461fe2437e408b9beb7cfd9b5972145": {"t": 1785754748.6677449, "n": 68589, "h": "eba38ac268b4e1f6", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification channel matrix, minimal/advanced key split, generated Actions mapping, `--check-notify` CLI behavior, Web one-click notification test, and local / Docker / GitHub Actions / Desktop setup notes are tracked in [Notification Guide](notifications.md).\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_SHOW_LLM_MODEL` | Whether notification report footers show the LLM model used for analysis. Defaults to `true`; set to `false` to hide runtime model metadata. This switch only affects presentation and does not change provider/model/Base URL, LiteLLM routing, or runtime model save/migration/cleanup behavior. | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n> Compatibility note: `REPORT_SHOW_LLM_MODEL` keeps the previous default-visible behavior (`true`) and only changes report footer rendering. It does not alter provider/model/Base URL, LiteLLM routing, or runtime model persistence/migration/cleanup semantics. Rollback is to remove the variable or set it back to `true`.\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n> Compatibility note for Issue #1306: this change only persists and exposes existing market-review output via history paths, and does not alter model name, provider, base URL, LiteLLM cleanup rules, or `.env` runtime migration semantics. Rollback is to revert this change set. Runtime compatibility references are `requirements.txt` (`litellm` constraints), `docs/LLM_CONFIG_GUIDE_EN.md`, and regression tests in `tests/test_analysis_api_contract.py`, `tests/test_analysis_history.py`, `tests/test_market_review.py`; official references: [LiteLLM OpenAI-compatible](https://docs.litellm.ai/docs/providers/openai_compatible), [OpenAI Chat Completion API](https://platform.openai.com/docs/api-reference/chat).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the notification baseline, diagnostics, and deployment notes, see [Notification Guide](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=8.0` indicates the target budget for the newly added fundamental stage, not a strict hard SLA; Windows, Docker, or rate-limited free data sources can raise it to `12-15s`.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `MARKET_REVIEW_COLOR_SCHEME` | Index change color style in market reviews: `green_up` = green gains/red losses (default), `red_up` = red gains/green losses | `green_up` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe notification channel matrix and `--check-notify` CLI details are documented in [Notification Guide](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Market review history replay** - Market review results are persisted with `report_type=market_review` and can be reopened from history list/detail or Markdown endpoints directly, without re-triggering a fresh analysis run.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: Once `/api/v1/analysis/market-review` completes, the report is persisted with `report_type=market_review`; open `/api/v1/history` and `/api/v1/history/{record_id}` (or Markdown history endpoints) to view it directly without re-running analysis.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "fb98a62938582ef2e76fc32bd5244bc8bc478f39": {"t": 1785754751.7859826, "n": 67936, "h": "ee5dee3e4a0fe11a", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification channel matrix, minimal/advanced key split, generated Actions mapping, `--check-notify` CLI behavior, Web one-click notification test, and local / Docker / GitHub Actions / Desktop setup notes are tracked in [Notification Guide](notifications.md).\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n> Compatibility note for Issue #1306: this change only persists and exposes existing market-review output via history paths, and does not alter model name, provider, base URL, LiteLLM cleanup rules, or `.env` runtime migration semantics. Rollback is to revert this change set. Runtime compatibility references are `requirements.txt` (`litellm` constraints), `docs/LLM_CONFIG_GUIDE_EN.md`, and regression tests in `tests/test_analysis_api_contract.py`, `tests/test_analysis_history.py`, `tests/test_market_review.py`; official references: [LiteLLM OpenAI-compatible](https://docs.litellm.ai/docs/providers/openai_compatible), [OpenAI Chat Completion API](https://platform.openai.com/docs/api-reference/chat).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the notification baseline, diagnostics, and deployment notes, see [Notification Guide](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=8.0` indicates the target budget for the newly added fundamental stage, not a strict hard SLA; Windows, Docker, or rate-limited free data sources can raise it to `12-15s`.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `MARKET_REVIEW_COLOR_SCHEME` | Index change color style in market reviews: `green_up` = green gains/red losses (default), `red_up` = red gains/green losses | `green_up` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe notification channel matrix and `--check-notify` CLI details are documented in [Notification Guide](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Market review history replay** - Market review results are persisted with `report_type=market_review` and can be reopened from history list/detail or Markdown endpoints directly, without re-triggering a fresh analysis run.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: Once `/api/v1/analysis/market-review` completes, the report is persisted with `report_type=market_review`; open `/api/v1/history` and `/api/v1/history/{record_id}` (or Markdown history endpoints) to view it directly without re-running analysis.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "f8458c582a30fb5f98daa6b5e39328d00ac49f64": {"t": 1785754753.633666, "n": 66748, "h": "4b350fc3354897a3", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification channel matrix, minimal/advanced key split, generated Actions mapping, `--check-notify` CLI behavior, Web one-click notification test, and local / Docker / GitHub Actions / Desktop setup notes are tracked in [Notification Guide](notifications.md).\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the notification baseline, diagnostics, and deployment notes, see [Notification Guide](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=8.0` indicates the target budget for the newly added fundamental stage, not a strict hard SLA; Windows, Docker, or rate-limited free data sources can raise it to `12-15s`.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `MARKET_REVIEW_COLOR_SCHEME` | Index change color style in market reviews: `green_up` = green gains/red losses (default), `red_up` = red gains/green losses | `green_up` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe notification channel matrix and `--check-notify` CLI details are documented in [Notification Guide](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "1a64da47008b054aa40766076af98cd00d53a6fc": {"t": 1785754755.5802171, "n": 66575, "h": "6b76696f5dec9d96", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification channel matrix, minimal/advanced key split, generated Actions mapping, `--check-notify` CLI behavior, Web one-click notification test, and local / Docker / GitHub Actions / Desktop setup notes are tracked in [Notification Guide](notifications.md).\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the notification baseline, diagnostics, and deployment notes, see [Notification Guide](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `8.0` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `3.0` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=8.0` indicates the target budget for the newly added fundamental stage, not a strict hard SLA; Windows, Docker, or rate-limited free data sources can raise it to `12-15s`.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe notification channel matrix and `--check-notify` CLI details are documented in [Notification Guide](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "60d8211aa499a8674bc21cb906b43e3a58c156dc": {"t": 1785754757.9053733, "n": 66498, "h": "42265be6a222ecba", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification channel matrix, minimal/advanced key split, generated Actions mapping, `--check-notify` CLI behavior, Web one-click notification test, and local / Docker / GitHub Actions / Desktop setup notes are tracked in [Notification Guide](notifications.md).\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the notification baseline, diagnostics, and deployment notes, see [Notification Guide](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `1.5` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `0.8` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=1.5` indicates the target budget for the newly added fundamental stage, not a strict hard SLA.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe notification channel matrix and `--check-notify` CLI details are documented in [Notification Guide](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "a75a0c502e06a49f08439275aaeb7be241c5befe": {"t": 1785754759.413401, "n": 66488, "h": "f2914b27ecbede45", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification baseline, minimal/advanced key split, Actions mapping, `--check-notify` CLI behavior, and Web one-click notification test are tracked in [Notification Baseline](notifications.md). A complete English notification topic remains a later follow-up.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message`; the sender appends `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with the `X-Gotify-Key` header | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the P0 notification baseline and diagnostics, see [Notification Baseline](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `GOTIFY_URL` | Gotify server base URL, without `/message` | Optional |\n| `GOTIFY_TOKEN` | Gotify application token sent with `X-Gotify-Key` | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,gotify,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `1.5` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `0.8` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=1.5` indicates the target budget for the newly added fundamental stage, not a strict hard SLA.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe P0 notification channel matrix and `--check-notify` CLI details are documented in [Notification Baseline](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nBark stays on the custom webhook baseline; no `BARK_*` settings are required.\nSet the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global\ntemplate, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_URLS=https://api.day.app/YOUR_BARK_KEY\n```\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### ntfy / Gotify\n\nntfy and Gotify are first-class notification channels. They send text / JSON\nonly and do not use Markdown-to-image.\n\nntfy uses the full topic endpoint; the last path segment is treated as the\ntopic:\n\n```env\nNTFY_URL=https://ntfy.sh/my-topic\nNTFY_TOKEN=\n```\n\nGotify uses the server base URL. The sender appends the fixed `/message` API and\nsends the application token in the `X-Gotify-Key` header. `GOTIFY_URL` may\ninclude a reverse-proxy path prefix, but must not include `/message`:\n\n```env\nGOTIFY_URL=https://gotify.example\nGOTIFY_TOKEN=app-token\n```\n\n```env\n# Actual request URL: https://example.com/gotify/message\nGOTIFY_URL=https://example.com/gotify\nGOTIFY_TOKEN=app-token\n```\n\n`NTFY_URL` and `GOTIFY_URL` intentionally use different URL semantics because\nthe two services expose different APIs: ntfy topics are part of the endpoint,\nwhile Gotify uses `/message` as a fixed server API.\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "9f5623842077a7d0c3ab7af7bf525c44c52148b3": {"t": 1785754763.021833, "n": 64995, "h": "b134b4152bb468df", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification baseline, minimal/advanced key split, Actions mapping, `--check-notify` CLI behavior, and Web one-click notification test are tracked in [Notification Baseline](notifications.md). A complete English notification topic remains a later follow-up.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the P0 notification baseline and diagnostics, see [Notification Baseline](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `NTFY_URL` | Full ntfy topic endpoint, must include topic path, e.g. `https://ntfy.sh/my-topic` | Optional |\n| `NTFY_TOKEN` | Optional ntfy Bearer Token | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,ntfy,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `1.5` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `0.8` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=1.5` indicates the target budget for the newly added fundamental stage, not a strict hard SLA.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe P0 notification channel matrix and `--check-notify` CLI details are documented in [Notification Baseline](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nWhen using Bark with a global template, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "8a0b093d24a1b3274597d03a11e3af7bf1207f62": {"t": 1785754765.285281, "n": 64513, "h": "ea41fd046ae6589e", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification baseline, minimal/advanced key split, Actions mapping, `--check-notify` CLI behavior, and Web one-click notification test are tracked in [Notification Baseline](notifications.md). A complete English notification topic remains a later follow-up.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | Verify Webhook HTTPS certificates (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the P0 notification baseline and diagnostics, see [Notification Baseline](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | Webhook HTTPS certificate verification (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `1.5` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `0.8` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=1.5` indicates the target budget for the newly added fundamental stage, not a strict hard SLA.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images automatically create and fix ownership for the `/app/data`, `/app/logs`, and `/app/reports` mounts during startup, then drop privileges to the non-root `dsa` user inside the container (UID/GID `1000:1000`). Normal Docker / Compose deployments do not require manual host-side `chown` or `chmod`.\n\nIf you override the runtime user with `--user` or Compose `user:`, or use read-only mounts, rootless Docker, NFS, or another storage environment that blocks `chown`, the automatic repair may not apply. In that case, make sure the actual runtime user can write to `data`, `logs`, and `reports`, or use writable volumes.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe P0 notification channel matrix and `--check-notify` CLI details are documented in [Notification Baseline](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nWhen using Bark with a global template, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "d2f677fe5bfbe5975f382edfde5ac9b2d4ac04fb": {"t": 1785754766.9036045, "n": 64510, "h": "1172117a9a2db522", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification baseline, minimal/advanced key split, Actions mapping, `--check-notify` CLI behavior, and Web one-click notification test are tracked in [Notification Baseline](notifications.md). A complete English notification topic remains a later follow-up.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | Verify Webhook HTTPS certificates (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the P0 notification baseline and diagnostics, see [Notification Baseline](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | Webhook HTTPS certificate verification (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `1.5` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `0.8` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=1.5` indicates the target budget for the newly added fundamental stage, not a strict hard SLA.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOfficial Docker images run as the non-root `dsa` user inside the container (UID/GID `1000:1000`). On first deployment or after changing host directories, make sure `data`, `logs`, and `reports` are writable by that user. If `logs` is not writable, file logging falls back to console output; database or report writes may still fail until permissions are fixed:\n\n```bash\nmkdir -p data logs reports\nsudo chown -R 1000:1000 data logs reports\n```\n\nIf you override the runtime user with `--user` or Compose `user:`, replace the UID/GID above with the actual container user, or grant write access with an equivalent ACL / permission policy.\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe P0 notification channel matrix and `--check-notify` CLI details are documented in [Notification Baseline](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nWhen using Bark with a global template, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "9f707052345fc6b19bafa1a7e365f8ea5fca58ca": {"t": 1785754768.875291, "n": 63874, "h": "1c1469ad390d112c", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification baseline, minimal/advanced key split, Actions mapping, `--check-notify` CLI behavior, and Web one-click notification test are tracked in [Notification Baseline](notifications.md). A complete English notification topic remains a later follow-up.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | Verify Webhook HTTPS certificates (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup; the same stable dedup key sends only once within the TTL | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown; the same cooldown key is rate-limited within the window | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges. Empty disables quiet hours | Optional |\n| `NOTIFICATION_TIMEZONE` | IANA timezone for quiet hours, e.g. `Asia/Shanghai`. Empty follows `TZ` or the local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: `info`, `warning`, `error`, `critical`. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. The current implementation does not send or persist digests | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the P0 notification baseline and diagnostics, see [Notification Baseline](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | Webhook HTTPS certificate verification (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_DEDUP_TTL_SECONDS` | Dedup TTL in seconds. `0` disables dedup | Optional |\n| `NOTIFICATION_COOLDOWN_SECONDS` | Cooldown window in seconds. `0` disables cooldown | Optional |\n| `NOTIFICATION_QUIET_HOURS` | Quiet-hours window in `HH:MM-HH:MM` format, supports overnight ranges | Optional |\n| `NOTIFICATION_TIMEZONE` | Quiet-hours timezone, e.g. `Asia/Shanghai`; empty follows `TZ` or local system timezone | Optional |\n| `NOTIFICATION_MIN_SEVERITY` | Minimum severity: info, warning, error, critical. Empty keeps current behavior | Optional |\n| `NOTIFICATION_DAILY_DIGEST_ENABLED` | Reserved daily digest flag. It does not send digests yet | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `1.5` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `0.8` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=1.5` indicates the target budget for the newly added fundamental stage, not a strict hard SLA.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe P0 notification channel matrix and `--check-notify` CLI details are documented in [Notification Baseline](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nWhen using Bark with a global template, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "72da793233d6dce14fb18eae2f6575034bd24f33": {"t": 1785754770.4569447, "n": 62305, "h": "a2fd0c656f127818", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification baseline, minimal/advanced key split, Actions mapping, `--check-notify` CLI behavior, and Web one-click notification test are tracked in [Notification Baseline](notifications.md). A complete English notification topic remains a later follow-up.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | Verify Webhook HTTPS certificates (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, and the P3 notification route keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the P0 notification baseline and diagnostics, see [Notification Baseline](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | Webhook HTTPS certificate verification (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `1.5` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `0.8` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=1.5` indicates the target budget for the newly added fundamental stage, not a strict hard SLA.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |\n| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe P0 notification channel matrix and `--check-notify` CLI details are documented in [Notification Baseline](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nWhen using Bark with a global template, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n### Longbridge\n- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss\n- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`\n- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)\n- If credentials are absent, the optional Longbridge fetcher is not instantiated\n- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "595908c81a4b1df490fe52638511ca5d83ea39e7": {"t": 1785754772.0983915, "n": 60924, "h": "426cfe5c60148257", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"assets/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification baseline, minimal/advanced key split, Actions mapping, `--check-notify` CLI behavior, and Web one-click notification test are tracked in [Notification Baseline](notifications.md). A complete English notification topic remains a later follow-up.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | Verify Webhook HTTPS certificates (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, and the P3 notification route keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the P0 notification baseline and diagnostics, see [Notification Baseline](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | Webhook HTTPS certificate verification (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `1.5` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `0.8` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=1.5` indicates the target budget for the newly added fundamental stage, not a strict hard SLA.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe P0 notification channel matrix and `--check-notify` CLI details are documented in [Notification Baseline](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nWhen using Bark with a global template, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "7d44cd0fa7dff89e25d9e494f93991fd7b4d1ce9": {"t": 1785754773.8853986, "n": 60928, "h": "f1adf2229b7a0a3b", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"../sources/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification baseline, minimal/advanced key split, Actions mapping, `--check-notify` CLI behavior, and Web one-click notification test are tracked in [Notification Baseline](notifications.md). A complete English notification topic remains a later follow-up.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | Verify Webhook HTTPS certificates (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, and the P3 notification route keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the P0 notification baseline and diagnostics, see [Notification Baseline](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | Webhook HTTPS certificate verification (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `1.5` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `0.8` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=1.5` indicates the target budget for the newly added fundamental stage, not a strict hard SLA.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe P0 notification channel matrix and `--check-notify` CLI details are documented in [Notification Baseline](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nWhen using Bark with a global template, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: the runtime behavior is implemented in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.\n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "c325a14b8f719a6c3e7fbb5e88ec520b1e8df669": {"t": 1785754775.6896775, "n": 61133, "h": "9064bdffb0c765cd", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"../sources/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n> Note: The configuration below documents existing runtime provider support and compatibility boundaries; this update is documentation-alignment only and does not introduce new runtime implementation.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification baseline, minimal/advanced key split, Actions mapping, `--check-notify` CLI behavior, and Web one-click notification test are tracked in [Notification Baseline](notifications.md). A complete English notification topic remains a later follow-up.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | Verify Webhook HTTPS certificates (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, and the P3 notification route keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the P0 notification baseline and diagnostics, see [Notification Baseline](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | Webhook HTTPS certificate verification (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `1.5` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `0.8` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=1.5` indicates the target budget for the newly added fundamental stage, not a strict hard SLA.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe P0 notification channel matrix and `--check-notify` CLI details are documented in [Notification Baseline](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nWhen using Bark with a global template, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: this PR updates runtime behavior in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{\"send_notification\": true}`; shares the same `GeminiAnalyzer/SearchService/NotificationService` construction semantics as `main.py --market-review` and Bot commands |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). This PR extends compatibility by prioritizing `litellm_model` and `llm_model_list`, then falling back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged. \n> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.\n> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.\n\n> Compatibility audit evidence:\n> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.\n> - Dependency boundary: this repo currently pins `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); the compatibility regressions for this path were verified under that dependency window.\n> - Verifiable tests:\n> - `tests/test_llm_channel_config.py` (configuration priority and provider/base URL mapping)\n> - `tests/test_market_review_runtime.py` (`build_market_review_runtime` shared assembly path)\n> - `tests/test_analysis_api_contract.py` (`/api/v1/analysis/market-review` contract and task status flow)\n> - Rollback path: if regression appears, restore historical `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, and legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `DEEPSEEK_*`, or import a desktop backup through `POST /api/v1/system/config/import` and restart; at runtime you can also clear `LITELLM_CONFIG` / `LLM_CHANNELS` to force legacy fallback.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "caf50cc0bdc0b11c770e6d0288ea1df056b82ca5": {"t": 1785754777.353771, "n": 58102, "h": "b5d7cb20ab4cc814", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"../sources/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n> Note: The configuration below documents existing runtime provider support and compatibility boundaries; this update is documentation-alignment only and does not introduce new runtime implementation.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification baseline, minimal/advanced key split, Actions mapping, `--check-notify` CLI behavior, and Web one-click notification test are tracked in [Notification Baseline](notifications.md). A complete English notification topic remains a later follow-up.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | Verify Webhook HTTPS certificates (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, and the P3 notification route keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the P0 notification baseline and diagnostics, see [Notification Baseline](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | Webhook HTTPS certificate verification (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `1.5` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `0.8` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=1.5` indicates the target budget for the newly added fundamental stage, not a strict hard SLA.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe P0 notification channel matrix and `--check-notify` CLI details are documented in [Notification Baseline](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nWhen using Bark with a global template, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: this PR updates runtime behavior in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger analysis via API\n- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "9f4755bda26cbd6862e87a15c8e861f19bdcb004": {"t": 1785754779.8665323, "n": 57920, "h": "7811ac7b00e5695c", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"../sources/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n> Note: The configuration below documents existing runtime provider support and compatibility boundaries; this update is documentation-alignment only and does not introduce new runtime implementation.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification baseline, minimal/advanced key split, Actions mapping, `--check-notify` CLI behavior, and Web one-click notification test are tracked in [Notification Baseline](notifications.md). A complete English notification topic remains a later follow-up.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | Verify Webhook HTTPS certificates (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, and the P3 notification route keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the P0 notification baseline and diagnostics, see [Notification Baseline](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | Webhook HTTPS certificate verification (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `1.5` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `0.8` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=1.5` indicates the target budget for the newly added fundamental stage, not a strict hard SLA.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe P0 notification channel matrix and `--check-notify` CLI details are documented in [Notification Baseline](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nWhen using Bark with a global template, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Decision Actionability\n\nSingle-stock reports calibrate operation advice with support/resistance, volume/chip context, main-force capital flow, and risk events. This reduces direct buy/sell flips caused only by one-day price movement or score thresholds. When price is between support and resistance and capital flow is unclear, the report prefers neutral actionable wording such as hold, range-bound watch, or shakeout watch. Buy calls require support confirmation or a valid resistance breakout with volume/capital-flow confirmation; sell/reduce calls require support failure, sustained outflow, or clearly elevated risk.\nThis post-processing update only adjusts advisory wording and stability logic and does not change the configured LLM model/provider routing semantics (including LiteLLM, providers, or API model settings).\nCompatibility check result: decision operability and runtime post-processing paths are changed, while model/provider/API configuration and persistence semantics remain unchanged; the compatibility boundary is now in analysis/pipeline/agent intent inference and stabilization mapping.\nVerification trail: this PR updates runtime behavior in `src/analyzer.py`, `src/core/pipeline.py`, `src/core/backtest_engine.py`, `src/report_language.py`, and `src/agent` decision-path modules (with corresponding tests in `tests/test_backtest_engine.py`, `tests/test_analyzer_news_prompt.py`, `tests/test_decision_stability.py`, and `tests/test_agent_pipeline.py`); it does not add/remove runtime config fields or config-cleanup logic in `src/config.py` or persistence code paths.\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold / Hold and Watch / Range-bound Watch / Shakeout Watch / Hold and watch | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger analysis via API\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "0adcef36271690ddff115f3350b5a51b2e42d6bd": {"t": 1785754782.870676, "n": 56251, "h": "ad9177bef75a7c3d", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"../sources/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n> Note: The configuration below documents existing runtime provider support and compatibility boundaries; this update is documentation-alignment only and does not introduce new runtime implementation.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification baseline, minimal/advanced key split, Actions mapping, `--check-notify` CLI behavior, and Web one-click notification test are tracked in [Notification Baseline](notifications.md). A complete English notification topic remains a later follow-up.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | Verify Webhook HTTPS certificates (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, and the P3 notification route keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels for single-stock, aggregate daily, market review, merged push, and Feishu document success notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels for EventMonitor notifications. Empty means all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels. No automatic system error producer is added in P3; empty means all configured channels | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the P0 notification baseline and diagnostics, see [Notification Baseline](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | Webhook HTTPS certificate verification (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `NOTIFICATION_REPORT_CHANNELS` | Report route channels, comma-separated. Allowed values: wechat,feishu,telegram,email,pushover,pushplus,serverchan3,custom,discord,slack,astrbot | Optional |\n| `NOTIFICATION_ALERT_CHANNELS` | Alert route channels, comma-separated. Empty keeps all configured channels | Optional |\n| `NOTIFICATION_SYSTEM_ERROR_CHANNELS` | Reserved system_error route channels, comma-separated. Empty keeps all configured channels | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `1.5` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `0.8` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=1.5` indicates the target budget for the newly added fundamental stage, not a strict hard SLA.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe P0 notification channel matrix and `--check-notify` CLI details are documented in [Notification Baseline](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nWhen using Bark with a global template, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger analysis via API\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}, "d6e4699627d39faca7fe28b75a00ee385b907fe2": {"t": 1785754784.564303, "n": 55205, "h": "5b84af5f06127477", "c": "# Complete Configuration & Deployment Guide\n\nThis document contains the complete configuration guide for the AI Stock Analysis System, intended for users who need advanced features or special deployment methods.\n\n> Quick start guide available in [README_EN.md](README_EN.md). This document covers advanced configuration.\n\n## Project Structure\n\n```\ndaily_stock_analysis/\n├── main.py # Main entry point\n├── src/ # Core business logic\n│ ├── analyzer.py # AI analyzer\n│ ├── config.py # Configuration management\n│ ├── notification.py # Message push notifications\n│ └── ...\n├── data_provider/ # Multi-source data adapters\n├── bot/ # Bot interaction module\n├── api/ # FastAPI backend service\n├── apps/dsa-web/ # React frontend\n├── docker/ # Docker configuration\n├── docs/ # Project documentation\n└── .github/workflows/ # GitHub Actions\n```\n\n## Table of Contents\n\n- [Project Structure](#project-structure)\n- [GitHub Actions Configuration](#github-actions-configuration)\n- [Complete Environment Variables List](#complete-environment-variables-list)\n- [Docker Deployment](#docker-deployment)\n- [Local Deployment](#local-deployment)\n- [Scheduled Task Configuration](#scheduled-task-configuration)\n- [Notification Channel Configuration](#notification-channel-configuration)\n- [Data Source Configuration](#data-source-configuration)\n- [Advanced Features](#advanced-features)\n- [Backtesting](#backtesting)\n- [Local WebUI Management Interface](#local-webui-management-interface)\n\n---\n\n## GitHub Actions Configuration\n\n### 1. Fork this Repository\n\nClick the `Fork` button in the upper right corner.\n\n### 2. Configure Secrets\n\nGo to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`\n\n<div align=\"center\">\n <img src=\"../sources/secret_config.png\" alt=\"GitHub Secrets Configuration\" width=\"600\">\n</div>\n\n#### AI Model Configuration (Configure at Least One)\n\n> Note: The configuration below documents existing runtime provider support and compatibility boundaries; this update is documentation-alignment only and does not introduce new runtime implementation.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for popular LLMs and Chinese-optimized web search with free quota for this project | Recommended |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families and a 10% top-up discount for this project | Recommended |\n| `GEMINI_API_KEY` | Get free key from [Google AI Studio](https://aistudio.google.com/) | Optional |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |\n| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |\n\n> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.\n\n#### Notification Channels (Multiple can be configured, all will receive notifications)\n\n> The notification baseline, minimal/advanced key split, Actions mapping, `--check-notify` CLI behavior, and Web one-click notification test are tracked in [Notification Baseline](notifications.md). A complete English notification topic remains a later follow-up.\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret (required when “Signature” security is enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu Webhook keyword (required when “Keyword” security is enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (get from @BotFather) | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID (for sending to topics) | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL ([How to create](https://support.discord.com/hc/en-us/articles/228383668)) | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should be a subset of `STOCK_LIST`; affects email recipients only, not analysis scope or other channels | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token ([Get here](https://www.pushplus.plus), Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey ([Get here](https://sc3.ft07.com/), mobile app push service) | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (supports DingTalk, etc., comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Bearer Token for custom webhooks (for authenticated webhooks) | Optional |\n| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |\n| `WEBHOOK_VERIFY_SSL` | Verify Webhook HTTPS certificates (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |\n\n> *Note: Configure at least one channel; multiple channels will all receive notifications\n>\n> The default `daily_analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. P0 maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, and `PUSHPLUS_TOPIC`; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles for a later phase.\n\n#### Push Behavior Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional |\n| `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional |\n| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `daily_analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional |\n| `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional |\n| `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional |\n| `REPORT_INTEGRITY_ENABLED` | Enable report integrity checks, retry or placeholder on missing fields (default `true`) | Optional |\n| `REPORT_INTEGRITY_RETRY` | Integrity retry count (default `1`, `0` = placeholder only) | Optional |\n| `REPORT_HISTORY_COMPARE_N` | History signal comparison count, `0` off (default), `>0` enable | Optional |\n| `ANALYSIS_DELAY` | Delay between stock analysis and market review (seconds) to avoid API rate limits, e.g., `10` | Optional |\n\n#### Other Configuration\n\n| Secret Name | Description | Required |\n|------------|------|:----:|\n| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594` | ✅ |\n| `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended |\n| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional |\n| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |\n| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-first, US-stock news enrichment, comma-separated for multiple keys) | Optional |\n| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |\n| `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 |\n\n#### ✅ Minimum Configuration Example\n\nTo get started quickly, you need at minimum:\n\n1. **AI Model**: `ANSPIRE_API_KEYS` (one key for LLMs and search), `AIHUBMIX_KEY` (one key for multiple model families), `GEMINI_API_KEY`, or `OPENAI_API_KEY`\n2. **Notification Channel**: At least one, e.g., `WECHAT_WEBHOOK_URL` or `EMAIL_SENDER` + `EMAIL_PASSWORD`\n3. **Stock List**: `STOCK_LIST` (required)\n4. **Search API**: `ANSPIRE_API_KEYS` or `SERPAPI_API_KEYS` (recommended for news and sentiment search)\n\n> Configure these 4 items and you're ready to go!\n\n### 3. Enable Actions\n\n1. Go to your forked repository\n2. Click the `Actions` tab at the top\n3. If prompted, click `I understand my workflows, go ahead and enable them`\n\n### 4. Manual Test\n\n1. Go to `Actions` tab\n2. Select `Daily Stock Analysis` workflow on the left\n3. Click `Run workflow` button on the right\n4. Select run mode\n5. Click green `Run workflow` to confirm\n\n### 5. Done!\n\nDefault schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.\n\n---\n\n## Complete Environment Variables List\n\n### AI Model Configuration\n\n> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |\n| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |\n| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |\n| `LLM_CHANNELS` | Channel names (comma-separated), use with `LLM_{NAME}_*`, see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | No |\n| `LITELLM_CONFIG` | Advanced model routing YAML path (expert use) | - | No |\n| `ANSPIRE_API_KEYS` | [Anspire](https://open.anspire.cn/?share_code=QFBC0FYC) API key, one key for the LLM gateway and search | - | Optional |\n| `AIHUBMIX_KEY` | [AIHubMix](https://aihubmix.com/?aff=CfMq) API key, one key for multiple model families | - | Optional |\n| `GEMINI_API_KEY` | Google Gemini API Key | - | Optional |\n| `GEMINI_MODEL` | Primary model name (legacy, `LITELLM_MODEL` preferred) | `gemini-3.1-pro-preview` | No |\n| `GEMINI_MODEL_FALLBACK` | Fallback model (legacy) | `gemini-3-flash-preview` | No |\n| `ANTHROPIC_API_KEY` | Anthropic Claude API Key | - | Optional |\n| `OPENAI_API_KEY` | OpenAI-compatible API Key | - | Optional |\n| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint | - | Optional |\n| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |\n| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |\n\n> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.\n\n### Notification Channel Configuration\n\nFor the P0 notification baseline and diagnostics, see [Notification Baseline](notifications.md).\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `WECHAT_WEBHOOK_URL` | WeChat Work Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_URL` | Feishu Bot Webhook URL | Optional |\n| `FEISHU_WEBHOOK_SECRET` | Feishu bot signing secret (only for webhook bots with Signature security enabled) | Optional |\n| `FEISHU_WEBHOOK_KEYWORD` | Feishu bot keyword (only for webhook bots with Keyword security enabled) | Optional |\n| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Optional |\n| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |\n| `TELEGRAM_MESSAGE_THREAD_ID` | Telegram Topic ID | Optional |\n| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Optional |\n| `DISCORD_BOT_TOKEN` | Discord Bot Token (choose one with Webhook) | Optional |\n| `DISCORD_MAIN_CHANNEL_ID` | Discord Channel ID (required when using Bot) | Optional |\n| `DISCORD_INTERACTIONS_PUBLIC_KEY` | Discord Public Key (required only for inbound Interaction/Webhook signature verification) | Optional |\n| `DISCORD_MAX_WORDS` | Discord Word Limit (default 2000 for un-upgraded servers) | Optional |\n| `SLACK_BOT_TOKEN` | Slack Bot Token (recommended, supports image upload; takes priority over Webhook when both set) | Optional |\n| `SLACK_CHANNEL_ID` | Slack Channel ID (required when using Bot) | Optional |\n| `SLACK_WEBHOOK_URL` | Slack Incoming Webhook URL (text only, no image support) | Optional |\n| `EMAIL_SENDER` | Sender email | Optional |\n| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |\n| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to self) | Optional |\n| `EMAIL_SENDER_NAME` | Sender display name | Optional |\n| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | Email routing groups (Issue #268): `STOCK_GROUP_N` should stay within `STOCK_LIST` and only changes email recipients | Optional |\n| `CUSTOM_WEBHOOK_URLS` | Custom Webhook (comma-separated) | Optional |\n| `CUSTOM_WEBHOOK_BEARER_TOKEN` | Custom Webhook Bearer Token | Optional |\n| `WEBHOOK_VERIFY_SSL` | Webhook HTTPS certificate verification (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk | Optional |\n| `PUSHOVER_USER_KEY` | Pushover User Key | Optional |\n| `PUSHOVER_API_TOKEN` | Pushover API Token | Optional |\n| `PUSHPLUS_TOKEN` | PushPlus Token (Chinese push service) | Optional |\n| `SERVERCHAN3_SENDKEY` | ServerChan v3 Sendkey | Optional |\n| `ASTRBOT_URL` | AstrBot Webhook URL | Optional |\n| `ASTRBOT_TOKEN` | Optional AstrBot Bearer Token | Optional |\n\n> Note: the default `daily_analysis` GitHub Actions workflow only maps fixed variable names. It does not automatically import arbitrary numbered variables such as `STOCK_GROUP_N` / `EMAIL_GROUP_N`. This feature therefore works in local `.env`, Docker, or any runtime where you explicitly inject those variables.\n\n#### Feishu Cloud Document Configuration (Optional, solves message truncation issues)\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `FEISHU_APP_ID` | Feishu App ID | Optional |\n| `FEISHU_APP_SECRET` | Feishu App Secret | Optional |\n| `FEISHU_FOLDER_TOKEN` | Feishu Cloud Drive Folder Token | Optional |\n\n> Feishu Cloud Document setup steps:\n> 1. Create an app in [Feishu Developer Console](https://open.feishu.cn/app)\n> 2. Configure GitHub Secrets\n> 3. Create a group and add the app bot\n> 4. Add the group as a collaborator to the cloud drive folder (with manage permissions)\n>\n> Note: `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app mode, cloud documents, or Stream Bot mode. They do not enable group webhook notifications by themselves. For simple push notifications, use `FEISHU_WEBHOOK_URL` first.\n\n### Search Service Configuration\n\n| Variable | Description | Required |\n|--------|------|:----:|\n| `ANSPIRE_API_KEYS` | Anspire Open API Key (shared with search and LLM fallback examples; availability depends on account/model entitlement, and can effectively enhance A-share analysis) | Recommended |\n| `SERPAPI_API_KEYS` | SerpAPI search-engine results for realtime financial news | Recommended |\n| `TAVILY_API_KEYS` | Tavily Search API Key | Optional |\n| `BOCHA_API_KEYS` | Bocha Search API Key (Chinese optimized) | Optional |\n| `BRAVE_API_KEYS` | Brave Search API Key (US stocks optimized) | Optional |\n| `MINIMAX_API_KEYS` | MiniMax Coding Plan Web Search (structured results) | Optional |\n| `SOCIAL_SENTIMENT_API_KEY` | Stock Sentiment API Key (Reddit / X / Polymarket, US stocks optional) | Optional |\n| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API endpoint (default `https://api.adanos.org`) | Optional |\n| `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 |\n| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional |\n\n> Behavior note: Search and social sentiment are optional enhancement services. If either service fails to initialize, the system logs a warning and degrades gracefully by skipping that stage without blocking the core analysis flow.\n\n### Data Source Configuration\n\n| Variable | Description | Default | Required |\n|--------|------|--------|:----:|\n| `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional |\n| `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 |\n| `ENABLE_REALTIME_QUOTE` | Enable real-time quotes (if disabled, uses historical closing prices for analysis) | `true` | Optional |\n| `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 |\n| `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 |\n| `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 |\n| `REALTIME_SOURCE_PRIORITY` | Real-time quote source priority (comma-separated), e.g., `tencent,akshare_sina,efinance,akshare_em` | See .env.example | Optional |\n| `ENABLE_FUNDAMENTAL_PIPELINE` | Master switch for fundamental aggregation; when disabled, returns `not_supported` block only, without altering the original analysis pipeline. | `true` | Optional |\n| `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS` | Total latency budget for the fundamental stage (seconds) | `1.5` | Optional |\n| `FUNDAMENTAL_FETCH_TIMEOUT_SECONDS` | Timeout for a single capability source call (seconds) | `0.8` | Optional |\n| `FUNDAMENTAL_RETRY_MAX` | Retry count for fundamental capabilities (including the first attempt) | `1` | Optional |\n| `FUNDAMENTAL_CACHE_TTL_SECONDS` | Fundamental aggregation cache TTL (seconds), short cache to reduce repeated API pulling. | `120` | Optional |\n| `FUNDAMENTAL_CACHE_MAX_ENTRIES` | Maximum entries for fundamental cache (evicted by time within TTL) | `256` | Optional |\n\n> **Behavior Notes:**\n> - **A-shares**: Returns aggregated capabilities by `valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards`.\n> - **ETFs**: Returns available items, marks missing capabilities as `not_supported`, and does not affect the original flow overall.\n> - **US/HK stocks**: Returns `not_supported` fallback block.\n> - Any exception uses fail-open logic, only logs errors without affecting the main technical/news/chip pipeline.\n> - **Field contracts**:\n> - `fundamental_context.belong_boards` = related board list for the stock (currently populated for A-shares only; `[]` when unavailable);\n> - `fundamental_context.boards.data` = `sector_rankings` (sector rise/fall leaderboard, structure `{top, bottom}`);\n> - `get_stock_info.belong_boards` = list of sectors the individual stock belongs to;\n> - `get_stock_info.boards` is a compatibility alias, value is identical to `belong_boards` (removal considered only in major version updates);\n> - `get_stock_info.sector_rankings` stays consistent with `fundamental_context.boards.data`.\n> - `AnalysisReport.details.belong_boards` = related board list in structured report details;\n> - `AnalysisReport.details.sector_rankings` = sector leaderboard in structured report details for board-linkage display.\n> - **Sector leaderboard** uses a fixed fallback order: consistent with global priority.\n> - **Timeout control** is a `best-effort` soft timeout: the stage will quickly degrade and continue execution based on the budget, but does not guarantee a hard interrupt of underlying third-party network calls.\n> - `FUNDAMENTAL_STAGE_TIMEOUT_SECONDS=1.5` indicates the target budget for the newly added fundamental stage, not a strict hard SLA.\n> - For a hard SLA, please upgrade to isolated child process execution in future versions to forcefully terminate timeout tasks.\n\n### Other Configuration\n\n| Variable | Description | Default |\n|--------|------|--------|\n| `STOCK_LIST` | Watchlist codes (comma-separated) | - |\n| `MAX_WORKERS` | Concurrent threads | `3` |\n| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |\n| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |\n| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |\n| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |\n| `LOG_DIR` | Log directory | `./logs` |\n\n> Behavior notes:\n> - 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.\n> - 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.\n> - 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.\n> - 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.\n> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.\n> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.\n\n---\n\n## Docker Deployment\n\nThe image uses prebuilt frontend assets under `/app/static` at runtime, so the running `server` container does not require the `apps/dsa-web` source tree or runtime `npm`. If WebUI cannot be opened after Docker deployment, first verify that `/app/static/index.html` exists inside the container.\n\nOfficial image registries:\n\n- GHCR: `ghcr.io/zhulinsen/daily_stock_analysis:<tag>`\n- Docker Hub: `<DOCKERHUB_USERNAME>/daily_stock_analysis:<tag>` (driven by the publisher's `DOCKERHUB_USERNAME` secret; the official release uses `zhulinsen/daily_stock_analysis`)\n\n### Quick Start\n\n```bash\n# 1. Clone repository\ngit clone https://github.com/ZhuLinsen/daily_stock_analysis.git\ncd daily_stock_analysis\n\n# 2. Configure environment variables\ncp .env.example .env\nvim .env # Fill in API Keys and configuration\n\n# 3. Start container\ndocker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)\ndocker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode\ndocker-compose -f ./docker/docker-compose.yml up -d # Start both modes\n\n# 4. Access WebUI\n# http://localhost:8000\n\n# 5. View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n```\n\n### Run Official Images Directly\n\nIf you do not want to keep the source tree on the target machine, you can run the published image directly:\n\n```bash\n# Web/API mode\ndocker pull zhulinsen/daily_stock_analysis:latest\ndocker run -d \\\n --name dsa-server \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n\n# Scheduled-task mode\ndocker run -d \\\n --name dsa-analyzer \\\n --env-file .env \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n zhulinsen/daily_stock_analysis:latest\n```\n\nFor pinned deployments or easier rollback, replace `latest` with a concrete version tag such as `v3.13.0`.\n\n### Run Mode Description\n\n| Command | Description | Port |\n|------|------|------|\n| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |\n| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |\n| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |\n\n### Docker Compose Configuration\n\n`docker-compose.yml` uses YAML anchors to reuse configuration:\n\n```yaml\nversion: '3.8'\n\nx-common: &common\n build:\n context: ..\n dockerfile: docker/Dockerfile\n restart: unless-stopped\n env_file:\n - ../.env\n environment:\n - TZ=Asia/Shanghai\n volumes:\n - ../data:/app/data\n - ../logs:/app/logs\n - ../reports:/app/reports\n - ../.env:/app/.env\n - ../strategies:/app/strategies:ro\n\nservices:\n # Scheduled task mode\n analyzer:\n <<: *common\n container_name: stock-analyzer\n\n # FastAPI mode\n server:\n <<: *common\n container_name: stock-server\n command: [\"python\", \"main.py\", \"--serve-only\", \"--host\", \"0.0.0.0\", \"--port\", \"${API_PORT:-8000}\"]\n ports:\n - \"${API_PORT:-8000}:${API_PORT:-8000}\"\n```\n\n### `.env` and Volume Mapping\n\nFor both `docker run` and Compose, keep these two layers in mind:\n\n- Environment injection: `--env-file .env` or Compose `env_file`\n This passes key/value pairs from `.env` into the container process environment.\n- File mapping: `-v \"$(pwd)/.env:/app/.env\"` or Compose `../.env:/app/.env`\n This mounts the same `.env` file into the container so the Web settings page and backend read/write the same persisted config file.\n\nRecommended host mappings:\n\n- `./data:/app/data` for runtime data and database files\n- `./logs:/app/logs` for logs\n- `./reports:/app/reports` for generated reports\n- `./strategies:/app/strategies:ro` for custom strategy YAML files\n\nOptional static asset override:\n\n- `./static:/app/static:ro`\n\n### Common Commands\n\n```bash\n# View running status\ndocker-compose -f ./docker/docker-compose.yml ps\n\n# View logs\ndocker-compose -f ./docker/docker-compose.yml logs -f server\n\n# Stop services\ndocker-compose -f ./docker/docker-compose.yml down\n\n# Rebuild image (after code update)\ndocker-compose -f ./docker/docker-compose.yml build --no-cache\ndocker-compose -f ./docker/docker-compose.yml up -d server\n```\n\n### Manual Image Build\n\n```bash\ndocker build -f docker/Dockerfile -t stock-analysis .\ndocker run -d \\\n --name dsa-server-local \\\n --env-file .env \\\n -p 8000:8000 \\\n -v \"$(pwd)/data:/app/data\" \\\n -v \"$(pwd)/logs:/app/logs\" \\\n -v \"$(pwd)/reports:/app/reports\" \\\n -v \"$(pwd)/.env:/app/.env\" \\\n stock-analysis \\\n python main.py --serve-only --host 0.0.0.0 --port 8000\n```\n\n---\n\n## Local Deployment\n\n### Install Dependencies\n\n```bash\n# Python 3.10+ recommended\npip install -r requirements.txt\n\n# Or use conda\nconda create -n stock python=3.10\nconda activate stock\npip install -r requirements.txt\n```\n\n### Command Line Arguments\n\n```bash\npython main.py # Full analysis (stocks + market review)\npython main.py --market-review # Market review only\npython main.py --no-market-review # Stock analysis only\npython main.py --stocks 600519,300750 # Specify stocks\npython main.py --dry-run # Fetch data only, no AI analysis\npython main.py --no-notify # Don't send notifications\npython main.py --schedule # Scheduled task mode\npython main.py --debug # Debug mode (verbose logging)\npython main.py --workers 5 # Specify concurrency\n```\n\n---\n\n## Scheduled Task Configuration\n\n### GitHub Actions Schedule\n\nEdit `.github/workflows/daily_analysis.yml`:\n\n```yaml\nschedule:\n # UTC time, Beijing time = UTC + 8\n - cron: '0 10 * * 1-5' # Monday to Friday 18:00 (Beijing Time)\n```\n\nCommon time reference:\n\n| Beijing Time | UTC cron expression |\n|---------|----------------|\n| 09:30 | `'30 1 * * 1-5'` |\n| 12:00 | `'0 4 * * 1-5'` |\n| 15:00 | `'0 7 * * 1-5'` |\n| 18:00 | `'0 10 * * 1-5'` |\n| 21:00 | `'0 13 * * 1-5'` |\n\n### Local Scheduled Tasks\n\n```bash\n# Start scheduled mode (default 18:00 execution)\npython main.py --schedule\n\n# Or use crontab\ncrontab -e\n# Add: 0 18 * * 1-5 cd /path/to/project && python main.py\n```\n\n> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.\n>\n> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.\n\n---\n\n## Notification Channel Configuration\n\nThe P0 notification channel matrix and `--check-notify` CLI details are documented in [Notification Baseline](notifications.md).\n\n### WeChat Work\n\n1. Add \"Group Bot\" in WeChat Work group chat\n2. Copy Webhook URL\n3. Set `WECHAT_WEBHOOK_URL`\n\n### Feishu\n\n> ⚠️ **Key distinction**: `FEISHU_WEBHOOK_SECRET` (webhook signing secret) and `FEISHU_APP_SECRET` (Feishu App Secret) are two completely different configuration variables and cannot be used interchangeably.\n\n**Minimum viable config (no security restrictions):**\n\n```env\nFEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token\n```\n\n**Step-by-step setup:**\n\n1. **Create a Custom Bot in the target Feishu group**:\n - Open the group → tap the settings icon (top right) → **Group Bots** → **Add Bot** → **Custom Bot**\n - Enter a name for the bot, then copy the generated **Webhook URL** (format: `https://open.feishu.cn/open-apis/bot/v2/hook/...`)\n2. Set `FEISHU_WEBHOOK_URL` to the URL you just copied.\n3. Check the bot's **Security Settings** and add the corresponding config if any extra option is enabled:\n - **No extra security**: only `FEISHU_WEBHOOK_URL` is needed.\n - **Signature verification enabled**: copy the secret shown in Feishu into `FEISHU_WEBHOOK_SECRET`. **Both sides must be enabled or disabled together** — if Feishu has signing on but `FEISHU_WEBHOOK_SECRET` is missing (or vice versa), every request will be rejected.\n - **Keyword enabled**: copy the exact same keyword into `FEISHU_WEBHOOK_KEYWORD`. The app will prepend it to every message automatically; no need to change report templates.\n - **IP allowlist enabled**: make sure the outbound IP of your runtime (local / Docker / GitHub Actions each have different IPs) is on the allowlist.\n4. `FEISHU_APP_ID` / `FEISHU_APP_SECRET` are for Feishu app / Stream Bot / cloud document flows only — they do **not** trigger group webhook notifications and must not be used instead of `FEISHU_WEBHOOK_URL`.\n\n**Common failure causes:**\n- Only `FEISHU_APP_ID` / `FEISHU_APP_SECRET` were set, but `FEISHU_WEBHOOK_URL` was not configured\n- The bot has Signature security enabled, but `FEISHU_WEBHOOK_SECRET` was not set locally (or was mistakenly set to `FEISHU_APP_SECRET`)\n- The bot has Keyword security enabled, but `FEISHU_WEBHOOK_KEYWORD` was not set locally\n- The bot was not added to the target group, or group permissions block it from posting\n- A Feishu IP allowlist is enabled and your runtime IP is not on the allowlist\n- Message content too long: Feishu has a per-message length limit; the system auto-segments messages. For full content in a single document, configure Feishu Cloud Document (`FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_FOLDER_TOKEN`)\n\nFor a full illustrated troubleshooting guide, see [docs/bot/feishu-bot-config.md](bot/feishu-bot-config.md).\n\n### Telegram\n\n1. Talk to @BotFather to create a Bot\n2. Get Bot Token\n3. Get Chat ID (via @userinfobot)\n4. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`\n5. (Optional) To send to Topic, set `TELEGRAM_MESSAGE_THREAD_ID` (get from Topic link)\n\n### Email\n\n1. Enable SMTP service for your email\n2. Get authorization code (not login password)\n3. Set `EMAIL_SENDER`, `EMAIL_PASSWORD`, `EMAIL_RECEIVERS`\n\nSupported email providers:\n- QQ Mail: smtp.qq.com:465\n- 163 Mail: smtp.163.com:465\n- Gmail: smtp.gmail.com:587\n\n**Send different stock groups to different email recipients** (Issue #268, optional):\nConfigure `STOCK_GROUP_N` and `EMAIL_GROUP_N` to route different stock groups to different inboxes. `STOCK_LIST` still defines the actual analysis scope, so each `STOCK_GROUP_N` should be a subset of `STOCK_LIST`. This only changes email recipients; Telegram, WeChat, Webhook, and other channels still receive the full report for the entire `STOCK_LIST`. Market review emails are sent to all configured group recipients.\n\n> GitHub Actions limitation: as of 2026-03-29, the repository's default `daily_analysis.yml` does not auto-import arbitrary numbered `STOCK_GROUP_N` / `EMAIL_GROUP_N` variables. If you only add them in repository Secrets / Variables without extending the workflow `env:` block, they will not reach the runtime process.\n\n```bash\nSTOCK_LIST=600519,300750,002594,AAPL\nSTOCK_GROUP_1=600519,300750\nEMAIL_GROUP_1=user1@example.com\nSTOCK_GROUP_2=002594,AAPL\nEMAIL_GROUP_2=user2@example.com\n```\n\n### Custom Webhook\n\nSupports any POST JSON Webhook, including:\n- DingTalk Bot\n- Discord Webhook\n- Slack Webhook\n- Bark (iOS push)\n- Self-hosted services\n\nSet `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.\n\nIf AstrBot, NapCat, or a self-hosted service requires a custom request body, set\n`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before\nURL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value\nis not a JSON object, DSA falls back to the default payload. Prefer\n`$content_json` / `$title_json` so newlines and quotes stay valid JSON:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"msg_type\":\"text\",\"content\":$content_json}\n```\n\nAvailable placeholders: `$content_json`, `$content`, `$title_json`, `$title`.\nRaw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make\nthe template invalid and trigger fallback.\n\nWhen using Bark with a global template, include the Bark body explicitly:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$title_json,\"body\":$content_json,\"group\":\"stock\"}\n```\n\nNapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,\nor `group_id`:\n\n```env\nCUSTOM_WEBHOOK_BODY_TEMPLATE={\"user_id\":123456,\"message\":$content_json}\n```\n\n### Discord\n\nDiscord supports two push methods:\n\n**Method 1: Webhook (Recommended, Simple)**\n\n1. Create Webhook in Discord channel settings\n2. Copy Webhook URL\n3. Configure environment variable:\n\n```bash\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy\n```\n\n**Method 2: Bot API (Requires more permissions)**\n\n1. Create application in [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create Bot and get Token\n3. Invite Bot to server\n4. Get Channel ID (right-click channel in developer mode)\n5. Configure environment variables:\n\n```bash\nDISCORD_BOT_TOKEN=your_bot_token\nDISCORD_MAIN_CHANNEL_ID=your_channel_id\n```\n\nIf you need to receive Discord Slash Command / Interaction callbacks instead of only sending notifications to Discord, also copy the public key from `Discord Developer Portal -> General Information -> Public Key` and configure:\n\n```bash\nDISCORD_INTERACTIONS_PUBLIC_KEY=your_public_key\n```\n\nWithout this public key, inbound Discord webhook requests are rejected.\n\n### Slack\n\nSlack supports two push methods. When both are configured, Bot API takes priority to ensure text and images land in the same channel:\n\n**Method 1: Bot API (Recommended, supports image upload)**\n\n1. Create a Slack App: https://api.slack.com/apps → Create New App\n2. Add Bot Token Scopes: `chat:write`, `files:write`\n3. Install to workspace and get Bot Token (xoxb-...)\n4. Get Channel ID: channel details → copy channel ID at the bottom\n5. Configure environment variables:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-...\nSLACK_CHANNEL_ID=C01234567\n```\n\n**Method 2: Incoming Webhook (Simple setup, text only)**\n\n1. Create an Incoming Webhook in Slack App management page\n2. Copy the Webhook URL\n3. Configure environment variable:\n\n```bash\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx\n```\n\n### Pushover (iOS/Android Push)\n\n[Pushover](https://pushover.net/) is a cross-platform push service supporting iOS and Android.\n\n1. Register Pushover account and download App\n2. Get User Key from [Pushover Dashboard](https://pushover.net/)\n3. Create Application to get API Token\n4. Configure environment variables:\n\n```bash\nPUSHOVER_USER_KEY=your_user_key\nPUSHOVER_API_TOKEN=your_api_token\n```\n\nFeatures:\n- Supports iOS/Android\n- Supports notification priority and sound settings\n- Free quota sufficient for personal use (10,000 messages/month)\n- Messages retained for 7 days\n\n---\n\n## Data Source Configuration\n\nSystem defaults to AkShare (free), also supports other data sources:\n\n### AkShare (Default)\n- Free, no configuration needed\n- Data source: Eastmoney scraper\n\n### Tushare Pro\n- Requires registration to get Token\n- More stable, more comprehensive data\n- Set `TUSHARE_TOKEN`\n\n### Baostock\n- Free, no configuration needed\n- Used as backup data source\n\n### YFinance\n- Free, no configuration needed\n- Supports US/HK stock data\n- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues\n\n---\n\n## Advanced Features\n\n### Hong Kong Stock Support\n\nUse `hk` prefix for HK stock codes:\n\n```bash\nSTOCK_LIST=600519,hk00700,hk01810\n```\n\nHK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths.\n\n### Multi-Model Switching\n\nConfigure multiple models, system auto-switches:\n\n```bash\n# Gemini (primary)\nGEMINI_API_KEY=xxx\nGEMINI_MODEL=gemini-3.1-pro-preview\n\n# OpenAI compatible (backup)\nOPENAI_API_KEY=xxx\nOPENAI_BASE_URL=https://api.deepseek.com\nOPENAI_MODEL=deepseek-v4-flash\n# deepseek-chat / deepseek-reasoner remain compatible, but DeepSeek marks them deprecated after 2026/07/24\n```\n\n### Advanced Model Routing (Powered by LiteLLM)\n\nSee [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md). Most users only need to think in terms of primary models, fallback models, and channels; this section is for expert users who want direct access to the underlying [LiteLLM](https://github.com/BerriAI/litellm) routing capabilities. No separate Proxy service is required.\n\n**Two-layer mechanism**: Same-model multi-key rotation (Router) and cross-model fallback are independent.\n\n**Multi-key + cross-model fallback example**:\n\n```env\n# Primary: 3 Gemini keys rotate; Router switches on 429\nGEMINI_API_KEYS=key1,key2,key3\nLITELLM_MODEL=gemini/gemini-3.1-pro-preview\n\n# Cross-model fallback: when all primary keys fail, try Claude → GPT\n# Requires ANTHROPIC_API_KEY, OPENAI_API_KEY\nLITELLM_FALLBACK_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4-mini\n```\n\n> ⚠️ `LITELLM_MODEL` must include provider prefix (e.g. `gemini/`, `anthropic/`, `openai/`). Legacy `GEMINI_MODEL` (no prefix) is only used when `LITELLM_MODEL` is not set.\n\n**Vision model (image stock code extraction)**: See [LLM Config Guide - Vision](LLM_CONFIG_GUIDE_EN.md#41-vision-model-image-stock-code-extraction).\n\n### Debug Mode\n\n```bash\npython main.py --debug\n```\n\nLog file locations:\n- Regular logs: `logs/stock_analysis_YYYYMMDD.log`\n- Debug logs: `logs/stock_analysis_debug_YYYYMMDD.log`\n\nDebug logs keep the app's own DEBUG messages, but LiteLLM internals default to `WARNING` to avoid token-level third-party noise during streaming generation. To inspect LiteLLM internals temporarily, set `LITELLM_LOG_LEVEL=DEBUG` in `.env`.\n\n### SQLite Write Stability\n\nFor file-based SQLite databases, the app now enables `WAL` and sets `busy_timeout` on connection startup. `save_daily_data()` also uses a batch atomic upsert on `(code, date)` to reduce lock contention during bulk writes and concurrent callbacks.\n\nYou can tune the behavior in `.env`:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SQLITE_WAL_ENABLED` | `true` | Enable `journal_mode=WAL` for file-based SQLite |\n| `SQLITE_BUSY_TIMEOUT_MS` | `5000` | SQLite lock wait timeout in milliseconds |\n| `SQLITE_WRITE_RETRY_MAX` | `3` | Max retries for `database is locked` / `database table is locked` errors |\n| `SQLITE_WRITE_RETRY_BASE_DELAY` | `0.1` | Base backoff delay in seconds for exponential write retries |\n\n---\n\n## Backtesting\n\nThe backtesting module automatically validates historical AI analysis records against actual price movements, evaluating the accuracy of analysis recommendations.\n\n### How It Works\n\n1. Selects `AnalysisHistory` records past the cooldown period (default 14 days)\n2. Fetches daily bar data after the analysis date (forward bars)\n3. Infers expected direction from the operation advice and compares against actual movement\n4. Evaluates stop-loss/take-profit hit conditions and simulates execution returns\n5. Aggregates into overall and per-stock performance metrics\n\n### Operation Advice Mapping\n\n| Operation Advice | Position | Expected Direction | Win Condition |\n|-----------------|----------|-------------------|---------------|\n| Buy / Add / Strong Buy | long | up | Return >= neutral band |\n| Sell / Reduce / Strong Sell | cash | down | Decline >= neutral band |\n| Hold | long | not_down | No significant decline |\n| Wait / Observe | cash | flat | Price within neutral band |\n\n### Configuration\n\nSet the following variables in `.env` (all optional, have defaults):\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `BACKTEST_ENABLED` | `true` | Whether to auto-run backtest after daily analysis |\n| `BACKTEST_EVAL_WINDOW_DAYS` | `10` | Evaluation window (trading days) |\n| `BACKTEST_MIN_AGE_DAYS` | `14` | Only backtest records older than N days to avoid incomplete data |\n| `BACKTEST_ENGINE_VERSION` | `v1` | Engine version, used to distinguish results when logic is updated |\n| `BACKTEST_NEUTRAL_BAND_PCT` | `2.0` | Neutral band threshold (%), ±2% treated as range-bound |\n\n### Auto-run\n\nBacktesting triggers automatically after the daily analysis flow completes (non-blocking; failures do not affect notifications). It can also be triggered manually via API.\n\n### Evaluation Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `direction_accuracy_pct` | Direction prediction accuracy (expected direction matches actual) |\n| `win_rate_pct` | Win rate (wins / (wins + losses), excludes neutral) |\n| `avg_stock_return_pct` | Average stock return percentage |\n| `avg_simulated_return_pct` | Average simulated execution return (including SL/TP exits) |\n| `stop_loss_trigger_rate` | Stop-loss trigger rate (only counts records with SL configured) |\n| `take_profit_trigger_rate` | Take-profit trigger rate (only counts records with TP configured) |\n\n---\n\n## Local WebUI Management Interface\n\nThe WebUI and FastAPI API share the same service process. After startup, use the browser workspace for configuration management, manual analysis, task progress, historical reports, backtesting, portfolio management, and smart import. Authentication, cloud-server access, and API usage details are covered below.\n\n### FastAPI API Service\n\nFastAPI provides RESTful API service for configuration management and triggering analysis.\n\n### Startup Methods\n\n| Command | Description |\n|------|------|\n| `python main.py --serve` | Start API service + run full analysis once |\n| `python main.py --serve-only` | Start API service only, manually trigger analysis |\n\n### Features\n\n- **Configuration Management** - View/modify watchlist\n- **Quick Analysis** - Trigger analysis via API\n- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE\n- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns\n- **API Documentation** - Visit `/docs` for Swagger UI\n\n### API Endpoints\n\n| Endpoint | Method | Description |\n|------|------|------|\n| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |\n| `/api/v1/analysis/tasks` | GET | Query task list |\n| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |\n| `/api/v1/analysis/status/{task_id}` | GET | Query task status |\n| `/api/v1/history` | GET | Query analysis history |\n| `/api/v1/usage/summary?period=today|month|all` | GET | Query LLM call counts and token usage grouped by call type and model |\n| `/api/v1/backtest/run` | POST | Trigger backtest |\n| `/api/v1/backtest/results` | GET | Query backtest results (paginated) |\n| `/api/v1/backtest/performance` | GET | Get overall backtest performance |\n| `/api/v1/backtest/performance/{code}` | GET | Get per-stock backtest performance |\n| `/api/health` | GET | Health check |\n| `/docs` | GET | API Swagger documentation |\n\n> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.\n\n> Progress-stream note: `GET /api/v1/analysis/tasks/stream` now emits `task_progress` in addition to `task_created / task_started / task_completed / task_failed`. The regular analysis path updates `progress` and `message` across quote preparation, news retrieval, context assembly, LLM generation, and report persistence. Streaming chunks are accumulated only on the server side; history is persisted only after the final JSON parses successfully. If streaming is unavailable before the first chunk, the system falls back to the previous non-stream request. If a stream fails after partial output has already arrived, the system first retries non-stream for the same model, then continues through existing fallback models in the original order (primary + fallback list).\n> If a progress callback fails, the analysis flow continues, and the exception is now logged at warning level to help troubleshoot SSE delivery gaps.\n\n> Note: This behavior is documented in the full guide (`full-guide*.md`) because it is detailed runtime SSE/fallback behavior and is therefore kept out of the README.\n\n**Usage examples**:\n```bash\n# Health check\ncurl http://127.0.0.1:8000/api/health\n\n# Trigger analysis (A-shares)\ncurl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \\\n -H 'Content-Type: application/json' \\\n -d '{\"stock_code\": \"600519\"}'\n\n# Query task status\ncurl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>\n\n# Query today's LLM usage\ncurl \"http://127.0.0.1:8000/api/v1/usage/summary?period=today\"\n\n# Trigger backtest (all stocks)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"force\": false}'\n\n# Trigger backtest (specific stock)\ncurl -X POST http://127.0.0.1:8000/api/v1/backtest/run \\\n -H 'Content-Type: application/json' \\\n -d '{\"code\": \"600519\", \"force\": false}'\n\n# Query overall backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance\n\n# Query per-stock backtest performance\ncurl http://127.0.0.1:8000/api/v1/backtest/performance/600519\n\n# Paginated backtest results\ncurl \"http://127.0.0.1:8000/api/v1/backtest/results?page=1&limit=20\"\n```\n\n### Custom Configuration\n\nModify default port or allow LAN access:\n\n```bash\npython main.py --serve-only --host 0.0.0.0 --port 8888\n```\n\n### Supported Stock Code Formats\n\n| Type | Format | Examples |\n|------|------|------|\n| A-shares | 6-digit number | `600519`, `000001`, `300750` |\n| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |\n| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |\n\n### Notes\n\n- Browser access: `http://127.0.0.1:8000` (or your configured port)\n- After analysis completion, notifications are automatically pushed to configured channels\n- This feature is automatically disabled in GitHub Actions environment\n\n---\n\n## FAQ\n\n### Q: Push messages getting truncated?\nA: WeChat Work/Feishu have message length limits, system already auto-segments messages. For complete content, configure Feishu Cloud Document feature.\n\n### Q: Data fetch failed?\nA: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry.\n\n### Q: How to add watchlist stocks?\nA: Modify `STOCK_LIST` environment variable, separate multiple codes with commas.\n\n### Q: GitHub Actions not executing?\nA: Check if Actions is enabled, and if cron expression is correct (note it's UTC time).\n\n---\n\n## Portfolio Web Notes\n\n### Manual FX refresh on `/portfolio`\n\n- The FX status card on the Web `/portfolio` page includes a manual refresh action.\n- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.\n- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.\n- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.\n- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.\n\n## Agent Tool Data Cache And Persistence\n\n- `get_daily_history` first tries to reuse local `stock_daily` daily-bar cache; when the cache is fresh and contains at least the dashboard default of 30 records, it avoids another external data-source request.\n- If Agent asks for more days than the local cache contains, the tool returns the available records and marks the response with `partial_cache=true`, `requested_days`, and `actual_records`.\n- When the cache is missing or stale, the tool keeps the original data-source fetch path; successful fetches are written back to `stock_daily` on a best-effort basis, and write failures do not block the Agent response.\n- `search_stock_news` and `search_comprehensive_intel` persist successful results to `news_intel` on a best-effort basis, reusing the existing URL / fallback-key deduplication logic.\n- `get_realtime_quote` does not use `stock_daily` as a realtime-quote cache and does not write intraday quotes into the daily-bar table; realtime quote caching should use a dedicated realtime store if needed.\n\n## Agent Event Monitor\n\nWhen `AGENT_EVENT_MONITOR_ENABLED=true`, schedule mode polls the rules in `AGENT_EVENT_ALERT_RULES_JSON` every `AGENT_EVENT_MONITOR_INTERVAL_MINUTES` minutes and sends triggered alerts through the existing notification channels. The runtime currently supports three rule types:\n\n> Compatibility and rollback note: this section documents current Event Monitor rule behavior (including `price_change_percent`) and does not change external model/provider API semantics such as model names, providers, Base URL, LiteLLM, `OPENAI_*`, `DEEPSEEK_*`, or `GEMINI_*` configuration.\n> Rollback is explicit: clear or disable `AGENT_EVENT_MONITOR_ENABLED`/related rule config to restore previous behavior.\n\n| `alert_type` | Direction | Threshold | Description |\n| --- | --- | --- | --- |\n| `price_cross` | `above` / `below` | `price` | Current price crosses a fixed threshold |\n| `price_change_percent` | `up` / `down` | `change_pct` | Intraday change percentage reaches a threshold |\n| `volume_spike` | - | `multiplier` | Latest volume exceeds the recent 20-day average by this multiplier |\n\nExample:\n\n```env\nAGENT_EVENT_MONITOR_ENABLED=true\nAGENT_EVENT_MONITOR_INTERVAL_MINUTES=5\nAGENT_EVENT_ALERT_RULES_JSON=[{\"stock_code\":\"600519\",\"alert_type\":\"price_cross\",\"direction\":\"above\",\"price\":1800},{\"stock_code\":\"300750\",\"alert_type\":\"price_change_percent\",\"direction\":\"down\",\"change_pct\":3.0},{\"stock_code\":\"000858\",\"alert_type\":\"volume_spike\",\"multiplier\":2.5}]\n```\n\n---\n\nFor more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)\n"}}, "tests/test_llm_channel_config.py": {"e8305ba738f90086d2bd7e19474054decb796526": {"t": 1785746890.4398053, "n": 24646, "h": "ff05fd6b77924d36", "c": "# -*- coding: utf-8 -*-\n\"\"\"Tests for env-based LLM channel parsing.\"\"\"\n\nimport os\nimport unittest\nfrom unittest.mock import patch\n\nfrom src.config import (\n ANSPIRE_LLM_BASE_URL_DEFAULT,\n ANSPIRE_LLM_MODEL_DEFAULT,\n Config,\n get_effective_agent_models_to_try,\n get_effective_agent_primary_model,\n get_fixed_litellm_temperature,\n normalize_litellm_temperature,\n)\nfrom src.llm.generation_params import (\n apply_litellm_generation_params,\n resolve_litellm_temperature_directive,\n)\n\n\nclass LLMChannelConfigTestCase(unittest.TestCase):\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_anspire_key_enables_openai_compatible_legacy_model(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"ANSPIRE_API_KEYS\": \"sk-anspire-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.anspire_api_keys, [\"sk-anspire-test-value\"])\n self.assertEqual(config.openai_api_keys, [\"sk-anspire-test-value\"])\n self.assertEqual(config.openai_base_url, ANSPIRE_LLM_BASE_URL_DEFAULT)\n self.assertEqual(config.litellm_model, f\"openai/{ANSPIRE_LLM_MODEL_DEFAULT}\")\n self.assertEqual(config.llm_models_source, \"legacy_env\")\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"__legacy_openai__\")\n self.assertEqual(params[\"api_base\"], ANSPIRE_LLM_BASE_URL_DEFAULT)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_anspire_legacy_overrides_stale_openai_base_url(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"ANSPIRE_API_KEYS\": \"sk-anspire-test-value\",\n \"OPENAI_BASE_URL\": \"https://stale-openai-compatible.example/v1\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.openai_api_keys, [\"sk-anspire-test-value\"])\n self.assertEqual(config.openai_base_url, ANSPIRE_LLM_BASE_URL_DEFAULT)\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"api_base\"], ANSPIRE_LLM_BASE_URL_DEFAULT)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_anspire_channel_reuses_shared_key_and_defaults(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"anspire\",\n \"ANSPIRE_API_KEYS\": \"sk-anspire-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"openai\")\n self.assertEqual(config.llm_channels[0][\"api_keys\"], [\"sk-anspire-test-value\"])\n self.assertEqual(config.llm_channels[0][\"models\"], [f\"openai/{ANSPIRE_LLM_MODEL_DEFAULT}\"])\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"api_base\"], ANSPIRE_LLM_BASE_URL_DEFAULT)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_blank_anspire_channel_enabled_uses_shared_disable_flag(\n self,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"LLM_CHANNELS\": \"anspire\",\n \"LLM_ANSPIRE_ENABLED\": \" \",\n \"ANSPIRE_LLM_ENABLED\": \"false\",\n \"ANSPIRE_API_KEYS\": \"sk-anspire-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.openai_api_keys, [])\n self.assertEqual(config.llm_channels, [])\n self.assertEqual(config.llm_model_list, [])\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_disabled_anspire_channel_does_not_fall_back_to_legacy(\n self,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"LLM_CHANNELS\": \"anspire\",\n \"LLM_ANSPIRE_ENABLED\": \"false\",\n \"ANSPIRE_API_KEYS\": \"sk-anspire-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.openai_api_keys, [])\n self.assertEqual(config.llm_channels, [])\n self.assertEqual(config.llm_model_list, [])\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_protocol_prefixes_bare_model_names(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"deepseek\",\n \"LLM_PRIMARY_BASE_URL\": \"https://api.deepseek.com/v1\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"deepseek\")\n self.assertEqual(config.llm_channels[0][\"models\"], [\"deepseek/deepseek-chat\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"deepseek/deepseek-chat\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_openai_compatible_channel_prefixes_non_provider_slash_models(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"siliconflow\",\n \"LLM_SILICONFLOW_PROTOCOL\": \"openai\",\n \"LLM_SILICONFLOW_BASE_URL\": \"https://api.siliconflow.cn/v1\",\n \"LLM_SILICONFLOW_API_KEY\": \"sk-test-value\",\n \"LLM_SILICONFLOW_MODELS\": \"Qwen/Qwen3-8B,deepseek-ai/DeepSeek-V3\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n config.llm_channels[0][\"models\"],\n [\"openai/Qwen/Qwen3-8B\", \"openai/deepseek-ai/DeepSeek-V3\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_alias_prefixed_models_are_canonicalized_once(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"vertex\",\n \"LLM_VERTEX_PROTOCOL\": \"vertex_ai\",\n \"LLM_VERTEX_API_KEY\": \"sk-test-value\",\n \"LLM_VERTEX_MODELS\": \"vertexai/gemini-2.5-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels[0][\"models\"], [\"vertex_ai/gemini-2.5-flash\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"vertex_ai/gemini-2.5-flash\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_minimax_prefixed_models_are_not_rewritten_for_openai_compatible_channels(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_BASE_URL\": \"https://api.example.com/v1\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"minimax/MiniMax-M1\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels[0][\"models\"], [\"minimax/MiniMax-M1\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"minimax/MiniMax-M1\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_disabled_channel_is_skipped(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_ENABLED\": \"false\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels, [])\n self.assertEqual(config.llm_model_list, [])\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_local_ollama_channel_can_skip_api_key(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"local\",\n \"LLM_LOCAL_PROTOCOL\": \"ollama\",\n \"LLM_LOCAL_BASE_URL\": \"http://127.0.0.1:11434\",\n \"LLM_LOCAL_API_KEY\": \"\",\n \"LLM_LOCAL_MODELS\": \"llama3.2\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"ollama/llama3.2\")\n self.assertNotIn(\"api_key\", params)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_legacy_provider_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"GEMINI_TEMPERATURE\": \"0.15\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"gemini/gemini-3.1-pro-preview\")\n self.assertAlmostEqual(config.llm_temperature, 0.15)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_defaults_to_legacy_chat_model_with_deprecation_warning(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"deepseek/deepseek-chat\")\n mock_warning.assert_called_once_with(\n \"Deprecation warning:\\n\"\n \"deepseek-chat will be deprecated on 2026-07-24,\\n\"\n \"please migrate to deepseek-v4-flash.\"\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_explicit_deepseek_litellm_model_is_preserved(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"deepseek/deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"deepseek/deepseek-chat\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_does_not_warn_when_channels_take_precedence(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"deepseek\",\n \"LLM_PRIMARY_API_KEY\": \"sk-channel-value\",\n \"LLM_PRIMARY_MODELS\": \"deepseek-v4-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(\n Config,\n \"_parse_litellm_yaml\",\n return_value=[\n {\n \"model_name\": \"primary\",\n \"litellm_params\": {\n \"model\": \"deepseek/deepseek-v4-flash\",\n \"api_key\": \"sk-yaml-value\",\n },\n }\n ],\n )\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_does_not_warn_when_litellm_yaml_takes_precedence(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LITELLM_CONFIG\": \"/tmp/litellm.yaml\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"litellm_config\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_prefers_unified_setting_when_present(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"GEMINI_TEMPERATURE\": \"0.15\",\n \"LLM_TEMPERATURE\": \"0.35\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.35)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_openai_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o\",\n \"LITELLM_MODEL\": \"openai/gpt-4o\",\n \"OPENAI_TEMPERATURE\": \"0.42\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.42)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_any_legacy_when_provider_mismatch(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o\",\n \"LITELLM_MODEL\": \"openai/gpt-4o\",\n \"ANTHROPIC_TEMPERATURE\": \"0.55\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.55)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_ignores_invalid_value(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"LLM_TEMPERATURE\": \"high\",\n \"GEMINI_TEMPERATURE\": \"0.25\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.25)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_kimi_k26_keeps_raw_configured_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"kimi-k2.6\",\n \"LLM_TEMPERATURE\": \"0.7\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"openai/kimi-k2.6\")\n self.assertAlmostEqual(config.llm_temperature, 0.7)\n\n def test_kimi_k26_temperature_normalization_handles_provider_wrappers(self) -> None:\n self.assertAlmostEqual(get_fixed_litellm_temperature(\"moonshot/kimi-k2.6\"), 1.0)\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/moonshot/kimi-k2.6\", 0.2), 1.0)\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/kimi-k2.6-preview\", 0.2), 1.0)\n self.assertAlmostEqual(\n normalize_litellm_temperature(\n \"openai/kimi-k2.6-preview\",\n 0.2,\n request_overrides={\"extra_body\": {\"thinking\": {\"type\": \"disabled\"}}},\n ),\n 0.6,\n )\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/gpt-4o-mini\", 0.2), 0.2)\n\n def test_kimi_k26_temperature_normalization_resolves_litellm_yaml_alias(self) -> None:\n model_list = [\n {\n \"model_name\": \"kimi_router\",\n \"litellm_params\": {\n \"model\": \"openai/kimi-k2.6\",\n \"api_key\": \"sk-yaml-value\",\n },\n }\n ]\n\n self.assertAlmostEqual(get_fixed_litellm_temperature(\"kimi_router\", model_list=model_list), 1.0)\n self.assertAlmostEqual(\n normalize_litellm_temperature(\"kimi_router\", 0.2, model_list=model_list),\n 1.0,\n )\n\n def test_kimi_k26_temperature_normalization_uses_non_thinking_yaml_alias_temperature(self) -> None:\n model_list = [\n {\n \"model_name\": \"kimi_router\",\n \"litellm_params\": {\n \"model\": \"openai/kimi-k2.6\",\n \"api_key\": \"sk-yaml-value\",\n \"extra_body\": {\"thinking\": {\"type\": \"disabled\"}},\n },\n }\n ]\n\n self.assertAlmostEqual(\n get_fixed_litellm_temperature(\"kimi_router\", model_list=model_list),\n 0.6,\n )\n self.assertAlmostEqual(\n normalize_litellm_temperature(\"kimi_router\", 0.2, model_list=model_list),\n 0.6,\n )\n\n def test_kimi_k26_temperature_normalization_uses_non_thinking_yaml_wire_model_without_model_name(self) -> None:\n model_list = [\n {\n \"litellm_params\": {\n \"model\": \"openai/kimi-k2.6\",\n \"api_key\": \"sk-yaml-value\",\n \"extra_body\": {\"thinking\": {\"type\": \"disabled\"}},\n },\n }\n ]\n\n self.assertAlmostEqual(\n get_fixed_litellm_temperature(\"openai/kimi-k2.6\", model_list=model_list),\n 0.6,\n )\n self.assertAlmostEqual(\n normalize_litellm_temperature(\"openai/kimi-k2.6\", 0.2, model_list=model_list),\n 0.6,\n )\n\n def test_gpt5_family_temperature_is_omitted_at_request_build_time(self) -> None:\n directive = resolve_litellm_temperature_directive(\"openai/gpt5.5-ferr\")\n self.assertTrue(directive.omit_temperature)\n\n call_kwargs = apply_litellm_generation_params(\n {\"model\": \"openai/gpt5.5-ferr\", \"messages\": [], \"temperature\": 0.2},\n \"openai/gpt5.5-ferr\",\n 0.2,\n )\n\n self.assertNotIn(\"temperature\", call_kwargs)\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/gpt5.5-ferr\", 0.2), 0.2)\n\n def test_gpt5_temperature_directive_resolves_litellm_yaml_alias(self) -> None:\n model_list = [\n {\n \"model_name\": \"future_router\",\n \"litellm_params\": {\"model\": \"openai/gpt-5.5\"},\n }\n ]\n\n directive = resolve_litellm_temperature_directive(\"future_router\", model_list=model_list)\n call_kwargs = apply_litellm_generation_params(\n {\"model\": \"future_router\", \"messages\": []},\n \"future_router\",\n 0.2,\n model_list=model_list,\n )\n\n self.assertTrue(directive.omit_temperature)\n self.assertNotIn(\"temperature\", call_kwargs)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_local_openai_compatible_channel_defaults_to_openai_protocol(self, _mock_parse_yaml, _mock_setup_env) -> None:\n \"\"\"Localhost channels without explicit protocol should default to openai, not ollama.\"\"\"\n env = {\n \"LLM_CHANNELS\": \"local\",\n \"LLM_LOCAL_BASE_URL\": \"http://127.0.0.1:8000/v1\",\n \"LLM_LOCAL_API_KEY\": \"not-needed\",\n \"LLM_LOCAL_MODELS\": \"my-model\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"openai/my-model\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"openai\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_model_empty_inherits_primary_model(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"gpt-4o-mini\",\n \"AGENT_LITELLM_MODEL\": \"\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"\")\n self.assertEqual(get_effective_agent_primary_model(config), \"openai/gpt-4o-mini\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_model_without_provider_prefix_is_normalized(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"gpt-4o-mini\",\n \"AGENT_LITELLM_MODEL\": \"deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"openai/deepseek-chat\")\n self.assertEqual(get_effective_agent_primary_model(config), \"openai/deepseek-chat\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_models_to_try_are_deduped_in_order(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"gemini/gemini-2.5-flash\",\n \"AGENT_LITELLM_MODEL\": \"openai/gpt-4o-mini\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini,openai/gpt-4o-mini,gemini/gemini-2.5-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"openai/gpt-4o-mini\", \"gemini/gemini-2.5-flash\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_models_to_try_dedupes_semantically_equivalent_openai_models(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"gemini/gemini-2.5-flash\",\n \"AGENT_LITELLM_MODEL\": \"gpt-4o-mini\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini,gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"openai/gpt-4o-mini\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(\n Config,\n \"_parse_litellm_yaml\",\n return_value=[\n {\n \"model_name\": \"gpt4o\",\n \"litellm_params\": {\n \"model\": \"openai/gpt-4o-mini\",\n \"api_key\": \"sk-test-value\",\n },\n }\n ],\n )\n def test_agent_model_preserves_yaml_alias_without_provider_prefix(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LITELLM_CONFIG\": \"/tmp/litellm.yaml\",\n \"AGENT_LITELLM_MODEL\": \"gpt4o\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"gpt4o\")\n self.assertEqual(get_effective_agent_primary_model(config), \"gpt4o\")\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"gpt4o\", \"openai/gpt-4o-mini\"],\n )\n\n\nif __name__ == \"__main__\":\n unittest.main()\n"}, "4aad40cdd43d8809b09687c9c07372db29560f36": {"t": 1785754702.3388507, "n": 24646, "h": "ff05fd6b77924d36", "c": "# -*- coding: utf-8 -*-\n\"\"\"Tests for env-based LLM channel parsing.\"\"\"\n\nimport os\nimport unittest\nfrom unittest.mock import patch\n\nfrom src.config import (\n ANSPIRE_LLM_BASE_URL_DEFAULT,\n ANSPIRE_LLM_MODEL_DEFAULT,\n Config,\n get_effective_agent_models_to_try,\n get_effective_agent_primary_model,\n get_fixed_litellm_temperature,\n normalize_litellm_temperature,\n)\nfrom src.llm.generation_params import (\n apply_litellm_generation_params,\n resolve_litellm_temperature_directive,\n)\n\n\nclass LLMChannelConfigTestCase(unittest.TestCase):\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_anspire_key_enables_openai_compatible_legacy_model(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"ANSPIRE_API_KEYS\": \"sk-anspire-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.anspire_api_keys, [\"sk-anspire-test-value\"])\n self.assertEqual(config.openai_api_keys, [\"sk-anspire-test-value\"])\n self.assertEqual(config.openai_base_url, ANSPIRE_LLM_BASE_URL_DEFAULT)\n self.assertEqual(config.litellm_model, f\"openai/{ANSPIRE_LLM_MODEL_DEFAULT}\")\n self.assertEqual(config.llm_models_source, \"legacy_env\")\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"__legacy_openai__\")\n self.assertEqual(params[\"api_base\"], ANSPIRE_LLM_BASE_URL_DEFAULT)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_anspire_legacy_overrides_stale_openai_base_url(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"ANSPIRE_API_KEYS\": \"sk-anspire-test-value\",\n \"OPENAI_BASE_URL\": \"https://stale-openai-compatible.example/v1\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.openai_api_keys, [\"sk-anspire-test-value\"])\n self.assertEqual(config.openai_base_url, ANSPIRE_LLM_BASE_URL_DEFAULT)\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"api_base\"], ANSPIRE_LLM_BASE_URL_DEFAULT)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_anspire_channel_reuses_shared_key_and_defaults(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"anspire\",\n \"ANSPIRE_API_KEYS\": \"sk-anspire-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"openai\")\n self.assertEqual(config.llm_channels[0][\"api_keys\"], [\"sk-anspire-test-value\"])\n self.assertEqual(config.llm_channels[0][\"models\"], [f\"openai/{ANSPIRE_LLM_MODEL_DEFAULT}\"])\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"api_base\"], ANSPIRE_LLM_BASE_URL_DEFAULT)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_blank_anspire_channel_enabled_uses_shared_disable_flag(\n self,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"LLM_CHANNELS\": \"anspire\",\n \"LLM_ANSPIRE_ENABLED\": \" \",\n \"ANSPIRE_LLM_ENABLED\": \"false\",\n \"ANSPIRE_API_KEYS\": \"sk-anspire-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.openai_api_keys, [])\n self.assertEqual(config.llm_channels, [])\n self.assertEqual(config.llm_model_list, [])\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_disabled_anspire_channel_does_not_fall_back_to_legacy(\n self,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"LLM_CHANNELS\": \"anspire\",\n \"LLM_ANSPIRE_ENABLED\": \"false\",\n \"ANSPIRE_API_KEYS\": \"sk-anspire-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.openai_api_keys, [])\n self.assertEqual(config.llm_channels, [])\n self.assertEqual(config.llm_model_list, [])\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_protocol_prefixes_bare_model_names(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"deepseek\",\n \"LLM_PRIMARY_BASE_URL\": \"https://api.deepseek.com/v1\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"deepseek\")\n self.assertEqual(config.llm_channels[0][\"models\"], [\"deepseek/deepseek-chat\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"deepseek/deepseek-chat\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_openai_compatible_channel_prefixes_non_provider_slash_models(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"siliconflow\",\n \"LLM_SILICONFLOW_PROTOCOL\": \"openai\",\n \"LLM_SILICONFLOW_BASE_URL\": \"https://api.siliconflow.cn/v1\",\n \"LLM_SILICONFLOW_API_KEY\": \"sk-test-value\",\n \"LLM_SILICONFLOW_MODELS\": \"Qwen/Qwen3-8B,deepseek-ai/DeepSeek-V3\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n config.llm_channels[0][\"models\"],\n [\"openai/Qwen/Qwen3-8B\", \"openai/deepseek-ai/DeepSeek-V3\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_alias_prefixed_models_are_canonicalized_once(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"vertex\",\n \"LLM_VERTEX_PROTOCOL\": \"vertex_ai\",\n \"LLM_VERTEX_API_KEY\": \"sk-test-value\",\n \"LLM_VERTEX_MODELS\": \"vertexai/gemini-2.5-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels[0][\"models\"], [\"vertex_ai/gemini-2.5-flash\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"vertex_ai/gemini-2.5-flash\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_minimax_prefixed_models_are_not_rewritten_for_openai_compatible_channels(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_BASE_URL\": \"https://api.example.com/v1\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"minimax/MiniMax-M1\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels[0][\"models\"], [\"minimax/MiniMax-M1\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"minimax/MiniMax-M1\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_disabled_channel_is_skipped(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_ENABLED\": \"false\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels, [])\n self.assertEqual(config.llm_model_list, [])\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_local_ollama_channel_can_skip_api_key(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"local\",\n \"LLM_LOCAL_PROTOCOL\": \"ollama\",\n \"LLM_LOCAL_BASE_URL\": \"http://127.0.0.1:11434\",\n \"LLM_LOCAL_API_KEY\": \"\",\n \"LLM_LOCAL_MODELS\": \"llama3.2\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"ollama/llama3.2\")\n self.assertNotIn(\"api_key\", params)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_legacy_provider_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"GEMINI_TEMPERATURE\": \"0.15\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"gemini/gemini-3.1-pro-preview\")\n self.assertAlmostEqual(config.llm_temperature, 0.15)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_defaults_to_legacy_chat_model_with_deprecation_warning(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"deepseek/deepseek-chat\")\n mock_warning.assert_called_once_with(\n \"Deprecation warning:\\n\"\n \"deepseek-chat will be deprecated on 2026-07-24,\\n\"\n \"please migrate to deepseek-v4-flash.\"\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_explicit_deepseek_litellm_model_is_preserved(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"deepseek/deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"deepseek/deepseek-chat\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_does_not_warn_when_channels_take_precedence(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"deepseek\",\n \"LLM_PRIMARY_API_KEY\": \"sk-channel-value\",\n \"LLM_PRIMARY_MODELS\": \"deepseek-v4-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(\n Config,\n \"_parse_litellm_yaml\",\n return_value=[\n {\n \"model_name\": \"primary\",\n \"litellm_params\": {\n \"model\": \"deepseek/deepseek-v4-flash\",\n \"api_key\": \"sk-yaml-value\",\n },\n }\n ],\n )\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_does_not_warn_when_litellm_yaml_takes_precedence(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LITELLM_CONFIG\": \"/tmp/litellm.yaml\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"litellm_config\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_prefers_unified_setting_when_present(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"GEMINI_TEMPERATURE\": \"0.15\",\n \"LLM_TEMPERATURE\": \"0.35\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.35)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_openai_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o\",\n \"LITELLM_MODEL\": \"openai/gpt-4o\",\n \"OPENAI_TEMPERATURE\": \"0.42\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.42)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_any_legacy_when_provider_mismatch(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o\",\n \"LITELLM_MODEL\": \"openai/gpt-4o\",\n \"ANTHROPIC_TEMPERATURE\": \"0.55\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.55)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_ignores_invalid_value(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"LLM_TEMPERATURE\": \"high\",\n \"GEMINI_TEMPERATURE\": \"0.25\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.25)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_kimi_k26_keeps_raw_configured_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"kimi-k2.6\",\n \"LLM_TEMPERATURE\": \"0.7\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"openai/kimi-k2.6\")\n self.assertAlmostEqual(config.llm_temperature, 0.7)\n\n def test_kimi_k26_temperature_normalization_handles_provider_wrappers(self) -> None:\n self.assertAlmostEqual(get_fixed_litellm_temperature(\"moonshot/kimi-k2.6\"), 1.0)\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/moonshot/kimi-k2.6\", 0.2), 1.0)\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/kimi-k2.6-preview\", 0.2), 1.0)\n self.assertAlmostEqual(\n normalize_litellm_temperature(\n \"openai/kimi-k2.6-preview\",\n 0.2,\n request_overrides={\"extra_body\": {\"thinking\": {\"type\": \"disabled\"}}},\n ),\n 0.6,\n )\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/gpt-4o-mini\", 0.2), 0.2)\n\n def test_kimi_k26_temperature_normalization_resolves_litellm_yaml_alias(self) -> None:\n model_list = [\n {\n \"model_name\": \"kimi_router\",\n \"litellm_params\": {\n \"model\": \"openai/kimi-k2.6\",\n \"api_key\": \"sk-yaml-value\",\n },\n }\n ]\n\n self.assertAlmostEqual(get_fixed_litellm_temperature(\"kimi_router\", model_list=model_list), 1.0)\n self.assertAlmostEqual(\n normalize_litellm_temperature(\"kimi_router\", 0.2, model_list=model_list),\n 1.0,\n )\n\n def test_kimi_k26_temperature_normalization_uses_non_thinking_yaml_alias_temperature(self) -> None:\n model_list = [\n {\n \"model_name\": \"kimi_router\",\n \"litellm_params\": {\n \"model\": \"openai/kimi-k2.6\",\n \"api_key\": \"sk-yaml-value\",\n \"extra_body\": {\"thinking\": {\"type\": \"disabled\"}},\n },\n }\n ]\n\n self.assertAlmostEqual(\n get_fixed_litellm_temperature(\"kimi_router\", model_list=model_list),\n 0.6,\n )\n self.assertAlmostEqual(\n normalize_litellm_temperature(\"kimi_router\", 0.2, model_list=model_list),\n 0.6,\n )\n\n def test_kimi_k26_temperature_normalization_uses_non_thinking_yaml_wire_model_without_model_name(self) -> None:\n model_list = [\n {\n \"litellm_params\": {\n \"model\": \"openai/kimi-k2.6\",\n \"api_key\": \"sk-yaml-value\",\n \"extra_body\": {\"thinking\": {\"type\": \"disabled\"}},\n },\n }\n ]\n\n self.assertAlmostEqual(\n get_fixed_litellm_temperature(\"openai/kimi-k2.6\", model_list=model_list),\n 0.6,\n )\n self.assertAlmostEqual(\n normalize_litellm_temperature(\"openai/kimi-k2.6\", 0.2, model_list=model_list),\n 0.6,\n )\n\n def test_gpt5_family_temperature_is_omitted_at_request_build_time(self) -> None:\n directive = resolve_litellm_temperature_directive(\"openai/gpt5.5-ferr\")\n self.assertTrue(directive.omit_temperature)\n\n call_kwargs = apply_litellm_generation_params(\n {\"model\": \"openai/gpt5.5-ferr\", \"messages\": [], \"temperature\": 0.2},\n \"openai/gpt5.5-ferr\",\n 0.2,\n )\n\n self.assertNotIn(\"temperature\", call_kwargs)\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/gpt5.5-ferr\", 0.2), 0.2)\n\n def test_gpt5_temperature_directive_resolves_litellm_yaml_alias(self) -> None:\n model_list = [\n {\n \"model_name\": \"future_router\",\n \"litellm_params\": {\"model\": \"openai/gpt-5.5\"},\n }\n ]\n\n directive = resolve_litellm_temperature_directive(\"future_router\", model_list=model_list)\n call_kwargs = apply_litellm_generation_params(\n {\"model\": \"future_router\", \"messages\": []},\n \"future_router\",\n 0.2,\n model_list=model_list,\n )\n\n self.assertTrue(directive.omit_temperature)\n self.assertNotIn(\"temperature\", call_kwargs)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_local_openai_compatible_channel_defaults_to_openai_protocol(self, _mock_parse_yaml, _mock_setup_env) -> None:\n \"\"\"Localhost channels without explicit protocol should default to openai, not ollama.\"\"\"\n env = {\n \"LLM_CHANNELS\": \"local\",\n \"LLM_LOCAL_BASE_URL\": \"http://127.0.0.1:8000/v1\",\n \"LLM_LOCAL_API_KEY\": \"not-needed\",\n \"LLM_LOCAL_MODELS\": \"my-model\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"openai/my-model\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"openai\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_model_empty_inherits_primary_model(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"gpt-4o-mini\",\n \"AGENT_LITELLM_MODEL\": \"\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"\")\n self.assertEqual(get_effective_agent_primary_model(config), \"openai/gpt-4o-mini\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_model_without_provider_prefix_is_normalized(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"gpt-4o-mini\",\n \"AGENT_LITELLM_MODEL\": \"deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"openai/deepseek-chat\")\n self.assertEqual(get_effective_agent_primary_model(config), \"openai/deepseek-chat\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_models_to_try_are_deduped_in_order(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"gemini/gemini-2.5-flash\",\n \"AGENT_LITELLM_MODEL\": \"openai/gpt-4o-mini\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini,openai/gpt-4o-mini,gemini/gemini-2.5-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"openai/gpt-4o-mini\", \"gemini/gemini-2.5-flash\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_models_to_try_dedupes_semantically_equivalent_openai_models(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"gemini/gemini-2.5-flash\",\n \"AGENT_LITELLM_MODEL\": \"gpt-4o-mini\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini,gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"openai/gpt-4o-mini\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(\n Config,\n \"_parse_litellm_yaml\",\n return_value=[\n {\n \"model_name\": \"gpt4o\",\n \"litellm_params\": {\n \"model\": \"openai/gpt-4o-mini\",\n \"api_key\": \"sk-test-value\",\n },\n }\n ],\n )\n def test_agent_model_preserves_yaml_alias_without_provider_prefix(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LITELLM_CONFIG\": \"/tmp/litellm.yaml\",\n \"AGENT_LITELLM_MODEL\": \"gpt4o\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"gpt4o\")\n self.assertEqual(get_effective_agent_primary_model(config), \"gpt4o\")\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"gpt4o\", \"openai/gpt-4o-mini\"],\n )\n\n\nif __name__ == \"__main__\":\n unittest.main()\n"}, "695365b3d8bc4f9e897e276021b916b5023f1132": {"t": 1785754704.5719268, "n": 23298, "h": "25b6d61e81434278", "c": "# -*- coding: utf-8 -*-\n\"\"\"Tests for env-based LLM channel parsing.\"\"\"\n\nimport os\nimport unittest\nfrom unittest.mock import patch\n\nfrom src.config import (\n ANSPIRE_LLM_BASE_URL_DEFAULT,\n ANSPIRE_LLM_MODEL_DEFAULT,\n Config,\n get_effective_agent_models_to_try,\n get_effective_agent_primary_model,\n get_fixed_litellm_temperature,\n normalize_litellm_temperature,\n)\n\n\nclass LLMChannelConfigTestCase(unittest.TestCase):\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_anspire_key_enables_openai_compatible_legacy_model(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"ANSPIRE_API_KEYS\": \"sk-anspire-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.anspire_api_keys, [\"sk-anspire-test-value\"])\n self.assertEqual(config.openai_api_keys, [\"sk-anspire-test-value\"])\n self.assertEqual(config.openai_base_url, ANSPIRE_LLM_BASE_URL_DEFAULT)\n self.assertEqual(config.litellm_model, f\"openai/{ANSPIRE_LLM_MODEL_DEFAULT}\")\n self.assertEqual(config.llm_models_source, \"legacy_env\")\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"__legacy_openai__\")\n self.assertEqual(params[\"api_base\"], ANSPIRE_LLM_BASE_URL_DEFAULT)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_anspire_legacy_overrides_stale_openai_base_url(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"ANSPIRE_API_KEYS\": \"sk-anspire-test-value\",\n \"OPENAI_BASE_URL\": \"https://stale-openai-compatible.example/v1\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.openai_api_keys, [\"sk-anspire-test-value\"])\n self.assertEqual(config.openai_base_url, ANSPIRE_LLM_BASE_URL_DEFAULT)\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"api_base\"], ANSPIRE_LLM_BASE_URL_DEFAULT)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_anspire_channel_reuses_shared_key_and_defaults(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"anspire\",\n \"ANSPIRE_API_KEYS\": \"sk-anspire-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"openai\")\n self.assertEqual(config.llm_channels[0][\"api_keys\"], [\"sk-anspire-test-value\"])\n self.assertEqual(config.llm_channels[0][\"models\"], [f\"openai/{ANSPIRE_LLM_MODEL_DEFAULT}\"])\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"api_base\"], ANSPIRE_LLM_BASE_URL_DEFAULT)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_blank_anspire_channel_enabled_uses_shared_disable_flag(\n self,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"LLM_CHANNELS\": \"anspire\",\n \"LLM_ANSPIRE_ENABLED\": \" \",\n \"ANSPIRE_LLM_ENABLED\": \"false\",\n \"ANSPIRE_API_KEYS\": \"sk-anspire-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.openai_api_keys, [])\n self.assertEqual(config.llm_channels, [])\n self.assertEqual(config.llm_model_list, [])\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_disabled_anspire_channel_does_not_fall_back_to_legacy(\n self,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"LLM_CHANNELS\": \"anspire\",\n \"LLM_ANSPIRE_ENABLED\": \"false\",\n \"ANSPIRE_API_KEYS\": \"sk-anspire-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.openai_api_keys, [])\n self.assertEqual(config.llm_channels, [])\n self.assertEqual(config.llm_model_list, [])\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_protocol_prefixes_bare_model_names(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"deepseek\",\n \"LLM_PRIMARY_BASE_URL\": \"https://api.deepseek.com/v1\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"deepseek\")\n self.assertEqual(config.llm_channels[0][\"models\"], [\"deepseek/deepseek-chat\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"deepseek/deepseek-chat\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_openai_compatible_channel_prefixes_non_provider_slash_models(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"siliconflow\",\n \"LLM_SILICONFLOW_PROTOCOL\": \"openai\",\n \"LLM_SILICONFLOW_BASE_URL\": \"https://api.siliconflow.cn/v1\",\n \"LLM_SILICONFLOW_API_KEY\": \"sk-test-value\",\n \"LLM_SILICONFLOW_MODELS\": \"Qwen/Qwen3-8B,deepseek-ai/DeepSeek-V3\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n config.llm_channels[0][\"models\"],\n [\"openai/Qwen/Qwen3-8B\", \"openai/deepseek-ai/DeepSeek-V3\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_alias_prefixed_models_are_canonicalized_once(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"vertex\",\n \"LLM_VERTEX_PROTOCOL\": \"vertex_ai\",\n \"LLM_VERTEX_API_KEY\": \"sk-test-value\",\n \"LLM_VERTEX_MODELS\": \"vertexai/gemini-2.5-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels[0][\"models\"], [\"vertex_ai/gemini-2.5-flash\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"vertex_ai/gemini-2.5-flash\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_minimax_prefixed_models_are_not_rewritten_for_openai_compatible_channels(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_BASE_URL\": \"https://api.example.com/v1\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"minimax/MiniMax-M1\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels[0][\"models\"], [\"minimax/MiniMax-M1\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"minimax/MiniMax-M1\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_disabled_channel_is_skipped(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_ENABLED\": \"false\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels, [])\n self.assertEqual(config.llm_model_list, [])\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_local_ollama_channel_can_skip_api_key(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"local\",\n \"LLM_LOCAL_PROTOCOL\": \"ollama\",\n \"LLM_LOCAL_BASE_URL\": \"http://127.0.0.1:11434\",\n \"LLM_LOCAL_API_KEY\": \"\",\n \"LLM_LOCAL_MODELS\": \"llama3.2\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"ollama/llama3.2\")\n self.assertNotIn(\"api_key\", params)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_legacy_provider_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"GEMINI_TEMPERATURE\": \"0.15\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"gemini/gemini-3.1-pro-preview\")\n self.assertAlmostEqual(config.llm_temperature, 0.15)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_defaults_to_legacy_chat_model_with_deprecation_warning(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"deepseek/deepseek-chat\")\n mock_warning.assert_called_once_with(\n \"Deprecation warning:\\n\"\n \"deepseek-chat will be deprecated on 2026-07-24,\\n\"\n \"please migrate to deepseek-v4-flash.\"\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_explicit_deepseek_litellm_model_is_preserved(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"deepseek/deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"deepseek/deepseek-chat\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_does_not_warn_when_channels_take_precedence(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"deepseek\",\n \"LLM_PRIMARY_API_KEY\": \"sk-channel-value\",\n \"LLM_PRIMARY_MODELS\": \"deepseek-v4-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(\n Config,\n \"_parse_litellm_yaml\",\n return_value=[\n {\n \"model_name\": \"primary\",\n \"litellm_params\": {\n \"model\": \"deepseek/deepseek-v4-flash\",\n \"api_key\": \"sk-yaml-value\",\n },\n }\n ],\n )\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_does_not_warn_when_litellm_yaml_takes_precedence(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LITELLM_CONFIG\": \"/tmp/litellm.yaml\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"litellm_config\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_prefers_unified_setting_when_present(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"GEMINI_TEMPERATURE\": \"0.15\",\n \"LLM_TEMPERATURE\": \"0.35\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.35)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_openai_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o\",\n \"LITELLM_MODEL\": \"openai/gpt-4o\",\n \"OPENAI_TEMPERATURE\": \"0.42\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.42)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_any_legacy_when_provider_mismatch(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o\",\n \"LITELLM_MODEL\": \"openai/gpt-4o\",\n \"ANTHROPIC_TEMPERATURE\": \"0.55\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.55)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_ignores_invalid_value(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"LLM_TEMPERATURE\": \"high\",\n \"GEMINI_TEMPERATURE\": \"0.25\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.25)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_kimi_k26_keeps_raw_configured_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"kimi-k2.6\",\n \"LLM_TEMPERATURE\": \"0.7\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"openai/kimi-k2.6\")\n self.assertAlmostEqual(config.llm_temperature, 0.7)\n\n def test_kimi_k26_temperature_normalization_handles_provider_wrappers(self) -> None:\n self.assertAlmostEqual(get_fixed_litellm_temperature(\"moonshot/kimi-k2.6\"), 1.0)\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/moonshot/kimi-k2.6\", 0.2), 1.0)\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/kimi-k2.6-preview\", 0.2), 1.0)\n self.assertAlmostEqual(\n normalize_litellm_temperature(\n \"openai/kimi-k2.6-preview\",\n 0.2,\n request_overrides={\"extra_body\": {\"thinking\": {\"type\": \"disabled\"}}},\n ),\n 0.6,\n )\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/gpt-4o-mini\", 0.2), 0.2)\n\n def test_kimi_k26_temperature_normalization_resolves_litellm_yaml_alias(self) -> None:\n model_list = [\n {\n \"model_name\": \"kimi_router\",\n \"litellm_params\": {\n \"model\": \"openai/kimi-k2.6\",\n \"api_key\": \"sk-yaml-value\",\n },\n }\n ]\n\n self.assertAlmostEqual(get_fixed_litellm_temperature(\"kimi_router\", model_list=model_list), 1.0)\n self.assertAlmostEqual(\n normalize_litellm_temperature(\"kimi_router\", 0.2, model_list=model_list),\n 1.0,\n )\n\n def test_kimi_k26_temperature_normalization_uses_non_thinking_yaml_alias_temperature(self) -> None:\n model_list = [\n {\n \"model_name\": \"kimi_router\",\n \"litellm_params\": {\n \"model\": \"openai/kimi-k2.6\",\n \"api_key\": \"sk-yaml-value\",\n \"extra_body\": {\"thinking\": {\"type\": \"disabled\"}},\n },\n }\n ]\n\n self.assertAlmostEqual(\n get_fixed_litellm_temperature(\"kimi_router\", model_list=model_list),\n 0.6,\n )\n self.assertAlmostEqual(\n normalize_litellm_temperature(\"kimi_router\", 0.2, model_list=model_list),\n 0.6,\n )\n\n def test_kimi_k26_temperature_normalization_uses_non_thinking_yaml_wire_model_without_model_name(self) -> None:\n model_list = [\n {\n \"litellm_params\": {\n \"model\": \"openai/kimi-k2.6\",\n \"api_key\": \"sk-yaml-value\",\n \"extra_body\": {\"thinking\": {\"type\": \"disabled\"}},\n },\n }\n ]\n\n self.assertAlmostEqual(\n get_fixed_litellm_temperature(\"openai/kimi-k2.6\", model_list=model_list),\n 0.6,\n )\n self.assertAlmostEqual(\n normalize_litellm_temperature(\"openai/kimi-k2.6\", 0.2, model_list=model_list),\n 0.6,\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_local_openai_compatible_channel_defaults_to_openai_protocol(self, _mock_parse_yaml, _mock_setup_env) -> None:\n \"\"\"Localhost channels without explicit protocol should default to openai, not ollama.\"\"\"\n env = {\n \"LLM_CHANNELS\": \"local\",\n \"LLM_LOCAL_BASE_URL\": \"http://127.0.0.1:8000/v1\",\n \"LLM_LOCAL_API_KEY\": \"not-needed\",\n \"LLM_LOCAL_MODELS\": \"my-model\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"openai/my-model\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"openai\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_model_empty_inherits_primary_model(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"gpt-4o-mini\",\n \"AGENT_LITELLM_MODEL\": \"\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"\")\n self.assertEqual(get_effective_agent_primary_model(config), \"openai/gpt-4o-mini\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_model_without_provider_prefix_is_normalized(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"gpt-4o-mini\",\n \"AGENT_LITELLM_MODEL\": \"deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"openai/deepseek-chat\")\n self.assertEqual(get_effective_agent_primary_model(config), \"openai/deepseek-chat\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_models_to_try_are_deduped_in_order(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"gemini/gemini-2.5-flash\",\n \"AGENT_LITELLM_MODEL\": \"openai/gpt-4o-mini\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini,openai/gpt-4o-mini,gemini/gemini-2.5-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"openai/gpt-4o-mini\", \"gemini/gemini-2.5-flash\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_models_to_try_dedupes_semantically_equivalent_openai_models(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"gemini/gemini-2.5-flash\",\n \"AGENT_LITELLM_MODEL\": \"gpt-4o-mini\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini,gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"openai/gpt-4o-mini\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(\n Config,\n \"_parse_litellm_yaml\",\n return_value=[\n {\n \"model_name\": \"gpt4o\",\n \"litellm_params\": {\n \"model\": \"openai/gpt-4o-mini\",\n \"api_key\": \"sk-test-value\",\n },\n }\n ],\n )\n def test_agent_model_preserves_yaml_alias_without_provider_prefix(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LITELLM_CONFIG\": \"/tmp/litellm.yaml\",\n \"AGENT_LITELLM_MODEL\": \"gpt4o\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"gpt4o\")\n self.assertEqual(get_effective_agent_primary_model(config), \"gpt4o\")\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"gpt4o\", \"openai/gpt-4o-mini\"],\n )\n\n\nif __name__ == \"__main__\":\n unittest.main()\n"}, "205795a325b6ca6924450754f11e1c2ca2bfeb52": {"t": 1785754705.8882327, "n": 19206, "h": "09739af57f7ecbc8", "c": "# -*- coding: utf-8 -*-\n\"\"\"Tests for env-based LLM channel parsing.\"\"\"\n\nimport os\nimport unittest\nfrom unittest.mock import patch\n\nfrom src.config import (\n Config,\n get_effective_agent_models_to_try,\n get_effective_agent_primary_model,\n get_fixed_litellm_temperature,\n normalize_litellm_temperature,\n)\n\n\nclass LLMChannelConfigTestCase(unittest.TestCase):\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_protocol_prefixes_bare_model_names(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"deepseek\",\n \"LLM_PRIMARY_BASE_URL\": \"https://api.deepseek.com/v1\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"deepseek\")\n self.assertEqual(config.llm_channels[0][\"models\"], [\"deepseek/deepseek-chat\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"deepseek/deepseek-chat\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_openai_compatible_channel_prefixes_non_provider_slash_models(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"siliconflow\",\n \"LLM_SILICONFLOW_PROTOCOL\": \"openai\",\n \"LLM_SILICONFLOW_BASE_URL\": \"https://api.siliconflow.cn/v1\",\n \"LLM_SILICONFLOW_API_KEY\": \"sk-test-value\",\n \"LLM_SILICONFLOW_MODELS\": \"Qwen/Qwen3-8B,deepseek-ai/DeepSeek-V3\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n config.llm_channels[0][\"models\"],\n [\"openai/Qwen/Qwen3-8B\", \"openai/deepseek-ai/DeepSeek-V3\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_alias_prefixed_models_are_canonicalized_once(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"vertex\",\n \"LLM_VERTEX_PROTOCOL\": \"vertex_ai\",\n \"LLM_VERTEX_API_KEY\": \"sk-test-value\",\n \"LLM_VERTEX_MODELS\": \"vertexai/gemini-2.5-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels[0][\"models\"], [\"vertex_ai/gemini-2.5-flash\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"vertex_ai/gemini-2.5-flash\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_minimax_prefixed_models_are_not_rewritten_for_openai_compatible_channels(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_BASE_URL\": \"https://api.example.com/v1\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"minimax/MiniMax-M1\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels[0][\"models\"], [\"minimax/MiniMax-M1\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"minimax/MiniMax-M1\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_disabled_channel_is_skipped(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_ENABLED\": \"false\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels, [])\n self.assertEqual(config.llm_model_list, [])\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_local_ollama_channel_can_skip_api_key(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"local\",\n \"LLM_LOCAL_PROTOCOL\": \"ollama\",\n \"LLM_LOCAL_BASE_URL\": \"http://127.0.0.1:11434\",\n \"LLM_LOCAL_API_KEY\": \"\",\n \"LLM_LOCAL_MODELS\": \"llama3.2\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"ollama/llama3.2\")\n self.assertNotIn(\"api_key\", params)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_legacy_provider_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"GEMINI_TEMPERATURE\": \"0.15\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"gemini/gemini-3.1-pro-preview\")\n self.assertAlmostEqual(config.llm_temperature, 0.15)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_defaults_to_legacy_chat_model_with_deprecation_warning(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"deepseek/deepseek-chat\")\n mock_warning.assert_called_once_with(\n \"Deprecation warning:\\n\"\n \"deepseek-chat will be deprecated on 2026-07-24,\\n\"\n \"please migrate to deepseek-v4-flash.\"\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_explicit_deepseek_litellm_model_is_preserved(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"deepseek/deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"deepseek/deepseek-chat\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_does_not_warn_when_channels_take_precedence(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"deepseek\",\n \"LLM_PRIMARY_API_KEY\": \"sk-channel-value\",\n \"LLM_PRIMARY_MODELS\": \"deepseek-v4-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(\n Config,\n \"_parse_litellm_yaml\",\n return_value=[\n {\n \"model_name\": \"primary\",\n \"litellm_params\": {\n \"model\": \"deepseek/deepseek-v4-flash\",\n \"api_key\": \"sk-yaml-value\",\n },\n }\n ],\n )\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_does_not_warn_when_litellm_yaml_takes_precedence(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LITELLM_CONFIG\": \"/tmp/litellm.yaml\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"litellm_config\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_prefers_unified_setting_when_present(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"GEMINI_TEMPERATURE\": \"0.15\",\n \"LLM_TEMPERATURE\": \"0.35\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.35)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_openai_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o\",\n \"LITELLM_MODEL\": \"openai/gpt-4o\",\n \"OPENAI_TEMPERATURE\": \"0.42\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.42)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_any_legacy_when_provider_mismatch(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o\",\n \"LITELLM_MODEL\": \"openai/gpt-4o\",\n \"ANTHROPIC_TEMPERATURE\": \"0.55\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.55)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_ignores_invalid_value(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"LLM_TEMPERATURE\": \"high\",\n \"GEMINI_TEMPERATURE\": \"0.25\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.25)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_kimi_k26_keeps_raw_configured_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"kimi-k2.6\",\n \"LLM_TEMPERATURE\": \"0.7\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"openai/kimi-k2.6\")\n self.assertAlmostEqual(config.llm_temperature, 0.7)\n\n def test_kimi_k26_temperature_normalization_handles_provider_wrappers(self) -> None:\n self.assertAlmostEqual(get_fixed_litellm_temperature(\"moonshot/kimi-k2.6\"), 1.0)\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/moonshot/kimi-k2.6\", 0.2), 1.0)\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/kimi-k2.6-preview\", 0.2), 1.0)\n self.assertAlmostEqual(\n normalize_litellm_temperature(\n \"openai/kimi-k2.6-preview\",\n 0.2,\n request_overrides={\"extra_body\": {\"thinking\": {\"type\": \"disabled\"}}},\n ),\n 0.6,\n )\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/gpt-4o-mini\", 0.2), 0.2)\n\n def test_kimi_k26_temperature_normalization_resolves_litellm_yaml_alias(self) -> None:\n model_list = [\n {\n \"model_name\": \"kimi_router\",\n \"litellm_params\": {\n \"model\": \"openai/kimi-k2.6\",\n \"api_key\": \"sk-yaml-value\",\n },\n }\n ]\n\n self.assertAlmostEqual(get_fixed_litellm_temperature(\"kimi_router\", model_list=model_list), 1.0)\n self.assertAlmostEqual(\n normalize_litellm_temperature(\"kimi_router\", 0.2, model_list=model_list),\n 1.0,\n )\n\n def test_kimi_k26_temperature_normalization_uses_non_thinking_yaml_alias_temperature(self) -> None:\n model_list = [\n {\n \"model_name\": \"kimi_router\",\n \"litellm_params\": {\n \"model\": \"openai/kimi-k2.6\",\n \"api_key\": \"sk-yaml-value\",\n \"extra_body\": {\"thinking\": {\"type\": \"disabled\"}},\n },\n }\n ]\n\n self.assertAlmostEqual(\n get_fixed_litellm_temperature(\"kimi_router\", model_list=model_list),\n 0.6,\n )\n self.assertAlmostEqual(\n normalize_litellm_temperature(\"kimi_router\", 0.2, model_list=model_list),\n 0.6,\n )\n\n def test_kimi_k26_temperature_normalization_uses_non_thinking_yaml_wire_model_without_model_name(self) -> None:\n model_list = [\n {\n \"litellm_params\": {\n \"model\": \"openai/kimi-k2.6\",\n \"api_key\": \"sk-yaml-value\",\n \"extra_body\": {\"thinking\": {\"type\": \"disabled\"}},\n },\n }\n ]\n\n self.assertAlmostEqual(\n get_fixed_litellm_temperature(\"openai/kimi-k2.6\", model_list=model_list),\n 0.6,\n )\n self.assertAlmostEqual(\n normalize_litellm_temperature(\"openai/kimi-k2.6\", 0.2, model_list=model_list),\n 0.6,\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_local_openai_compatible_channel_defaults_to_openai_protocol(self, _mock_parse_yaml, _mock_setup_env) -> None:\n \"\"\"Localhost channels without explicit protocol should default to openai, not ollama.\"\"\"\n env = {\n \"LLM_CHANNELS\": \"local\",\n \"LLM_LOCAL_BASE_URL\": \"http://127.0.0.1:8000/v1\",\n \"LLM_LOCAL_API_KEY\": \"not-needed\",\n \"LLM_LOCAL_MODELS\": \"my-model\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"openai/my-model\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"openai\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_model_empty_inherits_primary_model(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"gpt-4o-mini\",\n \"AGENT_LITELLM_MODEL\": \"\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"\")\n self.assertEqual(get_effective_agent_primary_model(config), \"openai/gpt-4o-mini\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_model_without_provider_prefix_is_normalized(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"gpt-4o-mini\",\n \"AGENT_LITELLM_MODEL\": \"deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"openai/deepseek-chat\")\n self.assertEqual(get_effective_agent_primary_model(config), \"openai/deepseek-chat\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_models_to_try_are_deduped_in_order(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"gemini/gemini-2.5-flash\",\n \"AGENT_LITELLM_MODEL\": \"openai/gpt-4o-mini\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini,openai/gpt-4o-mini,gemini/gemini-2.5-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"openai/gpt-4o-mini\", \"gemini/gemini-2.5-flash\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_models_to_try_dedupes_semantically_equivalent_openai_models(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"gemini/gemini-2.5-flash\",\n \"AGENT_LITELLM_MODEL\": \"gpt-4o-mini\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini,gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"openai/gpt-4o-mini\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(\n Config,\n \"_parse_litellm_yaml\",\n return_value=[\n {\n \"model_name\": \"gpt4o\",\n \"litellm_params\": {\n \"model\": \"openai/gpt-4o-mini\",\n \"api_key\": \"sk-test-value\",\n },\n }\n ],\n )\n def test_agent_model_preserves_yaml_alias_without_provider_prefix(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LITELLM_CONFIG\": \"/tmp/litellm.yaml\",\n \"AGENT_LITELLM_MODEL\": \"gpt4o\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"gpt4o\")\n self.assertEqual(get_effective_agent_primary_model(config), \"gpt4o\")\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"gpt4o\", \"openai/gpt-4o-mini\"],\n )\n\n\nif __name__ == \"__main__\":\n unittest.main()\n"}, "4574c52b045016282f3915d29428b75411984f68": {"t": 1785754707.8678403, "n": 19206, "h": "cb601c7739ec5eeb", "c": "# -*- coding: utf-8 -*-\n\"\"\"Tests for env-based LLM channel parsing.\"\"\"\n\nimport os\nimport unittest\nfrom unittest.mock import patch\n\nfrom src.config import (\n Config,\n get_effective_agent_models_to_try,\n get_effective_agent_primary_model,\n get_fixed_litellm_temperature,\n normalize_litellm_temperature,\n)\n\n\nclass LLMChannelConfigTestCase(unittest.TestCase):\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_protocol_prefixes_bare_model_names(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"deepseek\",\n \"LLM_PRIMARY_BASE_URL\": \"https://api.deepseek.com/v1\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"deepseek\")\n self.assertEqual(config.llm_channels[0][\"models\"], [\"deepseek/deepseek-chat\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"deepseek/deepseek-chat\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_openai_compatible_channel_prefixes_non_provider_slash_models(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"siliconflow\",\n \"LLM_SILICONFLOW_PROTOCOL\": \"openai\",\n \"LLM_SILICONFLOW_BASE_URL\": \"https://api.siliconflow.cn/v1\",\n \"LLM_SILICONFLOW_API_KEY\": \"sk-test-value\",\n \"LLM_SILICONFLOW_MODELS\": \"Qwen/Qwen3-8B,deepseek-ai/DeepSeek-V3\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n config.llm_channels[0][\"models\"],\n [\"openai/Qwen/Qwen3-8B\", \"openai/deepseek-ai/DeepSeek-V3\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_alias_prefixed_models_are_canonicalized_once(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"vertex\",\n \"LLM_VERTEX_PROTOCOL\": \"vertex_ai\",\n \"LLM_VERTEX_API_KEY\": \"sk-test-value\",\n \"LLM_VERTEX_MODELS\": \"vertexai/gemini-2.5-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels[0][\"models\"], [\"vertex_ai/gemini-2.5-flash\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"vertex_ai/gemini-2.5-flash\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_minimax_prefixed_models_are_not_rewritten_for_openai_compatible_channels(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_BASE_URL\": \"https://api.example.com/v1\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"minimax/MiniMax-M1\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels[0][\"models\"], [\"minimax/MiniMax-M1\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"minimax/MiniMax-M1\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_disabled_channel_is_skipped(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_ENABLED\": \"false\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels, [])\n self.assertEqual(config.llm_model_list, [])\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_local_ollama_channel_can_skip_api_key(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"local\",\n \"LLM_LOCAL_PROTOCOL\": \"ollama\",\n \"LLM_LOCAL_BASE_URL\": \"http://127.0.0.1:11434\",\n \"LLM_LOCAL_API_KEY\": \"\",\n \"LLM_LOCAL_MODELS\": \"llama3.2\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"ollama/llama3.2\")\n self.assertNotIn(\"api_key\", params)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_legacy_provider_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"GEMINI_TEMPERATURE\": \"0.15\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"gemini/gemini-3-flash-preview\")\n self.assertAlmostEqual(config.llm_temperature, 0.15)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_defaults_to_legacy_chat_model_with_deprecation_warning(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"deepseek/deepseek-chat\")\n mock_warning.assert_called_once_with(\n \"Deprecation warning:\\n\"\n \"deepseek-chat will be deprecated on 2026-07-24,\\n\"\n \"please migrate to deepseek-v4-flash.\"\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_explicit_deepseek_litellm_model_is_preserved(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"deepseek/deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"deepseek/deepseek-chat\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_does_not_warn_when_channels_take_precedence(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"deepseek\",\n \"LLM_PRIMARY_API_KEY\": \"sk-channel-value\",\n \"LLM_PRIMARY_MODELS\": \"deepseek-v4-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(\n Config,\n \"_parse_litellm_yaml\",\n return_value=[\n {\n \"model_name\": \"primary\",\n \"litellm_params\": {\n \"model\": \"deepseek/deepseek-v4-flash\",\n \"api_key\": \"sk-yaml-value\",\n },\n }\n ],\n )\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_does_not_warn_when_litellm_yaml_takes_precedence(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LITELLM_CONFIG\": \"/tmp/litellm.yaml\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"litellm_config\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_prefers_unified_setting_when_present(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"GEMINI_TEMPERATURE\": \"0.15\",\n \"LLM_TEMPERATURE\": \"0.35\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.35)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_openai_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o\",\n \"LITELLM_MODEL\": \"openai/gpt-4o\",\n \"OPENAI_TEMPERATURE\": \"0.42\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.42)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_any_legacy_when_provider_mismatch(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o\",\n \"LITELLM_MODEL\": \"openai/gpt-4o\",\n \"ANTHROPIC_TEMPERATURE\": \"0.55\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.55)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_ignores_invalid_value(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"LLM_TEMPERATURE\": \"high\",\n \"GEMINI_TEMPERATURE\": \"0.25\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.25)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_kimi_k26_keeps_raw_configured_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"kimi-k2.6\",\n \"LLM_TEMPERATURE\": \"0.7\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"openai/kimi-k2.6\")\n self.assertAlmostEqual(config.llm_temperature, 0.7)\n\n def test_kimi_k26_temperature_normalization_handles_provider_wrappers(self) -> None:\n self.assertAlmostEqual(get_fixed_litellm_temperature(\"moonshot/kimi-k2.6\"), 1.0)\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/moonshot/kimi-k2.6\", 0.2), 1.0)\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/kimi-k2.6-preview\", 0.2), 1.0)\n self.assertAlmostEqual(\n normalize_litellm_temperature(\n \"openai/kimi-k2.6-preview\",\n 0.2,\n request_overrides={\"extra_body\": {\"thinking\": {\"type\": \"disabled\"}}},\n ),\n 0.6,\n )\n self.assertAlmostEqual(normalize_litellm_temperature(\"openai/gpt-4o-mini\", 0.2), 0.2)\n\n def test_kimi_k26_temperature_normalization_resolves_litellm_yaml_alias(self) -> None:\n model_list = [\n {\n \"model_name\": \"kimi_router\",\n \"litellm_params\": {\n \"model\": \"openai/kimi-k2.6\",\n \"api_key\": \"sk-yaml-value\",\n },\n }\n ]\n\n self.assertAlmostEqual(get_fixed_litellm_temperature(\"kimi_router\", model_list=model_list), 1.0)\n self.assertAlmostEqual(\n normalize_litellm_temperature(\"kimi_router\", 0.2, model_list=model_list),\n 1.0,\n )\n\n def test_kimi_k26_temperature_normalization_uses_non_thinking_yaml_alias_temperature(self) -> None:\n model_list = [\n {\n \"model_name\": \"kimi_router\",\n \"litellm_params\": {\n \"model\": \"openai/kimi-k2.6\",\n \"api_key\": \"sk-yaml-value\",\n \"extra_body\": {\"thinking\": {\"type\": \"disabled\"}},\n },\n }\n ]\n\n self.assertAlmostEqual(\n get_fixed_litellm_temperature(\"kimi_router\", model_list=model_list),\n 0.6,\n )\n self.assertAlmostEqual(\n normalize_litellm_temperature(\"kimi_router\", 0.2, model_list=model_list),\n 0.6,\n )\n\n def test_kimi_k26_temperature_normalization_uses_non_thinking_yaml_wire_model_without_model_name(self) -> None:\n model_list = [\n {\n \"litellm_params\": {\n \"model\": \"openai/kimi-k2.6\",\n \"api_key\": \"sk-yaml-value\",\n \"extra_body\": {\"thinking\": {\"type\": \"disabled\"}},\n },\n }\n ]\n\n self.assertAlmostEqual(\n get_fixed_litellm_temperature(\"openai/kimi-k2.6\", model_list=model_list),\n 0.6,\n )\n self.assertAlmostEqual(\n normalize_litellm_temperature(\"openai/kimi-k2.6\", 0.2, model_list=model_list),\n 0.6,\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_local_openai_compatible_channel_defaults_to_openai_protocol(self, _mock_parse_yaml, _mock_setup_env) -> None:\n \"\"\"Localhost channels without explicit protocol should default to openai, not ollama.\"\"\"\n env = {\n \"LLM_CHANNELS\": \"local\",\n \"LLM_LOCAL_BASE_URL\": \"http://127.0.0.1:8000/v1\",\n \"LLM_LOCAL_API_KEY\": \"not-needed\",\n \"LLM_LOCAL_MODELS\": \"my-model\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"openai/my-model\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"openai\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_model_empty_inherits_primary_model(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"gpt-4o-mini\",\n \"AGENT_LITELLM_MODEL\": \"\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"\")\n self.assertEqual(get_effective_agent_primary_model(config), \"openai/gpt-4o-mini\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_model_without_provider_prefix_is_normalized(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"gpt-4o-mini\",\n \"AGENT_LITELLM_MODEL\": \"deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"openai/deepseek-chat\")\n self.assertEqual(get_effective_agent_primary_model(config), \"openai/deepseek-chat\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_models_to_try_are_deduped_in_order(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"gemini/gemini-2.5-flash\",\n \"AGENT_LITELLM_MODEL\": \"openai/gpt-4o-mini\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini,openai/gpt-4o-mini,gemini/gemini-2.5-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"openai/gpt-4o-mini\", \"gemini/gemini-2.5-flash\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_models_to_try_dedupes_semantically_equivalent_openai_models(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"gemini/gemini-2.5-flash\",\n \"AGENT_LITELLM_MODEL\": \"gpt-4o-mini\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini,gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"openai/gpt-4o-mini\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(\n Config,\n \"_parse_litellm_yaml\",\n return_value=[\n {\n \"model_name\": \"gpt4o\",\n \"litellm_params\": {\n \"model\": \"openai/gpt-4o-mini\",\n \"api_key\": \"sk-test-value\",\n },\n }\n ],\n )\n def test_agent_model_preserves_yaml_alias_without_provider_prefix(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LITELLM_CONFIG\": \"/tmp/litellm.yaml\",\n \"AGENT_LITELLM_MODEL\": \"gpt4o\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"gpt4o\")\n self.assertEqual(get_effective_agent_primary_model(config), \"gpt4o\")\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"gpt4o\", \"openai/gpt-4o-mini\"],\n )\n\n\nif __name__ == \"__main__\":\n unittest.main()\n"}, "9a08ba47faa92eebe7ed47617be2a8fdbf3a8a03": {"t": 1785754709.6873822, "n": 15791, "h": "ac5af4b4e7fcccd9", "c": "# -*- coding: utf-8 -*-\n\"\"\"Tests for env-based LLM channel parsing.\"\"\"\n\nimport os\nimport unittest\nfrom unittest.mock import patch\n\nfrom src.config import (\n Config,\n get_effective_agent_models_to_try,\n get_effective_agent_primary_model,\n)\n\n\nclass LLMChannelConfigTestCase(unittest.TestCase):\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_protocol_prefixes_bare_model_names(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"deepseek\",\n \"LLM_PRIMARY_BASE_URL\": \"https://api.deepseek.com/v1\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"deepseek\")\n self.assertEqual(config.llm_channels[0][\"models\"], [\"deepseek/deepseek-chat\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"deepseek/deepseek-chat\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_openai_compatible_channel_prefixes_non_provider_slash_models(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"siliconflow\",\n \"LLM_SILICONFLOW_PROTOCOL\": \"openai\",\n \"LLM_SILICONFLOW_BASE_URL\": \"https://api.siliconflow.cn/v1\",\n \"LLM_SILICONFLOW_API_KEY\": \"sk-test-value\",\n \"LLM_SILICONFLOW_MODELS\": \"Qwen/Qwen3-8B,deepseek-ai/DeepSeek-V3\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n config.llm_channels[0][\"models\"],\n [\"openai/Qwen/Qwen3-8B\", \"openai/deepseek-ai/DeepSeek-V3\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_alias_prefixed_models_are_canonicalized_once(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"vertex\",\n \"LLM_VERTEX_PROTOCOL\": \"vertex_ai\",\n \"LLM_VERTEX_API_KEY\": \"sk-test-value\",\n \"LLM_VERTEX_MODELS\": \"vertexai/gemini-2.5-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels[0][\"models\"], [\"vertex_ai/gemini-2.5-flash\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"vertex_ai/gemini-2.5-flash\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_minimax_prefixed_models_are_not_rewritten_for_openai_compatible_channels(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_BASE_URL\": \"https://api.example.com/v1\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"minimax/MiniMax-M1\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels[0][\"models\"], [\"minimax/MiniMax-M1\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"minimax/MiniMax-M1\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_disabled_channel_is_skipped(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_ENABLED\": \"false\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels, [])\n self.assertEqual(config.llm_model_list, [])\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_local_ollama_channel_can_skip_api_key(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"local\",\n \"LLM_LOCAL_PROTOCOL\": \"ollama\",\n \"LLM_LOCAL_BASE_URL\": \"http://127.0.0.1:11434\",\n \"LLM_LOCAL_API_KEY\": \"\",\n \"LLM_LOCAL_MODELS\": \"llama3.2\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"ollama/llama3.2\")\n self.assertNotIn(\"api_key\", params)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_legacy_provider_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"GEMINI_TEMPERATURE\": \"0.15\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"gemini/gemini-3-flash-preview\")\n self.assertAlmostEqual(config.llm_temperature, 0.15)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_defaults_to_legacy_chat_model_with_deprecation_warning(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"deepseek/deepseek-chat\")\n mock_warning.assert_called_once_with(\n \"Deprecation warning:\\n\"\n \"deepseek-chat will be deprecated on 2026-07-24,\\n\"\n \"please migrate to deepseek-v4-flash.\"\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_explicit_deepseek_litellm_model_is_preserved(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"deepseek/deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"deepseek/deepseek-chat\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_does_not_warn_when_channels_take_precedence(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"deepseek\",\n \"LLM_PRIMARY_API_KEY\": \"sk-channel-value\",\n \"LLM_PRIMARY_MODELS\": \"deepseek-v4-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(\n Config,\n \"_parse_litellm_yaml\",\n return_value=[\n {\n \"model_name\": \"primary\",\n \"litellm_params\": {\n \"model\": \"deepseek/deepseek-v4-flash\",\n \"api_key\": \"sk-yaml-value\",\n },\n }\n ],\n )\n @patch(\"src.config.logger.warning\")\n def test_deepseek_key_does_not_warn_when_litellm_yaml_takes_precedence(\n self,\n mock_warning,\n _mock_parse_yaml,\n _mock_setup_env,\n ) -> None:\n env = {\n \"DEEPSEEK_API_KEY\": \"sk-test-value\",\n \"LITELLM_CONFIG\": \"/tmp/litellm.yaml\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"litellm_config\")\n mock_warning.assert_not_called()\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_prefers_unified_setting_when_present(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"GEMINI_TEMPERATURE\": \"0.15\",\n \"LLM_TEMPERATURE\": \"0.35\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.35)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_openai_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o\",\n \"LITELLM_MODEL\": \"openai/gpt-4o\",\n \"OPENAI_TEMPERATURE\": \"0.42\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.42)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_any_legacy_when_provider_mismatch(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o\",\n \"LITELLM_MODEL\": \"openai/gpt-4o\",\n \"ANTHROPIC_TEMPERATURE\": \"0.55\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.55)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_ignores_invalid_value(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"LLM_TEMPERATURE\": \"high\",\n \"GEMINI_TEMPERATURE\": \"0.25\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.25)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_local_openai_compatible_channel_defaults_to_openai_protocol(self, _mock_parse_yaml, _mock_setup_env) -> None:\n \"\"\"Localhost channels without explicit protocol should default to openai, not ollama.\"\"\"\n env = {\n \"LLM_CHANNELS\": \"local\",\n \"LLM_LOCAL_BASE_URL\": \"http://127.0.0.1:8000/v1\",\n \"LLM_LOCAL_API_KEY\": \"not-needed\",\n \"LLM_LOCAL_MODELS\": \"my-model\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"openai/my-model\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"openai\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_model_empty_inherits_primary_model(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"gpt-4o-mini\",\n \"AGENT_LITELLM_MODEL\": \"\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"\")\n self.assertEqual(get_effective_agent_primary_model(config), \"openai/gpt-4o-mini\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_model_without_provider_prefix_is_normalized(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"gpt-4o-mini\",\n \"AGENT_LITELLM_MODEL\": \"deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"openai/deepseek-chat\")\n self.assertEqual(get_effective_agent_primary_model(config), \"openai/deepseek-chat\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_models_to_try_are_deduped_in_order(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"gemini/gemini-2.5-flash\",\n \"AGENT_LITELLM_MODEL\": \"openai/gpt-4o-mini\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini,openai/gpt-4o-mini,gemini/gemini-2.5-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"openai/gpt-4o-mini\", \"gemini/gemini-2.5-flash\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_models_to_try_dedupes_semantically_equivalent_openai_models(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"gemini/gemini-2.5-flash\",\n \"AGENT_LITELLM_MODEL\": \"gpt-4o-mini\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini,gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"openai/gpt-4o-mini\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(\n Config,\n \"_parse_litellm_yaml\",\n return_value=[\n {\n \"model_name\": \"gpt4o\",\n \"litellm_params\": {\n \"model\": \"openai/gpt-4o-mini\",\n \"api_key\": \"sk-test-value\",\n },\n }\n ],\n )\n def test_agent_model_preserves_yaml_alias_without_provider_prefix(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LITELLM_CONFIG\": \"/tmp/litellm.yaml\",\n \"AGENT_LITELLM_MODEL\": \"gpt4o\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"gpt4o\")\n self.assertEqual(get_effective_agent_primary_model(config), \"gpt4o\")\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"gpt4o\", \"openai/gpt-4o-mini\"],\n )\n\n\nif __name__ == \"__main__\":\n unittest.main()\n"}, "216a77f6a27aa6d2637ecdd16258b6bc1b813474": {"t": 1785754733.0530355, "n": 11829, "h": "4725cd7d8360f955", "c": "# -*- coding: utf-8 -*-\n\"\"\"Tests for env-based LLM channel parsing.\"\"\"\n\nimport os\nimport unittest\nfrom unittest.mock import patch\n\nfrom src.config import (\n Config,\n get_effective_agent_models_to_try,\n get_effective_agent_primary_model,\n)\n\n\nclass LLMChannelConfigTestCase(unittest.TestCase):\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_protocol_prefixes_bare_model_names(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"deepseek\",\n \"LLM_PRIMARY_BASE_URL\": \"https://api.deepseek.com/v1\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"deepseek\")\n self.assertEqual(config.llm_channels[0][\"models\"], [\"deepseek/deepseek-chat\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"deepseek/deepseek-chat\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_openai_compatible_channel_prefixes_non_provider_slash_models(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"siliconflow\",\n \"LLM_SILICONFLOW_PROTOCOL\": \"openai\",\n \"LLM_SILICONFLOW_BASE_URL\": \"https://api.siliconflow.cn/v1\",\n \"LLM_SILICONFLOW_API_KEY\": \"sk-test-value\",\n \"LLM_SILICONFLOW_MODELS\": \"Qwen/Qwen3-8B,deepseek-ai/DeepSeek-V3\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n config.llm_channels[0][\"models\"],\n [\"openai/Qwen/Qwen3-8B\", \"openai/deepseek-ai/DeepSeek-V3\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_alias_prefixed_models_are_canonicalized_once(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"vertex\",\n \"LLM_VERTEX_PROTOCOL\": \"vertex_ai\",\n \"LLM_VERTEX_API_KEY\": \"sk-test-value\",\n \"LLM_VERTEX_MODELS\": \"vertexai/gemini-2.5-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels[0][\"models\"], [\"vertex_ai/gemini-2.5-flash\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"vertex_ai/gemini-2.5-flash\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_disabled_channel_is_skipped(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_ENABLED\": \"false\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels, [])\n self.assertEqual(config.llm_model_list, [])\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_local_ollama_channel_can_skip_api_key(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"local\",\n \"LLM_LOCAL_PROTOCOL\": \"ollama\",\n \"LLM_LOCAL_BASE_URL\": \"http://127.0.0.1:11434\",\n \"LLM_LOCAL_API_KEY\": \"\",\n \"LLM_LOCAL_MODELS\": \"llama3.2\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"ollama/llama3.2\")\n self.assertNotIn(\"api_key\", params)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_legacy_provider_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"GEMINI_TEMPERATURE\": \"0.15\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"gemini/gemini-3-flash-preview\")\n self.assertAlmostEqual(config.llm_temperature, 0.15)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_prefers_unified_setting_when_present(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"GEMINI_TEMPERATURE\": \"0.15\",\n \"LLM_TEMPERATURE\": \"0.35\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.35)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_openai_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o\",\n \"LITELLM_MODEL\": \"openai/gpt-4o\",\n \"OPENAI_TEMPERATURE\": \"0.42\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.42)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_any_legacy_when_provider_mismatch(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o\",\n \"LITELLM_MODEL\": \"openai/gpt-4o\",\n \"ANTHROPIC_TEMPERATURE\": \"0.55\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.55)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_ignores_invalid_value(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"LLM_TEMPERATURE\": \"high\",\n \"GEMINI_TEMPERATURE\": \"0.25\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.25)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_local_openai_compatible_channel_defaults_to_openai_protocol(self, _mock_parse_yaml, _mock_setup_env) -> None:\n \"\"\"Localhost channels without explicit protocol should default to openai, not ollama.\"\"\"\n env = {\n \"LLM_CHANNELS\": \"local\",\n \"LLM_LOCAL_BASE_URL\": \"http://127.0.0.1:8000/v1\",\n \"LLM_LOCAL_API_KEY\": \"not-needed\",\n \"LLM_LOCAL_MODELS\": \"my-model\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"openai/my-model\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"openai\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_model_empty_inherits_primary_model(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"gpt-4o-mini\",\n \"AGENT_LITELLM_MODEL\": \"\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"\")\n self.assertEqual(get_effective_agent_primary_model(config), \"openai/gpt-4o-mini\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_model_without_provider_prefix_is_normalized(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"OPENAI_MODEL\": \"gpt-4o-mini\",\n \"AGENT_LITELLM_MODEL\": \"deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"openai/deepseek-chat\")\n self.assertEqual(get_effective_agent_primary_model(config), \"openai/deepseek-chat\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_models_to_try_are_deduped_in_order(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"gemini/gemini-2.5-flash\",\n \"AGENT_LITELLM_MODEL\": \"openai/gpt-4o-mini\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini,openai/gpt-4o-mini,gemini/gemini-2.5-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"openai/gpt-4o-mini\", \"gemini/gemini-2.5-flash\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_agent_models_to_try_dedupes_semantically_equivalent_openai_models(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"OPENAI_API_KEY\": \"sk-test-value\",\n \"LITELLM_MODEL\": \"gemini/gemini-2.5-flash\",\n \"AGENT_LITELLM_MODEL\": \"gpt-4o-mini\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini,gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"openai/gpt-4o-mini\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(\n Config,\n \"_parse_litellm_yaml\",\n return_value=[\n {\n \"model_name\": \"gpt4o\",\n \"litellm_params\": {\n \"model\": \"openai/gpt-4o-mini\",\n \"api_key\": \"sk-test-value\",\n },\n }\n ],\n )\n def test_agent_model_preserves_yaml_alias_without_provider_prefix(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LITELLM_CONFIG\": \"/tmp/litellm.yaml\",\n \"AGENT_LITELLM_MODEL\": \"gpt4o\",\n \"LITELLM_FALLBACK_MODELS\": \"openai/gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.agent_litellm_model, \"gpt4o\")\n self.assertEqual(get_effective_agent_primary_model(config), \"gpt4o\")\n self.assertEqual(\n get_effective_agent_models_to_try(config),\n [\"gpt4o\", \"openai/gpt-4o-mini\"],\n )\n\n\nif __name__ == \"__main__\":\n unittest.main()\n"}, "a97f41637b0e24ffff4ffa2c78af45cf58dc8fb7": {"t": 1785754734.3877645, "n": 7977, "h": "f890932d960e8d64", "c": "# -*- coding: utf-8 -*-\n\"\"\"Tests for env-based LLM channel parsing.\"\"\"\n\nimport os\nimport unittest\nfrom unittest.mock import patch\n\nfrom src.config import Config\n\n\nclass LLMChannelConfigTestCase(unittest.TestCase):\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_protocol_prefixes_bare_model_names(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"deepseek\",\n \"LLM_PRIMARY_BASE_URL\": \"https://api.deepseek.com/v1\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"deepseek-chat\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"deepseek\")\n self.assertEqual(config.llm_channels[0][\"models\"], [\"deepseek/deepseek-chat\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"deepseek/deepseek-chat\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_openai_compatible_channel_prefixes_non_provider_slash_models(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"siliconflow\",\n \"LLM_SILICONFLOW_PROTOCOL\": \"openai\",\n \"LLM_SILICONFLOW_BASE_URL\": \"https://api.siliconflow.cn/v1\",\n \"LLM_SILICONFLOW_API_KEY\": \"sk-test-value\",\n \"LLM_SILICONFLOW_MODELS\": \"Qwen/Qwen3-8B,deepseek-ai/DeepSeek-V3\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(\n config.llm_channels[0][\"models\"],\n [\"openai/Qwen/Qwen3-8B\", \"openai/deepseek-ai/DeepSeek-V3\"],\n )\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_alias_prefixed_models_are_canonicalized_once(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"vertex\",\n \"LLM_VERTEX_PROTOCOL\": \"vertex_ai\",\n \"LLM_VERTEX_API_KEY\": \"sk-test-value\",\n \"LLM_VERTEX_MODELS\": \"vertexai/gemini-2.5-flash\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels[0][\"models\"], [\"vertex_ai/gemini-2.5-flash\"])\n self.assertEqual(config.llm_model_list[0][\"litellm_params\"][\"model\"], \"vertex_ai/gemini-2.5-flash\")\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_disabled_channel_is_skipped(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_ENABLED\": \"false\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test-value\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o-mini\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_channels, [])\n self.assertEqual(config.llm_model_list, [])\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_local_ollama_channel_can_skip_api_key(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"local\",\n \"LLM_LOCAL_PROTOCOL\": \"ollama\",\n \"LLM_LOCAL_BASE_URL\": \"http://127.0.0.1:11434\",\n \"LLM_LOCAL_API_KEY\": \"\",\n \"LLM_LOCAL_MODELS\": \"llama3.2\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.llm_models_source, \"llm_channels\")\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"ollama/llama3.2\")\n self.assertNotIn(\"api_key\", params)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_legacy_provider_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"GEMINI_TEMPERATURE\": \"0.15\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertEqual(config.litellm_model, \"gemini/gemini-3-flash-preview\")\n self.assertAlmostEqual(config.llm_temperature, 0.15)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_prefers_unified_setting_when_present(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"GEMINI_TEMPERATURE\": \"0.15\",\n \"LLM_TEMPERATURE\": \"0.35\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.35)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_openai_temperature(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o\",\n \"LITELLM_MODEL\": \"openai/gpt-4o\",\n \"OPENAI_TEMPERATURE\": \"0.42\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.42)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_falls_back_to_any_legacy_when_provider_mismatch(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"LLM_CHANNELS\": \"primary\",\n \"LLM_PRIMARY_PROTOCOL\": \"openai\",\n \"LLM_PRIMARY_API_KEY\": \"sk-test\",\n \"LLM_PRIMARY_MODELS\": \"gpt-4o\",\n \"LITELLM_MODEL\": \"openai/gpt-4o\",\n \"ANTHROPIC_TEMPERATURE\": \"0.55\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.55)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_llm_temperature_ignores_invalid_value(self, _mock_parse_yaml, _mock_setup_env) -> None:\n env = {\n \"GEMINI_API_KEY\": \"secret-key-value\",\n \"LLM_TEMPERATURE\": \"high\",\n \"GEMINI_TEMPERATURE\": \"0.25\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n self.assertAlmostEqual(config.llm_temperature, 0.25)\n\n @patch(\"src.config.setup_env\")\n @patch.object(Config, \"_parse_litellm_yaml\", return_value=[])\n def test_local_openai_compatible_channel_defaults_to_openai_protocol(self, _mock_parse_yaml, _mock_setup_env) -> None:\n \"\"\"Localhost channels without explicit protocol should default to openai, not ollama.\"\"\"\n env = {\n \"LLM_CHANNELS\": \"local\",\n \"LLM_LOCAL_BASE_URL\": \"http://127.0.0.1:8000/v1\",\n \"LLM_LOCAL_API_KEY\": \"not-needed\",\n \"LLM_LOCAL_MODELS\": \"my-model\",\n }\n\n with patch.dict(os.environ, env, clear=True):\n config = Config._load_from_env()\n\n params = config.llm_model_list[0][\"litellm_params\"]\n self.assertEqual(params[\"model\"], \"openai/my-model\")\n self.assertEqual(config.llm_channels[0][\"protocol\"], \"openai\")\n\n\nif __name__ == \"__main__\":\n unittest.main()\n"}}, "docs/LLM_CONFIG_GUIDE_EN.md": {"e8305ba738f90086d2bd7e19474054decb796526": {"t": 1785809883.1545722, "n": 31214, "h": "a647e4aa8a5e80cd", "c": "# LLM Configuration Guide\n\nWelcome! Whether you are a beginner newly exposed to AI or a veteran skilled with various APIs, this guide will help you set up Large Language Models (LLMs) quickly.\n\nThis project exposes a unified AI model access flow that supports official APIs, OpenAI-compatible platforms, and local models. Under the hood it is powered by [LiteLLM](https://docs.litellm.ai/), but most users only need to think in terms of picking a provider, adding an API key, and optionally choosing a primary model or channels. To cater to different experience levels, we provide a three-tier configuration hierarchy. Choose the method that fits you best.\n\nIf you are choosing a concrete provider, setting up GitHub Actions Secrets / Variables, troubleshooting a `details.reason` error, or rolling back an LLM configuration, start with the [Provider Configuration Guide](./llm-providers.md). It is the maintained reference for provider presets, Actions variable mapping, runtime capability-check boundaries, and common error handling.\n\n---\n\n## Quick Navigation: Which section should you read?\n\n1. **[Beginners]** \"I just want to get the system running ASAP, keep it as simple as possible!\" -> [Go to Method 1: Simple Model Config](#method-1-simple-model-config-for-beginners)\n2. **[Advanced Users]** \"I have several Keys, want to configure fallback models, and define custom Base URLs.\" -> [Go to Method 2: Channels Mode Config](#method-2-channels-mode-config-advancedmulti-model)\n3. **[Veterans]** \"I want complex load balancing, request routing, and enterprise-level high availability!\" -> [Go to Method 3: Advanced YAML Config](#method-3-advanced-yaml-config-expert-setup)\n4. **[Local Models]** \"I want to use Ollama local models!\" -> [Go to Example 4: Using Ollama Local Models](#example-4-using-ollama-local-models)\n5. **[Vision Models]** \"I want to extract stock codes from images!\" -> [Go to Vision Model Config](#advanced-feature-vision-model-config)\n\n---\n\n## Method 1: Simple Model Config (For Beginners)\n\n**Goal:** Just paste your API Key and the model name to start using it immediately. No need to mess with complex concepts.\n\nIf you only plan to use one single model, this is the fastest way. Open the `.env` file in the project's root directory (if it doesn't exist, copy `.env.example` and rename it to `.env`).\n\n### Anspire Open Example:\n\n> 💡 **[Anspire Open](https://open.anspire.cn/?share_code=QFBC0FYC)**: supports Chinese-optimized search and OpenAI-compatible model access using a shared key.\n> - The following values are configuration examples only; model availability depends on your account and Anspire console.\n> - Documentation examples do not replace connectivity validation; please validate with the Web \"Test connection\" flow before relying on production traffic.\n\n```env\n# Anspire Open API keys (multiple keys supported, separated by commas)\n# Get your key at: https://open.anspire.cn/?share_code=QFBC0FYC\n# When no higher-priority OpenAI-compatible source is set, this key is reused for Anspire search + LLM path (example fallback behavior only).\n# Example model: Doubao-Seed-2.0-lite; example gateway: https://open-gateway.anspire.cn/v6\nANSPIRE_API_KEYS=sk-xxxxxxxxxxxxxxxx\n# Optional: switch example model or gateway according to your Anspire account and official docs.\n# ANSPIRE_LLM_MODEL=Doubao-Seed-2.0-pro\n# ANSPIRE_LLM_BASE_URL=https://open-gateway.anspire.ai/v6\n```\n\n### Example 1: Using a Third-party OpenAI-Compatible Platform (Highly Recommended)\n\nMost third-party relay platforms and local API providers support the OpenAI interface format. As long as the platform provides an API Key and a Base URL, you can configure it easily using the following pattern:\n\n```env\n# Fill in the API Key provided by your platform\nOPENAI_API_KEY=sk-xxxxxxxxxxxxxxxx\n# Fill in the platform's API Base URL (Very Important: Usually must end with /v1)\nOPENAI_BASE_URL=https://api.siliconflow.cn/v1\n# Fill in the specific model name (Very Important: You must add the \"openai/\" prefix so the system recognizes it)\nLITELLM_MODEL=openai/deepseek-ai/DeepSeek-V3 \n```\n\n### Example 2: Using the Official DeepSeek API\n```env\n# Fill in the API Key requested from the official DeepSeek platform\nDEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx\n```\n*Compatibility note: with only this line, the system still defaults to `deepseek/deepseek-chat` and logs a migration warning.*\n`deepseek-chat` / `deepseek-reasoner` still work for compatibility with old configs, but DeepSeek marks them deprecated after 2026/07/24. New configs should migrate through the Web quick channel or explicitly set `LITELLM_MODEL=deepseek/deepseek-v4-flash` for `deepseek-v4-flash` / `deepseek-v4-pro`.\n\n### Example 3: Using the Free Gemini API\n```env\n# Fill in your Google Gemini Key\nGEMINI_API_KEY=AIzac...\n```\n\n### Example 4: Using Ollama Local Models\n```env\n# Ollama requires no API Key; works after running ollama serve locally\nOLLAMA_API_BASE=http://localhost:11434\nLITELLM_MODEL=ollama/qwen3:8b\n```\n\n> **Important**: Ollama must be configured with `OLLAMA_API_BASE`. **Do not** use `OPENAI_BASE_URL`, or the system will concatenate URLs incorrectly (e.g. 404, `api/generate/api/show`). For remote Ollama, set `OLLAMA_API_BASE` to the actual address (e.g. `http://192.168.1.100:11434`). Current dependency constraint is `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (matches requirements.txt).\n\n> **Congratulations! If you're a beginner, you can stop reading here and run the program!**\n> Want to test the connection? Open your terminal in the root directory and run: `python scripts/check_env.py --llm`\n\n---\n\n## Method 2: Channels Mode Config (Advanced/Multi-model)\n\n**Goal:** I have Keys from multiple different platforms and want to use them together. If my primary model fails or the network drops, I want it to automatically switch to fallback models.\n\n**Configure via Web UI directly:** After starting the application, you can do this visually under **System Settings -> AI Model -> AI Model Access** in the Web UI.\n\n> **New editor behavior**: For DeepSeek, DashScope, and other OpenAI-compatible providers that expose `/v1/models`, the settings page can now fetch models directly from `{base_url}/models` and let you select multiple entries visually. The underlying storage format is still the existing comma-separated `LLM_{CHANNEL}_MODELS=model1,model2` value. If a provider does not support `/models`, authentication fails, or the endpoint is temporarily unavailable, you can still type the model list manually and save normally.\n\n### First-run Setup Status\n\nThe backend exposes a read-only status endpoint at `GET /api/v1/system/config/setup/status`. It reports whether the minimum first-run pieces are present: primary LLM, Agent model inheritance/configuration, stock list, optional notification channel, and local storage. The endpoint only reads the saved `.env` plus the current process environment; it does not reload runtime config, write `.env`, test a real model, or create a database file. Frontend onboarding and later smoke-run flows can build on this endpoint incrementally.\n\n### Web channel editor: compatibility, migration, and rollback rules\n\n- The preset provider / Base URL / sample models are **form defaults only**. What gets persisted is still exactly what you submit in `LLM_{CHANNEL}_PROTOCOL`, `LLM_{CHANNEL}_BASE_URL`, `LLM_{CHANNEL}_MODELS`, and `LLM_{CHANNEL}_API_KEY(S)`; the editor does not silently rewrite them to a different provider name or URL.\n- \"Discover models\" only calls `{base_url}/models` for `OpenAI Compatible` / `DeepSeek` channels, and the default \"Test connection\" action sends one minimal chat completion request against the first model in the list and shows the backend-normalized `resolved_model` in the result. If the response includes `details.reason=model_access_denied` (for example, the observed Issue #1208 SiliconFlow / OpenAI Compatible sample returned `Model disabled` through LiteLLM), treat it as a best-effort model availability diagnostic based on provider wording: first confirm that the tested model is enabled for the current account/key, then adjust the model order or remove unavailable models before retrying. Provider messages not covered by this conservative rule, or provider messages with different semantics, continue to use the fallback diagnostic path. Optional runtime capability checks must be explicitly selected by the user and send additional JSON / tools / stream / vision smoke requests; the result only represents a best-effort check for the current account, model, and endpoint at that moment. The returned `stage / error_code / details / latency_ms / capability_results` fields are for structured diagnostics only, are **never persisted** back into `.env`, and do not block saving.\n- If the response includes `details.reason=provider_blocked`, the provider or relay gateway explicitly blocked this request. This is distinct from local network / TLS failures and `model_access_denied`; first check account risk controls, region or request-source restrictions, model entitlement, relay gateway policy, and content-safety policy.\n- Runtime capability checks send real LLM requests and may incur token / image-input cost, RPM/TPM rate limiting, insufficient balance errors, or timeouts. A failed check may come from account permissions, model entitlement, endpoint region, balance, provider compatibility layers, or LiteLLM translation behavior; it does not prove that the provider globally lacks that capability. P3 does not include online smoke coverage for every real provider. Its compatibility basis is the repository dependency constraint `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0`, LiteLLM `completion()` / OpenAI I/O format / streaming / exception mapping, and the OpenAI Chat Completions shapes for JSON mode, tool calling, streaming, and vision input.\n- External references: LiteLLM Python SDK / OpenAI I/O format / streaming / exception mapping: <https://docs.litellm.ai/>; LiteLLM OpenAI-compatible routing: <https://docs.litellm.ai/docs/providers/openai_compatible>; OpenAI Chat Completions: <https://platform.openai.com/docs/api-reference/chat/create>; JSON mode: <https://platform.openai.com/docs/guides/structured-outputs?api-mode=chat>; tool calling: <https://platform.openai.com/docs/guides/function-calling?api-mode=chat>; streaming: <https://platform.openai.com/docs/guides/streaming-responses?api-mode=chat>; vision input: <https://platform.openai.com/docs/guides/images-vision?api-mode=chat>.\n- Saving channels only updates the keys submitted in that save operation; there is no whole-config silent migration when you switch channel settings. The one deliberate cleanup is runtime model references: if `LITELLM_MODEL`, `AGENT_LITELLM_MODEL`, `VISION_MODEL`, or `LITELLM_FALLBACK_MODELS` point to models that no longer exist in the currently enabled channels, the editor clears/removes those stale references before saving so runtime calls do not keep targeting invalid models. Even when enabled channels expose no selectable models, stale managed-provider values without a matching legacy key are cleaned. `cohere/*`, `google/*`, and `xai/*` are kept as explicit direct-env compatibility examples for legacy retention behavior only, and are not a runtime availability guarantee.\n- Backend consistency basis: runtime validation in `SystemConfigService._validate_llm_runtime_selection` (`src/services/system_config_service.py`) relies on `_uses_direct_env_provider` (`src/config.py`). Only `gemini`, `vertex_ai`, `anthropic`, `openai`, and `deepseek` are treated as managed key-backed providers; `cohere`, `google`, and `xai` are not in that allowlist, so they remain valid direct provider runtime entries.\n- Rollback stays minimal: restore the previous channel model list and re-select the runtime models, or restore the previous `LLM_*`, `LITELLM_MODEL`, `AGENT_LITELLM_MODEL`, `VISION_MODEL`, and `LLM_TEMPERATURE` values from your desktop export / manual `.env` backup. No extra migration script is required.\n- The current dependency constraint for this flow in the repository is `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`). Regression coverage for it lives in `tests/test_system_config_service.py`, `tests/test_system_config_api.py`, and `apps/dsa-web/src/components/settings/__tests__/LLMChannelEditor.test.tsx`.\n\n> **External provider model examples notice**: `cohere/*`, `google/*`, and `xai/*` provider-prefixed values are included here only to describe current runtime retention behavior and are **not** a global availability guarantee. Specific model names in docs or tests are configuration-retention examples, not production recommendations. Check the provider's official model/API docs and validate against the repository dependency constraint `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` before production use.\n\n### Rollback & compatibility evidence\n\n- Scope and cleanup behavior under `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0`: only runtime references (`LITELLM_MODEL`, `AGENT_LITELLM_MODEL`, `VISION_MODEL`, `LITELLM_FALLBACK_MODELS`) are sanitized during save; non-channel direct providers such as `cohere/*`, `google/*`, and `xai/*` are preserved.\n- Rollback path: export desktop config, then restore the backup through `POST /api/v1/system/config/import`; or manually restore historical `.env` entries (`LITELLM_*`, `AGENT_LITELLM_MODEL`, `VISION_MODEL`, `LLM_TEMPERATURE`) and restart.\n- Rollback evidence: `tests/test_system_config_service.py::test_import_desktop_env_restores_runtime_models_after_cleanup` covers restore from exported desktop backup after runtime cleanup.\n- Direct-provider evidence: `tests/test_system_config_service.py::SystemConfigServiceTestCase::test_validate_accepts_minimax_model_as_direct_env_provider`, `test_validate_accepts_cohere_model_as_direct_env_provider`, `test_validate_accepts_google_model_as_direct_env_provider`, and `test_validate_accepts_xai_model_as_direct_env_provider` cover the preserved direct-provider behavior.\n- Frontend regression commands: `cd apps/dsa-web && npm run lint && npm run build && npm run test -- src/components/settings/__tests__/LLMChannelEditor.test.tsx`.\n- Recommended rollback sequence (including UI reload): export desktop backup, restore via `POST /api/v1/system/config/import`, then call `GET /api/v1/system/config` to refresh the settings page and verify `LITELLM_MODEL` / `AGENT_LITELLM_MODEL` / `VISION_MODEL` / `LLM_TEMPERATURE` before continuing.\n\n### Official references for provider presets / Base URLs / model naming\n\n- OpenAI-compatible routing in LiteLLM: <https://docs.litellm.ai/docs/providers/openai_compatible>\n- OpenAI official API docs: <https://platform.openai.com/docs/api-reference/chat>\n- DeepSeek official API docs: <https://api-docs.deepseek.com/>\n- Anspire Open: <https://open.anspire.cn/?share_code=QFBC0FYC>\n- DashScope OpenAI-compatible mode: <https://help.aliyun.com/zh/model-studio/compatibility-of-openai-with-dashscope>\n- Moonshot / Kimi official compatibility docs: <https://platform.moonshot.ai/docs/guide/compatibility>\n- Anthropic official Messages API: <https://docs.anthropic.com/en/api/messages>\n- Gemini official OpenAI compatibility docs: <https://ai.google.dev/gemini-api/docs/openai>\n- Cohere official: <https://docs.cohere.com/>\n- Cohere API reference: <https://docs.cohere.com/reference/>\n- Cohere LiteLLM provider page: <https://docs.litellm.ai/docs/providers/cohere>\n- Google Gemini API and model list: <https://ai.google.dev/gemini-api/docs/openai>, <https://ai.google.dev/gemini-api/docs/models>\n- Google LiteLLM provider page: <https://docs.litellm.ai/docs/providers/gemini>\n- xAI official: <https://docs.x.ai/docs>\n- xAI LiteLLM provider page: <https://docs.litellm.ai/docs/providers/xai>\n- Ollama API docs: <https://github.com/ollama/ollama/blob/main/docs/api.md>\n\nIf you prefer modifying files, configuring this in the `.env` file is also very smooth. It allows you to manage multiple platforms simultaneously. The rules are:\n\n1. **Declare your channels first**: `LLM_CHANNELS=channel_name_1,channel_name_2`\n2. **Provide configurations for each channel** (Note the uppercase): `LLM_{CHANNEL_NAME}_XXX`\n\n### Example: Configuring DeepSeek and a Third-party Relay with Fallbacks\n```env\n# 1. Enable channel mode, declare two channels here: deepseek and aihubmix\nLLM_CHANNELS=deepseek,aihubmix\n\n# 2. Channel 1: Configure Official DeepSeek\nLLM_DEEPSEEK_BASE_URL=https://api.deepseek.com\nLLM_DEEPSEEK_API_KEY=sk-1111111111111\nLLM_DEEPSEEK_MODELS=deepseek-v4-flash,deepseek-v4-pro\n\n# 3. Channel 2: Configure a common relay/proxy API\nLLM_AIHUBMIX_BASE_URL=https://api.aihubmix.com/v1\nLLM_AIHUBMIX_API_KEY=sk-2222222222222\nLLM_AIHUBMIX_MODELS=gpt-5.5,claude-sonnet-4-6\n\n# 4. [Key Step] Specify the primary model and fallback list\n# Set your primary model:\nLITELLM_MODEL=deepseek/deepseek-v4-flash\n# Optional: set an Agent-only primary model (empty = inherit the primary model)\nAGENT_LITELLM_MODEL=deepseek/deepseek-v4-pro\n# If the primary model crashes, try these fallbacks sequentially:\nLITELLM_FALLBACK_MODELS=openai/gpt-5.4-mini,anthropic/claude-sonnet-4-6\n```\n\n### Example: Ollama Channel Mode (Local Models, No API Key)\n```env\n# 1. Enable channel mode, declare ollama channel\nLLM_CHANNELS=ollama\n\n# 2. Configure Ollama address (default local port 11434)\nLLM_OLLAMA_BASE_URL=http://localhost:11434\nLLM_OLLAMA_MODELS=qwen3:8b,llama3.2\n\n# 3. Specify primary model\nLITELLM_MODEL=ollama/qwen3:8b\n```\n\n### MiniMax Model Naming in Channel Mode\n\n- If you access MiniMax through an OpenAI-compatible channel, enter the model as `minimax/<model-name>` in the channel model list, for example `minimax/MiniMax-M1`.\n- The Web settings page now keeps that value unchanged in Primary, Agent Primary, Fallback, and Vision selectors instead of rewriting it to `openai/minimax/<model-name>`.\n\n### Ask-Stock Agent / LiteLLM compatibility notes\n\n- The ask-stock Agent follows the same three-tier runtime priority as the regular analyzer: `LITELLM_CONFIG` (LiteLLM YAML) > `LLM_CHANNELS` > legacy provider keys. Once an upper tier is valid and active, lower tiers are ignored for that request.\n- In YAML mode, the Agent reuses LiteLLM `model_list` / `model_name` routing semantics directly. In channel mode, it first reads `AGENT_LITELLM_MODEL`; when that is empty it inherits `LITELLM_MODEL`, then continues through `LITELLM_FALLBACK_MODELS`.\n- If you do not use YAML or Channels, leave `AGENT_LITELLM_MODEL` empty, and still rely on legacy provider env vars, the ask-stock Agent continues to inherit them: `GEMINI_API_KEY + GEMINI_MODEL` -> `gemini/<model>`, `OPENAI_API_KEY + OPENAI_MODEL` -> `openai/<model>`, and `ANTHROPIC_API_KEY + ANTHROPIC_MODEL` -> `anthropic/<model>`.\n- This fix only improves two things: preserving the backend's real failure reason and returning a more specific diagnostic when no usable Agent LLM is configured. It does **not** silently delete, clear, migrate, or rewrite your existing `GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, or `LITELLM_*` settings.\n- If the current environment has no valid Agent model path at all, the ask-stock page still returns a failure and now surfaces the backend's real configuration diagnosis. As soon as you restore any valid model source, the flow recovers without running any migration step.\n- The recommended forward path is still to configure `LITELLM_MODEL` / `AGENT_LITELLM_MODEL` explicitly or move to `LLM_CHANNELS`; legacy provider keys remain a compatibility fallback for older `.env` files, local macOS development, and existing deployments.\n\n### Strict Temperature Model Compatibility Notes\n\n- Moonshot officially documents Kimi as an OpenAI-compatible API, with `https://api.moonshot.ai/v1` as the base URL: <https://platform.kimi.ai/docs/guide/kimi-k2-6-quickstart>\n- LiteLLM officially requires the `openai/` prefix for OpenAI-compatible model routing: <https://docs.litellm.ai/docs/providers/openai_compatible>\n- Moonshot's compatibility docs distinguish two fixed values: **thinking mode must use `1.0`, while non-thinking mode must use `0.6`**; other values are rejected by the API: <https://platform.moonshot.ai/docs/guide/compatibility#parameters-differences-in-request-body>\n- The OpenAI Chat Completions API treats `temperature` as optional. For GPT-5 / o-series style models that only accept the provider default temperature, this project omits `temperature` at request time instead of rewriting your saved `LLM_TEMPERATURE`: <https://platform.openai.com/docs/api-reference/chat/create>\n- The current runtime dependency constraint in this repository is `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` (see `requirements.txt`); this compatibility fix is regression-covered under that constraint across the main analyzer, market review, direct Agent LiteLLM calls, and the system-settings channel connectivity test path.\n- This repository therefore normalizes `kimi-k2.6` and `kimi-k2.6-*` right before dispatch based on the **actual request mode**: default / thinking requests use `temperature=1.0`; if your LiteLLM YAML route alias explicitly sets `litellm_params.extra_body.thinking.type: disabled` (or an equivalent non-thinking override), it automatically switches to `temperature=0.6`. Your saved `LLM_TEMPERATURE` value in `.env` or the Web settings is not rewritten.\n- If a compatible platform returns an explicit parameter error for a not-yet-profiled model, such as unsupported `temperature`, default-only `1.0`, or unsupported `top_p`, the runtime repairs the **current request** and retries once. The strategy is cached only in the current process after the retry succeeds; it is never written back to `.env`, and a service restart re-evaluates the configured rules normally.\n- For streaming responses that already produced partial content, the runtime does not switch parameters mid-output. It keeps the existing same-model non-stream retry / fallback-model path to avoid stitching inconsistent answers together.\n- `SystemConfigService` only updates keys that you actually submit when saving from the Web settings page or importing a desktop `.env`; switching to a strict-temperature model does not silently clear, migrate, or rewrite an existing `LLM_TEMPERATURE`. Temporary request-time parameter strategies are not persisted back into the config file.\n- Non-strict primary models, non-strict fallbacks, and any request after switching back to a regular model still use your configured temperature. Existing configs do not need migration; changing the model restores the original behavior automatically.\n- Repository-side compatibility coverage lives in `tests/test_llm_channel_config.py`, `tests/test_market_analyzer_generate_text.py`, `tests/test_agent_pipeline.py`, and `tests/test_system_config_service.py`.\n- Minimal rollback: revert only the LLM generation-parameter adaptation change set; no separate `LLM_TEMPERATURE` migration is required.\n\n> **Critical Warning**: If you enable `LLM_CHANNELS`, any standard `DEEPSEEK_API_KEY` or `OPENAI_API_KEY` declared independently will be **completely ignored**. **Use only one mode** to prevent configuration conflicts.\n> **Docker note**: If `LITELLM_MODEL`, `LLM_CHANNELS`, `LLM_DEEPSEEK_MODELS`, or related variables are explicitly passed through `docker compose environment:` or `docker run -e`, they will override the `.env` written by the Web settings page after a container restart. Update the deployment environment at the same time.\n\n### Compatibility evidence and rollback audit notes (for this recovery change)\n\n- Compatibility is validated in two layers: first-party provider/API contract references (LiteLLM OpenAI-compatible routing, OpenAI Chat Completions, Moonshot/Kimi docs and model notes), and second the current runtime implementation in this repository under `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0`.\n- This recovery path is runtime-only and intentionally local: exception classification + one in-request repair retry + in-process cache. It does not rewrite `.env`, migrate saved config keys, or alter legacy values; it only omits/adjusts request parameters (`temperature`, `top_p`, `presence_penalty`, `frequency_penalty`, `seed`) for the current call. Rolling back requires no migration; restore previous settings and model/provider selection.\n- Regression evidence for this path is in `tests/test_llm_param_recovery.py`, `tests/test_system_config_service.py`, `tests/test_llm_channel_config.py`, `tests/test_system_config_api.py`, `tests/test_market_analyzer_generate_text.py`, `tests/test_agent_pipeline.py`; desktop backup import restore is directly covered by `test_import_desktop_env_restores_runtime_models_after_cleanup`.\n\n---\n\n## Method 3: Advanced YAML Config (Expert Setup)\n\n**Goal:** I want maximum control and origin-level routing rules for enterprise-grade high availability.\n\nThis layer maps directly to the underlying LiteLLM routing capabilities, including high concurrency, automatic retries, and TPM/RPM-based load balancing.\n\n1. Keep only one declaration line in your `.env`:\n ```env\n LITELLM_CONFIG=./litellm_config.yaml\n ```\n2. Create a `litellm_config.yaml` in the project root directory (you can refer to `docs/examples/litellm_config.example.yaml`).\n\nExample `litellm_config.yaml`:\n```yaml\nmodel_list:\n - model_name: my-smart-model\n litellm_params:\n model: deepseek/deepseek-v4-flash\n api_base: https://api.deepseek.com\n api_key: \"os.environ/MY_CUSTOM_SECRET_KEY\" # Fetch from environment vars for security\n\n # Ollama local model (no api_key needed)\n - model_name: ollama/qwen3:8b\n litellm_params:\n model: ollama/qwen3:8b\n api_base: http://localhost:11434\n```\n\n> **Priority Rule**: YAML is king! If YAML is configured, both **Channels Mode** and **Simple Mode** are entirely ignored. Hierarchy: `YAML > Channels > Simple`.\n\n### GitHub Actions Notes\n\nThe bundled `00-daily-analysis.yml` explicitly passes the common LLM runtime fields to the job environment:\n\n- Runtime selection: `LLM_CHANNELS`, `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, `AGENT_LITELLM_MODEL`, `VISION_MODEL`, `VISION_PROVIDER_PRIORITY`, `LLM_TEMPERATURE`\n- Multiple keys: `GEMINI_API_KEYS`, `ANTHROPIC_API_KEYS`, `OPENAI_API_KEYS`, `DEEPSEEK_API_KEYS` (the current workflow imports these from repository Secrets only, not from same-named Variables)\n- Common channel names: `primary`, `secondary`, `aihubmix`, `deepseek`, `dashscope`, `zhipu`, `moonshot`, `minimax`, `volcengine`, `siliconflow`, `openrouter`, `gemini`, `anthropic`, `openai`, `ollama`\n\nFor example, if you set `LLM_CHANNELS=primary,deepseek` in GitHub Actions, also configure the corresponding `LLM_PRIMARY_*` and `LLM_DEEPSEEK_*` entries. The `LLM_<NAME>_API_KEY` / `LLM_<NAME>_API_KEYS` fields are also imported from repository Secrets only right now, so storing them in Variables will not work at runtime. If you use a custom channel name such as `my_proxy`, GitHub Actions must explicitly add matching `LLM_MY_PROXY_*` mappings in the workflow `env:` block. Local `.env` and Docker runs do not have this limitation.\n\n---\n\n## Advanced Feature: Vision Model Config\n\nCertain specific features in our system (like uploading a stock chart screenshot to extract the stock code) require models capable of computer vision. You need to assign a dedicated vision model in your `.env`.\n\n```env\n# Specify your dedicated vision model name\nVISION_MODEL=openai/gpt-5.5\n# Make sure to provide its corresponding provider API KEY (e.g., OPENAI_API_KEY):\n# OPENAI_API_KEY=xxx\n```\n\n**Vision Fallback Mechanism:** To prevent unexpected failures, the system has a built-in fallback strategy. If the primary vision model fails, it will attempt to use alternative vision-capable provider keys in the following order:\n```env\n# Default fallback sequence:\nVISION_PROVIDER_PRIORITY=gemini,anthropic,openai\n```\n\n---\n\n## Troubleshooting\n\nAfraid you got the config wrong? Type the following commands in your terminal to diagnose:\n\n- `python scripts/check_env.py --config`: Only verifies if the logic in your `.env` is structurally correct. (Provides instant results, no network calls, strictly checks for syntax omissions).\n- `python scripts/check_env.py --llm`: Sends a real greeting to the LLM to test the actual endpoint. This thoroughly verifies if your **network is working** and if your **account has sufficient balance**.\n\n### Common Pitfalls\n\n| Weird Error You Got? | Likely Culprit | How to Fix It? |\n|----------------------|----------------|----------------|\n| **The UI says the primary model is not configured** | The system doesn't know which provider/model you want to use. | Add a clear instruction in `.env`: `LITELLM_MODEL=provider/your_model_name`. Example: `openai/gpt-5.5`. |\n| **I added multiple provider Keys, why is only one working?** | You mixed the **Simple Mode** and **Channels Mode**! | Choose one path. For simple setups, delete anything starting with `LLM_CHANNELS`. To use multi-model fallbacks, migrate all your Keys into the `LLM_CHANNELS` setup. |\n| **Returns 400, 401, or Invalid API Key** | The API Key is wrong, copied incompletely, account lacks credits, or you mistyped the model name (extremely common). | 1. Ensure there are no spaces at the start/end of your Key.<br> 2. Ensure your Base URL ends with `/v1`.<br> 3. Check if you forgot the `openai/` prefix on the model name! |\n| **Kimi K2.6 returns `invalid temperature` (it may say only `1.0` or `0.6` is allowed)** | The model requires different fixed temperatures for thinking vs non-thinking mode, while older config or call paths may still pass `0.7`. | After this fix, default / thinking `kimi-k2.6` requests automatically use `temperature=1.0`; if you explicitly disable thinking in a LiteLLM YAML route, the request automatically uses `0.6` instead. Prefer `openai/kimi-k2.6` with your Moonshot or relay OpenAI-compatible Base URL and API key. Non-Kimi fallbacks still keep your configured `LLM_TEMPERATURE`. |\n| **GPT-5 / o-series returns that `temperature` is unsupported or only the default is allowed** | These models only accept the provider default sampling parameters, while older call paths may still send `0.7`. | The request layer now omits `temperature` so the provider default is used. Your `.env` / Web `LLM_TEMPERATURE` is not rewritten, and regular models keep using it after you switch back. |\n| **Spins endlessly, eventually hits Timeout/ConnectionRefused** | You are using restricted APIs (like Google/OpenAI) in a blocked region without a proxy, or your cloud server lacks external internet access. | Highly recommend using **official regional APIs** (like DeepSeek) or **OpenAI-compatible relay platforms**. Third-party platforms bypass these network constraints. |\n| **Ollama returns 404, `Could not get model info`, or `api/generate/api/show`** | Using `OPENAI_BASE_URL` for Ollama makes the system concatenate URLs incorrectly | Use `OLLAMA_API_BASE=http://localhost:11434` or channel mode (`LLM_CHANNELS=ollama` + `LLM_OLLAMA_BASE_URL`) instead |\n\n*Veteran's Tip: If you enable **Agent Mode (Deep-thinking & web-search)**, experience shows you should use a stronger model like `deepseek-v4-pro`. Trying to save money by using weak mini-models for agents will likely result in infinite loops or missed objectives.*\n"}}, "docs/LLM_CONFIG_GUIDE.md": {"e8305ba738f90086d2bd7e19474054decb796526": {"t": 1785809883.3506603, "n": 20232, "h": "07193492bdf92e0e", "c": "# LLM (大模型) 配置指南\n\n欢迎!无论你是刚接触 AI 的新手小白,还是精通各种 API 的高玩老手,这份指南都能帮你快速把大模型(LLM)跑起来。\n\n本项目对外提供统一的 AI 模型接入体验,支持主流官方 API、OpenAI 兼容平台以及本地模型。底层由 [LiteLLM](https://docs.litellm.ai/) 驱动,但大多数用户只需要理解“选服务商、填 API Key、选主模型/渠道”这条默认路径。为了照顾不同阶段的用户,我们设计了“三层优先级”配置,按需选择最适合你的方式即可。\n\n如果你正在选择具体服务商、配置 GitHub Actions Secrets / Variables、排查 `details.reason` 错误或准备回滚配置,请优先查看 [LLM 服务商配置指南](./llm-providers.md)。该文档集中维护 provider 预设、Actions 变量对照、运行时能力检测边界和常见错误处理建议。\n\n> 本页的 provider/model/Base URL 说明本次未新增外部兼容语义,仅用于同步现网约定;实际兼容判断仍按当前仓库锁定依赖与运行时实现执行:\n> - 依赖边界:`litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0`(与 `requirements.txt` 一致)。\n> - 兼容验证入口:`tests/test_system_config_service.py`、`tests/test_system_config_api.py` 以及现有前端模型配置页回归用例。\n> - 回退路径:优先使用 `.env` 配置备份 + `POST /api/v1/system/config/import` 恢复;也可在重启前手动回填旧 `LITELLM_MODEL` / `LLM_*` / `AGENT_LITELLM_MODEL` / `VISION_MODEL` / `LLM_TEMPERATURE`。\n\n> **说明**:本页对 provider/model/base URL 的说明同步沿用当前依赖约束与历史约定,仅做文档补充,不引入新的运行时 provider、模型或 Base URL 行为变更。\n\n---\n\n## 快速导航:你应该看哪一节?\n\n1. **【新手小白】** \"我只想赶紧把系统跑起来,越简单越好!\" -> [指路【方式一:极简单模型配置】](#方式一极简单模型配置适合新手)\n2. **【进阶用户】** \"我有好几个 Key,想配置备用模型,还要改自定义网址(Base URL)。\" -> [指路【方式二:渠道(Channels)模式配置】](#方式二渠道channels模式配置适合进阶多模型)\n3. **【高玩老手】** \"我要做复杂的负载均衡、请求路由、甚至多异构平台高可用!\" -> [指路【方式三:YAML 高级配置】](#方式三yaml高级配置适合老手自定义)\n4. **【本地模型】** \"我想用 Ollama 本地模型!\" -> [指路【示例 4:使用 Ollama 本地模型】](#示例-4使用-ollama-本地模型)\n5. **【视觉模型】** \"我想用图片识别股票代码!\" -> [指路【扩展功能:看图模型(Vision)配置】](#扩展功能看图模型vision配置)\n\n---\n\n## 方式一:极简单模型配置(适合新手)\n\n**目标:** 只要记得填入 API Key 和对应的模型名就能立刻用。不需要折腾复杂概念。\n\n如果你只打算用一种模型,这是最快捷的办法。打开项目根目录下的 `.env` 文件(如果没有,复制一份 `.env.example` 并重命名为 `.env`)。\n\n### Anspire Open 示例:\n\n> 💡 **推荐 [Anspire Open](https://open.anspire.cn/?share_code=QFBC0FYC)**:支持中文优化的联网搜索与 OpenAI-compatible 路径一体化体验,适合只准备一个 Key 的用户。\n> - 以下为配置示例,模型与网关可用性以账号权限和 Anspire 控制台为准;文档示例不替代实际连通性验证。\n> - 建议在 Web 设置页点击“测试连接”进行实际鉴权与模型可用性检查,避免以文档默认值直接当作可用性承诺。\n\n```env\n# Anspire Open API Keys(支持多个,逗号分隔)\n# 获取: https://open.anspire.cn/?share_code=QFBC0FYC\n# 满足默认优先级条件时,系统会复用该 Key 处理搜索与 LLM(仅限示例兜底路径)。\n# 示例模型:Doubao-Seed-2.0-lite;示例网关:https://open-gateway.anspire.cn/v6\nANSPIRE_API_KEYS=sk-xxxxxxxxxxxxxxxx\n# 可选:按控制台可用性切换模型或网关\n# ANSPIRE_LLM_MODEL=Doubao-Seed-2.0-pro\n# ANSPIRE_LLM_BASE_URL=https://open-gateway.anspire.ai/v6\n```\n\n### 示例 1:使用通用第三方平台(兼容 OpenAI 格式,推荐)\n\n现在市面上绝大多数第三方聚合平台(例如硅基流动、AIHubmix、阿里百炼、智谱等)都兼容 OpenAI 的接口格式。只要平台提供了 API Key 和 Base URL,你都可以按照以下格式无脑配置:\n\n```env\n# 填入平台提供给你的 API Key\nOPENAI_API_KEY=sk-xxxxxxxxxxxxxxxx\n# 填入平台的接口地址 (非常重要:结尾通常必须带有 /v1)\nOPENAI_BASE_URL=https://api.siliconflow.cn/v1\n# 填入该平台上具体的模型名称(非常重要:注意前面必须加上 openai/ 前缀帮系统识别)\nLITELLM_MODEL=openai/deepseek-ai/DeepSeek-V3 \n```\n\n### 示例 2:使用 DeepSeek 官方接口\n```env\n# 填入你在 DeepSeek 官方平台申请的 API Key\nDEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx\n```\n*兼容提示:仅填这一行时,系统仍会默认使用 `deepseek/deepseek-chat` 并在日志提示迁移。*\n`deepseek-chat` / `deepseek-reasoner` 仍可用于兼容旧配置,但 DeepSeek 官方已标记为 2026/07/24 后废弃;新配置建议通过 Web 快速渠道或显式 `LITELLM_MODEL=deepseek/deepseek-v4-flash` 迁移到 `deepseek-v4-flash` / `deepseek-v4-pro`。\n\n### 示例 3:使用 Gemini 免费 API\n```env\n# 填入你获取的 Google Gemini Key\nGEMINI_API_KEY=AIzac...\n```\n\n### 示例 4:使用 Ollama 本地模型\n```env\n# Ollama 无需 API Key,本地运行 ollama serve 后即可使用\nOLLAMA_API_BASE=http://localhost:11434\nLITELLM_MODEL=ollama/qwen3:8b\n```\n\n> **重要**:Ollama 必须使用 `OLLAMA_API_BASE` 配置,**不要**使用 `OPENAI_BASE_URL`,否则系统会错误拼接 URL(如 404、`api/generate/api/show`)。远程 Ollama 时,将 `OLLAMA_API_BASE` 设为实际地址(如 `http://192.168.1.100:11434`)。当前依赖约束为 `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0`(与 requirements.txt 一致)。\n\n> **恭喜!小白读到这里就可以去运行程序了!**\n> 想测测看通没通?在主目录打开命令行输入:`python scripts/check_env.py --llm`\n\n---\n\n## 方式二:渠道(Channels)模式配置(适合进阶/多模型)\n\n**目标:** 我有多个不同平台的 Key 想要混着用,如果主模型卡了/网络挂了,我希望它能自动切换到备用模型。\n\n**网页端可以直接配:** 你可以启动程序后,在 **Web UI 的“系统设置 -> AI 模型 -> AI 模型接入”** 中非常直观地进行可视化配置!\n\n> **新版编辑体验补充**:对于 DeepSeek、阿里百炼(DashScope)以及其他兼容 OpenAI `/v1/models` 的渠道,设置页现在支持直接点击“获取模型”,从 `{base_url}/models` 拉取可用模型并多选;底层仍会保存为原来的 `LLM_{CHANNEL}_MODELS=model1,model2` 逗号格式。若渠道不支持该接口、鉴权失败或暂时不可达,仍可继续手动填写模型列表,不影响保存。\n\n### 首次启动配置状态\n\n后端提供只读状态接口 `GET /api/v1/system/config/setup/status`,用于判断首次启动闭环中最基础的几类配置是否已经就绪:LLM 主渠道、Agent 渠道、自选股、通知渠道和本地存储。这个接口只读取已保存的 `.env` 与当前进程环境变量,不会重载运行时配置、写入 `.env`、测试真实模型或创建数据库文件;前端向导和后续 smoke run 可以基于该接口逐步接入。\n\n### Web 渠道编辑器的兼容性 / 迁移 / 回退规则\n\n- 预设里的 provider / Base URL / 示例模型只用于**初始化表单**;真正落盘时仍是你当前输入的 `LLM_{CHANNEL}_PROTOCOL`、`LLM_{CHANNEL}_BASE_URL`、`LLM_{CHANNEL}_MODELS`、`LLM_{CHANNEL}_API_KEY(S)`,不会在后台偷偷改成别的 provider 名或 URL。\n- 设置页的“获取模型”只对 `OpenAI Compatible` / `DeepSeek` 渠道调用 `{base_url}/models`;“测试连接”默认只对模型列表首项发起一次最小聊天请求,并在结果中展示后端规范化后的 `resolved_model`。若返回 `details.reason=model_access_denied`(例如 Issue #1208 中已观测到的 SiliconFlow / OpenAI Compatible 经 LiteLLM 返回 `Model disabled`),请把它视为基于 provider 文案的 best-effort 模型可用性诊断,优先确认该模型是否已在当前账号/key 下开通,必要时调整模型顺序或移除不可用模型后重试;未覆盖或语义不同的 provider 文案会继续走兜底诊断。可选的“运行时能力检测”必须由用户显式选择后触发,会额外发起 JSON / tools / stream / vision smoke 请求,结果仅代表当前账号、模型和 endpoint 的一次 best-effort 检测。上述检测返回的 `stage / error_code / details / latency_ms / capability_results` 仅用于结构化诊断提示,**不会写回** `.env`,也不会阻止保存。\n- 若返回 `details.reason=provider_blocked`,表示服务商或中转网关明确拦截了本次请求;它区别于本地网络 / TLS 异常和 `model_access_denied`,应优先检查账号风控、地域或请求来源限制、模型权限、代理商网关策略和内容安全策略。\n- 运行时能力检测会产生真实 LLM 请求,可能带来 token / 图像输入费用、RPM/TPM 限流、余额不足或超时。检测失败可能来自账号权限、模型未开通、endpoint 区域、余额、服务商兼容层或 LiteLLM 转换路径,不等于该 provider 全局不支持对应能力。P3 未对所有真实 provider 做在线 smoke;兼容依据来自当前依赖约束 `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` 下的 LiteLLM `completion()` / OpenAI I/O format / streaming / exception mapping,以及 OpenAI Chat Completions 的 JSON mode、tool calling、streaming 和 vision input 形状。\n- 相关外部来源:LiteLLM Python SDK / OpenAI I/O format / streaming / exception mapping:<https://docs.litellm.ai/>;LiteLLM OpenAI-compatible 路由:<https://docs.litellm.ai/docs/providers/openai_compatible>;OpenAI Chat Completions:<https://platform.openai.com/docs/api-reference/chat/create>;JSON mode:<https://platform.openai.com/docs/guides/structured-outputs?api-mode=chat>;tool calling:<https://platform.openai.com/docs/guides/function-calling?api-mode=chat>;streaming:<https://platform.openai.com/docs/guides/streaming-responses?api-mode=chat>;vision input:<https://platform.openai.com/docs/guides/images-vision?api-mode=chat>。\n- 保存渠道时,只会更新这次提交的 key;不会因为切换渠道模式而静默迁移整个旧配置。唯一会被**同步清理**的是运行时模型引用:如果 `LITELLM_MODEL`、`AGENT_LITELLM_MODEL`、`VISION_MODEL` 或 `LITELLM_FALLBACK_MODELS` 指向了当前已启用渠道里已经不存在的模型,设置页会在保存前把这些失效引用清空/移除,避免运行时继续指向无效模型;即使当前启用渠道没有任何可选模型,也会清理缺少 legacy Key 支撑的托管 provider 旧值。`cohere/*`、`google/*`、`xai/*` 这类直连模型仅用于说明历史 `direct-env` 兼容保留语义,不等于可用性承诺,是否可用请按各厂商官方模型/API 文档再做实际验证。\n- 后端一致性依据:配置校验链路在 `SystemConfigService._validate_llm_runtime_selection`(`src/services/system_config_service.py`)中通过 `_uses_direct_env_provider`(`src/config.py`)判断运行时来源;当前仅 `gemini`、`vertex_ai`、`anthropic`、`openai`、`deepseek` 属于托管 key provider,`cohere`、`google`、`xai` 不在该白名单中,因此会保留为直连模型。\n- 回退方式也保持最小:把对应渠道模型列表改回去后重新选择主模型 / fallback,或直接用桌面端导出备份 / 手动 `.env` 还原之前的 `LLM_*`、`LITELLM_MODEL`、`AGENT_LITELLM_MODEL`、`VISION_MODEL`、`LLM_TEMPERATURE` 即可,不需要额外跑迁移脚本。Web 端如需恢复配置,也可在启用管理员鉴权(`ADMIN_AUTH_ENABLED=true`)后通过 `POST /api/v1/system/config/import` 回滚。\n- 当前仓库对此链路的依赖约束是 `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0`(见 `requirements.txt`);回归覆盖包括 `tests/test_system_config_service.py`、`tests/test_system_config_api.py` 和 `apps/dsa-web/src/components/settings/__tests__/LLMChannelEditor.test.tsx`。\n\n> **外部 provider 示例模型说明**:`cohere/*`、`google/*`、`xai/*` 等 provider 前缀值仅用于说明当前保存清理语义,**不代表该依赖约束内的逐型号可用性保证**。文档或测试中的具体模型名都是配置保留行为样例,不是生产推荐;实际可用性请以对应官方模型文档为准,并结合仓库依赖约束 `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` 复核。\n\n### 回退与兼容性证据\n\n- 依赖约束与静默清理范围:在 `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` 下,保存仅清理失效的 runtime 模型引用(`LITELLM_MODEL`、`AGENT_LITELLM_MODEL`、`VISION_MODEL`、`LITELLM_FALLBACK_MODELS`),`cohere/*`、`google/*`、`xai/*` 等非渠道直连模型会被保留。\n- 回退方式:可直接用桌面端导出备份后通过 `POST /api/v1/system/config/import` 恢复;也可手动把 `.env` 中历史 `LITELLM_* / AGENT_LITELLM_MODEL / VISION_MODEL / LLM_TEMPERATURE` 回填后重启生效。Web 端执行导入前请先开启管理员鉴权(`ADMIN_AUTH_ENABLED=true`)。\n- 回退回归证据:`tests/test_system_config_service.py::test_import_desktop_env_restores_runtime_models_after_cleanup` 覆盖“清理后用桌面导出备份恢复 runtime 引用”。\n- 直连 provider 回归证据:`tests/test_system_config_service.py::SystemConfigServiceTestCase::test_validate_accepts_minimax_model_as_direct_env_provider`、`test_validate_accepts_cohere_model_as_direct_env_provider`、`test_validate_accepts_google_model_as_direct_env_provider`、`test_validate_accepts_xai_model_as_direct_env_provider` 覆盖直连 provider 保留语义。\n- 前端回归命令:`cd apps/dsa-web && npm run lint && npm run build && npm run test -- src/components/settings/__tests__/LLMChannelEditor.test.tsx`。\n- 建议回退操作链路(含设置页刷新):先导出桌面备份,`POST /api/v1/system/config/import` 导入后,再通过 `GET /api/v1/system/config` 刷新页面配置,再确认 `LITELLM_MODEL / AGENT_LITELLM_MODEL / VISION_MODEL / LLM_TEMPERATURE` 与模型列表一致后再继续使用。\n\n### 常用官方文档来源(用于核对预设 provider / Base URL / 模型命名)\n\n- OpenAI Compatible 规范(LiteLLM):<https://docs.litellm.ai/docs/providers/openai_compatible>\n- OpenAI 官方:<https://platform.openai.com/docs/api-reference/chat>\n- DeepSeek 官方:<https://api-docs.deepseek.com/>\n- Anspire Open:<https://open.anspire.cn/?share_code=QFBC0FYC>\n- 阿里百炼 DashScope 兼容模式:<https://help.aliyun.com/zh/model-studio/compatibility-of-openai-with-dashscope>\n- Moonshot / Kimi 官方:<https://platform.moonshot.ai/docs/guide/compatibility>\n- Anthropic 官方:<https://docs.anthropic.com/en/api/messages>\n- Gemini 官方:<https://ai.google.dev/gemini-api/docs/openai>\n- Cohere 官方:<https://docs.cohere.com/>\n- Cohere API 参考:<https://docs.cohere.com/reference/>\n- Cohere LiteLLM Provider:<https://docs.litellm.ai/docs/providers/cohere>\n- Google Gemini API 与模型:<https://ai.google.dev/gemini-api/docs/openai>、<https://ai.google.dev/gemini-api/docs/models>\n- Google LiteLLM Provider:<https://docs.litellm.ai/docs/providers/gemini>\n- xAI 官方:<https://docs.x.ai/docs>\n- xAI LiteLLM Provider:<https://docs.litellm.ai/docs/providers/xai>\n- Ollama 官方:<https://github.com/ollama/ollama/blob/main/docs/api.md>\n\n如果不方便用网页版,在 `.env` 文件中配置也非常丝滑,它能让你同时管理多个第三方平台。规则如下:\n\n1. **先声明你有几个渠道**:`LLM_CHANNELS=渠道名称1,渠道名称2`\n2. **给每个渠道分别填写配置**(注意全大写):`LLM_{渠道名}_XXX`\n\n### 示例:同时配置 DeepSeek 和某中转平台,并设置备用切换\n```env\n# 1. 开启渠道模式,声明这里有两个渠道:deepseek 和 aihubmix\nLLM_CHANNELS=deepseek,aihubmix\n\n# 2. 渠道一:配置 DeepSeek 官方\nLLM_DEEPSEEK_BASE_URL=https://api.deepseek.com\nLLM_DEEPSEEK_API_KEY=sk-1111111111111\nLLM_DEEPSEEK_MODELS=deepseek-v4-flash,deepseek-v4-pro\n\n# 3. 渠道二:配置一个常用的聚合中转 API\nLLM_AIHUBMIX_BASE_URL=https://api.aihubmix.com/v1\nLLM_AIHUBMIX_API_KEY=sk-2222222222222\nLLM_AIHUBMIX_MODELS=gpt-5.5,claude-sonnet-4-6\n\n# 4. 【关键】指定主模型和备用模型列表\n# 平时首选用 deepseek 这款模型:\nLITELLM_MODEL=deepseek/deepseek-v4-flash\n# 可选:Agent 问股单独指定主模型(留空则继承主模型)\nAGENT_LITELLM_MODEL=deepseek/deepseek-v4-pro\n# 主模型崩了立刻挨个尝试下面这俩备用模型:\nLITELLM_FALLBACK_MODELS=openai/gpt-5.4-mini,anthropic/claude-sonnet-4-6\n```\n\n### 示例:Ollama 渠道模式(本地模型,无需 API Key)\n```env\n# 1. 开启渠道模式,声明 ollama 渠道\nLLM_CHANNELS=ollama\n\n# 2. 配置 Ollama 地址(本地默认 11434 端口)\nLLM_OLLAMA_BASE_URL=http://localhost:11434\nLLM_OLLAMA_MODELS=qwen3:8b,llama3.2\n\n# 3. 指定主模型\nLITELLM_MODEL=ollama/qwen3:8b\n```\n\n### MiniMax 渠道模型填写说明\n\n- 如果你通过 OpenAI Compatible 渠道接 MiniMax,请在渠道模型里直接填写 `minimax/<模型名>`,例如 `minimax/MiniMax-M1`。\n- Web 设置页里的主模型、Agent 主模型、Fallback、Vision 下拉会保留这个值原样展示,不会再错误改写成 `openai/minimax/<模型名>`。\n\n### 问股 Agent / LiteLLM 配置兼容说明\n\n- 问股 Agent 运行时沿用与普通分析相同的三层优先级:`LITELLM_CONFIG`(LiteLLM YAML)> `LLM_CHANNELS` > legacy provider keys。只要上层配置有效生效,下层配置就不会再参与本次请求。\n- YAML 模式下,Agent 直接复用 LiteLLM `model_list` / `model_name` 路由语义;渠道模式下,优先读取 `AGENT_LITELLM_MODEL`,留空时继承 `LITELLM_MODEL`,再按 `LITELLM_FALLBACK_MODELS` 继续 fallback。\n- 如果你没有启用 YAML / Channels,且 `AGENT_LITELLM_MODEL` 也留空,但本地仍保留 legacy 环境变量,问股 Agent 依然会继承旧配置:`GEMINI_API_KEY + GEMINI_MODEL` -> `gemini/<model>`,`OPENAI_API_KEY + OPENAI_MODEL` -> `openai/<model>`,`ANTHROPIC_API_KEY + ANTHROPIC_MODEL` -> `anthropic/<model>`。\n- 该兼容逻辑只增强“失败时保留后端真实错误原因”和“未配置 LLM 时给出更具体诊断”,**不会**静默删除、清空、迁移或改写你现有的 `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `LITELLM_*` 配置。\n- 如果当前环境没有任何有效 Agent 模型链路,问股页面会继续按失败语义返回,并直接展示后端真实配置诊断;补齐任一有效模型来源后即可恢复,无需额外执行配置迁移脚本。\n- 推荐的新配置方式仍然是显式设置 `LITELLM_MODEL` / `AGENT_LITELLM_MODEL` 或使用 `LLM_CHANNELS`;legacy provider keys 目前保留为兼容回退路径,方便旧 `.env`、本地 macOS 开发环境和历史部署平滑继续运行。\n\n### 严格 temperature 模型兼容说明\n\n- Moonshot 官方说明 Kimi API 兼容 OpenAI 接口,Base URL 使用 `https://api.moonshot.ai/v1`:<https://platform.kimi.ai/docs/guide/kimi-k2-6-quickstart>\n- LiteLLM 官方要求 OpenAI Compatible 渠道模型名使用 `openai/` 前缀:<https://docs.litellm.ai/docs/providers/openai_compatible>\n- Moonshot 官方兼容性文档区分两种固定值:**thinking 模式固定 `1.0`,non-thinking 模式固定 `0.6`**;传其它值会被接口拒绝:<https://platform.moonshot.ai/docs/guide/compatibility#parameters-differences-in-request-body>\n- OpenAI Chat Completions 规范中 `temperature` 是可选参数;对 GPT-5 / o 系列等只接受默认温度的模型,本项目会在请求层省略 `temperature`,让服务端使用默认值,而不是改写你的 `LLM_TEMPERATURE`:<https://platform.openai.com/docs/api-reference/chat/create>\n- 当前仓库的运行时依赖约束是 `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0`(见 `requirements.txt`);本次兼容逻辑按该约束回归验证了主分析、大盘复盘、Agent 直连 LiteLLM,以及系统设置页的渠道连通性测试。\n- 因此本项目会在请求发出前按**实际请求模式**归一化 `kimi-k2.6` 及其 `kimi-k2.6-*` 变体:默认 / thinking 路径使用 `temperature=1.0`;如果你的 LiteLLM YAML 路由别名里显式写了 `litellm_params.extra_body.thinking.type: disabled`(或等价 non-thinking 配置),则自动切到 `temperature=0.6`。你在 `.env` 或 Web 设置里保存的 `LLM_TEMPERATURE` 不会被改写。\n- 如果兼容平台对未收录的新模型返回明确的参数错误(例如 `temperature` 不支持、只能使用默认 `1.0`、`top_p` 不支持),运行时会对**当前请求**做一次参数修正并重试;只有重试成功后才把该策略缓存在当前进程内。该缓存不会写回 `.env`,服务重启后会重新按配置与适配规则判断。\n- 对已经产生部分内容的流式响应,系统不会在半截输出后切换参数;仍沿用原有“同模型非流式重试 / fallback 模型”的稳定路径,避免拼接出不一致的回答。\n- `SystemConfigService` 在 Web 设置保存 / 桌面端 `.env` 导入时只更新你提交的 key,不会因为切到严格 temperature 模型静默清空、迁移或重写已有 `LLM_TEMPERATURE`;渠道测试请求里的临时参数策略也不会回写到配置文件。\n- 非严格主模型、非严格 fallback 以及切回普通模型后的请求,仍继续使用你配置的温度;也就是说旧配置无需迁移,切换模型即可自动恢复原行为。\n- 本仓库兼容性回归覆盖见:`tests/test_llm_channel_config.py`、`tests/test_market_analyzer_generate_text.py`、`tests/test_agent_pipeline.py`、`tests/test_system_config_service.py`。\n- 最小回滚方式:直接回退本次 LLM 参数适配相关改动,无需单独迁移已有 `LLM_TEMPERATURE` 配置。\n\n### 兼容性与回退复核清单(按 PR 审核口径)\n\n- 运行时依赖约束:`litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0`(与 `requirements.txt` 一致)。\n- 回归验证入口:\n - 渠道模型发现与连接:`tests/test_llm_channel_config.py`\n - 运行时源清理与恢复(含桌面导出备份链路):`tests/test_system_config_service.py`\n - 接口校验与问题面向字段:`tests/test_system_config_api.py`\n - 设置页交互与保存后提示:`apps/dsa-web/src/components/settings/__tests__/LLMChannelEditor.test.tsx`\n- 旧配置回退路径:`桌面端导出备份 -> /api/v1/system/config/import`,或手动恢复 `LLM_* / LITELLM_* / AGENT_LITELLM_MODEL / VISION_MODEL / LLM_TEMPERATURE`;Web 导入备份前同样要求 `ADMIN_AUTH_ENABLED=true`,否则会返回 403。\n\n> **致命避坑说明**:如果你启用了 `LLM_CHANNELS`,那么你直接写在外面的 `DEEPSEEK_API_KEY` 或 `OPENAI_API_KEY` 将**全部失效(系统一律无视)**!二者**选其一即可**,千万不要既写了新手模式又写了渠道模式结果产生冲突。\n> **Docker 注意**:如果你在 `docker compose environment:` 或 `docker run -e` 中显式传入 `LITELLM_MODEL`、`LLM_CHANNELS`、`LLM_DEEPSEEK_MODELS` 等变量,容器重启后这些环境变量会覆盖 Web 设置页写入的 `.env`,需要同步修改部署配置。\n\n### 兼容依据与回退审计说明(本次 PR 适配说明)\n\n- 官方与运行时兼容依据采用两层:第一层为官方接口语义(LiteLLM OpenAI-compatible 路由、OpenAI Chat Completions、Moonshot/Kimi 文档与官方模型说明);第二层为本仓库当前运行时语义(`litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0`)下的实际错误归类。\n- 本次兼容恢复只使用“本地运行时错误归类 + 单请求修正重试 + 进程内缓存”策略,不写入 `.env`、不做配置迁移,仅在执行路径上动态规避不支持参数(`temperature`、`top_p`、`presence_penalty`、`frequency_penalty`、`seed`)。若要回退,不需要额外迁移命令,恢复旧值即可。\n- 回归与证据:`tests/test_llm_param_recovery.py`、`tests/test_system_config_service.py`、`tests/test_llm_channel_config.py`、`tests/test_system_config_api.py`、`tests/test_market_analyzer_generate_text.py`、`tests/test_agent_pipeline.py`;桌面导入与运行时清理回退另有 `test_import_desktop_env_restores_runtime_models_after_cleanup` 直接覆盖。\n\n---\n\n## 方式三:YAML 高级配置(适合老手自定义)\n\n**目标:** 我不在乎学习门槛,我要最高控制权,我要用原生规则做企业级高可用!\n\n这一层会直接映射到底层 LiteLLM 路由能力,支持高并发、自动重试、按 RPM/TPM 负载均衡等操作。\n\n### 本地运行 / Docker 部署模式配置说明\n\n1. 在 `.env` 中只保留一行指向声明:\n ```env\n LITELLM_CONFIG=./litellm_config.yaml\n ```\n2. 在项目根目录创建一个 `litellm_config.yaml`(可以参考自带的 `docs/examples/litellm_config.example.yaml`)。\n\n示例 `litellm_config.yaml`:\n```yaml\nmodel_list:\n - model_name: my-smart-model\n litellm_params:\n model: deepseek/deepseek-v4-flash\n api_base: https://api.deepseek.com\n api_key: \"os.environ/MY_CUSTOM_SECRET_KEY\" # 从环境变量读取 Key,安全防泄漏\n\n # Ollama 本地模型(无需 api_key)\n - model_name: ollama/qwen3:8b\n litellm_params:\n model: ollama/qwen3:8b\n api_base: http://localhost:11434\n```\n\n### GitHub Actions配置说明\n\n1. `Settings` → `Secrets and variables` → `Actions`。非敏感配置(如模型名、开关、Base URL)可以放在 `Secret` 或 `Variables`;凡是 `*_API_KEY` / `*_API_KEYS` 以及 `LLM_<NAME>_API_KEY` / `LLM_<NAME>_API_KEYS` 这类密钥字段,请统一放在 `Secret` 标签页的 `New repository secret`\n\n2. 按下表配置,只有全部必填配置正确配置,YAML 高级配置模式才可以生效,YAML配置文件的写法,可以参考自带的 `docs/examples/litellm_config.example.yaml`\n\n| Secret 名称 | 说明 | 必填 |\n|------------|------|:----:|\n| `LITELLM_CONFIG` | 高级模型路由配置文件路径,通常配置 `./litellm_config.yaml` | 必填 |\n| `LITELLM_MODEL` | 默认主模型名称或路由别名 | 必填 |\n| `LITELLM_CONFIG_YAML` | 存放 YAML 配置文件内容,可不在仓库中提交实体文件 | 可选 |\n| `LITELLM_API_KEY` | 用于存储API Key,可在配置文件中引用(环境变量引用方式)。由于GitHub Actions必须要指定导入的环境变量,因此你不能像本地运行模式那样自由命名环境变量 | 可选,必须配置到repository secret中 |\n| `ANTHROPIC_API_KEY` | 如果要多个API Key,这个变量名称也能拿来用 | 可选,必须配置到repository secret中 |\n| `OPENAI_API_KEY` | 同上,可以用来存储API Key | 可选,必须配置到repository secret中 |\n\n渠道模式无需上传 YAML 文件。仓库自带 `00-daily-analysis.yml` 已显式透传以下常用字段:\n\n- 运行时选择:`LLM_CHANNELS`、`LITELLM_MODEL`、`LITELLM_FALLBACK_MODELS`、`AGENT_LITELLM_MODEL`、`VISION_MODEL`、`VISION_PROVIDER_PRIORITY`、`LLM_TEMPERATURE`\n- 多 Key:`GEMINI_API_KEYS`、`ANTHROPIC_API_KEYS`、`OPENAI_API_KEYS`、`DEEPSEEK_API_KEYS`(当前 workflow 仅从 repository secrets 导入,不会读取同名 Variables)\n- 常用渠道名:`primary`、`secondary`、`aihubmix`、`deepseek`、`dashscope`、`zhipu`、`moonshot`、`minimax`、`volcengine`、`siliconflow`、`openrouter`、`gemini`、`anthropic`、`openai`、`ollama`\n\n例如在 GitHub Actions 中配置 `LLM_CHANNELS=primary,deepseek` 时,需同步配置 `LLM_PRIMARY_*` / `LLM_DEEPSEEK_*`。其中 `LLM_<NAME>_API_KEY` / `LLM_<NAME>_API_KEYS` 当前也仅从 repository secrets 导入;如果你把这些值放在 Variables,运行时不会生效。若使用自定义渠道名(如 `my_proxy`),GitHub Actions 还必须在 workflow `env:` 中显式新增对应的 `LLM_MY_PROXY_*` 映射;本地 `.env` 和 Docker 不受这个限制。\n\n\n> **三层配置互斥准则**:YAML 优先级最高!只要配置了 YAML,**渠道模式** 和 **新手极简模式** 统统被忽略。系统优先级为:`YAML配置 > 渠道模式 > 极简单模型`。\n\n---\n\n## 扩展功能:看图模型 (Vision) 配置\n\n系统中有些特定功能(比如上传股票软件截图,让 AI 提取出截图里的股票代码并放入自选股池)必须用到具备“视觉能力”的模型。你需在 `.env` 单独给它指派一个懂图片的模型。\n\n```env\n# 指定你看图专用的模型名\nVISION_MODEL=openai/gpt-5.5\n# 别忘了填写它对应提供商的 API KEY,如果是 OpenAI 兼容渠道就提供 OPENAI_API_KEY:\n# OPENAI_API_KEY=xxx\n```\n\n**备用看图机制:** 为了防止偶尔罢工,系统内置了切换策略。如果主视觉模型调用失败,它会按照下方的顺位尝试寻找是否有其他看图模型的 Key:\n```env\n# 默认的备用顺序:\nVISION_PROVIDER_PRIORITY=gemini,anthropic,openai\n```\n\n---\n\n## 检测与排错 (Troubleshooting)\n\n配好了之后心惊胆战不知道对不对?在命令行(Terminal)里敲入下面代码帮你挂号问诊:\n\n- `python scripts/check_env.py --config` :纯检测 `.env` 配置文件里的逻辑写得对不对,是不是少写了什么。(秒出结果,不调用网络,纯检查本地文本拼写)\n- `python scripts/check_env.py --llm` :系统会真的发一句问候语给大模型,让你亲眼看到他的回答。这能彻底测出你的**网络通不通、账号有没有欠费**。\n\n### 常见踩坑答疑台\n\n| 遇到了什么诡异报错? | 罪魁祸首可能是啥? | 该怎么收拾它? |\n|----------------------|----------------------|------------------|\n| **界面提示主模型未配置** | 系统不知道你到底想用哪家的哪个模型 | 在 `.env` 中写上一句明白话:`LITELLM_MODEL=provider/你的模型名`。比如 `openai/gpt-5.5` |\n| **我写了好几家的Key,为什么死活只有一个生效?修改还没用?** | 你把 **极简模式** 和 **渠道模式** 混着写了! | 想好一条路走到黑——只要简单就删掉 `LLM_CHANNELS` 开头的;想要丰富备用切换就要全部转投到 `LLM_CHANNELS` 下的编制里。 |\n| **错误码报 400 或 401 或 Invalid API Key** | API Key 填错、少复制了一截、账号充值没到账、或者模型名字敲错(极度常见)。 | 1. 检查复制的 Key 前后是否有误填空格。<br> 2. 检查 Base URL 最后是不是少了一个 `/v1`。<br> 3. 检查模型名是否少写了 `openai/` 之类的前缀! |\n| **Kimi K2.6 报 `invalid temperature`(可能提示只允许 `1.0` 或 `0.6`)** | 该模型按 thinking / non-thinking 模式要求不同固定 temperature;旧配置或调用入口可能还在传 `0.7`。 | 升级后系统会对 `kimi-k2.6` 默认 / thinking 请求自动使用 `temperature=1.0`;如果你在 LiteLLM YAML 路由里显式关闭 thinking,则自动改用 `0.6`。模型名建议写成 `openai/kimi-k2.6` 并配合 Moonshot / 聚合平台的 OpenAI 兼容 Base URL 与 API Key。非 Kimi fallback 仍会继续使用你配置的 `LLM_TEMPERATURE`。 |\n| **GPT-5 / o 系列报 `temperature` 不支持或只允许默认值** | 这类模型只接受服务端默认采样参数,但旧调用入口会显式传 `0.7`。 | 升级后请求层会省略 `temperature`,让服务端使用默认值;`.env` / Web 设置中的 `LLM_TEMPERATURE` 不会被改写,切回普通模型后仍按原值发送。 |\n| **转圈转不停,最后报 Timeout / ConnectionRefused 等** | 1. 在国内使用国外原版(像 Google、OpenAI),没开代理被墙了。<br>2. 你买的云服务器压根不能出境。 | 非常推荐使用**国内官方**(如DeepSeek、阿里)或者各种**兼容 OpenAI 的聚合中转接口**。因为中转站把网络问题帮你解决好了。 |\n| **Ollama 报 404、`Could not get model info` 或 `api/generate/api/show`** | 误用 `OPENAI_BASE_URL` 配置 Ollama,系统会错误拼接 URL | 改用 `OLLAMA_API_BASE=http://localhost:11434` 或渠道模式(`LLM_CHANNELS=ollama` + `LLM_OLLAMA_BASE_URL`) |\n\n*进阶老手的叮嘱:如果你开启了 **Agent (深度思考网络搜索问股) 模式**,这里有个经验之谈,推荐选用如 `deepseek-v4-pro` 这种逻辑推导能力更强的大模型。如果为了省钱用小微模型跑 Agent,它逻辑能力大概率跟不上,不仅达不到预期,还会白跑一堆空流程。*\n"}}}}