fix: 修复 task_queue、持仓估值与 LLM 诊断 (#1124) (#1146)

* fix(issue-1124): [bug]-修复-task_queue-测试、持仓盈亏漂移与-llm-连通性诊断
This commit is contained in:
mumu
2026-05-01 13:30:16 +08:00
committed by GitHub
parent 020fbf8ea7
commit 7711e9bf5f
18 changed files with 1619 additions and 170 deletions
+1
View File
@@ -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
+8
View File
@@ -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) => {
+18
View File
@@ -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',
+1
View File
@@ -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;
+8
View File
@@ -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;
+3
View File
@@ -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
+36
View File
@@ -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`,需要同步修改部署配置。
+26
View File
@@ -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`
+6 -6
View File
@@ -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)
+134 -18
View File
@@ -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]],
+376 -126
View File
@@ -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."""
+441 -2
View File
@@ -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()
+10
View File
@@ -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()
+191
View File
@@ -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)."""
+6
View File
@@ -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")