mirror of
https://github.com/ZhuLinsen/daily_stock_analysis.git
synced 2026-10-06 14:33:11 +08:00
* fix(issue-1124): [bug]-修复-task_queue-测试、持仓盈亏漂移与-llm-连通性诊断
This commit is contained in:
@@ -159,6 +159,7 @@ class PortfolioPositionItem(BaseModel):
|
||||
last_price: float
|
||||
market_value_base: float
|
||||
unrealized_pnl_base: float
|
||||
unrealized_pnl_pct: Optional[float] = None
|
||||
valuation_currency: str
|
||||
price_source: str = "unknown"
|
||||
price_provider: Optional[str] = None
|
||||
|
||||
@@ -179,6 +179,10 @@ class TestLLMChannelResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
error: Optional[str] = None
|
||||
error_code: Optional[str] = None
|
||||
stage: Optional[str] = None
|
||||
retryable: Optional[bool] = None
|
||||
details: Dict[str, Any] = Field(default_factory=dict)
|
||||
resolved_protocol: Optional[str] = None
|
||||
resolved_model: Optional[str] = None
|
||||
latency_ms: Optional[int] = None
|
||||
@@ -201,6 +205,10 @@ class DiscoverLLMChannelModelsResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
error: Optional[str] = None
|
||||
error_code: Optional[str] = None
|
||||
stage: Optional[str] = None
|
||||
retryable: Optional[bool] = None
|
||||
details: Dict[str, Any] = Field(default_factory=dict)
|
||||
resolved_protocol: Optional[str] = None
|
||||
models: List[str] = Field(default_factory=list)
|
||||
latency_ms: Optional[int] = None
|
||||
|
||||
@@ -146,11 +146,13 @@ interface ChannelConfig {
|
||||
interface ChannelTestState {
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
text?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
interface ChannelDiscoveryState {
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
text?: string;
|
||||
hint?: string;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
@@ -374,6 +376,11 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
|
||||
{discoveryState?.text || '支持 `/models` 的 OpenAI Compatible 渠道可自动拉取模型。'}
|
||||
</span>
|
||||
</div>
|
||||
{discoveryState?.hint ? (
|
||||
<p className="text-[11px] text-secondary-text">
|
||||
{discoveryState.hint}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{discoveredModels.length > 0 ? (
|
||||
<div>
|
||||
@@ -429,16 +436,23 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
|
||||
{testState?.status === 'loading' ? '测试中...' : '测试连接'}
|
||||
</Button>
|
||||
{testState?.text ? (
|
||||
<span className={`text-xs ${
|
||||
testState.status === 'success'
|
||||
? 'text-success'
|
||||
: testState.status === 'error'
|
||||
? 'text-danger'
|
||||
: 'text-muted-text'
|
||||
}`}
|
||||
>
|
||||
{testState.text}
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<span className={`block text-xs ${
|
||||
testState.status === 'success'
|
||||
? 'text-success'
|
||||
: testState.status === 'error'
|
||||
? 'text-danger'
|
||||
: 'text-muted-text'
|
||||
}`}
|
||||
>
|
||||
{testState.text}
|
||||
</span>
|
||||
{testState.hint ? (
|
||||
<p className="text-[11px] text-secondary-text">
|
||||
{testState.hint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -609,6 +623,73 @@ function buildModelOptions(models: string[], selectedModel: string, autoLabel: s
|
||||
return options;
|
||||
}
|
||||
|
||||
const LLM_STAGE_LABELS: Record<string, string> = {
|
||||
model_discovery: '模型发现',
|
||||
chat_completion: '聊天调用',
|
||||
response_parse: '响应解析',
|
||||
};
|
||||
|
||||
const LLM_ERROR_LABELS: Record<string, string> = {
|
||||
auth: '鉴权失败',
|
||||
timeout: '请求超时',
|
||||
quota: '额度或限流',
|
||||
model_not_found: '模型不存在',
|
||||
empty_response: '空响应',
|
||||
format_error: '格式异常',
|
||||
network_error: '网络异常',
|
||||
invalid_config: '配置无效',
|
||||
unsupported_protocol: '协议暂不支持',
|
||||
};
|
||||
|
||||
const LLM_TROUBLESHOOTING_HINTS: Record<string, string> = {
|
||||
auth: '请检查 API Key 是否正确、是否有多余空格,以及当前渠道是否需要额外组织/项目权限。',
|
||||
timeout: '可重试;若持续超时,请检查 Base URL、网络代理、服务商可用区或本地防火墙。',
|
||||
quota: '请检查余额、套餐额度、RPM/TPM 限流或并发设置,必要时稍后重试。',
|
||||
model_not_found: '请确认模型名与渠道协议匹配,并先用“获取模型”核对该渠道实际可用模型列表。',
|
||||
empty_response: '渠道已连通但未返回正文;可尝试切换兼容模型、关闭额外响应模式后再测试。',
|
||||
network_error: '请检查 Base URL、代理、TLS/证书、中转网关或本地网络策略,并可稍后重试。',
|
||||
invalid_config: '先补齐协议、Base URL、API Key 和模型配置,再执行一键测试。',
|
||||
unsupported_protocol: '当前仅对 OpenAI Compatible / DeepSeek 渠道提供自动模型发现,请改为手动维护模型列表。',
|
||||
};
|
||||
|
||||
function getLlmStageLabel(stage?: string | null): string {
|
||||
return LLM_STAGE_LABELS[stage || ''] || '连接测试';
|
||||
}
|
||||
|
||||
function getLlmErrorCodeLabel(code?: string | null): string {
|
||||
return LLM_ERROR_LABELS[code || ''] || '测试失败';
|
||||
}
|
||||
|
||||
function getLlmTroubleshootingHint(
|
||||
code?: string | null,
|
||||
stage?: string | null,
|
||||
context: 'test' | 'discovery' = 'test',
|
||||
): string | undefined {
|
||||
if (code === 'format_error') {
|
||||
return context === 'discovery' || stage === 'model_discovery'
|
||||
? '该渠道返回的 /models 响应格式不兼容,请改为手动填写模型列表。'
|
||||
: '返回结构与预期不一致,请确认该渠道兼容 Chat Completions 接口。';
|
||||
}
|
||||
if (code === 'empty_response' && (context === 'discovery' || stage === 'model_discovery')) {
|
||||
return '该渠道的 /models 接口未返回可用模型 ID;请检查 Base URL 是否指向兼容的模型列表接口,或改为手动填写模型列表。';
|
||||
}
|
||||
return LLM_TROUBLESHOOTING_HINTS[code || ''];
|
||||
}
|
||||
|
||||
function buildLlmFailureText(result: {
|
||||
message: string;
|
||||
error?: string | null;
|
||||
stage?: string | null;
|
||||
errorCode?: string | null;
|
||||
}): string {
|
||||
const prefix = `${getLlmStageLabel(result.stage)} · ${getLlmErrorCodeLabel(result.errorCode)}`;
|
||||
const summary = result.message || '测试失败';
|
||||
if (result.error && result.error !== result.message) {
|
||||
return `${prefix}:${summary}(原始摘要:${result.error})`;
|
||||
}
|
||||
return `${prefix}:${summary}`;
|
||||
}
|
||||
|
||||
const MANAGED_PROVIDERS = new Set(['gemini', 'vertex_ai', 'anthropic', 'openai', 'deepseek']);
|
||||
|
||||
function usesDirectEnvProvider(model: string): boolean {
|
||||
@@ -815,6 +896,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
|
||||
| { type: 'local-error'; text: string }
|
||||
| null
|
||||
>(null);
|
||||
const [saveWarnings, setSaveWarnings] = useState<string[]>([]);
|
||||
const [visibleKeys, setVisibleKeys] = useState<Record<number, boolean>>({});
|
||||
const [testStates, setTestStates] = useState<Record<number, ChannelTestState>>({});
|
||||
const [discoveryStates, setDiscoveryStates] = useState<Record<string, ChannelDiscoveryState>>({});
|
||||
@@ -842,6 +924,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
|
||||
setExpandedRows({});
|
||||
discoveryNonceRef.current = {};
|
||||
setSaveMessage(null);
|
||||
setSaveWarnings([]);
|
||||
setIsCollapsed(false);
|
||||
}, [channelsFingerprint, runtimeFingerprint, initialChannels, initialRuntimeConfig]);
|
||||
|
||||
@@ -1029,18 +1112,22 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
|
||||
|
||||
setIsSaving(true);
|
||||
setSaveMessage(null);
|
||||
setSaveWarnings([]);
|
||||
|
||||
try {
|
||||
const updateItems = channelsToUpdateItems(channels, initialNames, runtimeConfigForSave, managesRuntimeConfig);
|
||||
await systemConfigApi.update({
|
||||
const response = await systemConfigApi.update({
|
||||
configVersion,
|
||||
maskToken,
|
||||
reloadNow: true,
|
||||
items: updateItems,
|
||||
});
|
||||
setSaveMessage({ type: 'success', text: managesRuntimeConfig ? 'AI 配置已保存' : '渠道配置已保存' });
|
||||
const responseWarnings = response.warnings || [];
|
||||
await onSaved(updateItems);
|
||||
setSaveWarnings(responseWarnings);
|
||||
setSaveMessage({ type: 'success', text: managesRuntimeConfig ? 'AI 配置已保存' : '渠道配置已保存' });
|
||||
} catch (error: unknown) {
|
||||
setSaveWarnings([]);
|
||||
setSaveMessage({ type: 'error', error: getParsedApiError(error) });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
@@ -1065,13 +1152,15 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
|
||||
|
||||
const text = result.success
|
||||
? `连接成功${result.resolvedModel ? ` · ${result.resolvedModel}` : ''}${result.latencyMs ? ` · ${result.latencyMs} ms` : ''}`
|
||||
: (result.error || result.message || '测试失败');
|
||||
: buildLlmFailureText(result);
|
||||
const hint = result.success ? undefined : getLlmTroubleshootingHint(result.errorCode, result.stage, 'test');
|
||||
|
||||
setTestStates((previous) => ({
|
||||
...previous,
|
||||
[index]: {
|
||||
status: result.success ? 'success' : 'error',
|
||||
text,
|
||||
hint,
|
||||
},
|
||||
}));
|
||||
} catch (error: unknown) {
|
||||
@@ -1094,6 +1183,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
|
||||
[channel.id]: {
|
||||
status: 'loading',
|
||||
text: '正在获取模型列表...',
|
||||
hint: undefined,
|
||||
models: previous[channel.id]?.models || [],
|
||||
},
|
||||
}));
|
||||
@@ -1115,7 +1205,8 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
|
||||
status: result.success ? 'success' : 'error',
|
||||
text: result.success
|
||||
? `已获取 ${result.models.length} 个模型${result.latencyMs ? ` · ${result.latencyMs} ms` : ''}`
|
||||
: (result.error || result.message || '获取模型失败'),
|
||||
: buildLlmFailureText(result),
|
||||
hint: result.success ? undefined : getLlmTroubleshootingHint(result.errorCode, result.stage, 'discovery'),
|
||||
models: result.success ? result.models : (previous[channel.id]?.models || []),
|
||||
},
|
||||
}));
|
||||
@@ -1128,6 +1219,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
|
||||
[channel.id]: {
|
||||
status: 'error',
|
||||
text: parsed.message || '获取模型失败',
|
||||
hint: undefined,
|
||||
models: previous[channel.id]?.models || [],
|
||||
},
|
||||
}));
|
||||
@@ -1368,6 +1460,21 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{saveWarnings.length > 0 ? (
|
||||
<InlineAlert
|
||||
variant="warning"
|
||||
title="保存后提示"
|
||||
message={(
|
||||
<div className="space-y-1">
|
||||
{saveWarnings.map((warning) => (
|
||||
<p key={warning}>{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
className="rounded-lg px-3 py-2 text-sm shadow-none"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{saveMessage?.type === 'local-error' ? (
|
||||
<InlineAlert
|
||||
variant="danger"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { LLMChannelEditor } from '../LLMChannelEditor';
|
||||
@@ -174,6 +175,101 @@ describe('LLMChannelEditor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shows cleanup warning and restore path after stale runtime models are removed on save', async () => {
|
||||
update.mockResolvedValue({
|
||||
success: true,
|
||||
configVersion: 'v2',
|
||||
appliedCount: 1,
|
||||
skippedMaskedCount: 0,
|
||||
reloadTriggered: true,
|
||||
updatedKeys: ['LLM_DEEPSEEK_MODELS', 'LITELLM_MODEL'],
|
||||
warnings: [
|
||||
'检测到已同步清理失效的运行时模型引用:主模型 / Agent 主模型 / Vision 模型 / 备选模型中的失效项。如需恢复,请先补回对应渠道模型列表后重新选择;也可用桌面端导出备份或手动 .env 还原之前的 LLM_* / LITELLM_MODEL / AGENT_LITELLM_MODEL / VISION_MODEL / LLM_TEMPERATURE。',
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<LLMChannelEditor
|
||||
items={[
|
||||
{ key: 'LLM_CHANNELS', value: 'deepseek' },
|
||||
{ key: 'LLM_DEEPSEEK_PROTOCOL', value: 'deepseek' },
|
||||
{ key: 'LLM_DEEPSEEK_BASE_URL', value: 'https://api.deepseek.com' },
|
||||
{ key: 'LLM_DEEPSEEK_ENABLED', value: 'true' },
|
||||
{ key: 'LLM_DEEPSEEK_API_KEY', value: 'sk-test' },
|
||||
{ key: 'LLM_DEEPSEEK_MODELS', value: 'deepseek-chat,deepseek-reasoner' },
|
||||
{ key: 'LITELLM_MODEL', value: 'deepseek/deepseek-chat' },
|
||||
{ key: 'AGENT_LITELLM_MODEL', value: 'deepseek/deepseek-reasoner' },
|
||||
{ key: 'LITELLM_FALLBACK_MODELS', value: 'deepseek/deepseek-v4-pro,deepseek/deepseek-chat' },
|
||||
{ key: 'VISION_MODEL', value: 'deepseek/deepseek-reasoner' },
|
||||
]}
|
||||
configVersion="v1"
|
||||
maskToken="******"
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /DeepSeek 官方/i }));
|
||||
fireEvent.change(screen.getByLabelText('模型(逗号分隔)'), {
|
||||
target: { value: 'deepseek-v4-flash,deepseek-v4-pro' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存 AI 配置' }));
|
||||
|
||||
expect(await screen.findByText('保存后提示')).toBeInTheDocument();
|
||||
expect(screen.getByText(/已同步清理失效的运行时模型引用/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/桌面端导出备份或手动 \.env 还原/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps save warnings visible after onSaved-driven refresh', async () => {
|
||||
const warningMessage = '检测到已同步清理失效的运行时模型引用:主模型 / Agent 主模型 / Vision 模型 / 备选模型中的失效项。';
|
||||
const initialItems = [
|
||||
{ key: 'LLM_CHANNELS', value: 'deepseek' },
|
||||
{ key: 'LLM_DEEPSEEK_PROTOCOL', value: 'deepseek' },
|
||||
{ key: 'LLM_DEEPSEEK_BASE_URL', value: 'https://api.deepseek.com' },
|
||||
{ key: 'LLM_DEEPSEEK_ENABLED', value: 'true' },
|
||||
{ key: 'LLM_DEEPSEEK_API_KEY', value: 'sk-test' },
|
||||
{ key: 'LLM_DEEPSEEK_MODELS', value: 'deepseek-chat,deepseek-reasoner' },
|
||||
{ key: 'LITELLM_MODEL', value: 'deepseek/deepseek-chat' },
|
||||
{ key: 'AGENT_LITELLM_MODEL', value: 'deepseek/deepseek-reasoner' },
|
||||
{ key: 'LITELLM_FALLBACK_MODELS', value: 'deepseek/deepseek-v4-pro,cohere/command-r-plus' },
|
||||
{ key: 'VISION_MODEL', value: 'deepseek/deepseek-reasoner' },
|
||||
];
|
||||
const Component = () => {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
|
||||
return (
|
||||
<LLMChannelEditor
|
||||
items={items}
|
||||
configVersion="v1"
|
||||
maskToken="******"
|
||||
onSaved={async (updatedItems) => {
|
||||
setItems(updatedItems);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
update.mockResolvedValue({
|
||||
success: true,
|
||||
configVersion: 'v2',
|
||||
appliedCount: 1,
|
||||
skippedMaskedCount: 0,
|
||||
reloadTriggered: true,
|
||||
updatedKeys: ['LLM_DEEPSEEK_MODELS', 'LITELLM_MODEL'],
|
||||
warnings: [warningMessage],
|
||||
});
|
||||
|
||||
render(<Component />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /DeepSeek 官方/i }));
|
||||
fireEvent.change(screen.getByLabelText('模型(逗号分隔)'), {
|
||||
target: { value: 'deepseek-v4-flash,deepseek-v4-pro' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存 AI 配置' }));
|
||||
|
||||
expect(await screen.findByText('保存后提示')).toBeInTheDocument();
|
||||
expect(screen.getByText(warningMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps direct-env provider runtime models while saving channel changes', async () => {
|
||||
update.mockResolvedValue({
|
||||
success: true,
|
||||
@@ -361,11 +457,34 @@ describe('LLMChannelEditor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shows structured troubleshooting hint when channel auth fails', async () => {
|
||||
testLLMChannel.mockResolvedValue({ success: false, message: 'LLM authentication failed', error: '401 Unauthorized · Bearer [REDACTED]', errorCode: 'auth', stage: 'chat_completion', retryable: false, details: {}, resolvedProtocol: 'openai', resolvedModel: 'openai/gpt-4o-mini', latencyMs: null });
|
||||
|
||||
render(
|
||||
<LLMChannelEditor
|
||||
items={[{ key: 'LLM_CHANNELS', value: 'openai' }, { key: 'LLM_OPENAI_PROTOCOL', value: 'openai' }, { key: 'LLM_OPENAI_BASE_URL', value: 'https://api.openai.com/v1' }, { key: 'LLM_OPENAI_ENABLED', value: 'true' }, { key: 'LLM_OPENAI_API_KEY', value: 'secret-key' }, { key: 'LLM_OPENAI_MODELS', value: 'gpt-4o-mini' }]}
|
||||
configVersion="v1"
|
||||
maskToken="******"
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /OpenAI 官方/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试连接' }));
|
||||
|
||||
expect(await screen.findByText(/聊天调用 · 鉴权失败:LLM authentication failed/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/请检查 API Key 是否正确/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps manual model input available when discovery fails', async () => {
|
||||
discoverLLMChannelModels.mockResolvedValue({
|
||||
success: false,
|
||||
message: 'Model discovery is not supported for this protocol',
|
||||
error: 'LLM channel does not support /models discovery yet',
|
||||
errorCode: 'unsupported_protocol',
|
||||
stage: 'model_discovery',
|
||||
retryable: false,
|
||||
details: {},
|
||||
resolvedProtocol: 'gemini',
|
||||
models: [],
|
||||
latencyMs: null,
|
||||
@@ -390,13 +509,89 @@ describe('LLMChannelEditor', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Gemini 官方/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '获取模型' }));
|
||||
|
||||
await screen.findByText('LLM channel does not support /models discovery yet');
|
||||
await screen.findByText(/模型发现 · 协议暂不支持:Model discovery is not supported for this protocol/i);
|
||||
expect(screen.getByText(/当前仅对 OpenAI Compatible \/ DeepSeek 渠道提供自动模型发现/i)).toBeInTheDocument();
|
||||
|
||||
const manualInput = screen.getByLabelText('模型(逗号分隔)');
|
||||
fireEvent.change(manualInput, { target: { value: 'gemini-2.5-flash' } });
|
||||
expect(manualInput).toHaveValue('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('maps discovery format errors to the /models troubleshooting hint', async () => {
|
||||
discoverLLMChannelModels.mockResolvedValue({
|
||||
success: false,
|
||||
message: 'Failed to parse /models response',
|
||||
error: 'Unexpected discovery payload',
|
||||
errorCode: 'format_error',
|
||||
stage: 'response_parse',
|
||||
retryable: false,
|
||||
details: {},
|
||||
resolvedProtocol: 'openai',
|
||||
models: [],
|
||||
latencyMs: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<LLMChannelEditor
|
||||
items={[
|
||||
{ key: 'LLM_CHANNELS', value: 'openai' },
|
||||
{ key: 'LLM_OPENAI_PROTOCOL', value: 'openai' },
|
||||
{ key: 'LLM_OPENAI_BASE_URL', value: 'https://api.openai.com/v1' },
|
||||
{ key: 'LLM_OPENAI_ENABLED', value: 'true' },
|
||||
{ key: 'LLM_OPENAI_API_KEY', value: 'secret-key' },
|
||||
{ key: 'LLM_OPENAI_MODELS', value: '' },
|
||||
]}
|
||||
configVersion="v1"
|
||||
maskToken="******"
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /OpenAI 官方/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '获取模型' }));
|
||||
|
||||
expect(await screen.findByText(/响应解析 · 格式异常:Failed to parse \/models response/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/该渠道返回的 \/models 响应格式不兼容,请改为手动填写模型列表。/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('maps discovery empty responses to the /models troubleshooting hint', async () => {
|
||||
discoverLLMChannelModels.mockResolvedValue({
|
||||
success: false,
|
||||
message: 'No model IDs returned from /models response',
|
||||
error: 'Empty model discovery response',
|
||||
errorCode: 'empty_response',
|
||||
stage: 'model_discovery',
|
||||
retryable: false,
|
||||
details: {},
|
||||
resolvedProtocol: 'openai',
|
||||
models: [],
|
||||
latencyMs: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<LLMChannelEditor
|
||||
items={[
|
||||
{ key: 'LLM_CHANNELS', value: 'openai' },
|
||||
{ key: 'LLM_OPENAI_PROTOCOL', value: 'openai' },
|
||||
{ key: 'LLM_OPENAI_BASE_URL', value: 'https://api.openai.com/v1' },
|
||||
{ key: 'LLM_OPENAI_ENABLED', value: 'true' },
|
||||
{ key: 'LLM_OPENAI_API_KEY', value: 'secret-key' },
|
||||
{ key: 'LLM_OPENAI_MODELS', value: '' },
|
||||
]}
|
||||
configVersion="v1"
|
||||
maskToken="******"
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /OpenAI 官方/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '获取模型' }));
|
||||
|
||||
expect(await screen.findByText(/模型发现 · 空响应:No model IDs returned from \/models response/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/该渠道的 \/models 接口未返回可用模型 ID/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/切换兼容模型、关闭额外响应模式/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not apply stale discovery response after channel list re-sync', async () => {
|
||||
let resolvePendingFirst!: (value: unknown) => void;
|
||||
const pendingFirst = new Promise((resolve) => {
|
||||
|
||||
@@ -80,6 +80,12 @@ function formatPct(value: number | undefined | null): string {
|
||||
return `${value.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function formatSignedPct(value: number | undefined | null): string {
|
||||
if (value == null || Number.isNaN(value)) return '--';
|
||||
const sign = value > 0 ? '+' : '';
|
||||
return `${sign}${value.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function hasPositionPrice(row: PortfolioPositionItem): boolean {
|
||||
return row.priceAvailable !== false && row.priceSource !== 'missing';
|
||||
}
|
||||
@@ -995,6 +1001,7 @@ const PortfolioPage: React.FC = () => {
|
||||
<th className="text-right py-2 pr-2">现价</th>
|
||||
<th className="text-right py-2 pr-2">市值</th>
|
||||
<th className="text-right py-2">未实现盈亏</th>
|
||||
<th className="text-right py-2">收益率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1022,6 +1029,17 @@ const PortfolioPage: React.FC = () => {
|
||||
>
|
||||
{formatPositionMoney(row.unrealizedPnlBase, row)}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2 text-right ${
|
||||
hasPositionPrice(row) && row.unrealizedPnlPct !== null && row.unrealizedPnlPct !== undefined
|
||||
? row.unrealizedPnlPct >= 0
|
||||
? 'text-success'
|
||||
: 'text-danger'
|
||||
: 'text-secondary'
|
||||
}`}
|
||||
>
|
||||
{formatSignedPct(row.unrealizedPnlPct)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from 'react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createApiError, createParsedApiError } from '../../api/error';
|
||||
import PortfolioPage from '../PortfolioPage';
|
||||
@@ -96,7 +96,12 @@ function makeAccounts(items: AccountItem[] = [{ id: 1, name: 'Main' }]) {
|
||||
};
|
||||
}
|
||||
|
||||
function makeSnapshot(options: { accountId?: number; fxStale?: boolean; accountCount?: number } = {}) {
|
||||
function makeSnapshot(options: {
|
||||
accountId?: number;
|
||||
fxStale?: boolean;
|
||||
accountCount?: number;
|
||||
positions?: Array<Record<string, unknown>>;
|
||||
} = {}) {
|
||||
const accountId = options.accountId ?? 1;
|
||||
return {
|
||||
asOf: '2026-03-19',
|
||||
@@ -129,7 +134,7 @@ function makeSnapshot(options: { accountId?: number; fxStale?: boolean; accountC
|
||||
feeTotal: 0,
|
||||
taxTotal: 0,
|
||||
fxStale: options.fxStale ?? true,
|
||||
positions: [],
|
||||
positions: options.positions ?? [],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -315,6 +320,35 @@ describe('PortfolioPage FX refresh', () => {
|
||||
expect(await screen.findByText('汇率在线刷新已被禁用。')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders backend-provided position valuation fields and stale missing-price hint', async () => {
|
||||
getSnapshot.mockResolvedValueOnce(makeSnapshot({ fxStale: true, positions: [
|
||||
{ symbol: 'HK00700', market: 'hk', currency: 'HKD', quantity: 10, avgCost: 400, totalCost: 4000, lastPrice: 420, marketValueBase: 4200, unrealizedPnlBase: 200, unrealizedPnlPct: 5, valuationCurrency: 'HKD', priceSource: 'history_close', priceDate: '2026-03-18', priceStale: true, priceAvailable: true },
|
||||
{ symbol: 'AAPL', market: 'us', currency: 'USD', quantity: 5, avgCost: 100, totalCost: 500, lastPrice: 0, marketValueBase: 0, unrealizedPnlBase: 0, unrealizedPnlPct: null, valuationCurrency: 'USD', priceSource: 'missing', priceDate: null, priceStale: true, priceAvailable: false },
|
||||
] }));
|
||||
|
||||
render(<PortfolioPage />);
|
||||
|
||||
await waitForInitialLoad();
|
||||
|
||||
expect(await screen.findByText('HK00700')).toBeInTheDocument();
|
||||
expect(screen.getByText('420.0000')).toBeInTheDocument();
|
||||
expect(screen.getByText('HKD 4,200.00')).toBeInTheDocument();
|
||||
expect(screen.getByText('+5.00%')).toBeInTheDocument();
|
||||
expect(screen.getByText('收盘价 · 2026-03-18')).toBeInTheDocument();
|
||||
expect(screen.getByText('缺价')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('--').length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const hkRow = screen.getByText('HK00700').closest('tr');
|
||||
const aaplRow = screen.getByText('AAPL').closest('tr');
|
||||
expect(hkRow).not.toBeNull();
|
||||
expect(aaplRow).not.toBeNull();
|
||||
|
||||
const hkRowCells = within(hkRow as HTMLTableRowElement).getAllByRole('cell');
|
||||
const aaplRowCells = within(aaplRow as HTMLTableRowElement).getAllByRole('cell');
|
||||
expect(hkRowCells.at(-1)).toHaveClass('text-success');
|
||||
expect(aaplRowCells.at(-1)).toHaveClass('text-secondary');
|
||||
});
|
||||
|
||||
it('prefers disabled feedback over empty-pair feedback when refresh is disabled', async () => {
|
||||
refreshFx.mockResolvedValueOnce({
|
||||
asOf: '2026-03-19',
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface PortfolioPositionItem {
|
||||
lastPrice: number;
|
||||
marketValueBase: number;
|
||||
unrealizedPnlBase: number;
|
||||
unrealizedPnlPct?: number | null;
|
||||
valuationCurrency: string;
|
||||
priceSource?: 'realtime_quote' | 'history_close' | 'missing' | string;
|
||||
priceProvider?: string | null;
|
||||
|
||||
@@ -141,6 +141,10 @@ export interface TestLLMChannelResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
error?: string | null;
|
||||
errorCode?: string | null;
|
||||
stage?: string | null;
|
||||
retryable?: boolean | null;
|
||||
details?: Record<string, unknown>;
|
||||
resolvedProtocol?: string | null;
|
||||
resolvedModel?: string | null;
|
||||
latencyMs?: number | null;
|
||||
@@ -159,6 +163,10 @@ export interface DiscoverLLMChannelModelsResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
error?: string | null;
|
||||
errorCode?: string | null;
|
||||
stage?: string | null;
|
||||
retryable?: boolean | null;
|
||||
details?: Record<string, unknown>;
|
||||
resolvedProtocol?: string | null;
|
||||
models: string[];
|
||||
latencyMs?: number | null;
|
||||
|
||||
@@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
|
||||
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
|
||||
- [修复] 统一持仓快照输出现价/市值/浮盈亏/收益率与价格元信息,并为 LLM 渠道测试补充结构化诊断与设置页排障提示。
|
||||
- [文档] 补充 LLM 渠道编辑器的官方来源、依赖兼容窗口、保存时的运行时模型清理规则,以及旧配置回退路径说明。
|
||||
- [测试] 补齐 task_queue 运行时配置同步回归证据,明确 `tests/test_task_queue_config_sync.py` 作为本轮验收项。
|
||||
|
||||
## [3.14.2] - 2026-04-30
|
||||
|
||||
|
||||
@@ -75,6 +75,32 @@ LITELLM_MODEL=ollama/qwen3:8b
|
||||
|
||||
后端提供只读状态接口 `GET /api/v1/system/config/setup/status`,用于判断首次启动闭环中最基础的几类配置是否已经就绪:LLM 主渠道、Agent 渠道、自选股、通知渠道和本地存储。这个接口只读取已保存的 `.env` 与当前进程环境变量,不会重载运行时配置、写入 `.env`、测试真实模型或创建数据库文件;前端向导和后续 smoke run 可以基于该接口逐步接入。
|
||||
|
||||
### Web 渠道编辑器的兼容性 / 迁移 / 回退规则
|
||||
|
||||
- 预设里的 provider / Base URL / 示例模型只用于**初始化表单**;真正落盘时仍是你当前输入的 `LLM_{CHANNEL}_PROTOCOL`、`LLM_{CHANNEL}_BASE_URL`、`LLM_{CHANNEL}_MODELS`、`LLM_{CHANNEL}_API_KEY(S)`,不会在后台偷偷改成别的 provider 名或 URL。
|
||||
- 设置页的“获取模型”只对 `OpenAI Compatible` / `DeepSeek` 渠道调用 `{base_url}/models`;“测试连接”只发一次最小聊天请求。两者返回的 `stage / error_code / details / latency_ms` 仅用于结构化诊断提示,**不会写回** `.env`。
|
||||
- 保存渠道时,只会更新这次提交的 key;不会因为切换渠道模式而静默迁移整个旧配置。唯一会被**同步清理**的是运行时模型引用:如果 `LITELLM_MODEL`、`AGENT_LITELLM_MODEL`、`VISION_MODEL` 或 `LITELLM_FALLBACK_MODELS` 指向了当前已启用渠道里已经不存在的模型,设置页会在保存前把这些失效引用清空/移除,避免运行时继续指向无效模型;像 `cohere/...` 这类直连环境变量 provider 仍会保留。
|
||||
- 回退方式也保持最小:把对应渠道模型列表改回去后重新选择主模型 / fallback,或直接用桌面端导出备份 / 手动 `.env` 还原之前的 `LLM_*`、`LITELLM_MODEL`、`AGENT_LITELLM_MODEL`、`VISION_MODEL`、`LLM_TEMPERATURE` 即可,不需要额外跑迁移脚本。
|
||||
- 当前仓库对此链路的依赖窗口是 `litellm>=1.80.10,<1.82.7`(见 `requirements.txt`);回归覆盖包括 `tests/test_system_config_service.py`、`tests/test_system_config_api.py` 和 `apps/dsa-web/src/components/settings/__tests__/LLMChannelEditor.test.tsx`。
|
||||
|
||||
### 回退与兼容性证据
|
||||
|
||||
- 兼容窗口与静默清理范围:在 `litellm>=1.80.10,<1.82.7` 时,保存仅清理失效的 runtime 模型引用(`LITELLM_MODEL`、`AGENT_LITELLM_MODEL`、`VISION_MODEL`、`LITELLM_FALLBACK_MODELS`),`cohere/*` 等非渠道直连模型会被保留。
|
||||
- 回退方式:可直接用桌面端导出备份后通过 `POST /api/v1/system/config/import` 恢复;也可手动把 `.env` 中历史 `LITELLM_* / AGENT_LITELLM_MODEL / VISION_MODEL / LLM_TEMPERATURE` 回填后重启生效。
|
||||
- 回退回归证据:`tests/test_system_config_service.py::test_import_desktop_env_restores_runtime_models_after_cleanup` 覆盖“清理后用桌面导出备份恢复 runtime 引用”。
|
||||
- 建议回退操作链路(含设置页刷新):先导出桌面备份,`POST /api/v1/system/config/import` 导入后,再通过 `GET /api/v1/system/config` 刷新页面配置,再确认 `LITELLM_MODEL / AGENT_LITELLM_MODEL / VISION_MODEL / LLM_TEMPERATURE` 与模型列表一致后再继续使用。
|
||||
|
||||
### 常用官方文档来源(用于核对预设 provider / Base URL / 模型命名)
|
||||
|
||||
- OpenAI Compatible 规范(LiteLLM):<https://docs.litellm.ai/docs/providers/openai_compatible>
|
||||
- OpenAI 官方:<https://platform.openai.com/docs/api-reference/chat>
|
||||
- DeepSeek 官方:<https://api-docs.deepseek.com/>
|
||||
- 阿里百炼 DashScope 兼容模式:<https://help.aliyun.com/zh/model-studio/compatibility-of-openai-with-dashscope>
|
||||
- Moonshot / Kimi 官方:<https://platform.moonshot.ai/docs/guide/compatibility>
|
||||
- Anthropic 官方:<https://docs.anthropic.com/en/api/messages>
|
||||
- Gemini 官方:<https://ai.google.dev/gemini-api/docs/openai>
|
||||
- Ollama 官方:<https://github.com/ollama/ollama/blob/main/docs/api.md>
|
||||
|
||||
如果不方便用网页版,在 `.env` 文件中配置也非常丝滑,它能让你同时管理多个第三方平台。规则如下:
|
||||
|
||||
1. **先声明你有几个渠道**:`LLM_CHANNELS=渠道名称1,渠道名称2`
|
||||
@@ -143,6 +169,16 @@ LITELLM_MODEL=ollama/qwen3:8b
|
||||
- 本仓库兼容性回归覆盖见:`tests/test_llm_channel_config.py`、`tests/test_market_analyzer_generate_text.py`、`tests/test_agent_pipeline.py`、`tests/test_system_config_service.py`。
|
||||
- 最小回滚方式:直接回退本次 Kimi 固定温度相关改动,无需单独迁移已有 `LLM_TEMPERATURE` 配置。
|
||||
|
||||
### 兼容性与回退复核清单(按 PR 审核口径)
|
||||
|
||||
- 运行时依赖窗口:`litellm>=1.80.10,<1.82.7`(与 `requirements.txt` 一致)。
|
||||
- 回归验证入口:
|
||||
- 渠道模型发现与连接:`tests/test_llm_channel_config.py`
|
||||
- 运行时源清理与恢复(含桌面导出备份链路):`tests/test_system_config_service.py`
|
||||
- 接口校验与问题面向字段:`tests/test_system_config_api.py`
|
||||
- 设置页交互与保存后提示:`apps/dsa-web/src/components/settings/__tests__/LLMChannelEditor.test.tsx`
|
||||
- 旧配置回退路径:`桌面端导出备份 -> /api/v1/system/config/import`,或手动恢复 `LLM_* / LITELLM_* / AGENT_LITELLM_MODEL / VISION_MODEL / LLM_TEMPERATURE`。
|
||||
|
||||
> **致命避坑说明**:如果你启用了 `LLM_CHANNELS`,那么你直接写在外面的 `DEEPSEEK_API_KEY` 或 `OPENAI_API_KEY` 将**全部失效(系统一律无视)**!二者**选其一即可**,千万不要既写了新手模式又写了渠道模式结果产生冲突。
|
||||
> **Docker 注意**:如果你在 `docker compose environment:` 或 `docker run -e` 中显式传入 `LITELLM_MODEL`、`LLM_CHANNELS`、`LLM_DEEPSEEK_MODELS` 等变量,容器重启后这些环境变量会覆盖 Web 设置页写入的 `.env`,需要同步修改部署配置。
|
||||
|
||||
|
||||
@@ -75,6 +75,32 @@ LITELLM_MODEL=ollama/qwen3:8b
|
||||
|
||||
The backend exposes a read-only status endpoint at `GET /api/v1/system/config/setup/status`. It reports whether the minimum first-run pieces are present: primary LLM, Agent model inheritance/configuration, stock list, optional notification channel, and local storage. The endpoint only reads the saved `.env` plus the current process environment; it does not reload runtime config, write `.env`, test a real model, or create a database file. Frontend onboarding and later smoke-run flows can build on this endpoint incrementally.
|
||||
|
||||
### Web channel editor: compatibility, migration, and rollback rules
|
||||
|
||||
- The preset provider / Base URL / sample models are **form defaults only**. What gets persisted is still exactly what you submit in `LLM_{CHANNEL}_PROTOCOL`, `LLM_{CHANNEL}_BASE_URL`, `LLM_{CHANNEL}_MODELS`, and `LLM_{CHANNEL}_API_KEY(S)`; the editor does not silently rewrite them to a different provider name or URL.
|
||||
- "Discover models" only calls `{base_url}/models` for `OpenAI Compatible` / `DeepSeek` channels, and "Test connection" only sends one minimal chat completion request. The returned `stage / error_code / details / latency_ms` fields are for structured diagnostics only and are **never persisted** back into `.env`.
|
||||
- Saving channels only updates the keys submitted in that save operation; there is no whole-config silent migration when you switch channel settings. The one deliberate cleanup is runtime model references: if `LITELLM_MODEL`, `AGENT_LITELLM_MODEL`, `VISION_MODEL`, or `LITELLM_FALLBACK_MODELS` point to models that no longer exist in the currently enabled channels, the editor clears/removes those stale references before saving so runtime calls do not keep targeting invalid models. Direct-env providers such as `cohere/...` remain untouched.
|
||||
- Rollback stays minimal: restore the previous channel model list and re-select the runtime models, or restore the previous `LLM_*`, `LITELLM_MODEL`, `AGENT_LITELLM_MODEL`, `VISION_MODEL`, and `LLM_TEMPERATURE` values from your desktop export / manual `.env` backup. No extra migration script is required.
|
||||
- The current dependency window for this flow in the repository is `litellm>=1.80.10,<1.82.7` (see `requirements.txt`). Regression coverage for it lives in `tests/test_system_config_service.py`, `tests/test_system_config_api.py`, and `apps/dsa-web/src/components/settings/__tests__/LLMChannelEditor.test.tsx`.
|
||||
|
||||
### Rollback & compatibility evidence
|
||||
|
||||
- Scope and cleanup behavior under `litellm>=1.80.10,<1.82.7`: only runtime references (`LITELLM_MODEL`, `AGENT_LITELLM_MODEL`, `VISION_MODEL`, `LITELLM_FALLBACK_MODELS`) are sanitized during save; non-channel direct providers such as `cohere/*` are preserved.
|
||||
- Rollback path: export desktop config, then restore the backup through `POST /api/v1/system/config/import`; or manually restore historical `.env` entries (`LITELLM_*`, `AGENT_LITELLM_MODEL`, `VISION_MODEL`, `LLM_TEMPERATURE`) and restart.
|
||||
- Rollback evidence: `tests/test_system_config_service.py::test_import_desktop_env_restores_runtime_models_after_cleanup` covers restore from exported desktop backup after runtime cleanup.
|
||||
- Recommended rollback sequence (including UI reload): export desktop backup, restore via `POST /api/v1/system/config/import`, then call `GET /api/v1/system/config` to refresh the settings page and verify `LITELLM_MODEL` / `AGENT_LITELLM_MODEL` / `VISION_MODEL` / `LLM_TEMPERATURE` before continuing.
|
||||
|
||||
### Official references for provider presets / Base URLs / model naming
|
||||
|
||||
- OpenAI-compatible routing in LiteLLM: <https://docs.litellm.ai/docs/providers/openai_compatible>
|
||||
- OpenAI official API docs: <https://platform.openai.com/docs/api-reference/chat>
|
||||
- DeepSeek official API docs: <https://api-docs.deepseek.com/>
|
||||
- DashScope OpenAI-compatible mode: <https://help.aliyun.com/zh/model-studio/compatibility-of-openai-with-dashscope>
|
||||
- Moonshot / Kimi official compatibility docs: <https://platform.moonshot.ai/docs/guide/compatibility>
|
||||
- Anthropic official Messages API: <https://docs.anthropic.com/en/api/messages>
|
||||
- Gemini official OpenAI compatibility docs: <https://ai.google.dev/gemini-api/docs/openai>
|
||||
- Ollama API docs: <https://github.com/ollama/ollama/blob/main/docs/api.md>
|
||||
|
||||
If you prefer modifying files, configuring this in the `.env` file is also very smooth. It allows you to manage multiple platforms simultaneously. The rules are:
|
||||
|
||||
1. **Declare your channels first**: `LLM_CHANNELS=channel_name_1,channel_name_2`
|
||||
|
||||
@@ -572,7 +572,7 @@ class PortfolioRepository:
|
||||
account_id: Optional[int],
|
||||
date_from: Optional[date],
|
||||
date_to: Optional[date],
|
||||
symbol: Optional[str],
|
||||
symbols: Optional[List[str]],
|
||||
side: Optional[str],
|
||||
page: int,
|
||||
page_size: int,
|
||||
@@ -585,8 +585,8 @@ class PortfolioRepository:
|
||||
conditions.append(PortfolioTrade.trade_date >= date_from)
|
||||
if date_to is not None:
|
||||
conditions.append(PortfolioTrade.trade_date <= date_to)
|
||||
if symbol:
|
||||
conditions.append(PortfolioTrade.symbol == symbol)
|
||||
if symbols:
|
||||
conditions.append(PortfolioTrade.symbol.in_(symbols))
|
||||
if side:
|
||||
conditions.append(PortfolioTrade.side == side)
|
||||
|
||||
@@ -649,7 +649,7 @@ class PortfolioRepository:
|
||||
account_id: Optional[int],
|
||||
date_from: Optional[date],
|
||||
date_to: Optional[date],
|
||||
symbol: Optional[str],
|
||||
symbols: Optional[List[str]],
|
||||
action_type: Optional[str],
|
||||
page: int,
|
||||
page_size: int,
|
||||
@@ -662,8 +662,8 @@ class PortfolioRepository:
|
||||
conditions.append(PortfolioCorporateAction.effective_date >= date_from)
|
||||
if date_to is not None:
|
||||
conditions.append(PortfolioCorporateAction.effective_date <= date_to)
|
||||
if symbol:
|
||||
conditions.append(PortfolioCorporateAction.symbol == symbol)
|
||||
if symbols:
|
||||
conditions.append(PortfolioCorporateAction.symbol.in_(symbols))
|
||||
if action_type:
|
||||
conditions.append(PortfolioCorporateAction.action_type == action_type)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
||||
|
||||
from data_provider.base import canonical_stock_code
|
||||
from data_provider.base import canonical_stock_code, normalize_stock_code
|
||||
from src.config import get_config
|
||||
from src.repositories.portfolio_repo import (
|
||||
DuplicateTradeDedupHashError,
|
||||
@@ -181,7 +181,7 @@ class PortfolioService:
|
||||
raise ValueError("quantity and price must be > 0")
|
||||
if fee < 0 or tax < 0:
|
||||
raise ValueError("fee and tax must be >= 0")
|
||||
symbol_norm = canonical_stock_code(symbol)
|
||||
symbol_norm = self._normalize_symbol_for_storage(symbol)
|
||||
if not symbol_norm:
|
||||
raise ValueError("symbol is required")
|
||||
trade_uid_norm = (trade_uid or "").strip() or None
|
||||
@@ -196,11 +196,11 @@ class PortfolioService:
|
||||
trade_uid=trade_uid_norm,
|
||||
dedup_hash=dedup_hash_norm,
|
||||
session=session,
|
||||
)
|
||||
)
|
||||
if side_norm == "sell":
|
||||
self._validate_sell_quantity(
|
||||
account_id=account_id,
|
||||
symbol=symbol_norm,
|
||||
symbol=symbol,
|
||||
market=market_norm,
|
||||
currency=currency_norm,
|
||||
trade_date=trade_date,
|
||||
@@ -283,7 +283,7 @@ class PortfolioService:
|
||||
account = self._require_active_account_in_session(session=session, account_id=account_id)
|
||||
market_norm = self._normalize_market(market or account.market)
|
||||
currency_norm = self._normalize_currency(currency or self._default_currency_for_market(market_norm))
|
||||
symbol_norm = canonical_stock_code(symbol)
|
||||
symbol_norm = self._normalize_symbol_for_storage(symbol)
|
||||
if not symbol_norm:
|
||||
raise ValueError("symbol is required")
|
||||
row = self.repo.add_corporate_action_in_session(
|
||||
@@ -329,10 +329,10 @@ class PortfolioService:
|
||||
if date_from is not None and date_to is not None and date_from > date_to:
|
||||
raise ValueError("date_from must be <= date_to")
|
||||
|
||||
symbol_norm: Optional[str] = None
|
||||
symbol_filters: Optional[List[str]] = None
|
||||
if symbol is not None and symbol.strip():
|
||||
symbol_norm = canonical_stock_code(symbol)
|
||||
if not symbol_norm:
|
||||
symbol_filters = self._build_symbol_filter_values(symbol)
|
||||
if not symbol_filters:
|
||||
raise ValueError("symbol is invalid")
|
||||
|
||||
side_norm: Optional[str] = None
|
||||
@@ -345,7 +345,7 @@ class PortfolioService:
|
||||
account_id=account_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
symbol=symbol_norm,
|
||||
symbols=symbol_filters,
|
||||
side=side_norm,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
@@ -411,10 +411,10 @@ class PortfolioService:
|
||||
if date_from is not None and date_to is not None and date_from > date_to:
|
||||
raise ValueError("date_from must be <= date_to")
|
||||
|
||||
symbol_norm: Optional[str] = None
|
||||
symbol_filters: Optional[List[str]] = None
|
||||
if symbol is not None and symbol.strip():
|
||||
symbol_norm = canonical_stock_code(symbol)
|
||||
if not symbol_norm:
|
||||
symbol_filters = self._build_symbol_filter_values(symbol)
|
||||
if not symbol_filters:
|
||||
raise ValueError("symbol is invalid")
|
||||
|
||||
action_norm: Optional[str] = None
|
||||
@@ -427,7 +427,7 @@ class PortfolioService:
|
||||
account_id=account_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
symbol=symbol_norm,
|
||||
symbols=symbol_filters,
|
||||
action_type=action_norm,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
@@ -638,7 +638,7 @@ class PortfolioService:
|
||||
session: Optional[Any] = None,
|
||||
) -> None:
|
||||
key = (
|
||||
canonical_stock_code(symbol),
|
||||
self._normalize_symbol_for_position(symbol),
|
||||
self._normalize_market(market),
|
||||
self._normalize_currency(currency),
|
||||
)
|
||||
@@ -678,7 +678,7 @@ class PortfolioService:
|
||||
events = []
|
||||
for row in corporate_actions:
|
||||
event_key = (
|
||||
canonical_stock_code(row.symbol),
|
||||
self._normalize_symbol_for_position(row.symbol),
|
||||
self._normalize_market(row.market),
|
||||
self._normalize_currency(row.currency),
|
||||
)
|
||||
@@ -686,7 +686,7 @@ class PortfolioService:
|
||||
events.append(("corp", row.effective_date, row.id, row))
|
||||
for row in trades:
|
||||
event_key = (
|
||||
canonical_stock_code(row.symbol),
|
||||
self._normalize_symbol_for_position(row.symbol),
|
||||
self._normalize_market(row.market),
|
||||
self._normalize_currency(row.currency),
|
||||
)
|
||||
@@ -775,7 +775,7 @@ class PortfolioService:
|
||||
|
||||
if event_type == "trade":
|
||||
key = (
|
||||
canonical_stock_code(event.symbol),
|
||||
self._normalize_symbol_for_position(event.symbol),
|
||||
self._normalize_market(event.market),
|
||||
self._normalize_currency(event.currency),
|
||||
)
|
||||
@@ -855,7 +855,7 @@ class PortfolioService:
|
||||
|
||||
if event_type == "corp":
|
||||
key = (
|
||||
canonical_stock_code(event.symbol),
|
||||
self._normalize_symbol_for_position(event.symbol),
|
||||
self._normalize_market(event.market),
|
||||
self._normalize_currency(event.currency),
|
||||
)
|
||||
@@ -1021,6 +1021,10 @@ class PortfolioService:
|
||||
cost_base = 0.0
|
||||
unrealized_base = 0.0
|
||||
|
||||
unrealized_pct = None
|
||||
if abs(cost_base) > EPS:
|
||||
unrealized_pct = unrealized_base / cost_base * 100.0
|
||||
|
||||
position_rows.append(
|
||||
{
|
||||
"symbol": symbol,
|
||||
@@ -1032,6 +1036,7 @@ class PortfolioService:
|
||||
"last_price": round(float(last_price), 8),
|
||||
"market_value_base": round(market_base, 8),
|
||||
"unrealized_pnl_base": round(unrealized_base, 8),
|
||||
"unrealized_pnl_pct": round(unrealized_pct, 8) if unrealized_pct is not None else None,
|
||||
"valuation_currency": account.base_currency,
|
||||
"price_source": price_info.source,
|
||||
"price_provider": price_info.provider,
|
||||
@@ -1107,6 +1112,117 @@ class PortfolioService:
|
||||
provider = getattr(source, "value", None) or (str(source) if source is not None else None)
|
||||
return numeric_price, provider
|
||||
|
||||
@staticmethod
|
||||
def _normalize_symbol_for_storage(symbol: str) -> str:
|
||||
return canonical_stock_code(symbol)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_symbol_for_position(symbol: str) -> str:
|
||||
if not (symbol or "").strip():
|
||||
return ""
|
||||
|
||||
raw = canonical_stock_code(symbol)
|
||||
if len(raw) >= 8 and raw[:2] in {"SH", "SZ", "BJ"} and raw[2:].isdigit():
|
||||
return raw
|
||||
|
||||
if "." in raw:
|
||||
base, suffix = raw.rsplit(".", 1)
|
||||
if base.isdigit() and suffix in {"SH", "SS", "SZ", "BJ"}:
|
||||
exchange = "SH" if suffix == "SS" else suffix
|
||||
return f"{exchange}{base}"
|
||||
|
||||
return canonical_stock_code(normalize_stock_code(symbol))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_symbol(symbol: str) -> str:
|
||||
"""
|
||||
Canonicalization for symbol filtering with exchange-qualified input preservation.
|
||||
|
||||
Keep explicit A-share exchange annotations (SH/SZ/BJ) intact to avoid collapsing
|
||||
different exchange variants of the same 6-digit core code.
|
||||
"""
|
||||
raw = canonical_stock_code(symbol)
|
||||
if not raw:
|
||||
return ""
|
||||
|
||||
if len(raw) >= 8 and raw[:2] in {"SH", "SZ", "BJ"} and raw[2:].isdigit():
|
||||
return raw
|
||||
|
||||
if "." in raw:
|
||||
base, suffix = raw.rsplit(".", 1)
|
||||
if base.isdigit() and suffix in {"SH", "SS", "SZ", "BJ"}:
|
||||
exchange = "SH" if suffix == "SS" else suffix
|
||||
return f"{exchange}{base}"
|
||||
|
||||
return canonical_stock_code(normalize_stock_code(symbol))
|
||||
|
||||
@classmethod
|
||||
def _build_symbol_filter_values(cls, symbol: str) -> List[str]:
|
||||
original = (symbol or "").strip().upper()
|
||||
normalized = cls._normalize_symbol(original)
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
seen: Set[str] = set()
|
||||
values: List[str] = []
|
||||
|
||||
def _add(value: Optional[str]) -> None:
|
||||
candidate = (value or "").strip().upper()
|
||||
if candidate and candidate not in seen:
|
||||
seen.add(candidate)
|
||||
values.append(candidate)
|
||||
|
||||
_add(original)
|
||||
_add(normalized)
|
||||
|
||||
if normalized.startswith("HK"):
|
||||
hk_digits = normalized[2:]
|
||||
if hk_digits.isdigit() and len(hk_digits) == 5:
|
||||
legacy_hk_digits = str(int(hk_digits))
|
||||
_add(f"HK{hk_digits}")
|
||||
_add(f"HK{legacy_hk_digits}")
|
||||
_add(f"{hk_digits}.HK")
|
||||
_add(f"{legacy_hk_digits}.HK")
|
||||
return values
|
||||
|
||||
explicit_exchange: Optional[str] = None
|
||||
if len(original) >= 8 and original[:2] in {"SH", "SZ", "BJ"} and original[2:].isdigit():
|
||||
explicit_exchange = original[:2]
|
||||
explicit_code = original[2:]
|
||||
elif "." in original:
|
||||
base, suffix = original.rsplit(".", 1)
|
||||
if base.isdigit() and suffix in {"SH", "SS", "SZ", "BJ"}:
|
||||
explicit_exchange = "SH" if suffix == "SS" else suffix
|
||||
explicit_code = base
|
||||
else:
|
||||
explicit_code = None
|
||||
else:
|
||||
explicit_code = None
|
||||
|
||||
if normalized.isdigit():
|
||||
if len(normalized) == 6:
|
||||
exchanges = [explicit_exchange] if explicit_exchange else ["SH", "SZ", "BJ"]
|
||||
for exchange in exchanges:
|
||||
if exchange is None:
|
||||
continue
|
||||
_add(f"{exchange}{normalized}")
|
||||
_add(f"{normalized}.{'SS' if exchange == 'SH' else exchange}")
|
||||
if exchange == "SH":
|
||||
_add(f"{normalized}.SH")
|
||||
return values
|
||||
|
||||
if explicit_exchange is not None and explicit_code is not None and explicit_code.isdigit():
|
||||
if len(explicit_code) == 6:
|
||||
_add(f"{explicit_exchange}{explicit_code}")
|
||||
_add(f"{explicit_code}.{'SS' if explicit_exchange == 'SH' else explicit_exchange}")
|
||||
if explicit_exchange == "SH":
|
||||
_add(f"{explicit_code}.SH")
|
||||
elif len(normalized) == 5:
|
||||
_add(f"HK{normalized}")
|
||||
_add(f"{normalized}.HK")
|
||||
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
def _consume_fifo_lots(
|
||||
lots: List[Dict[str, Any]],
|
||||
|
||||
@@ -298,27 +298,35 @@ class SystemConfigService:
|
||||
)
|
||||
errors = [issue for issue in validation_issues if issue["severity"] == "error"]
|
||||
if errors:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "LLM channel configuration is invalid",
|
||||
"error": errors[0]["message"],
|
||||
"resolved_protocol": resolved_protocol or None,
|
||||
"models": [],
|
||||
"latency_ms": None,
|
||||
}
|
||||
return self._build_llm_channel_result(
|
||||
success=False,
|
||||
message="LLM channel configuration is invalid",
|
||||
error=errors[0]["message"],
|
||||
stage="model_discovery",
|
||||
error_code="invalid_config",
|
||||
retryable=False,
|
||||
details={"issue_key": errors[0]["key"], "issue_code": errors[0]["code"]},
|
||||
resolved_protocol=resolved_protocol or None,
|
||||
models=[],
|
||||
latency_ms=None,
|
||||
)
|
||||
|
||||
if resolved_protocol not in {"openai", "deepseek"}:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Model discovery is not supported for this protocol",
|
||||
"error": (
|
||||
return self._build_llm_channel_result(
|
||||
success=False,
|
||||
message="Model discovery is not supported for this protocol",
|
||||
error=(
|
||||
f"LLM channel '{channel_name}' protocol '{resolved_protocol}' "
|
||||
"does not support /models discovery yet"
|
||||
),
|
||||
"resolved_protocol": resolved_protocol or None,
|
||||
"models": [],
|
||||
"latency_ms": None,
|
||||
}
|
||||
stage="model_discovery",
|
||||
error_code="unsupported_protocol",
|
||||
retryable=False,
|
||||
details={"protocol": resolved_protocol or None},
|
||||
resolved_protocol=resolved_protocol or None,
|
||||
models=[],
|
||||
latency_ms=None,
|
||||
)
|
||||
|
||||
api_keys = [segment.strip() for segment in api_key.split(",") if segment.strip()]
|
||||
selected_api_key = api_keys[0] if api_keys else ""
|
||||
@@ -339,66 +347,96 @@ class SystemConfigService:
|
||||
latency_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
except requests.RequestException as exc:
|
||||
logger.warning("LLM channel model discovery failed for %s: %s", channel_name, exc)
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to discover models",
|
||||
"error": str(exc),
|
||||
"resolved_protocol": resolved_protocol or None,
|
||||
"models": [],
|
||||
"latency_ms": None,
|
||||
}
|
||||
error_code, retryable, message = self._classify_llm_exception(exc)
|
||||
return self._build_llm_channel_result(
|
||||
success=False,
|
||||
message=message,
|
||||
error=str(exc),
|
||||
stage="model_discovery",
|
||||
error_code=error_code,
|
||||
retryable=retryable,
|
||||
details={"endpoint": models_url},
|
||||
resolved_protocol=resolved_protocol or None,
|
||||
models=[],
|
||||
latency_ms=None,
|
||||
)
|
||||
|
||||
if 300 <= response.status_code < 400:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Model discovery request was redirected",
|
||||
"error": "Redirect responses are not allowed for model discovery",
|
||||
"resolved_protocol": resolved_protocol or None,
|
||||
"models": [],
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
return self._build_llm_channel_result(
|
||||
success=False,
|
||||
message="Model discovery request was redirected",
|
||||
error="Redirect responses are not allowed for model discovery",
|
||||
stage="model_discovery",
|
||||
error_code="network_error",
|
||||
retryable=False,
|
||||
details={"endpoint": models_url, "http_status": response.status_code},
|
||||
resolved_protocol=resolved_protocol or None,
|
||||
models=[],
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Model discovery request failed",
|
||||
"error": self._extract_llm_discovery_error(response),
|
||||
"resolved_protocol": resolved_protocol or None,
|
||||
"models": [],
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
error_text = self._extract_llm_discovery_error(response)
|
||||
error_code, retryable, message = self._classify_llm_http_error(
|
||||
status_code=response.status_code,
|
||||
error_text=error_text,
|
||||
)
|
||||
return self._build_llm_channel_result(
|
||||
success=False,
|
||||
message=message,
|
||||
error=error_text,
|
||||
stage="model_discovery",
|
||||
error_code=error_code,
|
||||
retryable=retryable,
|
||||
details={"endpoint": models_url, "http_status": response.status_code},
|
||||
resolved_protocol=resolved_protocol or None,
|
||||
models=[],
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Model discovery returned invalid JSON",
|
||||
"error": "The /models endpoint did not return valid JSON",
|
||||
"resolved_protocol": resolved_protocol or None,
|
||||
"models": [],
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
return self._build_llm_channel_result(
|
||||
success=False,
|
||||
message="Model discovery returned invalid JSON",
|
||||
error="The /models endpoint did not return valid JSON",
|
||||
stage="response_parse",
|
||||
error_code="format_error",
|
||||
retryable=False,
|
||||
details={"endpoint": models_url, "http_status": response.status_code},
|
||||
resolved_protocol=resolved_protocol or None,
|
||||
models=[],
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
models = self._extract_discovered_llm_models(payload)
|
||||
if not models:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Model discovery returned no models",
|
||||
"error": "The /models endpoint did not return any model IDs",
|
||||
"resolved_protocol": resolved_protocol or None,
|
||||
"models": [],
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
return self._build_llm_channel_result(
|
||||
success=False,
|
||||
message="Model discovery returned no models",
|
||||
error="The /models endpoint did not return any model IDs",
|
||||
stage="response_parse",
|
||||
error_code="empty_response",
|
||||
retryable=False,
|
||||
details={"endpoint": models_url, "http_status": response.status_code},
|
||||
resolved_protocol=resolved_protocol or None,
|
||||
models=[],
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "LLM channel model discovery succeeded",
|
||||
"error": None,
|
||||
"resolved_protocol": resolved_protocol or None,
|
||||
"models": models,
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
return self._build_llm_channel_result(
|
||||
success=True,
|
||||
message="LLM channel model discovery succeeded",
|
||||
error=None,
|
||||
stage="model_discovery",
|
||||
error_code=None,
|
||||
retryable=False,
|
||||
details={"endpoint": models_url, "model_count": len(models)},
|
||||
resolved_protocol=resolved_protocol or None,
|
||||
models=models,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
def test_llm_channel(
|
||||
self,
|
||||
@@ -426,14 +464,18 @@ class SystemConfigService:
|
||||
)
|
||||
errors = [issue for issue in validation_issues if issue["severity"] == "error"]
|
||||
if errors:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "LLM channel configuration is invalid",
|
||||
"error": errors[0]["message"],
|
||||
"resolved_protocol": None,
|
||||
"resolved_model": None,
|
||||
"latency_ms": None,
|
||||
}
|
||||
return self._build_llm_channel_result(
|
||||
success=False,
|
||||
message="LLM channel configuration is invalid",
|
||||
error=errors[0]["message"],
|
||||
stage="chat_completion",
|
||||
error_code="invalid_config",
|
||||
retryable=False,
|
||||
details={"issue_key": errors[0]["key"], "issue_code": errors[0]["code"]},
|
||||
resolved_protocol=None,
|
||||
resolved_model=None,
|
||||
latency_ms=None,
|
||||
)
|
||||
|
||||
resolved_protocol = resolve_llm_channel_protocol(protocol, base_url=base_url, models=raw_models, channel_name=name)
|
||||
resolved_models = [normalize_llm_channel_model(model, resolved_protocol, base_url) for model in raw_models]
|
||||
@@ -468,63 +510,53 @@ class SystemConfigService:
|
||||
started_at = time.perf_counter()
|
||||
response = litellm.completion(**call_kwargs)
|
||||
latency_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
content = ""
|
||||
if response and getattr(response, "choices", None):
|
||||
choice = response.choices[0]
|
||||
# MiniMax-M2.7 uses content_blocks format directly on choice (not inside message)
|
||||
# Check both possible locations for content_blocks
|
||||
content_blocks = None
|
||||
if hasattr(choice, "content_blocks"):
|
||||
content_blocks = choice.content_blocks
|
||||
elif hasattr(choice.message, "content_blocks"):
|
||||
content_blocks = choice.message.content_blocks
|
||||
content, parse_error_code, parse_error = self._extract_llm_completion_content(response)
|
||||
if parse_error_code:
|
||||
message = (
|
||||
"LLM channel returned an empty response"
|
||||
if parse_error_code == "empty_response"
|
||||
else "LLM channel returned an unexpected response format"
|
||||
)
|
||||
return self._build_llm_channel_result(
|
||||
success=False,
|
||||
message=message,
|
||||
error=parse_error,
|
||||
stage="response_parse",
|
||||
error_code=parse_error_code,
|
||||
retryable=False,
|
||||
details={"response_error": parse_error},
|
||||
resolved_protocol=resolved_protocol or None,
|
||||
resolved_model=resolved_model,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
if content_blocks:
|
||||
# MiniMax response format: concatenate ALL text blocks
|
||||
# Handle both type=="text" with .text and .content fields
|
||||
text_parts = []
|
||||
for block in content_blocks:
|
||||
if getattr(block, "type", None) == "text":
|
||||
text = getattr(block, "text", "") or ""
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
elif hasattr(block, "content") and block.content:
|
||||
text_parts.append(block.content)
|
||||
content = "".join(text_parts).strip()
|
||||
else:
|
||||
# Standard OpenAI format
|
||||
message = getattr(choice, "message", None)
|
||||
if message:
|
||||
content = str(message.content or "").strip()
|
||||
|
||||
if not content:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "LLM channel returned an empty response",
|
||||
"error": "Empty response",
|
||||
"resolved_protocol": resolved_protocol or None,
|
||||
"resolved_model": resolved_model,
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "LLM channel test succeeded",
|
||||
"error": None,
|
||||
"resolved_protocol": resolved_protocol or None,
|
||||
"resolved_model": resolved_model,
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
return self._build_llm_channel_result(
|
||||
success=True,
|
||||
message="LLM channel test succeeded",
|
||||
error=None,
|
||||
stage="chat_completion",
|
||||
error_code=None,
|
||||
retryable=False,
|
||||
details={"response_preview": content[:80]},
|
||||
resolved_protocol=resolved_protocol or None,
|
||||
resolved_model=resolved_model,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("LLM channel test failed for %s: %s", channel_name, exc)
|
||||
return {
|
||||
"success": False,
|
||||
"message": "LLM channel test failed",
|
||||
"error": str(exc),
|
||||
"resolved_protocol": resolved_protocol or None,
|
||||
"resolved_model": resolved_model,
|
||||
"latency_ms": None,
|
||||
}
|
||||
error_code, retryable, message = self._classify_llm_exception(exc)
|
||||
return self._build_llm_channel_result(
|
||||
success=False,
|
||||
message=message,
|
||||
error=str(exc),
|
||||
stage="chat_completion",
|
||||
error_code=error_code,
|
||||
retryable=retryable,
|
||||
details={"model": resolved_model},
|
||||
resolved_protocol=resolved_protocol or None,
|
||||
resolved_model=resolved_model,
|
||||
latency_ms=None,
|
||||
)
|
||||
|
||||
def update(
|
||||
self,
|
||||
@@ -543,6 +575,7 @@ class SystemConfigService:
|
||||
if errors:
|
||||
raise ConfigValidationError(issues=errors)
|
||||
|
||||
previous_map = self._manager.read_config_map()
|
||||
submitted_keys: Set[str] = set()
|
||||
updates: List[Tuple[str, str]] = []
|
||||
sensitive_keys: Set[str] = set()
|
||||
@@ -582,6 +615,12 @@ class SystemConfigService:
|
||||
reload_now=reload_now,
|
||||
)
|
||||
)
|
||||
warnings.extend(
|
||||
self._build_runtime_model_cleanup_warnings(
|
||||
previous_map=previous_map,
|
||||
updates=dict(updates),
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -675,6 +714,53 @@ class SystemConfigService:
|
||||
|
||||
return warnings
|
||||
|
||||
@staticmethod
|
||||
def _build_runtime_model_cleanup_warnings(
|
||||
*,
|
||||
previous_map: Dict[str, str],
|
||||
updates: Dict[str, str],
|
||||
) -> List[str]:
|
||||
"""Explain when save payload clears stale runtime model references."""
|
||||
runtime_labels = {
|
||||
"LITELLM_MODEL": "主模型",
|
||||
"AGENT_LITELLM_MODEL": "Agent 主模型",
|
||||
"VISION_MODEL": "Vision 模型",
|
||||
}
|
||||
cleared_labels: List[str] = []
|
||||
for key, label in runtime_labels.items():
|
||||
if previous_map.get(key, "").strip() and key in updates and not updates[key].strip():
|
||||
cleared_labels.append(label)
|
||||
|
||||
removed_fallbacks: List[str] = []
|
||||
if "LITELLM_FALLBACK_MODELS" in updates:
|
||||
previous_fallbacks = [
|
||||
item.strip()
|
||||
for item in previous_map.get("LITELLM_FALLBACK_MODELS", "").split(",")
|
||||
if item.strip()
|
||||
]
|
||||
next_fallbacks = {
|
||||
item.strip()
|
||||
for item in updates["LITELLM_FALLBACK_MODELS"].split(",")
|
||||
if item.strip()
|
||||
}
|
||||
removed_fallbacks = [item for item in previous_fallbacks if item not in next_fallbacks]
|
||||
|
||||
if not cleared_labels and not removed_fallbacks:
|
||||
return []
|
||||
|
||||
cleaned_targets = list(cleared_labels)
|
||||
if removed_fallbacks:
|
||||
cleaned_targets.append("备选模型中的失效项")
|
||||
|
||||
cleaned_text = " / ".join(cleaned_targets)
|
||||
warning = (
|
||||
f"检测到已同步清理失效的运行时模型引用:{cleaned_text}。"
|
||||
"如需恢复,请先补回对应渠道模型列表后重新选择;"
|
||||
"也可用桌面端导出备份或手动 .env 还原之前的 LLM_* / "
|
||||
"LITELLM_MODEL / AGENT_LITELLM_MODEL / VISION_MODEL / LLM_TEMPERATURE。"
|
||||
)
|
||||
return [warning]
|
||||
|
||||
def apply_simple_updates(
|
||||
self,
|
||||
updates: Sequence[Tuple[str, str]],
|
||||
@@ -1405,6 +1491,170 @@ class SystemConfigService:
|
||||
except (TypeError, ValueError):
|
||||
return 0.7
|
||||
|
||||
@classmethod
|
||||
def _build_llm_channel_result(
|
||||
cls,
|
||||
*,
|
||||
success: bool,
|
||||
message: str,
|
||||
error: Optional[str],
|
||||
stage: Optional[str],
|
||||
error_code: Optional[str],
|
||||
retryable: Optional[bool],
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
resolved_protocol: Optional[str] = None,
|
||||
resolved_model: Optional[str] = None,
|
||||
models: Optional[List[str]] = None,
|
||||
latency_ms: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"success": success,
|
||||
"message": cls._sanitize_llm_error_text(message),
|
||||
"error": cls._sanitize_llm_error_text(error) if error else None,
|
||||
"stage": stage,
|
||||
"error_code": error_code,
|
||||
"retryable": retryable,
|
||||
"details": cls._sanitize_llm_details(details),
|
||||
"resolved_protocol": resolved_protocol,
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
if resolved_model is not None or models is None:
|
||||
payload["resolved_model"] = resolved_model
|
||||
if models is not None:
|
||||
payload["models"] = models
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_llm_error_text(text: Any) -> str:
|
||||
if text is None:
|
||||
return ""
|
||||
sanitized = str(text).strip()
|
||||
if not sanitized:
|
||||
return ""
|
||||
|
||||
patterns = [
|
||||
(r"(?i)(authorization\s*[:=]\s*)(bearer\s+)?([^\s,;]+)", r"\1[REDACTED]"),
|
||||
(r"(?i)(api[_-]?key\s*[:=]\s*)([^\s,;]+)", r"\1[REDACTED]"),
|
||||
(r"(?i)(cookie\s*[:=]\s*)([^\s,;]+)", r"\1[REDACTED]"),
|
||||
(r"(?i)bearer\s+[a-z0-9._\-]+", "Bearer [REDACTED]"),
|
||||
(r"(?i)sk-[a-z0-9_\-]+", "[REDACTED]"),
|
||||
]
|
||||
for pattern, replacement in patterns:
|
||||
sanitized = re.sub(pattern, replacement, sanitized)
|
||||
sanitized = " ".join(sanitized.split())
|
||||
return sanitized[:300]
|
||||
|
||||
@classmethod
|
||||
def _sanitize_llm_details(cls, details: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if not details:
|
||||
return {}
|
||||
sanitized: Dict[str, Any] = {}
|
||||
for key, value in details.items():
|
||||
if isinstance(value, str):
|
||||
sanitized[key] = cls._sanitize_llm_error_text(value)
|
||||
elif isinstance(value, dict):
|
||||
sanitized[key] = cls._sanitize_llm_details(value)
|
||||
elif isinstance(value, list):
|
||||
sanitized[key] = [
|
||||
cls._sanitize_llm_error_text(item) if isinstance(item, str) else item
|
||||
for item in value
|
||||
]
|
||||
else:
|
||||
sanitized[key] = value
|
||||
return sanitized
|
||||
|
||||
@staticmethod
|
||||
def _classify_llm_http_error(status_code: int, error_text: str) -> Tuple[str, bool, str]:
|
||||
lowered = (error_text or "").lower()
|
||||
if status_code in {401, 403} or any(token in lowered for token in ("unauthorized", "forbidden", "invalid api key", "authentication")):
|
||||
return "auth", False, "LLM authentication failed"
|
||||
if status_code in {402, 429} or any(token in lowered for token in ("quota", "rate limit", "too many requests", "insufficient_quota")):
|
||||
return "quota", True, "LLM request was rejected by quota or rate limiting"
|
||||
if status_code == 404:
|
||||
return "network_error", False, "LLM model discovery endpoint could not be found"
|
||||
if "model" in lowered and any(token in lowered for token in ("not found", "does not exist", "unknown")):
|
||||
return "model_not_found", False, "Configured model could not be found on this channel"
|
||||
if any(token in lowered for token in ("timeout", "timed out")):
|
||||
return "timeout", True, "LLM request timed out"
|
||||
return "network_error", status_code >= 500, "LLM request failed before a valid response was returned"
|
||||
|
||||
@staticmethod
|
||||
def _has_model_not_found_signal(text: str) -> bool:
|
||||
lowered = text.lower()
|
||||
|
||||
model_candidates = [
|
||||
re.search(r"model\s+not\s+found\s*[::]?\s*[`\"']?\s*([a-z0-9._/-]{2,})", lowered),
|
||||
re.search(r"model\s*[`\"']?\s*([a-z0-9._/-]{2,})\s*[`\"']?\s+does\s+not\s+exist", lowered),
|
||||
re.search(r"model\s+does\s+not\s+exist\s*[::]?\s*[`\"']?\s*([a-z0-9._/-]{2,})", lowered),
|
||||
re.search(r"unknown\s+model\s*[::]?\s*[`\"']?\s*([a-z0-9._/-]{2,})", lowered),
|
||||
re.search(r"no\s+such\s+model\s*[::]?\s*[`\"']?\s*([a-z0-9._/-]{2,})", lowered),
|
||||
]
|
||||
|
||||
for match in model_candidates:
|
||||
if not match:
|
||||
continue
|
||||
model_id = match.group(1).strip()
|
||||
if model_id and not model_id.startswith("/") and "http" not in model_id:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _classify_llm_exception(exc: Exception) -> Tuple[str, bool, str]:
|
||||
exc_name = type(exc).__name__.lower()
|
||||
text = str(exc).lower()
|
||||
if isinstance(exc, TimeoutError) or "timeout" in exc_name or "timed out" in text:
|
||||
return "timeout", True, "LLM request timed out"
|
||||
if "ratelimit" in exc_name or any(token in text for token in ("quota", "rate limit", "too many requests", "insufficient_quota")):
|
||||
return "quota", True, "LLM request was rejected by quota or rate limiting"
|
||||
if any(token in exc_name for token in ("auth", "permission")) or any(token in text for token in ("unauthorized", "forbidden", "invalid api key", "authentication")):
|
||||
return "auth", False, "LLM authentication failed"
|
||||
if ("notfound" in exc_name or "model" in text) and (
|
||||
"not found" in text or "does not exist" in text or "unknown model" in text
|
||||
) and SystemConfigService._has_model_not_found_signal(text):
|
||||
return "model_not_found", False, "Configured model could not be found on this channel"
|
||||
if any(token in exc_name for token in ("connection", "network")) or any(token in text for token in ("connection", "network", "dns", "refused", "ssl")):
|
||||
return "network_error", True, "LLM request failed before a valid response was returned"
|
||||
return "network_error", False, "LLM channel test failed"
|
||||
|
||||
@staticmethod
|
||||
def _extract_llm_completion_content(response: Any) -> Tuple[str, Optional[str], Optional[str]]:
|
||||
if response is None:
|
||||
return "", "empty_response", "Completion returned no response object"
|
||||
|
||||
choices = getattr(response, "choices", None)
|
||||
if not choices:
|
||||
return "", "format_error", "Completion response did not include choices"
|
||||
|
||||
choice = choices[0]
|
||||
content_blocks = getattr(choice, "content_blocks", None)
|
||||
if content_blocks is None:
|
||||
message = getattr(choice, "message", None)
|
||||
if message is not None:
|
||||
content_blocks = getattr(message, "content_blocks", None)
|
||||
message = getattr(choice, "message", None)
|
||||
if content_blocks is not None:
|
||||
text_parts: List[str] = []
|
||||
for block in content_blocks:
|
||||
if getattr(block, "type", None) == "text":
|
||||
text = getattr(block, "text", "") or ""
|
||||
if text:
|
||||
text_parts.append(str(text))
|
||||
elif hasattr(block, "content") and block.content:
|
||||
text_parts.append(str(block.content))
|
||||
content = "".join(text_parts).strip()
|
||||
if content:
|
||||
return content, None, None
|
||||
|
||||
if message is None:
|
||||
return "", "format_error", "Completion response did not include a message object"
|
||||
if not hasattr(message, "content"):
|
||||
return "", "format_error", "Completion message did not include a content field"
|
||||
content = str(message.content or "").strip()
|
||||
if not content:
|
||||
return "", "empty_response", "Completion returned an empty message content"
|
||||
return content, None, None
|
||||
|
||||
@staticmethod
|
||||
def _extract_llm_discovery_error(response: requests.Response) -> str:
|
||||
"""Extract a concise error message from a failed model discovery response."""
|
||||
|
||||
@@ -10,6 +10,8 @@ import threading
|
||||
import unittest
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
from unittest.mock import patch
|
||||
|
||||
import pandas as pd
|
||||
@@ -18,7 +20,7 @@ from sqlalchemy import select
|
||||
|
||||
from src.config import Config
|
||||
from src.repositories.portfolio_repo import PortfolioBusyError, PortfolioRepository
|
||||
from src.services.portfolio_service import PortfolioConflictError, PortfolioOversellError, PortfolioService
|
||||
from src.services.portfolio_service import _AvgState, PortfolioConflictError, PortfolioOversellError, PortfolioService
|
||||
from src.storage import DatabaseManager, PortfolioDailySnapshot, PortfolioPosition, PortfolioPositionLot, PortfolioTrade
|
||||
|
||||
|
||||
@@ -74,6 +76,40 @@ class PortfolioServiceTestCase(unittest.TestCase):
|
||||
)
|
||||
self.db.save_daily_data(df, code=symbol, data_source="unit-test")
|
||||
|
||||
def _create_account_with_position(
|
||||
self,
|
||||
*,
|
||||
market: str,
|
||||
currency: str,
|
||||
symbol: str,
|
||||
quantity: float = 10.0,
|
||||
price: float = 100.0,
|
||||
close: Optional[float] = None,
|
||||
close_date: Optional[date] = None,
|
||||
) -> int:
|
||||
account = self.service.create_account(name=f"{market}-account", broker="Demo", market=market, base_currency=currency)
|
||||
aid = account["id"]
|
||||
self.service.record_cash_ledger(
|
||||
account_id=aid,
|
||||
event_date=date(2026, 1, 1),
|
||||
direction="in",
|
||||
amount=100000,
|
||||
currency=currency,
|
||||
)
|
||||
self.service.record_trade(
|
||||
account_id=aid,
|
||||
symbol=symbol,
|
||||
trade_date=date(2026, 1, 2),
|
||||
side="buy",
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
market=market,
|
||||
currency=currency,
|
||||
)
|
||||
if close is not None:
|
||||
self._save_close(self.service._normalize_symbol(symbol), close_date or date(2026, 1, 3), close)
|
||||
return aid
|
||||
|
||||
def test_current_snapshot_uses_realtime_price_when_close_missing(self) -> None:
|
||||
today = date.today()
|
||||
account = self.service.create_account(name="Main", broker="Demo", market="cn", base_currency="CNY")
|
||||
@@ -231,10 +267,217 @@ class PortfolioServiceTestCase(unittest.TestCase):
|
||||
self.assertAlmostEqual(fifo_acc["positions"][0]["quantity"], 50.0, places=6)
|
||||
self.assertAlmostEqual(avg_acc["positions"][0]["quantity"], 50.0, places=6)
|
||||
|
||||
def test_snapshot_position_price_metadata_uses_backend_values_for_cn_hk_us(self) -> None:
|
||||
for market, currency, symbol, close, expected_symbol in [
|
||||
("cn", "CNY", "600519", 12.5, "600519"),
|
||||
("hk", "HKD", "hk700", 420.0, "HK00700"),
|
||||
("us", "USD", "aapl", 210.0, "AAPL"),
|
||||
]:
|
||||
with self.subTest(market=market):
|
||||
aid = self._create_account_with_position(market=market, currency=currency, symbol=symbol, close=close)
|
||||
position = self.service.get_portfolio_snapshot(account_id=aid, as_of=date(2026, 1, 3), cost_method="fifo")["accounts"][0]["positions"][0]
|
||||
|
||||
self.assertEqual(position["symbol"], expected_symbol)
|
||||
self.assertEqual(position["price_source"], "history_close")
|
||||
self.assertEqual(position["price_date"], "2026-01-03")
|
||||
self.assertFalse(position["price_stale"])
|
||||
self.assertTrue(position["price_available"])
|
||||
self.assertAlmostEqual(position["last_price"], close, places=6)
|
||||
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)
|
||||
|
||||
def test_snapshot_marks_stale_close_and_missing_price(self) -> None:
|
||||
aid = self._create_account_with_position(
|
||||
market="cn",
|
||||
currency="CNY",
|
||||
symbol="600519",
|
||||
close=110,
|
||||
close_date=date(2026, 1, 2),
|
||||
)
|
||||
self.service.record_trade(
|
||||
account_id=aid,
|
||||
symbol="000001",
|
||||
trade_date=date(2026, 1, 2),
|
||||
side="buy",
|
||||
quantity=5,
|
||||
price=20,
|
||||
market="cn",
|
||||
currency="CNY",
|
||||
)
|
||||
self._save_close("600519", date(2026, 1, 2), 110)
|
||||
|
||||
snapshot = self.service.get_portfolio_snapshot(account_id=aid, as_of=date(2026, 1, 3), cost_method="fifo")
|
||||
positions = {item["symbol"]: item for item in snapshot["accounts"][0]["positions"]}
|
||||
|
||||
stale_close = positions["600519"]
|
||||
self.assertEqual(stale_close["price_source"], "history_close")
|
||||
self.assertEqual(stale_close["price_date"], "2026-01-02")
|
||||
self.assertTrue(stale_close["price_stale"])
|
||||
self.assertTrue(stale_close["price_available"])
|
||||
self.assertAlmostEqual(stale_close["last_price"], 110.0, places=6)
|
||||
self.assertAlmostEqual(stale_close["unrealized_pnl_pct"], 10.0, places=6)
|
||||
|
||||
missing = positions["000001"]
|
||||
self.assertEqual(missing["price_source"], "missing")
|
||||
self.assertIsNone(missing["price_date"])
|
||||
self.assertTrue(missing["price_stale"])
|
||||
self.assertFalse(missing["price_available"])
|
||||
self.assertAlmostEqual(missing["last_price"], 0.0, places=6)
|
||||
self.assertAlmostEqual(missing["market_value_base"], 0.0, places=6)
|
||||
self.assertAlmostEqual(missing["unrealized_pnl_base"], 0.0, places=6)
|
||||
self.assertIsNone(missing["unrealized_pnl_pct"])
|
||||
|
||||
def test_build_positions_handles_zero_cost_without_division(self) -> None:
|
||||
account = SimpleNamespace(base_currency="CNY")
|
||||
|
||||
positions, _, _, _, _ = self.service._build_positions(
|
||||
account=account,
|
||||
as_of_date=date(2026, 1, 3),
|
||||
cost_method="avg",
|
||||
fifo_lots={},
|
||||
avg_state={("AAPL", "us", "USD"): _AvgState(quantity=10.0, total_cost=0.0)},
|
||||
)
|
||||
|
||||
self.assertEqual(len(positions), 1)
|
||||
self.assertEqual(positions[0]["price_source"], "missing")
|
||||
self.assertIsNone(positions[0]["unrealized_pnl_pct"])
|
||||
self.assertAlmostEqual(positions[0]["last_price"], 0.0, places=6)
|
||||
|
||||
def test_symbol_filter_matches_legacy_prefix_suffix_variants(self) -> None:
|
||||
account = self.service.create_account(name="Legacy", broker="Demo", market="cn", base_currency="CNY")
|
||||
aid = account["id"]
|
||||
for symbol in ["600519", "SH600519", "600519.SH", "600519.SS"]:
|
||||
self.service.repo.add_trade(
|
||||
account_id=aid,
|
||||
trade_uid=None,
|
||||
symbol=symbol,
|
||||
market="cn",
|
||||
currency="CNY",
|
||||
trade_date=date(2026, 1, 2),
|
||||
side="buy",
|
||||
quantity=1,
|
||||
price=10,
|
||||
fee=0,
|
||||
tax=0,
|
||||
)
|
||||
|
||||
rows = self.service.list_trade_events(account_id=aid, symbol="600519", page=1, page_size=20)["items"]
|
||||
self.assertEqual({row["symbol"] for row in rows}, {"600519", "SH600519", "600519.SH", "600519.SS"})
|
||||
|
||||
def test_symbol_filter_matches_legacy_hk_variants(self) -> None:
|
||||
account = self.service.create_account(name="Legacy HK", broker="Demo", market="hk", base_currency="HKD")
|
||||
aid = account["id"]
|
||||
for symbol in ["HK00700", "HK700", "00700.HK", "700.HK"]:
|
||||
self.service.repo.add_trade(
|
||||
account_id=aid,
|
||||
trade_uid=None,
|
||||
symbol=symbol,
|
||||
market="hk",
|
||||
currency="HKD",
|
||||
trade_date=date(2026, 1, 2),
|
||||
side="buy",
|
||||
quantity=1,
|
||||
price=10,
|
||||
fee=0,
|
||||
tax=0,
|
||||
)
|
||||
|
||||
rows = self.service.list_trade_events(account_id=aid, symbol="HK00700", page=1, page_size=20)["items"]
|
||||
self.assertEqual({row["symbol"] for row in rows}, {"HK00700", "HK700", "00700.HK", "700.HK"})
|
||||
|
||||
def test_explicit_exchange_symbol_filter_does_not_match_other_exchanges(self) -> None:
|
||||
account = self.service.create_account(name="Mixed", broker="Demo", market="cn", base_currency="CNY")
|
||||
aid = account["id"]
|
||||
for symbol in ["SH000001", "SZ000001", "000001.SH", "000001.SZ"]:
|
||||
self.service.repo.add_trade(
|
||||
account_id=aid,
|
||||
trade_uid=None,
|
||||
symbol=symbol,
|
||||
market="cn",
|
||||
currency="CNY",
|
||||
trade_date=date(2026, 1, 2),
|
||||
side="buy",
|
||||
quantity=1,
|
||||
price=10,
|
||||
fee=0,
|
||||
tax=0,
|
||||
)
|
||||
|
||||
rows = self.service.list_trade_events(account_id=aid, symbol="SH000001", page=1, page_size=20)["items"]
|
||||
self.assertEqual({row["symbol"] for row in rows}, {"SH000001", "000001.SH"})
|
||||
|
||||
def test_explicit_exchange_symbols_are_preserved_in_position_snapshot_and_validation(self) -> None:
|
||||
account = self.service.create_account(name="Explicit", broker="Demo", market="cn", base_currency="CNY")
|
||||
aid = account["id"]
|
||||
self.service.record_cash_ledger(
|
||||
account_id=aid,
|
||||
event_date=date(2026, 1, 1),
|
||||
direction="in",
|
||||
amount=10000,
|
||||
currency="CNY",
|
||||
)
|
||||
self.service.record_trade(
|
||||
account_id=aid,
|
||||
symbol="SH000001",
|
||||
trade_date=date(2026, 1, 2),
|
||||
side="buy",
|
||||
quantity=1,
|
||||
price=10,
|
||||
currency="CNY",
|
||||
market="cn",
|
||||
)
|
||||
self.service.record_trade(
|
||||
account_id=aid,
|
||||
symbol="000001.SZ",
|
||||
trade_date=date(2026, 1, 2),
|
||||
side="buy",
|
||||
quantity=1,
|
||||
price=10,
|
||||
currency="CNY",
|
||||
market="cn",
|
||||
)
|
||||
self.service.record_trade(
|
||||
account_id=aid,
|
||||
symbol="BJ920748",
|
||||
trade_date=date(2026, 1, 2),
|
||||
side="buy",
|
||||
quantity=1,
|
||||
price=10,
|
||||
currency="CNY",
|
||||
market="cn",
|
||||
)
|
||||
|
||||
sh_trades = self.service.list_trade_events(account_id=aid, symbol="SH000001", page=1, page_size=20)["items"]
|
||||
sz_trades = self.service.list_trade_events(account_id=aid, symbol="000001.SZ", page=1, page_size=20)["items"]
|
||||
bj_trades = self.service.list_trade_events(account_id=aid, symbol="BJ920748", page=1, page_size=20)["items"]
|
||||
self.assertEqual(sh_trades[0]["symbol"], "SH000001")
|
||||
self.assertEqual(sz_trades[0]["symbol"], "000001.SZ")
|
||||
self.assertEqual(bj_trades[0]["symbol"], "BJ920748")
|
||||
|
||||
snapshot = self.service.get_portfolio_snapshot(
|
||||
account_id=aid,
|
||||
as_of=date(2026, 1, 4),
|
||||
cost_method="fifo",
|
||||
)
|
||||
symbols = {item["symbol"] for item in snapshot["accounts"][0]["positions"]}
|
||||
self.assertEqual(symbols, {"SH000001", "SZ000001", "BJ920748"})
|
||||
|
||||
with self.assertRaises(PortfolioOversellError):
|
||||
self.service.record_trade(
|
||||
account_id=aid,
|
||||
symbol="SZ000001",
|
||||
trade_date=date(2026, 1, 5),
|
||||
side="sell",
|
||||
quantity=2,
|
||||
price=10,
|
||||
market="cn",
|
||||
currency="CNY",
|
||||
)
|
||||
|
||||
def test_corporate_actions_dividend_and_split(self) -> None:
|
||||
account = self.service.create_account(name="Main", broker="Demo", market="cn", base_currency="CNY")
|
||||
aid = account["id"]
|
||||
|
||||
self.service.record_cash_ledger(
|
||||
account_id=aid,
|
||||
event_date=date(2026, 1, 1),
|
||||
@@ -284,6 +527,53 @@ class PortfolioServiceTestCase(unittest.TestCase):
|
||||
self.assertAlmostEqual(pos["quantity"], 200.0, places=6)
|
||||
self.assertAlmostEqual(pos["avg_cost"], 5.0, places=6)
|
||||
|
||||
def test_normalize_symbol_preserves_cn_exchange_prefix_and_suffix(self) -> None:
|
||||
self.assertEqual(self.service._normalize_symbol("sh600519"), "SH600519")
|
||||
self.assertEqual(self.service._normalize_symbol("600519.SH"), "SH600519")
|
||||
self.assertEqual(self.service._normalize_symbol("SZ000001"), "SZ000001")
|
||||
self.assertEqual(self.service._normalize_symbol("000001.SZ"), "SZ000001")
|
||||
|
||||
def test_explicit_exchange_position_valuation_uses_exchange_qualified_symbol(self) -> None:
|
||||
account = self.service.create_account(name="Explicit Valuation", broker="Demo", market="cn", base_currency="CNY")
|
||||
aid = account["id"]
|
||||
self.service.record_cash_ledger(
|
||||
account_id=aid,
|
||||
event_date=date(2026, 1, 1),
|
||||
direction="in",
|
||||
amount=20000,
|
||||
currency="CNY",
|
||||
)
|
||||
self.service.record_trade(
|
||||
account_id=aid,
|
||||
symbol="SH600519",
|
||||
trade_date=date(2026, 1, 2),
|
||||
side="buy",
|
||||
quantity=1,
|
||||
price=10,
|
||||
currency="CNY",
|
||||
market="cn",
|
||||
)
|
||||
self.service.record_trade(
|
||||
account_id=aid,
|
||||
symbol="000001.SZ",
|
||||
trade_date=date(2026, 1, 2),
|
||||
side="buy",
|
||||
quantity=2,
|
||||
price=8,
|
||||
currency="CNY",
|
||||
market="cn",
|
||||
)
|
||||
self._save_close(self.service._normalize_symbol("SH600519"), date(2026, 1, 3), 12.0)
|
||||
self._save_close(self.service._normalize_symbol("000001.SZ"), date(2026, 1, 3), 9.0)
|
||||
|
||||
snapshot = self.service.get_portfolio_snapshot(account_id=aid, as_of=date(2026, 1, 3), cost_method="fifo")
|
||||
positions = {item["symbol"]: item for item in snapshot["accounts"][0]["positions"]}
|
||||
self.assertEqual(set(positions), {"SH600519", "SZ000001"})
|
||||
self.assertEqual(positions["SH600519"]["price_source"], "history_close")
|
||||
self.assertAlmostEqual(positions["SH600519"]["last_price"], 12.0, places=6)
|
||||
self.assertEqual(positions["SZ000001"]["price_source"], "history_close")
|
||||
self.assertAlmostEqual(positions["SZ000001"]["last_price"], 9.0, places=6)
|
||||
|
||||
def test_same_day_dividend_processed_before_trade(self) -> None:
|
||||
account = self.service.create_account(name="Main", broker="Demo", market="cn", base_currency="CNY")
|
||||
aid = account["id"]
|
||||
@@ -653,6 +943,155 @@ class PortfolioServiceTestCase(unittest.TestCase):
|
||||
self.assertIsInstance(errors[0], PortfolioConflictError)
|
||||
self.assertIn("Duplicate trade_uid", str(errors[0]))
|
||||
|
||||
def test_event_symbol_filters_match_legacy_prefixed_symbols(self) -> None:
|
||||
account = self.service.create_account(name="Main", broker="Demo", market="cn", base_currency="CNY")
|
||||
aid = account["id"]
|
||||
|
||||
self.service.repo.add_trade(
|
||||
account_id=aid,
|
||||
trade_uid="legacy-prefixed-trade",
|
||||
symbol="SH600519",
|
||||
market="cn",
|
||||
currency="CNY",
|
||||
trade_date=date(2026, 1, 2),
|
||||
side="buy",
|
||||
quantity=10,
|
||||
price=100,
|
||||
fee=0,
|
||||
tax=0,
|
||||
)
|
||||
self.service.repo.add_corporate_action(
|
||||
account_id=aid,
|
||||
symbol="SH600519",
|
||||
market="cn",
|
||||
currency="CNY",
|
||||
effective_date=date(2026, 1, 3),
|
||||
action_type="cash_dividend",
|
||||
cash_dividend_per_share=1.0,
|
||||
)
|
||||
|
||||
trades = self.service.list_trade_events(account_id=aid, symbol="600519", page=1, page_size=20)
|
||||
actions = self.service.list_corporate_action_events(account_id=aid, symbol="600519", page=1, page_size=20)
|
||||
|
||||
self.assertEqual(trades["total"], 1)
|
||||
self.assertEqual(actions["total"], 1)
|
||||
self.assertEqual(trades["items"][0]["symbol"], "SH600519")
|
||||
self.assertEqual(actions["items"][0]["symbol"], "SH600519")
|
||||
|
||||
def test_event_symbol_filters_match_legacy_suffix_symbols(self) -> None:
|
||||
account = self.service.create_account(name="Main", broker="Demo", market="cn", base_currency="CNY")
|
||||
aid = account["id"]
|
||||
|
||||
self.service.repo.add_trade(
|
||||
account_id=aid,
|
||||
trade_uid="legacy-suffix-trade",
|
||||
symbol="600519.SH",
|
||||
market="cn",
|
||||
currency="CNY",
|
||||
trade_date=date(2026, 1, 2),
|
||||
side="buy",
|
||||
quantity=10,
|
||||
price=100,
|
||||
fee=0,
|
||||
tax=0,
|
||||
)
|
||||
self.service.repo.add_corporate_action(
|
||||
account_id=aid,
|
||||
symbol="600519.SH",
|
||||
market="cn",
|
||||
currency="CNY",
|
||||
effective_date=date(2026, 1, 3),
|
||||
action_type="cash_dividend",
|
||||
cash_dividend_per_share=1.0,
|
||||
)
|
||||
|
||||
trades = self.service.list_trade_events(account_id=aid, symbol="600519", page=1, page_size=20)
|
||||
actions = self.service.list_corporate_action_events(account_id=aid, symbol="600519", page=1, page_size=20)
|
||||
|
||||
self.assertEqual(trades["total"], 1)
|
||||
self.assertEqual(actions["total"], 1)
|
||||
self.assertEqual(trades["items"][0]["symbol"], "600519.SH")
|
||||
self.assertEqual(actions["items"][0]["symbol"], "600519.SH")
|
||||
|
||||
def test_event_symbol_filters_match_legacy_hk_variants(self) -> None:
|
||||
account = self.service.create_account(name="Main", broker="Demo", market="hk", base_currency="HKD")
|
||||
aid = account["id"]
|
||||
|
||||
self.service.repo.add_trade(
|
||||
account_id=aid,
|
||||
trade_uid="legacy-hk-prefixed-trade",
|
||||
symbol="HK700",
|
||||
market="hk",
|
||||
currency="HKD",
|
||||
trade_date=date(2026, 1, 2),
|
||||
side="buy",
|
||||
quantity=10,
|
||||
price=400,
|
||||
fee=0,
|
||||
tax=0,
|
||||
)
|
||||
self.service.repo.add_trade(
|
||||
account_id=aid,
|
||||
trade_uid="legacy-hk-suffix-trade",
|
||||
symbol="00700.HK",
|
||||
market="hk",
|
||||
currency="HKD",
|
||||
trade_date=date(2026, 1, 3),
|
||||
side="buy",
|
||||
quantity=5,
|
||||
price=410,
|
||||
fee=0,
|
||||
tax=0,
|
||||
)
|
||||
self.service.repo.add_trade(
|
||||
account_id=aid,
|
||||
trade_uid="legacy-hk-short-suffix-trade",
|
||||
symbol="700.HK",
|
||||
market="hk",
|
||||
currency="HKD",
|
||||
trade_date=date(2026, 1, 4),
|
||||
side="buy",
|
||||
quantity=3,
|
||||
price=415,
|
||||
fee=0,
|
||||
tax=0,
|
||||
)
|
||||
self.service.repo.add_corporate_action(
|
||||
account_id=aid,
|
||||
symbol="HK700",
|
||||
market="hk",
|
||||
currency="HKD",
|
||||
effective_date=date(2026, 1, 4),
|
||||
action_type="cash_dividend",
|
||||
cash_dividend_per_share=1.0,
|
||||
)
|
||||
self.service.repo.add_corporate_action(
|
||||
account_id=aid,
|
||||
symbol="00700.HK",
|
||||
market="hk",
|
||||
currency="HKD",
|
||||
effective_date=date(2026, 1, 5),
|
||||
action_type="cash_dividend",
|
||||
cash_dividend_per_share=1.5,
|
||||
)
|
||||
self.service.repo.add_corporate_action(
|
||||
account_id=aid,
|
||||
symbol="700.HK",
|
||||
market="hk",
|
||||
currency="HKD",
|
||||
effective_date=date(2026, 1, 6),
|
||||
action_type="cash_dividend",
|
||||
cash_dividend_per_share=2.0,
|
||||
)
|
||||
|
||||
trades = self.service.list_trade_events(account_id=aid, symbol="HK00700", page=1, page_size=20)
|
||||
actions = self.service.list_corporate_action_events(account_id=aid, symbol="HK00700", page=1, page_size=20)
|
||||
|
||||
self.assertEqual(trades["total"], 3)
|
||||
self.assertEqual(actions["total"], 3)
|
||||
self.assertEqual({item["symbol"] for item in trades["items"]}, {"HK700", "00700.HK", "700.HK"})
|
||||
self.assertEqual({item["symbol"] for item in actions["items"]}, {"HK700", "00700.HK", "700.HK"})
|
||||
|
||||
def test_portfolio_write_session_maps_sqlite_locked_error(self) -> None:
|
||||
repo = PortfolioRepository(db_manager=self.db)
|
||||
session = self.db.get_session()
|
||||
|
||||
@@ -294,6 +294,10 @@ class SystemConfigApiTestCase(unittest.TestCase):
|
||||
"success": True,
|
||||
"message": "LLM channel test succeeded",
|
||||
"error": None,
|
||||
"error_code": None,
|
||||
"stage": "chat_completion",
|
||||
"retryable": False,
|
||||
"details": {},
|
||||
"resolved_protocol": "openai",
|
||||
"resolved_model": "openai/gpt-4o-mini",
|
||||
"latency_ms": 123,
|
||||
@@ -312,6 +316,7 @@ class SystemConfigApiTestCase(unittest.TestCase):
|
||||
|
||||
self.assertTrue(payload["success"])
|
||||
self.assertEqual(payload["resolved_model"], "openai/gpt-4o-mini")
|
||||
self.assertEqual(payload["stage"], "chat_completion")
|
||||
mock_test.assert_called_once()
|
||||
|
||||
def test_validate_returns_user_facing_model_message_without_internal_env_key_name(self) -> None:
|
||||
@@ -339,6 +344,10 @@ class SystemConfigApiTestCase(unittest.TestCase):
|
||||
"success": True,
|
||||
"message": "LLM channel model discovery succeeded",
|
||||
"error": None,
|
||||
"error_code": None,
|
||||
"stage": "model_discovery",
|
||||
"retryable": False,
|
||||
"details": {"model_count": 2},
|
||||
"resolved_protocol": "openai",
|
||||
"models": ["qwen-plus", "qwen-turbo"],
|
||||
"latency_ms": 88,
|
||||
@@ -356,6 +365,7 @@ class SystemConfigApiTestCase(unittest.TestCase):
|
||||
|
||||
self.assertTrue(payload["success"])
|
||||
self.assertEqual(payload["models"], ["qwen-plus", "qwen-turbo"])
|
||||
self.assertEqual(payload["stage"], "model_discovery")
|
||||
mock_discover.assert_called_once()
|
||||
|
||||
|
||||
|
||||
@@ -716,6 +716,39 @@ class SystemConfigServiceTestCase(unittest.TestCase):
|
||||
self.assertEqual(payload["resolved_protocol"], "openai")
|
||||
self.assertEqual(payload["resolved_model"], "openai/deepseek-chat")
|
||||
|
||||
@patch("litellm.completion")
|
||||
def test_test_llm_channel_falls_back_to_message_content_when_content_blocks_empty(
|
||||
self,
|
||||
mock_completion,
|
||||
) -> None:
|
||||
mock_completion.return_value = type(
|
||||
"MockResponse",
|
||||
(),
|
||||
{
|
||||
"choices": [
|
||||
type(
|
||||
"Choice",
|
||||
(),
|
||||
{
|
||||
"content_blocks": [],
|
||||
"message": type("Message", (), {"content": "OK"})(),
|
||||
},
|
||||
)(),
|
||||
]
|
||||
},
|
||||
)()
|
||||
|
||||
payload = self.service.test_llm_channel(
|
||||
name="primary",
|
||||
protocol="openai",
|
||||
base_url="https://api.deepseek.com/v1",
|
||||
api_key="sk-test-value",
|
||||
models=["deepseek-chat"],
|
||||
)
|
||||
|
||||
self.assertTrue(payload["success"])
|
||||
self.assertEqual(payload["resolved_model"], "openai/deepseek-chat")
|
||||
|
||||
@patch("litellm.completion")
|
||||
def test_test_llm_channel_allows_ollama_prefix_without_explicit_protocol(self, mock_completion) -> None:
|
||||
mock_completion.return_value = type(
|
||||
@@ -828,6 +861,50 @@ class SystemConfigServiceTestCase(unittest.TestCase):
|
||||
self.assertEqual(payload["resolved_model"], "openai/gpt-4o-mini")
|
||||
self.assertEqual(mock_completion.call_args.kwargs["temperature"], 0.42)
|
||||
|
||||
@patch("litellm.completion")
|
||||
def test_test_llm_channel_classifies_common_failure_scenarios(self, mock_completion) -> None:
|
||||
cases = [
|
||||
(PermissionError("401 Unauthorized Bearer sk-secret-value"), "auth", "chat_completion", False),
|
||||
(TimeoutError("request timed out"), "timeout", "chat_completion", True),
|
||||
(Exception("404 model not found: gpt-4o-mini"), "model_not_found", "chat_completion", False),
|
||||
(Exception("The model `gpt-4o-mini` does not exist"), "model_not_found", "chat_completion", False),
|
||||
(Exception("404 Not Found: page not found"), "network_error", "chat_completion", False),
|
||||
(
|
||||
type("MockResponse", (), {"choices": [type("Choice", (), {"message": type("Message", (), {"content": ""})()})()]})(),
|
||||
"empty_response",
|
||||
"response_parse",
|
||||
False,
|
||||
),
|
||||
(object(), "format_error", "response_parse", False),
|
||||
]
|
||||
|
||||
for response_or_exc, error_code, stage, retryable in cases:
|
||||
with self.subTest(error_code=error_code):
|
||||
mock_completion.reset_mock()
|
||||
if isinstance(response_or_exc, Exception):
|
||||
mock_completion.side_effect = response_or_exc
|
||||
mock_completion.return_value = None
|
||||
else:
|
||||
mock_completion.side_effect = None
|
||||
mock_completion.return_value = response_or_exc
|
||||
|
||||
payload = self.service.test_llm_channel(
|
||||
name="primary",
|
||||
protocol="openai",
|
||||
base_url="https://api.example.com/v1",
|
||||
api_key="sk-secret-value",
|
||||
models=["gpt-4o-mini"],
|
||||
)
|
||||
|
||||
self.assertFalse(payload["success"])
|
||||
self.assertEqual(payload["error_code"], error_code)
|
||||
self.assertEqual(payload["stage"], stage)
|
||||
self.assertEqual(payload["retryable"], retryable)
|
||||
if error_code == "auth":
|
||||
self.assertNotIn("sk-secret-value", payload["error"])
|
||||
if error_code == "format_error":
|
||||
self.assertIn("choices", payload["error"])
|
||||
|
||||
@patch("src.services.system_config_service.requests.get")
|
||||
def test_discover_llm_channel_models_returns_deduped_ids(self, mock_get) -> None:
|
||||
mock_response = Mock()
|
||||
@@ -863,6 +940,36 @@ class SystemConfigServiceTestCase(unittest.TestCase):
|
||||
)
|
||||
self.assertFalse(mock_get.call_args.kwargs["allow_redirects"])
|
||||
|
||||
@patch("src.services.system_config_service.requests.get")
|
||||
def test_discover_llm_channel_models_classifies_error_scenarios(self, mock_get) -> None:
|
||||
auth_response = Mock(ok=False, status_code=401, text="invalid api key sk-secret-value")
|
||||
auth_response.json.return_value = {"error": {"message": "invalid api key sk-secret-value"}}
|
||||
not_found_response = Mock(ok=False, status_code=404, text="not found")
|
||||
not_found_response.json.return_value = {"error": {"message": "not found"}}
|
||||
invalid_json_response = Mock(ok=True, status_code=200, text="<html>bad gateway</html>")
|
||||
invalid_json_response.json.side_effect = ValueError("invalid json")
|
||||
|
||||
for response, error_code, stage, retryable in [
|
||||
(auth_response, "auth", "model_discovery", False),
|
||||
(not_found_response, "network_error", "model_discovery", False),
|
||||
(invalid_json_response, "format_error", "response_parse", False),
|
||||
]:
|
||||
with self.subTest(error_code=error_code):
|
||||
mock_get.return_value = response
|
||||
payload = self.service.discover_llm_channel_models(
|
||||
name="dashscope",
|
||||
protocol="openai",
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
api_key="sk-secret-value",
|
||||
)
|
||||
|
||||
self.assertFalse(payload["success"])
|
||||
self.assertEqual(payload["error_code"], error_code)
|
||||
self.assertEqual(payload["stage"], stage)
|
||||
self.assertEqual(payload["retryable"], retryable)
|
||||
if error_code == "auth":
|
||||
self.assertNotIn("sk-secret-value", payload["error"])
|
||||
|
||||
@patch("src.services.system_config_service.requests.get")
|
||||
def test_discover_llm_channel_models_rejects_redirect_responses(self, mock_get) -> None:
|
||||
mock_response = Mock()
|
||||
@@ -1027,6 +1134,90 @@ class SystemConfigServiceTestCase(unittest.TestCase):
|
||||
self.assertIn("以 schedule 模式重新启动后生效", schedule_warning)
|
||||
self.assertNotIn("它属于启动期单次运行配置", schedule_warning)
|
||||
|
||||
def test_update_warns_when_runtime_model_references_are_cleared(self) -> None:
|
||||
self._rewrite_env(
|
||||
"STOCK_LIST=600519,000001",
|
||||
"LLM_CHANNELS=deepseek",
|
||||
"LLM_DEEPSEEK_PROTOCOL=deepseek",
|
||||
"LLM_DEEPSEEK_BASE_URL=https://api.deepseek.com",
|
||||
"LLM_DEEPSEEK_API_KEY=sk-test-value",
|
||||
"LLM_DEEPSEEK_MODELS=deepseek-chat,deepseek-v4-flash,deepseek-v4-pro",
|
||||
"LITELLM_MODEL=deepseek/deepseek-chat",
|
||||
"AGENT_LITELLM_MODEL=deepseek/deepseek-v4-pro",
|
||||
"LITELLM_FALLBACK_MODELS=deepseek/deepseek-v4-pro,deepseek/deepseek-chat,cohere/command-r-plus",
|
||||
"VISION_MODEL=deepseek/deepseek-v4-flash",
|
||||
)
|
||||
|
||||
response = self.service.update(
|
||||
config_version=self.manager.get_config_version(),
|
||||
items=[
|
||||
{"key": "LLM_DEEPSEEK_MODELS", "value": "deepseek-v4-flash,deepseek-v4-pro"},
|
||||
{"key": "LITELLM_MODEL", "value": ""},
|
||||
{"key": "AGENT_LITELLM_MODEL", "value": ""},
|
||||
{"key": "LITELLM_FALLBACK_MODELS", "value": "deepseek/deepseek-v4-pro,cohere/command-r-plus"},
|
||||
{"key": "VISION_MODEL", "value": ""},
|
||||
],
|
||||
reload_now=False,
|
||||
)
|
||||
|
||||
self.assertTrue(response["success"])
|
||||
warning = next(
|
||||
warning
|
||||
for warning in response["warnings"]
|
||||
if "已同步清理失效的运行时模型引用" in warning
|
||||
)
|
||||
self.assertIn("主模型 / Agent 主模型 / Vision 模型 / 备选模型中的失效项", warning)
|
||||
self.assertIn("桌面端导出备份", warning)
|
||||
|
||||
def test_import_desktop_env_restores_runtime_models_after_cleanup(self) -> None:
|
||||
self._rewrite_env(
|
||||
"STOCK_LIST=600519,000001",
|
||||
"LLM_CHANNELS=deepseek",
|
||||
"LLM_DEEPSEEK_PROTOCOL=deepseek",
|
||||
"LLM_DEEPSEEK_BASE_URL=https://api.deepseek.com",
|
||||
"LLM_DEEPSEEK_API_KEY=sk-test-value",
|
||||
"LLM_DEEPSEEK_MODELS=deepseek-chat,deepseek-v4-flash,deepseek-v4-pro",
|
||||
"LITELLM_MODEL=deepseek/deepseek-chat",
|
||||
"AGENT_LITELLM_MODEL=deepseek/deepseek-v4-pro",
|
||||
"LITELLM_FALLBACK_MODELS=deepseek/deepseek-v4-pro,deepseek/deepseek-chat,cohere/command-r-plus",
|
||||
"VISION_MODEL=deepseek/deepseek-v4-flash",
|
||||
)
|
||||
|
||||
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": "LLM_DEEPSEEK_MODELS", "value": "deepseek-v4-flash"},
|
||||
{"key": "LITELLM_MODEL", "value": ""},
|
||||
{"key": "AGENT_LITELLM_MODEL", "value": ""},
|
||||
{"key": "LITELLM_FALLBACK_MODELS", "value": "deepseek/deepseek-v4-flash"},
|
||||
{"key": "VISION_MODEL", "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["AGENT_LITELLM_MODEL"], "")
|
||||
self.assertEqual(cleared_map["VISION_MODEL"], "")
|
||||
self.assertEqual(cleared_map["LITELLM_FALLBACK_MODELS"], "deepseek/deepseek-v4-flash")
|
||||
|
||||
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["AGENT_LITELLM_MODEL"], pre_clear_map["AGENT_LITELLM_MODEL"])
|
||||
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_validate_rejects_comma_only_api_key(self) -> None:
|
||||
"""Whitespace/comma-only api_key must fail validation (P2: parsed-segment check)."""
|
||||
|
||||
@@ -90,6 +90,12 @@ class TaskQueueConfigSyncTestCase(unittest.TestCase):
|
||||
self.assertIs(first, second)
|
||||
self.assertEqual(second.max_workers, 1)
|
||||
|
||||
def test_get_task_queue_supports_string_max_workers(self) -> None:
|
||||
with patch("src.config.get_config", return_value=SimpleNamespace(max_workers="2")):
|
||||
queue = get_task_queue()
|
||||
|
||||
self.assertEqual(queue.max_workers, 2)
|
||||
|
||||
def test_dedupe_stock_code_key_normalizes_market_suffix(self) -> None:
|
||||
self.assertEqual(_dedupe_stock_code_key(" 600519.sh "), "600519")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user