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:
zhulinsen
2026-06-28 17:03:15 +08:00
committed by GitHub
parent 62adb5c4ee
commit ade3b4cb6e
37 changed files with 901 additions and 104 deletions

View File

@@ -753,7 +753,8 @@ RUN_IMMEDIATELY=true
MARKET_REVIEW_ENABLED=true MARKET_REVIEW_ENABLED=true
# 是否将大盘环境摘要注入个股分析 Prompt 并启用保守护栏true/false默认开启 # 是否将大盘环境摘要注入个股分析 Prompt 并启用保守护栏true/false默认开启
DAILY_MARKET_CONTEXT_ENABLED=true 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 # MARKET_REVIEW_REGION=cn
# 大盘复盘指数涨跌颜色green_up=绿涨红跌默认red_up=红涨绿跌 # 大盘复盘指数涨跌颜色green_up=绿涨红跌默认red_up=红涨绿跌
# MARKET_REVIEW_COLOR_SCHEME=green_up # MARKET_REVIEW_COLOR_SCHEME=green_up

View File

@@ -24,6 +24,8 @@ For English contributors: please fill in English. All fields marked (EN) accept
> 注意:请按实际 `git diff` 全量列出文件范围(建议注明文件总数),避免遗漏文档/后端/API/前端文件导致描述不一致。 > 注意:请按实际 `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 不一致: > 建议先执行并粘贴以下命令输出,避免与实际 diff 不一致:
```bash ```bash
@@ -54,23 +56,57 @@ git diff --name-only "$BASE_REF"..HEAD
python -m pytest -m "not network" 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: 关键输出/结论 / 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 CIai-governance:pass / backend-gate:pass / docker-build:pass / web-gate:pass`(仅示例,按实际结果替换)。
> 若上述核验项与 PR 文本冲突,建议先更新 PR 描述再提交,避免审查因状态不一致被阻塞。
## Visual Evidence (if applicable) ## 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.)* *(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 评论附件或外部可访问链接。 > - 审查证据链接:可直接使用 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 linksWeb UI/报告改动项必填,未提供请在下方“不适用原因”给出替代证据):
- settings 页建议命名:`smoke-settings-page-zh` / `smoke-settings-page-en`
- 前后对比 / Before & After如有 - 前后对比 / 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 ## 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).* *(EN) Describe compatibility impact and potential risks (write `None` if not applicable).*
- 若本 PR 修改第三方模型 / API 的兼容语义、请求参数、路由前缀或 provider fallback请提供**官方来源链接或公告**,并说明这是长期约束、当前运行时约束还是临时兼容处理。 - 若本 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.)* *(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 行为),请写明当前验证过的兼容范围与覆盖路径。 - 若本 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.)* *(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 触及运行时配置保存、清理、迁移或回填逻辑,请明确说明旧配置是否会被自动改写、清空、迁移或保持不变,以及用户如何恢复原行为。 - 若本 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.)* *(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 ## Rollback Plan
@@ -113,4 +155,5 @@ python -m pytest -m "not network"
- [ ] 已评估兼容性与风险 / Compatibility and risk have been assessed - [ ] 已评估兼容性与风险 / Compatibility and risk have been assessed
- [ ] 已提供回滚方案 / A rollback plan is provided - [ ] 已提供回滚方案 / 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 - [ ] 若修改报告格式或 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` - [ ] 若涉及用户可见变更,已同步更新相关文档与 `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`

View File

@@ -93,11 +93,6 @@ const STOP_LOSS_MODE_OPTIONS = [
{ value: 'breach', label: '已触发止损' }, { 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 }> = [ const MARKET_LIGHT_STATUS_OPTIONS: Array<{ value: MarketLightStatus; label: string }> = [
{ value: 'red', label: '红灯' }, { value: 'red', label: '红灯' },
@@ -453,7 +448,7 @@ export const AlertRuleForm: React.FC<AlertRuleFormProps> = ({ onSubmit, isSubmit
<Select <Select
label={text.marketRegion} label={text.marketRegion}
value={marketRegion} value={marketRegion}
options={language === 'zh' ? MARKET_REGION_OPTIONS : ALERT_MARKET_REGION_OPTIONS[language]} options={ALERT_MARKET_REGION_OPTIONS[language]}
disabled={isSubmitting} disabled={isSubmitting}
onChange={(value) => setMarketRegion(value as MarketRegion)} onChange={(value) => setMarketRegion(value as MarketRegion)}
/> />

View File

@@ -234,6 +234,18 @@ describe('AlertRuleForm', () => {
expect(screen.queryByText('组合回撤')).not.toBeInTheDocument(); 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 () => { it('submits a market light status rule payload', async () => {
render(<AlertRuleForm onSubmit={onSubmit} />); render(<AlertRuleForm onSubmit={onSubmit} />);

View File

@@ -215,12 +215,6 @@ describe('SettingsField', () => {
options: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], options: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
expectedLabels: ['调试', '信息', '警告', '错误', '严重'], expectedLabels: ['调试', '信息', '警告', '错误', '严重'],
}, },
{
key: 'MARKET_REVIEW_REGION',
category: 'system',
options: ['cn', 'hk', 'us', 'both'],
expectedLabels: ['A 股', '港股', '美股', '全部市场'],
},
] as const; ] as const;
selectCases.forEach(({ key, category, options, expectedLabels }) => { 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', () => { it('renders context compression profile options with Chinese labels', () => {
const onChange = vi.fn(); const onChange = vi.fn();

View File

@@ -1138,10 +1138,11 @@ const settingsHelpZhCN: SettingsHelpMap = {
}, },
'settings.system.market_review': { 'settings.system.market_review': {
title: '大盘分析', title: '大盘分析',
summary: '控制大盘分析功能的开关、覆盖市场和配色方案。', summary: '控制大盘分析功能的开关、支持的市场子集和配色方案。',
usage: 'MARKET_REVIEW_ENABLED 开启大盘分析DAILY_MARKET_CONTEXT_ENABLED 默认开启,会把当日大盘摘要用于个股分析 Prompt 与保守护栏MARKET_REVIEW_REGION 选择市场cn/hk/us/bothMARKET_REVIEW_COLOR_SCHEME 选择配色。', usage: 'MARKET_REVIEW_ENABLED 开启大盘分析DAILY_MARKET_CONTEXT_ENABLED 默认开启,会把当日大盘摘要用于个股分析 Prompt 与保守护栏MARKET_REVIEW_REGION 支持输入逗号分隔的市场子集(如 cn,us,jp 或 cn,us,jp,kr不传可回退为 cnMARKET_REVIEW_COLOR_SCHEME 选择配色。',
valueNotes: [ 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 后仍可生成大盘复盘报告,但个股分析不会读取大盘摘要或软化买入/加仓建议。', '默认开启 DAILY_MARKET_CONTEXT_ENABLED设为 false 后仍可生成大盘复盘报告,但个股分析不会读取大盘摘要或软化买入/加仓建议。',
'配色方案影响大盘报告中指数涨跌的颜色显示green_up 为绿涨红跌red_up 为红涨绿跌。', '配色方案影响大盘报告中指数涨跌的颜色显示green_up 为绿涨红跌red_up 为红涨绿跌。',
], ],
@@ -2228,10 +2229,11 @@ const settingsHelpEnUS: SettingsHelpMap = {
}, },
'settings.system.market_review': { 'settings.system.market_review': {
title: 'Market Review', title: 'Market Review',
summary: 'Controls the market review feature: on/off, coverage region, and color scheme.', 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 selects markets (cn/hk/us/both); MARKET_REVIEW_COLOR_SCHEME selects colors.', 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: [ 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.', '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.', 'Color scheme affects how index changes are displayed: green_up = green for gains/red for losses; red_up = red for gains/green for losses.',
], ],

View File

@@ -328,7 +328,7 @@ const fieldDescriptionMap: Record<string, string> = {
RUN_IMMEDIATELY: '程序启动后立即执行一次分析任务。', RUN_IMMEDIATELY: '程序启动后立即执行一次分析任务。',
MARKET_REVIEW_ENABLED: '是否启用大盘复盘流程。', MARKET_REVIEW_ENABLED: '是否启用大盘复盘流程。',
DAILY_MARKET_CONTEXT_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 为红涨绿跌。', MARKET_REVIEW_COLOR_SCHEME: '控制大盘复盘指数涨跌幅图标颜色green_up 为绿涨红跌red_up 为红涨绿跌。',
ANALYSIS_DELAY: '启动任务前的延迟秒数,可用于等待依赖服务就绪。', ANALYSIS_DELAY: '启动任务前的延迟秒数,可用于等待依赖服务就绪。',
SAVE_CONTEXT_SNAPSHOT: '控制是否持久化整份分析历史 context_snapshot关闭后不会保存低敏输入概览、市场阶段摘要和增强上下文但不影响当次分析的 pack 构建或 Prompt 摘要。', SAVE_CONTEXT_SNAPSHOT: '控制是否持久化整份分析历史 context_snapshot关闭后不会保存低敏输入概览、市场阶段摘要和增强上下文但不影响当次分析的 pack 构建或 Prompt 摘要。',
@@ -426,12 +426,6 @@ const fieldOptionLabelMap: Record<string, Record<string, string>> = {
basic: '基础', basic: '基础',
debug: '调试', debug: '调试',
}, },
MARKET_REVIEW_REGION: {
cn: 'A 股',
hk: '港股',
us: '美股',
both: '全部市场',
},
AGENT_ARCH: { AGENT_ARCH: {
single: '单 Agent', single: '单 Agent',
multi: '多 Agent编排', multi: '多 Agent编排',
@@ -509,12 +503,6 @@ const fieldOptionLabelMapEn: Record<string, Record<string, string>> = {
basic: 'Basic', basic: 'Basic',
debug: 'Debug', debug: 'Debug',
}, },
MARKET_REVIEW_REGION: {
cn: 'A-shares',
hk: 'Hong Kong',
us: 'US',
both: 'All markets',
},
AGENT_ARCH: { AGENT_ARCH: {
single: 'Single Agent', single: 'Single Agent',
multi: 'Multi Agent (orchestrator)', multi: 'Multi Agent (orchestrator)',

View File

@@ -139,10 +139,6 @@ describe('systemConfigI18n option label localization', () => {
['LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL', 'off', undefined, '关闭'], ['LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL', 'off', undefined, '关闭'],
['LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL', 'basic', undefined, '基础'], ['LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL', 'basic', undefined, '基础'],
['LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL', 'debug', 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', 'green_up', 'Green Up / Red Down', '绿涨红跌'],
['MARKET_REVIEW_COLOR_SCHEME', 'red_up', 'Red Up / Green Down', '红涨绿跌'], ['MARKET_REVIEW_COLOR_SCHEME', 'red_up', 'Red Up / Green Down', '红涨绿跌'],
['GENERATION_BACKEND', 'litellm', undefined, '默认模型配置'], ['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', () => { describe('SAVE_CONTEXT_SNAPSHOT settings help contract', () => {

View File

@@ -339,9 +339,10 @@ class YfinanceFetcher(BaseFetcher):
def get_main_indices(self, region: str = "cn") -> Optional[List[Dict[str, Any]]]: 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=us 时委托给 _get_us_main_indices。
region=hk 时委托给 _get_hk_main_indices。 region=hk 时委托给 _get_hk_main_indices。
region=jp/kr/tw 时分别委托给对应市场指数方法。
""" """
import yfinance as yf import yfinance as yf

View File

@@ -13,8 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [文档] 补齐概念板块排行字段契约与通知报告行业/概念类型列展示说明。 - [文档] 补齐概念板块排行字段契约与通知报告行业/概念类型列展示说明。
- [新功能] #1742 新增信号归因分析功能dashboard.signal_attribution解释推荐理由的构成技术指标、新闻舆情、基本面、市场环境的贡献度以及最强看多/看空信号)。支持默认通知报告和 Jinja2 模板渲染,包含中英文国际化标签。归一化函数在 _parse_response() 和 parse_dashboard_json() 中显式调用,确保有效非零贡献度归一化到 100all-zero 保留为 0表示无有效信号 - [新功能] #1742 新增信号归因分析功能dashboard.signal_attribution解释推荐理由的构成技术指标、新闻舆情、基本面、市场环境的贡献度以及最强看多/看空信号)。支持默认通知报告和 Jinja2 模板渲染,包含中英文国际化标签。归一化函数在 _parse_response() 和 parse_dashboard_json() 中显式调用,确保有效非零贡献度归一化到 100all-zero 保留为 0表示无有效信号
- [改进] Agent 路径同步:更新 executor.py 和 decision_agent.py 的 prompt确保 agent/multi-agent 分析时也生成 signal_attribution 字段。 - [改进] 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 Phase 1 硬化日本/韩国 suffix-only 个股 MVP集中 JP/KR/TW suffix 识别规则,扩充日韩股票种子索引,并为 yfinance 报价/基本面上下文补充市场、币种与数据质量元数据。
- [文档] #1815 补充 JP/KR/TW suffix-only MVP 在外部 API、provider/model/base URL 与运行时配置上的边界说明:当前为结构化字段兼容验证且可回退到旧链路。 - [文档] #1815 补充 JP/KR/TW suffix-only MVP 在外部 API、provider/model/base URL 与运行时配置上的边界说明:当前为结构化字段兼容验证且可回退到旧链路。
- [文档] #1815 细化 PR 提交流程约束:.github/PULL_REQUEST_TEMPLATE.md 补充 Head CI 一致性、Web 设置变更可视证据、第三方兼容性声明与回滚说明要求,避免描述与验证状态/变更影响不一致。
- [修复] 修复通知 Markdown 表格转换在空单元格后将后续内容错配到错误表头的问题。 - [修复] 修复通知 Markdown 表格转换在空单元格后将后续内容错配到错误表头的问题。
- [修复] 将 Docker 可安装的 Longbridge SDK 版本固定为 0.2.75,避免 `longbridge>=0.2.77` 从包索引消失后导致 docker-build 失败。 - [修复] 将 Docker 可安装的 Longbridge SDK 版本固定为 0.2.75,避免 `longbridge>=0.2.77` 从包索引消失后导致 docker-build 失败。
- [修复] 持仓快照今日估值改为受限并发预取多只持仓实时价,减少持仓较多时 Web 组合页面刷新超时。 - [修复] 持仓快照今日估值改为受限并发预取多只持仓实时价,减少持仓较多时 Web 组合页面刷新超时。

View File

@@ -338,7 +338,7 @@ P6 不做:
- `AlertTriggerItem` 保留 `diagnostics` 字符串,并新增派生字段 `market_phase_summary``analysis_context_pack_overview``analysis_visibility_source` - `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` - 真实 `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` - `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。 - `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 或持仓敏感细节。 - 告警通知只输出公开摘要阶段标签、trigger source、partial-bar warning、数据质量等级和前两条 limitations。通知不得输出 raw context pack、Prompt、新闻正文、完整 diagnostics JSON、webhook URL、token 或持仓敏感细节。
- Web 告警历史展示 phase badge、数据质量等级和 limitations 空态;旧触发记录缺少公开摘要时不影响列表读取。 - Web 告警历史展示 phase badge、数据质量等级和 limitations 空态;旧触发记录缺少公开摘要时不影响列表读取。
@@ -358,10 +358,10 @@ P7 在现有 Alert API、Web 告警中心和 `src/services/alert_worker.py` 中
| `target_scope` | `target` | 允许的 `alert_type` | 参数 | 触发语义 | | `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` / `jp` / `kr` | `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_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` 契约 ### `MarketLightSnapshot` 契约

View File

@@ -69,7 +69,7 @@ Web 入口位于 `/decision-signals`
- 时间线只支持 `30d``90d``180d` 三个时间范围,默认 `90d`;每次最多请求 100 条。若返回 `total > items.length`Web 会显示“仅展示最近 100 条信号,请缩小时间范围”,避免静默展示不完整轨迹。 - 时间线只支持 `30d``90d``180d` 三个时间范围,默认 `90d`;每次最多请求 100 条。若返回 `total > items.length`Web 会显示“仅展示最近 100 条信号,请缩小时间范围”,避免静默展示不完整轨迹。
- 时间线 status filter 只支持 `all``active``all` 不传 `status``active``status=active`。P1 不提供 terminal status filter也不做前端 terminal 过滤。 - 时间线 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` - 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。 - 详情抽屉展示动作、状态、评分、置信度、周期、计划质量、市场阶段、价格计划、风险、观察条件、证据、数据质量和 metadata。
- Web 只能把信号标记为 `closed``invalidated``archived`,不提供 terminal 状态恢复为 active。 - 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 懒回填入口。 - 历史报告详情不再内嵌展示报告绑定的 `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 懒回填入口。

View File

@@ -440,7 +440,7 @@ daily_stock_analysis/
| `MAX_WORKERS` | 并发线程数 | `3` | | `MAX_WORKERS` | 并发线程数 | `3` |
| `MARKET_REVIEW_ENABLED` | 启用大盘复盘 | `true` | | `MARKET_REVIEW_ENABLED` | 启用大盘复盘 | `true` |
| `DAILY_MARKET_CONTEXT_ENABLED` | 将当日大盘环境摘要注入个股分析 Prompt并在高风险/退潮环境下软化激进买入建议;默认开启,设为 `false` 后仍可运行大盘复盘 | `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` | | `MARKET_REVIEW_COLOR_SCHEME` | 大盘复盘指数涨跌颜色:`green_up`=绿涨红跌(默认),`red_up`=红涨绿跌 | `green_up` |
| `TRADING_DAY_CHECK_ENABLED` | 交易日检查:默认 `true`,非交易日跳过执行;设为 `false` 或使用 `--force-run` 可强制执行Issue #373 | `true` | | `TRADING_DAY_CHECK_ENABLED` | 交易日检查:默认 `true`,非交易日跳过执行;设为 `false` 或使用 `--force-run` 可强制执行Issue #373 | `true` |
| `SCHEDULE_ENABLED` | 启用定时任务 | `false` | | `SCHEDULE_ENABLED` | 启用定时任务 | `false` |
@@ -759,12 +759,12 @@ docker run -e SCHEDULE_ENABLED=true -e SCHEDULE_RUN_IMMEDIATELY=false ...
#### 交易日判断Issue #373 #### 交易日判断Issue #373
默认根据自选股市场A 股 / 港股 / 美股)和 `MARKET_REVIEW_REGION` 判断是否为交易日: 默认根据自选股市场A 股 / 港股 / 美股 / 日股 / 韩股)和 `MARKET_REVIEW_REGION` 判断是否为交易日:
- 使用 `exchange-calendars` 区分 A 股 / 港股 / 美股各自的交易日历(含节假日) - 使用 `exchange-calendars` 区分 A 股 / 港股 / 美股 / 日股 / 韩股各自的交易日历(含节假日)
- 混合持仓时,每只股票只在其市场开市日分析,休市股票当日跳过 - 混合持仓时,每只股票只在其市场开市日分析,休市股票当日跳过
- 全部相关市场均为非交易日时,整体跳过执行(不启动 pipeline、不发推送 - 全部相关市场均为非交易日时,整体跳过执行(不启动 pipeline、不发推送
- 断点续传和 `--dry-run` 的“数据已存在”判断共用同一套“最新可复用交易日”解析逻辑,不再直接使用服务器自然日 - 断点续传和 `--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` - 覆盖方式:`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` 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 中。 持仓页新增手动单股分析入口,对应 `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` 判断冷却状态,避免浏览器本地时区解析影响展示。 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)。
## 持仓管理说明 ## 持仓管理说明

View File

@@ -363,7 +363,7 @@ For the notification baseline, diagnostics, and deployment notes, see [Notificat
| `MAX_WORKERS` | Concurrent threads | `3` | | `MAX_WORKERS` | Concurrent threads | `3` |
| `MARKET_REVIEW_ENABLED` | Enable market review | `true` | | `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` | | `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` | | `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_ENABLED` | Enable scheduled tasks | `false` |
| `SCHEDULE_TIME` | Scheduled execution time | `18:00` | | `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`. 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. 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. 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.
--- ---

View File

@@ -22,19 +22,73 @@
兼容性与回退说明(针对结构化检测命中项): 兼容性与回退说明(针对结构化检测命中项):
- `#1815` 本次仅新增 `yfinance` 报价/基本面上下文中的可选字段元数据(如 `market``currency``data_quality``missing_fields``provider`),未改动 LLM provider/model/base URL、配置 Schema、运行时环境变量、数据库字段、存量缓存序列化或消息协议版本。 - `#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 路径变更。 - 兼容性验证依据:行情/基本面上下文在 `data_provider/base.py``realtime_types.py` 中按现有 `getattr`/可选字段约定向下游透传,不强制读写新增字段;无配置迁移脚本,未观察到 provider/model/base URL fallback 路径变更。
- 回退方式:若新增元数据字段在某端产生兼容问题,可先忽略这些字段并按既有市场判定+行情展示链路运行;必要时回滚本次提交或通过移除 `jp/kr` `MarketSymbol` 及路由扩展恢复旧行为。 - 回退方式:若新增元数据字段在某端产生兼容问题,可先忽略这些字段并按既有市场判定+行情展示链路运行;必要时回滚本次提交或通过移除 `jp/kr` `MarketSymbol` 及路由扩展恢复旧行为。
不承诺项: 不承诺项:
- 不承诺实时行情Yahoo Finance 数据可能延迟或字段缺失。 - 不承诺实时行情Yahoo Finance 数据可能延迟或字段缺失。
- 不承诺完整基本面、行业/板块、市场宽度涨跌家数或日韩大盘复盘 - 不承诺完整基本面、行业/板块、市场宽度涨跌家数。JP/KR 大盘复盘 v1 仅提供主要指数、新闻线索与模板/LLM 复盘,不提供日韩市场宽度或板块排行
- 不承诺完整日韩全市场股票列表Web 自动补全当前仅覆盖仓内种子索引中的常用标的(已扩充至各 30 只左右的头部标的),未命中时仍可手动输入 suffix 代码。 - 不承诺完整日韩全市场股票列表Web 自动补全当前仅覆盖仓内种子索引中的常用标的(已扩充至各 30 只左右的头部标的),未命中时仍可手动输入 suffix 代码。
- 不补齐 Portfolio 的 JPY/KRW 汇率、成本、市值完整口径;相关字段仅放开市场类型以避免前后端校验拒绝。 - 不补齐 Portfolio 的 JPY/KRW 汇率、成本、市值完整口径;相关字段仅放开市场类型以避免前后端校验拒绝。
回滚方式:移除 `jp/kr` 市场识别、交易日历注册、YFinance 路由扩展、Web/API 类型放行、`scripts/stock_index_seeds/` 日韩种子索引,并删除本文档中的能力声明。 回滚方式:移除 `jp/kr` 市场识别、交易日历注册、YFinance 路由扩展、Web/API 类型放行、`scripts/stock_index_seeds/` 日韩种子索引,并删除本文档中的能力声明。
## 日本/韩国大盘复盘 v1Issue #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 MVPIssue #1772Refs #1772 ## 台湾个股 suffix-only MVPIssue #1772Refs #1772
当前阶段支持手动输入台湾股票的 Yahoo Finance 后缀代码进入既有个股分析、历史保存和基础报告展示链路。TWSE 上市股票使用 `.TW` 后缀TPEx 上柜(柜买)股票使用 `.TWO` 后缀,二者折叠为同一 `tw` 市场标签。**本次覆盖市场识别detection、数据路由层、DecisionSignal/Portfolio/Intelligence 服务层与 API 市场枚举,以及 DecisionSignal/Portfolio 前端市场类型与筛选**;台股股票索引/种子、Web 自动补全与告警(大盘红绿灯)市场放行仍作为后续 PR。对齐 #1718 日韩 MVP 模式。 当前阶段支持手动输入台湾股票的 Yahoo Finance 后缀代码进入既有个股分析、历史保存和基础报告展示链路。TWSE 上市股票使用 `.TW` 后缀TPEx 上柜(柜买)股票使用 `.TWO` 后缀,二者折叠为同一 `tw` 市场标签。**本次覆盖市场识别detection、数据路由层、DecisionSignal/Portfolio/Intelligence 服务层与 API 市场枚举,以及 DecisionSignal/Portfolio 前端市场类型与筛选**;台股股票索引/种子、Web 自动补全与告警(大盘红绿灯)市场放行仍作为后续 PR。对齐 #1718 日韩 MVP 模式。

View File

@@ -587,7 +587,7 @@ def _resolve_daily_market_context_target_date(
current_time: datetime, current_time: datetime,
) -> date: ) -> date:
normalized_region = str(region or "cn").strip().lower() 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 from src.core.trading_calendar import get_effective_trading_date

