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:
zhulinsen
2026-06-28 18:53:39 +08:00
committed by GitHub
parent 7715896fe4
commit 3fddbd8fbc
29 changed files with 1038 additions and 66 deletions

View File

@@ -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)

View File

@@ -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} />);

View File

@@ -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' } };

View File

@@ -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>

View File

@@ -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();

View File

@@ -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';

View File

@@ -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[];
}

View File

@@ -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/limitationsMarket 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 首页重新分析完成后自动切换到同一股票最新生成的报告,避免仍停留在旧报告内容。

View File

@@ -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` 判断是否为交易日:

View File

@@ -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.

View File

@@ -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
View File

@@ -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:

View File

@@ -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,

View File

@@ -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,

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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,
}
)

View File

@@ -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(

View File

@@ -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(),

View File

@@ -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.

View File

@@ -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

View File

@@ -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(

View File

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

View File

@@ -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")

View File

@@ -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",

View File

@@ -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(

View File

@@ -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)."""

View File

@@ -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")