mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
fix: clarify JP KR service boundaries (#1823)
* feat: add JP KR market review support * fix: clarify JP KR service boundaries * fix(review-feedback-1823): add the fields to the response schema and cover the endpoint path, not * fix(review-feedback-1823): Avoid routing JP/KR reviews through MarketLight schema * fix(review-feedback-1823): add JP/KR to that resolver when accepting these values * fix(review-feedback-1823): Keep JP/KR daily contexts from normalizing to CN and add JP/KR labels * fix(review-feedback-1823): 解决冲突后再合入 * fix(review-feedback-1823): 解决冲突并确认最终 diff 后再评审合入 * fix(review-feedback-1823): 澄清或补齐验证证据 * fix(review-feedback-1823): 解决冲突,并在冲突解决后的 head 上重新确认关键验证结果 * fix(review-feedback-1823): Make the Chinese JP/KR prompt shell region-aware * fix(review-feedback-1823): 解决冲突后再判断最终 head 是否可合入 * fix(review-feedback-1823): 基于目标分支解决冲突,并在冲突后的最终 diff 上重新确认相关后端/Web 检查 * fix(review-feedback-1823): 修复 * fix(review-feedback-1823): 解决冲突 * fix(review-feedback-1823): 解决冲突后再合入 * fix(review-feedback-1823): 解决冲突后再合入 * fix(review-feedback-1823): Make the all-markets checkbox exclusive * fix(review-feedback-1823): 解决冲突,并在冲突解决后的最新 head 上重新确认关键测试和 Web 构建结果 * fix(review-feedback-1823): 解决冲突并确保解决后的 head 重新通过对应验证 * fix(review-feedback-1823): 解决冲突,并在冲突解决后重新确认受影响的后端、Web 与文档改动仍一致 * fix(review-feedback-1823): 解决冲突,再基于最新 base 复核完整 diff,并重新确认相关后端/Web 验证结果仍成立 * fix(review-feedback-1823): 解决冲突后重新确认最终 diff 与 CI 结果 * fix(review-feedback-1823): 收敛 * fix(review-feedback-1823): 基于目标分支解决冲突并重新确认关键验证结果 * fix(review-feedback-1823): 解决 * fix(review-feedback-1823): 收敛 * fix(review-feedback-1823): 解决冲突后重新确认 diff 与 CI * fix(review-feedback-1823): 补充本次未改变哪些配置契约、对应测试覆盖和回退方式 * fix(review-feedback-1823): 收敛 * fix(review-feedback-1823): PR 描述与实际 diff scope 仍不完全对齐:完整改动包含 * fix(review-feedback-1823): 解决冲突并在冲突后的最终 diff 上重新确认 CI/关键测试结果 * fix(review-feedback-1823): 解决冲突并确认解决后的 diff 与现有 43 个文件 scope 仍一致 * fix(review-feedback-1823): 解决冲突后再合入 * fix(review-feedback-1823): 解决冲突,并在最终 head 上重新确认受影响的后端与 Web 验证结果 * fix(review-feedback-1823): 解决冲突并确认冲突解决后的最终 head 仍通过对应验证 * fix(review-feedback-1823): 处理旧 both 历史记录在 JP/KR 复盘上下文匹配中的语义兼容风险
This commit is contained in:
@@ -166,6 +166,8 @@ class PortfolioPositionItem(BaseModel):
|
||||
price_date: Optional[str] = None
|
||||
price_stale: bool = False
|
||||
price_available: bool = True
|
||||
data_quality: str = "ok"
|
||||
limitations: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PortfolioPositionAnalysisRequest(BaseModel):
|
||||
@@ -191,6 +193,8 @@ class PortfolioAccountSnapshot(BaseModel):
|
||||
fee_total: float
|
||||
tax_total: float
|
||||
fx_stale: bool
|
||||
data_quality: str = "ok"
|
||||
limitations: List[str] = Field(default_factory=list)
|
||||
positions: List[PortfolioPositionItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -207,6 +211,8 @@ class PortfolioSnapshotResponse(BaseModel):
|
||||
fee_total: float
|
||||
tax_total: float
|
||||
fx_stale: bool
|
||||
data_quality: str = "ok"
|
||||
limitations: List[str] = Field(default_factory=list)
|
||||
accounts: List[PortfolioAccountSnapshot] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
@@ -250,6 +250,11 @@ describe('AlertRuleForm', () => {
|
||||
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.queryByRole('option', { name: '日股(jp)' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: '韩股(kr)' })).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText('市场区域'), { target: { value: 'hk' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建规则' }));
|
||||
|
||||
@@ -263,6 +268,18 @@ describe('AlertRuleForm', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps JP/KR out of market light options in English UI mode', () => {
|
||||
renderEnglishForm();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Target scope'), { target: { value: 'market' } });
|
||||
|
||||
expect(screen.getByRole('option', { name: 'A-shares (cn)' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Hong Kong (hk)' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'US (us)' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'Japan (jp)' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'Korea (kr)' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits a market light score-drop rule payload', async () => {
|
||||
render(<AlertRuleForm onSubmit={onSubmit} />);
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export const ALERT_TYPE_LABELS: Record<UiLanguage, Record<AlertType, string>> =
|
||||
};
|
||||
export const ALERT_SEVERITY_LABELS: Record<UiLanguage, Record<string, string>> = { zh: { info: '提示', warning: '警告', critical: '严重' }, en: { info: 'Info', warning: 'Warning', critical: 'Critical' } };
|
||||
export const ALERT_SCOPE_LABELS: Record<UiLanguage, Record<AlertTargetScope, string>> = { zh: { single_symbol: '单标的', watchlist: '自选股', portfolio_holdings: '持仓标的', portfolio_account: '持仓账户', market: '大盘市场' }, en: { single_symbol: 'Single symbol', watchlist: 'Watchlist', portfolio_holdings: 'Portfolio holdings', portfolio_account: 'Portfolio account', market: 'Market' } };
|
||||
export const ALERT_MARKET_REGION_LABELS: Record<UiLanguage, Record<MarketRegion, string>> = { zh: { cn: 'A 股', hk: '港股', us: '美股', jp: '日股', kr: '韩股' }, en: { cn: 'A-shares', hk: 'Hong Kong', us: 'US', jp: 'Japan', kr: 'Korea' } };
|
||||
export const ALERT_MARKET_REGION_LABELS: Record<UiLanguage, Record<MarketRegion, string>> = { zh: { cn: 'A 股', hk: '港股', us: '美股' }, en: { cn: 'A-shares', hk: 'Hong Kong', us: 'US' } };
|
||||
export const ALERT_MARKET_LIGHT_STATUS_LABELS: Record<UiLanguage, Record<MarketLightStatus, string>> = { zh: { yellow: '黄灯', red: '红灯' }, en: { yellow: 'Yellow', red: 'Red' } };
|
||||
export const ALERT_DIRECTION_LABELS = {
|
||||
zh: { abovePrice: '上破', belowPrice: '下破', upChange: '上涨', downChange: '下跌', aboveThreshold: '上穿', belowThreshold: '下穿', bullishCross: '金叉', bearishCross: '死叉', stopLossNear: '接近止损', stopLossBreach: '已触发止损' },
|
||||
@@ -56,12 +56,12 @@ export const ALERT_CHANGE_DIRECTION_OPTIONS: Record<UiLanguage, Array<Option<'up
|
||||
export const ALERT_THRESHOLD_DIRECTION_OPTIONS: Record<UiLanguage, Array<Option<'above' | 'below'>>> = { zh: [{ value: 'above', label: '上穿' }, { value: 'below', label: '下穿' }], en: [{ value: 'above', label: 'Crosses above' }, { value: 'below', label: 'Crosses below' }] };
|
||||
export const ALERT_CROSS_DIRECTION_OPTIONS: Record<UiLanguage, Array<Option<'bullish_cross' | 'bearish_cross'>>> = { zh: [{ value: 'bullish_cross', label: '金叉' }, { value: 'bearish_cross', label: '死叉' }], en: [{ value: 'bullish_cross', label: 'Bullish cross' }, { value: 'bearish_cross', label: 'Bearish cross' }] };
|
||||
export const ALERT_STOP_LOSS_MODE_OPTIONS: Record<UiLanguage, Array<Option<PortfolioStopLossMode>>> = { zh: [{ value: 'near', label: '接近止损' }, { value: 'breach', label: '已触发止损' }], en: [{ value: 'near', label: 'Near stop loss' }, { value: 'breach', label: 'Stop loss breached' }] };
|
||||
export const ALERT_MARKET_REGION_OPTIONS: Record<UiLanguage, Array<Option<MarketRegion>>> = { zh: [{ value: 'cn', label: 'A 股(cn)' }, { value: 'hk', label: '港股(hk)' }, { value: 'us', label: '美股(us)' }, { value: 'jp', label: '日股(jp)' }, { value: 'kr', label: '韩股(kr)' }], en: [{ value: 'cn', label: 'A-shares (cn)' }, { value: 'hk', label: 'Hong Kong (hk)' }, { value: 'us', label: 'US (us)' }, { value: 'jp', label: 'Japan (jp)' }, { value: 'kr', label: 'Korea (kr)' }] };
|
||||
export const ALERT_MARKET_REGION_OPTIONS: Record<UiLanguage, Array<Option<MarketRegion>>> = { zh: [{ value: 'cn', label: 'A 股(cn)' }, { value: 'hk', label: '港股(hk)' }, { value: 'us', label: '美股(us)' }], en: [{ value: 'cn', label: 'A-shares (cn)' }, { value: 'hk', label: 'Hong Kong (hk)' }, { value: 'us', label: 'US (us)' }] };
|
||||
export const ALERT_MARKET_LIGHT_STATUS_OPTIONS: Record<UiLanguage, Array<Option<MarketLightStatus>>> = { zh: [{ value: 'red', label: '红灯' }, { value: 'yellow', label: '黄灯' }], en: [{ value: 'red', label: 'Red' }, { value: 'yellow', label: 'Yellow' }] };
|
||||
|
||||
export const PORTFOLIO_TEXT = {
|
||||
zh: { documentTitle: '持仓分析 - DSA', title: '持仓管理', description: '组合快照、手工录入、CSV 导入与风险分析(支持全组合 / 单账户切换)', accountView: '账户视图', allAccounts: '全部账户', costMethod: '成本口径', fifo: '先进先出(FIFO)', avg: '均价成本(AVG)', collapseCreate: '收起新建', createAccount: '新建账户', deleteAccount: '删除账户', deletingAccount: '删除中...', deleteAccountTitle: '删除持仓账户', deleteAccountConfirm: '确认删除', deleteAccountMessage: '确认删除账户 {name}(#{id})吗?删除后该账户会从默认列表、快照、风险和录入入口隐藏;历史流水不会物理删除。', refreshing: '刷新中...', refreshData: '刷新数据', noAccounts: '还没有可用账户,请先创建账户后再录入交易或导入 CSV。', riskDegraded: '风险模块降级', operationHint: '操作提示', analysisTask: '分析任务', totalEquity: '总权益', totalMarketValue: '总市值', totalCash: '总现金', fxStatus: '汇率状态', refreshFx: '刷新汇率', stale: '过期', latest: '最新', fxRefreshResult: '汇率刷新结果', positionsTitle: '持仓明细', countItems: '共 {count} 项', noPositionsTitle: '当前无持仓数据', noPositionsDescription: '录入交易或导入 CSV 后,这里会展示按账户汇总的持仓明细。', account: '账户', code: '代码', quantity: '数量', avgCost: '均价', lastPrice: '现价', marketValue: '市值', unrealizedPnl: '未实现盈亏', returnPct: '收益率', action: '操作', submitting: '提交中', analyze: '分析', sectorConcentration: '行业集中度分布', positionConcentrationFallback: '行业数据暂不可用,当前展示个股集中度', noConcentrationTitle: '暂无集中度数据', noConcentrationDescription: '风险模块完成计算后,这里会展示行业或个股维度的集中度分布。', displayScope: '展示口径', sectorDimension: '行业维度', positionDimensionFallback: '个股维度(降级显示)', sectorAlert: '板块集中度告警', topWeight: 'Top1 权重', yes: '是', no: '否', writeBlocked: '当前处于“全部账户”视图。为避免误写,请先选择一个具体账户后再进行手工录入或 CSV 提交。', drawdownMonitor: '回撤监控', maxDrawdown: '最大回撤', currentDrawdown: '当前回撤', alert: '告警', stopLossWarning: '止损接近预警', triggeredCount: '触发数', nearCount: '接近数', scope: '口径', accountCount: '账户数', currency: '计价币种', costMethodShort: '成本法', aiRiskSignals: 'AI 风险信号', aiRiskUnavailable: '信号风险暂不可用', aiRiskTotal: '风险信号', sellSignals: '卖出', reduceSignals: '减仓', alertSignals: '预警', noAiRiskSignals: '暂无防御型信号' },
|
||||
en: { documentTitle: 'Portfolio Analysis - DSA', title: 'Portfolio management', description: 'Portfolio snapshots, manual entries, CSV import, and risk analysis with full-portfolio or single-account views', accountView: 'Account view', allAccounts: 'All accounts', costMethod: 'Cost method', fifo: 'FIFO', avg: 'Average cost', collapseCreate: 'Collapse', createAccount: 'New account', deleteAccount: 'Delete account', deletingAccount: 'Deleting...', deleteAccountTitle: 'Delete portfolio account', deleteAccountConfirm: 'Delete account', deleteAccountMessage: 'Delete account {name} (#{id})? It will be hidden from default lists, snapshots, risk views, and entry forms; historical ledger rows are not physically deleted.', refreshing: 'Refreshing...', refreshData: 'Refresh data', noAccounts: 'No accounts are available. Create an account before entering trades or importing CSV files.', riskDegraded: 'Risk module degraded', operationHint: 'Operation hint', analysisTask: 'Analysis task', totalEquity: 'Total equity', totalMarketValue: 'Total market value', totalCash: 'Total cash', fxStatus: 'FX status', refreshFx: 'Refresh FX', stale: 'Stale', latest: 'Current', fxRefreshResult: 'FX refresh result', positionsTitle: 'Positions', countItems: '{count} items', noPositionsTitle: 'No positions', noPositionsDescription: 'After you enter trades or import CSV data, account-level positions appear here.', account: 'Account', code: 'Code', quantity: 'Quantity', avgCost: 'Avg cost', lastPrice: 'Last price', marketValue: 'Market value', unrealizedPnl: 'Unrealized P/L', returnPct: 'Return', action: 'Action', submitting: 'Submitting', analyze: 'Analyze', sectorConcentration: 'Sector concentration', positionConcentrationFallback: 'Sector data unavailable; showing position concentration', noConcentrationTitle: 'No concentration data', noConcentrationDescription: 'Sector or position concentration appears after the risk module finishes.', displayScope: 'Display scope', sectorDimension: 'Sector', positionDimensionFallback: 'Position fallback', sectorAlert: 'Sector concentration alert', topWeight: 'Top1 weight', yes: 'Yes', no: 'No', writeBlocked: 'You are viewing all accounts. Select a specific account before manual entry or CSV submission to avoid writing to the wrong scope.', drawdownMonitor: 'Drawdown monitor', maxDrawdown: 'Max drawdown', currentDrawdown: 'Current drawdown', alert: 'Alert', stopLossWarning: 'Stop-loss proximity warning', triggeredCount: 'Triggered', nearCount: 'Near', scope: 'Scope', accountCount: 'Accounts', currency: 'Quote currency', costMethodShort: 'Cost method', aiRiskSignals: 'AI risk signals', aiRiskUnavailable: 'Signal risk unavailable', aiRiskTotal: 'Risk signals', sellSignals: 'Sell', reduceSignals: 'Reduce', alertSignals: 'Alert', noAiRiskSignals: 'No defensive signals' },
|
||||
zh: { documentTitle: '持仓分析 - DSA', title: '持仓管理', description: '组合快照、手工录入、CSV 导入与风险分析(支持全组合 / 单账户切换)', accountView: '账户视图', allAccounts: '全部账户', costMethod: '成本口径', fifo: '先进先出(FIFO)', avg: '均价成本(AVG)', collapseCreate: '收起新建', createAccount: '新建账户', deleteAccount: '删除账户', deletingAccount: '删除中...', deleteAccountTitle: '删除持仓账户', deleteAccountConfirm: '确认删除', deleteAccountMessage: '确认删除账户 {name}(#{id})吗?删除后该账户会从默认列表、快照、风险和录入入口隐藏;历史流水不会物理删除。', refreshing: '刷新中...', refreshData: '刷新数据', noAccounts: '还没有可用账户,请先创建账户后再录入交易或导入 CSV。', riskDegraded: '风险模块降级', operationHint: '操作提示', analysisTask: '分析任务', snapshotPartialTitle: '组合估值限制', totalEquity: '总权益', totalMarketValue: '总市值', totalCash: '总现金', fxStatus: '汇率状态', refreshFx: '刷新汇率', stale: '过期', latest: '最新', fxRefreshResult: '汇率刷新结果', positionsTitle: '持仓明细', countItems: '共 {count} 项', noPositionsTitle: '当前无持仓数据', noPositionsDescription: '录入交易或导入 CSV 后,这里会展示按账户汇总的持仓明细。', account: '账户', code: '代码', quantity: '数量', avgCost: '均价', lastPrice: '现价', marketValue: '市值', unrealizedPnl: '未实现盈亏', returnPct: '收益率', action: '操作', submitting: '提交中', analyze: '分析', sectorConcentration: '行业集中度分布', positionConcentrationFallback: '行业数据暂不可用,当前展示个股集中度', noConcentrationTitle: '暂无集中度数据', noConcentrationDescription: '风险模块完成计算后,这里会展示行业或个股维度的集中度分布。', displayScope: '展示口径', sectorDimension: '行业维度', positionDimensionFallback: '个股维度(降级显示)', sectorAlert: '板块集中度告警', topWeight: 'Top1 权重', yes: '是', no: '否', writeBlocked: '当前处于“全部账户”视图。为避免误写,请先选择一个具体账户后再进行手工录入或 CSV 提交。', drawdownMonitor: '回撤监控', maxDrawdown: '最大回撤', currentDrawdown: '当前回撤', alert: '告警', stopLossWarning: '止损接近预警', triggeredCount: '触发数', nearCount: '接近数', scope: '口径', accountCount: '账户数', currency: '计价币种', costMethodShort: '成本法', aiRiskSignals: 'AI 风险信号', aiRiskUnavailable: '信号风险暂不可用', aiRiskTotal: '风险信号', sellSignals: '卖出', reduceSignals: '减仓', alertSignals: '预警', noAiRiskSignals: '暂无防御型信号' },
|
||||
en: { documentTitle: 'Portfolio Analysis - DSA', title: 'Portfolio management', description: 'Portfolio snapshots, manual entries, CSV import, and risk analysis with full-portfolio or single-account views', accountView: 'Account view', allAccounts: 'All accounts', costMethod: 'Cost method', fifo: 'FIFO', avg: 'Average cost', collapseCreate: 'Collapse', createAccount: 'New account', deleteAccount: 'Delete account', deletingAccount: 'Deleting...', deleteAccountTitle: 'Delete portfolio account', deleteAccountConfirm: 'Delete account', deleteAccountMessage: 'Delete account {name} (#{id})? It will be hidden from default lists, snapshots, risk views, and entry forms; historical ledger rows are not physically deleted.', refreshing: 'Refreshing...', refreshData: 'Refresh data', noAccounts: 'No accounts are available. Create an account before entering trades or importing CSV files.', riskDegraded: 'Risk module degraded', operationHint: 'Operation hint', analysisTask: 'Analysis task', snapshotPartialTitle: 'Portfolio valuation limitations', totalEquity: 'Total equity', totalMarketValue: 'Total market value', totalCash: 'Total cash', fxStatus: 'FX status', refreshFx: 'Refresh FX', stale: 'Stale', latest: 'Current', fxRefreshResult: 'FX refresh result', positionsTitle: 'Positions', countItems: '{count} items', noPositionsTitle: 'No positions', noPositionsDescription: 'After you enter trades or import CSV data, account-level positions appear here.', account: 'Account', code: 'Code', quantity: 'Quantity', avgCost: 'Avg cost', lastPrice: 'Last price', marketValue: 'Market value', unrealizedPnl: 'Unrealized P/L', returnPct: 'Return', action: 'Action', submitting: 'Submitting', analyze: 'Analyze', sectorConcentration: 'Sector concentration', positionConcentrationFallback: 'Sector data unavailable; showing position concentration', noConcentrationTitle: 'No concentration data', noConcentrationDescription: 'Sector or position concentration appears after the risk module finishes.', displayScope: 'Display scope', sectorDimension: 'Sector', positionDimensionFallback: 'Position fallback', sectorAlert: 'Sector concentration alert', topWeight: 'Top1 weight', yes: 'Yes', no: 'No', writeBlocked: 'You are viewing all accounts. Select a specific account before manual entry or CSV submission to avoid writing to the wrong scope.', drawdownMonitor: 'Drawdown monitor', maxDrawdown: 'Max drawdown', currentDrawdown: 'Current drawdown', alert: 'Alert', stopLossWarning: 'Stop-loss proximity warning', triggeredCount: 'Triggered', nearCount: 'Near', scope: 'Scope', accountCount: 'Accounts', currency: 'Quote currency', costMethodShort: 'Cost method', aiRiskSignals: 'AI risk signals', aiRiskUnavailable: 'Signal risk unavailable', aiRiskTotal: 'Risk signals', sellSignals: 'Sell', reduceSignals: 'Reduce', alertSignals: 'Alert', noAiRiskSignals: 'No defensive signals' },
|
||||
} as const;
|
||||
export const PORTFOLIO_SIDE_LABELS: Record<UiLanguage, Record<PortfolioSide, string>> = { zh: { buy: '买入', sell: '卖出' }, en: { buy: 'Buy', sell: 'Sell' } };
|
||||
export const PORTFOLIO_CASH_DIRECTION_LABELS: Record<UiLanguage, Record<PortfolioCashDirection, string>> = { zh: { in: '流入', out: '流出' }, en: { in: 'Inflow', out: 'Outflow' } };
|
||||
|
||||
@@ -80,6 +80,23 @@ type PortfolioSignalLookupResult = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type PortfolioPageLanguage = 'zh' | 'en';
|
||||
|
||||
const PORTFOLIO_LIMITATION_LABELS: Record<string, Record<PortfolioPageLanguage, string>> = {
|
||||
realtime_quote_best_effort: {
|
||||
zh: '实时行情为尽力获取',
|
||||
en: 'Realtime quotes are best-effort',
|
||||
},
|
||||
fx_and_cost_basis_partial: {
|
||||
zh: '汇率与成本基础为部分口径',
|
||||
en: 'FX and cost basis are partial',
|
||||
},
|
||||
sector_and_risk_metrics_limited: {
|
||||
zh: '行业与风险指标覆盖有限',
|
||||
en: 'Sector and risk metrics are limited',
|
||||
},
|
||||
};
|
||||
|
||||
type PendingDelete =
|
||||
| { eventType: 'trade'; id: number; message: string }
|
||||
| { eventType: 'cash'; id: number; message: string }
|
||||
@@ -112,6 +129,10 @@ function isNewerSignal(left: DecisionSignalItem | undefined, right: DecisionSign
|
||||
return getSignalTime(right) > getSignalTime(left);
|
||||
}
|
||||
|
||||
function formatPortfolioLimitation(limitation: string, language: PortfolioPageLanguage): string {
|
||||
return PORTFOLIO_LIMITATION_LABELS[limitation]?.[language] ?? limitation;
|
||||
}
|
||||
|
||||
const DECISION_SIGNAL_MARKETS = new Set<DecisionSignalMarket>(['cn', 'hk', 'us', 'jp', 'kr', 'tw']);
|
||||
type PortfolioAccountMarket = 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
|
||||
|
||||
@@ -925,6 +946,11 @@ const PortfolioPage: React.FC = () => {
|
||||
decisionActionLabels,
|
||||
) ?? text.alert
|
||||
);
|
||||
const snapshotQualityMessage = snapshot?.dataQuality === 'partial' && snapshot.limitations?.length
|
||||
? snapshot.limitations
|
||||
.map((limitation) => formatPortfolioLimitation(limitation, language))
|
||||
.join(language === 'en' ? '; ' : ';')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="portfolio-page min-h-screen space-y-4 p-4 md:p-6">
|
||||
@@ -1101,6 +1127,15 @@ const PortfolioPage: React.FC = () => {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{snapshotQualityMessage ? (
|
||||
<InlineAlert
|
||||
variant="warning"
|
||||
title={text.snapshotPartialTitle}
|
||||
message={snapshotQualityMessage}
|
||||
className="rounded-xl px-3 py-2 text-xs shadow-none"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
<Card variant="gradient" padding="md">
|
||||
<p className="text-xs text-secondary">{text.totalEquity}</p>
|
||||
|
||||
@@ -121,6 +121,8 @@ function makeSnapshot(options: {
|
||||
accountId?: number;
|
||||
fxStale?: boolean;
|
||||
accountCount?: number;
|
||||
dataQuality?: string;
|
||||
limitations?: string[];
|
||||
positions?: Array<Record<string, unknown>>;
|
||||
} = {}) {
|
||||
const accountId = options.accountId ?? 1;
|
||||
@@ -137,6 +139,8 @@ function makeSnapshot(options: {
|
||||
feeTotal: 0,
|
||||
taxTotal: 0,
|
||||
fxStale: options.fxStale ?? true,
|
||||
dataQuality: options.dataQuality ?? 'ok',
|
||||
limitations: options.limitations ?? [],
|
||||
accounts: [
|
||||
{
|
||||
accountId,
|
||||
@@ -350,6 +354,21 @@ describe('PortfolioPage FX refresh', () => {
|
||||
expect(screen.getByRole('button', { name: '刷新汇率' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows aggregate partial valuation limitations near summary totals', async () => {
|
||||
getSnapshot.mockResolvedValueOnce(makeSnapshot({
|
||||
dataQuality: 'partial',
|
||||
limitations: ['realtime_quote_best_effort', 'fx_and_cost_basis_partial'],
|
||||
}));
|
||||
|
||||
render(<PortfolioPage />);
|
||||
|
||||
await waitForInitialLoad();
|
||||
|
||||
expect(await screen.findByText('组合估值限制')).toBeInTheDocument();
|
||||
expect(screen.getByText(/实时行情为尽力获取/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/汇率与成本基础为部分口径/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders portfolio risk drawdown labels in English UI mode', async () => {
|
||||
renderEnglishPage();
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export type AlertSeverity = 'info' | 'warning' | 'critical';
|
||||
export type AlertTargetScope = 'single_symbol' | 'watchlist' | 'portfolio_holdings' | 'portfolio_account' | 'market';
|
||||
export type AlertDirection = 'above' | 'below' | 'up' | 'down' | 'bullish_cross' | 'bearish_cross';
|
||||
export type PortfolioStopLossMode = 'near' | 'breach';
|
||||
export type MarketRegion = 'cn' | 'hk' | 'us' | 'jp' | 'kr';
|
||||
export type MarketRegion = 'cn' | 'hk' | 'us';
|
||||
export type MarketLightStatus = 'yellow' | 'red';
|
||||
export type AlertDryRunStatus = 'triggered' | 'not_triggered' | 'evaluation_error';
|
||||
export type AlertTriggerStatus = 'triggered' | 'skipped' | 'degraded' | 'failed';
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface PortfolioPositionItem {
|
||||
priceDate?: string | null;
|
||||
priceStale?: boolean;
|
||||
priceAvailable?: boolean;
|
||||
dataQuality?: 'ok' | 'partial' | string;
|
||||
limitations?: string[];
|
||||
}
|
||||
|
||||
export interface PortfolioPositionAnalysisRequest {
|
||||
@@ -71,6 +73,8 @@ export interface PortfolioAccountSnapshot {
|
||||
feeTotal: number;
|
||||
taxTotal: number;
|
||||
fxStale: boolean;
|
||||
dataQuality?: 'ok' | 'partial' | string;
|
||||
limitations?: string[];
|
||||
positions: PortfolioPositionItem[];
|
||||
}
|
||||
|
||||
@@ -87,6 +91,8 @@ export interface PortfolioSnapshotResponse {
|
||||
feeTotal: number;
|
||||
taxTotal: number;
|
||||
fxStale: boolean;
|
||||
dataQuality?: 'ok' | 'partial' | string;
|
||||
limitations?: string[];
|
||||
accounts: PortfolioAccountSnapshot[];
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
- [文档] #1815 补充 JP/KR/TW suffix-only MVP 在外部 API、provider/model/base URL 与运行时配置上的边界说明:当前为结构化字段兼容验证且可回退到旧链路。
|
||||
- [文档] #1815 细化 PR 提交流程约束:.github/PULL_REQUEST_TEMPLATE.md 补充 Head CI 一致性、Web 设置变更可视证据、第三方兼容性声明与回滚说明要求,避免描述与验证状态/变更影响不一致。
|
||||
- [修复] 修复通知 Markdown 表格转换在空单元格后将后续内容错配到错误表头的问题。
|
||||
- [改进] #1815 Phase 3 收敛 JP/KR Portfolio 与 Market Light 边界:JP/KR 持仓快照标记 partial/limitations,Market Light 告警继续限定 cn/hk/us,并同步 Web 选项、文档和测试。
|
||||
- [文档] #1815 明确 JP/KR Phase 3 收敛时的兼容与回退路径:`MARKET_REVIEW_REGION=jp/kr` 仅扩展复盘输入;Market Light 告警、LLM provider/model/base URL、运行时配置持久化与清理语义保持不变,并补充官方来源、当前 LiteLLM 依赖窗口与回归测试证据。
|
||||
- [文档] #1815 集中补充 `MARKET_REVIEW_REGION` 保存/校验/回退矩阵、旧 `both` 三市场边界到 `cn,hk,us` 的迁移说明、JP/KR yfinance 指数依赖边界,以及 Market Light 告警与设置页 UI 变更的可替代验证证据;补充冲突解决后最终 head 的 backend gate 与 Web lint/build 验证结论。
|
||||
- [修复] 将 Docker 可安装的 Longbridge SDK 版本固定为 0.2.75,避免 `longbridge>=0.2.77` 从包索引消失后导致 docker-build 失败。
|
||||
- [修复] 持仓快照今日估值改为受限并发预取多只持仓实时价,减少持仓较多时 Web 组合页面刷新超时。
|
||||
- [修复] Web 首页重新分析完成后自动切换到同一股票最新生成的报告,避免仍停留在旧报告内容。
|
||||
|
||||
@@ -222,6 +222,7 @@ daily_stock_analysis/
|
||||
> 完整说明见 [LLM 配置指南](LLM_CONFIG_GUIDE.md)(三层配置、渠道模式、Vision、Agent、排错);常用服务商预设、Actions 变量对照和错误排障见 [LLM 服务商配置指南](llm-providers.md)。
|
||||
> 兼容性说明(Issue #1306/#1391,顺带确认 #1381):本节相关改动只复用已有历史写入链路展示大盘复盘结果,不新增 API/API 参数、Web 阶段结果独立展示、日报四阶段结构化持久化或日报状态表,不修改 `provider` / `model` / `base_url` 运行时路由与默认模型行为;#1381 同样仅为后端 runtime 复用,不新增配置迁移/清理/回写分支。若 Issue #1381 的 API/Web/日报结构化验收未同步落地,本 PR 不应作为完整交付收口,需留待后续 PR 继续交付。回退路径为发布回滚(可直接 revert 当前提交,或按现有配置回退链路)。兼容验证主要沿用既有约束检查(`requirements.txt`:`litellm` 版本约束)与既有配置回归测试:`tests/test_system_config_service.py`、`tests/test_system_config_api.py`、`tests/test_llm_channel_config.py`、`tests/test_market_review_runtime.py`;官方源参考:[LiteLLM OpenAI-compatible](https://docs.litellm.ai/docs/providers/openai_compatible)、[OpenAI Chat Completion API](https://platform.openai.com/docs/api-reference/chat)。
|
||||
> #1391 Phase 2 的结构化检测风险来自 `src/agent/factory.py` 的 `agent_max_steps` / `agent_orchestrator_timeout_s` int 安全兜底,属于配置读取侧的类型兼容增强,不会改写 `litellm_model`、`agent_litellm_model`、`openai_base_url` 或 `LLM_*` 路由状态;回归可复核 `tests/test_agent_pipeline.py::TestAgentConfig::test_build_agent_executor_does_not_mutate_llm_route_config` 与 `tests/test_agent_pipeline.py::TestAgentConfig::test_build_agent_executor_multi_arch_does_not_mutate_llm_route_config`。当配置值非法(如非数字)时,`src.agent.factory` 会记录 warning 并回退到默认值,便于排障与避免误判配置已生效。
|
||||
> #1815 Phase 3 的兼容边界说明:本轮仅收敛 JP/KR 与 Market Light 的服务边界,不新增 LLM provider/model/base_url 迁移逻辑,不改写 `.env` 主路由模型持久化语义。`MarketSymbol`、告警枚举与快照 `data_quality/limitations` 调整按已有 `.env` 原子 upsert 语义写入保存配置;未显示提交的键不会被清空。
|
||||
> 本节仅同步模型/渠道配置清单,不额外引入新的外部 provider / Base URL 兼容约定;兼容语义以当前仓库 `requirements.txt` 依赖约束和相关测试为准,历史回退路径见上述两份文档中“回退/恢复”说明。
|
||||
|
||||
| 变量名 | 说明 | 默认值 | 必填 |
|
||||
@@ -764,6 +765,13 @@ docker run -e SCHEDULE_ENABLED=true -e SCHEDULE_RUN_IMMEDIATELY=false ...
|
||||
|
||||
> 兼容说明:如果运行时显式传入 `RUN_IMMEDIATELY`,但没有单独传 `SCHEDULE_RUN_IMMEDIATELY`,内置调度模式会继续继承前者,避免被 `.env` 中持久化的 `SCHEDULE_RUN_IMMEDIATELY` 旧值反向覆盖。
|
||||
|
||||
> 兼容说明(Issue #1815):`MARKET_REVIEW_REGION=cn|hk|us|jp|kr|both` 仅扩展大盘复盘输入集合;JP/KR 仅供复盘上下文消费,不会放开 Market Light 告警。
|
||||
> - `src/config.py`、`src/core/config_registry.py`、`src/services/system_config_service.py` 的改动仅是配置语义扩展,不改 `provider`/`model`/`base_url` 的运行时路由,也不触发 provider/model/base URL 迁移或清理逻辑。
|
||||
> - 本轮实际受控配置项:`MARKET_REVIEW_REGION`、`MARKET_REVIEW_COLOR_SCHEME`;`LITELLM_MODEL`、`AGENT_LITELLM_MODEL`、`LITELLM_FALLBACK_MODELS`、`VISION_MODEL`、`OPENAI_BASE_URL` 等旧值保持原子 upsert 语义,不会在更新其他字段时被静默清空或覆盖。
|
||||
> - 可核验证据摘要:官方 provider / Base URL / 模型命名来源沿用 [LLM 配置指南](LLM_CONFIG_GUIDE.md#常用官方文档来源用于核对预设-provider--base-url--模型命名),当前运行时依赖窗口沿用 `requirements.txt` 中的 `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0`;本轮不新增配置迁移脚本或清理分支,保存/导入仍只写本次提交键。`tests/test_system_config_service.py::SystemConfigServiceTestCase::test_update_market_review_region_does_not_trigger_runtime_model_cleanup` 覆盖只保存 `MARKET_REVIEW_REGION` 时不清空或改写 `LITELLM_CONFIG`、`LLM_CHANNELS`、`LLM_OPENAI_*`、`LITELLM_MODEL`、`AGENT_LITELLM_MODEL`、`LITELLM_FALLBACK_MODELS`、`VISION_MODEL`、`OPENAI_*` 等旧配置。
|
||||
> - 旧值回退策略:先恢复备份 `MARKET_REVIEW_REGION` 与配置文件即可回到旧边界,未提交的模型/路由键保留原值;必要时 `revert` PR 并按 `.env` 备份完成回退。
|
||||
> - 可回滚路径:恢复提交前 `.env` / 配置备份中的 `MARKET_REVIEW_REGION` 与相关运行时变量,或直接 revert 本 PR。
|
||||
|
||||
#### 交易日判断(Issue #373)
|
||||
|
||||
默认根据自选股市场(A 股 / 港股 / 美股 / 日股 / 韩股)和 `MARKET_REVIEW_REGION` 判断是否为交易日:
|
||||
|
||||
@@ -192,6 +192,7 @@ Default schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.
|
||||
|
||||
> Full details: [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) (three-tier config, channels, Vision, Agent, troubleshooting).
|
||||
> Compatibility note for Issue #1306: this change only persists and exposes existing market-review output via history paths, and does not alter model name, provider, base URL, LiteLLM cleanup rules, or `.env` runtime migration semantics. Rollback is to revert this change set. Runtime compatibility references are `requirements.txt` (`litellm` constraints), `docs/LLM_CONFIG_GUIDE_EN.md`, and regression tests in `tests/test_analysis_api_contract.py`, `tests/test_analysis_history.py`, `tests/test_market_review.py`; official references: [LiteLLM OpenAI-compatible](https://docs.litellm.ai/docs/providers/openai_compatible), [OpenAI Chat Completion API](https://platform.openai.com/docs/api-reference/chat).
|
||||
> Phase 3 compatibility note for #1815: this change only narrows JP/KR vs Market Light runtime boundaries. It does not add new provider/model/base URL migration logic, and it does not change `.env` model persistence semantics. `MarketSymbol`, alert market enums, and snapshot `data_quality/limitations` are boundary-contract updates only.
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|--------|------|--------|:----:|
|
||||
@@ -386,6 +387,12 @@ For the notification baseline, diagnostics, and deployment notes, see [Notificat
|
||||
> - The official quickstart documents `quotes.get(universes=["CN_Equity_A"])`, but online smoke tests confirmed two additional real-world constraints: universe access depends on plan permissions, and `quotes.get(symbols=[...])` has a per-request symbol limit.
|
||||
> - TickFlow currently returns `change_pct` / `amplitude` / `turnover_rate` as ratio values; this integration normalizes them to the project's percent convention so they match AkShare / Tushare / efinance semantics.
|
||||
> - In scheduler mode, if runtime env explicitly sets `RUN_IMMEDIATELY` but does not set `SCHEDULE_RUN_IMMEDIATELY`, the scheduler keeps inheriting the legacy runtime override instead of being pulled back to a persisted `.env` alias value.
|
||||
|
||||
> Compatibility note (Issue #1815): `MARKET_REVIEW_REGION=cn|hk|us|jp|kr|both` only expands the market set used by market review; `jp`/`kr` are for recap scope and do not open JP/KR for Market Light alerts.
|
||||
> - Changes in `src/config.py`, `src/core/config_registry.py`, and `src/services/system_config_service.py` are configuration-contract updates only, and do not alter runtime provider/model/base URL routing semantics or trigger provider migration/cleanup logic.
|
||||
> - Affected config keys are `MARKET_REVIEW_REGION` and `MARKET_REVIEW_COLOR_SCHEME`; existing model/runtime keys (`LITELLM_MODEL`, `AGENT_LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, `VISION_MODEL`, `OPENAI_BASE_URL`, etc.) remain unchanged under the existing atomic upsert semantics and are not silently cleared when this scope is changed.
|
||||
> - Verifiable evidence summary: official provider / Base URL / model-name sources remain the [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md#official-references-for-provider-presets--base-urls--model-naming), and the locked runtime dependency window remains `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` in `requirements.txt`; this scope adds no migration script or cleanup branch, and save/import still writes only submitted keys. `tests/test_system_config_service.py::SystemConfigServiceTestCase::test_update_market_review_region_does_not_trigger_runtime_model_cleanup` covers saving `MARKET_REVIEW_REGION` without clearing or rewriting existing `LITELLM_CONFIG`, `LLM_CHANNELS`, `LLM_OPENAI_*`, `LITELLM_MODEL`, `AGENT_LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, `VISION_MODEL`, `OPENAI_*`, and related runtime settings.
|
||||
> - Rollback is a restore-and-recover path: apply pre-PR `.env` / config backup for the above keys, restore `MARKET_REVIEW_REGION`, and restart the runtime; or revert this PR directly.
|
||||
> - CN market review reports now use a post-market workstation layout with market signal, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. The market signal uses a plain-text score such as `66/100 (constructive, risk-on)` instead of block bars so it renders consistently across terminals and notification clients. News catalysts list only headline, source, and link instead of search snippets to reduce mixed-language noise. Missing data sources degrade by omitting or simplifying only the affected block.
|
||||
> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.
|
||||
|
||||
|
||||
@@ -117,3 +117,18 @@ PY
|
||||
- 不补齐 Portfolio 的 TWD 汇率、成本、市值完整口径(属上述后续 PR 范围)。
|
||||
|
||||
回滚方式:移除 `tw` 市场识别、交易日历注册、YFinance 路由扩展与服务层/API 市场枚举及前端市场类型放行,并删除本文档中的能力声明。
|
||||
|
||||
## 日本/韩国 Portfolio 与 Market Light 边界(Issue #1815 Phase 3)
|
||||
|
||||
Portfolio 允许 JP/KR 账户、交易和持仓快照进入现有链路,但会将账户/持仓快照标记为 `data_quality=partial`,并通过 `limitations` 明确 `realtime_quote_best_effort`、`fx_and_cost_basis_partial`、`sector_and_risk_metrics_limited`;不承诺 JPY/KRW 汇率、成本、市值、行业集中度或组合风险指标完整口径。
|
||||
|
||||
- JP/KR 账户、交易、现金流水和公司行动 API 保持可创建/查询;当前不新增 JPY/KRW 汇率源、税费模型、交易单位/最小变动价位校验或行业映射。
|
||||
- Market Light 快照和 Market Light 告警仍只支持 `cn` / `hk` / `us`。
|
||||
- Web 告警市场下拉不展示 `jp` / `kr`;后端 `normalize_market_region()` 对 `jp` / `kr` 返回显式 unsupported 错误。
|
||||
- Web 设置页中 `MARKET_REVIEW_REGION` 从固定枚举下拉收敛为自由文本输入,用于保存 `cn,us,jp`、`cn,hk,us` 等逗号分隔子集;该 UI 变化只影响大盘复盘配置,不影响 Market Light 告警市场枚举。
|
||||
- `MARKET_REVIEW_REGION` 既有 `cn`、`hk`、`us` 可原样保留;若用户希望维持 JP/KR 扩展前 `both` 对应的三市场复盘边界,应改为 `cn,hk,us`;只有希望纳入五市场复盘时才继续使用 `both` 或显式配置 `cn,hk,us,jp,kr`。
|
||||
- 该轮边界收敛不改动 LLM Provider / Model / Base URL 的持久化语义,也不执行默认模型、运行时配置清理或回写;配置更新仍是**原子 upsert**(`ConfigManager.apply_updates`),保存/导入只写入提交的键,未提交的 `LITELLM_MODEL`、`LITELLM_FALLBACK_MODELS`、`AGENT_LITELLM_MODEL`、`VISION_MODEL`、`OPENAI_BASE_URL` 等旧值保留不清空。
|
||||
- 可直接核验的配置兼容证据:本轮未新增或替换外部 provider/model/Base URL,仍沿用 LiteLLM OpenAI-compatible 路由(<https://docs.litellm.ai/docs/providers/openai_compatible>)、OpenAI Chat Completions 请求形状(<https://platform.openai.com/docs/api-reference/chat/create>),以及 [LLM 服务商配置指南](llm-providers.md#官方来源与兼容性) 中集中维护的各 provider 官方来源链接。当前运行时依赖窗口以 `requirements.txt` 的 `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` 为准;旧配置没有迁移脚本或清理分支,保存/导入仍只通过 `ConfigManager.apply_updates` 写入本次提交键。回退路径是恢复变更前 `.env`/配置备份中的 `MARKET_REVIEW_REGION`,或直接 revert 本 PR;未提交的 `LITELLM_CONFIG`、`LLM_CHANNELS`、`LLM_OPENAI_*`、`LITELLM_MODEL`、`AGENT_LITELLM_MODEL`、`LITELLM_FALLBACK_MODELS`、`VISION_MODEL`、`OPENAI_*` 等既有运行时配置不需要迁移。回归证据为 `tests/test_system_config_service.py::SystemConfigServiceTestCase::test_update_market_review_region_does_not_trigger_runtime_model_cleanup` 与 `tests/test_config_env_compat.py::test_market_review_region_updates_do_not_change_llm_provider_model_contract`。
|
||||
- Web UI 可视证据口径:Market Light 告警目标范围切到“大盘市场”时,市场区域下拉只显示 A 股、港股、美股,不显示日股/韩股;设置页 `MARKET_REVIEW_REGION` 渲染为可输入逗号分隔值的文本框。当前仓库不保存一次性截图证据,可替代证据为 `apps/dsa-web/src/components/alerts/__tests__/AlertRuleForm.test.tsx`、`apps/dsa-web/src/components/settings/__tests__/SettingsField.test.tsx` 和 `apps/dsa-web/tests/system_config_i18n.test.ts` 的断言。
|
||||
|
||||
回滚方式:移除 Portfolio snapshot 的 `data_quality` / `limitations` 扩展,恢复告警前端/后端对市场枚举的旧边界说明;如需整体回滚,移除 `jp/kr` 市场识别、交易日历注册、YFinance 路由扩展、Web/API 类型放行、`scripts/stock_index_seeds/` 日韩种子索引,并删除本文档中的能力声明。
|
||||
|
||||
14
main.py
14
main.py
@@ -582,6 +582,15 @@ def _can_reuse_market_context_for_review(summary: str, region: str) -> bool:
|
||||
return len(parts) <= 1
|
||||
|
||||
|
||||
def _resolve_daily_market_context_market(market: str, normalized_region: str) -> str:
|
||||
if "," not in normalized_region:
|
||||
return market
|
||||
parts = [item.strip() for item in normalized_region.split(",") if item.strip()]
|
||||
if parts and all(item in {"jp", "kr"} for item in parts):
|
||||
return parts[0]
|
||||
return market
|
||||
|
||||
|
||||
def _resolve_daily_market_context_target_date(
|
||||
region: str,
|
||||
current_time: datetime,
|
||||
@@ -591,7 +600,10 @@ def _resolve_daily_market_context_target_date(
|
||||
|
||||
from src.core.trading_calendar import get_effective_trading_date
|
||||
|
||||
return get_effective_trading_date(market, current_time=current_time)
|
||||
return get_effective_trading_date(
|
||||
_resolve_daily_market_context_market(market, normalized_region),
|
||||
current_time=current_time,
|
||||
)
|
||||
|
||||
|
||||
def _market_review_report_text(review_result: Any) -> str:
|
||||
|
||||
@@ -28,6 +28,7 @@ from src.services.run_diagnostics import (
|
||||
record_history_run,
|
||||
record_notification_run,
|
||||
)
|
||||
from src.schemas.market_light import MARKET_LIGHT_REGIONS
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -93,6 +94,19 @@ def _record_market_review_notification_run(
|
||||
_refresh_market_review_history_diagnostics(query_id=query_id)
|
||||
|
||||
|
||||
def _collect_market_light_snapshot(
|
||||
snapshots: Dict[str, Dict[str, Any]],
|
||||
*,
|
||||
region: str,
|
||||
review_result: Any,
|
||||
) -> None:
|
||||
if region not in MARKET_LIGHT_REGIONS:
|
||||
return
|
||||
snapshot = getattr(review_result, "market_light_snapshot", None)
|
||||
if isinstance(snapshot, dict) and snapshot:
|
||||
snapshots[region] = snapshot
|
||||
|
||||
|
||||
def _get_market_review_text(language: str) -> dict[str, str]:
|
||||
normalized = normalize_report_language(language)
|
||||
if normalized == "en":
|
||||
@@ -211,7 +225,11 @@ def run_market_review(
|
||||
)
|
||||
review_result = mkt_analyzer.run_daily_review_with_snapshot()
|
||||
mkt_report = review_result.report
|
||||
market_light_snapshots[mkt] = review_result.market_light_snapshot
|
||||
_collect_market_light_snapshot(
|
||||
market_light_snapshots,
|
||||
region=mkt,
|
||||
review_result=review_result,
|
||||
)
|
||||
market_review_payloads[mkt] = _coerce_market_review_payload(
|
||||
review_result,
|
||||
region=mkt,
|
||||
@@ -245,7 +263,12 @@ def run_market_review(
|
||||
)
|
||||
review_result = market_analyzer.run_daily_review_with_snapshot()
|
||||
review_report = review_result.report
|
||||
market_light_snapshots = {run_region: review_result.market_light_snapshot}
|
||||
market_light_snapshots = {}
|
||||
_collect_market_light_snapshot(
|
||||
market_light_snapshots,
|
||||
region=run_region,
|
||||
review_result=review_result,
|
||||
)
|
||||
market_review_payloads = {
|
||||
run_region: _coerce_market_review_payload(
|
||||
review_result,
|
||||
|
||||
@@ -30,7 +30,7 @@ from src.llm.backend_registry import (
|
||||
resolve_generation_fallback_backend_id,
|
||||
)
|
||||
from src.llm.generation_backend import GenerationError
|
||||
from src.schemas.market_light import MarketLightSnapshot
|
||||
from src.schemas.market_light import MARKET_LIGHT_REGIONS, MarketLightSnapshot
|
||||
from src.services.run_diagnostics import record_llm_run, record_llm_run_started
|
||||
from src.services.intelligence_service import IntelligenceService
|
||||
from data_provider.base import DataFetcherManager
|
||||
@@ -111,7 +111,7 @@ class MarketLightReviewResult:
|
||||
|
||||
overview: MarketOverview
|
||||
report: str
|
||||
market_light_snapshot: Dict[str, Any]
|
||||
market_light_snapshot: Optional[Dict[str, Any]]
|
||||
structured_payload: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -758,7 +758,11 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
|
||||
language = self._get_review_language()
|
||||
sections = self._split_report_sections(report)
|
||||
title = self._extract_report_title(report) or self._get_review_title(overview.date).lstrip("# ").strip()
|
||||
light = market_light_snapshot or self.build_market_light_snapshot(overview)
|
||||
light = (
|
||||
market_light_snapshot or self.build_market_light_snapshot(overview)
|
||||
if self._supports_market_light()
|
||||
else None
|
||||
)
|
||||
breadth_dimensions = None
|
||||
if isinstance(light, dict):
|
||||
dimensions = light.get("dimensions")
|
||||
@@ -787,7 +791,6 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"date": overview.date,
|
||||
"market_scope": self._get_market_scope_name(language),
|
||||
"market_light": light,
|
||||
"indices": [idx.to_dict() for idx in overview.indices],
|
||||
"sectors": {
|
||||
"top": list(overview.top_sectors or []),
|
||||
@@ -802,6 +805,9 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
|
||||
"markdown_report": report,
|
||||
}
|
||||
|
||||
if light is not None:
|
||||
payload["market_light"] = light
|
||||
|
||||
if has_breadth_data:
|
||||
payload["breadth"] = {
|
||||
"up_count": overview.up_count,
|
||||
@@ -815,6 +821,9 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
|
||||
|
||||
return payload
|
||||
|
||||
def _supports_market_light(self) -> bool:
|
||||
return self.region in MARKET_LIGHT_REGIONS
|
||||
|
||||
@staticmethod
|
||||
def _extract_report_title(report: str) -> str:
|
||||
for line in (report or "").splitlines():
|
||||
@@ -1290,6 +1299,84 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
|
||||
label = str(scores["temperature_label"])
|
||||
return score, label
|
||||
|
||||
def _build_output_template_sections(self, review_language: str) -> str:
|
||||
"""Build LLM output sections according to market data capabilities."""
|
||||
if review_language == "en":
|
||||
if self.profile.has_market_stats and self.profile.has_sector_rankings:
|
||||
return """### 3. Fund Flows
|
||||
(Interpret what turnover, participation, and flow signals imply.)
|
||||
|
||||
### 4. Sector Highlights
|
||||
(Distinguish industry-sector moves from concept/theme moves, then analyze drivers and persistence.)
|
||||
|
||||
### 5. Outlook
|
||||
(Provide the near-term outlook based on price action and news.)
|
||||
|
||||
### 6. Risk Alerts
|
||||
(List the main risks to monitor.)
|
||||
|
||||
### 7. Strategy Plan
|
||||
(Provide an offensive/balanced/defensive stance, a position-sizing guideline, one invalidation trigger, and end with "For reference only, not investment advice.")"""
|
||||
|
||||
section_number = 3
|
||||
sections: List[str] = []
|
||||
if self.profile.has_market_stats:
|
||||
sections.append(f"""### {section_number}. Fund Flows
|
||||
(Interpret only the provided turnover, participation, breadth, and flow signals.)""")
|
||||
section_number += 1
|
||||
if self.profile.has_sector_rankings:
|
||||
sections.append(f"""### {section_number}. Sector Highlights
|
||||
(Analyze only the provided industry-sector and concept/theme rankings.)""")
|
||||
section_number += 1
|
||||
sections.extend([
|
||||
f"""### {section_number}. News Catalysts
|
||||
(Connect recent news to index price action and macro/external-market clues. Do not infer unsupported breadth, fund-flow, or sector-ranking data.)""",
|
||||
f"""### {section_number + 1}. Outlook
|
||||
(Provide the near-term outlook based on index price action and the available news.)""",
|
||||
f"""### {section_number + 2}. Risk Alerts
|
||||
(List the main risks to monitor.)""",
|
||||
f"""### {section_number + 3}. Strategy Plan
|
||||
(Provide an offensive/balanced/defensive stance, a position-sizing guideline, one invalidation trigger, and end with "For reference only, not investment advice.")""",
|
||||
])
|
||||
return "\n\n".join(sections)
|
||||
|
||||
if self.profile.has_market_stats and self.profile.has_sector_rankings:
|
||||
return """### 三、板块主线
|
||||
(区分行业板块与概念题材,分析领涨/领跌背后的逻辑、持续性和是否形成主线)
|
||||
|
||||
### 四、资金与情绪
|
||||
(解读成交额、涨跌停结构、市场宽度和风险偏好)
|
||||
|
||||
### 五、消息催化
|
||||
(结合近三日新闻,提炼真正影响明日交易的催化或扰动)
|
||||
|
||||
### 六、明日交易计划
|
||||
(给出进攻/均衡/防守结论、仓位区间、关注方向、回避方向和一个触发失效条件)
|
||||
|
||||
### 七、风险提示
|
||||
(列出需要关注的风险点;最后补充“建议仅供参考,不构成投资建议”。)"""
|
||||
|
||||
numerals = ["一", "二", "三", "四", "五", "六", "七", "八"]
|
||||
section_number = 3
|
||||
sections: List[str] = []
|
||||
|
||||
def add_section(title: str, hint: str) -> None:
|
||||
nonlocal section_number
|
||||
sections.append(f"### {numerals[section_number - 1]}、{title}\n{hint}")
|
||||
section_number += 1
|
||||
|
||||
if self.profile.has_sector_rankings:
|
||||
add_section("板块主线", "(仅分析已提供的行业板块与概念题材榜单,不扩展未提供的数据)")
|
||||
if self.profile.has_market_stats:
|
||||
add_section("资金与情绪", "(仅解读已提供的成交额、涨跌停结构、市场宽度和风险偏好数据)")
|
||||
add_section(
|
||||
"消息催化",
|
||||
"(结合近三日新闻和指数表现,提炼真正影响明日交易的催化或扰动;不要推断未提供的资金流、市场宽度或板块榜)",
|
||||
)
|
||||
add_section("明日交易计划", "(给出进攻/均衡/防守结论、仓位区间、关注方向、回避方向和一个触发失效条件)")
|
||||
add_section("风险提示", "(列出需要关注的风险点;最后补充“建议仅供参考,不构成投资建议”。)")
|
||||
return "\n\n".join(sections)
|
||||
|
||||
def _build_review_prompt(self, overview: MarketOverview, news: List) -> str:
|
||||
"""构建复盘报告 Prompt"""
|
||||
review_language = self._get_review_language()
|
||||
@@ -1320,17 +1407,16 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
|
||||
url_line = f"\n URL: {url}" if url else ""
|
||||
news_text += f"{i}. {title}{meta}\n {snippet or '-'}{url_line}\n"
|
||||
|
||||
# 按 region 组装市场概况与板块区块(美股无涨跌家数、板块数据)
|
||||
# 按 region 组装市场概况与板块区块(美股/港股/日韩无涨跌家数、板块数据)
|
||||
stats_block = ""
|
||||
sector_block = ""
|
||||
data_limits_block = ""
|
||||
if review_language == "en":
|
||||
if self.profile.has_market_stats:
|
||||
stats_block = f"""## Market Breadth
|
||||
- Advancers: {overview.up_count} | Decliners: {overview.down_count} | Flat: {overview.flat_count}
|
||||
- Limit-up: {overview.limit_up_count} | Limit-down: {overview.limit_down_count}
|
||||
- Turnover: {overview.total_amount:.0f} ({self._get_turnover_unit_label()})"""
|
||||
else:
|
||||
stats_block = "## Market Breadth\n(No equivalent advance/decline statistics are available for this market.)"
|
||||
|
||||
if self.profile.has_sector_rankings:
|
||||
sector_block = f"""## Sector / Theme Performance
|
||||
@@ -1338,16 +1424,22 @@ Industry leading: {top_sectors_text if top_sectors_text else "N/A"}
|
||||
Industry lagging: {bottom_sectors_text if bottom_sectors_text else "N/A"}
|
||||
Concept leading: {top_concepts_text if top_concepts_text else "N/A"}
|
||||
Concept lagging: {bottom_concepts_text if bottom_concepts_text else "N/A"}"""
|
||||
else:
|
||||
sector_block = "## Sector / Theme Performance\n(Sector/theme data not available for this market.)"
|
||||
|
||||
data_limit_lines = []
|
||||
if not self.profile.has_market_stats:
|
||||
data_limit_lines.append(
|
||||
"- Market breadth, aggregate turnover, participation, and fund-flow signals are not available for this market."
|
||||
)
|
||||
if not self.profile.has_sector_rankings:
|
||||
data_limit_lines.append("- Sector/theme ranking data is not available for this market.")
|
||||
if data_limit_lines:
|
||||
data_limits_block = "## Data Limits\n" + "\n".join(data_limit_lines)
|
||||
else:
|
||||
if self.profile.has_market_stats:
|
||||
stats_block = f"""## 市场概况
|
||||
- 上涨: {overview.up_count} 家 | 下跌: {overview.down_count} 家 | 平盘: {overview.flat_count} 家
|
||||
- 涨停: {overview.limit_up_count} 家 | 跌停: {overview.limit_down_count} 家
|
||||
- 两市成交额: {overview.total_amount:.0f} 亿元"""
|
||||
else:
|
||||
stats_block = "## 市场概况\n(该市场暂无涨跌家数等统计)"
|
||||
|
||||
if self.profile.has_sector_rankings:
|
||||
sector_block = f"""## 板块表现
|
||||
@@ -1355,8 +1447,14 @@ Concept lagging: {bottom_concepts_text if bottom_concepts_text else "N/A"}"""
|
||||
行业领跌: {bottom_sectors_text if bottom_sectors_text else "暂无数据"}
|
||||
概念领涨: {top_concepts_text if top_concepts_text else "暂无数据"}
|
||||
概念领跌: {bottom_concepts_text if bottom_concepts_text else "暂无数据"}"""
|
||||
else:
|
||||
sector_block = "## 板块表现\n(该市场暂无板块涨跌数据)"
|
||||
|
||||
data_limit_lines = []
|
||||
if not self.profile.has_market_stats:
|
||||
data_limit_lines.append("- 该市场暂无涨跌家数、涨跌停、成交额汇总、参与度或资金流信号。")
|
||||
if not self.profile.has_sector_rankings:
|
||||
data_limit_lines.append("- 该市场暂无行业板块/概念题材涨跌榜。")
|
||||
if data_limit_lines:
|
||||
data_limits_block = "## 数据边界\n" + "\n".join(data_limit_lines)
|
||||
|
||||
data_no_indices_hint = (
|
||||
"注意:由于行情数据获取失败,请主要根据【市场新闻】进行定性分析和总结,不要编造具体的指数点位。"
|
||||
@@ -1371,9 +1469,40 @@ Concept lagging: {bottom_concepts_text if bottom_concepts_text else "N/A"}"""
|
||||
)
|
||||
indices_placeholder = indices_text if indices_text else "No index data (API error)"
|
||||
news_placeholder = news_text if news_text else "No relevant news"
|
||||
data_boundary_requirement = (
|
||||
"- Respect Data Limits: do not invent or over-interpret unsupported breadth, fund-flow, turnover, participation, or sector-ranking data.\n"
|
||||
if data_limits_block
|
||||
else ""
|
||||
)
|
||||
market_summary_hint = (
|
||||
"2-3 sentences summarizing overall market tone, index moves, and liquidity."
|
||||
if self.profile.has_market_stats
|
||||
else "2-3 sentences summarizing overall market tone, index moves, and available news context."
|
||||
)
|
||||
else:
|
||||
indices_placeholder = indices_text if indices_text else "暂无指数数据(接口异常)"
|
||||
news_placeholder = news_text if news_text else "暂无相关新闻"
|
||||
data_boundary_requirement = (
|
||||
"- 严格遵守数据边界:未提供涨跌家数、资金流、成交额汇总或板块榜时,不要编造或过度解读。\n"
|
||||
if data_limits_block
|
||||
else ""
|
||||
)
|
||||
market_summary_hint = (
|
||||
"2-3句话概括指数、涨跌家数、成交额和情绪温度,明确“强势/偏暖/震荡/偏弱”判断"
|
||||
if self.profile.has_market_stats
|
||||
else "2-3句话概括指数表现、新闻线索和整体风险状态,不要补写未提供的市场宽度或资金流数据"
|
||||
)
|
||||
|
||||
output_template_sections = self._build_output_template_sections(review_language)
|
||||
zh_market_scope_name = self._get_market_scope_name("zh")
|
||||
zh_report_title = f"{overview.date} 大盘复盘"
|
||||
if self.region in ("jp", "kr"):
|
||||
zh_report_title = f"{overview.date} {zh_market_scope_name}大盘复盘"
|
||||
workflow_hint = (
|
||||
"报告要像交易员盘后工作台:先给结论,再按数据表、主线、催化、计划展开"
|
||||
if self.profile.has_market_stats or self.profile.has_sector_rankings
|
||||
else "报告要像交易员盘后工作台:先给结论,再按指数、新闻催化和计划展开"
|
||||
)
|
||||
|
||||
if review_language == "en":
|
||||
report_title = self._get_review_title(overview.date).removeprefix("## ").strip()
|
||||
@@ -1385,6 +1514,7 @@ Concept lagging: {bottom_concepts_text if bottom_concepts_text else "N/A"}"""
|
||||
- No code blocks
|
||||
- Use emoji sparingly in headings (at most one per heading)
|
||||
- The entire fixed shell, headings, guidance, and conclusion must be in English
|
||||
{data_boundary_requirement}
|
||||
|
||||
---
|
||||
|
||||
@@ -1400,6 +1530,8 @@ Concept lagging: {bottom_concepts_text if bottom_concepts_text else "N/A"}"""
|
||||
|
||||
{sector_block}
|
||||
|
||||
{data_limits_block}
|
||||
|
||||
## Market News
|
||||
{news_placeholder}
|
||||
|
||||
@@ -1414,25 +1546,12 @@ Concept lagging: {bottom_concepts_text if bottom_concepts_text else "N/A"}"""
|
||||
## {report_title}
|
||||
|
||||
### 1. Market Summary
|
||||
(2-3 sentences summarizing overall market tone, index moves, and liquidity.)
|
||||
({market_summary_hint})
|
||||
|
||||
### 2. Index Commentary
|
||||
({self._get_index_hint()})
|
||||
|
||||
### 3. Fund Flows
|
||||
(Interpret what turnover, participation, and flow signals imply.)
|
||||
|
||||
### 4. Sector Highlights
|
||||
(Distinguish industry-sector moves from concept/theme moves, then analyze drivers and persistence.)
|
||||
|
||||
### 5. Outlook
|
||||
(Provide the near-term outlook based on price action and news.)
|
||||
|
||||
### 6. Risk Alerts
|
||||
(List the main risks to monitor.)
|
||||
|
||||
### 7. Strategy Plan
|
||||
(Provide an offensive/balanced/defensive stance, a position-sizing guideline, one invalidation trigger, and end with “For reference only, not investment advice.”)
|
||||
{output_template_sections}
|
||||
|
||||
---
|
||||
|
||||
@@ -1447,8 +1566,9 @@ Output the report content directly, no extra commentary.
|
||||
- 禁止输出 JSON 格式
|
||||
- 禁止输出代码块
|
||||
- emoji 仅在标题处少量使用(每个标题最多1个)
|
||||
- 报告要像交易员盘后工作台:先给结论,再按数据表、主线、催化、计划展开
|
||||
- {workflow_hint}
|
||||
- 不要重复列出已由系统注入的表格数据;正文负责解释表格背后的含义
|
||||
{data_boundary_requirement}
|
||||
|
||||
---
|
||||
|
||||
@@ -1464,6 +1584,8 @@ Output the report content directly, no extra commentary.
|
||||
|
||||
{sector_block}
|
||||
|
||||
{data_limits_block}
|
||||
|
||||
## 市场新闻
|
||||
{news_placeholder}
|
||||
|
||||
@@ -1475,30 +1597,17 @@ Output the report content directly, no extra commentary.
|
||||
|
||||
# 输出格式模板(请严格按此格式输出)
|
||||
|
||||
## {overview.date} 大盘复盘
|
||||
## {zh_report_title}
|
||||
|
||||
> 一句话给出今日市场状态、核心矛盾和明日优先观察方向。
|
||||
|
||||
### 一、盘面总览
|
||||
(2-3句话概括指数、涨跌家数、成交额和情绪温度,明确“强势/偏暖/震荡/偏弱”判断)
|
||||
({market_summary_hint})
|
||||
|
||||
### 二、指数结构
|
||||
({self._get_index_hint()},说明谁在护盘、谁在拖累,以及关键支撑/压力)
|
||||
|
||||
### 三、板块主线
|
||||
(区分行业板块与概念题材,分析领涨/领跌背后的逻辑、持续性和是否形成主线)
|
||||
|
||||
### 四、资金与情绪
|
||||
(解读成交额、涨跌停结构、市场宽度和风险偏好)
|
||||
|
||||
### 五、消息催化
|
||||
(结合近三日新闻,提炼真正影响明日交易的催化或扰动)
|
||||
|
||||
### 六、明日交易计划
|
||||
(给出进攻/均衡/防守结论、仓位区间、关注方向、回避方向和一个触发失效条件)
|
||||
|
||||
### 七、风险提示
|
||||
(列出需要关注的风险点;最后补充“建议仅供参考,不构成投资建议”。)
|
||||
{output_template_sections}
|
||||
|
||||
---
|
||||
|
||||
@@ -1595,24 +1704,50 @@ Market conditions can change quickly. The data above is for reference only and d
|
||||
|
||||
market_labels = {"cn": "A股", "us": "美股", "hk": "港股", "jp": "日股", "kr": "韩股"}
|
||||
market_label = market_labels.get(self.region, "A股")
|
||||
dashboard_block = self._build_stats_block(overview)
|
||||
dashboard_block = self._build_stats_block(overview) if self.profile.has_market_stats else ""
|
||||
indices_block = self._build_indices_block(overview)
|
||||
sector_block = self._build_sector_block(overview)
|
||||
sector_block = self._build_sector_block(overview) if self.profile.has_sector_rankings else ""
|
||||
summary_focus = (
|
||||
"指数承接、成交额变化和板块持续性"
|
||||
if self.profile.has_market_stats and self.profile.has_sector_rankings
|
||||
else "指数承接、消息催化和整体风险状态"
|
||||
)
|
||||
market_summary_block = (
|
||||
dashboard_block
|
||||
if dashboard_block
|
||||
else (
|
||||
"暂无市场宽度数据。"
|
||||
if self.profile.has_market_stats
|
||||
else "- 当前以主要指数与可用新闻线索评估整体风险状态。"
|
||||
)
|
||||
)
|
||||
sector_section = (
|
||||
f"""
|
||||
### 三、板块主线
|
||||
{sector_block or "- 暂无板块涨跌榜数据。"}
|
||||
"""
|
||||
if self.profile.has_sector_rankings
|
||||
else ""
|
||||
)
|
||||
funds_section = (
|
||||
"""
|
||||
### 四、资金与情绪
|
||||
- 结合成交额和涨跌家数看,当前更适合等待确认,避免仅凭单一热点追高。
|
||||
"""
|
||||
if self.profile.has_market_stats
|
||||
else ""
|
||||
)
|
||||
return f"""## {overview.date} 大盘复盘
|
||||
|
||||
> 今日{market_label}市场整体呈现**{market_mood}**态势,优先观察指数承接、成交额变化和板块持续性。
|
||||
> 今日{market_label}市场整体呈现**{market_mood}**态势,优先观察{summary_focus}。
|
||||
|
||||
### 一、盘面总览
|
||||
{dashboard_block or "暂无市场宽度数据。"}
|
||||
{market_summary_block}
|
||||
|
||||
### 二、指数结构
|
||||
{indices_block or indices_text or "暂无指数数据。"}
|
||||
|
||||
### 三、板块主线
|
||||
{sector_block or "- 暂无板块涨跌榜数据。"}
|
||||
|
||||
### 四、资金与情绪
|
||||
- 结合成交额和涨跌家数看,当前更适合等待确认,避免仅凭单一热点追高。
|
||||
{sector_section}
|
||||
{funds_section}
|
||||
|
||||
### 五、消息催化
|
||||
- 暂无可用新闻时,应降低对题材持续性的确定性判断。
|
||||
@@ -1639,7 +1774,7 @@ Market conditions can change quickly. The data above is for reference only and d
|
||||
|
||||
# 3. 生成复盘报告
|
||||
report = self.generate_market_review(overview, news)
|
||||
snapshot = self.build_market_light_snapshot(overview)
|
||||
snapshot = self.build_market_light_snapshot(overview) if self._supports_market_light() else None
|
||||
structured_payload = self.build_market_review_payload(
|
||||
overview,
|
||||
news,
|
||||
|
||||
@@ -11,6 +11,7 @@ from pydantic import BaseModel, Field
|
||||
MarketRegion = Literal["cn", "hk", "us", "jp", "kr"]
|
||||
MarketLightStatus = Literal["green", "yellow", "red"]
|
||||
MarketLightDataQuality = Literal["ok", "partial", "unavailable"]
|
||||
MARKET_LIGHT_REGIONS = frozenset(("cn", "hk", "us"))
|
||||
|
||||
|
||||
class MarketLightDimension(BaseModel):
|
||||
|
||||
@@ -33,6 +33,7 @@ MARKET_REVIEW_REPORT_TYPE = "market_review"
|
||||
_REGION_LABEL_ZH = {"cn": "A股", "hk": "港股", "us": "美股", "jp": "日股", "kr": "韩股"}
|
||||
_REGION_LABEL_EN = {"cn": "A-share", "hk": "HK", "us": "US", "jp": "Japan", "kr": "Korea"}
|
||||
_VALID_REGIONS = frozenset(_REGION_LABEL_ZH)
|
||||
_LEGACY_BOTH_REGIONS = frozenset({"cn", "hk", "us"})
|
||||
_UNTRUSTED_MARKET_SUMMARY_SENTINELS = (
|
||||
"BEGIN_UNTRUSTED_MARKET_SUMMARY",
|
||||
"END_UNTRUSTED_MARKET_SUMMARY",
|
||||
@@ -114,7 +115,13 @@ class DailyMarketContextService:
|
||||
current_query_id: Optional[str] = None,
|
||||
require_query_id_match: bool = False,
|
||||
) -> Optional[DailyMarketContext]:
|
||||
normalized_region = _normalize_region(region)
|
||||
normalized_region = _normalize_context_region(region)
|
||||
if normalized_region is None:
|
||||
logger.info(
|
||||
"跳过多市场或不支持区域的大盘上下文复用: region=%s",
|
||||
region,
|
||||
)
|
||||
return None
|
||||
context_date = target_date or self._today_fn()
|
||||
report_language = normalize_report_language(getattr(config, "report_language", "zh"))
|
||||
cache_key = self._cache_key(
|
||||
@@ -706,6 +713,13 @@ def _normalize_region(region: str) -> str:
|
||||
return normalized if normalized in _VALID_REGIONS else "cn"
|
||||
|
||||
|
||||
def _normalize_context_region(region: str) -> Optional[str]:
|
||||
normalized = str(region or "cn").strip().lower()
|
||||
if normalized in _VALID_REGIONS:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
|
||||
def _loads_mapping(value: Any) -> Dict[str, Any]:
|
||||
if isinstance(value, Mapping):
|
||||
return dict(value)
|
||||
@@ -846,7 +860,7 @@ def _region_matches(value: Any, region: str) -> bool:
|
||||
return False
|
||||
text = str(value).strip().lower()
|
||||
if text == "both":
|
||||
return True
|
||||
return region in _LEGACY_BOTH_REGIONS
|
||||
parts = {item.strip() for item in text.split(",") if item.strip()}
|
||||
return region in parts
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ MARKET_LIGHT_HISTORY_BATCH_SIZE = 100
|
||||
|
||||
def normalize_market_region(region: str) -> str:
|
||||
value = str(region or "").strip().lower()
|
||||
if value in {"jp", "kr"}:
|
||||
raise ValueError(
|
||||
f"market light currently supports cn, hk, us only; unsupported market: {region}"
|
||||
)
|
||||
if value not in MARKET_LIGHT_REGIONS:
|
||||
raise ValueError(f"market target must be one of cn, hk, us, jp, kr: {region}")
|
||||
return value
|
||||
|
||||
@@ -31,6 +31,7 @@ except Exception: # pragma: no cover - optional dependency path
|
||||
|
||||
EPS = 1e-8
|
||||
VALID_MARKETS = {"cn", "hk", "us", "jp", "kr", "tw"}
|
||||
PARTIAL_VALUATION_MARKETS = {"jp", "kr", "tw"}
|
||||
VALID_COST_METHODS = {"fifo", "avg"}
|
||||
VALID_SIDES = {"buy", "sell"}
|
||||
VALID_CASH_DIRECTIONS = {"in", "out"}
|
||||
@@ -39,6 +40,29 @@ PORTFOLIO_FX_REFRESH_DISABLED_REASON = "portfolio_fx_update_disabled"
|
||||
PORTFOLIO_REALTIME_QUOTE_MAX_WORKERS = 4
|
||||
|
||||
|
||||
def _portfolio_limitations_for_market(market: str) -> List[str]:
|
||||
"""Return explicit snapshot limitations for markets with partial valuation semantics."""
|
||||
|
||||
if market not in PARTIAL_VALUATION_MARKETS:
|
||||
return []
|
||||
return [
|
||||
"realtime_quote_best_effort",
|
||||
"fx_and_cost_basis_partial",
|
||||
"sector_and_risk_metrics_limited",
|
||||
]
|
||||
|
||||
|
||||
def _merge_portfolio_limitations(*groups: Iterable[str]) -> List[str]:
|
||||
merged: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
for group in groups:
|
||||
for item in group:
|
||||
if item and item not in seen:
|
||||
seen.add(item)
|
||||
merged.append(item)
|
||||
return merged
|
||||
|
||||
|
||||
class PortfolioConflictError(Exception):
|
||||
"""Raised when request conflicts with existing portfolio state."""
|
||||
|
||||
@@ -471,6 +495,7 @@ class PortfolioService:
|
||||
"fee_total": 0.0,
|
||||
"tax_total": 0.0,
|
||||
"fx_stale": False,
|
||||
"limitations": [],
|
||||
}
|
||||
|
||||
for account in account_rows:
|
||||
@@ -496,6 +521,10 @@ class PortfolioService:
|
||||
)
|
||||
|
||||
accounts_payload.append(account_snapshot["public"])
|
||||
aggregate["limitations"] = _merge_portfolio_limitations(
|
||||
aggregate["limitations"],
|
||||
account_snapshot["public"].get("limitations", []),
|
||||
)
|
||||
|
||||
cash_cny, stale_cash, _ = self._convert_amount(
|
||||
amount=account_snapshot["total_cash"],
|
||||
@@ -572,6 +601,8 @@ class PortfolioService:
|
||||
"fee_total": round(aggregate["fee_total"], 6),
|
||||
"tax_total": round(aggregate["tax_total"], 6),
|
||||
"fx_stale": aggregate["fx_stale"],
|
||||
"data_quality": "partial" if aggregate["limitations"] else "ok",
|
||||
"limitations": aggregate["limitations"],
|
||||
"accounts": accounts_payload,
|
||||
}
|
||||
|
||||
@@ -912,6 +943,15 @@ class PortfolioService:
|
||||
|
||||
unrealized_pnl_base = market_value_base - total_cost_base
|
||||
total_equity_base = total_cash_base + market_value_base
|
||||
position_limitations = [
|
||||
limitation
|
||||
for position in position_rows
|
||||
for limitation in position.get("limitations", [])
|
||||
]
|
||||
limitations = _merge_portfolio_limitations(
|
||||
_portfolio_limitations_for_market(account.market),
|
||||
position_limitations,
|
||||
)
|
||||
|
||||
account_payload = {
|
||||
"account_id": account.id,
|
||||
@@ -930,6 +970,8 @@ class PortfolioService:
|
||||
"fee_total": round(fees_total_base, 6),
|
||||
"tax_total": round(taxes_total_base, 6),
|
||||
"fx_stale": fx_stale,
|
||||
"data_quality": "partial" if limitations else "ok",
|
||||
"limitations": limitations,
|
||||
"positions": position_rows,
|
||||
}
|
||||
|
||||
@@ -1025,6 +1067,7 @@ class PortfolioService:
|
||||
realtime_prices=realtime_prices,
|
||||
)
|
||||
last_price = price_info.price
|
||||
limitations = _portfolio_limitations_for_market(market)
|
||||
|
||||
if price_info.is_available:
|
||||
local_market_value = qty * float(last_price)
|
||||
@@ -1069,6 +1112,8 @@ class PortfolioService:
|
||||
"price_date": price_info.price_date.isoformat() if price_info.price_date else None,
|
||||
"price_stale": price_info.is_stale,
|
||||
"price_available": price_info.is_available,
|
||||
"data_quality": "partial" if limitations else "ok",
|
||||
"limitations": limitations,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -313,6 +313,38 @@ class ConfigEnvCompatibilityTestCase(unittest.TestCase):
|
||||
self.assertEqual(with_news_intel.news_intel_retention_days, 45)
|
||||
self.assertEqual(with_news_intel.newsnow_base_url, "https://newsnow.example.com")
|
||||
|
||||
@patch("src.config.setup_env")
|
||||
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
|
||||
def test_market_review_region_updates_do_not_change_llm_provider_model_contract(
|
||||
self,
|
||||
_mock_parse_litellm_yaml,
|
||||
_mock_setup_env,
|
||||
) -> None:
|
||||
base_env = {
|
||||
"STOCK_LIST": "600519",
|
||||
"MARKET_REVIEW_REGION": "cn",
|
||||
"LITELLM_MODEL": "openai/gpt-4.1",
|
||||
"OPENAI_MODEL": "gpt-4.1",
|
||||
"OPENAI_BASE_URL": "https://openai.example.com/v1",
|
||||
"OPENAI_API_KEY": "base-key-12345",
|
||||
"LITELLM_FALLBACK_MODELS": "openai/gpt-5.5,openai/gpt-4o-mini",
|
||||
"VISION_MODEL": "openai/gpt-4o-mini",
|
||||
}
|
||||
with patch.dict(os.environ, base_env, clear=True):
|
||||
baseline = Config._load_from_env()
|
||||
|
||||
with_jpkr_env = dict(base_env)
|
||||
with_jpkr_env["MARKET_REVIEW_REGION"] = "both"
|
||||
with patch.dict(os.environ, with_jpkr_env, clear=True):
|
||||
with_jpkr = Config._load_from_env()
|
||||
|
||||
self.assertEqual(with_jpkr.litellm_model, baseline.litellm_model)
|
||||
self.assertEqual(with_jpkr.litellm_fallback_models, baseline.litellm_fallback_models)
|
||||
self.assertEqual(with_jpkr.vision_model, baseline.vision_model)
|
||||
self.assertEqual(with_jpkr.openai_model, baseline.openai_model)
|
||||
self.assertEqual(with_jpkr.openai_api_key, baseline.openai_api_key)
|
||||
self.assertEqual(with_jpkr.openai_base_url, baseline.openai_base_url)
|
||||
|
||||
def test_env_example_alphasift_install_spec_matches_trusted_default(self):
|
||||
env_example = Path(__file__).resolve().parents[1] / ".env.example"
|
||||
|
||||
@@ -838,6 +870,22 @@ class ConfigEnvCompatibilityTestCase(unittest.TestCase):
|
||||
|
||||
self.assertEqual(parsed, "zh")
|
||||
|
||||
def test_parse_market_review_region_accepts_jp_kr_values_and_comma_lists(self) -> None:
|
||||
self.assertEqual(Config._parse_market_review_region("jp"), "jp")
|
||||
self.assertEqual(Config._parse_market_review_region("KR"), "kr")
|
||||
self.assertEqual(
|
||||
Config._parse_market_review_region("kr,jp,us"),
|
||||
"us,jp,kr",
|
||||
)
|
||||
self.assertEqual(
|
||||
Config._parse_market_review_region("cn,eu,us"),
|
||||
"cn,us",
|
||||
)
|
||||
self.assertEqual(
|
||||
Config._parse_market_review_region("both"),
|
||||
"cn,hk,us,jp,kr",
|
||||
)
|
||||
|
||||
@patch("src.config.setup_env")
|
||||
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
|
||||
def test_invalid_numeric_env_values_fall_back_to_defaults(
|
||||
|
||||
@@ -136,6 +136,92 @@ def test_reuses_same_day_market_review_history_without_running_review() -> None:
|
||||
run_review.assert_not_called()
|
||||
|
||||
|
||||
def test_reuses_jp_market_review_history_without_normalizing_to_cn() -> None:
|
||||
db = MagicMock()
|
||||
db.get_analysis_history.return_value = [
|
||||
_history_record(
|
||||
created_at=datetime(2026, 6, 6, 9, 30),
|
||||
region="jp",
|
||||
summary="日股退潮,高风险,建议观望,仓位上限30%。",
|
||||
)
|
||||
]
|
||||
service = DailyMarketContextService(
|
||||
db_manager=db,
|
||||
today_fn=lambda: date(2026, 6, 6),
|
||||
)
|
||||
|
||||
with patch("src.services.daily_market_context.run_market_review") as run_review:
|
||||
context = service.get_context(
|
||||
region="jp",
|
||||
config=SimpleNamespace(report_language="zh"),
|
||||
notifier=MagicMock(),
|
||||
analyzer=MagicMock(),
|
||||
search_service=MagicMock(),
|
||||
allow_generate=False,
|
||||
)
|
||||
|
||||
assert context is not None
|
||||
assert context.region == "jp"
|
||||
assert context.summary.startswith("日股退潮")
|
||||
assert "high_risk" in context.risk_tags
|
||||
assert "low_position_cap" in context.risk_tags
|
||||
run_review.assert_not_called()
|
||||
|
||||
|
||||
def test_jp_kr_request_does_not_reuse_legacy_both_history() -> None:
|
||||
db = MagicMock()
|
||||
db.get_analysis_history.return_value = [
|
||||
_history_record(
|
||||
created_at=datetime(2026, 6, 6, 9, 30),
|
||||
region="both",
|
||||
summary="旧三市场复盘,高风险,建议观望,仓位上限30%。",
|
||||
)
|
||||
]
|
||||
service = DailyMarketContextService(
|
||||
db_manager=db,
|
||||
today_fn=lambda: date(2026, 6, 6),
|
||||
)
|
||||
|
||||
for region in ("jp", "kr"):
|
||||
with patch("src.services.daily_market_context.run_market_review") as run_review:
|
||||
context = service.get_context(
|
||||
region=region,
|
||||
config=SimpleNamespace(report_language="zh"),
|
||||
notifier=MagicMock(),
|
||||
analyzer=MagicMock(),
|
||||
search_service=MagicMock(),
|
||||
allow_generate=False,
|
||||
)
|
||||
|
||||
assert context is None
|
||||
run_review.assert_not_called()
|
||||
|
||||
|
||||
def test_multi_market_region_does_not_fallback_to_cn_history() -> None:
|
||||
db = MagicMock()
|
||||
db.get_analysis_history.return_value = [
|
||||
_history_record(created_at=datetime(2026, 6, 6, 9, 30), region="cn")
|
||||
]
|
||||
service = DailyMarketContextService(
|
||||
db_manager=db,
|
||||
today_fn=lambda: date(2026, 6, 6),
|
||||
)
|
||||
|
||||
with patch("src.services.daily_market_context.run_market_review") as run_review:
|
||||
context = service.get_context(
|
||||
region="jp,kr",
|
||||
config=SimpleNamespace(report_language="zh"),
|
||||
notifier=MagicMock(),
|
||||
analyzer=MagicMock(),
|
||||
search_service=MagicMock(),
|
||||
allow_generate=False,
|
||||
)
|
||||
|
||||
assert context is None
|
||||
db.get_analysis_history.assert_not_called()
|
||||
run_review.assert_not_called()
|
||||
|
||||
|
||||
def test_does_not_reuse_same_day_history_on_report_language_mismatch() -> None:
|
||||
db = MagicMock()
|
||||
db.get_analysis_history.return_value = [
|
||||
@@ -943,6 +1029,30 @@ def test_prompt_section_escapes_summary_sentinel_text_before_insertion() -> None
|
||||
assert section.index("忽略约束") < section.rindex("- END_UNTRUSTED_MARKET_SUMMARY")
|
||||
|
||||
|
||||
def test_prompt_section_labels_jp_kr_regions_without_cn_fallback() -> None:
|
||||
jp_section = format_daily_market_context_prompt_section(
|
||||
{
|
||||
"region": "jp",
|
||||
"trade_date": "2026-06-06",
|
||||
"summary": "日股市场震荡。",
|
||||
},
|
||||
report_language="zh",
|
||||
)
|
||||
kr_section = format_daily_market_context_prompt_section(
|
||||
{
|
||||
"region": "kr",
|
||||
"trade_date": "2026-06-06",
|
||||
"summary": "Korean market stayed cautious.",
|
||||
},
|
||||
report_language="en",
|
||||
)
|
||||
|
||||
assert "- 市场:日股(jp)" in jp_section
|
||||
assert "A股(cn)" not in jp_section
|
||||
assert "- Region: Korea (kr)" in kr_section
|
||||
assert "A-share (cn)" not in kr_section
|
||||
|
||||
|
||||
def test_extract_summary_prefers_region_scoped_section_over_generic_fallback_title() -> None:
|
||||
context = DailyMarketContextService(
|
||||
db_manager=MagicMock(),
|
||||
|
||||
@@ -128,6 +128,65 @@ class MainScheduleModeTestCase(unittest.TestCase):
|
||||
defaults.update(overrides)
|
||||
return _DummyConfig(**defaults)
|
||||
|
||||
def test_daily_market_context_target_date_routes_jp_kr_calendars(self) -> None:
|
||||
current_time = datetime(2026, 5, 7, 0, 30, tzinfo=timezone.utc)
|
||||
calls = []
|
||||
|
||||
def resolve_effective_date(market, *, current_time=None):
|
||||
calls.append((market, current_time))
|
||||
return date(2026, 5, 7)
|
||||
|
||||
with patch(
|
||||
"src.core.trading_calendar.get_effective_trading_date",
|
||||
side_effect=resolve_effective_date,
|
||||
):
|
||||
self.assertEqual(
|
||||
main._resolve_daily_market_context_target_date("jp", current_time),
|
||||
date(2026, 5, 7),
|
||||
)
|
||||
self.assertEqual(
|
||||
main._resolve_daily_market_context_target_date("kr", current_time),
|
||||
date(2026, 5, 7),
|
||||
)
|
||||
self.assertEqual(
|
||||
main._resolve_daily_market_context_target_date("jp,kr", current_time),
|
||||
date(2026, 5, 7),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[
|
||||
("jp", current_time),
|
||||
("kr", current_time),
|
||||
("jp", current_time),
|
||||
],
|
||||
)
|
||||
|
||||
def test_compute_trading_day_filter_supports_comma_list_regions(self) -> None:
|
||||
args = self._make_args()
|
||||
config = self._make_config(
|
||||
trading_day_check_enabled=True,
|
||||
market_review_enabled=True,
|
||||
market_review_region="jp,kr",
|
||||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||||
)
|
||||
|
||||
stock_codes = ["cn-stock", "jp-stock", "kr-stock", "us-stock", "none-stock"]
|
||||
|
||||
with patch(
|
||||
"src.core.trading_calendar.get_market_for_stock",
|
||||
side_effect=lambda code: {"cn-stock": "cn", "jp-stock": "jp", "kr-stock": "kr", "us-stock": "us"}.get(code),
|
||||
), patch("src.core.trading_calendar.get_open_markets_today", return_value={"jp", "kr"}):
|
||||
filtered_codes, effective_region, should_skip_all = main._compute_trading_day_filter(
|
||||
config,
|
||||
args,
|
||||
stock_codes,
|
||||
)
|
||||
|
||||
self.assertEqual(filtered_codes, ["jp-stock", "kr-stock", "none-stock"])
|
||||
self.assertEqual(effective_region, "jp,kr")
|
||||
self.assertFalse(should_skip_all)
|
||||
|
||||
def test_public_webui_bind_warns_when_auth_is_disabled(self) -> None:
|
||||
with patch("src.auth.is_auth_enabled", return_value=False), \
|
||||
patch("main.logger.warning") as warning_log:
|
||||
@@ -1835,6 +1894,37 @@ class MainScheduleModeTestCase(unittest.TestCase):
|
||||
self.assertEqual(call_args.kwargs["override_region"], "cn,us")
|
||||
self.assertEqual(call_args.kwargs["trigger_source"], "cli")
|
||||
|
||||
def test_market_review_mode_respects_comma_list_market_review_region(self) -> None:
|
||||
args = self._make_args(market_review=True)
|
||||
config = self._make_config(
|
||||
trading_day_check_enabled=True,
|
||||
market_review_region="jp,kr",
|
||||
market_review_enabled=False,
|
||||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||||
)
|
||||
runtime_notifier = MagicMock()
|
||||
runtime_analyzer = MagicMock()
|
||||
runtime_search_service = MagicMock()
|
||||
|
||||
with patch("main.parse_arguments", return_value=args), \
|
||||
patch("main.get_config", return_value=config), \
|
||||
patch("main.setup_logging"), \
|
||||
patch("main._run_market_review_with_shared_lock") as run_with_lock, \
|
||||
patch(
|
||||
"src.core.market_review_runtime.build_market_review_runtime",
|
||||
return_value=(runtime_notifier, runtime_analyzer, runtime_search_service),
|
||||
) as runtime_builder, \
|
||||
patch("src.core.market_review.run_market_review"), \
|
||||
patch("src.core.trading_calendar.get_open_markets_today", return_value={"jp", "kr"}):
|
||||
exit_code = main.main()
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
runtime_builder.assert_called_once_with(config)
|
||||
call_args = run_with_lock.call_args
|
||||
self.assertIs(call_args.args[0], config)
|
||||
self.assertEqual(call_args.kwargs["override_region"], "jp,kr")
|
||||
self.assertEqual(call_args.kwargs["trigger_source"], "cli")
|
||||
|
||||
def test_bootstrap_logging_persists_when_config_load_fails(self) -> None:
|
||||
"""Config load failure must be logged to stderr and return exit code 1.
|
||||
|
||||
|
||||
@@ -2641,6 +2641,35 @@ class TestMarketAnalyzerBypassFix:
|
||||
assert "### 6. Strategy Framework" in result
|
||||
assert "### 一、市场总结" not in result
|
||||
|
||||
def test_generate_template_review_uses_jp_title_for_english_fallback(self):
|
||||
from src.core.market_profile import JP_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 = "jp"
|
||||
ma.profile = JP_PROFILE
|
||||
ma.strategy = get_market_strategy_blueprint("jp")
|
||||
ma.config.report_language = "en"
|
||||
overview = MarketOverview(
|
||||
date="2026-03-05",
|
||||
indices=[
|
||||
MarketIndex(
|
||||
code="N225",
|
||||
name="Nikkei 225",
|
||||
current=39000.0,
|
||||
change=120.0,
|
||||
change_pct=0.31,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result = ma.generate_market_review(overview, [])
|
||||
|
||||
assert "Japan Market Recap" in result
|
||||
assert "Today's Japan market showed" in result
|
||||
assert "A-share Market Recap" not in result
|
||||
|
||||
def test_generate_template_review_keeps_chinese_shell_for_us_when_report_language_is_default(self):
|
||||
from src.core.market_profile import US_PROFILE
|
||||
from src.core.market_strategy import get_market_strategy_blueprint
|
||||
|
||||
@@ -57,6 +57,12 @@ class MarketLightServiceTestCase(unittest.TestCase):
|
||||
os.environ.pop("DATABASE_PATH", None)
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_normalize_market_region_rejects_jp_kr_until_market_light_supported(self) -> None:
|
||||
for region in ("jp", "kr"):
|
||||
with self.subTest(region=region):
|
||||
with self.assertRaisesRegex(ValueError, "supports cn, hk, us only"):
|
||||
normalize_market_region(region)
|
||||
|
||||
def _add_history(self, *, created_at: datetime, context_snapshot: dict | None) -> None:
|
||||
with self.db.get_session() as session:
|
||||
session.add(
|
||||
|
||||
@@ -260,6 +260,10 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
|
||||
"# A-share Market Recap\n\nCN body",
|
||||
persist_history.call_args.kwargs["markdown_report"],
|
||||
)
|
||||
self.assertEqual(
|
||||
set(persist_history.call_args.kwargs["market_light_snapshots"]),
|
||||
{"cn", "hk", "us"},
|
||||
)
|
||||
sent_content = notifier.send.call_args.args[0]
|
||||
self.assertTrue(sent_content.startswith("🎯 Market Review\n\n"))
|
||||
self.assertIn("# US Market Recap\n\nUS body", sent_content)
|
||||
@@ -392,6 +396,40 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
|
||||
self.assertEqual(snapshots["cn"]["score"], 60)
|
||||
self.assertEqual(snapshots["us"]["score"], 55)
|
||||
|
||||
def test_run_market_review_jp_kr_skips_market_light_snapshot_schema(self) -> None:
|
||||
notifier = self._make_notifier()
|
||||
|
||||
from src.market_analyzer import MarketOverview
|
||||
|
||||
with patch.object(
|
||||
market_review_module.MarketAnalyzer,
|
||||
"get_market_overview",
|
||||
side_effect=[
|
||||
MarketOverview(date="2026-03-06"),
|
||||
MarketOverview(date="2026-03-06"),
|
||||
],
|
||||
), patch.object(
|
||||
market_review_module.MarketAnalyzer,
|
||||
"search_market_news",
|
||||
return_value=[],
|
||||
), patch.object(
|
||||
market_review_module.MarketAnalyzer,
|
||||
"generate_market_review",
|
||||
side_effect=["JP body", "KR body"],
|
||||
), patch.object(market_review_module, "_persist_market_review_history") as persist_history:
|
||||
result = run_market_review(
|
||||
notifier,
|
||||
config=SimpleNamespace(report_language="zh", market_review_region="jp,kr"),
|
||||
send_notification=False,
|
||||
)
|
||||
|
||||
self.assertIn("# 日股大盘复盘\n\nJP body", result)
|
||||
self.assertIn("# 韩股大盘复盘\n\nKR body", result)
|
||||
self.assertEqual(persist_history.call_args.kwargs["market_light_snapshots"], {})
|
||||
payload = persist_history.call_args.kwargs["market_review_payload"]
|
||||
self.assertNotIn("market_light", payload["markets"]["jp"])
|
||||
self.assertNotIn("market_light", payload["markets"]["kr"])
|
||||
|
||||
def test_run_market_review_normalizes_single_region_snapshot_key(self) -> None:
|
||||
notifier = self._make_notifier()
|
||||
market_analyzer = MagicMock()
|
||||
|
||||
@@ -48,6 +48,28 @@ class TestMarketAnalyzerStrategyPrompt(unittest.TestCase):
|
||||
self.assertIn("Strategy Plan", prompt)
|
||||
self.assertIn("US Market Regime Strategy", prompt)
|
||||
|
||||
def test_jp_kr_prompt_uses_region_aware_english_shell(self):
|
||||
cases = [
|
||||
("jp", "Japan market"),
|
||||
("kr", "Korea market"),
|
||||
]
|
||||
|
||||
for region, market_scope_name in cases:
|
||||
with self.subTest(region=region), 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"professional {market_scope_name} analyst", prompt)
|
||||
self.assertIn("## Data Limits", prompt)
|
||||
self.assertIn("### 3. News Catalysts", prompt)
|
||||
self.assertNotIn("### 3. Fund Flows", prompt)
|
||||
self.assertNotIn("### 4. Sector Highlights", prompt)
|
||||
self.assertNotIn("Interpret what turnover, participation, and flow signals imply", prompt)
|
||||
self.assertNotIn("professional US/A/H market analyst", prompt)
|
||||
|
||||
def test_us_prompt_localizes_strategy_markdown_when_report_language_is_zh(self):
|
||||
with patch("src.market_analyzer.get_config", return_value=SimpleNamespace(report_language="zh")):
|
||||
analyzer = MarketAnalyzer(region="us")
|
||||
@@ -59,6 +81,31 @@ class TestMarketAnalyzerStrategyPrompt(unittest.TestCase):
|
||||
self.assertNotIn("Strategy Blueprint", prompt)
|
||||
self.assertIn("风险偏好", prompt)
|
||||
|
||||
def test_jp_kr_prompt_uses_region_aware_chinese_shell(self):
|
||||
cases = [
|
||||
("jp", "日本市场", "日本市场三段式复盘策略"),
|
||||
("kr", "韩国市场", "韩国市场三段式复盘策略"),
|
||||
]
|
||||
|
||||
for region, market_scope_name, strategy_title in cases:
|
||||
with self.subTest(region=region), 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"专业的{market_scope_name}分析师", prompt)
|
||||
self.assertIn(f"结构化的{market_scope_name}大盘复盘报告", prompt)
|
||||
self.assertIn(f"## 2026-02-24 {market_scope_name}大盘复盘", prompt)
|
||||
self.assertIn("## 数据边界", prompt)
|
||||
self.assertIn("### 三、消息催化", prompt)
|
||||
self.assertIn(strategy_title, prompt)
|
||||
self.assertNotIn("### 三、板块主线", prompt)
|
||||
self.assertNotIn("### 四、资金与情绪", prompt)
|
||||
self.assertNotIn("解读成交额、涨跌停结构、市场宽度", prompt)
|
||||
self.assertNotIn("A/H/美股市场分析师", prompt)
|
||||
|
||||
def test_cn_prompt_uses_english_shell_when_report_language_is_en(self):
|
||||
with patch("src.market_analyzer.get_config", return_value=SimpleNamespace(report_language="en")):
|
||||
analyzer = MarketAnalyzer(region="cn")
|
||||
|
||||
@@ -179,6 +179,50 @@ class PortfolioApiTestCase(unittest.TestCase):
|
||||
self.assertAlmostEqual(account_snapshot["total_market_value"], 11000.0, places=6)
|
||||
self.assertAlmostEqual(account_snapshot["total_equity"], 11000.0, places=6)
|
||||
|
||||
def test_snapshot_exposes_partial_quality_fields_for_mixed_position_markets(self) -> None:
|
||||
create_resp = self.client.post(
|
||||
"/api/v1/portfolio/accounts",
|
||||
json={"name": "Mixed", "broker": "Demo", "market": "cn", "base_currency": "CNY"},
|
||||
)
|
||||
self.assertEqual(create_resp.status_code, 200)
|
||||
account_id = create_resp.json()["id"]
|
||||
|
||||
trade_resp = self.client.post(
|
||||
"/api/v1/portfolio/trades",
|
||||
json={
|
||||
"account_id": account_id,
|
||||
"symbol": "7203.T",
|
||||
"trade_date": "2026-01-02",
|
||||
"side": "buy",
|
||||
"quantity": 10,
|
||||
"price": 1000,
|
||||
"fee": 0,
|
||||
"tax": 0,
|
||||
"market": "jp",
|
||||
"currency": "JPY",
|
||||
},
|
||||
)
|
||||
self.assertEqual(trade_resp.status_code, 200, trade_resp.text)
|
||||
self._save_close("7203.T", date(2026, 1, 3), 1200.0)
|
||||
|
||||
snapshot_resp = self.client.get(
|
||||
"/api/v1/portfolio/snapshot",
|
||||
params={"as_of": "2026-01-03"},
|
||||
)
|
||||
self.assertEqual(snapshot_resp.status_code, 200)
|
||||
payload = snapshot_resp.json()
|
||||
self.assertEqual(payload["data_quality"], "partial")
|
||||
self.assertIn("fx_and_cost_basis_partial", payload["limitations"])
|
||||
account_snapshot = payload["accounts"][0]
|
||||
position = account_snapshot["positions"][0]
|
||||
|
||||
self.assertEqual(account_snapshot["market"], "cn")
|
||||
self.assertEqual(account_snapshot["data_quality"], "partial")
|
||||
self.assertIn("fx_and_cost_basis_partial", account_snapshot["limitations"])
|
||||
self.assertEqual(position["market"], "jp")
|
||||
self.assertEqual(position["data_quality"], "partial")
|
||||
self.assertIn("realtime_quote_best_effort", position["limitations"])
|
||||
|
||||
def test_delete_account_deactivates_without_hard_deleting(self) -> None:
|
||||
create_resp = self.client.post(
|
||||
"/api/v1/portfolio/accounts",
|
||||
|
||||
@@ -469,6 +469,74 @@ class PortfolioServiceTestCase(unittest.TestCase):
|
||||
self.assertAlmostEqual(position["market_value_base"], close * 10, places=6)
|
||||
self.assertAlmostEqual(position["unrealized_pnl_base"], close * 10 - 1000, places=6)
|
||||
self.assertAlmostEqual(position["unrealized_pnl_pct"], (close * 10 - 1000) / 1000 * 100, places=6)
|
||||
self.assertEqual(position["data_quality"], "ok")
|
||||
self.assertEqual(position["limitations"], [])
|
||||
|
||||
def test_jp_kr_portfolio_snapshot_marks_partial_valuation_boundaries(self) -> None:
|
||||
for market, currency, symbol, close in [
|
||||
("jp", "JPY", "7203.T", 3000.0),
|
||||
("kr", "KRW", "005930.KS", 70000.0),
|
||||
]:
|
||||
with self.subTest(market=market):
|
||||
aid = self._create_account_with_position(
|
||||
market=market,
|
||||
currency=currency,
|
||||
symbol=symbol,
|
||||
close=close,
|
||||
)
|
||||
|
||||
snapshot = self.service.get_portfolio_snapshot(
|
||||
account_id=aid,
|
||||
as_of=date(2026, 1, 3),
|
||||
cost_method="fifo",
|
||||
)
|
||||
account = snapshot["accounts"][0]
|
||||
position = account["positions"][0]
|
||||
|
||||
self.assertEqual(account["market"], market)
|
||||
self.assertEqual(account["base_currency"], currency)
|
||||
self.assertEqual(account["data_quality"], "partial")
|
||||
self.assertEqual(
|
||||
account["limitations"],
|
||||
[
|
||||
"realtime_quote_best_effort",
|
||||
"fx_and_cost_basis_partial",
|
||||
"sector_and_risk_metrics_limited",
|
||||
],
|
||||
)
|
||||
self.assertEqual(position["symbol"], symbol)
|
||||
self.assertEqual(position["data_quality"], "partial")
|
||||
self.assertIn("fx_and_cost_basis_partial", position["limitations"])
|
||||
|
||||
def test_aggregate_snapshot_marks_partial_when_any_account_has_limitations(self) -> None:
|
||||
self._create_account_with_position(
|
||||
market="cn",
|
||||
currency="CNY",
|
||||
symbol="600519",
|
||||
close=120.0,
|
||||
)
|
||||
self._create_account_with_position(
|
||||
market="jp",
|
||||
currency="JPY",
|
||||
symbol="7203.T",
|
||||
close=3000.0,
|
||||
)
|
||||
|
||||
snapshot = self.service.get_portfolio_snapshot(
|
||||
as_of=date(2026, 1, 3),
|
||||
cost_method="fifo",
|
||||
)
|
||||
|
||||
self.assertEqual(snapshot["account_count"], 2)
|
||||
self.assertEqual(snapshot["data_quality"], "partial")
|
||||
self.assertEqual(
|
||||
snapshot["limitations"],
|
||||
[
|
||||
"realtime_quote_best_effort",
|
||||
"fx_and_cost_basis_partial",
|
||||
"sector_and_risk_metrics_limited",
|
||||
],
|
||||
)
|
||||
|
||||
def test_snapshot_marks_stale_close_and_missing_price(self) -> None:
|
||||
aid = self._create_account_with_position(
|
||||
|
||||
@@ -1924,6 +1924,12 @@ class SystemConfigServiceTestCase(unittest.TestCase):
|
||||
context_profile_schema["validation"]["enum"],
|
||||
["cost", "balanced", "long_context_raw_first"],
|
||||
)
|
||||
market_review_schema = items["MARKET_REVIEW_REGION"]["schema"]
|
||||
self.assertEqual(
|
||||
market_review_schema["validation"]["allowed_values"],
|
||||
["cn", "hk", "us", "jp", "kr", "both"],
|
||||
)
|
||||
self.assertEqual(market_review_schema["validation"]["delimiter"], ",")
|
||||
self.assertEqual(
|
||||
items["AGENT_CONTEXT_COMPRESSION_TRIGGER_TOKENS"]["schema"]["default_value"],
|
||||
"",
|
||||
@@ -1962,6 +1968,14 @@ class SystemConfigServiceTestCase(unittest.TestCase):
|
||||
self.assertTrue(validation["valid"])
|
||||
self.assertEqual(validation["issues"], [])
|
||||
|
||||
def test_validate_accepts_comma_separated_market_review_region(self) -> None:
|
||||
validation = self.service.validate(
|
||||
items=[{"key": "MARKET_REVIEW_REGION", "value": "cn,jp,us"}]
|
||||
)
|
||||
|
||||
self.assertTrue(validation["valid"])
|
||||
self.assertEqual(validation["issues"], [])
|
||||
|
||||
def test_validate_accepts_blank_context_compression_preset_fields(self) -> None:
|
||||
validation = self.service.validate(
|
||||
items=[
|
||||
@@ -3664,6 +3678,80 @@ class SystemConfigServiceTestCase(unittest.TestCase):
|
||||
self.assertIn("主模型 / Agent 主模型 / Vision 模型 / 备选模型中的失效项", warning)
|
||||
self.assertIn("桌面端导出备份", warning)
|
||||
|
||||
def test_update_market_review_region_does_not_trigger_runtime_model_cleanup(self) -> None:
|
||||
litellm_config_path = Path(self.temp_dir.name) / "litellm_config.yaml"
|
||||
litellm_config_path.write_text("model_list: []\n", encoding="utf-8")
|
||||
|
||||
self._rewrite_env(
|
||||
"MARKET_REVIEW_REGION=cn",
|
||||
"LITELLM_MODEL=openai/gpt-4o-mini",
|
||||
"AGENT_LITELLM_MODEL=openai/gpt-4o",
|
||||
"LITELLM_FALLBACK_MODELS=openai/gpt-4o-mini,openai/gpt-4o",
|
||||
"VISION_MODEL=openai/gpt-4o",
|
||||
f"LITELLM_CONFIG={litellm_config_path}",
|
||||
"LLM_CHANNELS=openai",
|
||||
"LLM_OPENAI_PROTOCOL=openai",
|
||||
"LLM_OPENAI_BASE_URL=https://llm-openai.example.com/v1",
|
||||
"LLM_OPENAI_API_KEYS=legacy-openai-secret",
|
||||
"LLM_OPENAI_MODELS=openai/gpt-4o-mini,openai/gpt-4o",
|
||||
"OPENAI_BASE_URL=https://openai.example.com/v1",
|
||||
"OPENAI_API_KEY=sk-openai",
|
||||
"OPENAI_MODEL=gpt-4.1",
|
||||
"ANTHROPIC_MODEL=claude-sonnet-4-6",
|
||||
)
|
||||
|
||||
response = self.service.update(
|
||||
config_version=self.manager.get_config_version(),
|
||||
items=[{"key": "MARKET_REVIEW_REGION", "value": "both"}],
|
||||
reload_now=False,
|
||||
)
|
||||
|
||||
self.assertTrue(response["success"])
|
||||
self.assertIn("MARKET_REVIEW_REGION", response["updated_keys"])
|
||||
current_map = self.manager.read_config_map()
|
||||
self.assertEqual(current_map["MARKET_REVIEW_REGION"], "both")
|
||||
self.assertEqual(current_map["LITELLM_MODEL"], "openai/gpt-4o-mini")
|
||||
self.assertEqual(current_map["AGENT_LITELLM_MODEL"], "openai/gpt-4o")
|
||||
self.assertEqual(current_map["LITELLM_FALLBACK_MODELS"], "openai/gpt-4o-mini,openai/gpt-4o")
|
||||
self.assertEqual(current_map["VISION_MODEL"], "openai/gpt-4o")
|
||||
self.assertEqual(current_map["LITELLM_CONFIG"], str(litellm_config_path))
|
||||
self.assertEqual(current_map["LLM_CHANNELS"], "openai")
|
||||
self.assertEqual(current_map["LLM_OPENAI_PROTOCOL"], "openai")
|
||||
self.assertEqual(current_map["LLM_OPENAI_BASE_URL"], "https://llm-openai.example.com/v1")
|
||||
self.assertEqual(current_map["LLM_OPENAI_API_KEYS"], "legacy-openai-secret")
|
||||
self.assertEqual(current_map["LLM_OPENAI_MODELS"], "openai/gpt-4o-mini,openai/gpt-4o")
|
||||
self.assertEqual(current_map["OPENAI_BASE_URL"], "https://openai.example.com/v1")
|
||||
self.assertEqual(current_map["OPENAI_API_KEY"], "sk-openai")
|
||||
self.assertEqual(current_map["OPENAI_MODEL"], "gpt-4.1")
|
||||
self.assertEqual(current_map["ANTHROPIC_MODEL"], "claude-sonnet-4-6")
|
||||
self.assertFalse(
|
||||
any("已同步清理失效的运行时模型引用" in warning for warning in response["warnings"]),
|
||||
response["warnings"],
|
||||
)
|
||||
|
||||
def test_update_market_review_region_accepts_comma_separated_regions(self) -> None:
|
||||
response = self.service.update(
|
||||
config_version=self.manager.get_config_version(),
|
||||
items=[{"key": "MARKET_REVIEW_REGION", "value": "cn,jp,us"}],
|
||||
reload_now=False,
|
||||
)
|
||||
|
||||
self.assertTrue(response["success"])
|
||||
self.assertIn("MARKET_REVIEW_REGION", response["updated_keys"])
|
||||
current_map = self.manager.read_config_map()
|
||||
self.assertEqual(current_map["MARKET_REVIEW_REGION"], "cn,jp,us")
|
||||
|
||||
def test_import_env_market_review_region_accepts_comma_separated_regions(self) -> None:
|
||||
response = self.service.import_env(
|
||||
config_version=self.manager.get_config_version(),
|
||||
content="MARKET_REVIEW_REGION=jp,kr\n",
|
||||
reload_now=False,
|
||||
)
|
||||
|
||||
self.assertTrue(response["success"])
|
||||
current_map = self.manager.read_config_map()
|
||||
self.assertEqual(current_map["MARKET_REVIEW_REGION"], "jp,kr")
|
||||
|
||||
def test_import_desktop_env_restores_runtime_models_after_cleanup(self) -> None:
|
||||
self._rewrite_env(
|
||||
"STOCK_LIST=600519,000001",
|
||||
@@ -3713,6 +3801,48 @@ class SystemConfigServiceTestCase(unittest.TestCase):
|
||||
self.assertEqual(restored_map["VISION_MODEL"], pre_clear_map["VISION_MODEL"])
|
||||
self.assertEqual(restored_map["LITELLM_FALLBACK_MODELS"], pre_clear_map["LITELLM_FALLBACK_MODELS"])
|
||||
|
||||
def test_import_desktop_env_restores_provider_and_base_url_after_provider_cleanup(self) -> None:
|
||||
self._rewrite_env(
|
||||
"STOCK_LIST=600519,000001",
|
||||
"LITELLM_MODEL=openai/gpt-4o-mini",
|
||||
"OPENAI_MODEL=gpt-4.1",
|
||||
"OPENAI_BASE_URL=https://openai.example.com/v1",
|
||||
"OPENAI_API_KEY=legacy-openai-key",
|
||||
)
|
||||
|
||||
backup_content = self.service.export_desktop_env()["content"]
|
||||
pre_clear_map = dict(self.manager.read_config_map())
|
||||
|
||||
clear_response = self.service.update(
|
||||
config_version=self.manager.get_config_version(),
|
||||
items=[
|
||||
{"key": "LITELLM_MODEL", "value": ""},
|
||||
{"key": "OPENAI_MODEL", "value": ""},
|
||||
{"key": "OPENAI_BASE_URL", "value": ""},
|
||||
{"key": "OPENAI_API_KEY", "value": ""},
|
||||
],
|
||||
reload_now=False,
|
||||
)
|
||||
self.assertTrue(clear_response["success"])
|
||||
|
||||
cleared_map = self.manager.read_config_map()
|
||||
self.assertEqual(cleared_map["LITELLM_MODEL"], "")
|
||||
self.assertEqual(cleared_map["OPENAI_MODEL"], "")
|
||||
self.assertEqual(cleared_map["OPENAI_BASE_URL"], "")
|
||||
self.assertEqual(cleared_map["OPENAI_API_KEY"], "")
|
||||
|
||||
restore_payload = self.service.import_desktop_env(
|
||||
config_version=self.manager.get_config_version(),
|
||||
content=backup_content,
|
||||
reload_now=False,
|
||||
)
|
||||
self.assertTrue(restore_payload["success"])
|
||||
|
||||
restored_map = self.manager.read_config_map()
|
||||
self.assertEqual(restored_map["LITELLM_MODEL"], pre_clear_map["LITELLM_MODEL"])
|
||||
self.assertEqual(restored_map["OPENAI_MODEL"], pre_clear_map["OPENAI_MODEL"])
|
||||
self.assertEqual(restored_map["OPENAI_BASE_URL"], pre_clear_map["OPENAI_BASE_URL"])
|
||||
self.assertEqual(restored_map["OPENAI_API_KEY"], pre_clear_map["OPENAI_API_KEY"])
|
||||
|
||||
def test_validate_rejects_comma_only_api_key(self) -> None:
|
||||
"""Whitespace/comma-only api_key must fail validation (P2: parsed-segment check)."""
|
||||
|
||||
@@ -742,6 +742,18 @@ class ComputeEffectiveRegionTestCase(unittest.TestCase):
|
||||
result = trading_calendar.compute_effective_region("both", {"cn", "us"})
|
||||
self.assertEqual(result, "cn,us")
|
||||
|
||||
def test_comma_list_region_uses_supported_markets_open_today(self):
|
||||
result = trading_calendar.compute_effective_region("cn,jp", {"cn", "jp", "kr"})
|
||||
self.assertEqual(result, "cn,jp")
|
||||
|
||||
def test_comma_list_region_falls_back_to_single_market_when_only_one_open(self):
|
||||
result = trading_calendar.compute_effective_region("cn,jp", {"jp", "kr"})
|
||||
self.assertEqual(result, "jp")
|
||||
|
||||
def test_comma_list_region_ignores_invalid_markets(self):
|
||||
result = trading_calendar.compute_effective_region("cn,xx,kr", {"cn", "kr"})
|
||||
self.assertEqual(result, "cn,kr")
|
||||
|
||||
def test_both_cn_hk_open_returns_comma_joined_two(self):
|
||||
result = trading_calendar.compute_effective_region("both", {"cn", "hk"})
|
||||
self.assertEqual(result, "cn,hk")
|
||||
|
||||
Reference in New Issue
Block a user