View File

@@ -996,7 +996,7 @@ class Config:
run_immediately: bool = True # 启动时是否立即执行一次(非定时模式) run_immediately: bool = True # 启动时是否立即执行一次(非定时模式)
market_review_enabled: bool = True # 是否启用大盘复盘 market_review_enabled: bool = True # 是否启用大盘复盘
daily_market_context_enabled: bool = True # 是否将大盘环境摘要用于个股分析 Prompt 与保守护栏 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_region: str = "cn"
market_review_color_scheme: str = "green_up" market_review_color_scheme: str = "green_up"
# 交易日检查:默认启用,非交易日跳过执行;设为 false 或 --force-run 可强制执行Issue #373 # 交易日检查:默认启用,非交易日跳过执行;设为 false 或 --force-run 可强制执行Issue #373
@@ -2523,10 +2523,24 @@ class Config:
"""解析大盘复盘市场区域,非法值记录警告后回退为 cn""" """解析大盘复盘市场区域,非法值记录警告后回退为 cn"""
import logging import logging
v = (value or 'cn').strip().lower() 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 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( 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' return 'cn'

View File

@@ -3326,20 +3326,21 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
}, },
"MARKET_REVIEW_REGION": { "MARKET_REVIEW_REGION": {
"title": "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", "category": "system",
"data_type": "string", "data_type": "string",
"ui_control": "select", "ui_control": "text",
"is_sensitive": False, "is_sensitive": False,
"is_required": False, "is_required": False,
"is_editable": True, "is_editable": True,
"default_value": "cn", "default_value": "cn",
"options": ["cn", "hk", "us", "both"], "options": ["cn", "hk", "us", "jp", "kr", "both"],
"validation": {"enum": ["cn", "hk", "us", "both"]}, "validation": {"allowed_values": ["cn", "hk", "us", "jp", "kr", "both"], "delimiter": ","},
"display_order": 48, "display_order": 48,
"help_key": "settings.system.market_review", "help_key": "settings.system.market_review",
"examples": [ "examples": [
"MARKET_REVIEW_REGION=cn", "MARKET_REVIEW_REGION=cn",
"MARKET_REVIEW_REGION=jp",
"MARKET_REVIEW_REGION=both", "MARKET_REVIEW_REGION=both",
], ],
"docs": [ "docs": [

View File

@@ -3,7 +3,7 @@
大盘复盘市场区域配置 大盘复盘市场区域配置
定义各市场区域的指数、新闻搜索词、Prompt 提示等元数据, 定义各市场区域的指数、新闻搜索词、Prompt 提示等元数据,
供 MarketAnalyzer 按 region 切换 A 股/美股复盘行为。 供 MarketAnalyzer 按 region 切换 A 股/港股/美股/日韩复盘行为。
""" """
from dataclasses import dataclass from dataclasses import dataclass
@@ -14,7 +14,7 @@ from typing import List
class MarketProfile: class MarketProfile:
"""大盘复盘市场区域配置""" """大盘复盘市场区域配置"""
region: str # "cn" | "us" region: str # "cn" | "hk" | "us" | "jp" | "kr"
# 用于判断整体走势的指数代码cn 用上证 000001us 用标普 SPX # 用于判断整体走势的指数代码cn 用上证 000001us 用标普 SPX
mood_index_code: str mood_index_code: str
# 新闻搜索关键词 # 新闻搜索关键词
@@ -66,6 +66,32 @@ HK_PROFILE = MarketProfile(
has_sector_rankings=False, 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: def get_profile(region: str) -> MarketProfile:
"""根据 region 返回对应的 MarketProfile""" """根据 region 返回对应的 MarketProfile"""
@@ -73,4 +99,8 @@ def get_profile(region: str) -> MarketProfile:
return US_PROFILE return US_PROFILE
if region == "hk": if region == "hk":
return HK_PROFILE return HK_PROFILE
if region == "jp":
return JP_PROFILE
if region == "kr":
return KR_PROFILE
return CN_PROFILE return CN_PROFILE

View File

@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
=================================== ===================================
股票智能分析系统 - 大盘复盘模块(支持 A 股 / 港股 / 美股) 股票智能分析系统 - 大盘复盘模块(支持 A 股 / 港股 / 美股 / 日本 / 韩国
=================================== ===================================
职责: 职责:
1. 根据 MARKET_REVIEW_REGION 配置选择市场区域cn / hk / us / both 1. 根据 MARKET_REVIEW_REGION 配置选择市场区域cn / hk / us / jp / kr / both
2. 执行大盘复盘分析并生成复盘报告 2. 执行大盘复盘分析并生成复盘报告
3. 保存和发送复盘报告 3. 保存和发送复盘报告
""" """
@@ -38,6 +38,8 @@ _MARKET_REVIEW_MARKETS = (
('cn', 'cn_title', 'A 股'), ('cn', 'cn_title', 'A 股'),
('hk', 'hk_title', '港股'), ('hk', 'hk_title', '港股'),
('us', 'us_title', '美股'), ('us', 'us_title', '美股'),
('jp', 'jp_title', '日股'),
('kr', 'kr_title', '韩股'),
) )
_MARKET_REVIEW_REGION_ORDER = tuple(market for market, _, _ in _MARKET_REVIEW_MARKETS) _MARKET_REVIEW_REGION_ORDER = tuple(market for market, _, _ in _MARKET_REVIEW_MARKETS)
_VALID_MARKET_REVIEW_REGIONS = frozenset(_MARKET_REVIEW_REGION_ORDER) _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", "cn_title": "# A-share Market Recap",
"us_title": "# US Market Recap", "us_title": "# US Market Recap",
"hk_title": "# HK Market Recap", "hk_title": "# HK Market Recap",
"jp_title": "# Japan Market Recap",
"kr_title": "# Korea Market Recap",
"separator": "> Next market recap follows", "separator": "> Next market recap follows",
} }
return { return {
@@ -108,6 +112,8 @@ def _get_market_review_text(language: str) -> dict[str, str]:
"cn_title": "# A股大盘复盘", "cn_title": "# A股大盘复盘",
"us_title": "# 美股大盘复盘", "us_title": "# 美股大盘复盘",
"hk_title": "# 港股大盘复盘", "hk_title": "# 港股大盘复盘",
"jp_title": "# 日股大盘复盘",
"kr_title": "# 韩股大盘复盘",
"separator": "> 以下为下一市场大盘复盘", "separator": "> 以下为下一市场大盘复盘",
} }

View File

@@ -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: def get_market_strategy_blueprint(region: str) -> MarketStrategyBlueprint:
"""Return strategy blueprint by market region.""" """Return strategy blueprint by market region."""
if region == "us": if region == "us":
return US_BLUEPRINT return US_BLUEPRINT
if region == "hk": if region == "hk":
return HK_BLUEPRINT return HK_BLUEPRINT
if region == "jp":
return JP_BLUEPRINT
if region == "kr":
return KR_BLUEPRINT
return CN_BLUEPRINT return CN_BLUEPRINT

View File

@@ -5,7 +5,7 @@
=================================== ===================================
职责: 职责:
1. 按市场A股/港股/美股)判断当日是否为交易日 1. 按市场A股/港股/美股/日股/韩股/台股)判断当日是否为交易日
2. 按市场时区取“今日”日期,避免服务器 UTC 导致日期错误 2. 按市场时区取“今日”日期,避免服务器 UTC 导致日期错误
3. 支持 per-stock 过滤:只分析当日开市市场的股票 3. 支持 per-stock 过滤:只分析当日开市市场的股票
4. 提供 regular-session 市场阶段推断基线,不改变现有分析入口行为 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). Get markets that are open today (by each market's local timezone).
Returns: Returns:
Set of market keys ('cn', 'hk', 'us') that are trading today Set of market keys that are trading today
""" """
if not _XCALS_AVAILABLE: if not _XCALS_AVAILABLE:
return {"cn", "hk", "us"} return set(MARKET_TIMEZONE)
result: Set[str] = set() result: Set[str] = set()
for mkt, tz_name in MARKET_TIMEZONE.items(): for mkt, tz_name in MARKET_TIMEZONE.items():
try: try:
@@ -543,22 +543,44 @@ def compute_effective_region(
Compute effective market review region given config and open markets. Compute effective market review region given config and open markets.
Args: 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 open_markets: Markets open today
Returns: Returns:
None: caller uses config default (check disabled) None: caller uses config default (check disabled)
'': all relevant markets closed, skip market review '': 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"): markets = ("cn", "hk", "us", "jp", "kr")
config_region = "cn" normalized = (config_region or "cn").strip().lower()
if config_region in ("cn", "hk", "us"): if not normalized:
return config_region if config_region in open_markets else "" normalized = "cn"
# both: return only the markets that are actually open today
parts = [m for m in ("cn", "hk", "us") if m in open_markets] requested = {
if not parts: 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 "" return ""
if len(parts) == 1: if len(open_selected) == 1:
return parts[0] return open_selected[0]
return ",".join(parts) return ",".join(open_selected)

View File

@@ -140,14 +140,14 @@ class MarketAnalyzer:
Args: Args:
search_service: 搜索服务实例 search_service: 搜索服务实例
analyzer: AI分析器实例用于调用LLM analyzer: AI分析器实例用于调用LLM
region: 市场区域 cn=A股 us=美股 region: 市场区域 cn=A股 hk=港股 us=美股 jp=日本 kr=韩国
config: 本次复盘使用的配置;未传时读取全局配置 config: 本次复盘使用的配置;未传时读取全局配置
""" """
self.config = config or get_config() self.config = config or get_config()
self.search_service = search_service self.search_service = search_service
self.analyzer = analyzer self.analyzer = analyzer
self.data_manager = DataFetcherManager() 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.profile: MarketProfile = get_profile(self.region)
self.strategy = get_market_strategy_blueprint(self.region) self.strategy = get_market_strategy_blueprint(self.region)
@@ -170,6 +170,10 @@ class MarketAnalyzer:
return "US market" if review_language == "en" else "美股市场" return "US market" if review_language == "en" else "美股市场"
if self.region == "hk": if self.region == "hk":
return "Hong Kong market" if review_language == "en" else "港股市场" 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": if review_language == "en":
return "A-share market" return "A-share market"
return "A股市场" return "A股市场"
@@ -180,13 +184,17 @@ class MarketAnalyzer:
return "USD bn" if self._get_review_language() == "en" else "十亿美元" return "USD bn" if self._get_review_language() == "en" else "十亿美元"
if self.region == "hk": if self.region == "hk":
return "HKD bn" if self._get_review_language() == "en" else "十亿港元" 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 "亿" return "CNY 100m" if self._get_review_language() == "en" else "亿"
def _format_turnover_value(self, amount_raw: float) -> str: def _format_turnover_value(self, amount_raw: float) -> str:
"""Format raw turnover according to market-specific units.""" """Format raw turnover according to market-specific units."""
if amount_raw == 0.0: if amount_raw == 0.0:
return "N/A" return "N/A"
if self.region in ("us", "hk"): if self.region in ("us", "hk", "jp", "kr"):
return f"{amount_raw / 1e9:.2f}" return f"{amount_raw / 1e9:.2f}"
if amount_raw > 1e6: if amount_raw > 1e6:
return f"{amount_raw / 1e8:.0f}" return f"{amount_raw / 1e8:.0f}"
@@ -202,7 +210,12 @@ class MarketAnalyzer:
def _get_review_title(self, date: str) -> str: def _get_review_title(self, date: str) -> str:
if self._get_review_language() == "en": 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") market_name = market_names.get(self.region, "A-share Market Recap")
return f"## {date} {market_name}" return f"## {date} {market_name}"
return f"## {date} 大盘复盘" 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." return "Analyze the key moves in the S&P 500, Nasdaq, Dow, and other major indices."
if self.region == "hk": if self.region == "hk":
return "Analyze the key moves in the HSI, Hang Seng Tech, HSCEI, and other major indices." 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 "Analyze the price action in the SSE, SZSE, ChiNext, and other major indices."
return self.profile.prompt_index_hint 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. - Risk-on: broad index breakout with expanding southbound participation.
- Neutral: mixed index signals; focus on selective relative strength. - Neutral: mixed index signals; focus on selective relative strength.
- Risk-off: failed breakouts and rising volatility; prioritize capital preservation.""" - 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": if self.region == "us" and self._get_review_language() == "zh":
return """## 美股市场三段式复盘策略 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. - **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. - **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. - **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": if self.region == "us" and review_language == "zh":
return """### 六、策略框架 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", "cn": "大盘" if review_language == "zh" else "A-share market",
"us": "美股市场" if review_language == "zh" else "US market", "us": "美股市场" if review_language == "zh" else "US market",
"hk": "港股市场" if review_language == "zh" else "HK 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: try:
@@ -1292,7 +1377,7 @@ Concept lagging: {bottom_concepts_text if bottom_concepts_text else "N/A"}"""
if review_language == "en": if review_language == "en":
report_title = self._get_review_title(overview.date).removeprefix("## ").strip() 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] [Requirements]
- Output pure Markdown only - Output pure Markdown only
@@ -1355,7 +1440,7 @@ Output the report content directly, no extra commentary.
""" """
# A 股场景使用中文提示语 # 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 文本格式 - 必须输出纯 Markdown 文本格式
@@ -1482,7 +1567,12 @@ Output the report content directly, no extra commentary.
- **Concept Leaders**: {top_concept_text or "N/A"} - **Concept Leaders**: {top_concept_text or "N/A"}
- **Concept Laggards**: {bottom_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") market_name = market_names.get(self.region, "A-share Market Recap")
report = f"""## {overview.date} {market_name} 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 return report
market_labels = {"cn": "A股", "us": "美股", "hk": "港股"} market_labels = {"cn": "A股", "us": "美股", "hk": "港股", "jp": "日股", "kr": "韩股"}
market_label = market_labels.get(self.region, "A股") market_label = market_labels.get(self.region, "A股")
dashboard_block = self._build_stats_block(overview) dashboard_block = self._build_stats_block(overview)
indices_block = self._build_indices_block(overview) indices_block = self._build_indices_block(overview)

View File

@@ -8,7 +8,7 @@ from typing import Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
MarketRegion = Literal["cn", "hk", "us"] MarketRegion = Literal["cn", "hk", "us", "jp", "kr"]
MarketLightStatus = Literal["green", "yellow", "red"] MarketLightStatus = Literal["green", "yellow", "red"]
MarketLightDataQuality = Literal["ok", "partial", "unavailable"] MarketLightDataQuality = Literal["ok", "partial", "unavailable"]

View File

@@ -30,8 +30,8 @@ MARKET_REVIEW_HISTORY_CODE = "MARKET"
MARKET_REVIEW_REPORT_TYPE = "market_review" MARKET_REVIEW_REPORT_TYPE = "market_review"
_REGION_LABEL_ZH = {"cn": "A股", "hk": "港股", "us": "美股"} _REGION_LABEL_ZH = {"cn": "A股", "hk": "港股", "us": "美股", "jp": "日股", "kr": "韩股"}
_REGION_LABEL_EN = {"cn": "A-share", "hk": "HK", "us": "US"} _REGION_LABEL_EN = {"cn": "A-share", "hk": "HK", "us": "US", "jp": "Japan", "kr": "Korea"}
_VALID_REGIONS = frozenset(_REGION_LABEL_ZH) _VALID_REGIONS = frozenset(_REGION_LABEL_ZH)
_UNTRUSTED_MARKET_SUMMARY_SENTINELS = ( _UNTRUSTED_MARKET_SUMMARY_SENTINELS = (
"BEGIN_UNTRUSTED_MARKET_SUMMARY", "BEGIN_UNTRUSTED_MARKET_SUMMARY",

View File

@@ -24,6 +24,8 @@ MARKET_REGION_LABELS = {
"cn": "A股大盘", "cn": "A股大盘",
"hk": "港股大盘", "hk": "港股大盘",
"us": "美股大盘", "us": "美股大盘",
"jp": "日股大盘",
"kr": "韩股大盘",
} }
MARKET_LIGHT_DATA_SOURCE = "market_light" MARKET_LIGHT_DATA_SOURCE = "market_light"

View File

@@ -17,14 +17,14 @@ from src.storage import AnalysisHistory, DatabaseManager
logger = logging.getLogger(__name__) 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 MARKET_LIGHT_HISTORY_BATCH_SIZE = 100
def normalize_market_region(region: str) -> str: def normalize_market_region(region: str) -> str:
value = str(region or "").strip().lower() value = str(region or "").strip().lower()
if value not in MARKET_LIGHT_REGIONS: 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 return value

View File

@@ -31,6 +31,57 @@ class ConfigEnvCompatibilityTestCase(unittest.TestCase):
self.assertEqual(config.tickflow_api_key, "tf-secret") 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("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[]) @patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_load_from_env_keeps_default_behavior_without_tickflow_api_key( def test_load_from_env_keeps_default_behavior_without_tickflow_api_key(

View File

@@ -778,6 +778,22 @@ class TestMarketReviewFieldsRegistered(unittest.TestCase):
self.assertEqual(field["validation"]["enum"], ["green_up", "red_up"]) self.assertEqual(field["validation"]["enum"], ["green_up", "red_up"])
self.assertFalse(field["is_sensitive"]) 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): def test_daily_market_context_field_definition_exists(self):
field = get_field_definition("DAILY_MARKET_CONTEXT_ENABLED") field = get_field_definition("DAILY_MARKET_CONTEXT_ENABLED")
self.assertEqual(field["category"], "system") self.assertEqual(field["category"], "system")
@@ -793,6 +809,7 @@ class TestMarketReviewFieldsRegistered(unittest.TestCase):
field_keys = {f["key"] for f in system_cat["fields"]} field_keys = {f["key"] for f in system_cat["fields"]}
self.assertIn("MARKET_REVIEW_COLOR_SCHEME", field_keys) self.assertIn("MARKET_REVIEW_COLOR_SCHEME", field_keys)
self.assertIn("DAILY_MARKET_CONTEXT_ENABLED", field_keys) self.assertIn("DAILY_MARKET_CONTEXT_ENABLED", field_keys)
self.assertIn("MARKET_REVIEW_REGION", field_keys)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1030,3 +1030,40 @@ def test_yellow_market_light_status_marks_context_conservative() -> None:
assert context is not None assert context is not None
assert "conservative" in context.to_safe_dict()["risk_tags"] 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

View File

@@ -1109,6 +1109,28 @@ class MainScheduleModeTestCase(unittest.TestCase):
refresh.assert_called_once_with(config) refresh.assert_called_once_with(config)
pipeline.run.assert_called_once() 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: def test_run_full_analysis_does_not_reuse_single_context_for_multi_market_review(self) -> None:
args = self._make_args() args = self._make_args()
target_date = date(2026, 3, 26) target_date = date(2026, 3, 26)

View File

@@ -2673,6 +2673,47 @@ class TestMarketAnalyzerBypassFix:
assert "### 1. Market Summary" not in result assert "### 1. Market Summary" not in result
assert "US Market Recap" 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): def test_inject_data_into_review_matches_english_headings(self):
from src.market_analyzer import MarketOverview, MarketIndex from src.market_analyzer import MarketOverview, MarketIndex
@@ -2987,6 +3028,36 @@ Sector text.
assert snapshot["dimensions"]["index"]["available"] is True assert snapshot["dimensions"]["index"]["available"] is True
assert snapshot["dimensions"]["limit"] == {"score": 50, "available": False} 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): def test_market_review_payload_omits_breadth_for_markets_without_stats(self):
from src.core.market_profile import US_PROFILE from src.core.market_profile import US_PROFILE
from src.market_analyzer import MarketIndex, MarketOverview from src.market_analyzer import MarketIndex, MarketOverview

View File

@@ -18,6 +18,7 @@ from src.services.market_light_service import (
MARKET_LIGHT_HISTORY_BATCH_SIZE, MARKET_LIGHT_HISTORY_BATCH_SIZE,
build_current_snapshot, build_current_snapshot,
load_previous_snapshot, load_previous_snapshot,
normalize_market_region,
) )
from src.storage import AnalysisHistory, DatabaseManager from src.storage import AnalysisHistory, DatabaseManager
@@ -205,6 +206,20 @@ class MarketLightServiceTestCase(unittest.TestCase):
self.assertEqual(snapshot["region"], "cn") self.assertEqual(snapshot["region"], "cn")
self.assertEqual(snapshot["score"], 33) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -60,9 +60,12 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
cases = [ cases = [
(None, ["cn"]), (None, ["cn"]),
("", ["cn"]), ("", ["cn"]),
("both", ["cn", "hk", "us"]), ("both", ["cn", "hk", "us", "jp", "kr"]),
(" CN,US,cn ", ["cn", "us"]), (" CN,US,cn ", ["cn", "us"]),
("us,cn,us", ["cn", "us"]), ("us,cn,us", ["cn", "us"]),
("jp", ["jp"]),
("KR", ["kr"]),
("kr,jp,us", ["us", "jp", "kr"]),
("eu,apac", ["cn"]), ("eu,apac", ["cn"]),
(",,", ["cn"]), (",,", ["cn"]),
("HK", ["hk"]), ("HK", ["hk"]),
@@ -217,6 +220,16 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
report="US body", report="US body",
market_light_snapshot={"region": "us", "trade_date": "2026-03-06", "score": 55}, 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( with patch.object(
market_review_module, market_review_module,
@@ -225,7 +238,7 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
), patch.object( ), patch.object(
market_review_module, market_review_module,
"MarketAnalyzer", "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: ), patch.object(market_review_module, "_persist_market_review_history") as persist_history:
result = run_market_review(notifier, send_notification=True) 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("# HK Market Recap\n\nHK body", result)
self.assertIn("> Next market recap follows", result) self.assertIn("> Next market recap follows", result)
self.assertIn("# US Market Recap\n\nUS body", 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] saved_content = notifier.save_report_to_file.call_args.args[0]
self.assertTrue(saved_content.startswith("# 🎯 Market Review\n\n")) self.assertTrue(saved_content.startswith("# 🎯 Market Review\n\n"))
self.assertIn("# A-share Market Recap\n\nCN body", saved_content) self.assertIn("# A-share Market Recap\n\nCN body", saved_content)
self.assertIn("> Next market recap follows", saved_content) self.assertIn("> Next market recap follows", saved_content)
self.assertIn("# HK Market Recap\n\nHK body", saved_content) self.assertIn("# HK Market Recap\n\nHK body", saved_content)
self.assertIn("# US Market Recap\n\nUS 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( self.assertIn(
"# A-share Market Recap\n\nCN body", "# A-share Market Recap\n\nCN body",
persist_history.call_args.kwargs["markdown_report"], persist_history.call_args.kwargs["markdown_report"],
@@ -246,6 +263,39 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
sent_content = notifier.send.call_args.args[0] sent_content = notifier.send.call_args.args[0]
self.assertTrue(sent_content.startswith("🎯 Market Review\n\n")) self.assertTrue(sent_content.startswith("🎯 Market Review\n\n"))
self.assertIn("# US Market Recap\n\nUS body", sent_content) 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: def test_run_market_review_comma_joined_subset_cn_us(self) -> None:
"""Regression: compute_effective_region("both", {"cn","us"}) -> "cn,us" """Regression: compute_effective_region("both", {"cn","us"}) -> "cn,us"

View File

@@ -71,6 +71,65 @@ class TestMarketAnalyzerStrategyPrompt(unittest.TestCase):
self.assertNotIn("### 一、市场总结", prompt) self.assertNotIn("### 一、市场总结", prompt)
self.assertNotIn("A股市场三段式复盘策略", 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): def test_market_stats_passes_market_review_purpose(self):
analyzer = MarketAnalyzer.__new__(MarketAnalyzer) analyzer = MarketAnalyzer.__new__(MarketAnalyzer)
analyzer.region = "hk" analyzer.region = "hk"

View File

@@ -723,9 +723,20 @@ class MarketPhaseContextTestCase(unittest.TestCase):
class ComputeEffectiveRegionTestCase(unittest.TestCase): class ComputeEffectiveRegionTestCase(unittest.TestCase):
"""Regression tests for compute_effective_region subset logic.""" """Regression tests for compute_effective_region subset logic."""
def test_both_all_open_returns_comma_joined_three(self): def test_get_open_markets_today_fail_open_includes_new_markets(self):
result = trading_calendar.compute_effective_region("both", {"cn", "hk", "us"}) with patch.object(trading_calendar, "_XCALS_AVAILABLE", False):
self.assertEqual(result, "cn,hk,us") 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): def test_both_cn_us_open_returns_comma_joined_two(self):
result = trading_calendar.compute_effective_region("both", {"cn", "us"}) 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"}) result = trading_calendar.compute_effective_region("both", {"cn", "hk"})
self.assertEqual(result, "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): def test_both_single_market_open_returns_single(self):
result = trading_calendar.compute_effective_region("both", {"us"}) result = trading_calendar.compute_effective_region("both", {"us"})
self.assertEqual(result, "us") self.assertEqual(result, "us")
@@ -745,6 +768,8 @@ class ComputeEffectiveRegionTestCase(unittest.TestCase):
def test_single_region_open(self): 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("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): def test_single_region_closed(self):
self.assertEqual(trading_calendar.compute_effective_region("hk", {"cn", "us"}), "") self.assertEqual(trading_calendar.compute_effective_region("hk", {"cn", "us"}), "")

View 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()