mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
feat: add JP KR market review support (#1822)
* feat: add JP KR market review support * fix(review-feedback-1822): update the Market Light schema/service support before accepting these * fix(review-feedback-1822): add JP/KR to daily market context before accepting them and update * fix(review-feedback-1822): add English JP/KR strategy text or make the renderer language-aware * fix(review-feedback-1822): update the prompt role/shell alongside the new accepted regions * fix(review-feedback-1822): 修正 PR 描述中的过期验证结论,并补充/澄清 JP/KR Yahoo Finance 指数兼容性证据或在线验证边界 * fix(review-feedback-1822): 收敛 Market Light 告警契约与用户文档同步问题 * fix(review-feedback-1822): 修正后再合入 * fix(review-feedback-1822): 补齐外部 Yahoo Finance 指数接入与运行时配置变更的兼容性/迁移证据,并修正 PR 描述中与当前 CI 状态不一致的内容 * fix(review-feedback-1822): 当前 CI 状态为 failure,且阻断型 backend-gate 失败 * fix(review-feedback-1822): 修复 MARKET REVIEW REGION 逗号值在交易日过滤与配置 schema 中的契约漂移,并补充对应回归测试 * fix(review-feedback-1822): 收敛 PR 描述中的验证状态与用户可见 Web 改动证据 * fix(review-feedback-1822): 修正 PR 描述中的过期验证结论,并补充 Web 设置变更的截图或无法截图时的替代可视证据说明 * fix(review-feedback-1822): 收敛 PR 描述中的验证状态,并补齐 Web 设置变更的截图或替代可视证据 * fix(review-feedback-1822): 收敛 PR 描述后再合入 * fix(review-feedback-1822): 收敛 PR 描述中的验证状态,并补充 Web 设置变更截图或无法截图时的替代可视证据说明 * fix(review-feedback-1822): 收敛验证状态和 Web UI 可视证据,避免合入记录与实际 head 不一致 * fix(review-feedback-1822): 收敛 PR 描述中的验证状态与 Web 可视证据 * fix(review-feedback-1822): 收敛 PR 描述中的验证状态,并补齐 Web 设置变更的截图或替代可视证据说明 * fix(review-feedback-1822): 修正 PR 描述与当前 CI 事实不一致的问题,补充 Web 设置可视证据,并收敛或拆出 PR 模板改动 * fix(review-feedback-1822): 收敛 PR 描述与证据 * fix(review-feedback-1822): 修正 PR 描述与证据,使验证状态、用户可见变更证据、模板改动范围和当前 head 保持一致 * fix(review-feedback-1822): 收敛 PR 描述、补齐可视证据,并澄清/补充外部模型/API 与运行时配置迁移相关兼容性证据 * fix(review-feedback-1822): 收敛 Web/文档契约不一致、同步残留测试,并更新 PR 描述与可视证据
This commit is contained in:
@@ -753,7 +753,8 @@ RUN_IMMEDIATELY=true
|
||||
MARKET_REVIEW_ENABLED=true
|
||||
# 是否将大盘环境摘要注入个股分析 Prompt 并启用保守护栏(true/false,默认开启)
|
||||
DAILY_MARKET_CONTEXT_ENABLED=true
|
||||
# 大盘复盘市场区域:cn(A股)、hk(港股)、us(美股)、both(全部市场),hk/us 适合仅关注港股/美股的用户
|
||||
# 大盘复盘市场区域:cn(A股)、hk(港股)、us(美股)、jp(日股)、kr(韩股)、both(全部市场);
|
||||
# 支持逗号子集(如 cn,us,kr);非法值/空值会回退为 cn。cn/hk/us/jp/kr 适合仅关注对应单区域的用户
|
||||
# MARKET_REVIEW_REGION=cn
|
||||
# 大盘复盘指数涨跌颜色:green_up=绿涨红跌(默认),red_up=红涨绿跌
|
||||
# MARKET_REVIEW_COLOR_SCHEME=green_up
|
||||
|
||||
55
.github/PULL_REQUEST_TEMPLATE.md
vendored
55
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -24,6 +24,8 @@ For English contributors: please fill in English. All fields marked (EN) accept
|
||||
|
||||
> 注意:请按实际 `git diff` 全量列出文件范围(建议注明文件总数),避免遗漏文档/后端/API/前端文件导致描述不一致。
|
||||
|
||||
> 若本 PR 修改了 `.github/PULL_REQUEST_TEMPLATE.md`、`.github/copilot-instructions.md`、`AGENTS.md`、`.github/instructions/*` 或 `.claude/skills/**` 等协作与治理文件,请补充“变更原因 + 影响面 + 回滚方式(默认 revert)”到 Summary / Compatibility / Rollback,避免 Scope 与描述不一致。
|
||||
|
||||
> 建议先执行并粘贴以下命令输出,避免与实际 diff 不一致:
|
||||
|
||||
```bash
|
||||
@@ -54,23 +56,57 @@ git diff --name-only "$BASE_REF"..HEAD
|
||||
python -m pytest -m "not network"
|
||||
```
|
||||
|
||||
> `Full-suite note` 必须与当次 PR 的当前 Head CI 结果保持一致;若本地复现存在环境相关失败,请明确标注“本地环境差异”并给出 GitHub CI 的结论与链接。
|
||||
> 请避免保留与本 PR 无关的历史失败措辞,按本次实际结果填报。
|
||||
> 如历史描述中仍保留 `./scripts/ci_gate.sh` 失败记录,请先改为当前 Head CI 状态或说明与 Head CI 的差异来源。
|
||||
> 若 `Full-suite note` 与当前 Head CI 不一致,PR 文本不完整,请先更新 PR 描述后再提交。
|
||||
|
||||
- 请在下面按实际结果填写并与 `Full-suite note` 保持一致(任一未填视为信息缺失):
|
||||
- ai-governance:`pass` / `fail`,附链接
|
||||
- backend-gate:`pass` / `fail`,附链接
|
||||
- docker-build:`pass` / `fail`,附链接
|
||||
- web-gate:`pass` / `fail`,附链接
|
||||
- 若本 PR 修改 `.github/PULL_REQUEST_TEMPLATE.md` 等流程模板协作文件,请先说明变更必要性、影响边界,并明确回滚方式(默认 `revert this PR`);否则请在下一版中拆为单独 chore PR。
|
||||
|
||||
关键输出/结论 / Key output & conclusion:
|
||||
|
||||
- 【必填】当前 Head CI:`ai-governance:pass / backend-gate:pass / docker-build:pass / web-gate:pass`(按实际结果替换)并附对应链接。
|
||||
- 若需保留本地失败现象,请在同段写明“本地环境差异 + 当前 CI 通过/失败结果 + CI 链接”。
|
||||
- 若全部通过,需补充一句:`当前状态:全部通过(pass)`,并明确 Head CI 全部为 pass。
|
||||
|
||||
- 建议将本行直接粘贴到 PR 描述正文首段:`当前 Head CI:ai-governance:pass / backend-gate:pass / docker-build:pass / web-gate:pass`(仅示例,按实际结果替换)。
|
||||
|
||||
> 若上述核验项与 PR 文本冲突,建议先更新 PR 描述再提交,避免审查因状态不一致被阻塞。
|
||||
|
||||
## Visual Evidence (if applicable)
|
||||
|
||||
若本 PR 修改报告格式、报告渲染效果或 Web UI 界面,请在此处附受影响报告 / 页面截图;涉及前后差异时,优先附前后对比。Issue / PR 过程截图、审查截图、一次性验收截图和临时可视证据请放在 PR 描述、PR 评论、GitHub 附件、Actions artifact 或外部可访问链接中,不要作为仓库文件合入。
|
||||
【必填】若本 PR 修改报告格式、报告渲染效果或 Web UI 界面,请在此处附受影响报告 / 页面截图;涉及前后差异时,优先附前后对比。Issue / PR 过程截图、审查截图、一次性验收截图和临时可视证据请放在 PR 描述、PR 评论、GitHub 附件、Actions artifact 或外部可访问链接中,不要作为仓库文件合入。
|
||||
*(EN) If this PR changes report formatting, report rendering, or Web UI, attach screenshots of the affected report/page here; before/after screenshots are preferred when relevant. Issue/PR process screenshots, review screenshots, one-off acceptance screenshots, and temporary visual evidence should be linked from the PR body/comments, GitHub attachments, Actions artifacts, or external accessible evidence; do not commit them as repository files.)*
|
||||
|
||||
> 如截图无法获取,请在“原因”中明确写明替代证据(如 Playwright/e2e 产物路径、审查链接)及其可追溯命令,不得留空。
|
||||
> 如截图无法获取,请在“原因”中明确写明替代证据(如 Playwright/e2e 产物路径、审查链接)及其可追溯命令,不得留空。涉及 Web 设置/报告渲染变更时,需确保截图或替代证据明确指向变更项。
|
||||
>
|
||||
> 若本 PR 修改 Web UI,建议至少补一条可复现路径,例如:
|
||||
> 若本 PR 修改 Web UI,建议至少补一条可复现路径,例如(优先 settings page):
|
||||
>
|
||||
> - Playwright 截图产物:`apps/dsa-web/e2e/smoke.spec.ts`(`npx playwright test apps/dsa-web/e2e/smoke.spec.ts --grep "backtest page renders filter controls after login"`)
|
||||
> - Playwright 截图产物:`apps/dsa-web/e2e/smoke.spec.ts`(`cd apps/dsa-web && npx playwright test e2e/smoke.spec.ts --grep "settings page renders title and save actions after login"`)
|
||||
> - 审查证据链接:可直接使用 Actions 产物、GitHub 评论附件或外部可访问链接。
|
||||
|
||||
- 截图链接 / Screenshot links(必填):
|
||||
> 替代证据模板(设置页变更建议):
|
||||
> - 命令:`cd apps/dsa-web && npx playwright test e2e/smoke.spec.ts --grep "settings page"`
|
||||
> - 产物路径:`apps/dsa-web/test-results/**/smoke-settings-page-*.png`
|
||||
> - 说明:截图中应可见本次修改的系统设置项(字段、标签、帮助文案)
|
||||
|
||||
- 截图链接 / Screenshot links(Web UI/报告改动项必填,未提供请在下方“不适用原因”给出替代证据):
|
||||
- settings 页建议命名:`smoke-settings-page-zh` / `smoke-settings-page-en`
|
||||
- 前后对比 / Before & After(如有):
|
||||
- 不适用原因 / Reason if not applicable(必填):
|
||||
- settings 字段变更说明:截图或产物应明确包含 `MARKET_REVIEW_REGION` 字段与帮助文案区块(中文/英文)。
|
||||
- 不适用原因 / Reason if not applicable(若未附截图,此项务必填写,且包含可复现证据与命令):
|
||||
- Playwright 命令(无截图时):`cd apps/dsa-web && npx playwright test e2e/smoke.spec.ts --grep "settings page"`
|
||||
- 产物路径(无截图时):`apps/dsa-web/test-results/**/smoke-settings-page-*.png`
|
||||
- 说明:截图(或产物)必须可见本次修改的设置字段文案与帮助信息。
|
||||
|
||||
> 若本 PR 修改 Web 设置字段(字段、文案或帮助文案),截图或替代证据必须可定位到对应设置项区域并可追溯至变更项;该项为必填。
|
||||
|
||||
> 若本 PR 修改 Web UI 或报告展示且无法获取截图,原因栏必须给出可复现替代证据(例如 Playwright 截图产物路径 + 命令),且不得留空。
|
||||
|
||||
## Compatibility And Risk
|
||||
|
||||
@@ -78,11 +114,17 @@ python -m pytest -m "not network"
|
||||
*(EN) Describe compatibility impact and potential risks (write `None` if not applicable).*
|
||||
|
||||
- 若本 PR 修改第三方模型 / API 的兼容语义、请求参数、路由前缀或 provider fallback,请提供**官方来源链接或公告**,并说明这是长期约束、当前运行时约束还是临时兼容处理。
|
||||
请在下方补充所影响外部 API/服务、回归范围与回退方式。
|
||||
*(EN) If this PR changes third-party model/API compatibility, request parameters, routing prefixes, or provider fallback behavior, include an **official source link or announcement** and clarify whether the rule is permanent, runtime-specific, or a temporary compatibility workaround.)*
|
||||
- 若本 PR 未触及第三方模型/API、provider/model/base URL 或运行时配置保存/清理/迁移逻辑,请在此段直接按以下文案确认(无须再次展开):
|
||||
`本 PR 未变更 provider/model/base URL、运行时配置清理迁移语义;历史配置保持不变;回滚方式为 revert 本提交。`
|
||||
- 若本 PR 修改 `.github/PULL_REQUEST_TEMPLATE.md` / PR 流程模板类文件,请在此明确:仅影响协作流程与模板维护,不改 runtime 行为;回退方式为 revert;并补充是否影响自动化提交流程。
|
||||
*(EN) If this PR changes `.github/PULL_REQUEST_TEMPLATE.md` or other PR workflow files, state that it only affects contribution governance templates (no runtime behavior), provide rollback by revert, and note any CI/checklist impact.)*
|
||||
- 若本 PR 依赖特定运行时 / 锁定依赖窗口(例如 LiteLLM 版本范围、OpenAI-compatible 路由、YAML alias 行为),请写明当前验证过的兼容范围与覆盖路径。
|
||||
*(EN) If this PR depends on a specific runtime or pinned dependency window (for example a LiteLLM version range, OpenAI-compatible routing, or YAML alias behavior), state the compatibility window you verified and which code paths were covered.)*
|
||||
- 若本 PR 触及运行时配置保存、清理、迁移或回填逻辑,请明确说明旧配置是否会被自动改写、清空、迁移或保持不变,以及用户如何恢复原行为。
|
||||
*(EN) If this PR touches runtime config save/cleanup/migration/backfill logic, explicitly describe whether existing config is rewritten, cleared, migrated, or left intact, and how users can restore the previous behavior.)*
|
||||
- 若本 PR **未触及** provider/model/base URL 或运行时配置保存/清理/迁移逻辑(本条仅作为声明),请明确写:`本 PR 未变更 provider/model/base URL、运行时配置清理迁移语义;历史配置保持不变;回滚方式为 revert 本提交。`
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
@@ -113,4 +155,5 @@ python -m pytest -m "not network"
|
||||
- [ ] 已评估兼容性与风险 / Compatibility and risk have been assessed
|
||||
- [ ] 已提供回滚方案 / A rollback plan is provided
|
||||
- [ ] 若修改报告格式或 Web UI 界面,已在 PR 描述/评论附受影响报告 / 页面截图,且未把一次性验收截图作为仓库文件合入 / If report formatting or Web UI changed, affected report/page screenshots are linked in the PR body/comments and one-off acceptance screenshots are not committed as repository files
|
||||
- [ ] 若本 PR 修改 Web 设置字段(字段、文案或帮助文本),请补充设置页截图;无法截图时需提供替代可视证据(命令 + 产物路径),并指向对应变更项 / If Web settings fields changed (labels or help text), screenshots of the settings page are required; if unavailable, provide alternative visual evidence with command + artifact path that points to the changed item.
|
||||
- [ ] 若涉及用户可见变更,已同步更新相关文档与 `docs/CHANGELOG.md`;`README.md` 仅在首页级信息变化时更新,细节优先写入 `docs/*.md` / If user-visible changes are included, relevant docs and `docs/CHANGELOG.md` are updated; `README.md` is updated only for homepage-level changes, with details kept in `docs/*.md`
|
||||
|
||||
@@ -93,11 +93,6 @@ const STOP_LOSS_MODE_OPTIONS = [
|
||||
{ value: 'breach', label: '已触发止损' },
|
||||
];
|
||||
|
||||
const MARKET_REGION_OPTIONS = [
|
||||
{ value: 'cn', label: 'A 股(cn)' },
|
||||
{ value: 'hk', label: '港股(hk)' },
|
||||
{ value: 'us', label: '美股(us)' },
|
||||
];
|
||||
|
||||
const MARKET_LIGHT_STATUS_OPTIONS: Array<{ value: MarketLightStatus; label: string }> = [
|
||||
{ value: 'red', label: '红灯' },
|
||||
@@ -453,7 +448,7 @@ export const AlertRuleForm: React.FC<AlertRuleFormProps> = ({ onSubmit, isSubmit
|
||||
<Select
|
||||
label={text.marketRegion}
|
||||
value={marketRegion}
|
||||
options={language === 'zh' ? MARKET_REGION_OPTIONS : ALERT_MARKET_REGION_OPTIONS[language]}
|
||||
options={ALERT_MARKET_REGION_OPTIONS[language]}
|
||||
disabled={isSubmitting}
|
||||
onChange={(value) => setMarketRegion(value as MarketRegion)}
|
||||
/>
|
||||
|
||||
@@ -234,6 +234,18 @@ describe('AlertRuleForm', () => {
|
||||
expect(screen.queryByText('组合回撤')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows JP/KR options for market region in Chinese UI mode', () => {
|
||||
render(<AlertRuleForm onSubmit={onSubmit} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('目标范围'), { target: { value: 'market' } });
|
||||
|
||||
expect(screen.getByRole('option', { name: 'A 股(cn)' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: '港股(hk)' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: '美股(us)' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: '日股(jp)' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: '韩股(kr)' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits a market light status rule payload', async () => {
|
||||
render(<AlertRuleForm onSubmit={onSubmit} />);
|
||||
|
||||
|
||||
@@ -215,12 +215,6 @@ describe('SettingsField', () => {
|
||||
options: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
|
||||
expectedLabels: ['调试', '信息', '警告', '错误', '严重'],
|
||||
},
|
||||
{
|
||||
key: 'MARKET_REVIEW_REGION',
|
||||
category: 'system',
|
||||
options: ['cn', 'hk', 'us', 'both'],
|
||||
expectedLabels: ['A 股', '港股', '美股', '全部市场'],
|
||||
},
|
||||
] as const;
|
||||
|
||||
selectCases.forEach(({ key, category, options, expectedLabels }) => {
|
||||
@@ -262,6 +256,45 @@ describe('SettingsField', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders MARKET_REVIEW_REGION as free-text field with comma-separated defaults', () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<SettingsField
|
||||
item={{
|
||||
key: 'MARKET_REVIEW_REGION',
|
||||
value: 'cn,jp',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'MARKET_REVIEW_REGION',
|
||||
category: 'system',
|
||||
dataType: 'string',
|
||||
uiControl: 'text',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 1,
|
||||
},
|
||||
}}
|
||||
value="cn,jp"
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText('大盘复盘市场') as HTMLInputElement;
|
||||
expect(input).toHaveValue('cn,jp');
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'cn,jp,kr' },
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('MARKET_REVIEW_REGION', 'cn,jp,kr');
|
||||
});
|
||||
|
||||
it('renders context compression profile options with Chinese labels', () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
|
||||
@@ -1138,10 +1138,11 @@ const settingsHelpZhCN: SettingsHelpMap = {
|
||||
},
|
||||
'settings.system.market_review': {
|
||||
title: '大盘分析',
|
||||
summary: '控制大盘分析功能的开关、覆盖市场和配色方案。',
|
||||
usage: 'MARKET_REVIEW_ENABLED 开启大盘分析;DAILY_MARKET_CONTEXT_ENABLED 默认开启,会把当日大盘摘要用于个股分析 Prompt 与保守护栏;MARKET_REVIEW_REGION 选择市场(cn/hk/us/both);MARKET_REVIEW_COLOR_SCHEME 选择配色。',
|
||||
summary: '控制大盘分析功能的开关、支持的市场子集和配色方案。',
|
||||
usage: 'MARKET_REVIEW_ENABLED 开启大盘分析;DAILY_MARKET_CONTEXT_ENABLED 默认开启,会把当日大盘摘要用于个股分析 Prompt 与保守护栏;MARKET_REVIEW_REGION 支持输入逗号分隔的市场子集(如 cn,us,jp 或 cn,us,jp,kr),不传可回退为 cn;MARKET_REVIEW_COLOR_SCHEME 选择配色。',
|
||||
valueNotes: [
|
||||
'cn 覆盖 A 股,hk 覆盖港股,us 覆盖美股,both 覆盖全部。',
|
||||
'cn 覆盖 A 股,hk 覆盖港股,us 覆盖美股,jp 覆盖日股,kr 覆盖韩股,both 覆盖全部(cn,hk,us,jp,kr)。',
|
||||
'MARKET_REVIEW_REGION 直接写入文本框,支持逗号分隔的子集;空值或非法值会回退到 cn。',
|
||||
'默认开启 DAILY_MARKET_CONTEXT_ENABLED;设为 false 后仍可生成大盘复盘报告,但个股分析不会读取大盘摘要或软化买入/加仓建议。',
|
||||
'配色方案影响大盘报告中指数涨跌的颜色显示:green_up 为绿涨红跌,red_up 为红涨绿跌。',
|
||||
],
|
||||
@@ -2228,10 +2229,11 @@ const settingsHelpEnUS: SettingsHelpMap = {
|
||||
},
|
||||
'settings.system.market_review': {
|
||||
title: 'Market Review',
|
||||
summary: 'Controls the market review feature: on/off, coverage region, and color scheme.',
|
||||
usage: 'MARKET_REVIEW_ENABLED toggles market review; DAILY_MARKET_CONTEXT_ENABLED is on by default and controls whether the daily market summary is injected into stock-analysis prompts and conservative guardrails; MARKET_REVIEW_REGION selects markets (cn/hk/us/both); MARKET_REVIEW_COLOR_SCHEME selects colors.',
|
||||
summary: 'Controls the market review feature: on/off, market subset input, and color scheme.',
|
||||
usage: 'MARKET_REVIEW_ENABLED toggles market review; DAILY_MARKET_CONTEXT_ENABLED is on by default and controls whether the daily market summary is injected into stock-analysis prompts and conservative guardrails; MARKET_REVIEW_REGION accepts a comma-separated region subset (for example cn,us,jp or cn,us,jp,kr), and `both` keeps all supported markets; invalid or empty values fall back to `cn`; MARKET_REVIEW_COLOR_SCHEME selects colors.',
|
||||
valueNotes: [
|
||||
'cn covers A-shares, hk covers Hong Kong, us covers US stocks, both covers all.',
|
||||
'cn covers A-shares, hk covers Hong Kong, us covers US stocks, jp covers Japan, kr covers Korea, and both covers all (cn,hk,us,jp,kr).',
|
||||
'MARKET_REVIEW_REGION is free-text input; you can enter a comma-separated subset like cn,us or cn,us,jp,kr.',
|
||||
'DAILY_MARKET_CONTEXT_ENABLED is enabled by default; set it to false to keep market review reports running without injecting the summary into stock analysis or softening buy/add advice.',
|
||||
'Color scheme affects how index changes are displayed: green_up = green for gains/red for losses; red_up = red for gains/green for losses.',
|
||||
],
|
||||
|
||||
@@ -328,7 +328,7 @@ const fieldDescriptionMap: Record<string, string> = {
|
||||
RUN_IMMEDIATELY: '程序启动后立即执行一次分析任务。',
|
||||
MARKET_REVIEW_ENABLED: '是否启用大盘复盘流程。',
|
||||
DAILY_MARKET_CONTEXT_ENABLED: '默认开启。开启后会把当日大盘摘要注入个股分析,并在高风险或退潮环境下软化激进买入建议;关闭后仍可运行大盘复盘。',
|
||||
MARKET_REVIEW_REGION: '大盘复盘默认市场区域(如 cn/us/hk)。',
|
||||
MARKET_REVIEW_REGION: '大盘复盘默认市场区域(如 cn/hk/us/jp/kr/both),支持逗号分隔子集,如 `cn,us,jp`。',
|
||||
MARKET_REVIEW_COLOR_SCHEME: '控制大盘复盘指数涨跌幅图标颜色:green_up 为绿涨红跌,red_up 为红涨绿跌。',
|
||||
ANALYSIS_DELAY: '启动任务前的延迟秒数,可用于等待依赖服务就绪。',
|
||||
SAVE_CONTEXT_SNAPSHOT: '控制是否持久化整份分析历史 context_snapshot;关闭后不会保存低敏输入概览、市场阶段摘要和增强上下文,但不影响当次分析的 pack 构建或 Prompt 摘要。',
|
||||
@@ -426,12 +426,6 @@ const fieldOptionLabelMap: Record<string, Record<string, string>> = {
|
||||
basic: '基础',
|
||||
debug: '调试',
|
||||
},
|
||||
MARKET_REVIEW_REGION: {
|
||||
cn: 'A 股',
|
||||
hk: '港股',
|
||||
us: '美股',
|
||||
both: '全部市场',
|
||||
},
|
||||
AGENT_ARCH: {
|
||||
single: '单 Agent',
|
||||
multi: '多 Agent(编排)',
|
||||
@@ -509,12 +503,6 @@ const fieldOptionLabelMapEn: Record<string, Record<string, string>> = {
|
||||
basic: 'Basic',
|
||||
debug: 'Debug',
|
||||
},
|
||||
MARKET_REVIEW_REGION: {
|
||||
cn: 'A-shares',
|
||||
hk: 'Hong Kong',
|
||||
us: 'US',
|
||||
both: 'All markets',
|
||||
},
|
||||
AGENT_ARCH: {
|
||||
single: 'Single Agent',
|
||||
multi: 'Multi Agent (orchestrator)',
|
||||
|
||||
@@ -139,10 +139,6 @@ describe('systemConfigI18n option label localization', () => {
|
||||
['LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL', 'off', undefined, '关闭'],
|
||||
['LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL', 'basic', undefined, '基础'],
|
||||
['LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL', 'debug', undefined, '调试'],
|
||||
['MARKET_REVIEW_REGION', 'cn', undefined, 'A 股'],
|
||||
['MARKET_REVIEW_REGION', 'hk', undefined, '港股'],
|
||||
['MARKET_REVIEW_REGION', 'us', undefined, '美股'],
|
||||
['MARKET_REVIEW_REGION', 'both', undefined, '全部市场'],
|
||||
['MARKET_REVIEW_COLOR_SCHEME', 'green_up', 'Green Up / Red Down', '绿涨红跌'],
|
||||
['MARKET_REVIEW_COLOR_SCHEME', 'red_up', 'Red Up / Green Down', '红涨绿跌'],
|
||||
['GENERATION_BACKEND', 'litellm', undefined, '默认模型配置'],
|
||||
@@ -170,6 +166,11 @@ describe('systemConfigI18n option label localization', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('treats free-text config keys as passthrough for option labels', () => {
|
||||
expect(getFieldOptionLabelZh('MARKET_REVIEW_REGION', 'cn')).toBe('cn');
|
||||
expect(getFieldOptionLabelZh('MARKET_REVIEW_REGION', 'cn,us,jp,kr')).toBe('cn,us,jp,kr');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SAVE_CONTEXT_SNAPSHOT settings help contract', () => {
|
||||
|
||||
@@ -339,9 +339,10 @@ class YfinanceFetcher(BaseFetcher):
|
||||
|
||||
def get_main_indices(self, region: str = "cn") -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
获取主要指数行情 (Yahoo Finance),支持 A 股、美股与港股。
|
||||
获取主要指数行情 (Yahoo Finance),支持 A 股、美股、港股、日股、韩股与台股。
|
||||
region=us 时委托给 _get_us_main_indices。
|
||||
region=hk 时委托给 _get_hk_main_indices。
|
||||
region=jp/kr/tw 时分别委托给对应市场指数方法。
|
||||
"""
|
||||
import yfinance as yf
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
- [文档] 补齐概念板块排行字段契约与通知报告行业/概念类型列展示说明。
|
||||
- [新功能] #1742 新增信号归因分析功能(dashboard.signal_attribution),解释推荐理由的构成(技术指标、新闻舆情、基本面、市场环境的贡献度,以及最强看多/看空信号)。支持默认通知报告和 Jinja2 模板渲染,包含中英文国际化标签。归一化函数在 _parse_response() 和 parse_dashboard_json() 中显式调用,确保有效非零贡献度归一化到 100,all-zero 保留为 0(表示无有效信号)。
|
||||
- [改进] Agent 路径同步:更新 executor.py 和 decision_agent.py 的 prompt,确保 agent/multi-agent 分析时也生成 signal_attribution 字段。
|
||||
- [新功能] #1815 Phase 2 大盘复盘新增 `jp`/`kr` 市场:支持日经225/TOPIX、KOSPI/KOSDAQ 指数复盘,扩展 `MARKET_REVIEW_REGION`、交易日过滤、Web 设置枚举、市场 profile/strategy、文档与回归测试。
|
||||
- [改进] #1815 Phase 1 硬化日本/韩国 suffix-only 个股 MVP:集中 JP/KR/TW suffix 识别规则,扩充日韩股票种子索引,并为 yfinance 报价/基本面上下文补充市场、币种与数据质量元数据。
|
||||
- [文档] #1815 补充 JP/KR/TW suffix-only MVP 在外部 API、provider/model/base URL 与运行时配置上的边界说明:当前为结构化字段兼容验证且可回退到旧链路。
|
||||
- [文档] #1815 细化 PR 提交流程约束:.github/PULL_REQUEST_TEMPLATE.md 补充 Head CI 一致性、Web 设置变更可视证据、第三方兼容性声明与回滚说明要求,避免描述与验证状态/变更影响不一致。
|
||||
- [修复] 修复通知 Markdown 表格转换在空单元格后将后续内容错配到错误表头的问题。
|
||||
- [修复] 将 Docker 可安装的 Longbridge SDK 版本固定为 0.2.75,避免 `longbridge>=0.2.77` 从包索引消失后导致 docker-build 失败。
|
||||
- [修复] 持仓快照今日估值改为受限并发预取多只持仓实时价,减少持仓较多时 Web 组合页面刷新超时。
|
||||
|
||||
@@ -338,7 +338,7 @@ P6 不做:
|
||||
- `AlertTriggerItem` 保留 `diagnostics` 字符串,并新增派生字段 `market_phase_summary`、`analysis_context_pack_overview`、`analysis_visibility_source`。
|
||||
- 真实 `status=triggered` 的 worker 记录会在 JSON diagnostics 中合并 sibling key `analysis_visibility`,包含 `market_phase_summary`、`analysis_context_pack_overview`、`source`。旧纯文本 diagnostics 保留原文,API 派生字段返回 `null`,source 返回 `legacy_text`。
|
||||
- `analysis_visibility_source` 取值为 `alert_trigger_market_context`、`analysis_history_snapshot`、`evaluator_snapshot`、`legacy_text` 或 `null`。
|
||||
- symbol 目标使用 `get_market_for_stock(normalize_stock_code(effective_target))` 构造触发时 phase;`target_scope=market` 直接用 `normalize_market_region(target)`,不会把 `cn|hk|us` 当作股票代码推断;账户级无法唯一定位市场时允许 summary 落为 `unknown`。
|
||||
- symbol 目标使用 `get_market_for_stock(normalize_stock_code(effective_target))` 构造触发时 phase;`target_scope=market` 直接用 `normalize_market_region(target)`,不会把 `cn|hk|us|jp|kr` 当作股票代码推断;账户级无法唯一定位市场时允许 summary 落为 `unknown`。
|
||||
- `analysis_context_pack_overview` 只来自 evaluator 已带 overview 或最近 30 天内的历史 snapshot。最近历史查询复用历史服务的代码变体候选,并以 best-effort + 批内短缓存方式执行;缺失或解析失败返回 `null`,不伪造 pack。
|
||||
- 告警通知只输出公开摘要:阶段标签、trigger source、partial-bar warning、数据质量等级和前两条 limitations。通知不得输出 raw context pack、Prompt、新闻正文、完整 diagnostics JSON、webhook URL、token 或持仓敏感细节。
|
||||
- Web 告警历史展示 phase badge、数据质量等级和 limitations 空态;旧触发记录缺少公开摘要时不影响列表读取。
|
||||
@@ -358,10 +358,10 @@ P7 在现有 Alert API、Web 告警中心和 `src/services/alert_worker.py` 中
|
||||
|
||||
| `target_scope` | `target` | 允许的 `alert_type` | 参数 | 触发语义 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `market` | `cn` / `hk` / `us` | `market_light_status` | `statuses=["red","yellow"]`,只允许 `red/yellow`,默认 `["red","yellow"]` | 当前 `MarketLightSnapshot.status` 命中列表时触发 |
|
||||
| `market` | `cn` / `hk` / `us` | `market_light_score_drop` | `min_drop > 0` | `prev.score - current.score >= min_drop`,且 `prev.trade_date < current.trade_date` |
|
||||
| `market` | `cn` / `hk` / `us` / `jp` / `kr` | `market_light_status` | `statuses=["red","yellow"]`,只允许 `red/yellow`,默认 `["red","yellow"]` | 当前 `MarketLightSnapshot.status` 命中列表时触发 |
|
||||
| `market` | `cn` / `hk` / `us` / `jp` / `kr` | `market_light_score_drop` | `min_drop > 0` | `prev.score - current.score >= min_drop`,且 `prev.trade_date < current.trade_date` |
|
||||
|
||||
scope/type 校验是双向约束:`target_scope=market` 只能使用两类 Market Light 规则;`market_light_*` 规则也只能使用 `target_scope=market`。`target` 会 `strip().lower()` 后严格限定为 `cn|hk|us`,非法 target 返回 HTTP 400 + `validation_error`。
|
||||
scope/type 校验是双向约束:`target_scope=market` 只能使用两类 Market Light 规则;`market_light_*` 规则也只能使用 `target_scope=market`。`target` 会 `strip().lower()` 后严格限定为 `cn|hk|us|jp|kr`,非法 target 返回 HTTP 400 + `validation_error`。
|
||||
|
||||
### `MarketLightSnapshot` 契约
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ Web 入口位于 `/decision-signals`:
|
||||
- 时间线只支持 `30d`、`90d`、`180d` 三个时间范围,默认 `90d`;每次最多请求 100 条。若返回 `total > items.length`,Web 会显示“仅展示最近 100 条信号,请缩小时间范围”,避免静默展示不完整轨迹。
|
||||
- 时间线 status filter 只支持 `all` 与 `active`:`all` 不传 `status`,`active` 传 `status=active`。P1 不提供 terminal status filter,也不做前端 terminal 过滤。
|
||||
- P1 不提供 profile filter;`decision_profile` 仍只存在于 metadata 中,不能可靠 server-side 过滤。历史缺失或非法 profile 的信号在 Web 中显示为 `unknown`,不会误标为 `balanced`。
|
||||
- market filter 在 API / 服务层与 Web 前端均已支持 `cn/hk/us/jp/kr/tw`;`jp/kr/tw` 的前端本地化标签均已补齐,`tw` 信号可经 API 正常写入、按 `market=tw` 查询,并可在 Web DecisionSignal 页面通过市场筛选项选择台股(tw);告警(大盘红绿灯)市场仍为 cn/hk/us。
|
||||
- market filter 在 API / 服务层与 Web 前端均已支持 `cn/hk/us/jp/kr/tw`;`jp/kr/tw` 的前端本地化标签均已补齐,`tw` 信号可经 API 正常写入、按 `market=tw` 查询,并可在 Web DecisionSignal 页面通过市场筛选项选择台股(tw);告警(大盘红绿灯)市场支持 `cn/hk/us/jp/kr`。
|
||||
- 详情抽屉展示动作、状态、评分、置信度、周期、计划质量、市场阶段、价格计划、风险、观察条件、证据、数据质量和 metadata。
|
||||
- Web 只能把信号标记为 `closed`、`invalidated` 或 `archived`,不提供 terminal 状态恢复为 active。
|
||||
- 历史报告详情不再内嵌展示报告绑定的 `source_type=analysis` 信号,也不会因打开报告详情触发 `source_report_id` 信号查询;需要查看报告来源信号时统一进入 `/decision-signals` 页面按来源报告 ID 精确筛选,或打开 `/decision-signals?sourceReportId=<recordId>` deep link。该筛选和 deep link 都会使用 `source_type=analysis + source_report_id` 的精确查询,以保留旧报告的 best-effort 懒回填入口。
|
||||
|
||||
@@ -440,7 +440,7 @@ daily_stock_analysis/
|
||||
| `MAX_WORKERS` | 并发线程数 | `3` |
|
||||
| `MARKET_REVIEW_ENABLED` | 启用大盘复盘 | `true` |
|
||||
| `DAILY_MARKET_CONTEXT_ENABLED` | 将当日大盘环境摘要注入个股分析 Prompt,并在高风险/退潮环境下软化激进买入建议;默认开启,设为 `false` 后仍可运行大盘复盘 | `true` |
|
||||
| `MARKET_REVIEW_REGION` | 大盘复盘市场区域:cn(A股)、hk(港股)、us(美股)、both(三市场),us 适合仅关注美股的用户 | `cn` |
|
||||
| `MARKET_REVIEW_REGION` | 大盘复盘市场区域:cn(A股)、hk(港股)、us(美股)、jp(日股)、kr(韩股)、both(五市场),us/jp/kr 适合仅关注单区域用户 | `cn` |
|
||||
| `MARKET_REVIEW_COLOR_SCHEME` | 大盘复盘指数涨跌颜色:`green_up`=绿涨红跌(默认),`red_up`=红涨绿跌 | `green_up` |
|
||||
| `TRADING_DAY_CHECK_ENABLED` | 交易日检查:默认 `true`,非交易日跳过执行;设为 `false` 或使用 `--force-run` 可强制执行(Issue #373) | `true` |
|
||||
| `SCHEDULE_ENABLED` | 启用定时任务 | `false` |
|
||||
@@ -759,12 +759,12 @@ docker run -e SCHEDULE_ENABLED=true -e SCHEDULE_RUN_IMMEDIATELY=false ...
|
||||
|
||||
#### 交易日判断(Issue #373)
|
||||
|
||||
默认根据自选股市场(A 股 / 港股 / 美股)和 `MARKET_REVIEW_REGION` 判断是否为交易日:
|
||||
- 使用 `exchange-calendars` 区分 A 股 / 港股 / 美股各自的交易日历(含节假日)
|
||||
默认根据自选股市场(A 股 / 港股 / 美股 / 日股 / 韩股)和 `MARKET_REVIEW_REGION` 判断是否为交易日:
|
||||
- 使用 `exchange-calendars` 区分 A 股 / 港股 / 美股 / 日股 / 韩股各自的交易日历(含节假日)
|
||||
- 混合持仓时,每只股票只在其市场开市日分析,休市股票当日跳过
|
||||
- 全部相关市场均为非交易日时,整体跳过执行(不启动 pipeline、不发推送)
|
||||
- 断点续传和 `--dry-run` 的“数据已存在”判断共用同一套“最新可复用交易日”解析逻辑,不再直接使用服务器自然日
|
||||
- `最新可复用交易日` 会按股票所属市场的本地时区解析:A 股使用 `Asia/Shanghai`,港股使用 `Asia/Hong_Kong`,美股使用 `America/New_York`
|
||||
- `最新可复用交易日` 会按股票所属市场的本地时区解析:A 股使用 `Asia/Shanghai`,港股使用 `Asia/Hong_Kong`,美股使用 `America/New_York`,日股使用 `Asia/Tokyo`,韩股使用 `Asia/Seoul`
|
||||
- 非交易日(周末 / 节假日)运行时,会回退到最近一个交易日检查本地数据;若该交易日数据已存在,则跳过重复抓取,否则继续补数
|
||||
- 交易日盘中或收盘前运行时,会以上一个已完成交易日作为复用目标;交易日收盘后运行时,当日数据已存在则可直接跳过,不存在则继续抓取
|
||||
- 覆盖方式:`TRADING_DAY_CHECK_ENABLED=false` 或 命令行 `--force-run`
|
||||
@@ -898,7 +898,7 @@ Issue #1742 在个股分析报告的 `dashboard.signal_attribution` 中新增信
|
||||
|
||||
P6 将既有 `market_phase_summary` 与 `analysis_context_pack_overview` 复用到告警、持仓、历史、回测和通知链路,不新增 phase/pack 协议,也不做数据库迁移。告警触发记录仍使用现有 `diagnostics` 文本字段;当 diagnostics 可 JSON 化时,worker 会在 `status=triggered` 记录中合并写入 `analysis_visibility.market_phase_summary`、`analysis_visibility.analysis_context_pack_overview` 和 `analysis_visibility.source`。旧纯文本 diagnostics 继续保留原文,Alert API 派生字段为空且 `analysis_visibility_source=legacy_text`。
|
||||
|
||||
告警 phase 摘要来自触发时上下文:symbol 目标按股票市场推断,`target_scope=market` 直接使用 `cn|hk|us` 市场区域,账户级无法唯一定位时允许落为 `unknown`。pack overview 只来自评估器已带 overview 或最近 30 天历史 snapshot 的低敏 overview,缺失时返回 `null`,不伪造 pack,不自动触发轻量 LLM 分析。公开 source 取值为 `alert_trigger_market_context`、`analysis_history_snapshot`、`evaluator_snapshot`、`legacy_text` 或 `null`。
|
||||
告警 phase 摘要来自触发时上下文:symbol 目标按股票市场推断,`target_scope=market` 直接使用 `cn|hk|us|jp|kr` 市场区域,账户级无法唯一定位时允许落为 `unknown`。pack overview 只来自评估器已带 overview 或最近 30 天历史 snapshot 的低敏 overview,缺失时返回 `null`,不伪造 pack,不自动触发轻量 LLM 分析。公开 source 取值为 `alert_trigger_market_context`、`analysis_history_snapshot`、`evaluator_snapshot`、`legacy_text` 或 `null`。
|
||||
|
||||
持仓页新增手动单股分析入口,对应 `POST /api/v1/portfolio/positions/{symbol}/analysis`。请求字段为 `account_id`、`analysis_phase=auto|premarket|intraday|postmarket` 和 `force`;只有当前持仓快照中非零持仓可提交,无持仓返回 404,多账户同持一只股票但未传 `account_id` 返回 `400 ambiguous_position_account`。该入口沿用异步任务 accepted / duplicate 语义,`force` 只影响分析刷新,不绕过 in-flight duplicate。后端只把低敏 `portfolio_context` 传入内部 pipeline 和 context pack 的可选 `portfolio` block;该 block 不参与既有六块数据质量总分,也不会出现在任务列表或 SSE payload 中。
|
||||
|
||||
@@ -1707,7 +1707,7 @@ AGENT_EVENT_ALERT_RULES_JSON=[{"stock_code":"600519","alert_type":"price_cross",
|
||||
|
||||
worker 会把 `triggered`、`skipped`、`degraded`、`failed` 写入 `alert_triggers` 作为评估历史;正常未触发不写历史。DB 持久化规则的 `triggered` 历史按 `rule_id + target + data_source + data_timestamp` 对同一数据点做 best-effort 去重,重复命中会复用最早一条触发记录,`data_timestamp` 缺失时不去重。真实触发后会把每个通知渠道的 attempt 写入 `alert_notifications`,并为 Alert API 创建的持久化规则写入 `alert_cooldowns` 业务冷却状态;若读取持久化冷却失败,worker 会临时使用进程内 fingerprint 防止 DB 异常期间重复推送。legacy `AGENT_EVENT_ALERT_RULES_JSON` 规则继续使用进程内 fingerprint 抑制,不写持久化冷却;通知基础设施的 `notification_noise.py` 降噪仍独立生效。Web 规则列表使用后端返回的 `cooldown_active` 判断冷却状态,避免浏览器本地时区解析影响展示。
|
||||
|
||||
技术指标规则只使用日线 close 的边缘触发,partial bar 处理是服务器本地时区 + 16:00 的启发式,不做市场日历精确判定。`watchlist` 每轮刷新 `STOCK_LIST` 后展开,`portfolio_holdings` 从持仓快照的非零持仓按 symbol 去重展开,`portfolio_account` 复用持仓风险服务做账户级聚合评估。`market` 规则的 target 仅支持 `cn|hk|us`,使用结构化 `MarketLightSnapshot`;`trade_date` 来自当次 market overview,`data_quality=unavailable` 会跳过触发,非交易日会被交易日 gate 跳过,`market_light_score_drop` 只比较跨交易日 score。WebUI 的“告警”页面可以管理持久化规则、执行一次性 dry-run 测试,并查看触发历史、通知尝试结果和只读冷却状态;批量规则的列表冷却状态是父规则摘要,子目标冷却以触发历史为准。详细边界见 [实时告警中心](alerts.md)。
|
||||
技术指标规则只使用日线 close 的边缘触发,partial bar 处理是服务器本地时区 + 16:00 的启发式,不做市场日历精确判定。`watchlist` 每轮刷新 `STOCK_LIST` 后展开,`portfolio_holdings` 从持仓快照的非零持仓按 symbol 去重展开,`portfolio_account` 复用持仓风险服务做账户级聚合评估。`market` 规则的 target 仅支持 `cn|hk|us|jp|kr`,使用结构化 `MarketLightSnapshot`;`trade_date` 来自当次 market overview,`data_quality=unavailable` 会跳过触发,非交易日会被交易日 gate 跳过,`market_light_score_drop` 只比较跨交易日 score。WebUI 的“告警”页面可以管理持久化规则、执行一次性 dry-run 测试,并查看触发历史、通知尝试结果和只读冷却状态;批量规则的列表冷却状态是父规则摘要,子目标冷却以触发历史为准。详细边界见 [实时告警中心](alerts.md)。
|
||||
|
||||
## 持仓管理说明
|
||||
|
||||
|
||||
@@ -363,7 +363,7 @@ For the notification baseline, diagnostics, and deployment notes, see [Notificat
|
||||
| `MAX_WORKERS` | Concurrent threads | `3` |
|
||||
| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |
|
||||
| `DAILY_MARKET_CONTEXT_ENABLED` | Inject the daily market context into stock-analysis prompts and soften aggressive buy advice in high-risk/risk-off markets; enabled by default, and market review can still run when this is set to `false` | `true` |
|
||||
| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |
|
||||
| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), jp (JP stocks), kr (KR stocks), both (all five markets) | `cn` |
|
||||
| `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` |
|
||||
| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |
|
||||
| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |
|
||||
@@ -767,7 +767,7 @@ Normalization functions are explicitly called in `_parse_response()` and `parse_
|
||||
|
||||
P6 reuses the existing `market_phase_summary` and `analysis_context_pack_overview` across alerts, portfolio, history, backtesting, and notifications. It does not introduce a new phase/pack protocol and does not require a database migration. Alert trigger rows keep using the existing text `diagnostics` field; when diagnostics can be represented as JSON, the worker merges `analysis_visibility.market_phase_summary`, `analysis_visibility.analysis_context_pack_overview`, and `analysis_visibility.source` into triggered rows. Legacy plain-text diagnostics remain readable; Alert API derived fields stay empty and `analysis_visibility_source=legacy_text`.
|
||||
|
||||
Alert phase summaries are generated from trigger-time context: symbol targets infer the stock market, `target_scope=market` uses the `cn|hk|us` region directly, and account-level targets that cannot map to a single market may fall back to `unknown`. The pack overview only comes from an evaluator-provided overview or a recent low-sensitivity history snapshot from the last 30 days. Missing data returns `null`; the alert worker does not fabricate packs and does not automatically run a lightweight LLM analysis. Public source values are `alert_trigger_market_context`, `analysis_history_snapshot`, `evaluator_snapshot`, `legacy_text`, or `null`.
|
||||
Alert phase summaries are generated from trigger-time context: symbol targets infer the stock market, `target_scope=market` uses the `cn|hk|us|jp|kr` region directly, and account-level targets that cannot map to a single market may fall back to `unknown`. The pack overview only comes from an evaluator-provided overview or a recent low-sensitivity history snapshot from the last 30 days. Missing data returns `null`; the alert worker does not fabricate packs and does not automatically run a lightweight LLM analysis. Public source values are `alert_trigger_market_context`, `analysis_history_snapshot`, `evaluator_snapshot`, `legacy_text`, or `null`.
|
||||
|
||||
The portfolio page adds a manual per-position analysis action backed by `POST /api/v1/portfolio/positions/{symbol}/analysis`. The request accepts `account_id`, `analysis_phase=auto|premarket|intraday|postmarket`, and `force`. Only non-zero current holdings can be submitted; missing holdings return 404, and the same symbol held in multiple accounts without `account_id` returns `400 ambiguous_position_account`. The endpoint keeps the existing async accepted / duplicate semantics, and `force` only controls refresh behavior; it does not bypass in-flight duplicate detection. The backend passes only a low-sensitivity `portfolio_context` internally into the pipeline and into an optional context-pack `portfolio` block. That block does not affect the six existing data-quality weights and is not exposed through task lists or SSE payloads.
|
||||
|
||||
@@ -1542,7 +1542,7 @@ AGENT_EVENT_ALERT_RULES_JSON=[{"stock_code":"600519","alert_type":"price_cross",
|
||||
|
||||
The 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.
|
||||
|
||||
Technical 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. `market` rules accept only `cn|hk|us` targets and use structured `MarketLightSnapshot` data; `trade_date` comes from the current market overview, `data_quality=unavailable` skips triggering, non-trading days are skipped by the trading-day gate, and `market_light_score_drop` compares score across trading days only. 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.
|
||||
Technical 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. `market` rules accept only `cn|hk|us|jp|kr` targets and use structured `MarketLightSnapshot` data; `trade_date` comes from the current market overview, `data_quality=unavailable` skips triggering, non-trading days are skipped by the trading-day gate, and `market_light_score_drop` compares score across trading days only. 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -22,19 +22,73 @@
|
||||
兼容性与回退说明(针对结构化检测命中项):
|
||||
|
||||
- `#1815` 本次仅新增 `yfinance` 报价/基本面上下文中的可选字段元数据(如 `market`、`currency`、`data_quality`、`missing_fields`、`provider`),未改动 LLM provider/model/base URL、配置 Schema、运行时环境变量、数据库字段、存量缓存序列化或消息协议版本。
|
||||
- 外部 API 边界仍仅限既有 `yfinance` fetch 路径(含 `Ticker`/`history`/`fast_info`)与既有兜底逻辑;没有新增或迁移 API 网关/host,`YFINANCE_PRIORITY` 是唯一受影响的可见参数。
|
||||
- 与本条 PR 相关的配置语义上,未新增或替换 provider、model、base URL,未新增配置清理/迁移分支;已保存配置仍保持原样,回退方式为回退该提交。
|
||||
- 外部 API 边界仍仅限既有 `yfinance` fetch 路径(含 `Ticker`/`history`/`fast_info`)与既有兜底逻辑;没有新增或迁移 API 网关/host,`YFINANCE_PRIORITY` 是唯一受影响的可见参数。JP/KR 主指数与 Yahoo symbol 对应如下(可核验):
|
||||
- 日经225:`^N225`(<https://finance.yahoo.com/quote/%5EN225/>)
|
||||
- 东证指数:`^TOPX`(<https://finance.yahoo.com/quote/%5ETOPX/>)
|
||||
- KOSPI:`^KS11`(<https://finance.yahoo.com/quote/%5EKS11/>)
|
||||
- KOSDAQ:`^KQ11`(<https://finance.yahoo.com/quote/%5EKQ11/>)
|
||||
- 依赖版本:`requirements.txt` 中 `yfinance>=0.2.0`,回归覆盖路径见 `tests/test_yfinance_jp_kr_indices.py` 与 `tests/test_yfinance_hk_indices.py`。
|
||||
- 兼容性与回退:`MARKET_REVIEW_REGION` 会保留合法逗号子集(如 `cn,us`)并保持 `both` 全量行为,非法值或空值回退到 `cn`,不会清空或迁移已保存配置。
|
||||
- 运行时边界:JP/KR 指数按 market_review 的 fail-open 约定逐项抓取;单项失败不会阻断其余指数与其他市场;当两个市场均无可用主指数行情时返回本地可见 `None/空`,主流程继续可按其余市场输出或直接降级。
|
||||
- 兼容性验证依据:行情/基本面上下文在 `data_provider/base.py` 与 `realtime_types.py` 中按现有 `getattr`/可选字段约定向下游透传,不强制读写新增字段;无配置迁移脚本,未观察到 provider/model/base URL fallback 路径变更。
|
||||
- 回退方式:若新增元数据字段在某端产生兼容问题,可先忽略这些字段并按既有市场判定+行情展示链路运行;必要时回滚本次提交或通过移除 `jp/kr` `MarketSymbol` 及路由扩展恢复旧行为。
|
||||
|
||||
不承诺项:
|
||||
|
||||
- 不承诺实时行情;Yahoo Finance 数据可能延迟或字段缺失。
|
||||
- 不承诺完整基本面、行业/板块、市场宽度、涨跌家数或日韩大盘复盘。
|
||||
- 不承诺完整基本面、行业/板块、市场宽度或涨跌家数。JP/KR 大盘复盘 v1 仅提供主要指数、新闻线索与模板/LLM 复盘,不提供日韩市场宽度或板块排行。
|
||||
- 不承诺完整日韩全市场股票列表;Web 自动补全当前仅覆盖仓内种子索引中的常用标的(已扩充至各 30 只左右的头部标的),未命中时仍可手动输入 suffix 代码。
|
||||
- 不补齐 Portfolio 的 JPY/KRW 汇率、成本、市值完整口径;相关字段仅放开市场类型以避免前后端校验拒绝。
|
||||
|
||||
回滚方式:移除 `jp/kr` 市场识别、交易日历注册、YFinance 路由扩展、Web/API 类型放行、`scripts/stock_index_seeds/` 日韩种子索引,并删除本文档中的能力声明。
|
||||
|
||||
## 日本/韩国大盘复盘 v1(Issue #1815 Phase 2)
|
||||
|
||||
大盘复盘 `MARKET_REVIEW_REGION` 新增 `jp` 与 `kr`,并纳入 `both` 的多市场顺序:`cn,hk,us,jp,kr`。
|
||||
|
||||
支持范围:
|
||||
|
||||
- `jp`:通过 Yahoo Finance 获取日经225 `^N225` 与东证指数 `^TOPX`,输出日股大盘复盘。可复核页面:
|
||||
- `^N225`:<https://finance.yahoo.com/quote/%5EN225/>
|
||||
- `^TOPX`:<https://finance.yahoo.com/quote/%5ETOPX/>
|
||||
- `kr`:通过 Yahoo Finance 获取 KOSPI `^KS11` 与 KOSDAQ `^KQ11`,输出韩股大盘复盘。可复核页面:
|
||||
- `^KS11`:<https://finance.yahoo.com/quote/%5EKS11/>
|
||||
- `^KQ11`:<https://finance.yahoo.com/quote/%5EKQ11/>
|
||||
- Web 设置页通过 `MARKET_REVIEW_REGION` 文本框输入逗号分隔子集(如 `cn,jp`、`cn,us,jp,kr`);交易日检查会按 `XTKS / Asia/Tokyo` 与 `XKRX / Asia/Seoul` 过滤 `both` 中当日开市市场。
|
||||
- 复盘策略、新闻搜索词、Prompt 市场语义和中英文通知标题均按 JP/KR 独立 profile 处理。
|
||||
|
||||
说明(兼容性与验收口径):
|
||||
|
||||
- 线上数据可用性来自 Yahoo Finance 指数页面与接口契约,当前实现仅覆盖 `data_provider/yfinance_fetcher.py` 的指数路由与降级行为;不对实时行情连通性作稳定性承诺。
|
||||
- 与该条目标相关的本地自动化验证默认使用离线回归:`tests/test_yfinance_jp_kr_indices.py`、`tests/test_yfinance_hk_indices.py`(共性映射/回退)与 `tests/test_trading_calendar.py`(交易日过滤)。如果要补充实时可用性复核,可在联网环境直接访问上述 Yahoo Finance 页面进行一次性抽检。
|
||||
|
||||
- 外部兼容性边界(当前实现默认假设):
|
||||
- 数据源:`yfinance`(版本下限 `requirements.txt` 中的 `yfinance>=0.2.0`)
|
||||
- 长期约束:`^N225`、`^TOPX`、`^KS11`、`^KQ11` 必须在 Yahoo Finance 端有可检索 quote 页面;无法检索视为索引级不可用,由 `market_review` fail-open 机制退化到已有市场输出,不中断主流程。
|
||||
- 兼容验证(可复核):
|
||||
- <https://finance.yahoo.com/quote/%5EN225/>
|
||||
- <https://finance.yahoo.com/quote/%5ETOPX/>
|
||||
- <https://finance.yahoo.com/quote/%5EKS11/>
|
||||
- <https://finance.yahoo.com/quote/%5EKQ11/>
|
||||
- 可复现联机复核命令(选做):
|
||||
```bash
|
||||
python - <<'PY'
|
||||
from yfinance import Ticker
|
||||
for symbol in ("^N225", "^TOPX", "^KS11", "^KQ11"):
|
||||
data = Ticker(symbol).history(period="5d")
|
||||
print(symbol, "rows", len(data))
|
||||
PY
|
||||
```
|
||||
|
||||
边界:
|
||||
|
||||
- JP/KR 大盘复盘 v1 不提供涨跌家数、涨跌停、行业/板块排行或资金流统计;结构化 payload 中 `breadth` 仍只在有市场宽度数据时出现。
|
||||
- 单一 JP/KR 指数拉取失败按既有 yfinance fail-open 逻辑跳过,不拖垮其它指数或其它市场。
|
||||
- 如果 `exchange-calendars` 缺少对应交易所日历,继续沿用既有交易日 fail-open/fail-closed 语义。
|
||||
|
||||
回滚方式:从 `MARKET_REVIEW_REGION` 合法值、Web 设置枚举、MarketProfile/MarketStrategy、`_MARKET_REVIEW_MARKETS` 和本文档中移除 `jp` / `kr`。
|
||||
|
||||
## 台湾个股 suffix-only MVP(Issue #1772,Refs #1772)
|
||||
|
||||
当前阶段支持手动输入台湾股票的 Yahoo Finance 后缀代码,进入既有个股分析、历史保存和基础报告展示链路。TWSE 上市股票使用 `.TW` 后缀,TPEx 上柜(柜买)股票使用 `.TWO` 后缀,二者折叠为同一 `tw` 市场标签。**本次覆盖市场识别(detection)、数据路由层、DecisionSignal/Portfolio/Intelligence 服务层与 API 市场枚举,以及 DecisionSignal/Portfolio 前端市场类型与筛选**;台股股票索引/种子、Web 自动补全与告警(大盘红绿灯)市场放行仍作为后续 PR。对齐 #1718 日韩 MVP 模式。
|
||||
|
||||
2
main.py
2
main.py
@@ -587,7 +587,7 @@ def _resolve_daily_market_context_target_date(
|
||||
current_time: datetime,
|
||||
) -> date:
|
||||
normalized_region = str(region or "cn").strip().lower()
|
||||
market = normalized_region if normalized_region in {"cn", "hk", "us"} else "cn"
|
||||
market = normalized_region if normalized_region in {"cn", "hk", "us", "jp", "kr"} else "cn"
|
||||
|
||||
from src.core.trading_calendar import get_effective_trading_date
|
||||
|
||||
|
||||
@@ -996,7 +996,7 @@ class Config:
|
||||
run_immediately: bool = True # 启动时是否立即执行一次(非定时模式)
|
||||
market_review_enabled: bool = True # 是否启用大盘复盘
|
||||
daily_market_context_enabled: bool = True # 是否将大盘环境摘要用于个股分析 Prompt 与保守护栏
|
||||
# 大盘复盘市场区域:cn(A股)、hk(港股)、us(美股)、both(三市场),us 适合仅关注美股的用户
|
||||
# 大盘复盘市场区域:cn(A股)、hk(港股)、us(美股)、jp(日股)、kr(韩股)、both(全部市场)
|
||||
market_review_region: str = "cn"
|
||||
market_review_color_scheme: str = "green_up"
|
||||
# 交易日检查:默认启用,非交易日跳过执行;设为 false 或 --force-run 可强制执行(Issue #373)
|
||||
@@ -2523,10 +2523,24 @@ class Config:
|
||||
"""解析大盘复盘市场区域,非法值记录警告后回退为 cn"""
|
||||
import logging
|
||||
v = (value or 'cn').strip().lower()
|
||||
if v in ('cn', 'us', 'hk', 'both'):
|
||||
supported_regions = ('cn', 'hk', 'us', 'jp', 'kr', 'both')
|
||||
ordered_regions = ('cn', 'hk', 'us', 'jp', 'kr')
|
||||
|
||||
if v in supported_regions:
|
||||
if v == 'both':
|
||||
return ','.join(ordered_regions)
|
||||
return v
|
||||
|
||||
if ',' in v:
|
||||
requested = {item.strip() for item in v.split(',') if item.strip()}
|
||||
normalized = [region for region in ordered_regions if region in requested]
|
||||
if 'both' in requested:
|
||||
normalized = list(ordered_regions)
|
||||
if normalized:
|
||||
return ','.join(normalized)
|
||||
|
||||
logging.getLogger(__name__).warning(
|
||||
f"MARKET_REVIEW_REGION 配置值 '{value}' 无效,已回退为默认值 'cn'(合法值:cn / hk / us / both)"
|
||||
f"MARKET_REVIEW_REGION 配置值 '{value}' 无效,已回退为默认值 'cn'(合法值:cn / hk / us / jp / kr / both;支持逗号分隔有效值)"
|
||||
)
|
||||
return 'cn'
|
||||
|
||||
|
||||
@@ -3326,20 +3326,21 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||
},
|
||||
"MARKET_REVIEW_REGION": {
|
||||
"title": "Market Review Region",
|
||||
"description": "Market region for review: cn (A-shares), hk (Hong Kong), us (US stocks), or both (all markets).",
|
||||
"description": "Market region for review: cn (A-shares), hk (Hong Kong), us (US stocks), jp (Japan), kr (Korea), or both (all markets).",
|
||||
"category": "system",
|
||||
"data_type": "string",
|
||||
"ui_control": "select",
|
||||
"ui_control": "text",
|
||||
"is_sensitive": False,
|
||||
"is_required": False,
|
||||
"is_editable": True,
|
||||
"default_value": "cn",
|
||||
"options": ["cn", "hk", "us", "both"],
|
||||
"validation": {"enum": ["cn", "hk", "us", "both"]},
|
||||
"options": ["cn", "hk", "us", "jp", "kr", "both"],
|
||||
"validation": {"allowed_values": ["cn", "hk", "us", "jp", "kr", "both"], "delimiter": ","},
|
||||
"display_order": 48,
|
||||
"help_key": "settings.system.market_review",
|
||||
"examples": [
|
||||
"MARKET_REVIEW_REGION=cn",
|
||||
"MARKET_REVIEW_REGION=jp",
|
||||
"MARKET_REVIEW_REGION=both",
|
||||
],
|
||||
"docs": [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
大盘复盘市场区域配置
|
||||
|
||||
定义各市场区域的指数、新闻搜索词、Prompt 提示等元数据,
|
||||
供 MarketAnalyzer 按 region 切换 A 股/美股复盘行为。
|
||||
供 MarketAnalyzer 按 region 切换 A 股/港股/美股/日韩复盘行为。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -14,7 +14,7 @@ from typing import List
|
||||
class MarketProfile:
|
||||
"""大盘复盘市场区域配置"""
|
||||
|
||||
region: str # "cn" | "us"
|
||||
region: str # "cn" | "hk" | "us" | "jp" | "kr"
|
||||
# 用于判断整体走势的指数代码,cn 用上证 000001,us 用标普 SPX
|
||||
mood_index_code: str
|
||||
# 新闻搜索关键词
|
||||
@@ -66,6 +66,32 @@ HK_PROFILE = MarketProfile(
|
||||
has_sector_rankings=False,
|
||||
)
|
||||
|
||||
JP_PROFILE = MarketProfile(
|
||||
region="jp",
|
||||
mood_index_code="N225",
|
||||
news_queries=[
|
||||
"日本股市 日经225",
|
||||
"Japan stock market Nikkei TOPIX",
|
||||
"日经225 东证指数 行情",
|
||||
],
|
||||
prompt_index_hint="分析日经225、东证指数等日本主要指数走势特点",
|
||||
has_market_stats=False,
|
||||
has_sector_rankings=False,
|
||||
)
|
||||
|
||||
KR_PROFILE = MarketProfile(
|
||||
region="kr",
|
||||
mood_index_code="KS11",
|
||||
news_queries=[
|
||||
"韩国股市 KOSPI",
|
||||
"Korea stock market KOSPI KOSDAQ",
|
||||
"KOSPI KOSDAQ 行情",
|
||||
],
|
||||
prompt_index_hint="分析 KOSPI、KOSDAQ 等韩国主要指数走势特点",
|
||||
has_market_stats=False,
|
||||
has_sector_rankings=False,
|
||||
)
|
||||
|
||||
|
||||
def get_profile(region: str) -> MarketProfile:
|
||||
"""根据 region 返回对应的 MarketProfile"""
|
||||
@@ -73,4 +99,8 @@ def get_profile(region: str) -> MarketProfile:
|
||||
return US_PROFILE
|
||||
if region == "hk":
|
||||
return HK_PROFILE
|
||||
if region == "jp":
|
||||
return JP_PROFILE
|
||||
if region == "kr":
|
||||
return KR_PROFILE
|
||||
return CN_PROFILE
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
股票智能分析系统 - 大盘复盘模块(支持 A 股 / 港股 / 美股)
|
||||
股票智能分析系统 - 大盘复盘模块(支持 A 股 / 港股 / 美股 / 日本 / 韩国)
|
||||
===================================
|
||||
|
||||
职责:
|
||||
1. 根据 MARKET_REVIEW_REGION 配置选择市场区域(cn / hk / us / both)
|
||||
1. 根据 MARKET_REVIEW_REGION 配置选择市场区域(cn / hk / us / jp / kr / both)
|
||||
2. 执行大盘复盘分析并生成复盘报告
|
||||
3. 保存和发送复盘报告
|
||||
"""
|
||||
@@ -38,6 +38,8 @@ _MARKET_REVIEW_MARKETS = (
|
||||
('cn', 'cn_title', 'A 股'),
|
||||
('hk', 'hk_title', '港股'),
|
||||
('us', 'us_title', '美股'),
|
||||
('jp', 'jp_title', '日股'),
|
||||
('kr', 'kr_title', '韩股'),
|
||||
)
|
||||
_MARKET_REVIEW_REGION_ORDER = tuple(market for market, _, _ in _MARKET_REVIEW_MARKETS)
|
||||
_VALID_MARKET_REVIEW_REGIONS = frozenset(_MARKET_REVIEW_REGION_ORDER)
|
||||
@@ -100,6 +102,8 @@ def _get_market_review_text(language: str) -> dict[str, str]:
|
||||
"cn_title": "# A-share Market Recap",
|
||||
"us_title": "# US Market Recap",
|
||||
"hk_title": "# HK Market Recap",
|
||||
"jp_title": "# Japan Market Recap",
|
||||
"kr_title": "# Korea Market Recap",
|
||||
"separator": "> Next market recap follows",
|
||||
}
|
||||
return {
|
||||
@@ -108,6 +112,8 @@ def _get_market_review_text(language: str) -> dict[str, str]:
|
||||
"cn_title": "# A股大盘复盘",
|
||||
"us_title": "# 美股大盘复盘",
|
||||
"hk_title": "# 港股大盘复盘",
|
||||
"jp_title": "# 日股大盘复盘",
|
||||
"kr_title": "# 韩股大盘复盘",
|
||||
"separator": "> 以下为下一市场大盘复盘",
|
||||
}
|
||||
|
||||
|
||||
@@ -163,10 +163,80 @@ HK_BLUEPRINT = MarketStrategyBlueprint(
|
||||
)
|
||||
|
||||
|
||||
JP_BLUEPRINT = MarketStrategyBlueprint(
|
||||
region="jp",
|
||||
title="日本市场三段式复盘策略",
|
||||
positioning="聚焦日经225、东证指数、汇率与全球风险偏好,形成次日交易计划。",
|
||||
principles=[
|
||||
"先看日经225与TOPIX是否同向,再看日元、半导体/出口链与金融股表现。",
|
||||
"把指数结论映射到仓位、节奏与风险控制动作。",
|
||||
"只基于可得指数、新闻和价格行为判断,不臆造市场广度或板块统计。",
|
||||
],
|
||||
dimensions=[
|
||||
StrategyDimension(
|
||||
name="趋势结构",
|
||||
objective="判断日本市场处于上攻、震荡还是防守阶段。",
|
||||
checkpoints=["日经225/TOPIX是否同向", "指数是否突破或跌破关键区间", "大盘权重与成长链是否共振"],
|
||||
),
|
||||
StrategyDimension(
|
||||
name="宏观与汇率",
|
||||
objective="识别日元、利率和全球风险偏好对权益市场的影响。",
|
||||
checkpoints=["日元方向对出口链的影响", "日本央行和美债利率叙事", "海外科技股与半导体链映射"],
|
||||
),
|
||||
StrategyDimension(
|
||||
name="主题线索",
|
||||
objective="提炼可延续主线与需要规避的拥挤方向。",
|
||||
checkpoints=["半导体/自动化/汽车链持续性", "金融与内需股是否轮动", "新闻催化是否支撑价格行为"],
|
||||
),
|
||||
],
|
||||
action_framework=[
|
||||
"进攻:主要指数共振上行 + 外部风险偏好改善 + 主线强化。",
|
||||
"均衡:指数分化或汇率扰动,降低追涨并等待确认。",
|
||||
"防守:主要指数转弱或外部风险升温,优先控制仓位。",
|
||||
],
|
||||
)
|
||||
|
||||
KR_BLUEPRINT = MarketStrategyBlueprint(
|
||||
region="kr",
|
||||
title="韩国市场三段式复盘策略",
|
||||
positioning="聚焦 KOSPI、KOSDAQ、半导体权重与全球科技风险偏好,形成次日交易计划。",
|
||||
principles=[
|
||||
"先看 KOSPI/KOSDAQ 是否同向,再看三星电子、SK 海力士等权重线索。",
|
||||
"区分指数 beta、半导体周期和成长股风险偏好的贡献。",
|
||||
"只基于可得指数、新闻和价格行为判断,不臆造市场广度或板块统计。",
|
||||
],
|
||||
dimensions=[
|
||||
StrategyDimension(
|
||||
name="趋势结构",
|
||||
objective="判断韩国市场处于上攻、震荡还是防守阶段。",
|
||||
checkpoints=["KOSPI/KOSDAQ 是否同向", "权重股是否支撑指数", "关键支撑阻力是否被突破"],
|
||||
),
|
||||
StrategyDimension(
|
||||
name="科技周期",
|
||||
objective="识别半导体、AI 硬件和全球科技股对韩国市场的映射。",
|
||||
checkpoints=["存储/半导体链新闻催化", "美股科技方向联动", "外资风险偏好变化"],
|
||||
),
|
||||
StrategyDimension(
|
||||
name="主题线索",
|
||||
objective="提炼可延续主线与需要规避的拥挤方向。",
|
||||
checkpoints=["电池/汽车/互联网是否轮动", "KOSDAQ 成长股风险偏好", "新闻催化是否支撑价格行为"],
|
||||
),
|
||||
],
|
||||
action_framework=[
|
||||
"进攻:KOSPI/KOSDAQ 共振上行 + 科技权重确认 + 外部风险偏好改善。",
|
||||
"均衡:指数或权重股分化,控制仓位并等待确认。",
|
||||
"防守:科技权重转弱或外部风险升温,优先控制回撤。",
|
||||
],
|
||||
)
|
||||
|
||||
def get_market_strategy_blueprint(region: str) -> MarketStrategyBlueprint:
|
||||
"""Return strategy blueprint by market region."""
|
||||
if region == "us":
|
||||
return US_BLUEPRINT
|
||||
if region == "hk":
|
||||
return HK_BLUEPRINT
|
||||
if region == "jp":
|
||||
return JP_BLUEPRINT
|
||||
if region == "kr":
|
||||
return KR_BLUEPRINT
|
||||
return CN_BLUEPRINT
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
===================================
|
||||
|
||||
职责:
|
||||
1. 按市场(A股/港股/美股)判断当日是否为交易日
|
||||
1. 按市场(A股/港股/美股/日股/韩股/台股)判断当日是否为交易日
|
||||
2. 按市场时区取“今日”日期,避免服务器 UTC 导致日期错误
|
||||
3. 支持 per-stock 过滤:只分析当日开市市场的股票
|
||||
4. 提供 regular-session 市场阶段推断基线,不改变现有分析入口行为
|
||||
@@ -519,10 +519,10 @@ def get_open_markets_today() -> Set[str]:
|
||||
Get markets that are open today (by each market's local timezone).
|
||||
|
||||
Returns:
|
||||
Set of market keys ('cn', 'hk', 'us') that are trading today
|
||||
Set of market keys that are trading today
|
||||
"""
|
||||
if not _XCALS_AVAILABLE:
|
||||
return {"cn", "hk", "us"}
|
||||
return set(MARKET_TIMEZONE)
|
||||
result: Set[str] = set()
|
||||
for mkt, tz_name in MARKET_TIMEZONE.items():
|
||||
try:
|
||||
@@ -543,22 +543,44 @@ def compute_effective_region(
|
||||
Compute effective market review region given config and open markets.
|
||||
|
||||
Args:
|
||||
config_region: From MARKET_REVIEW_REGION ('cn' | 'hk' | 'us' | 'both')
|
||||
config_region: From MARKET_REVIEW_REGION ('cn' | 'hk' | 'us' | 'jp' | 'kr' | 'both' or comma subset)
|
||||
open_markets: Markets open today
|
||||
|
||||
Returns:
|
||||
None: caller uses config default (check disabled)
|
||||
'': all relevant markets closed, skip market review
|
||||
'cn' | 'hk' | 'us' | 'both': effective subset for today
|
||||
'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'both': effective subset for today
|
||||
"""
|
||||
if config_region not in ("cn", "hk", "us", "both"):
|
||||
config_region = "cn"
|
||||
if config_region in ("cn", "hk", "us"):
|
||||
return config_region if config_region in open_markets else ""
|
||||
# both: return only the markets that are actually open today
|
||||
parts = [m for m in ("cn", "hk", "us") if m in open_markets]
|
||||
if not parts:
|
||||
markets = ("cn", "hk", "us", "jp", "kr")
|
||||
normalized = (config_region or "cn").strip().lower()
|
||||
if not normalized:
|
||||
normalized = "cn"
|
||||
|
||||
requested = {
|
||||
item.strip() for item in normalized.split(",") if item.strip()
|
||||
}
|
||||
if not requested:
|
||||
requested = {"cn"}
|
||||
|
||||
if "both" in requested:
|
||||
requested = set(markets)
|
||||
else:
|
||||
# Ignore invalid tokens and only keep known markets.
|
||||
requested = {item for item in requested if item in markets}
|
||||
|
||||
if not requested:
|
||||
# No valid market token left after filtering; follow parser fallback behavior.
|
||||
requested = {"cn"}
|
||||
|
||||
# single explicit region: keep single-region return semantics (empty when closed)
|
||||
if len(requested) == 1:
|
||||
region = next(iter(requested))
|
||||
return region if region in open_markets else ""
|
||||
|
||||
# multi-region subset: keep only markets open today, in canonical order
|
||||
open_selected = [m for m in markets if m in requested and m in open_markets]
|
||||
if not open_selected:
|
||||
return ""
|
||||
if len(parts) == 1:
|
||||
return parts[0]
|
||||
return ",".join(parts)
|
||||
if len(open_selected) == 1:
|
||||
return open_selected[0]
|
||||
return ",".join(open_selected)
|
||||
|
||||
@@ -140,14 +140,14 @@ class MarketAnalyzer:
|
||||
Args:
|
||||
search_service: 搜索服务实例
|
||||
analyzer: AI分析器实例(用于调用LLM)
|
||||
region: 市场区域 cn=A股 us=美股
|
||||
region: 市场区域 cn=A股 hk=港股 us=美股 jp=日本 kr=韩国
|
||||
config: 本次复盘使用的配置;未传时读取全局配置
|
||||
"""
|
||||
self.config = config or get_config()
|
||||
self.search_service = search_service
|
||||
self.analyzer = analyzer
|
||||
self.data_manager = DataFetcherManager()
|
||||
self.region = region if region in ("cn", "us", "hk") else "cn"
|
||||
self.region = region if region in ("cn", "us", "hk", "jp", "kr") else "cn"
|
||||
self.profile: MarketProfile = get_profile(self.region)
|
||||
self.strategy = get_market_strategy_blueprint(self.region)
|
||||
|
||||
@@ -170,6 +170,10 @@ class MarketAnalyzer:
|
||||
return "US market" if review_language == "en" else "美股市场"
|
||||
if self.region == "hk":
|
||||
return "Hong Kong market" if review_language == "en" else "港股市场"
|
||||
if self.region == "jp":
|
||||
return "Japan market" if review_language == "en" else "日本市场"
|
||||
if self.region == "kr":
|
||||
return "Korea market" if review_language == "en" else "韩国市场"
|
||||
if review_language == "en":
|
||||
return "A-share market"
|
||||
return "A股市场"
|
||||
@@ -180,13 +184,17 @@ class MarketAnalyzer:
|
||||
return "USD bn" if self._get_review_language() == "en" else "十亿美元"
|
||||
if self.region == "hk":
|
||||
return "HKD bn" if self._get_review_language() == "en" else "十亿港元"
|
||||
if self.region == "jp":
|
||||
return "JPY bn" if self._get_review_language() == "en" else "十亿日元"
|
||||
if self.region == "kr":
|
||||
return "KRW bn" if self._get_review_language() == "en" else "十亿韩元"
|
||||
return "CNY 100m" if self._get_review_language() == "en" else "亿"
|
||||
|
||||
def _format_turnover_value(self, amount_raw: float) -> str:
|
||||
"""Format raw turnover according to market-specific units."""
|
||||
if amount_raw == 0.0:
|
||||
return "N/A"
|
||||
if self.region in ("us", "hk"):
|
||||
if self.region in ("us", "hk", "jp", "kr"):
|
||||
return f"{amount_raw / 1e9:.2f}"
|
||||
if amount_raw > 1e6:
|
||||
return f"{amount_raw / 1e8:.0f}"
|
||||
@@ -202,7 +210,12 @@ class MarketAnalyzer:
|
||||
|
||||
def _get_review_title(self, date: str) -> str:
|
||||
if self._get_review_language() == "en":
|
||||
market_names = {"us": "US Market Recap", "hk": "HK Market Recap"}
|
||||
market_names = {
|
||||
"us": "US Market Recap",
|
||||
"hk": "HK Market Recap",
|
||||
"jp": "Japan Market Recap",
|
||||
"kr": "Korea Market Recap",
|
||||
}
|
||||
market_name = market_names.get(self.region, "A-share Market Recap")
|
||||
return f"## {date} {market_name}"
|
||||
return f"## {date} 大盘复盘"
|
||||
@@ -213,6 +226,10 @@ class MarketAnalyzer:
|
||||
return "Analyze the key moves in the S&P 500, Nasdaq, Dow, and other major indices."
|
||||
if self.region == "hk":
|
||||
return "Analyze the key moves in the HSI, Hang Seng Tech, HSCEI, and other major indices."
|
||||
if self.region == "jp":
|
||||
return "Analyze the key moves in the Nikkei 225, TOPIX, and other major Japanese indices."
|
||||
if self.region == "kr":
|
||||
return "Analyze the key moves in the KOSPI, KOSDAQ, and other major Korean indices."
|
||||
return "Analyze the price action in the SSE, SZSE, ChiNext, and other major indices."
|
||||
return self.profile.prompt_index_hint
|
||||
|
||||
@@ -244,6 +261,60 @@ Focus on HSI trend, southbound flow dynamics, and sector rotation to define next
|
||||
- Risk-on: broad index breakout with expanding southbound participation.
|
||||
- Neutral: mixed index signals; focus on selective relative strength.
|
||||
- Risk-off: failed breakouts and rising volatility; prioritize capital preservation."""
|
||||
if self.region == "jp" and self._get_review_language() == "en":
|
||||
return """## Strategy Blueprint: Japan Market Regime Strategy
|
||||
Focus on Nikkei 225, TOPIX, currency dynamics, and global risk appetite to define the next-session trading plan.
|
||||
|
||||
### Strategy Principles
|
||||
- Read Nikkei 225 and TOPIX alignment first, then assess yen moves, semiconductor/export chains, and financials.
|
||||
- Translate index conclusions into position sizing, trading pace, and risk-control actions.
|
||||
- Base judgments only on available index data, news, and price action without inventing breadth or sector statistics.
|
||||
|
||||
### Analysis Dimensions
|
||||
- Trend Regime: Classify Japan equities as advancing, range-bound, or defensive.
|
||||
- Are Nikkei 225 and TOPIX directionally aligned
|
||||
- Have key index ranges been reclaimed or lost
|
||||
- Are large-cap weights and growth chains moving together
|
||||
- Macro & FX: Map yen, rates, and global risk appetite into equity impact.
|
||||
- Yen direction and implications for exporters
|
||||
- Bank of Japan and US Treasury yield narratives
|
||||
- Overseas technology and semiconductor read-through
|
||||
- Theme Signals: Identify durable leadership and crowded areas to avoid.
|
||||
- Semiconductor, automation, and auto-chain persistence
|
||||
- Rotation between financials and domestic-demand stocks
|
||||
- Whether news catalysts confirm price action
|
||||
|
||||
### Action Framework
|
||||
- Risk-on: major indices rise together with improving external risk appetite and stronger leadership.
|
||||
- Neutral: index divergence or FX disruption; avoid chasing and wait for confirmation.
|
||||
- Risk-off: major indices weaken or external risk rises; prioritize position control."""
|
||||
if self.region == "kr" and self._get_review_language() == "en":
|
||||
return """## Strategy Blueprint: Korea Market Regime Strategy
|
||||
Focus on KOSPI, KOSDAQ, semiconductor heavyweights, and global technology risk appetite to define the next-session trading plan.
|
||||
|
||||
### Strategy Principles
|
||||
- Read KOSPI and KOSDAQ alignment first, then assess heavyweight signals from Samsung Electronics, SK Hynix, and related technology leaders.
|
||||
- Separate broad index beta, semiconductor cycle exposure, and growth-stock risk appetite.
|
||||
- Base judgments only on available index data, news, and price action without inventing breadth or sector statistics.
|
||||
|
||||
### Analysis Dimensions
|
||||
- Trend Regime: Classify Korea equities as advancing, range-bound, or defensive.
|
||||
- Are KOSPI and KOSDAQ directionally aligned
|
||||
- Are heavyweight technology names supporting the indices
|
||||
- Have key support or resistance levels been reclaimed or lost
|
||||
- Technology Cycle: Map semiconductor, AI hardware, and global technology moves into Korea equity risk.
|
||||
- Memory and semiconductor-chain catalysts
|
||||
- US technology-market read-through
|
||||
- Foreign investor risk appetite signals
|
||||
- Theme Signals: Identify durable leadership and crowded areas to avoid.
|
||||
- Rotation across batteries, autos, and internet platforms
|
||||
- KOSDAQ growth-stock risk appetite
|
||||
- Whether news catalysts confirm price action
|
||||
|
||||
### Action Framework
|
||||
- Risk-on: KOSPI and KOSDAQ rise together with confirmed technology leadership and improving external risk appetite.
|
||||
- Neutral: index or heavyweight divergence; keep sizing controlled and wait for confirmation.
|
||||
- Risk-off: technology heavyweights weaken or external risk rises; prioritize drawdown control."""
|
||||
if self.region == "us" and self._get_review_language() == "zh":
|
||||
return """## 美股市场三段式复盘策略
|
||||
聚焦指数趋势、宏观叙事与板块轮动,给出次日风控与仓位框架。
|
||||
@@ -298,6 +369,18 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
|
||||
- **Trend Regime**: Classify the market as momentum, range, or risk-off based on HSI/HSTECH/HSCEI alignment.
|
||||
- **Capital Flows**: Track southbound flow direction and macro narrative for risk appetite signals.
|
||||
- **Sector Themes**: Focus on tech/internet platform persistence and financials/property policy sensitivity.
|
||||
"""
|
||||
if self.region == "jp" and review_language == "en":
|
||||
return """### 6. Strategy Framework
|
||||
- **Trend Regime**: Classify Japan equities as advancing, range-bound, or defensive based on Nikkei 225/TOPIX alignment.
|
||||
- **Macro & FX**: Track yen, rates, and global risk appetite for exporter and financial-sector implications.
|
||||
- **Theme Signals**: Focus on semiconductor, automation, auto-chain, financial, and domestic-demand rotation.
|
||||
"""
|
||||
if self.region == "kr" and review_language == "en":
|
||||
return """### 6. Strategy Framework
|
||||
- **Trend Regime**: Classify Korea equities as advancing, range-bound, or defensive based on KOSPI/KOSDAQ alignment.
|
||||
- **Technology Cycle**: Track semiconductor, AI hardware, and global technology read-through for market risk appetite.
|
||||
- **Theme Signals**: Focus on battery, auto, internet-platform, and KOSDAQ growth-stock rotation.
|
||||
"""
|
||||
if self.region == "us" and review_language == "zh":
|
||||
return """### 六、策略框架
|
||||
@@ -525,6 +608,8 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
|
||||
"cn": "大盘" if review_language == "zh" else "A-share market",
|
||||
"us": "美股市场" if review_language == "zh" else "US market",
|
||||
"hk": "港股市场" if review_language == "zh" else "HK market",
|
||||
"jp": "日本股市" if review_language == "zh" else "Japan stock market",
|
||||
"kr": "韩国股市" if review_language == "zh" else "Korea stock market",
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -1292,7 +1377,7 @@ Concept lagging: {bottom_concepts_text if bottom_concepts_text else "N/A"}"""
|
||||
|
||||
if review_language == "en":
|
||||
report_title = self._get_review_title(overview.date).removeprefix("## ").strip()
|
||||
return f"""You are a professional US/A/H market analyst. Please produce a concise market recap report based on the data below.
|
||||
return f"""You are a professional {self._get_market_scope_name('en')} analyst. Please produce a concise market recap report based on the data below.
|
||||
|
||||
[Requirements]
|
||||
- Output pure Markdown only
|
||||
@@ -1355,7 +1440,7 @@ Output the report content directly, no extra commentary.
|
||||
"""
|
||||
|
||||
# A 股场景使用中文提示语
|
||||
return f"""你是一位专业的A/H/美股市场分析师,请根据以下数据生成一份结构化的{self._get_market_scope_name('zh')}大盘复盘报告。
|
||||
return f"""你是一位专业的{self._get_market_scope_name('zh')}分析师,请根据以下数据生成一份结构化的{self._get_market_scope_name('zh')}大盘复盘报告。
|
||||
|
||||
【重要】输出要求:
|
||||
- 必须输出纯 Markdown 文本格式
|
||||
@@ -1482,7 +1567,12 @@ Output the report content directly, no extra commentary.
|
||||
- **Concept Leaders**: {top_concept_text or "N/A"}
|
||||
- **Concept Laggards**: {bottom_concept_text or "N/A"}
|
||||
"""
|
||||
market_names = {"us": "US Market Recap", "hk": "HK Market Recap"}
|
||||
market_names = {
|
||||
"us": "US Market Recap",
|
||||
"hk": "HK Market Recap",
|
||||
"jp": "Japan Market Recap",
|
||||
"kr": "Korea Market Recap",
|
||||
}
|
||||
market_name = market_names.get(self.region, "A-share Market Recap")
|
||||
report = f"""## {overview.date} {market_name}
|
||||
|
||||
@@ -1503,7 +1593,7 @@ Market conditions can change quickly. The data above is for reference only and d
|
||||
"""
|
||||
return report
|
||||
|
||||
market_labels = {"cn": "A股", "us": "美股", "hk": "港股"}
|
||||
market_labels = {"cn": "A股", "us": "美股", "hk": "港股", "jp": "日股", "kr": "韩股"}
|
||||
market_label = market_labels.get(self.region, "A股")
|
||||
dashboard_block = self._build_stats_block(overview)
|
||||
indices_block = self._build_indices_block(overview)
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Literal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
MarketRegion = Literal["cn", "hk", "us"]
|
||||
MarketRegion = Literal["cn", "hk", "us", "jp", "kr"]
|
||||
MarketLightStatus = Literal["green", "yellow", "red"]
|
||||
MarketLightDataQuality = Literal["ok", "partial", "unavailable"]
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ MARKET_REVIEW_HISTORY_CODE = "MARKET"
|
||||
MARKET_REVIEW_REPORT_TYPE = "market_review"
|
||||
|
||||
|
||||
_REGION_LABEL_ZH = {"cn": "A股", "hk": "港股", "us": "美股"}
|
||||
_REGION_LABEL_EN = {"cn": "A-share", "hk": "HK", "us": "US"}
|
||||
_REGION_LABEL_ZH = {"cn": "A股", "hk": "港股", "us": "美股", "jp": "日股", "kr": "韩股"}
|
||||
_REGION_LABEL_EN = {"cn": "A-share", "hk": "HK", "us": "US", "jp": "Japan", "kr": "Korea"}
|
||||
_VALID_REGIONS = frozenset(_REGION_LABEL_ZH)
|
||||
_UNTRUSTED_MARKET_SUMMARY_SENTINELS = (
|
||||
"BEGIN_UNTRUSTED_MARKET_SUMMARY",
|
||||
|
||||
@@ -24,6 +24,8 @@ MARKET_REGION_LABELS = {
|
||||
"cn": "A股大盘",
|
||||
"hk": "港股大盘",
|
||||
"us": "美股大盘",
|
||||
"jp": "日股大盘",
|
||||
"kr": "韩股大盘",
|
||||
}
|
||||
MARKET_LIGHT_DATA_SOURCE = "market_light"
|
||||
|
||||
|
||||
@@ -17,14 +17,14 @@ from src.storage import AnalysisHistory, DatabaseManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MARKET_LIGHT_REGIONS = frozenset({"cn", "hk", "us"})
|
||||
MARKET_LIGHT_REGIONS = frozenset({"cn", "hk", "us", "jp", "kr"})
|
||||
MARKET_LIGHT_HISTORY_BATCH_SIZE = 100
|
||||
|
||||
|
||||
def normalize_market_region(region: str) -> str:
|
||||
value = str(region or "").strip().lower()
|
||||
if value not in MARKET_LIGHT_REGIONS:
|
||||
raise ValueError(f"market target must be one of cn, hk, us: {region}")
|
||||
raise ValueError(f"market target must be one of cn, hk, us, jp, kr: {region}")
|
||||
return value
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,57 @@ class ConfigEnvCompatibilityTestCase(unittest.TestCase):
|
||||
|
||||
self.assertEqual(config.tickflow_api_key, "tf-secret")
|
||||
|
||||
@patch("src.config.setup_env")
|
||||
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
|
||||
def test_market_review_region_accepts_comma_separated_supported_values(
|
||||
self, _mock_parse_litellm_yaml, _mock_setup_env
|
||||
):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"STOCK_LIST": "600519",
|
||||
"MARKET_REVIEW_REGION": "cn,us,jp",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
config = Config._load_from_env()
|
||||
|
||||
self.assertEqual(config.market_review_region, "cn,us,jp")
|
||||
|
||||
@patch("src.config.setup_env")
|
||||
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
|
||||
def test_market_review_region_filters_invalid_values_in_comma_subset(
|
||||
self, _mock_parse_litellm_yaml, _mock_setup_env
|
||||
):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"STOCK_LIST": "600519",
|
||||
"MARKET_REVIEW_REGION": "cn,eu,us,kr,xx",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
config = Config._load_from_env()
|
||||
|
||||
self.assertEqual(config.market_review_region, "cn,us,kr")
|
||||
|
||||
@patch("src.config.setup_env")
|
||||
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
|
||||
def test_market_review_region_falls_back_to_cn_when_no_supported_tokens(
|
||||
self, _mock_parse_litellm_yaml, _mock_setup_env
|
||||
):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"STOCK_LIST": "600519",
|
||||
"MARKET_REVIEW_REGION": "eu,apac",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
config = Config._load_from_env()
|
||||
|
||||
self.assertEqual(config.market_review_region, "cn")
|
||||
|
||||
@patch("src.config.setup_env")
|
||||
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
|
||||
def test_load_from_env_keeps_default_behavior_without_tickflow_api_key(
|
||||
|
||||
@@ -778,6 +778,22 @@ class TestMarketReviewFieldsRegistered(unittest.TestCase):
|
||||
self.assertEqual(field["validation"]["enum"], ["green_up", "red_up"])
|
||||
self.assertFalse(field["is_sensitive"])
|
||||
|
||||
def test_market_review_region_field_definition_exists(self):
|
||||
field = get_field_definition("MARKET_REVIEW_REGION")
|
||||
self.assertEqual(field["category"], "system")
|
||||
self.assertEqual(field["data_type"], "string")
|
||||
self.assertEqual(field["ui_control"], "text")
|
||||
self.assertEqual(field["default_value"], "cn")
|
||||
self.assertEqual(
|
||||
field["validation"]["allowed_values"],
|
||||
["cn", "hk", "us", "jp", "kr", "both"],
|
||||
)
|
||||
self.assertEqual(
|
||||
field["validation"]["delimiter"],
|
||||
",",
|
||||
)
|
||||
self.assertFalse(field["is_sensitive"])
|
||||
|
||||
def test_daily_market_context_field_definition_exists(self):
|
||||
field = get_field_definition("DAILY_MARKET_CONTEXT_ENABLED")
|
||||
self.assertEqual(field["category"], "system")
|
||||
@@ -793,6 +809,7 @@ class TestMarketReviewFieldsRegistered(unittest.TestCase):
|
||||
field_keys = {f["key"] for f in system_cat["fields"]}
|
||||
self.assertIn("MARKET_REVIEW_COLOR_SCHEME", field_keys)
|
||||
self.assertIn("DAILY_MARKET_CONTEXT_ENABLED", field_keys)
|
||||
self.assertIn("MARKET_REVIEW_REGION", field_keys)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1030,3 +1030,40 @@ def test_yellow_market_light_status_marks_context_conservative() -> None:
|
||||
|
||||
assert context is not None
|
||||
assert "conservative" in context.to_safe_dict()["risk_tags"]
|
||||
|
||||
|
||||
def test_daily_market_context_keeps_jp_kr_regions_and_labels() -> None:
|
||||
service = DailyMarketContextService(
|
||||
db_manager=MagicMock(),
|
||||
today_fn=lambda: date(2026, 6, 6),
|
||||
)
|
||||
|
||||
jp_context = service._build_context_from_payload(
|
||||
region="jp",
|
||||
trade_date=date(2026, 6, 6),
|
||||
payload={"summary": "日经225震荡,风险偏好谨慎。"},
|
||||
source="analysis_history",
|
||||
)
|
||||
kr_context = service._build_context_from_payload(
|
||||
region="kr",
|
||||
trade_date=date(2026, 6, 6),
|
||||
payload={"summary": "KOSPI震荡,等待确认。"},
|
||||
source="analysis_history",
|
||||
)
|
||||
|
||||
assert jp_context is not None
|
||||
assert jp_context.region == "jp"
|
||||
assert kr_context is not None
|
||||
assert kr_context.region == "kr"
|
||||
|
||||
jp_section = format_daily_market_context_prompt_section(
|
||||
jp_context.to_safe_dict(),
|
||||
report_language="zh",
|
||||
)
|
||||
kr_section = format_daily_market_context_prompt_section(
|
||||
kr_context.to_safe_dict(),
|
||||
report_language="en",
|
||||
)
|
||||
|
||||
assert "市场:日股(jp)" in jp_section
|
||||
assert "Region: Korea (kr)" in kr_section
|
||||
|
||||
@@ -1109,6 +1109,28 @@ class MainScheduleModeTestCase(unittest.TestCase):
|
||||
refresh.assert_called_once_with(config)
|
||||
pipeline.run.assert_called_once()
|
||||
|
||||
def test_resolve_daily_market_context_target_date_passes_jp_kr_to_trading_calendar(self) -> None:
|
||||
current_time = datetime(2026, 3, 26, 12, 0, tzinfo=timezone.utc)
|
||||
target_date = date(2026, 3, 25)
|
||||
|
||||
with patch("src.core.trading_calendar.get_effective_trading_date", return_value=target_date) as get_date:
|
||||
self.assertEqual(
|
||||
main._resolve_daily_market_context_target_date("jp", current_time),
|
||||
target_date,
|
||||
)
|
||||
self.assertEqual(
|
||||
main._resolve_daily_market_context_target_date("kr", current_time),
|
||||
target_date,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
get_date.call_args_list,
|
||||
[
|
||||
unittest.mock.call("jp", current_time=current_time),
|
||||
unittest.mock.call("kr", current_time=current_time),
|
||||
],
|
||||
)
|
||||
|
||||
def test_run_full_analysis_does_not_reuse_single_context_for_multi_market_review(self) -> None:
|
||||
args = self._make_args()
|
||||
target_date = date(2026, 3, 26)
|
||||
|
||||
@@ -2673,6 +2673,47 @@ class TestMarketAnalyzerBypassFix:
|
||||
assert "### 1. Market Summary" not in result
|
||||
assert "US Market Recap" not in result
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("region", "profile_name", "index_code", "index_name", "english_title", "zh_label"),
|
||||
[
|
||||
("jp", "JP_PROFILE", "N225", "Nikkei 225", "Japan Market Recap", "今日日股市场整体呈现"),
|
||||
("kr", "KR_PROFILE", "KS11", "KOSPI", "Korea Market Recap", "今日韩股市场整体呈现"),
|
||||
],
|
||||
)
|
||||
def test_generate_template_review_uses_jp_kr_labels_for_no_llm_fallback(
|
||||
self, region, profile_name, index_code, index_name, english_title, zh_label
|
||||
):
|
||||
import src.core.market_profile as market_profile
|
||||
from src.core.market_strategy import get_market_strategy_blueprint
|
||||
from src.market_analyzer import MarketOverview, MarketIndex
|
||||
|
||||
ma = self._make_market_analyzer_with_mock_generate_text(return_value=None)
|
||||
ma.region = region
|
||||
ma.profile = getattr(market_profile, profile_name)
|
||||
ma.strategy = get_market_strategy_blueprint(region)
|
||||
overview = MarketOverview(
|
||||
date="2026-03-05",
|
||||
indices=[
|
||||
MarketIndex(
|
||||
code=index_code,
|
||||
name=index_name,
|
||||
current=30000.0,
|
||||
change=120.0,
|
||||
change_pct=0.4,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
ma.config.report_language = "en"
|
||||
english_result = ma.generate_market_review(overview, [])
|
||||
assert f"## 2026-03-05 {english_title}" in english_result
|
||||
assert "A-share Market Recap" not in english_result
|
||||
|
||||
ma.config.report_language = "zh"
|
||||
zh_result = ma.generate_market_review(overview, [])
|
||||
assert zh_label in zh_result
|
||||
assert "今日A股市场整体呈现" not in zh_result
|
||||
|
||||
def test_inject_data_into_review_matches_english_headings(self):
|
||||
from src.market_analyzer import MarketOverview, MarketIndex
|
||||
|
||||
@@ -2987,6 +3028,36 @@ Sector text.
|
||||
assert snapshot["dimensions"]["index"]["available"] is True
|
||||
assert snapshot["dimensions"]["limit"] == {"score": 50, "available": False}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("region", "profile_name", "index_code", "index_name"),
|
||||
[
|
||||
("jp", "JP_PROFILE", "N225", "Nikkei 225"),
|
||||
("kr", "KR_PROFILE", "KS11", "KOSPI"),
|
||||
],
|
||||
)
|
||||
def test_market_light_snapshot_accepts_jp_kr_regions(
|
||||
self, region, profile_name, index_code, index_name
|
||||
):
|
||||
import src.core.market_profile as market_profile
|
||||
from src.market_analyzer import MarketIndex, MarketOverview
|
||||
|
||||
ma = self._make_market_analyzer_with_mock_generate_text(return_value="review")
|
||||
ma.region = region
|
||||
ma.profile = getattr(market_profile, profile_name)
|
||||
overview = MarketOverview(
|
||||
date="2026-03-06",
|
||||
indices=[MarketIndex(code=index_code, name=index_name, current=30000, change_pct=0.5)],
|
||||
)
|
||||
|
||||
snapshot = ma.build_market_light_snapshot(overview)
|
||||
|
||||
assert snapshot["region"] == region
|
||||
assert snapshot["trade_date"] == "2026-03-06"
|
||||
assert snapshot["data_quality"] == "partial"
|
||||
assert snapshot["dimensions"]["breadth"] == {"score": 50, "available": False}
|
||||
assert snapshot["dimensions"]["index"]["available"] is True
|
||||
assert snapshot["dimensions"]["limit"] == {"score": 50, "available": False}
|
||||
|
||||
def test_market_review_payload_omits_breadth_for_markets_without_stats(self):
|
||||
from src.core.market_profile import US_PROFILE
|
||||
from src.market_analyzer import MarketIndex, MarketOverview
|
||||
|
||||
@@ -18,6 +18,7 @@ from src.services.market_light_service import (
|
||||
MARKET_LIGHT_HISTORY_BATCH_SIZE,
|
||||
build_current_snapshot,
|
||||
load_previous_snapshot,
|
||||
normalize_market_region,
|
||||
)
|
||||
from src.storage import AnalysisHistory, DatabaseManager
|
||||
|
||||
@@ -205,6 +206,20 @@ class MarketLightServiceTestCase(unittest.TestCase):
|
||||
self.assertEqual(snapshot["region"], "cn")
|
||||
self.assertEqual(snapshot["score"], 33)
|
||||
|
||||
def test_market_light_service_accepts_jp_kr_regions(self) -> None:
|
||||
self._add_history(
|
||||
created_at=datetime(2026, 3, 6, 18, 0),
|
||||
context_snapshot={"market_light_snapshots": {"jp": _snapshot("jp", "2026-03-06", 54)}},
|
||||
)
|
||||
|
||||
previous = load_previous_snapshot("JP", before_trade_date="2026-03-07", db_manager=self.db)
|
||||
|
||||
self.assertEqual(normalize_market_region("KR"), "kr")
|
||||
self.assertIsNotNone(previous)
|
||||
assert previous is not None
|
||||
self.assertEqual(previous["region"], "jp")
|
||||
self.assertEqual(previous["score"], 54)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -60,9 +60,12 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
|
||||
cases = [
|
||||
(None, ["cn"]),
|
||||
("", ["cn"]),
|
||||
("both", ["cn", "hk", "us"]),
|
||||
("both", ["cn", "hk", "us", "jp", "kr"]),
|
||||
(" CN,US,cn ", ["cn", "us"]),
|
||||
("us,cn,us", ["cn", "us"]),
|
||||
("jp", ["jp"]),
|
||||
("KR", ["kr"]),
|
||||
("kr,jp,us", ["us", "jp", "kr"]),
|
||||
("eu,apac", ["cn"]),
|
||||
(",,", ["cn"]),
|
||||
("HK", ["hk"]),
|
||||
@@ -217,6 +220,16 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
|
||||
report="US body",
|
||||
market_light_snapshot={"region": "us", "trade_date": "2026-03-06", "score": 55},
|
||||
)
|
||||
jp_analyzer = MagicMock()
|
||||
jp_analyzer.run_daily_review_with_snapshot.return_value = SimpleNamespace(
|
||||
report="JP body",
|
||||
market_light_snapshot={"region": "jp", "trade_date": "2026-03-06", "score": 54},
|
||||
)
|
||||
kr_analyzer = MagicMock()
|
||||
kr_analyzer.run_daily_review_with_snapshot.return_value = SimpleNamespace(
|
||||
report="KR body",
|
||||
market_light_snapshot={"region": "kr", "trade_date": "2026-03-06", "score": 53},
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
market_review_module,
|
||||
@@ -225,7 +238,7 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
|
||||
), patch.object(
|
||||
market_review_module,
|
||||
"MarketAnalyzer",
|
||||
side_effect=[cn_analyzer, hk_analyzer, us_analyzer],
|
||||
side_effect=[cn_analyzer, hk_analyzer, us_analyzer, jp_analyzer, kr_analyzer],
|
||||
), patch.object(market_review_module, "_persist_market_review_history") as persist_history:
|
||||
result = run_market_review(notifier, send_notification=True)
|
||||
|
||||
@@ -233,12 +246,16 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
|
||||
self.assertIn("# HK Market Recap\n\nHK body", result)
|
||||
self.assertIn("> Next market recap follows", result)
|
||||
self.assertIn("# US Market Recap\n\nUS body", result)
|
||||
self.assertIn("# Japan Market Recap\n\nJP body", result)
|
||||
self.assertIn("# Korea Market Recap\n\nKR body", result)
|
||||
saved_content = notifier.save_report_to_file.call_args.args[0]
|
||||
self.assertTrue(saved_content.startswith("# 🎯 Market Review\n\n"))
|
||||
self.assertIn("# A-share Market Recap\n\nCN body", saved_content)
|
||||
self.assertIn("> Next market recap follows", saved_content)
|
||||
self.assertIn("# HK Market Recap\n\nHK body", saved_content)
|
||||
self.assertIn("# US Market Recap\n\nUS body", saved_content)
|
||||
self.assertIn("# Japan Market Recap\n\nJP body", saved_content)
|
||||
self.assertIn("# Korea Market Recap\n\nKR body", saved_content)
|
||||
self.assertIn(
|
||||
"# A-share Market Recap\n\nCN body",
|
||||
persist_history.call_args.kwargs["markdown_report"],
|
||||
@@ -246,6 +263,39 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
|
||||
sent_content = notifier.send.call_args.args[0]
|
||||
self.assertTrue(sent_content.startswith("🎯 Market Review\n\n"))
|
||||
self.assertIn("# US Market Recap\n\nUS body", sent_content)
|
||||
self.assertIn("# Japan Market Recap\n\nJP body", sent_content)
|
||||
self.assertIn("# Korea Market Recap\n\nKR body", sent_content)
|
||||
|
||||
def test_run_market_review_comma_joined_subset_jp_kr(self) -> None:
|
||||
notifier = self._make_notifier()
|
||||
jp_analyzer = MagicMock()
|
||||
jp_analyzer.run_daily_review_with_snapshot.return_value = SimpleNamespace(
|
||||
report="JP body",
|
||||
market_light_snapshot={"region": "jp", "trade_date": "2026-03-06", "score": 54},
|
||||
)
|
||||
kr_analyzer = MagicMock()
|
||||
kr_analyzer.run_daily_review_with_snapshot.return_value = SimpleNamespace(
|
||||
report="KR body",
|
||||
market_light_snapshot={"region": "kr", "trade_date": "2026-03-06", "score": 53},
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
market_review_module,
|
||||
"get_config",
|
||||
return_value=SimpleNamespace(report_language="zh", market_review_region="cn"),
|
||||
), patch.object(
|
||||
market_review_module,
|
||||
"MarketAnalyzer",
|
||||
side_effect=[jp_analyzer, kr_analyzer],
|
||||
), patch.object(market_review_module, "_persist_market_review_history"):
|
||||
result = run_market_review(
|
||||
notifier, send_notification=False, override_region="jp,kr"
|
||||
)
|
||||
|
||||
self.assertIn("# 日股大盘复盘\n\nJP body", result)
|
||||
self.assertIn("# 韩股大盘复盘\n\nKR body", result)
|
||||
self.assertNotIn("A股大盘复盘", result)
|
||||
self.assertNotIn("美股大盘复盘", result)
|
||||
|
||||
def test_run_market_review_comma_joined_subset_cn_us(self) -> None:
|
||||
"""Regression: compute_effective_region("both", {"cn","us"}) -> "cn,us"
|
||||
|
||||
@@ -71,6 +71,65 @@ class TestMarketAnalyzerStrategyPrompt(unittest.TestCase):
|
||||
self.assertNotIn("### 一、市场总结", prompt)
|
||||
self.assertNotIn("A股市场三段式复盘策略", prompt)
|
||||
|
||||
def test_jp_kr_strategy_blocks_are_localized_when_report_language_is_en(self):
|
||||
cases = [
|
||||
("jp", "Japan Market Regime Strategy", "Macro & FX", "日本市场三段式复盘策略"),
|
||||
("kr", "Korea Market Regime Strategy", "Technology Cycle", "韩国市场三段式复盘策略"),
|
||||
]
|
||||
|
||||
for region, title, dimension, chinese_title in cases:
|
||||
with self.subTest(region=region):
|
||||
with patch(
|
||||
"src.market_analyzer.get_config",
|
||||
return_value=SimpleNamespace(report_language="en"),
|
||||
):
|
||||
analyzer = MarketAnalyzer(region=region)
|
||||
|
||||
prompt_block = analyzer._get_strategy_prompt_block()
|
||||
markdown_block = analyzer._get_strategy_markdown_block("en")
|
||||
|
||||
self.assertIn(title, prompt_block)
|
||||
self.assertIn(dimension, prompt_block)
|
||||
self.assertNotIn(chinese_title, prompt_block)
|
||||
self.assertNotIn("只基于可得指数", prompt_block)
|
||||
self.assertIn("### 6. Strategy Framework", markdown_block)
|
||||
self.assertIn(dimension, markdown_block)
|
||||
self.assertNotIn("### 六、策略框架", markdown_block)
|
||||
|
||||
def test_jp_kr_review_prompt_roles_are_market_aware(self):
|
||||
cases = [
|
||||
("jp", "Japan market", "日本市场"),
|
||||
("kr", "Korea market", "韩国市场"),
|
||||
]
|
||||
|
||||
for region, english_market, chinese_market in cases:
|
||||
with self.subTest(region=region, language="en"):
|
||||
with patch(
|
||||
"src.market_analyzer.get_config",
|
||||
return_value=SimpleNamespace(report_language="en"),
|
||||
):
|
||||
analyzer = MarketAnalyzer(region=region)
|
||||
|
||||
prompt = analyzer._build_review_prompt(MarketOverview(date="2026-02-24"), [])
|
||||
|
||||
self.assertIn(
|
||||
f"You are a professional {english_market} analyst.",
|
||||
prompt,
|
||||
)
|
||||
self.assertNotIn("US/A/H market analyst", prompt)
|
||||
|
||||
with self.subTest(region=region, language="zh"):
|
||||
with patch(
|
||||
"src.market_analyzer.get_config",
|
||||
return_value=SimpleNamespace(report_language="zh"),
|
||||
):
|
||||
analyzer = MarketAnalyzer(region=region)
|
||||
|
||||
prompt = analyzer._build_review_prompt(MarketOverview(date="2026-02-24"), [])
|
||||
|
||||
self.assertIn(f"你是一位专业的{chinese_market}分析师", prompt)
|
||||
self.assertNotIn("A/H/美股市场分析师", prompt)
|
||||
|
||||
def test_market_stats_passes_market_review_purpose(self):
|
||||
analyzer = MarketAnalyzer.__new__(MarketAnalyzer)
|
||||
analyzer.region = "hk"
|
||||
|
||||
@@ -723,9 +723,20 @@ class MarketPhaseContextTestCase(unittest.TestCase):
|
||||
class ComputeEffectiveRegionTestCase(unittest.TestCase):
|
||||
"""Regression tests for compute_effective_region subset logic."""
|
||||
|
||||
def test_both_all_open_returns_comma_joined_three(self):
|
||||
result = trading_calendar.compute_effective_region("both", {"cn", "hk", "us"})
|
||||
self.assertEqual(result, "cn,hk,us")
|
||||
def test_get_open_markets_today_fail_open_includes_new_markets(self):
|
||||
with patch.object(trading_calendar, "_XCALS_AVAILABLE", False):
|
||||
self.assertEqual(
|
||||
trading_calendar.get_open_markets_today(),
|
||||
{"cn", "hk", "us", "jp", "kr", "tw"},
|
||||
)
|
||||
|
||||
def test_both_all_open_returns_comma_joined_supported_markets(self):
|
||||
result = trading_calendar.compute_effective_region("both", {"cn", "hk", "us", "jp", "kr"})
|
||||
self.assertEqual(result, "cn,hk,us,jp,kr")
|
||||
|
||||
def test_both_jp_kr_open_returns_comma_joined_two(self):
|
||||
result = trading_calendar.compute_effective_region("both", {"jp", "kr"})
|
||||
self.assertEqual(result, "jp,kr")
|
||||
|
||||
def test_both_cn_us_open_returns_comma_joined_two(self):
|
||||
result = trading_calendar.compute_effective_region("both", {"cn", "us"})
|
||||
@@ -735,6 +746,18 @@ class ComputeEffectiveRegionTestCase(unittest.TestCase):
|
||||
result = trading_calendar.compute_effective_region("both", {"cn", "hk"})
|
||||
self.assertEqual(result, "cn,hk")
|
||||
|
||||
def test_comma_subset_open_returns_commas_ordered_subset(self):
|
||||
result = trading_calendar.compute_effective_region("cn,jp,us", {"cn", "us"})
|
||||
self.assertEqual(result, "cn,us")
|
||||
|
||||
def test_comma_subset_with_invalid_tokens_filters_invalid_and_orders_by_market_list(self):
|
||||
result = trading_calendar.compute_effective_region("us,eu,cn,xx,jp", {"us", "cn"})
|
||||
self.assertEqual(result, "cn,us")
|
||||
|
||||
def test_comma_subset_no_supported_tokens_falls_back_to_cn(self):
|
||||
result = trading_calendar.compute_effective_region("eu,xx", {"cn", "hk"})
|
||||
self.assertEqual(result, "cn")
|
||||
|
||||
def test_both_single_market_open_returns_single(self):
|
||||
result = trading_calendar.compute_effective_region("both", {"us"})
|
||||
self.assertEqual(result, "us")
|
||||
@@ -745,6 +768,8 @@ class ComputeEffectiveRegionTestCase(unittest.TestCase):
|
||||
|
||||
def test_single_region_open(self):
|
||||
self.assertEqual(trading_calendar.compute_effective_region("hk", {"cn", "hk", "us"}), "hk")
|
||||
self.assertEqual(trading_calendar.compute_effective_region("jp", {"jp"}), "jp")
|
||||
self.assertEqual(trading_calendar.compute_effective_region("kr", {"kr"}), "kr")
|
||||
|
||||
def test_single_region_closed(self):
|
||||
self.assertEqual(trading_calendar.compute_effective_region("hk", {"cn", "us"}), "")
|
||||
|
||||
83
tests/test_yfinance_jp_kr_indices.py
Normal file
83
tests/test_yfinance_jp_kr_indices.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for JP/KR Yahoo Finance market-review index mappings."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pandas as pd
|
||||
|
||||
if 'fake_useragent' not in sys.modules:
|
||||
sys.modules['fake_useragent'] = MagicMock()
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
|
||||
def _make_mock_hist(close: float = 100.0, prev_close: float = 98.0) -> pd.DataFrame:
|
||||
return pd.DataFrame(
|
||||
{
|
||||
'Close': [prev_close, close],
|
||||
'Open': [prev_close - 1, close - 1],
|
||||
'High': [prev_close + 2, close + 2],
|
||||
'Low': [prev_close - 2, close - 2],
|
||||
'Volume': [1000.0, 1200.0],
|
||||
},
|
||||
index=pd.DatetimeIndex(['2026-03-26', '2026-03-27']),
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_yf(hist_df: pd.DataFrame):
|
||||
mock_ticker = MagicMock()
|
||||
mock_ticker.history.return_value = hist_df
|
||||
mock_yf = MagicMock()
|
||||
mock_yf.Ticker.return_value = mock_ticker
|
||||
return mock_yf
|
||||
|
||||
|
||||
class TestJpKrIndexMappings(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from data_provider.yfinance_fetcher import YfinanceFetcher
|
||||
self.fetcher = YfinanceFetcher()
|
||||
|
||||
def test_jp_indices_use_expected_yahoo_symbols(self):
|
||||
mock_yf = _make_mock_yf(pd.DataFrame())
|
||||
|
||||
self.fetcher._get_jp_main_indices(mock_yf)
|
||||
|
||||
ticker_calls = [call.args[0] for call in mock_yf.Ticker.call_args_list]
|
||||
self.assertEqual(ticker_calls, ['^N225', '^TOPX'])
|
||||
|
||||
def test_kr_indices_use_expected_yahoo_symbols(self):
|
||||
mock_yf = _make_mock_yf(pd.DataFrame())
|
||||
|
||||
self.fetcher._get_kr_main_indices(mock_yf)
|
||||
|
||||
ticker_calls = [call.args[0] for call in mock_yf.Ticker.call_args_list]
|
||||
self.assertEqual(ticker_calls, ['^KS11', '^KQ11'])
|
||||
|
||||
def test_jp_indices_return_expected_codes_when_data_available(self):
|
||||
result = self.fetcher._get_jp_main_indices(_make_mock_yf(_make_mock_hist()))
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
assert result is not None
|
||||
self.assertEqual([item['code'] for item in result], ['N225', 'TOPX'])
|
||||
self.assertEqual([item['name'] for item in result], ['日经225', '东证指数'])
|
||||
|
||||
def test_kr_indices_return_expected_codes_when_data_available(self):
|
||||
result = self.fetcher._get_kr_main_indices(_make_mock_yf(_make_mock_hist()))
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
assert result is not None
|
||||
self.assertEqual([item['code'] for item in result], ['KS11', 'KQ11'])
|
||||
self.assertEqual([item['name'] for item in result], ['KOSPI', 'KOSDAQ'])
|
||||
|
||||
def test_jp_kr_indices_return_none_when_all_empty(self):
|
||||
mock_yf = _make_mock_yf(pd.DataFrame())
|
||||
|
||||
self.assertIsNone(self.fetcher._get_jp_main_indices(mock_yf))
|
||||
self.assertIsNone(self.fetcher._get_kr_main_indices(mock_yf))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user