mirror of
https://github.com/ZhuLinsen/daily_stock_analysis.git
synced 2026-10-06 14:33:11 +08:00
feat: field decision signal profiles (#1967)
This commit is contained in:
@@ -33,6 +33,7 @@ from src.services.decision_signal_service import (
|
||||
)
|
||||
from src.services.decision_signal_outcome_service import DecisionSignalOutcomeService
|
||||
from src.services.decision_signal_reassess_service import (
|
||||
UNSUPPORTED_PERSIST_MESSAGE,
|
||||
DecisionSignalReassessService,
|
||||
DecisionSignalReassessUnsupportedOperationError,
|
||||
DecisionSignalSourceReportNotFoundError,
|
||||
@@ -143,6 +144,10 @@ def list_signals(
|
||||
stock_code: Optional[str] = Query(None, description="Optional stock code filter"),
|
||||
action: Optional[str] = Query(None, description="Optional decision action filter"),
|
||||
market_phase: Optional[str] = Query(None, description="Optional market phase filter"),
|
||||
decision_profile: Optional[str] = Query(
|
||||
None,
|
||||
description="Optional decision profile filter: conservative/balanced/aggressive/unknown",
|
||||
),
|
||||
source_type: Optional[str] = Query(None, description="Optional source type filter"),
|
||||
source_report_id: Optional[int] = Query(None, description="Optional source report id filter"),
|
||||
trace_id: Optional[str] = Query(None, description="Optional trace id filter"),
|
||||
@@ -168,6 +173,7 @@ def list_signals(
|
||||
stock_code=stock_code,
|
||||
action=action,
|
||||
market_phase=market_phase,
|
||||
decision_profile=decision_profile,
|
||||
source_type=source_type,
|
||||
source_report_id=source_report_id,
|
||||
trace_id=trace_id,
|
||||
@@ -328,10 +334,7 @@ def reassess_signal(request: DecisionSignalReassessRequest) -> DecisionSignalRea
|
||||
if request.persist:
|
||||
raise _error(
|
||||
400,
|
||||
DecisionSignalReassessUnsupportedOperationError(
|
||||
"Persisting reassessed decision_profile signals requires decision_profile "
|
||||
"to be promoted to a first-class field."
|
||||
),
|
||||
DecisionSignalReassessUnsupportedOperationError(UNSUPPORTED_PERSIST_MESSAGE),
|
||||
error="unsupported_operation",
|
||||
)
|
||||
|
||||
@@ -508,7 +511,8 @@ def put_feedback(signal_id: int, request: DecisionSignalFeedbackRequest) -> Deci
|
||||
},
|
||||
summary="更新决策信号状态",
|
||||
description=(
|
||||
"只更新合法状态和可选 metadata;传入 metadata 时按整包替换保存。"
|
||||
"只更新合法状态和可选 metadata;省略 metadata 时保留原值,null 时清空,"
|
||||
"object 时按整包替换并保持正式 decision_profile 身份。"
|
||||
"expired/invalidated/closed/archived 等 terminal 状态不能直接 PATCH 回 active。"
|
||||
),
|
||||
operation_id="updateDecisionSignalStatus",
|
||||
|
||||
@@ -110,6 +110,7 @@ from api.v1.schemas.alerts import (
|
||||
AlertTriggerListResponse,
|
||||
)
|
||||
from api.v1.schemas.decision_signals import (
|
||||
DecisionProfile,
|
||||
DecisionSignalCreateRequest,
|
||||
DecisionSignalFeedbackItem,
|
||||
DecisionSignalFeedbackRequest,
|
||||
@@ -221,6 +222,7 @@ __all__ = [
|
||||
"AlertTriggerItem",
|
||||
"AlertTriggerListResponse",
|
||||
# decision signals
|
||||
"DecisionProfile",
|
||||
"DecisionSignalCreateRequest",
|
||||
"DecisionSignalFeedbackItem",
|
||||
"DecisionSignalFeedbackRequest",
|
||||
|
||||
@@ -10,6 +10,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from api.v1.schemas.market_phase import MarketPhaseValue
|
||||
from src.schemas.decision_action import DecisionAction
|
||||
from src.schemas.decision_profile import DecisionProfile
|
||||
|
||||
|
||||
DecisionSignalSourceType = Literal["analysis", "agent", "alert", "market_review", "manual"]
|
||||
@@ -21,7 +22,6 @@ DecisionSignalOutcomeStatus = Literal["completed", "unable"]
|
||||
DecisionSignalOutcomeValue = Literal["hit", "miss", "neutral"]
|
||||
DecisionSignalFeedbackValue = Literal["useful", "not_useful"]
|
||||
DecisionSignalFeedbackSource = Literal["web", "api"]
|
||||
DecisionProfile = Literal["conservative", "balanced", "aggressive"]
|
||||
|
||||
|
||||
class DecisionSignalCreateRequest(BaseModel):
|
||||
@@ -32,6 +32,10 @@ class DecisionSignalCreateRequest(BaseModel):
|
||||
source_agent: Optional[str] = Field(None, json_schema_extra={"maxLength": 64})
|
||||
source_report_id: Optional[int] = None
|
||||
trace_id: Optional[str] = Field(None, json_schema_extra={"maxLength": 64})
|
||||
decision_profile: DecisionProfile = Field(
|
||||
default=None,
|
||||
description="Optional decision profile. Omit to use server-side default/fallback; explicit null is rejected.",
|
||||
)
|
||||
market_phase: Optional[MarketPhaseValue] = None
|
||||
trigger_source: str = Field(..., min_length=1, json_schema_extra={"maxLength": 64})
|
||||
action: DecisionAction
|
||||
@@ -53,7 +57,10 @@ class DecisionSignalCreateRequest(BaseModel):
|
||||
plan_quality: Optional[DecisionSignalPlanQuality] = None
|
||||
status: Optional[DecisionSignalStatus] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
metadata: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="Optional metadata object. Omitted or null values are treated as absent.",
|
||||
)
|
||||
report_language: Optional[Literal["zh", "en", "ko"]] = None
|
||||
|
||||
|
||||
@@ -98,7 +105,14 @@ class DecisionSignalPreview(BaseModel):
|
||||
|
||||
class DecisionSignalStatusUpdateRequest(BaseModel):
|
||||
status: DecisionSignalStatus
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
metadata: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optional replacement metadata. Omit to preserve the stored value; "
|
||||
"null clears it; an object replaces it while preserving the formal "
|
||||
"decision_profile identity."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DecisionSignalOutcomeRunRequest(BaseModel):
|
||||
@@ -214,6 +228,7 @@ class DecisionSignalItem(BaseModel):
|
||||
source_agent: Optional[str] = None
|
||||
source_report_id: Optional[int] = None
|
||||
trace_id: Optional[str] = None
|
||||
decision_profile: Optional[DecisionProfile] = None
|
||||
market_phase: Optional[str] = None
|
||||
trigger_source: str
|
||||
action: str
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('decisionSignalsApi', () => {
|
||||
source_agent: null,
|
||||
source_report_id: 3001,
|
||||
trace_id: 'trace-3001',
|
||||
decision_profile: 'aggressive',
|
||||
market_phase: 'intraday',
|
||||
trigger_source: 'api',
|
||||
action: 'watch',
|
||||
@@ -73,6 +74,7 @@ describe('decisionSignalsApi', () => {
|
||||
sourceType: 'analysis',
|
||||
sourceReportId: 3001,
|
||||
traceId: 'trace-3001',
|
||||
decisionProfile: 'aggressive',
|
||||
marketPhase: 'intraday',
|
||||
triggerSource: 'api',
|
||||
action: 'watch',
|
||||
@@ -105,6 +107,7 @@ describe('decisionSignalsApi', () => {
|
||||
source_type: 'analysis',
|
||||
source_report_id: 3001,
|
||||
trace_id: 'trace-3001',
|
||||
decision_profile: 'aggressive',
|
||||
market_phase: 'intraday',
|
||||
trigger_source: 'api',
|
||||
action: 'watch',
|
||||
@@ -132,12 +135,50 @@ describe('decisionSignalsApi', () => {
|
||||
expect(response.created).toBe(false);
|
||||
expect(response.item.id).toBe(11);
|
||||
expect(response.item.sourceReportId).toBe(3001);
|
||||
expect(response.item.decisionProfile).toBe('aggressive');
|
||||
expect(response.item.entryLow).toBe(1680);
|
||||
expect(response.item.evidence).toEqual({ source_url: 'https://example.com/news' });
|
||||
expect(response.item.dataQualitySummary).toEqual({ raw_score: 80, level: 'usable' });
|
||||
expect(response.item.metadata).toEqual({ task_id: 'task-1' });
|
||||
});
|
||||
|
||||
it('preserves explicit null metadata when creating a signal', async () => {
|
||||
post.mockResolvedValueOnce({
|
||||
data: {
|
||||
item: {
|
||||
id: 12,
|
||||
stock_code: 'AAPL',
|
||||
market: 'us',
|
||||
source_type: 'manual',
|
||||
trigger_source: 'web',
|
||||
action: 'watch',
|
||||
plan_quality: 'unknown',
|
||||
status: 'active',
|
||||
metadata: null,
|
||||
},
|
||||
created: true,
|
||||
},
|
||||
});
|
||||
|
||||
await decisionSignalsApi.create({
|
||||
stockCode: 'AAPL',
|
||||
market: 'us',
|
||||
sourceType: 'manual',
|
||||
triggerSource: 'web',
|
||||
action: 'watch',
|
||||
metadata: null,
|
||||
});
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/api/v1/decision-signals', {
|
||||
stock_code: 'AAPL',
|
||||
market: 'us',
|
||||
source_type: 'manual',
|
||||
trigger_source: 'web',
|
||||
action: 'watch',
|
||||
metadata: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('lists signals with snake_case query params', async () => {
|
||||
get.mockResolvedValueOnce({
|
||||
data: {
|
||||
@@ -164,6 +205,7 @@ describe('decisionSignalsApi', () => {
|
||||
stockCode: '00700',
|
||||
action: 'hold',
|
||||
marketPhase: 'postmarket',
|
||||
decisionProfile: 'unknown',
|
||||
sourceType: 'manual',
|
||||
sourceReportId: 99,
|
||||
traceId: 'trace-99',
|
||||
@@ -185,6 +227,7 @@ describe('decisionSignalsApi', () => {
|
||||
stock_code: '00700',
|
||||
action: 'hold',
|
||||
market_phase: 'postmarket',
|
||||
decision_profile: 'unknown',
|
||||
source_type: 'manual',
|
||||
source_report_id: 99,
|
||||
trace_id: 'trace-99',
|
||||
|
||||
@@ -132,6 +132,7 @@ function toSnakeCreatePayload(payload: DecisionSignalCreateRequest): Record<stri
|
||||
source_agent: payload.sourceAgent,
|
||||
source_report_id: payload.sourceReportId,
|
||||
trace_id: payload.traceId,
|
||||
decision_profile: payload.decisionProfile,
|
||||
market_phase: payload.marketPhase,
|
||||
trigger_source: payload.triggerSource,
|
||||
action: payload.action,
|
||||
@@ -186,6 +187,7 @@ function toListParams(params: DecisionSignalListParams = {}): Record<string, str
|
||||
stock_code: params.stockCode,
|
||||
action: params.action,
|
||||
market_phase: params.marketPhase,
|
||||
decision_profile: params.decisionProfile,
|
||||
source_type: params.sourceType,
|
||||
source_report_id: params.sourceReportId,
|
||||
trace_id: params.traceId,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../../utils/decisionAction';
|
||||
import { cn } from '../../utils/cn';
|
||||
import { parseDecisionSignalDate } from '../../utils/decisionSignalTime';
|
||||
import { getDecisionSignalProfileLabel } from '../../utils/decisionSignalProfile';
|
||||
import {
|
||||
getDecisionSignalHorizonLabel,
|
||||
getDecisionSignalMarketLabel,
|
||||
@@ -209,6 +210,7 @@ type DecisionSignalCardProps = {
|
||||
export const DecisionSignalCard: React.FC<DecisionSignalCardProps> = ({ item, onSelect, selected = false }) => {
|
||||
const { language, t } = useUiLanguage();
|
||||
const actionLabel = getActionLabel(item, t);
|
||||
const profileLabel = getDecisionSignalProfileLabel(item, t);
|
||||
const interactive = Boolean(onSelect);
|
||||
const entryRange = formatEntryRange(item);
|
||||
const pricePlanItems = [
|
||||
@@ -228,6 +230,7 @@ export const DecisionSignalCard: React.FC<DecisionSignalCardProps> = ({ item, on
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={getActionVariant(item)}>{actionLabel}</Badge>
|
||||
<Badge variant={STATUS_VARIANTS[item.status]}>{t(STATUS_LABEL_KEYS[item.status])}</Badge>
|
||||
<Badge variant="info">{t('decisionSignals.profile')}: {profileLabel}</Badge>
|
||||
<span className="font-mono text-sm text-secondary-text">{item.stockCode}</span>
|
||||
</div>
|
||||
<h3 className="mt-2 text-base font-semibold text-foreground">
|
||||
@@ -339,6 +342,7 @@ export const DecisionSignalDetails: React.FC<DecisionSignalDetailsProps> = ({
|
||||
}) => {
|
||||
const { language, t } = useUiLanguage();
|
||||
const actionLabel = getActionLabel(item, t);
|
||||
const profileLabel = getDecisionSignalProfileLabel(item, t);
|
||||
const entryRange = formatEntryRange(item);
|
||||
const evidenceData = asJsonViewerData(item.evidence);
|
||||
const qualityData = asJsonViewerData(item.dataQualitySummary);
|
||||
@@ -351,6 +355,7 @@ export const DecisionSignalDetails: React.FC<DecisionSignalDetailsProps> = ({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={getActionVariant(item)} size="md">{actionLabel}</Badge>
|
||||
<Badge variant={STATUS_VARIANTS[item.status]} size="md">{t(STATUS_LABEL_KEYS[item.status])}</Badge>
|
||||
<Badge variant="info" size="md">{t('decisionSignals.profile')}: {profileLabel}</Badge>
|
||||
</div>
|
||||
<h3 className="mt-3 text-xl font-semibold text-foreground">{item.stockName || item.stockCode}</h3>
|
||||
<p className="mt-1 font-mono text-sm text-secondary-text">{item.stockCode} · {getDecisionSignalMarketLabel(item.market, t)}</p>
|
||||
@@ -362,6 +367,7 @@ export const DecisionSignalDetails: React.FC<DecisionSignalDetailsProps> = ({
|
||||
<DetailRow label={t('decisionSignals.score')} value={formatNumber(item.score)} />
|
||||
<DetailRow label={t('decisionSignals.confidence')} value={formatConfidence(item.confidence)} />
|
||||
<DetailRow label={t('decisionSignals.horizon')} value={getDecisionSignalHorizonLabel(item.horizon, t)} />
|
||||
<DetailRow label={t('decisionSignals.profile')} value={profileLabel} />
|
||||
<DetailRow label={t('decisionSignals.planQuality')} value={getDecisionSignalPlanQualityLabel(item.planQuality, t)} />
|
||||
<DetailRow label={t('decisionSignals.marketPhase')} value={getDecisionSignalMarketPhaseLabel(item.marketPhase, t)} />
|
||||
<DetailRow label={t('decisionSignals.sourceReport')} value={item.sourceReportId ? `#${item.sourceReportId}` : '-'} />
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useUiLanguage } from '../../contexts/UiLanguageContext';
|
||||
import type { UiLanguage, UiTextKey } from '../../i18n/uiText';
|
||||
import type { DecisionSignalItem, DecisionSignalStatus } from '../../types/decisionSignals';
|
||||
import { buildDecisionActionLabelMap, getDecisionActionLabel } from '../../utils/decisionAction';
|
||||
import { getDecisionProfile } from '../../utils/decisionSignalProfile';
|
||||
import { getDecisionSignalProfileLabel } from '../../utils/decisionSignalProfile';
|
||||
import {
|
||||
getDecisionSignalHorizonLabel,
|
||||
} from '../../utils/decisionSignalLabels';
|
||||
@@ -154,7 +154,7 @@ type TimelineTooltipProps = {
|
||||
payload?: Array<{ payload?: TimelineDatum }>;
|
||||
};
|
||||
|
||||
const TimelineTooltip: React.FC<TimelineTooltipProps> = ({ active, payload }) => {
|
||||
export const TimelineTooltip: React.FC<TimelineTooltipProps> = ({ active, payload }) => {
|
||||
const { language, t } = useUiLanguage();
|
||||
const actionLabels = buildDecisionActionLabelMap(t);
|
||||
if (!active || !payload?.[0]?.payload) return null;
|
||||
@@ -178,7 +178,7 @@ const TimelineTooltip: React.FC<TimelineTooltipProps> = ({ active, payload }) =>
|
||||
<span>{t('decisionSignals.horizon')}: {getDecisionSignalHorizonLabel(item.horizon, t)}</span>
|
||||
<span>{t('decisionSignals.status')}: {t(STATUS_LABEL_KEYS[item.status])}</span>
|
||||
<span>{t('decisionSignals.sourceReport')}: {item.sourceReportId ? `#${item.sourceReportId}` : '-'}</span>
|
||||
<span>{t('decisionSignals.profile')}: {getDecisionProfile(item)}</span>
|
||||
<span>{t('decisionSignals.profile')}: {getDecisionSignalProfileLabel(item, t)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+21
-1
@@ -11,6 +11,7 @@ const signal: DecisionSignalItem = {
|
||||
market: 'cn',
|
||||
sourceType: 'analysis',
|
||||
sourceReportId: 3001,
|
||||
decisionProfile: 'aggressive',
|
||||
marketPhase: 'intraday',
|
||||
triggerSource: 'web',
|
||||
action: 'hold',
|
||||
@@ -34,7 +35,7 @@ const signal: DecisionSignalItem = {
|
||||
expiresAt: '2026-06-18T09:30:00',
|
||||
createdAt: '2026-06-17T09:30:00',
|
||||
updatedAt: '2026-06-17T09:30:00',
|
||||
metadata: { source: 'test' },
|
||||
metadata: { source: 'test', decision_profile: 'balanced' },
|
||||
};
|
||||
|
||||
function renderCard(onSelect?: (item: DecisionSignalItem) => void) {
|
||||
@@ -53,6 +54,7 @@ describe('DecisionSignalCard', () => {
|
||||
|
||||
expect(screen.getByText('贵州茅台').closest('button')).toBeNull();
|
||||
expect(screen.getByText('72%')).toBeInTheDocument();
|
||||
expect(screen.getByText('风格: 进取')).toBeInTheDocument();
|
||||
expect(screen.getByText('1600 - 1620')).toBeInTheDocument();
|
||||
expect(screen.getByText('业绩窗口')).toBeInTheDocument();
|
||||
expect(screen.getByText('跌破 1550')).toBeInTheDocument();
|
||||
@@ -123,9 +125,27 @@ describe('DecisionSignalDetails', () => {
|
||||
expect(screen.getByText('3 日')).toBeInTheDocument();
|
||||
expect(screen.getByText('完整')).toBeInTheDocument();
|
||||
expect(screen.getByText('盘中')).toBeInTheDocument();
|
||||
expect(screen.getByText('风格')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('进取').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.queryByText('3d')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders explicit null profile as unknown on card and details', () => {
|
||||
window.localStorage.setItem('dsa.uiLanguage', 'zh');
|
||||
render(
|
||||
<UiLanguageProvider>
|
||||
<>
|
||||
<DecisionSignalCard item={{ ...signal, decisionProfile: null, metadata: { decision_profile: 'balanced' } }} />
|
||||
<DecisionSignalDetails item={{ ...signal, decisionProfile: null, metadata: { decision_profile: 'balanced' } }} />
|
||||
</>
|
||||
</UiLanguageProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText('风格: 未知').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('风格').closest('div')).toHaveTextContent('未知');
|
||||
expect(screen.queryByText('均衡')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders opaque JSON fields without creating html nodes from their string values', () => {
|
||||
window.localStorage.setItem('dsa.uiLanguage', 'zh');
|
||||
const { container } = render(
|
||||
|
||||
+45
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../../utils/decisionSignalTimeline';
|
||||
import {
|
||||
DecisionSignalTimeline,
|
||||
TimelineTooltip,
|
||||
} from '../DecisionSignalTimeline';
|
||||
|
||||
vi.mock('recharts', () => ({
|
||||
@@ -165,4 +166,48 @@ describe('DecisionSignalTimeline', () => {
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('timeline failed');
|
||||
});
|
||||
|
||||
it('renders tooltip profile from the first-class field before metadata fallback', () => {
|
||||
render(
|
||||
<UiLanguageProvider>
|
||||
<TimelineTooltip
|
||||
active
|
||||
payload={[{
|
||||
payload: {
|
||||
...buildTimelineData([
|
||||
makeSignal({
|
||||
decisionProfile: 'aggressive',
|
||||
metadata: { decision_profile: 'balanced' },
|
||||
}),
|
||||
])[0],
|
||||
},
|
||||
}]}
|
||||
/>
|
||||
</UiLanguageProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('风格: 进取')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders explicit null profile as unknown instead of falling back to metadata', () => {
|
||||
render(
|
||||
<UiLanguageProvider>
|
||||
<TimelineTooltip
|
||||
active
|
||||
payload={[{
|
||||
payload: {
|
||||
...buildTimelineData([
|
||||
makeSignal({
|
||||
decisionProfile: null,
|
||||
metadata: { decision_profile: 'balanced' },
|
||||
}),
|
||||
])[0],
|
||||
},
|
||||
}]}
|
||||
/>
|
||||
</UiLanguageProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('风格: 未知')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,6 +195,7 @@ const zh = {
|
||||
'decisionSignals.allActions': '全部动作',
|
||||
'decisionSignals.allMarkets': '全部市场',
|
||||
'decisionSignals.allPhases': '全部阶段',
|
||||
'decisionSignals.allProfiles': '全部风格',
|
||||
'decisionSignals.allSources': '全部来源',
|
||||
'decisionSignals.allStatuses': '全部状态',
|
||||
'decisionSignals.archive': '归档',
|
||||
@@ -279,6 +280,7 @@ const zh = {
|
||||
'decisionSignals.profile.aggressive': '进取',
|
||||
'decisionSignals.profile.balanced': '均衡',
|
||||
'decisionSignals.profile.conservative': '保守',
|
||||
'decisionSignals.profile.unknown': '未知',
|
||||
'decisionSignals.reason': '理由',
|
||||
'decisionSignals.reassessBlockedNote': '该预览已被风控约束为非进攻展示动作。',
|
||||
'decisionSignals.reassessBlockedTitle': '预览被风控阻断',
|
||||
@@ -338,6 +340,7 @@ const zh = {
|
||||
'decisionSignals.timelineGuideDescription': '调整时间范围、状态或市场后,点击查询时间线应用筛选。',
|
||||
'decisionSignals.timelineGuideTitle': '查询当前股票时间线',
|
||||
'decisionSignals.timelineMarket': '时间线市场',
|
||||
'decisionSignals.timelineProfile': '时间线风格',
|
||||
'decisionSignals.timelineRange': '时间范围',
|
||||
'decisionSignals.timelineRange.30d': '30 天',
|
||||
'decisionSignals.timelineRange.90d': '90 天',
|
||||
@@ -992,6 +995,7 @@ const en: Record<UiTextKey, string> = {
|
||||
'decisionSignals.allActions': 'All actions',
|
||||
'decisionSignals.allMarkets': 'All markets',
|
||||
'decisionSignals.allPhases': 'All phases',
|
||||
'decisionSignals.allProfiles': 'All profiles',
|
||||
'decisionSignals.allSources': 'All sources',
|
||||
'decisionSignals.allStatuses': 'All statuses',
|
||||
'decisionSignals.archive': 'Archive',
|
||||
@@ -1076,6 +1080,7 @@ const en: Record<UiTextKey, string> = {
|
||||
'decisionSignals.profile.aggressive': 'Aggressive',
|
||||
'decisionSignals.profile.balanced': 'Balanced',
|
||||
'decisionSignals.profile.conservative': 'Conservative',
|
||||
'decisionSignals.profile.unknown': 'Unknown',
|
||||
'decisionSignals.reason': 'Reason',
|
||||
'decisionSignals.reassessBlockedNote': 'This preview is constrained to a non-aggressive display action.',
|
||||
'decisionSignals.reassessBlockedTitle': 'Preview blocked by guardrail',
|
||||
@@ -1135,6 +1140,7 @@ const en: Record<UiTextKey, string> = {
|
||||
'decisionSignals.timelineGuideDescription': 'Adjust range, status, or market, then search the timeline to apply the filters.',
|
||||
'decisionSignals.timelineGuideTitle': 'Search the current stock timeline',
|
||||
'decisionSignals.timelineMarket': 'Timeline market',
|
||||
'decisionSignals.timelineProfile': 'Timeline profile',
|
||||
'decisionSignals.timelineRange': 'Range',
|
||||
'decisionSignals.timelineRange.30d': '30 days',
|
||||
'decisionSignals.timelineRange.90d': '90 days',
|
||||
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
DecisionSignalSourceType,
|
||||
DecisionSignalStatus,
|
||||
DecisionProfile,
|
||||
DecisionProfileDisplay,
|
||||
} from '../types/decisionSignals';
|
||||
import type { Market, StockIndexItem } from '../types/stockIndex';
|
||||
import { cn } from '../utils/cn';
|
||||
@@ -69,6 +70,7 @@ type TimelineFilters = {
|
||||
market: '' | DecisionSignalMarket;
|
||||
range: TimelineRange;
|
||||
status: TimelineStatusFilter;
|
||||
decisionProfile: '' | DecisionProfileDisplay;
|
||||
};
|
||||
|
||||
type TimelineMarketSource = 'context' | 'user' | null;
|
||||
@@ -151,6 +153,7 @@ const DEFAULT_TIMELINE_FILTERS: TimelineFilters = {
|
||||
market: '',
|
||||
range: '90d',
|
||||
status: 'all',
|
||||
decisionProfile: '',
|
||||
};
|
||||
|
||||
const TIMELINE_RANGE_DAYS: Record<TimelineRange, number> = {
|
||||
@@ -285,6 +288,7 @@ function toTimelineParams(filters: TimelineFilters, stockCode: string): Decision
|
||||
createdFrom: createdFrom.toISOString(),
|
||||
createdTo: createdTo.toISOString(),
|
||||
status: filters.status === 'active' ? 'active' : undefined,
|
||||
decisionProfile: filters.decisionProfile || undefined,
|
||||
page: 1,
|
||||
pageSize: TIMELINE_PAGE_SIZE,
|
||||
};
|
||||
@@ -1221,7 +1225,7 @@ const DecisionSignalsPage: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
<Card title={t('decisionSignals.timelineTitle')} subtitle={t('decisionSignals.timelineDescription')} padding="md">
|
||||
<form className="grid gap-3 md:grid-cols-4" onSubmit={handleTimelineSearch}>
|
||||
<form className="grid gap-3 md:grid-cols-5" onSubmit={handleTimelineSearch}>
|
||||
<select
|
||||
className="input-surface input-focus-glow h-11 rounded-xl border bg-transparent px-3 text-sm"
|
||||
value={timelineFilters.market}
|
||||
@@ -1256,6 +1260,23 @@ const DecisionSignalsPage: React.FC = () => {
|
||||
<option value="all">{t('decisionSignals.timelineStatus.all')}</option>
|
||||
<option value="active">{t('decisionSignals.timelineStatus.active')}</option>
|
||||
</select>
|
||||
<select
|
||||
className="input-surface input-focus-glow h-11 rounded-xl border bg-transparent px-3 text-sm"
|
||||
value={timelineFilters.decisionProfile}
|
||||
onChange={(event) => setTimelineFilters((current) => ({
|
||||
...current,
|
||||
decisionProfile: event.target.value as TimelineFilters['decisionProfile'],
|
||||
}))}
|
||||
aria-label={t('decisionSignals.timelineProfile')}
|
||||
>
|
||||
<option value="">{t('decisionSignals.allProfiles')}</option>
|
||||
{REASSESS_PROFILES.map((profile) => (
|
||||
<option key={profile} value={profile}>
|
||||
{t(`decisionSignals.profile.${profile}` as UiTextKey)}
|
||||
</option>
|
||||
))}
|
||||
<option value="unknown">{t('decisionSignals.profile.unknown')}</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-secondary inline-flex h-11 items-center justify-center gap-2"
|
||||
|
||||
@@ -811,7 +811,7 @@ describe('DecisionSignalsPage', () => {
|
||||
expect(screen.getByRole('button', { name: '查询时间线' })).toBeDisabled();
|
||||
expect(decisionSignalsApi.list).toHaveBeenCalledTimes(1);
|
||||
expect(within(screen.getByLabelText('时间线状态')).queryByRole('option', { name: '已关闭' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('profile')).not.toBeInTheDocument();
|
||||
expect(within(screen.getByLabelText('时间线风格')).getByRole('option', { name: '未知' })).toHaveValue('unknown');
|
||||
});
|
||||
|
||||
it('queries timeline with independent filters and no default status', async () => {
|
||||
@@ -823,6 +823,7 @@ describe('DecisionSignalsPage', () => {
|
||||
|
||||
fireEvent.change(screen.getByLabelText('时间线市场'), { target: { value: 'cn' } });
|
||||
fireEvent.change(screen.getByLabelText('时间范围'), { target: { value: '30d' } });
|
||||
fireEvent.change(screen.getByLabelText('时间线风格'), { target: { value: 'unknown' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询时间线' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -834,6 +835,7 @@ describe('DecisionSignalsPage', () => {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
status: undefined,
|
||||
decisionProfile: 'unknown',
|
||||
}));
|
||||
const params = vi.mocked(decisionSignalsApi.list).mock.calls.at(-1)?.[0] as Record<string, string>;
|
||||
expect(params.createdFrom).toEqual(expect.any(String));
|
||||
@@ -945,6 +947,7 @@ describe('DecisionSignalsPage', () => {
|
||||
fireEvent.change(screen.getByLabelText('时间线市场'), { target: { value: 'us' } });
|
||||
fireEvent.change(screen.getByLabelText('时间范围'), { target: { value: '30d' } });
|
||||
fireEvent.change(screen.getByLabelText('时间线状态'), { target: { value: 'active' } });
|
||||
fireEvent.change(screen.getByLabelText('时间线风格'), { target: { value: 'conservative' } });
|
||||
|
||||
expect(decisionSignalsApi.list).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -957,6 +960,7 @@ describe('DecisionSignalsPage', () => {
|
||||
stockCode: 'AAPL',
|
||||
market: 'us',
|
||||
status: 'active',
|
||||
decisionProfile: 'conservative',
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface DecisionSignalItem {
|
||||
sourceAgent?: string | null;
|
||||
sourceReportId?: number | null;
|
||||
traceId?: string | null;
|
||||
decisionProfile?: DecisionProfile | null;
|
||||
marketPhase?: MarketPhaseValue | null;
|
||||
triggerSource: string;
|
||||
action: DecisionAction;
|
||||
@@ -61,6 +62,7 @@ export interface DecisionSignalCreateRequest {
|
||||
sourceAgent?: string | null;
|
||||
sourceReportId?: number | null;
|
||||
traceId?: string | null;
|
||||
decisionProfile?: DecisionProfile;
|
||||
marketPhase?: MarketPhaseValue | null;
|
||||
triggerSource: string;
|
||||
action: DecisionAction;
|
||||
@@ -92,6 +94,7 @@ export interface DecisionSignalListParams {
|
||||
stockCode?: string;
|
||||
action?: DecisionAction;
|
||||
marketPhase?: MarketPhaseValue;
|
||||
decisionProfile?: DecisionProfileDisplay;
|
||||
sourceType?: DecisionSignalSourceType;
|
||||
sourceReportId?: number;
|
||||
traceId?: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DecisionSignalItem } from '../../types/decisionSignals';
|
||||
import { getDecisionProfile } from '../decisionSignalProfile';
|
||||
import { getDecisionProfile, getDecisionProfileLabel } from '../decisionSignalProfile';
|
||||
|
||||
const signal: DecisionSignalItem = {
|
||||
id: 1,
|
||||
@@ -14,6 +14,14 @@ const signal: DecisionSignalItem = {
|
||||
};
|
||||
|
||||
describe('getDecisionProfile', () => {
|
||||
it('prefers the first-class field over legacy metadata', () => {
|
||||
expect(getDecisionProfile({
|
||||
...signal,
|
||||
decisionProfile: 'aggressive',
|
||||
metadata: { decision_profile: 'balanced' },
|
||||
})).toBe('aggressive');
|
||||
});
|
||||
|
||||
it.each(['conservative', 'balanced', 'aggressive'] as const)('reads %s from metadata', (profile) => {
|
||||
expect(getDecisionProfile({
|
||||
...signal,
|
||||
@@ -21,10 +29,32 @@ describe('getDecisionProfile', () => {
|
||||
})).toBe(profile);
|
||||
});
|
||||
|
||||
it('treats explicit null first-class profile as unknown without metadata fallback', () => {
|
||||
expect(getDecisionProfile({
|
||||
...signal,
|
||||
decisionProfile: null,
|
||||
metadata: { decision_profile: 'balanced' },
|
||||
})).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns unknown for missing or invalid metadata', () => {
|
||||
expect(getDecisionProfile(signal)).toBe('unknown');
|
||||
expect(getDecisionProfile({ ...signal, decisionProfile: undefined, metadata: { decision_profile: 'balanced' } })).toBe('unknown');
|
||||
expect(getDecisionProfile({ ...signal, metadata: null })).toBe('unknown');
|
||||
expect(getDecisionProfile({ ...signal, metadata: [] })).toBe('unknown');
|
||||
expect(getDecisionProfile({ ...signal, metadata: { decision_profile: 'balanced-v2' } })).toBe('unknown');
|
||||
});
|
||||
|
||||
it('maps profile display values through UI labels', () => {
|
||||
const labels = {
|
||||
'decisionSignals.profile.aggressive': '进取',
|
||||
'decisionSignals.profile.balanced': '均衡',
|
||||
'decisionSignals.profile.conservative': '保守',
|
||||
'decisionSignals.profile.unknown': '未知',
|
||||
} as const;
|
||||
const t = (key: string) => labels[key as keyof typeof labels] ?? key;
|
||||
|
||||
expect(getDecisionProfileLabel('aggressive', t)).toBe('进取');
|
||||
expect(getDecisionProfileLabel('unknown', t)).toBe('未知');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,33 @@ import type {
|
||||
DecisionProfileDisplay,
|
||||
DecisionSignalItem,
|
||||
} from '../types/decisionSignals';
|
||||
import type { UiTextKey } from '../i18n/uiText';
|
||||
|
||||
function isDecisionProfile(value: unknown): value is Exclude<DecisionProfileDisplay, 'unknown'> {
|
||||
return value === 'conservative' || value === 'balanced' || value === 'aggressive';
|
||||
}
|
||||
|
||||
export function getDecisionProfile(item: DecisionSignalItem): DecisionProfileDisplay {
|
||||
if (Object.prototype.hasOwnProperty.call(item, 'decisionProfile')) {
|
||||
return isDecisionProfile(item.decisionProfile) ? item.decisionProfile : 'unknown';
|
||||
}
|
||||
|
||||
const metadata = item.metadata;
|
||||
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return 'unknown';
|
||||
const value = (metadata as Record<string, unknown>).decision_profile;
|
||||
return value === 'conservative' || value === 'balanced' || value === 'aggressive'
|
||||
? value
|
||||
: 'unknown';
|
||||
return isDecisionProfile(value) ? value : 'unknown';
|
||||
}
|
||||
|
||||
export function getDecisionProfileLabel(
|
||||
profile: DecisionProfileDisplay,
|
||||
t: (key: UiTextKey) => string,
|
||||
): string {
|
||||
return t(`decisionSignals.profile.${profile}` as UiTextKey);
|
||||
}
|
||||
|
||||
export function getDecisionSignalProfileLabel(
|
||||
item: DecisionSignalItem,
|
||||
t: (key: UiTextKey) => string,
|
||||
): string {
|
||||
return getDecisionProfileLabel(getDecisionProfile(item), t);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
- [改进] `STOCK_LIST` 自选股解析支持中文逗号、顿号、分号、空格和换行等常见粘贴分隔符,运行时、定时热刷新、CLI `--stocks`、Web 设置保存和自选 API 统一识别,并在写回时规范为英文逗号。
|
||||
- [改进] 新增 `NEWS_INTEL_AUTO_FETCH_ENABLED` 单开关,开启后个股分析、Agent 分析和大盘复盘会 fail-open 自动初始化并刷新 RSS/Atom/NewsNow 本地资讯池。
|
||||
- [改进] Web AI 建议页新增主股票上下文,复用最近分析和股票索引候选,并改进表现统计零样本说明。
|
||||
- [改进] DecisionSignal 将 `decision_profile` 升级为正式 nullable 字段,统一 same-profile 查询、去重、续期和失效语义,并保持 create metadata `null` 兼容与 SQLite 幂等回填诊断。
|
||||
- [改进] 补充本次设置页布局收敛:移动端分类导航改为横向滚动列表并保证设置内容首屏可见,桌面端保留分类说明并收紧字段布局层级与间距,提升首屏效率与可配置信息密度。
|
||||
- [文档] 在 README 快速开始中补充行情数据源配置说明(TUSHARE_TOKEN / Longbridge),明确未配置时仍可走 AkShare、Baostock、YFinance 等免费兜底源,日志中相关提示不影响运行。同步更新docs下的中英双份 README
|
||||
- [改进] 新增 #1743 Phase 6a 内部 DSA Tool Surface 契约,统一工具 schema、stock scope fail-closed guard、结构化错误、审计摘要和脱敏诊断边界,并明确外部 AgentBackend 工具能力仍需 wire-level probe 证明。
|
||||
|
||||
@@ -933,6 +933,24 @@
|
||||
},
|
||||
"description": "Optional market phase filter"
|
||||
},
|
||||
{
|
||||
"name": "decision_profile",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional decision profile filter: conservative/balanced/aggressive/unknown",
|
||||
"title": "Decision Profile"
|
||||
},
|
||||
"description": "Optional decision profile filter: conservative/balanced/aggressive/unknown"
|
||||
},
|
||||
{
|
||||
"name": "source_type",
|
||||
"in": "query",
|
||||
@@ -1395,7 +1413,7 @@
|
||||
"DecisionSignals"
|
||||
],
|
||||
"summary": "更新决策信号状态",
|
||||
"description": "只更新合法状态和可选 metadata;传入 metadata 时按整包替换保存。expired/invalidated/closed/archived 等 terminal 状态不能直接 PATCH 回 active。",
|
||||
"description": "只更新合法状态和可选 metadata;省略 metadata 时保留原值,null 时清空,object 时按整包替换并保持正式 decision_profile 身份。expired/invalidated/closed/archived 等 terminal 状态不能直接 PATCH 回 active。",
|
||||
"operationId": "updateDecisionSignalStatus",
|
||||
"security": [
|
||||
{
|
||||
@@ -3854,6 +3872,16 @@
|
||||
"maxLength": 64,
|
||||
"title": "Trace Id"
|
||||
},
|
||||
"decision_profile": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"conservative",
|
||||
"balanced",
|
||||
"aggressive"
|
||||
],
|
||||
"title": "Decision Profile",
|
||||
"description": "Optional decision profile. Omit to use server-side default/fallback; explicit null is rejected."
|
||||
},
|
||||
"market_phase": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -4120,7 +4148,8 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Metadata"
|
||||
"title": "Metadata",
|
||||
"description": "Optional metadata object. Omitted or null values are treated as absent."
|
||||
},
|
||||
"report_language": {
|
||||
"anyOf": [
|
||||
@@ -4172,7 +4201,8 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Metadata"
|
||||
"title": "Metadata",
|
||||
"description": "Optional replacement metadata. Omit to preserve the stored value; null clears it; an object replaces it while preserving the formal decision_profile identity."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -4243,6 +4273,22 @@
|
||||
],
|
||||
"title": "Trace Id"
|
||||
},
|
||||
"decision_profile": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"conservative",
|
||||
"balanced",
|
||||
"aggressive"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Decision Profile"
|
||||
},
|
||||
"market_phase": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
+22
-11
@@ -6,14 +6,14 @@
|
||||
|
||||
- `DecisionSignal` 只记录建议、证据摘要、风险、观察条件、生命周期和来源,不执行下单或调仓。
|
||||
- 写入失败、提取失败、告警信号关联失败和通知发送失败都不阻断主分析、告警触发或报告保存。
|
||||
- P7 不新增 API、数据库字段、环境变量、config registry 项或 `.env.example` 内容。
|
||||
- #1756 只将 `decision_profile` 字段化并修正 server-side filter、去重、续期和 active 失效语义;不新增环境变量、config registry 项或 `.env.example` 内容。
|
||||
- 当前没有 `DECISION_SIGNAL_*` 开关;信号功能的关闭或回滚通过 revert 对应代码完成。
|
||||
|
||||
## 字段与枚举
|
||||
|
||||
核心字段由 `api/v1/schemas/decision_signals.py` 定义,主要包括:
|
||||
|
||||
- 身份与来源:`stock_code`、`stock_name`、`market`、`source_type`、`source_agent`、`source_report_id`、`trace_id`、`trigger_source`。
|
||||
- 身份与来源:`stock_code`、`stock_name`、`market`、`source_type`、`source_agent`、`source_report_id`、`trace_id`、`decision_profile`、`trigger_source`。
|
||||
- 建议语义:`action`、`action_label`、`confidence`、`score`、`horizon`、`market_phase`、`plan_quality`、`status`。
|
||||
- 计划与解释:`entry_low`、`entry_high`、`stop_loss`、`target_price`、`invalidation`、`watch_conditions`、`reason`、`risk_summary`、`catalyst_summary`。
|
||||
- 证据与质量:`evidence`、`data_quality_summary`、`metadata`。
|
||||
@@ -28,6 +28,7 @@
|
||||
| `market_phase` | `premarket`、`intraday`、`lunch_break`、`closing_auction`、`postmarket`、`non_trading`、`unknown` |
|
||||
| `action` | `buy`、`add`、`hold`、`reduce`、`sell`、`watch`、`avoid`、`alert` |
|
||||
| `horizon` | `intraday`、`1d`、`3d`、`5d`、`10d`、`swing`、`long` |
|
||||
| `decision_profile` | `conservative`、`balanced`、`aggressive`;数据库 `NULL` 表示 legacy / unknown |
|
||||
| `plan_quality` | `complete`、`partial`、`minimal`、`unknown` |
|
||||
| `status` | `active`、`expired`、`invalidated`、`closed`、`archived` |
|
||||
|
||||
@@ -57,15 +58,17 @@ Web 展示必须把这些 wire value 映射为当前 UI 语言的用户可读标
|
||||
- 未传 `horizon` 时,`alert` 或盘前/盘中/午间休市/集合竞价阶段默认 `intraday`,盘后、非交易时段、未知阶段或缺少阶段时默认 `3d`。
|
||||
- `intraday` 过期时间优先读取低敏 `metadata.market_phase_summary.minutes_to_close/minutes_to_open`;缺失时按市场 fallback TTL。
|
||||
- `expired`、`invalidated`、`closed`、`archived` 不能通过 `PATCH /status` 直接恢复为 `active`。
|
||||
- 同源去重优先使用 `(source_report_id, source_type, market, stock_code, action, horizon, market_phase)`;没有 report 但有 `trace_id` 时使用 trace 维度。
|
||||
- 新的相反 active 信号会把旧 active 信号标记为 `invalidated`,并把失效来源写入 metadata。
|
||||
- 同源去重优先使用 `(source_report_id, source_type, market, stock_code, decision_profile, action, horizon, market_phase)`;没有 report 但有 `trace_id` 时使用 trace 维度。
|
||||
- `decision_profile` 参与信号身份:`NULL` 只与 `NULL` 匹配,非空 profile 只与相同 profile 匹配。Exact dedup、relaxed dedup、horizon/phase fill、expired refresh、active invalidation 和 stale backfill invalidation 都遵循该 same-profile 语义。
|
||||
- 新的相反 active 信号只会把同 profile 的旧 active 信号标记为 `invalidated`,并把失效来源写入 metadata。不同非 `NULL` profile 可并存,即使 action 相反。
|
||||
- Expired duplicate refresh 不会改写 `decision_profile`,只能刷新同 profile 记录。
|
||||
|
||||
## API
|
||||
|
||||
当前公开接口由 `api/v1/endpoints/decision_signals.py` 和 `docs/architecture/api_spec.json` 描述:
|
||||
|
||||
- `POST /api/v1/decision-signals`:创建或按同源键去重,返回 `{ item, created }`。
|
||||
- `GET /api/v1/decision-signals`:分页查询,支持市场、股票、动作、阶段、来源、状态、时间范围和持仓过滤。
|
||||
- `GET /api/v1/decision-signals`:分页查询,支持市场、股票、动作、阶段、`decision_profile`、来源、状态、时间范围和持仓过滤。省略或传空 `decision_profile` 不加 profile 条件,返回所有 profile;`decision_profile=unknown` 查询 `NULL` 行;合法 profile 精确匹配。
|
||||
- `GET /api/v1/decision-signals/{signal_id}`:查询单条。
|
||||
- `PATCH /api/v1/decision-signals/{signal_id}/status`:更新状态和可选 metadata。
|
||||
- `GET /api/v1/decision-signals/latest/{stock_code}`:查询股票最新 active 信号。
|
||||
@@ -94,7 +97,7 @@ Web 展示必须把这些 wire value 映射为当前 UI 语言的用户可读标
|
||||
|
||||
- `source_report_id` 是唯一事实来源,重评估只读取对应持久化历史报告快照。
|
||||
- 不支持 `signal_id`,也不接受客户端提交 `action`、`score`、`confidence`、价格、metadata 或 guardrail 结果;额外字段会被请求校验拒绝。
|
||||
- `persist=true` 当前固定返回 HTTP 400,错误码为 `unsupported_operation`。保存重评估结果依赖后续将 `decision_profile` 字段化。
|
||||
- `persist=true` 当前固定返回 HTTP 400,错误码为 `unsupported_operation`。保存重评估结果留给 #1757。
|
||||
- 重评估不会静默抓取实时行情,也不会用当前市场数据补齐历史快照。
|
||||
- 历史报告不存在、非个股报告或快照缺少结构化决策输入时,分别返回明确错误。
|
||||
- data quality 会归一为 `high`、`medium`、`low`、`poor`、`unknown`,guardrail 只使用归一化后的等级。
|
||||
@@ -115,8 +118,9 @@ Web 入口位于 `/decision-signals`:
|
||||
- 时间线只支持 `30d`、`90d`、`180d` 三个时间范围,默认 `90d`;每次最多请求 100 条。若返回 `total > items.length`,Web 会显示“仅展示最近 100 条信号,请缩小时间范围”,避免静默展示不完整轨迹。
|
||||
- 时间线筛选保留独立的 market、range、status 表单和查询按钮。选择新当前股票时,如果能推导市场,只在这一次初始化时间线 market;用户之后可以手动改 market,查询以按钮提交时的表单快照为准。
|
||||
- 时间线 status filter 只支持 `all` 与 `active`:`all` 不传 `status`,`active` 传 `status=active`。P1 不提供 terminal status filter,也不做前端 terminal 过滤。
|
||||
- 时间线支持 profile filter,复用 list API 的 server-side `decision_profile` 查询;`unknown` 只用于筛选和展示 legacy `NULL` 行。普通高级列表不新增 profile filter。
|
||||
- 信号表现统计保持全局已复盘 outcome 口径,不等于当前可见信号数量,也不随当前股票或高级列表筛选变化;当已复盘样本数为 0 时,Web 显示零样本空状态而不是一组 `0/-` 指标。
|
||||
- P1 不提供 profile filter;`decision_profile` 仍只存在于 metadata 中,不能可靠 server-side 过滤。历史缺失或非法 profile 的信号在 Web 中显示为 `unknown`,不会误标为 `balanced`。
|
||||
- Web 展示优先读取正式 `decision_profile` 字段,只有字段缺失时才回退 legacy metadata;历史缺失或非法 profile 的信号显示为 `unknown`,不会误标为 `balanced`。
|
||||
- market filter 在 API / 服务层与 Web 前端均已支持 `cn/hk/us/jp/kr/tw`;`jp/kr/tw` 的前端本地化标签均已补齐,`tw` 信号可经 API 正常写入、按 `market=tw` 查询,并可在 Web DecisionSignal 页面通过市场筛选项选择台股(tw);告警(大盘红绿灯)市场支持 `cn/hk/us/jp/kr`。
|
||||
- 详情抽屉展示动作、状态、评分、置信度、周期、计划质量、市场阶段、价格计划、风险、观察条件、证据、数据质量和 metadata。
|
||||
- 详情抽屉或已有来源报告 ID 的页面上下文可以发起 reassess preview;没有可用来源报告 ID 时入口禁用。Preview 不加入列表、latest 或时间线,也不提供保存按钮。
|
||||
@@ -126,9 +130,9 @@ Web 入口位于 `/decision-signals`:
|
||||
|
||||
所有用户可见枚举必须使用 i18n 标签;技术 ID、股票代码、API 字段名、env key、URL 示例可以保留英文。
|
||||
|
||||
## Decision profile metadata
|
||||
## Decision profile identity
|
||||
|
||||
P1 自动生成的 `source_type=analysis` 信号会在 metadata 中写入默认决策风格元数据:
|
||||
#1756 后 `decision_profile` 是 `decision_signals` 的正式 nullable 字段,同时 metadata 保留兼容字段:
|
||||
|
||||
- `decision_profile=balanced`
|
||||
- `profile_source=auto_default`:普通新分析生成路径。
|
||||
@@ -137,7 +141,12 @@ P1 自动生成的 `source_type=analysis` 信号会在 metadata 中写入默认
|
||||
- `signal_generation_version=legacy-report-extractor-v1`
|
||||
- `decision_signal_metadata_version=decision-signal-metadata-v1`
|
||||
|
||||
`profile_policy_version` 只表示默认 profile metadata contract version,不代表已经实现独立 profile policy engine、scoring engine 或多 profile 生成。P1/P2 不写入 `scoring_version` 或 `scoring_breakdown`;这些字段如需引入,应由后续 reassess / scoring issue 定义。
|
||||
- 新写入时,顶层合法 `decision_profile` 优先;顶层显式 `null`、空值或非法值直接拒绝。顶层缺失时才 fallback 合法 `metadata.decision_profile`;二者都缺失或 metadata profile 非法时默认写入 `balanced`。
|
||||
- 新写入会同步 `metadata.decision_profile` 为正式字段值,避免双源冲突;metadata 省略或显式 `null` 均按无 metadata 处理,object 会浅复制,非 object 会被拒绝。
|
||||
- PATCH metadata 省略时保留原值,显式 `null` 时清空为 SQL `NULL`,object 时整包替换。正式 profile 非 `NULL` 时会覆盖 metadata 中的冲突值;正式 profile 为 legacy `NULL` 时会移除请求 object 中的 profile key,且不会提升正式字段。
|
||||
- 自动失效写入同样遵循正式字段权威语义:正式 profile 非 `NULL` 时同步 metadata profile;legacy `NULL` 时只追加失效信息,保留原 legacy metadata,不注入或删除 profile。
|
||||
- Legacy / unknown 只用数据库 `NULL` 表示。`profile_policy_version` 只表示默认 profile metadata contract version,不代表已经实现独立 profile policy engine、scoring engine 或多 profile 生成。P1/P2 不写入 `scoring_version` 或 `scoring_breakdown`;这些字段如需引入,应由后续 reassess / scoring issue 定义。
|
||||
- Lazy backfill 语义:省略 profile 保留旧的 `source_type=analysis + source_report_id` 懒回填;`decision_profile=balanced` 可生成 balanced 回填;`decision_profile=unknown`、`conservative`、`aggressive` 不自动创建行。
|
||||
|
||||
## 告警、通知与组合风险
|
||||
|
||||
@@ -174,11 +183,13 @@ P7 的全局验收是确认信号池、通知摘要和 Web 展示不泄露 token
|
||||
|
||||
## 迁移与回滚
|
||||
|
||||
本功能在 P1-P6 已完成所需表和 sidecar 结构;P7 不新增 migration。
|
||||
#1756 对 SQLite 执行非破坏性 migration。
|
||||
|
||||
迁移说明:
|
||||
|
||||
- 升级后无需新增 `.env`、`.env.example` 或 Web 设置项。
|
||||
- Existing SQLite 只在缺列时 `ALTER TABLE ADD COLUMN decision_profile`,不会 drop/rebuild `decision_signals`,也不会删除旧 index。
|
||||
- Migration 会幂等创建 profile-aware indexes,并 row-by-row 防御解析 `metadata_json`:仅合法 `metadata.decision_profile` 回填到正式字段;invalid JSON、非 object 或非法 profile 保持 `NULL`。启动日志会记录 backfilled、invalid JSON、non-object、invalid profile 和 skipped existing profile 统计,这些统计只用于诊断,不阻断启动。
|
||||
- 旧历史报告不会批量回填。只有显式调用信号列表接口或在 Web AI 建议页按来源报告 ID 触发精确查询 `source_type=analysis + source_report_id` 且无命中时,才会 best-effort 懒回填。
|
||||
- 已存在的 `decision_signals`、feedback 和 outcome 数据保持兼容。
|
||||
|
||||
|
||||
+8
-8
@@ -1389,25 +1389,25 @@ P2 自动提取的市场阶段优先读取保存快照中的 `market_phase_summa
|
||||
|
||||
P3 开始,生命周期由 `DecisionSignalService` 统一补齐:显式传入的 `horizon` / `expires_at` 永远优先;未传 `horizon` 时,`alert` 或 `premarket/intraday/lunch_break/closing_auction` 默认 `intraday`,`postmarket/non_trading/unknown` 或无阶段上下文时默认 `3d`;未传 `expires_at` 时,`intraday` 优先读取 `metadata.market_phase_summary.minutes_to_close/minutes_to_open`,无上下文时使用确定性 TTL fallback(A 股 4h、港股 5.5h、美股 6.5h、未知 4h),`1d/3d/5d/10d` 按自然日,`swing/long` 不自动过期。fallback TTL 只是缺少交易日历上下文时的降级策略,不等价于真实交易所收盘时间。自动提取只把 `market_phase_summary.phase/session_date/minutes_to_open/minutes_to_close` 作为低敏 hint 写入 `metadata.market_phase_summary`,最终 `horizon/expires_at` 仍由 service 计算。
|
||||
|
||||
核心字段包括 `stock_code`、`stock_name`、`market`、`source_type`、`source_agent`、`source_report_id`、`trace_id`、`market_phase`、`trigger_source`、`action`、`action_label`、`confidence`、`score`、`horizon`、`entry_low`、`entry_high`、`stop_loss`、`target_price`、`invalidation`、`watch_conditions`、`reason`、`risk_summary`、`catalyst_summary`、`evidence`、`data_quality_summary`、`plan_quality`、`status`、`expires_at`、`created_at`、`updated_at` 和 `metadata`。`action` 复用八态建议动作;`market_phase` 复用市场阶段枚举;`source_type` 支持 `analysis|agent|alert|market_review|manual`;`status` 支持 `active|expired|invalidated|closed|archived`;`horizon` 支持 `intraday|1d|3d|5d|10d|swing|long`。
|
||||
核心字段包括 `stock_code`、`stock_name`、`market`、`source_type`、`source_agent`、`source_report_id`、`trace_id`、`decision_profile`、`market_phase`、`trigger_source`、`action`、`action_label`、`confidence`、`score`、`horizon`、`entry_low`、`entry_high`、`stop_loss`、`target_price`、`invalidation`、`watch_conditions`、`reason`、`risk_summary`、`catalyst_summary`、`evidence`、`data_quality_summary`、`plan_quality`、`status`、`expires_at`、`created_at`、`updated_at` 和 `metadata`。`action` 复用八态建议动作;`decision_profile` 支持 `conservative|balanced|aggressive`,数据库 `NULL` 仅表示 legacy/unknown;`market_phase` 复用市场阶段枚举;`source_type` 支持 `analysis|agent|alert|market_review|manual`;`status` 支持 `active|expired|invalidated|closed|archived`;`horizon` 支持 `intraday|1d|3d|5d|10d|swing|long`。
|
||||
|
||||
`confidence` 为 `0.0-1.0`,`score` 为 `0-100`,与历史报告的 `sentiment_score` 解耦。价格计划字段 `entry_low`、`entry_high`、`stop_loss`、`target_price` 必须是有限正数,且同时传入 `entry_low` 和 `entry_high` 时要求 `entry_low <= entry_high`。`plan_quality` 支持 `complete|partial|minimal|unknown`:调用方显式传入合法值时直接保存;未传时由 service 计算,入场区间(`entry_low` 或 `entry_high` 任一有值)算 1 项,`stop_loss`、`target_price`、`invalidation`、`watch_conditions` 各算 1 项,满足 2 项为 `partial`,满足 4 项及以上为 `complete`,仅有 action/reason 为 `minimal`。
|
||||
|
||||
新增 API:
|
||||
|
||||
- `POST /api/v1/decision-signals`:创建或按同源键去重,返回 `{ item, created }`,HTTP 200。精确去重键为 `(source_report_id, source_type, market, stock_code, action, horizon, market_phase)`;没有 report 但有 `trace_id` 时使用 `(trace_id, source_type, market, stock_code, action, horizon, market_phase)`;两者皆无则不去重。精确匹配失败后,会按同源 + `source_type/market/stock_code/action` 做窄 relaxed fallback,只填补旧记录为空的 `horizon/market_phase`,且 `horizon` 只有在新值由 service 默认生成时才可填补;显式不同期限或已有不同阶段仍保留多条。若命中同源 expired 记录,且新请求为 active 并携带未来 `expires_at`,会原地刷新该记录并返回 `created=false`,这次续期按新的 active 激活事件处理。active 新建或 expired 续期后的 bullish 信号(`buy/add`)会把更早的 active defensive 信号(`reduce/sell/avoid`)标记为 `invalidated`,反向同理;active duplicate retry 也会重跑该失效修复,以恢复上次创建成功但失效写入失败的 partial create;普通旧 duplicate/replay 不作为新的激活事件。`hold/watch/alert` 不触发自动失效。API 响应 schema 不变,刷新或重复命中都对外返回 `created=false`;本功能不提供并发唯一性保证。
|
||||
- `GET /api/v1/decision-signals`:分页查询,支持 `market`、`stock_code`、`action`、`market_phase`、`source_type`、`source_report_id`、`trace_id`、`trigger_source`、`status`、时间范围、`holding_only`、`account_id`。
|
||||
- `POST /api/v1/decision-signals`:创建或按同源键去重,返回 `{ item, created }`,HTTP 200。新写入可省略 `decision_profile` 并默认 `balanced`,也可传合法 `conservative|balanced|aggressive`;顶层显式 `null`、空值或非法值会被拒绝。顶层缺失时才 fallback 合法 `metadata.decision_profile`,写入前会同步 `metadata.decision_profile` 为正式字段值;metadata 省略或显式 `null` 均按无 metadata 处理,object 会浅复制,非 object 会被拒绝。精确去重键为 `(source_report_id, source_type, market, stock_code, decision_profile, action, horizon, market_phase)`;没有 report 但有 `trace_id` 时使用 `(trace_id, source_type, market, stock_code, decision_profile, action, horizon, market_phase)`;两者皆无则不去重。精确匹配、relaxed fallback、horizon/phase fill、expired refresh、active invalidation 和 stale backfill invalidation 都遵循 same-profile 语义:`NULL` 只匹配 `NULL`,非空 profile 只匹配相同 profile;expired duplicate refresh 不会改写 `decision_profile`。精确匹配失败后,会按同源 + `source_type/market/stock_code/decision_profile/action` 做窄 relaxed fallback,只填补旧记录为空的 `horizon/market_phase`,且 `horizon` 只有在新值由 service 默认生成时才可填补;显式不同期限、已有不同阶段或不同 profile 仍保留多条。若命中同源 expired 同 profile 记录,且新请求为 active 并携带未来 `expires_at`,会原地刷新该记录并返回 `created=false`,这次续期按新的 active 激活事件处理。active 新建或 expired 续期后的 bullish 信号(`buy/add`)会把更早同 profile 的 active defensive 信号(`reduce/sell/avoid`)标记为 `invalidated`,反向同理;不同非空 profile 可并存,即使动作相反。active duplicate retry 也会重跑同 profile 失效修复,以恢复上次创建成功但失效写入失败的 partial create;普通旧 duplicate/replay 不作为新的激活事件。`hold/watch/alert` 不触发自动失效。刷新或重复命中都对外返回 `created=false`;本功能不提供并发唯一性保证。
|
||||
- `GET /api/v1/decision-signals`:分页查询,支持 `market`、`stock_code`、`action`、`market_phase`、`decision_profile`、`source_type`、`source_report_id`、`trace_id`、`trigger_source`、`status`、时间范围、`holding_only`、`account_id`。省略或传空 `decision_profile` 不加 profile 条件,返回所有 profile;`decision_profile=unknown` 查询 legacy `NULL` 行;合法 profile 精确匹配。
|
||||
- `GET /api/v1/decision-signals/{signal_id}`:查询单条,不存在返回 404。
|
||||
- `PATCH /api/v1/decision-signals/{signal_id}/status`:更新合法状态和可选 `metadata`;传入 `metadata` 时按整包替换保存。`expired/invalidated/closed/archived` 等 terminal 状态不能直接 PATCH 回 `active`,expired 续期仍只能重新 `POST` active + 未来 `expires_at`。
|
||||
- `PATCH /api/v1/decision-signals/{signal_id}/status`:更新合法状态和可选 `metadata`;省略 metadata 时保留原值,显式 `null` 时清空为 SQL `NULL`,object 时整包替换。正式 `decision_profile` 非 `NULL` 时会覆盖 metadata 中的冲突 profile;正式字段为 legacy `NULL` 时会移除请求 object 中的 profile key,且不会提升正式字段。`expired/invalidated/closed/archived` 等 terminal 状态不能直接 PATCH 回 `active`,expired 续期仍只能重新 `POST` active + 未来 `expires_at`。
|
||||
- `GET /api/v1/decision-signals/latest/{stock_code}`:按股票查询最新 active 信号,默认 `limit=1`。
|
||||
|
||||
读取入口会懒过期:列表、详情和 latest 查询前会把已到 `expires_at` 的 active 信号标为 expired;创建时已过期的 active 信号会直接保存为 expired;同源 expired 信号只能通过重新 `POST` active + 未来 `expires_at` 的方式延展,`PATCH /status` 不接受 `expires_at`。`expired|invalidated|closed|archived` 不会被 PATCH 直接复活,`closed|invalidated|archived` 也不会被 create 路径复活。相反信号自动失效会合并写入旧信号 `metadata`:`invalidated_by_signal_id`、`invalidated_reason`、`invalidated_at`、`previous_status`;旧 metadata JSON 损坏时会替换为失效 metadata 并写入 `metadata_replaced_due_to_invalid_json=true`,不阻断新信号创建。时间字段按 UTC 归一化为无时区 `datetime` 保存和比较;带时区输入会先转为 UTC 后去掉 `tzinfo`,无时区输入按 UTC 处理,API 响应继续返回不带时区后缀的 ISO 字符串。股票代码入库与查询按 `market` 确定性归一化:A 股 `600519`、`SH600519`、`600519.SH` 等常见变体按同一代码匹配;港股 `00700`、`HK00700`、`00700.HK` 按 `HK00700` 匹配;美股 ticker 统一大写。`holding_only=true` 只读取 active 账户下 `portfolio_positions` 中 `quantity > 0` 的缓存持仓,并按持仓 `(market, stock_code)` 匹配信号,可选 active `account_id`;该查询不会调用组合 snapshot replay,无缓存时返回空结果,需先通过 portfolio snapshot API 刷新缓存。
|
||||
读取入口会懒过期:列表、详情和 latest 查询前会把已到 `expires_at` 的 active 信号标为 expired;创建时已过期的 active 信号会直接保存为 expired;同源 expired 信号只能通过重新 `POST` active + 未来 `expires_at` 的方式延展,`PATCH /status` 不接受 `expires_at`。`expired|invalidated|closed|archived` 不会被 PATCH 直接复活,`closed|invalidated|archived` 也不会被 create 路径复活。相反信号自动失效会合并写入旧信号 `metadata`:`invalidated_by_signal_id`、`invalidated_reason`、`invalidated_at`、`previous_status`;正式 profile 非 `NULL` 时同步 metadata profile,正式 profile 为 legacy `NULL` 时保留原 metadata profile 且不提升正式字段。旧 metadata JSON 损坏或不是 object 时会替换为失效 metadata 并写入对应 replacement marker,不阻断新信号创建。时间字段按 UTC 归一化为无时区 `datetime` 保存和比较;带时区输入会先转为 UTC 后去掉 `tzinfo`,无时区输入按 UTC 处理,API 响应继续返回不带时区后缀的 ISO 字符串。股票代码入库与查询按 `market` 确定性归一化:A 股 `600519`、`SH600519`、`600519.SH` 等常见变体按同一代码匹配;港股 `00700`、`HK00700`、`00700.HK` 按 `HK00700` 匹配;美股 ticker 统一大写。`holding_only=true` 只读取 active 账户下 `portfolio_positions` 中 `quantity > 0` 的缓存持仓,并按持仓 `(market, stock_code)` 匹配信号,可选 active `account_id`;该查询不会调用组合 snapshot replay,无缓存时返回空结果,需先通过 portfolio snapshot API 刷新缓存。
|
||||
|
||||
`source_report_id` 可为空且不强制校验历史记录存在;删除历史记录时只显式清理 `source_type=analysis` 且 `source_report_id` 命中实际删除 ID 的历史绑定信号,`manual/agent/alert/market_review` 等弱引用信号不会仅因 ID 碰撞被删除;列表接口支持按 `source_report_id` 和 `trace_id` 做 typed filter。`task_id`、`alert_trigger_id` 等后续关联字段先放入 `metadata`,P1 不新增独立列,也不提供 typed filter,后续联动阶段再提升为独立契约。JSON 字段、长文本字段和展示型短文本字段(`stock_name/source_agent/trigger_source/action_label`)会在写入前执行信号专用脱敏,覆盖敏感 key、Bearer、Authorization/Cookie header 或赋值、token-like 字符串、其他敏感赋值、webhook URL、URL userinfo 以及带敏感 query/fragment 参数的 URL;普通证据 URL 会保留以保证来源可追溯,且长文本不会套用诊断文本的 300 字符截断。`trace_id` 是同源去重身份字段,若包含会被脱敏的敏感 credential,API 会拒绝请求而不是保存有损 redaction 后的值。
|
||||
|
||||
这些接口继承现有 `/api/v1/*` 管理员鉴权:`ADMIN_AUTH_ENABLED=true` 时必须携带有效管理员会话 Cookie;本功能不新增独立认证方式。
|
||||
|
||||
#1390 P4 在 Web 端接入已有 `DecisionSignal` API,不新增后端契约、数据库表或配置项。侧边栏“AI 建议”入口 `/decision-signals` 是结构化决策信号的集中查询入口,默认展示 `status=active` 的信号,并支持按市场、股票代码、动作、市场阶段、来源、来源报告 ID 和状态筛选;页面还提供按股票代码查询最新 active 信号的入口。信号详情展示动作、置信度/评分、horizon、plan_quality、market_phase、价格计划、风险、观察条件、来源报告和数据质量;Web 只允许把信号标记为 `closed`、`invalidated` 或 `archived`,不提供 terminal 状态恢复为 active。
|
||||
#1390 P4 在 Web 端接入已有 `DecisionSignal` API。#1756 后侧边栏“AI 建议”入口 `/decision-signals` 仍是结构化决策信号的集中查询入口,默认展示 `status=active` 的信号,并支持按市场、股票代码、动作、市场阶段、来源、来源报告 ID 和状态筛选;时间线区域新增 profile filter,复用 list API 的 server-side `decision_profile` 查询,`unknown` 仅用于筛选和展示 legacy `NULL` 行,普通高级列表不新增 profile filter。页面还提供按股票代码查询最新 active 信号的入口。卡片、详情和时间线展示优先读取正式 `decision_profile` 字段,只有字段缺失时才 fallback legacy metadata;显式 `null`、历史缺失或非法 profile 显示为 unknown。信号详情展示动作、风格、置信度/评分、horizon、plan_quality、market_phase、价格计划、风险、观察条件、来源报告和数据质量;Web 只允许把信号标记为 `closed`、`invalidated` 或 `archived`,不提供 terminal 状态恢复为 active。
|
||||
|
||||
#1390 P5 新增信号级反馈、后验评估和统计 sidecar,不扩展 `decision_signals` 主表,也不复用绑定 `analysis_history_id` 的 `BacktestResult`。`decision_signal_feedback` 按 `signal_id` 保存最新 `useful|not_useful` 反馈、可选原因/备注和来源;`decision_signal_outcomes` 按 `(signal_id, horizon, engine_version)` 幂等保存后验结果,当前 `engine_version=decision-signal-v1`。Outcome 在评估时冻结 `action/market/market_phase/source_type/source_agent/plan_quality/data_quality_level/holding_state` 等统计维度,历史统计不依赖后续 live join 改写。删除历史报告时,会先找出 `source_type=analysis` 且绑定被删历史 ID 的信号,再清理对应 feedback/outcome 子表。
|
||||
|
||||
@@ -1419,7 +1419,7 @@ P5 在 Web `/decision-signals` 页面筛选区下方展示当前 outcome engine
|
||||
|
||||
#1390 P6 将 `DecisionSignal` 复用到告警、通知和组合风险,不新增表、迁移或配置。真实股票级告警触发会优先关联同标的 latest active 信号,并把低敏 `decision_signal_summary` 写入 `alert_triggers.diagnostics`;没有 active 信号时,worker 只创建最小 `source_type=alert`、`action=alert` 信号,`trace_id=alert-rule-<hash>` 仅用于同源重试的 best-effort 幂等去重,不覆盖 active 信号本体,且不写 `market_phase` 避免跨阶段重复。告警通知和分析通知只引用摘要中的 `action/horizon/reason/watch_conditions/risk_summary/source_report_id` 等公开字段,通知失败不影响 trigger 或信号写入。`GET /api/v1/portfolio/risk` 追加 `decision_signal_risk` 聚合块,只统计当前持仓中的 active `sell/reduce/alert` 信号,明确排除 `avoid/buy/add/hold/watch`;信号查询失败时风险接口 fail-open,Web 风险区显示降级状态。
|
||||
|
||||
#1390 P7 的收口文档见 [DecisionSignal 决策信号专题](decision-signals.md)。P7 不新增 `DECISION_SIGNAL_*` 配置、数据库 migration、API 字段或运行时开关;当前回滚方式为 revert 对应代码。回滚后信号提取和写入停止,既有报告保存、告警触发、通知发送和组合风险主流程不依赖信号池继续运行;历史 signal、feedback 和 outcome 数据不会自动清理。
|
||||
#1390 P7 的收口文档见 [DecisionSignal 决策信号专题](decision-signals.md)。#1756 不新增 `DECISION_SIGNAL_*` 配置或运行时开关,但会为 `decision_signals` 增加 nullable `decision_profile` 字段、API 请求/响应字段和 profile-aware index;existing SQLite 只在缺列时 `ALTER TABLE ADD COLUMN`,不会 drop/rebuild 表,也不会删除旧 index。迁移会幂等创建 profile-aware index,并 row-by-row 防御解析 `metadata_json`,仅合法 `metadata.decision_profile` 回填,invalid JSON、非 object 或非法 profile 保持 `NULL`。当前回滚方式为 revert 对应代码。回滚后信号提取和写入停止,既有报告保存、告警触发、通知发送和组合风险主流程不依赖信号池继续运行;历史 signal、feedback 和 outcome 数据不会自动清理。
|
||||
|
||||
普通个股历史报告详情不再内嵌展示该报告提取出的 `source_type=analysis` 信号,也不会因打开报告详情而发起 `source_report_id=<recordId>` 的信号查询;需要查看结构化 AI 建议时统一进入 `/decision-signals` 页面筛选来源报告 ID、打开 `/decision-signals?sourceReportId=<recordId>` deep link,或按股票查询。填写来源报告 ID 或使用该 URL 参数时,Web 会发起 `source_type=analysis + source_report_id=<recordId>` 的精确查询,不叠加默认 `status=active` 等其他列表筛选,以保留旧报告 best-effort 懒回填语义。
|
||||
|
||||
@@ -1529,7 +1529,7 @@ FastAPI 提供 RESTful API 服务,支持配置管理和触发分析。
|
||||
| `/api/v1/history/{record_id}/diagnostics` | GET | 查询历史报告运行诊断摘要与脱敏复制文本 |
|
||||
| `/api/v1/history/{record_id}/flow` | GET | 查询历史报告运行流快照,普通个股和 `MARKET/market_review` 大盘复盘复用同一契约 |
|
||||
| `/api/v1/decision-signals` | POST | 显式创建或按同源键去重决策信号,返回 `{ item, created }` |
|
||||
| `/api/v1/decision-signals` | GET | 分页查询决策信号,支持股票、市场、动作、阶段、来源、状态、时间范围和 cache-only 持仓过滤 |
|
||||
| `/api/v1/decision-signals` | GET | 分页查询决策信号,支持股票、市场、动作、阶段、风格、来源、状态、时间范围和 cache-only 持仓过滤 |
|
||||
| `/api/v1/decision-signals/outcomes/run` | POST | 显式触发信号后验评估,默认跳过 completed/终态 unable、重算可恢复 unable,`force=true` 重算覆盖 |
|
||||
| `/api/v1/decision-signals/outcomes` | GET | 分页查询信号后验结果 |
|
||||
| `/api/v1/decision-signals/outcomes/stats` | GET | 查询当前后验引擎统计,默认排除 archived 信号 |
|
||||
|
||||
@@ -1216,14 +1216,14 @@ For P2 automatic extraction, `market_phase` first reads `market_phase_summary.ph
|
||||
|
||||
Starting with P3, `DecisionSignalService` owns lifecycle defaults. Explicit `horizon` / `expires_at` values always win. When `horizon` is omitted, `alert` or `premarket/intraday/lunch_break/closing_auction` defaults to `intraday`, while `postmarket/non_trading/unknown` or missing phase context defaults to `3d`. When `expires_at` is omitted, `intraday` first uses `metadata.market_phase_summary.minutes_to_close/minutes_to_open`; without context it uses deterministic TTL fallback values (CN 4h, HK 5.5h, US 6.5h, unknown 4h). `1d/3d/5d/10d` use natural days, and `swing/long` do not auto-expire. The fallback TTL is only a no-context degradation path, not an exchange-calendar close time. Automatic extraction writes only low-sensitive `market_phase_summary.phase/session_date/minutes_to_open/minutes_to_close` hints into `metadata.market_phase_summary`; final `horizon/expires_at` values are still computed by the service.
|
||||
|
||||
Core fields include `stock_code`, `stock_name`, `market`, `source_type`, `source_agent`, `source_report_id`, `trace_id`, `market_phase`, `trigger_source`, `action`, `action_label`, `confidence`, `score`, `horizon`, `entry_low`, `entry_high`, `stop_loss`, `target_price`, `invalidation`, `watch_conditions`, `reason`, `risk_summary`, `catalyst_summary`, `evidence`, `data_quality_summary`, `plan_quality`, `status`, `expires_at`, `created_at`, `updated_at`, and `metadata`. `action` reuses the eight-state action taxonomy; `market_phase` reuses the market phase enum; `source_type` supports `analysis|agent|alert|market_review|manual`; `status` supports `active|expired|invalidated|closed|archived`; `horizon` supports `intraday|1d|3d|5d|10d|swing|long`.
|
||||
Core fields include `stock_code`, `stock_name`, `market`, `source_type`, `source_agent`, `source_report_id`, `trace_id`, `decision_profile`, `market_phase`, `trigger_source`, `action`, `action_label`, `confidence`, `score`, `horizon`, `entry_low`, `entry_high`, `stop_loss`, `target_price`, `invalidation`, `watch_conditions`, `reason`, `risk_summary`, `catalyst_summary`, `evidence`, `data_quality_summary`, `plan_quality`, `status`, `expires_at`, `created_at`, `updated_at`, and `metadata`. `action` reuses the eight-state action taxonomy; `decision_profile` supports `conservative|balanced|aggressive`, while database `NULL` means legacy/unknown only; `market_phase` reuses the market phase enum; `source_type` supports `analysis|agent|alert|market_review|manual`; `status` supports `active|expired|invalidated|closed|archived`; `horizon` supports `intraday|1d|3d|5d|10d|swing|long`.
|
||||
|
||||
`confidence` is `0.0-1.0`, and `score` is `0-100`, separate from historical `sentiment_score`. Price-plan fields `entry_low`, `entry_high`, `stop_loss`, and `target_price` must be finite positive numbers; when both `entry_low` and `entry_high` are present, `entry_low <= entry_high` is required. `plan_quality` supports `complete|partial|minimal|unknown`: a valid explicit value is saved as-is; otherwise the service computes it. The entry range (`entry_low` or `entry_high`) counts as one slot, and `stop_loss`, `target_price`, `invalidation`, and `watch_conditions` each count as one slot. Two slots produce `partial`, four or more produce `complete`, and action/reason without enough slots produces `minimal`.
|
||||
|
||||
New API endpoints:
|
||||
|
||||
- `POST /api/v1/decision-signals`: create or deduplicate a signal and return `{ item, created }` with HTTP 200. Exact deduplication uses `(source_report_id, source_type, market, stock_code, action, horizon, market_phase)` when `source_report_id` is present, or `(trace_id, source_type, market, stock_code, action, horizon, market_phase)` when only `trace_id` is present. Signals without either source identifier are not deduplicated. After an exact miss, a narrow relaxed fallback searches the same source plus `source_type/market/stock_code/action` and only fills old blank `horizon/market_phase` values. `horizon` can be filled only when the new value was generated by the service default; explicit different horizons or already different phases remain separate rows. When the same source key matches an expired signal and the new request is active with a future `expires_at`, the existing row is refreshed in place, still returns `created=false`, and that renewal is treated as a new active activation event. Active creation or expired renewal of a bullish signal (`buy/add`) invalidates earlier active defensive signals (`reduce/sell/avoid`) for the same stock, and the reverse also applies; active duplicate retries also rerun this repair to recover from a previous partial create where the signal was saved but invalidation failed; ordinary old duplicate/replay attempts are not treated as new activation events. `hold/watch/alert` do not trigger automatic invalidation. The API response schema is unchanged, and both refreshed and duplicate outcomes return `created=false`. P3 does not guarantee concurrent idempotency.
|
||||
- `GET /api/v1/decision-signals`: paginated query with `market`, `stock_code`, `action`, `market_phase`, `source_type`, `source_report_id`, `trace_id`, `trigger_source`, `status`, time ranges, `holding_only`, and `account_id`.
|
||||
- `POST /api/v1/decision-signals`: create or deduplicate a signal and return `{ item, created }` with HTTP 200. New writes may omit `decision_profile` and default to `balanced`, or pass a valid `conservative|balanced|aggressive` value; a top-level explicit `null`, empty value, or invalid value is rejected. Only when the top-level field is missing may the service fall back to a valid `metadata.decision_profile`, and before persistence it synchronizes `metadata.decision_profile` to the first-class field value. Omitted or explicit `null` metadata is treated as absent, objects are shallow-copied, and non-object metadata is rejected. Exact deduplication uses `(source_report_id, source_type, market, stock_code, decision_profile, action, horizon, market_phase)` when `source_report_id` is present, or `(trace_id, source_type, market, stock_code, decision_profile, action, horizon, market_phase)` when only `trace_id` is present. Signals without either source identifier are not deduplicated. Exact deduplication, relaxed fallback, horizon/phase fill, expired refresh, active invalidation, and stale backfill invalidation all use same-profile semantics: `NULL` only matches `NULL`, and a non-null profile only matches the same profile. Expired duplicate refresh never rewrites `decision_profile`. After an exact miss, a narrow relaxed fallback searches the same source plus `source_type/market/stock_code/decision_profile/action` and only fills old blank `horizon/market_phase` values. `horizon` can be filled only when the new value was generated by the service default; explicit different horizons, already different phases, or different profiles remain separate rows. When the same source key matches an expired same-profile signal and the new request is active with a future `expires_at`, the existing row is refreshed in place, still returns `created=false`, and that renewal is treated as a new active activation event. Active creation or expired renewal of a bullish signal (`buy/add`) invalidates earlier active defensive signals (`reduce/sell/avoid`) for the same stock and same profile, and the reverse also applies; different non-null profiles may coexist even for opposite active signals. Active duplicate retries also rerun the same-profile repair to recover from a previous partial create where the signal was saved but invalidation failed; ordinary old duplicate/replay attempts are not treated as new activation events. `hold/watch/alert` do not trigger automatic invalidation. Both refreshed and duplicate outcomes return `created=false`. P3 does not guarantee concurrent idempotency.
|
||||
- `GET /api/v1/decision-signals`: paginated query with `market`, `stock_code`, `action`, `market_phase`, `decision_profile`, `source_type`, `source_report_id`, `trace_id`, `trigger_source`, `status`, time ranges, `holding_only`, and `account_id`. Omitting or passing an empty `decision_profile` applies no profile condition and returns all profiles; `decision_profile=unknown` queries legacy `NULL` rows; valid profile values match exactly.
|
||||
- `POST /api/v1/decision-signals/outcomes/run`: explicitly trigger signal-level outcome evaluation; by default it skips completed and terminal unable rows, recomputes recoverable unable rows, and `force=true` recomputes and overwrites the current key.
|
||||
- `GET /api/v1/decision-signals/outcomes`: paginated query for signal outcome rows.
|
||||
- `GET /api/v1/decision-signals/outcomes/stats`: aggregate current outcome-engine stats; by default it excludes archived signals.
|
||||
@@ -1231,16 +1231,16 @@ New API endpoints:
|
||||
- `GET /api/v1/decision-signals/{signal_id}/feedback`: fetch the selected signal's user feedback; missing feedback returns `feedback_value=null`.
|
||||
- `PUT /api/v1/decision-signals/{signal_id}/feedback`: upsert the selected signal's latest `useful|not_useful` feedback.
|
||||
- `GET /api/v1/decision-signals/{signal_id}`: fetch one signal; missing IDs return 404.
|
||||
- `PATCH /api/v1/decision-signals/{signal_id}/status`: update a valid status and optional `metadata`; when `metadata` is provided it replaces the whole stored metadata object. `expired/invalidated/closed/archived` terminal states cannot be patched directly back to `active`; expired renewal still requires re-posting active data with a future `expires_at`.
|
||||
- `PATCH /api/v1/decision-signals/{signal_id}/status`: update a valid status and optional `metadata`; omitting metadata preserves the stored value, explicit `null` clears it to SQL `NULL`, and an object replaces the whole value. A non-null formal `decision_profile` overwrites a conflicting metadata profile; for a legacy formal `NULL`, the profile key is removed from the request object and the formal field is not promoted. `expired/invalidated/closed/archived` terminal states cannot be patched directly back to `active`; expired renewal still requires re-posting active data with a future `expires_at`.
|
||||
- `GET /api/v1/decision-signals/latest/{stock_code}`: return latest active signals for a stock, default `limit=1`.
|
||||
|
||||
Read paths lazily expire active signals whose `expires_at` has passed before list, detail, and latest queries; creating an already expired active signal stores it as `expired`; the same-source expired signal can only be extended by re-posting active data with a future `expires_at`, and `PATCH /status` does not accept `expires_at`. `expired|invalidated|closed|archived` cannot be patched directly back to active, and `closed|invalidated|archived` are not reactivated by the create path. Automatic opposite-signal invalidation merges these fields into the old signal metadata: `invalidated_by_signal_id`, `invalidated_reason`, `invalidated_at`, and `previous_status`. If old metadata JSON is corrupt, it is replaced with invalidation metadata plus `metadata_replaced_due_to_invalid_json=true`, and the new signal creation is not blocked. Time fields are normalized to UTC naive datetimes for storage and comparison; timezone-aware inputs are converted to UTC and stripped of `tzinfo`, naive inputs are treated as UTC, and API responses continue to return ISO strings without timezone suffixes. Stock codes are normalized deterministically by `market`: CN variants such as `600519`, `SH600519`, and `600519.SH` match the same stored code; HK variants such as `00700`, `HK00700`, and `00700.HK` match `HK00700`; US tickers are uppercased. `holding_only=true` reads only cached `portfolio_positions` rows with `quantity > 0` under active accounts and matches signals by the held `(market, stock_code)`, optionally scoped by an active `account_id`; it does not call portfolio snapshot replay. When no cache exists, it returns an empty result and callers should refresh the cache through the portfolio snapshot API first.
|
||||
Read paths lazily expire active signals whose `expires_at` has passed before list, detail, and latest queries; creating an already expired active signal stores it as `expired`; the same-source expired signal can only be extended by re-posting active data with a future `expires_at`, and `PATCH /status` does not accept `expires_at`. `expired|invalidated|closed|archived` cannot be patched directly back to active, and `closed|invalidated|archived` are not reactivated by the create path. Automatic opposite-signal invalidation merges these fields into the old signal metadata: `invalidated_by_signal_id`, `invalidated_reason`, `invalidated_at`, and `previous_status`. A non-null formal profile is synchronized into metadata; a legacy formal `NULL` preserves the existing legacy metadata profile and does not promote the formal field. If old metadata JSON is corrupt or not an object, it is replaced with invalidation metadata plus the corresponding replacement marker, and the new signal creation is not blocked. Time fields are normalized to UTC naive datetimes for storage and comparison; timezone-aware inputs are converted to UTC and stripped of `tzinfo`, naive inputs are treated as UTC, and API responses continue to return ISO strings without timezone suffixes. Stock codes are normalized deterministically by `market`: CN variants such as `600519`, `SH600519`, and `600519.SH` match the same stored code; HK variants such as `00700`, `HK00700`, and `00700.HK` match `HK00700`; US tickers are uppercased. `holding_only=true` reads only cached `portfolio_positions` rows with `quantity > 0` under active accounts and matches signals by the held `(market, stock_code)`, optionally scoped by an active `account_id`; it does not call portfolio snapshot replay. When no cache exists, it returns an empty result and callers should refresh the cache through the portfolio snapshot API first.
|
||||
|
||||
`source_report_id` is nullable and is not required to reference an existing history row; deleting history records explicitly removes only history-bound signals with `source_type=analysis` whose `source_report_id` matches actually deleted IDs, so `manual/agent/alert/market_review` weak-reference signals are not deleted solely because of an ID collision. The list endpoint supports typed filters for `source_report_id` and `trace_id`. Follow-up association fields such as `task_id` and `alert_trigger_id` should be stored in `metadata` for P1; P1 does not add dedicated columns or typed filters for them, which are deferred to the later integration phase. JSON fields, long text fields, and public short text fields (`stock_name/source_agent/trigger_source/action_label`) are sanitized before persistence with a signal-specific sanitizer that redacts sensitive keys, Bearer values, Authorization/Cookie headers or assignments, token-like strings, other sensitive assignments, webhook URLs, URL userinfo, and URLs with sensitive query or fragment parameters. Ordinary evidence URLs are preserved for source traceability, and long text does not use the diagnostics 300-character truncation. `trace_id` is a same-source identity field; if it contains sensitive credentials that would be redacted, the API rejects the request instead of storing a lossy redacted value.
|
||||
|
||||
These endpoints inherit the existing `/api/v1/*` admin authentication middleware: when `ADMIN_AUTH_ENABLED=true`, callers must send a valid admin session cookie. DecisionSignal does not add a separate auth scheme.
|
||||
|
||||
#1390 P4 wires the existing `DecisionSignal` API into the Web UI without adding backend contracts, database tables, or configuration. The sidebar "AI signals" entry at `/decision-signals` is the centralized query surface for structured decision signals; the page defaults to `status=active`, supports filtering by market, stock code, action, market phase, source, source report ID, and status, and includes a latest-active lookup by stock code. Signal details show action, confidence/score, horizon, plan_quality, market_phase, price plan, risk, watch conditions, source report, and data quality. The Web UI only allows marking a signal as `closed`, `invalidated`, or `archived`; it does not restore terminal states to active.
|
||||
#1390 P4 wires the existing `DecisionSignal` API into the Web UI. After #1756, the sidebar "AI signals" entry at `/decision-signals` remains the centralized query surface for structured decision signals; the page defaults to `status=active`, supports filtering by market, stock code, action, market phase, source, source report ID, and status, and the timeline adds a profile filter backed by the server-side list `decision_profile` parameter. `unknown` is used only to filter and display legacy `NULL` rows, and the regular advanced list does not add a profile filter. The page also includes a latest-active lookup by stock code. Cards, details, and timeline displays read the first-class `decision_profile` field first and fall back to legacy metadata only when that field is missing; explicit `null`, missing history, or invalid profile values display as unknown. Signal details show action, profile, confidence/score, horizon, plan_quality, market_phase, price plan, risk, watch conditions, source report, and data quality. The Web UI only allows marking a signal as `closed`, `invalidated`, or `archived`; it does not restore terminal states to active.
|
||||
|
||||
#1390 P5 adds signal-level feedback, forward outcome evaluation, and stats sidecars. It does not extend the `decision_signals` main table and does not reuse `BacktestResult`, which is tied to `analysis_history_id`. `decision_signal_feedback` stores the latest `useful|not_useful` feedback per `signal_id` with optional reason/note/source. `decision_signal_outcomes` stores idempotent rows by `(signal_id, horizon, engine_version)`, currently `engine_version=decision-signal-v1`. Each outcome freezes `action/market/market_phase/source_type/source_agent/plan_quality/data_quality_level/holding_state` at evaluation time so historical stats are not rewritten by later live-join changes. Deleting history first finds `source_type=analysis` signals bound to the deleted history IDs, then removes their feedback/outcome sidecars.
|
||||
|
||||
@@ -1252,7 +1252,7 @@ The portfolio page loads AI signals as a non-blocking enhancement: portfolio sna
|
||||
|
||||
#1390 P6 reuses `DecisionSignal` across alerts, notifications, and portfolio risk without adding tables, migrations, or configuration. Real stock-level alert triggers first link the latest active signal for the same symbol and write a low-sensitive `decision_signal_summary` into `alert_triggers.diagnostics`; when no active signal exists, the worker creates only a minimal `source_type=alert`, `action=alert` signal. Its `trace_id=alert-rule-<hash>` is for best-effort retry de-duplication, not active-signal overwrites, and the payload intentionally omits `market_phase` to avoid cross-phase duplicates. Alert and analysis notifications reference only public summary fields such as `action/horizon/reason/watch_conditions/risk_summary/source_report_id`, and notification failure does not block trigger or signal writes. `GET /api/v1/portfolio/risk` now includes a `decision_signal_risk` block that counts active `sell/reduce/alert` signals for current holdings, explicitly excluding `avoid/buy/add/hold/watch`; if signal lookup fails, the risk endpoint fails open and the Web risk card shows a degraded state.
|
||||
|
||||
#1390 P7 is documented in [DecisionSignal Topic](decision-signals.md) (Chinese-only). P7 adds no `DECISION_SIGNAL_*` configuration, database migration, API field, or runtime switch. Rollback is to revert the related code. After rollback, signal extraction and writes stop, while report saving, alert triggering, notification sending, and the portfolio risk main flow continue through their existing paths. Historical signal, feedback, and outcome rows are not deleted automatically.
|
||||
#1390 P7 is documented in [DecisionSignal Topic](decision-signals.md) (Chinese-only). #1756 adds no `DECISION_SIGNAL_*` configuration or runtime switch, but it does add a nullable `decision_profile` column, API request/response field, and profile-aware indexes for `decision_signals`. Existing SQLite databases only run `ALTER TABLE ADD COLUMN` when the column is missing; the migration does not drop/rebuild `decision_signals` and does not delete old indexes. It creates profile-aware indexes idempotently and defensively parses `metadata_json` row by row, backfilling only legal `metadata.decision_profile` values while keeping invalid JSON, non-object metadata, or invalid profiles as `NULL`. Rollback is to revert the related code. After rollback, signal extraction and writes stop, while report saving, alert triggering, notification sending, and the portfolio risk main flow continue through their existing paths. Historical signal, feedback, and outcome rows are not deleted automatically.
|
||||
|
||||
Regular stock history report details no longer embed the extracted `source_type=analysis` signals and no longer issue a `source_report_id=<recordId>` query when the report details open. To inspect structured AI recommendations, use `/decision-signals` and filter by source report ID, open the `/decision-signals?sourceReportId=<recordId>` deep link, or search by stock. When source report ID is filled or provided through that URL parameter, the Web UI sends an exact `source_type=analysis + source_report_id=<recordId>` query without adding default `status=active` or other list filters, preserving the best-effort lazy backfill semantics for older reports.
|
||||
|
||||
@@ -1361,7 +1361,7 @@ For this feature, the product behavior is:
|
||||
| `/api/v1/history` | GET | Query analysis history |
|
||||
| `/api/v1/history/{record_id}/diagnostics` | GET | Query a historical report run diagnostic summary and sanitized copy text |
|
||||
| `/api/v1/decision-signals` | POST | Explicitly create or deduplicate a decision signal and return `{ item, created }` |
|
||||
| `/api/v1/decision-signals` | GET | Paginated decision-signal query with stock, market, action, phase, source, status, time-range, and cache-only holdings filters |
|
||||
| `/api/v1/decision-signals` | GET | Paginated decision-signal query with stock, market, action, phase, profile, source, status, time-range, and cache-only holdings filters |
|
||||
| `/api/v1/decision-signals/outcomes/run` | POST | Explicitly trigger signal outcome evaluation; by default skips completed/terminal unable rows, recomputes recoverable unable rows, and `force=true` recomputes |
|
||||
| `/api/v1/decision-signals/outcomes` | GET | Paginated signal outcome query |
|
||||
| `/api/v1/decision-signals/outcomes/stats` | GET | Query current outcome-engine stats; archived signals are excluded by default |
|
||||
|
||||
@@ -9,6 +9,10 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, desc, func, or_, select
|
||||
|
||||
from src.schemas.decision_profile import (
|
||||
DECISION_PROFILE_FILTER_ALL,
|
||||
DecisionProfileFilter,
|
||||
)
|
||||
from src.storage import (
|
||||
DatabaseManager,
|
||||
DecisionSignalRecord,
|
||||
@@ -46,6 +50,7 @@ class DecisionSignalRepository:
|
||||
"trigger_source",
|
||||
"market",
|
||||
"stock_code",
|
||||
"decision_profile",
|
||||
"action",
|
||||
"horizon",
|
||||
"market_phase",
|
||||
@@ -159,6 +164,7 @@ class DecisionSignalRepository:
|
||||
market: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
market_phase: Optional[str] = None,
|
||||
decision_profile_filter: DecisionProfileFilter = DECISION_PROFILE_FILTER_ALL,
|
||||
source_type: Optional[str] = None,
|
||||
source_report_id: Optional[int] = None,
|
||||
trace_id: Optional[str] = None,
|
||||
@@ -182,6 +188,7 @@ class DecisionSignalRepository:
|
||||
market=market,
|
||||
action=action,
|
||||
market_phase=market_phase,
|
||||
decision_profile_filter=decision_profile_filter,
|
||||
source_type=source_type,
|
||||
source_report_id=source_report_id,
|
||||
trace_id=trace_id,
|
||||
@@ -242,6 +249,7 @@ class DecisionSignalRepository:
|
||||
market: str,
|
||||
stock_code: str,
|
||||
actions: List[str],
|
||||
decision_profile: Optional[str],
|
||||
exclude_signal_id: Optional[int] = None,
|
||||
) -> List[DecisionSignalRecord]:
|
||||
self.expire_due_signals()
|
||||
@@ -251,6 +259,7 @@ class DecisionSignalRepository:
|
||||
DecisionSignalRecord.status == "active",
|
||||
DecisionSignalRecord.market == market,
|
||||
DecisionSignalRecord.stock_code == stock_code,
|
||||
self._same_profile_condition(decision_profile),
|
||||
DecisionSignalRecord.action.in_(actions),
|
||||
]
|
||||
if exclude_signal_id is not None:
|
||||
@@ -344,12 +353,14 @@ class DecisionSignalRepository:
|
||||
action = fields.get("action")
|
||||
horizon = fields.get("horizon")
|
||||
market_phase = fields.get("market_phase")
|
||||
decision_profile = fields.get("decision_profile")
|
||||
if source_report_id is not None:
|
||||
conditions = [
|
||||
DecisionSignalRecord.source_report_id == source_report_id,
|
||||
DecisionSignalRecord.source_type == source_type,
|
||||
DecisionSignalRecord.market == market,
|
||||
DecisionSignalRecord.stock_code == stock_code,
|
||||
DecisionSignalRepository._same_profile_condition(decision_profile),
|
||||
DecisionSignalRecord.action == action,
|
||||
DecisionSignalRecord.horizon == horizon,
|
||||
DecisionSignalRecord.market_phase == market_phase,
|
||||
@@ -360,6 +371,7 @@ class DecisionSignalRepository:
|
||||
DecisionSignalRecord.source_type == source_type,
|
||||
DecisionSignalRecord.market == market,
|
||||
DecisionSignalRecord.stock_code == stock_code,
|
||||
DecisionSignalRepository._same_profile_condition(decision_profile),
|
||||
DecisionSignalRecord.action == action,
|
||||
DecisionSignalRecord.horizon == horizon,
|
||||
DecisionSignalRecord.market_phase == market_phase,
|
||||
@@ -390,6 +402,7 @@ class DecisionSignalRepository:
|
||||
DecisionSignalRecord.source_type == fields.get("source_type"),
|
||||
DecisionSignalRecord.market == fields.get("market"),
|
||||
DecisionSignalRecord.stock_code == fields.get("stock_code"),
|
||||
cls._same_profile_condition(fields.get("decision_profile")),
|
||||
DecisionSignalRecord.action == fields.get("action"),
|
||||
]
|
||||
if source_report_id is not None:
|
||||
@@ -457,14 +470,16 @@ class DecisionSignalRepository:
|
||||
existing.updated_at = utc_naive_now()
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
def _build_conditions(
|
||||
cls,
|
||||
*,
|
||||
stock_codes: Optional[List[str]],
|
||||
stock_identities: Optional[List[Tuple[str, str]]],
|
||||
market: Optional[str],
|
||||
action: Optional[str],
|
||||
market_phase: Optional[str],
|
||||
decision_profile_filter: DecisionProfileFilter,
|
||||
source_type: Optional[str],
|
||||
source_report_id: Optional[int],
|
||||
trace_id: Optional[str],
|
||||
@@ -493,6 +508,7 @@ class DecisionSignalRepository:
|
||||
conditions.append(DecisionSignalRecord.action == action)
|
||||
if market_phase:
|
||||
conditions.append(DecisionSignalRecord.market_phase == market_phase)
|
||||
cls._append_profile_filter_condition(conditions, decision_profile_filter)
|
||||
if source_type:
|
||||
conditions.append(DecisionSignalRecord.source_type == source_type)
|
||||
if source_report_id is not None:
|
||||
@@ -512,3 +528,22 @@ class DecisionSignalRepository:
|
||||
if expires_to:
|
||||
conditions.append(DecisionSignalRecord.expires_at <= expires_to)
|
||||
return conditions
|
||||
|
||||
@staticmethod
|
||||
def _same_profile_condition(profile: Optional[str]) -> Any:
|
||||
if profile is None:
|
||||
return DecisionSignalRecord.decision_profile.is_(None)
|
||||
return DecisionSignalRecord.decision_profile == profile
|
||||
|
||||
@classmethod
|
||||
def _append_profile_filter_condition(
|
||||
cls,
|
||||
conditions: List[Any],
|
||||
decision_profile_filter: DecisionProfileFilter,
|
||||
) -> None:
|
||||
if decision_profile_filter.is_all:
|
||||
return
|
||||
if decision_profile_filter.is_unknown:
|
||||
conditions.append(DecisionSignalRecord.decision_profile.is_(None))
|
||||
return
|
||||
conditions.append(cls._same_profile_condition(decision_profile_filter.profile))
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Decision profile domain helpers for persisted DecisionSignal identity.
|
||||
|
||||
SQL same-profile semantics are intentionally null-safe:
|
||||
|
||||
- ``profile is None`` means legacy/unknown profile and must be expressed as
|
||||
``decision_profile IS NULL``.
|
||||
- a non-null profile must be expressed as ``decision_profile = <profile>``.
|
||||
|
||||
Do not use bare ``None`` to mean "all profiles" in list filters. Use the
|
||||
normalized filter object below so omitted/empty filters and ``unknown`` remain
|
||||
distinct.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
|
||||
DecisionProfile = Literal["conservative", "balanced", "aggressive"]
|
||||
DecisionProfileFilterKind = Literal["all", "unknown", "profile"]
|
||||
|
||||
VALID_DECISION_PROFILES: tuple[DecisionProfile, ...] = (
|
||||
"conservative",
|
||||
"balanced",
|
||||
"aggressive",
|
||||
)
|
||||
DECISION_PROFILE_UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecisionProfileFilter:
|
||||
kind: DecisionProfileFilterKind
|
||||
profile: Optional[DecisionProfile] = None
|
||||
|
||||
@property
|
||||
def is_all(self) -> bool:
|
||||
return self.kind == "all"
|
||||
|
||||
@property
|
||||
def is_unknown(self) -> bool:
|
||||
return self.kind == "unknown"
|
||||
|
||||
|
||||
DECISION_PROFILE_FILTER_ALL = DecisionProfileFilter("all")
|
||||
DECISION_PROFILE_FILTER_UNKNOWN = DecisionProfileFilter("unknown")
|
||||
|
||||
|
||||
def normalize_decision_profile(
|
||||
value: Any,
|
||||
*,
|
||||
field_name: str = "decision_profile",
|
||||
) -> Optional[DecisionProfile]:
|
||||
"""Return a normalized profile or raise for a non-empty invalid value."""
|
||||
|
||||
if value in (None, ""):
|
||||
return None
|
||||
text = str(value).strip().lower()
|
||||
if not text:
|
||||
return None
|
||||
if text in VALID_DECISION_PROFILES:
|
||||
return text # type: ignore[return-value]
|
||||
allowed = ", ".join(VALID_DECISION_PROFILES)
|
||||
raise ValueError(f"{field_name} must be one of: {allowed}")
|
||||
|
||||
|
||||
def normalize_decision_profile_filter(value: Any) -> DecisionProfileFilter:
|
||||
"""Normalize list-filter input while preserving all-vs-unknown semantics."""
|
||||
|
||||
if value in (None, ""):
|
||||
return DECISION_PROFILE_FILTER_ALL
|
||||
text = str(value).strip().lower()
|
||||
if not text:
|
||||
return DECISION_PROFILE_FILTER_ALL
|
||||
if text == DECISION_PROFILE_UNKNOWN:
|
||||
return DECISION_PROFILE_FILTER_UNKNOWN
|
||||
profile = normalize_decision_profile(text, field_name="decision_profile")
|
||||
return DecisionProfileFilter("profile", profile)
|
||||
|
||||
|
||||
def extract_legacy_decision_profile(metadata: Any) -> Optional[DecisionProfile]:
|
||||
"""Extract a legal legacy profile from metadata; invalid values are ignored."""
|
||||
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
try:
|
||||
return normalize_decision_profile(metadata.get("decision_profile"))
|
||||
except ValueError:
|
||||
return None
|
||||
@@ -146,6 +146,7 @@ def build_decision_signal_payload_from_report(
|
||||
"source_type": "analysis",
|
||||
"source_report_id": source_report_id,
|
||||
"trace_id": trace_id,
|
||||
"decision_profile": "balanced",
|
||||
"market_phase": _extract_market_phase(context_snapshot, result),
|
||||
"trigger_source": str(query_source or "").strip() or "system",
|
||||
"action": action,
|
||||
|
||||
@@ -8,6 +8,7 @@ from collections.abc import Mapping
|
||||
from typing import Any, Optional
|
||||
|
||||
from src.schemas.decision_action import build_action_fields, normalize_decision_action
|
||||
from src.schemas.decision_profile import normalize_decision_profile
|
||||
from src.schemas.decision_scale import action_for_score, score_action_conflicts_without_guardrail
|
||||
from src.services.decision_profile_policy import (
|
||||
PROFILE_POLICY_VERSION,
|
||||
@@ -23,8 +24,7 @@ from src.utils.sniper_points import find_sniper_points, parse_sniper_value
|
||||
|
||||
|
||||
UNSUPPORTED_PERSIST_MESSAGE = (
|
||||
"Persisting reassessed decision_profile signals requires decision_profile "
|
||||
"to be promoted to a first-class field."
|
||||
"Persisting reassessed decision_profile signals is tracked by #1757."
|
||||
)
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ class DecisionSignalReassessService:
|
||||
) -> dict[str, Any]:
|
||||
if persist:
|
||||
raise DecisionSignalReassessUnsupportedOperationError(UNSUPPORTED_PERSIST_MESSAGE)
|
||||
decision_profile_norm = normalize_decision_profile(decision_profile)
|
||||
if decision_profile_norm is None:
|
||||
raise ValueError("decision_profile is required")
|
||||
|
||||
record = self.db.get_analysis_history_by_id(source_report_id)
|
||||
if record is None:
|
||||
@@ -77,12 +80,12 @@ class DecisionSignalReassessService:
|
||||
)
|
||||
policy = apply_decision_profile_policy(
|
||||
candidate,
|
||||
decision_profile=decision_profile,
|
||||
decision_profile=decision_profile_norm,
|
||||
data_quality_level=data_quality_level,
|
||||
)
|
||||
preview_candidate = policy.candidate
|
||||
metadata = {
|
||||
"decision_profile": decision_profile,
|
||||
"decision_profile": decision_profile_norm,
|
||||
"profile_source": "user_selected",
|
||||
"profile_policy_version": PROFILE_POLICY_VERSION,
|
||||
"signal_generation_version": SIGNAL_GENERATION_VERSION,
|
||||
|
||||
@@ -20,6 +20,13 @@ from src.schemas.decision_action import (
|
||||
localize_action_label,
|
||||
normalize_decision_action,
|
||||
)
|
||||
from src.schemas.decision_profile import (
|
||||
DecisionProfileFilter,
|
||||
VALID_DECISION_PROFILES,
|
||||
extract_legacy_decision_profile,
|
||||
normalize_decision_profile,
|
||||
normalize_decision_profile_filter,
|
||||
)
|
||||
from src.schemas.decision_scale import action_for_score, score_action_conflicts_without_guardrail
|
||||
from src.services.portfolio_service import VALID_MARKETS
|
||||
from src.storage import (
|
||||
@@ -106,6 +113,7 @@ class DecisionSignalService:
|
||||
market: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
market_phase: Optional[str] = None,
|
||||
decision_profile: Optional[Any] = None,
|
||||
source_type: Optional[str] = None,
|
||||
source_report_id: Optional[Any] = None,
|
||||
trace_id: Optional[str] = None,
|
||||
@@ -126,6 +134,7 @@ class DecisionSignalService:
|
||||
market_norm = self._normalize_optional_market(market)
|
||||
action_norm = self._normalize_optional_action(action)
|
||||
market_phase_norm = self._normalize_optional_enum(market_phase, MARKET_PHASES, "market_phase")
|
||||
decision_profile_filter = normalize_decision_profile_filter(decision_profile)
|
||||
source_type_norm = self._normalize_optional_enum(source_type, SOURCE_TYPES, "source_type")
|
||||
source_report_id_norm = self._optional_int(source_report_id, "source_report_id")
|
||||
trace_id_norm = self._optional_identity_text(trace_id, "trace_id", max_length=64)
|
||||
@@ -178,6 +187,7 @@ class DecisionSignalService:
|
||||
market=market_norm,
|
||||
action=action_norm,
|
||||
market_phase=market_phase_norm,
|
||||
decision_profile_filter=decision_profile_filter,
|
||||
source_type=source_type_norm,
|
||||
source_report_id=source_report_id_norm,
|
||||
trace_id=trace_id_norm,
|
||||
@@ -195,6 +205,7 @@ class DecisionSignalService:
|
||||
market=market_norm,
|
||||
action=action_norm,
|
||||
market_phase=market_phase_norm,
|
||||
decision_profile_filter=decision_profile_filter,
|
||||
source_type=source_type_norm,
|
||||
source_report_id=source_report_id_norm,
|
||||
trace_id=trace_id_norm,
|
||||
@@ -214,6 +225,7 @@ class DecisionSignalService:
|
||||
market=market_norm,
|
||||
action=action_norm,
|
||||
market_phase=market_phase_norm,
|
||||
decision_profile_filter=decision_profile_filter,
|
||||
source_type=source_type_norm,
|
||||
source_report_id=source_report_id_norm,
|
||||
trace_id=trace_id_norm,
|
||||
@@ -264,7 +276,6 @@ class DecisionSignalService:
|
||||
replace_metadata: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
status_norm = self._normalize_enum(status, SIGNAL_STATUSES, "status")
|
||||
metadata_json = self._json_dumps(metadata) if replace_metadata else None
|
||||
existing = self.repo.get(signal_id)
|
||||
if existing is None:
|
||||
raise DecisionSignalNotFoundError(f"Decision signal not found: {signal_id}")
|
||||
@@ -272,6 +283,20 @@ class DecisionSignalService:
|
||||
existing.status in TERMINAL_STATUSES or self._is_expired(existing.expires_at)
|
||||
):
|
||||
raise ValueError("terminal decision signal cannot be reactivated through status update")
|
||||
metadata_json = None
|
||||
if replace_metadata:
|
||||
if isinstance(metadata, dict):
|
||||
normalized_metadata = dict(metadata)
|
||||
if existing.decision_profile is None:
|
||||
normalized_metadata.pop("decision_profile", None)
|
||||
else:
|
||||
normalized_metadata = self._synchronize_metadata_decision_profile(
|
||||
normalized_metadata,
|
||||
existing.decision_profile,
|
||||
)
|
||||
metadata_json = self._json_dumps(normalized_metadata)
|
||||
else:
|
||||
metadata_json = self._json_dumps(metadata)
|
||||
row = self.repo.update_status(
|
||||
signal_id,
|
||||
status=status_norm,
|
||||
@@ -289,6 +314,7 @@ class DecisionSignalService:
|
||||
market: Optional[str],
|
||||
action: Optional[str],
|
||||
market_phase: Optional[str],
|
||||
decision_profile_filter: DecisionProfileFilter,
|
||||
source_type: Optional[str],
|
||||
source_report_id: Optional[int],
|
||||
trace_id: Optional[str],
|
||||
@@ -305,6 +331,13 @@ class DecisionSignalService:
|
||||
|
||||
if source_type != "analysis" or source_report_id is None:
|
||||
return False
|
||||
if decision_profile_filter.is_unknown:
|
||||
return False
|
||||
if (
|
||||
not decision_profile_filter.is_all
|
||||
and decision_profile_filter.profile != "balanced"
|
||||
):
|
||||
return False
|
||||
return not any(
|
||||
value not in (None, "", False)
|
||||
for value in (
|
||||
@@ -571,6 +604,7 @@ class DecisionSignalService:
|
||||
market=row.market,
|
||||
stock_code=row.stock_code,
|
||||
actions=sorted(opposing_actions),
|
||||
decision_profile=row.decision_profile,
|
||||
exclude_signal_id=row.id,
|
||||
)
|
||||
for newer_row in newer_rows:
|
||||
@@ -639,6 +673,23 @@ class DecisionSignalService:
|
||||
if not action_label:
|
||||
action_label = localize_action_label(action, report_language)
|
||||
|
||||
raw_metadata = payload.get("metadata")
|
||||
if raw_metadata is None:
|
||||
metadata: Dict[str, Any] = {}
|
||||
elif isinstance(raw_metadata, dict):
|
||||
metadata = dict(raw_metadata)
|
||||
else:
|
||||
raise ValueError("metadata must be an object")
|
||||
|
||||
if "decision_profile" in payload:
|
||||
decision_profile = normalize_decision_profile(payload.get("decision_profile"))
|
||||
if decision_profile is None:
|
||||
allowed = ", ".join(VALID_DECISION_PROFILES)
|
||||
raise ValueError(f"decision_profile must be one of: {allowed}")
|
||||
else:
|
||||
decision_profile = extract_legacy_decision_profile(metadata) or "balanced"
|
||||
metadata = self._synchronize_metadata_decision_profile(metadata, decision_profile)
|
||||
|
||||
confidence = self._optional_float(payload.get("confidence"), "confidence")
|
||||
if confidence is not None and not 0.0 <= confidence <= 1.0:
|
||||
raise ValueError("confidence must be between 0.0 and 1.0")
|
||||
@@ -659,7 +710,7 @@ class DecisionSignalService:
|
||||
expires_at = self._default_expires_at(
|
||||
horizon=horizon,
|
||||
market=market,
|
||||
metadata=payload.get("metadata"),
|
||||
metadata=metadata,
|
||||
)
|
||||
created_at = self._parse_datetime(payload.get("_created_at_override"))
|
||||
|
||||
@@ -671,6 +722,7 @@ class DecisionSignalService:
|
||||
"source_agent": self._optional_public_text(payload.get("source_agent"), "source_agent", max_length=64),
|
||||
"source_report_id": self._optional_int(payload.get("source_report_id"), "source_report_id"),
|
||||
"trace_id": self._optional_identity_text(payload.get("trace_id"), "trace_id", max_length=64),
|
||||
"decision_profile": decision_profile,
|
||||
"market_phase": market_phase,
|
||||
"trigger_source": self._normalize_trigger_source(payload.get("trigger_source")),
|
||||
"action": action,
|
||||
@@ -691,7 +743,7 @@ class DecisionSignalService:
|
||||
"data_quality_summary_json": self._json_dumps(payload.get("data_quality_summary")),
|
||||
"status": self._normalize_optional_enum(payload.get("status"), SIGNAL_STATUSES, "status") or "active",
|
||||
"expires_at": expires_at,
|
||||
"metadata_json": self._json_dumps(payload.get("metadata")),
|
||||
"metadata_json": self._json_dumps(metadata),
|
||||
}
|
||||
if created_at is not None:
|
||||
fields["created_at"] = created_at
|
||||
@@ -792,6 +844,7 @@ class DecisionSignalService:
|
||||
market=row.market,
|
||||
stock_code=row.stock_code,
|
||||
actions=sorted(opposing_actions),
|
||||
decision_profile=row.decision_profile,
|
||||
exclude_signal_id=row.id,
|
||||
)
|
||||
for old_row in old_rows:
|
||||
@@ -850,19 +903,34 @@ class DecisionSignalService:
|
||||
"invalidated_at": utc_naive_now().isoformat(),
|
||||
"previous_status": row.status,
|
||||
})
|
||||
if row.decision_profile is not None:
|
||||
metadata = self._synchronize_metadata_decision_profile(
|
||||
metadata,
|
||||
row.decision_profile,
|
||||
)
|
||||
return self._json_dumps(metadata)
|
||||
|
||||
@staticmethod
|
||||
def _synchronize_metadata_decision_profile(
|
||||
metadata: Dict[str, Any],
|
||||
decision_profile: str,
|
||||
) -> Dict[str, Any]:
|
||||
normalized = dict(metadata)
|
||||
normalized["decision_profile"] = decision_profile
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _metadata_for_invalidation(row: DecisionSignalRecord) -> Dict[str, Any]:
|
||||
if not row.metadata_json:
|
||||
return {}
|
||||
try:
|
||||
value = json.loads(row.metadata_json)
|
||||
except json.JSONDecodeError as exc:
|
||||
except (TypeError, ValueError, RecursionError) as exc:
|
||||
logger.warning(
|
||||
"Replacing invalid decision signal metadata during invalidation: id=%s error=%s",
|
||||
"Replacing invalid decision signal metadata during invalidation: "
|
||||
"id=%s error_type=%s",
|
||||
row.id,
|
||||
exc,
|
||||
type(exc).__name__,
|
||||
)
|
||||
return {"metadata_replaced_due_to_invalid_json": True}
|
||||
if isinstance(value, dict):
|
||||
@@ -1141,6 +1209,7 @@ class DecisionSignalService:
|
||||
"source_agent": row.source_agent,
|
||||
"source_report_id": row.source_report_id,
|
||||
"trace_id": row.trace_id,
|
||||
"decision_profile": row.decision_profile,
|
||||
"market_phase": row.market_phase,
|
||||
"trigger_source": row.trigger_source,
|
||||
"action": row.action,
|
||||
|
||||
+213
@@ -57,6 +57,7 @@ from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
|
||||
from src.agent.provider_trace import PROVIDER_TRACE_RETENTION_LIMIT
|
||||
from src.config import get_config
|
||||
from src.schemas.decision_profile import extract_legacy_decision_profile
|
||||
from src.utils.sniper_points import extract_sniper_points, parse_sniper_value
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -993,6 +994,7 @@ class DecisionSignalRecord(Base):
|
||||
source_agent = Column(String(64))
|
||||
source_report_id = Column(Integer, index=True)
|
||||
trace_id = Column(String(64), index=True)
|
||||
decision_profile = Column(String(16), index=True)
|
||||
market_phase = Column(String(24), index=True)
|
||||
trigger_source = Column(String(64), nullable=False, index=True)
|
||||
action = Column(String(16), nullable=False, index=True)
|
||||
@@ -1041,6 +1043,35 @@ class DecisionSignalRecord(Base):
|
||||
'horizon',
|
||||
'market_phase',
|
||||
),
|
||||
Index(
|
||||
'ix_decision_signal_report_type_market_stock_profile_action_horizon_phase',
|
||||
'source_report_id',
|
||||
'source_type',
|
||||
'market',
|
||||
'stock_code',
|
||||
'decision_profile',
|
||||
'action',
|
||||
'horizon',
|
||||
'market_phase',
|
||||
),
|
||||
Index(
|
||||
'ix_decision_signal_trace_type_market_stock_profile_action_horizon_phase',
|
||||
'trace_id',
|
||||
'source_type',
|
||||
'market',
|
||||
'stock_code',
|
||||
'decision_profile',
|
||||
'action',
|
||||
'horizon',
|
||||
'market_phase',
|
||||
),
|
||||
Index(
|
||||
'ix_decision_signal_market_stock_profile_created',
|
||||
'market',
|
||||
'stock_code',
|
||||
'decision_profile',
|
||||
'created_at',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1181,6 +1212,7 @@ class DatabaseManager(metaclass=_DatabaseManagerMeta):
|
||||
# 创建所有表
|
||||
Base.metadata.create_all(self._engine)
|
||||
self._ensure_llm_usage_telemetry_columns()
|
||||
self._ensure_decision_signal_profile_schema()
|
||||
self._ensure_intelligence_item_scope_values()
|
||||
self._ensure_schema_migration_record()
|
||||
self._ensure_intelligence_items_unique_index()
|
||||
@@ -1228,6 +1260,187 @@ class DatabaseManager(metaclass=_DatabaseManagerMeta):
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _ensure_decision_signal_profile_schema(self) -> None:
|
||||
"""Add and backfill nullable decision_profile for existing SQLite DBs."""
|
||||
|
||||
if not self._is_sqlite_engine:
|
||||
return
|
||||
inspector = inspect(self._engine)
|
||||
if not inspector.has_table(DecisionSignalRecord.__tablename__):
|
||||
return
|
||||
|
||||
try:
|
||||
existing = {
|
||||
column["name"]
|
||||
for column in inspector.get_columns(DecisionSignalRecord.__tablename__)
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"[DecisionSignal] failed to inspect decision_profile column; "
|
||||
"profile migration cannot continue safely: %s",
|
||||
exc,
|
||||
)
|
||||
raise
|
||||
|
||||
if "decision_profile" not in existing:
|
||||
try:
|
||||
with self._engine.begin() as connection:
|
||||
connection.exec_driver_sql(
|
||||
f"ALTER TABLE {DecisionSignalRecord.__tablename__} "
|
||||
"ADD COLUMN decision_profile VARCHAR(16)"
|
||||
)
|
||||
except OperationalError as exc:
|
||||
if not self._is_sqlite_duplicate_column_error(exc, "decision_profile"):
|
||||
raise
|
||||
|
||||
self._ensure_decision_signal_profile_indexes()
|
||||
self._backfill_decision_signal_profile_from_metadata()
|
||||
|
||||
def _ensure_decision_signal_profile_indexes(self) -> None:
|
||||
"""Create profile-aware indexes without dropping legacy indexes."""
|
||||
|
||||
expected_indexes = {
|
||||
"ix_decision_signals_decision_profile": ["decision_profile"],
|
||||
"ix_decision_signal_market_stock_profile_created": [
|
||||
"market", "stock_code", "decision_profile", "created_at",
|
||||
],
|
||||
"ix_decision_signal_report_type_market_stock_profile_action_horizon_phase": [
|
||||
"source_report_id", "source_type", "market", "stock_code",
|
||||
"decision_profile", "action", "horizon", "market_phase",
|
||||
],
|
||||
"ix_decision_signal_trace_type_market_stock_profile_action_horizon_phase": [
|
||||
"trace_id", "source_type", "market", "stock_code",
|
||||
"decision_profile", "action", "horizon", "market_phase",
|
||||
],
|
||||
}
|
||||
with self._engine.begin() as connection:
|
||||
for index_name, columns in expected_indexes.items():
|
||||
connection.exec_driver_sql(
|
||||
f"CREATE INDEX IF NOT EXISTS {index_name} "
|
||||
f"ON decision_signals ({', '.join(columns)})"
|
||||
)
|
||||
|
||||
actual_indexes = {
|
||||
index["name"]: index["column_names"]
|
||||
for index in inspect(self._engine).get_indexes(
|
||||
DecisionSignalRecord.__tablename__
|
||||
)
|
||||
}
|
||||
for index_name, expected_columns in expected_indexes.items():
|
||||
if actual_indexes.get(index_name) != expected_columns:
|
||||
raise RuntimeError(
|
||||
"decision_profile index verification failed: "
|
||||
f"index={index_name} expected={expected_columns} "
|
||||
f"actual={actual_indexes.get(index_name)}"
|
||||
)
|
||||
|
||||
def _backfill_decision_signal_profile_from_metadata(self) -> None:
|
||||
stats = {
|
||||
"candidate_count": 0,
|
||||
"backfilled_count": 0,
|
||||
"guard_skipped_count": 0,
|
||||
"missing_metadata_count": 0,
|
||||
"missing_profile_count": 0,
|
||||
"invalid_json_count": 0,
|
||||
"non_object_count": 0,
|
||||
"invalid_profile_count": 0,
|
||||
"skipped_existing_profile_count": 0,
|
||||
}
|
||||
with self._engine.begin() as connection:
|
||||
stats["skipped_existing_profile_count"] = connection.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM decision_signals "
|
||||
"WHERE decision_profile IS NOT NULL"
|
||||
)
|
||||
).scalar_one()
|
||||
candidate_rows = [
|
||||
(row["id"], row["metadata_json"])
|
||||
for row in connection.execute(
|
||||
text(
|
||||
"SELECT id, metadata_json FROM decision_signals "
|
||||
"WHERE decision_profile IS NULL ORDER BY id"
|
||||
)
|
||||
).mappings()
|
||||
]
|
||||
stats["candidate_count"] = len(candidate_rows)
|
||||
|
||||
for signal_id, metadata_json in candidate_rows:
|
||||
if metadata_json is None:
|
||||
stats["missing_metadata_count"] += 1
|
||||
continue
|
||||
try:
|
||||
metadata = json.loads(metadata_json)
|
||||
except (TypeError, ValueError, RecursionError):
|
||||
stats["invalid_json_count"] += 1
|
||||
continue
|
||||
if not isinstance(metadata, dict):
|
||||
stats["non_object_count"] += 1
|
||||
continue
|
||||
|
||||
raw_profile = metadata.get("decision_profile")
|
||||
if raw_profile is None or (
|
||||
isinstance(raw_profile, str) and not raw_profile.strip()
|
||||
):
|
||||
stats["missing_profile_count"] += 1
|
||||
continue
|
||||
profile = extract_legacy_decision_profile(metadata)
|
||||
if profile is None:
|
||||
stats["invalid_profile_count"] += 1
|
||||
continue
|
||||
|
||||
result = connection.execute(
|
||||
text(
|
||||
"UPDATE decision_signals "
|
||||
"SET decision_profile = :decision_profile "
|
||||
"WHERE id = :signal_id AND decision_profile IS NULL"
|
||||
),
|
||||
{"decision_profile": profile, "signal_id": signal_id},
|
||||
)
|
||||
if result.rowcount == 1:
|
||||
stats["backfilled_count"] += 1
|
||||
elif result.rowcount == 0:
|
||||
stats["guard_skipped_count"] += 1
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"decision_profile backfill updated an unexpected number "
|
||||
f"of rows for signal_id={signal_id}: {result.rowcount}"
|
||||
)
|
||||
|
||||
classified_count = sum(
|
||||
stats[key]
|
||||
for key in (
|
||||
"backfilled_count",
|
||||
"guard_skipped_count",
|
||||
"missing_metadata_count",
|
||||
"missing_profile_count",
|
||||
"invalid_json_count",
|
||||
"non_object_count",
|
||||
"invalid_profile_count",
|
||||
)
|
||||
)
|
||||
if classified_count != stats["candidate_count"]:
|
||||
raise RuntimeError(
|
||||
"decision_profile migration stats did not classify every "
|
||||
f"candidate: candidates={stats['candidate_count']} "
|
||||
f"classified={classified_count}"
|
||||
)
|
||||
logger.info(
|
||||
"[DecisionSignal] decision_profile migration stats: "
|
||||
"candidate_count=%s backfilled_count=%s guard_skipped_count=%s "
|
||||
"missing_metadata_count=%s missing_profile_count=%s "
|
||||
"invalid_json_count=%s non_object_count=%s invalid_profile_count=%s "
|
||||
"skipped_existing_profile_count=%s",
|
||||
stats["candidate_count"],
|
||||
stats["backfilled_count"],
|
||||
stats["guard_skipped_count"],
|
||||
stats["missing_metadata_count"],
|
||||
stats["missing_profile_count"],
|
||||
stats["invalid_json_count"],
|
||||
stats["non_object_count"],
|
||||
stats["invalid_profile_count"],
|
||||
stats["skipped_existing_profile_count"],
|
||||
)
|
||||
|
||||
def _ensure_intelligence_items_unique_index(self) -> None:
|
||||
if not self._is_sqlite_engine:
|
||||
return
|
||||
|
||||
@@ -174,6 +174,8 @@ def test_create_duplicate_list_detail_latest_and_status_update(client_and_db) ->
|
||||
signal_id = created["item"]["id"]
|
||||
assert created["item"]["stock_code"] == "600519"
|
||||
assert created["item"]["plan_quality"] == "partial"
|
||||
assert created["item"]["decision_profile"] == "balanced"
|
||||
assert created["item"]["metadata"]["decision_profile"] == "balanced"
|
||||
assert created["item"]["expires_at"] is not None
|
||||
|
||||
duplicate_resp = client.post(
|
||||
@@ -213,11 +215,15 @@ def test_create_duplicate_list_detail_latest_and_status_update(client_and_db) ->
|
||||
|
||||
patch_resp = client.patch(
|
||||
f"/api/v1/decision-signals/{signal_id}/status",
|
||||
json={"status": "closed", "metadata": {"closed_by": "api-test"}},
|
||||
json={
|
||||
"status": "closed",
|
||||
"metadata": {"closed_by": "api-test", "decision_profile": "aggressive"},
|
||||
},
|
||||
)
|
||||
assert patch_resp.status_code == 200, patch_resp.text
|
||||
assert patch_resp.json()["status"] == "closed"
|
||||
assert patch_resp.json()["metadata"]["closed_by"] == "api-test"
|
||||
assert patch_resp.json()["metadata"]["decision_profile"] == "balanced"
|
||||
assert "task_id" not in patch_resp.json()["metadata"]
|
||||
|
||||
clear_metadata_resp = client.patch(
|
||||
@@ -246,6 +252,40 @@ def test_create_duplicate_list_detail_latest_and_status_update(client_and_db) ->
|
||||
assert missing_resp.status_code == 404
|
||||
|
||||
|
||||
def test_create_rejects_explicit_null_decision_profile_and_accepts_null_metadata(client_and_db) -> None:
|
||||
client, _db = client_and_db
|
||||
|
||||
null_profile_resp = client.post(
|
||||
"/api/v1/decision-signals",
|
||||
json=_payload(source_report_id=3010, trace_id="trace-null-profile", decision_profile=None),
|
||||
)
|
||||
assert null_profile_resp.status_code == 422, null_profile_resp.text
|
||||
assert "decision_profile" in null_profile_resp.text
|
||||
|
||||
null_metadata_resp = client.post(
|
||||
"/api/v1/decision-signals",
|
||||
json=_payload(source_report_id=3011, trace_id="trace-null-metadata", metadata=None),
|
||||
)
|
||||
assert null_metadata_resp.status_code == 200, null_metadata_resp.text
|
||||
null_metadata_item = null_metadata_resp.json()["item"]
|
||||
assert null_metadata_item["decision_profile"] == "balanced"
|
||||
assert null_metadata_item["metadata"] == {"decision_profile": "balanced"}
|
||||
|
||||
omitted_metadata_payload = _payload(
|
||||
source_report_id=3012,
|
||||
trace_id="trace-omitted-metadata",
|
||||
)
|
||||
omitted_metadata_payload.pop("metadata")
|
||||
omitted_metadata_resp = client.post(
|
||||
"/api/v1/decision-signals",
|
||||
json=omitted_metadata_payload,
|
||||
)
|
||||
assert omitted_metadata_resp.status_code == 200, omitted_metadata_resp.text
|
||||
omitted_metadata_item = omitted_metadata_resp.json()["item"]
|
||||
assert omitted_metadata_item["decision_profile"] == "balanced"
|
||||
assert omitted_metadata_item["metadata"] == {"decision_profile": "balanced"}
|
||||
|
||||
|
||||
def test_create_treats_null_lifecycle_fields_as_missing(client_and_db) -> None:
|
||||
client, _db = client_and_db
|
||||
|
||||
@@ -933,6 +973,7 @@ def test_create_schema_and_service_validation_errors(client_and_db) -> None:
|
||||
{"entry_high": 0},
|
||||
{"stop_loss": "nan"},
|
||||
{"target_price": "inf"},
|
||||
{"metadata": ["not-an-object"]},
|
||||
]
|
||||
for overrides in schema_invalid_cases:
|
||||
resp = client.post("/api/v1/decision-signals", json=_payload(**overrides))
|
||||
@@ -1215,6 +1256,60 @@ def test_dedup_distinguishes_market_for_same_symbol(client_and_db) -> None:
|
||||
assert hk_resp.json()["item"]["id"] != us_resp.json()["item"]["id"]
|
||||
|
||||
|
||||
def test_list_decision_profile_filter_distinguishes_unknown_from_omitted(client_and_db) -> None:
|
||||
client, db = client_and_db
|
||||
|
||||
balanced_resp = client.post(
|
||||
"/api/v1/decision-signals",
|
||||
json=_payload(
|
||||
source_report_id=3611,
|
||||
trace_id="trace-profile-api-balanced",
|
||||
decision_profile="balanced",
|
||||
),
|
||||
)
|
||||
aggressive_resp = client.post(
|
||||
"/api/v1/decision-signals",
|
||||
json=_payload(
|
||||
source_report_id=3612,
|
||||
trace_id="trace-profile-api-aggressive",
|
||||
decision_profile="aggressive",
|
||||
),
|
||||
)
|
||||
legacy_resp = client.post(
|
||||
"/api/v1/decision-signals",
|
||||
json=_payload(
|
||||
source_report_id=3613,
|
||||
trace_id="trace-profile-api-legacy",
|
||||
decision_profile="balanced",
|
||||
),
|
||||
)
|
||||
assert balanced_resp.status_code == 200, balanced_resp.text
|
||||
assert aggressive_resp.status_code == 200, aggressive_resp.text
|
||||
assert legacy_resp.status_code == 200, legacy_resp.text
|
||||
legacy_id = legacy_resp.json()["item"]["id"]
|
||||
with db.session_scope() as session:
|
||||
row = session.query(DecisionSignalRecord).filter_by(id=legacy_id).one()
|
||||
row.decision_profile = None
|
||||
|
||||
all_resp = client.get("/api/v1/decision-signals", params={"stock_code": "600519", "status": "active"})
|
||||
unknown_resp = client.get(
|
||||
"/api/v1/decision-signals",
|
||||
params={"stock_code": "600519", "status": "active", "decision_profile": "unknown"},
|
||||
)
|
||||
aggressive_list_resp = client.get(
|
||||
"/api/v1/decision-signals",
|
||||
params={"stock_code": "600519", "status": "active", "decision_profile": "aggressive"},
|
||||
)
|
||||
|
||||
assert all_resp.status_code == 200, all_resp.text
|
||||
assert unknown_resp.status_code == 200, unknown_resp.text
|
||||
assert aggressive_list_resp.status_code == 200, aggressive_list_resp.text
|
||||
assert all_resp.json()["total"] == 3
|
||||
assert [item["id"] for item in unknown_resp.json()["items"]] == [legacy_id]
|
||||
assert aggressive_list_resp.json()["total"] == 1
|
||||
assert aggressive_list_resp.json()["items"][0]["decision_profile"] == "aggressive"
|
||||
|
||||
|
||||
def _decision_signal_count(db: DatabaseManager) -> int:
|
||||
with db.session_scope() as session:
|
||||
return session.query(DecisionSignalRecord).count()
|
||||
@@ -1317,8 +1412,7 @@ def test_reassess_persist_true_rejects_before_db_lookup(client_and_db, monkeypat
|
||||
assert response.status_code == 400, response.text
|
||||
assert response.json()["error"] == "unsupported_operation"
|
||||
assert response.json()["message"] == (
|
||||
"Persisting reassessed decision_profile signals requires decision_profile "
|
||||
"to be promoted to a first-class field."
|
||||
"Persisting reassessed decision_profile signals is tracked by #1757."
|
||||
)
|
||||
assert _decision_signal_count(db) == before
|
||||
|
||||
|
||||
@@ -54,6 +54,18 @@ def test_decision_signal_topic_references_live_api_schema_and_docs() -> None:
|
||||
assert "decision-signals.md" in index_en
|
||||
assert "decision-signals.md" in alerts
|
||||
assert "decision-signals.md" in notifications
|
||||
assert "(source_report_id, source_type, market, stock_code, decision_profile" in full_guide
|
||||
assert "(source_report_id, source_type, market, stock_code, decision_profile" in full_guide_en
|
||||
assert "顶层显式 `null`、空值或非法值会被拒绝" in full_guide
|
||||
assert "top-level explicit `null`, empty value, or invalid value is rejected" in full_guide_en
|
||||
assert "metadata 省略或显式 `null` 均按无 metadata 处理" in full_guide
|
||||
assert "Omitted or explicit `null` metadata is treated as absent" in full_guide_en
|
||||
assert "显式 `null` 时清空为 SQL `NULL`" in full_guide
|
||||
assert "explicit `null` clears it to SQL `NULL`" in full_guide_en
|
||||
assert "正式字段为 legacy `NULL` 时会移除请求 object 中的 profile key" in full_guide
|
||||
assert "for a legacy formal `NULL`, the profile key is removed" in full_guide_en
|
||||
assert "API 响应 schema 不变" not in full_guide
|
||||
assert "The API response schema is unchanged" not in full_guide_en
|
||||
|
||||
list_parameters = api_spec["paths"]["/api/v1/decision-signals"]["get"]["parameters"]
|
||||
latest_parameters = api_spec["paths"]["/api/v1/decision-signals/latest/{stock_code}"]["get"]["parameters"]
|
||||
|
||||
@@ -147,6 +147,7 @@ def test_build_payload_maps_report_context_and_price_plan() -> None:
|
||||
assert payload["source_type"] == "analysis"
|
||||
assert payload["source_report_id"] == 88
|
||||
assert payload["trace_id"] == "trace-88"
|
||||
assert payload["decision_profile"] == "balanced"
|
||||
assert payload["trigger_source"] == "api"
|
||||
assert payload["action"] == "buy"
|
||||
assert payload["confidence"] == 0.8
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy import inspect
|
||||
|
||||
from src.config import Config
|
||||
from src.repositories.decision_signal_repo import DecisionSignalRepository
|
||||
from src.schemas.decision_profile import normalize_decision_profile_filter
|
||||
from src.storage import Base, DatabaseManager, DecisionSignalRecord, utc_naive_now
|
||||
|
||||
|
||||
@@ -157,6 +158,37 @@ def test_create_if_absent_deduplicates_report_and_trace_keys(isolated_db) -> Non
|
||||
assert no_key_row2.id != no_key_row1.id
|
||||
|
||||
|
||||
def test_create_if_absent_does_not_dedup_same_report_across_profiles(isolated_db) -> None:
|
||||
repo = DecisionSignalRepository(isolated_db)
|
||||
|
||||
balanced = repo.create_if_absent(_fields(source_report_id=2351, decision_profile="balanced"))
|
||||
aggressive = repo.create_if_absent(
|
||||
_fields(source_report_id=2351, trace_id="trace-2351-aggressive", decision_profile="aggressive")
|
||||
)
|
||||
balanced_duplicate = repo.create_if_absent(
|
||||
_fields(source_report_id=2351, trace_id="trace-2351-balanced-retry", decision_profile="balanced")
|
||||
)
|
||||
|
||||
assert balanced.created is True
|
||||
assert aggressive.created is True
|
||||
assert aggressive.row.id != balanced.row.id
|
||||
assert balanced_duplicate.created is False
|
||||
assert balanced_duplicate.row.id == balanced.row.id
|
||||
|
||||
legacy = repo.create_if_absent(_fields(source_report_id=2352, trace_id="trace-2352-null"))
|
||||
balanced_from_legacy_source = repo.create_if_absent(
|
||||
_fields(source_report_id=2352, trace_id="trace-2352-balanced", decision_profile="balanced")
|
||||
)
|
||||
legacy_duplicate = repo.create_if_absent(
|
||||
_fields(source_report_id=2352, trace_id="trace-2352-null-retry")
|
||||
)
|
||||
assert legacy.created is True
|
||||
assert balanced_from_legacy_source.created is True
|
||||
assert balanced_from_legacy_source.row.id != legacy.row.id
|
||||
assert legacy_duplicate.created is False
|
||||
assert legacy_duplicate.row.id == legacy.row.id
|
||||
|
||||
|
||||
def test_create_if_absent_relaxed_merge_only_fills_missing_default_dimensions(isolated_db) -> None:
|
||||
repo = DecisionSignalRepository(isolated_db)
|
||||
|
||||
@@ -246,6 +278,49 @@ def test_create_if_absent_relaxed_merge_only_fills_missing_default_dimensions(is
|
||||
assert different_phase_new.row.id != different_phase.row.id
|
||||
|
||||
|
||||
def test_create_if_absent_relaxed_merge_does_not_fill_across_profiles(isolated_db) -> None:
|
||||
repo = DecisionSignalRepository(isolated_db)
|
||||
|
||||
original = repo.create_if_absent(
|
||||
_fields(
|
||||
source_report_id=2411,
|
||||
trace_id="trace-relaxed-profile-balanced",
|
||||
decision_profile="balanced",
|
||||
horizon=None,
|
||||
market_phase=None,
|
||||
)
|
||||
)
|
||||
aggressive = repo.create_if_absent(
|
||||
_fields(
|
||||
source_report_id=2411,
|
||||
trace_id="trace-relaxed-profile-aggressive",
|
||||
decision_profile="aggressive",
|
||||
horizon="3d",
|
||||
market_phase="intraday",
|
||||
),
|
||||
allow_relaxed_horizon_fill=True,
|
||||
)
|
||||
balanced_fill = repo.create_if_absent(
|
||||
_fields(
|
||||
source_report_id=2411,
|
||||
trace_id="trace-relaxed-profile-balanced-fill",
|
||||
decision_profile="balanced",
|
||||
horizon="3d",
|
||||
market_phase="intraday",
|
||||
),
|
||||
allow_relaxed_horizon_fill=True,
|
||||
)
|
||||
|
||||
assert original.created is True
|
||||
assert aggressive.created is True
|
||||
assert aggressive.row.id != original.row.id
|
||||
assert balanced_fill.created is False
|
||||
assert balanced_fill.refreshed is True
|
||||
assert balanced_fill.row.id == original.row.id
|
||||
assert balanced_fill.row.horizon == "3d"
|
||||
assert balanced_fill.row.market_phase == "intraday"
|
||||
|
||||
|
||||
def test_create_if_absent_relaxed_merge_skips_terminal_candidates(isolated_db) -> None:
|
||||
repo = DecisionSignalRepository(isolated_db)
|
||||
closed = repo.create(
|
||||
@@ -492,6 +567,104 @@ def test_create_if_absent_refreshes_expired_same_key_only_with_future_active(iso
|
||||
assert still_closed.reason == "closed old"
|
||||
|
||||
|
||||
def test_create_if_absent_expired_refresh_keeps_profile_identity(isolated_db) -> None:
|
||||
repo = DecisionSignalRepository(isolated_db)
|
||||
expired_balanced = repo.create_if_absent(
|
||||
_fields(
|
||||
source_report_id=2361,
|
||||
trace_id="trace-refresh-profile-balanced",
|
||||
decision_profile="balanced",
|
||||
status="expired",
|
||||
expires_at=utc_naive_now() - timedelta(days=1),
|
||||
reason="old balanced",
|
||||
)
|
||||
)
|
||||
|
||||
aggressive = repo.create_if_absent(
|
||||
_fields(
|
||||
source_report_id=2361,
|
||||
trace_id="trace-refresh-profile-aggressive",
|
||||
decision_profile="aggressive",
|
||||
status="active",
|
||||
expires_at=utc_naive_now() + timedelta(days=1),
|
||||
reason="new aggressive",
|
||||
)
|
||||
)
|
||||
refreshed_balanced = repo.create_if_absent(
|
||||
_fields(
|
||||
source_report_id=2361,
|
||||
trace_id="trace-refresh-profile-balanced-new",
|
||||
decision_profile="balanced",
|
||||
status="active",
|
||||
expires_at=utc_naive_now() + timedelta(days=1),
|
||||
reason="new balanced",
|
||||
)
|
||||
)
|
||||
|
||||
assert expired_balanced.created is True
|
||||
assert aggressive.created is True
|
||||
assert aggressive.row.id != expired_balanced.row.id
|
||||
assert refreshed_balanced.created is False
|
||||
assert refreshed_balanced.refreshed is True
|
||||
assert refreshed_balanced.row.id == expired_balanced.row.id
|
||||
assert refreshed_balanced.row.decision_profile == "balanced"
|
||||
assert refreshed_balanced.row.reason == "new balanced"
|
||||
|
||||
|
||||
def test_profile_filters_and_active_action_lookup_are_null_safe(isolated_db) -> None:
|
||||
repo = DecisionSignalRepository(isolated_db)
|
||||
legacy_buy = repo.create(_fields(source_report_id=2371, trace_id="trace-profile-null", decision_profile=None))
|
||||
balanced_buy = repo.create(
|
||||
_fields(source_report_id=2372, trace_id="trace-profile-balanced", decision_profile="balanced")
|
||||
)
|
||||
aggressive_buy = repo.create(
|
||||
_fields(source_report_id=2373, trace_id="trace-profile-aggressive", decision_profile="aggressive")
|
||||
)
|
||||
repo.create(_fields(source_report_id=2374, trace_id="trace-profile-balanced-sell", action="sell", decision_profile="balanced"))
|
||||
|
||||
all_rows, all_total = repo.list(
|
||||
stock_codes=["600519"],
|
||||
decision_profile_filter=normalize_decision_profile_filter(None),
|
||||
page=1,
|
||||
page_size=10,
|
||||
)
|
||||
unknown_rows, unknown_total = repo.list(
|
||||
stock_codes=["600519"],
|
||||
decision_profile_filter=normalize_decision_profile_filter("unknown"),
|
||||
page=1,
|
||||
page_size=10,
|
||||
)
|
||||
balanced_rows, balanced_total = repo.list(
|
||||
stock_codes=["600519"],
|
||||
action="buy",
|
||||
decision_profile_filter=normalize_decision_profile_filter("balanced"),
|
||||
page=1,
|
||||
page_size=10,
|
||||
)
|
||||
|
||||
assert all_total == 4
|
||||
assert {row.id for row in all_rows} >= {legacy_buy.id, balanced_buy.id, aggressive_buy.id}
|
||||
assert unknown_total == 1
|
||||
assert unknown_rows[0].id == legacy_buy.id
|
||||
assert balanced_total == 1
|
||||
assert balanced_rows[0].id == balanced_buy.id
|
||||
|
||||
legacy_active = repo.list_active_by_stock_actions(
|
||||
market="cn",
|
||||
stock_code="600519",
|
||||
actions=["buy"],
|
||||
decision_profile=None,
|
||||
)
|
||||
balanced_active = repo.list_active_by_stock_actions(
|
||||
market="cn",
|
||||
stock_code="600519",
|
||||
actions=["buy"],
|
||||
decision_profile="balanced",
|
||||
)
|
||||
assert [row.id for row in legacy_active] == [legacy_buy.id]
|
||||
assert [row.id for row in balanced_active] == [balanced_buy.id]
|
||||
|
||||
|
||||
def test_create_all_is_idempotent_and_indexes_exist(isolated_db) -> None:
|
||||
Base.metadata.create_all(isolated_db._engine)
|
||||
Base.metadata.create_all(isolated_db._engine)
|
||||
@@ -504,3 +677,6 @@ def test_create_all_is_idempotent_and_indexes_exist(isolated_db) -> None:
|
||||
assert "ix_decision_signal_market_status_time" in index_names
|
||||
assert "ix_decision_signal_report_type_market_stock_action_horizon_phase" in index_names
|
||||
assert "ix_decision_signal_trace_type_market_stock_action_horizon_phase" in index_names
|
||||
assert "ix_decision_signal_market_stock_profile_created" in index_names
|
||||
assert "ix_decision_signal_report_type_market_stock_profile_action_horizon_phase" in index_names
|
||||
assert "ix_decision_signal_trace_type_market_stock_profile_action_horizon_phase" in index_names
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -127,6 +128,95 @@ def test_service_normalizes_fields_and_partial_plan_quality(isolated_db) -> None
|
||||
assert item["entry_low"] == 1680.5
|
||||
assert item["stop_loss"] == 1600.0
|
||||
assert item["plan_quality"] == "partial"
|
||||
assert item["decision_profile"] == "balanced"
|
||||
assert item["metadata"]["decision_profile"] == "balanced"
|
||||
|
||||
|
||||
def test_service_canonicalizes_decision_profile_and_rejects_non_object_metadata(isolated_db) -> None:
|
||||
service = DecisionSignalService(db_manager=isolated_db)
|
||||
|
||||
caller_metadata = {"decision_profile": "balanced", "task_id": "conflict"}
|
||||
top_level = service.create_signal(
|
||||
_payload(
|
||||
source_report_id=1111,
|
||||
trace_id="trace-profile-top-level",
|
||||
decision_profile="aggressive",
|
||||
metadata=caller_metadata,
|
||||
)
|
||||
)["item"]
|
||||
assert top_level["decision_profile"] == "aggressive"
|
||||
assert top_level["metadata"]["decision_profile"] == "aggressive"
|
||||
assert top_level["metadata"]["task_id"] == "conflict"
|
||||
assert caller_metadata == {
|
||||
"decision_profile": "balanced",
|
||||
"task_id": "conflict",
|
||||
}
|
||||
|
||||
legacy_metadata = service.create_signal(
|
||||
_payload(
|
||||
source_report_id=1112,
|
||||
trace_id="trace-profile-metadata",
|
||||
metadata={"decision_profile": "conservative"},
|
||||
)
|
||||
)["item"]
|
||||
assert legacy_metadata["decision_profile"] == "conservative"
|
||||
assert legacy_metadata["metadata"]["decision_profile"] == "conservative"
|
||||
|
||||
invalid_metadata_profile = service.create_signal(
|
||||
_payload(
|
||||
source_report_id=1113,
|
||||
trace_id="trace-profile-invalid-metadata",
|
||||
metadata={"decision_profile": "balanced-v2"},
|
||||
)
|
||||
)["item"]
|
||||
assert invalid_metadata_profile["decision_profile"] == "balanced"
|
||||
assert invalid_metadata_profile["metadata"]["decision_profile"] == "balanced"
|
||||
|
||||
with pytest.raises(ValueError, match="decision_profile"):
|
||||
service.create_signal(
|
||||
_payload(
|
||||
source_report_id=1116,
|
||||
trace_id="trace-profile-null-top-level",
|
||||
decision_profile=None,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="decision_profile"):
|
||||
service.create_signal(
|
||||
_payload(
|
||||
source_report_id=1117,
|
||||
trace_id="trace-profile-empty-top-level",
|
||||
decision_profile="",
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="decision_profile"):
|
||||
service.create_signal(
|
||||
_payload(
|
||||
source_report_id=1114,
|
||||
trace_id="trace-profile-invalid-top-level",
|
||||
decision_profile="balanced-v2",
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="metadata must be an object"):
|
||||
service.create_signal(
|
||||
_payload(
|
||||
source_report_id=1115,
|
||||
trace_id="trace-profile-non-object-metadata",
|
||||
metadata=["legacy"],
|
||||
)
|
||||
)
|
||||
|
||||
null_metadata = service.create_signal(
|
||||
_payload(
|
||||
source_report_id=1118,
|
||||
trace_id="trace-profile-null-metadata",
|
||||
metadata=None,
|
||||
)
|
||||
)["item"]
|
||||
assert null_metadata["decision_profile"] == "balanced"
|
||||
assert null_metadata["metadata"] == {"decision_profile": "balanced"}
|
||||
|
||||
|
||||
def test_service_defaults_lifecycle_and_preserves_explicit_values(isolated_db) -> None:
|
||||
@@ -282,6 +372,44 @@ def test_list_signals_lazily_backfills_analysis_history_signal(isolated_db) -> N
|
||||
assert session.query(DecisionSignalRecord).count() == 1
|
||||
|
||||
|
||||
def test_list_signals_profile_filter_controls_lazy_backfill(isolated_db) -> None:
|
||||
record_id = isolated_db.save_analysis_history(
|
||||
result=_history_result(),
|
||||
query_id="query-lazy-signal-profile-filter",
|
||||
report_type="simple",
|
||||
news_content="新闻摘要",
|
||||
context_snapshot={"market_phase_summary": {"phase": "postmarket"}},
|
||||
save_snapshot=True,
|
||||
)
|
||||
service = DecisionSignalService(db_manager=isolated_db)
|
||||
|
||||
unknown = service.list_signals(
|
||||
source_type="analysis",
|
||||
source_report_id=record_id,
|
||||
decision_profile="unknown",
|
||||
)
|
||||
conservative = service.list_signals(
|
||||
source_type="analysis",
|
||||
source_report_id=record_id,
|
||||
decision_profile="conservative",
|
||||
)
|
||||
with isolated_db.get_session() as session:
|
||||
assert session.query(DecisionSignalRecord).count() == 0
|
||||
|
||||
balanced = service.list_signals(
|
||||
source_type="analysis",
|
||||
source_report_id=record_id,
|
||||
decision_profile="balanced",
|
||||
)
|
||||
|
||||
assert unknown == {"items": [], "total": 0, "page": 1, "page_size": 20}
|
||||
assert conservative == {"items": [], "total": 0, "page": 1, "page_size": 20}
|
||||
assert balanced["total"] == 1
|
||||
assert balanced["items"][0]["decision_profile"] == "balanced"
|
||||
with isolated_db.get_session() as session:
|
||||
assert session.query(DecisionSignalRecord).count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("market_phase_summary", "created_offset", "expected_ttl"),
|
||||
(
|
||||
@@ -388,7 +516,7 @@ def test_list_signals_invalidates_stale_backfill_when_newer_opposing_signal_exis
|
||||
context_snapshot={"market_phase_summary": {"phase": "postmarket"}},
|
||||
save_snapshot=True,
|
||||
)
|
||||
report_created_at = utc_naive_now() - timedelta(days=1)
|
||||
report_created_at = utc_naive_now() - timedelta(hours=1)
|
||||
with isolated_db.get_session() as session:
|
||||
row = session.query(AnalysisHistory).filter(AnalysisHistory.id == record_id).one()
|
||||
row.created_at = report_created_at
|
||||
@@ -410,6 +538,7 @@ def test_list_signals_invalidates_stale_backfill_when_newer_opposing_signal_exis
|
||||
assert backfilled["source_report_id"] == record_id
|
||||
assert backfilled["action"] == "buy"
|
||||
assert backfilled["status"] == "invalidated"
|
||||
assert backfilled["metadata"]["decision_profile"] == "balanced"
|
||||
assert backfilled["metadata"]["invalidated_by_signal_id"] == newer_sell["id"]
|
||||
assert backfilled["metadata"]["invalidated_reason"] == "opposite_active_signal:buy->sell"
|
||||
assert service.get_signal(newer_sell["id"])["status"] == "active"
|
||||
@@ -418,6 +547,46 @@ def test_list_signals_invalidates_stale_backfill_when_newer_opposing_signal_exis
|
||||
assert [item["id"] for item in latest["items"]] == [newer_sell["id"]]
|
||||
|
||||
|
||||
def test_list_signals_stale_backfill_invalidation_does_not_cross_profile(isolated_db) -> None:
|
||||
record_id = isolated_db.save_analysis_history(
|
||||
result=_history_result(
|
||||
operation_advice="买入",
|
||||
decision_type="buy",
|
||||
action="buy",
|
||||
action_label="买入",
|
||||
analysis_summary="旧报告建议买入。",
|
||||
),
|
||||
query_id="query-stale-backfill-profile-buy",
|
||||
report_type="simple",
|
||||
news_content="新闻摘要",
|
||||
context_snapshot={"market_phase_summary": {"phase": "postmarket"}},
|
||||
save_snapshot=True,
|
||||
)
|
||||
report_created_at = utc_naive_now() - timedelta(days=1)
|
||||
with isolated_db.get_session() as session:
|
||||
row = session.query(AnalysisHistory).filter(AnalysisHistory.id == record_id).one()
|
||||
row.created_at = report_created_at
|
||||
session.commit()
|
||||
|
||||
service = DecisionSignalService(db_manager=isolated_db)
|
||||
newer_sell = service.create_signal(
|
||||
_payload(
|
||||
source_report_id=record_id + 1000,
|
||||
trace_id="trace-newer-opposing-sell-aggressive",
|
||||
action="sell",
|
||||
decision_profile="aggressive",
|
||||
)
|
||||
)["item"]
|
||||
|
||||
listed = service.list_signals(source_type="analysis", source_report_id=record_id)
|
||||
|
||||
assert listed["total"] == 1
|
||||
backfilled = listed["items"][0]
|
||||
assert backfilled["decision_profile"] == "balanced"
|
||||
assert backfilled["status"] == "active"
|
||||
assert service.get_signal(newer_sell["id"])["status"] == "active"
|
||||
|
||||
|
||||
def test_list_signals_does_not_backfill_market_review_history(isolated_db) -> None:
|
||||
record_id = isolated_db.save_analysis_history(
|
||||
result=_history_result(code="MARKET", name="大盘复盘", operation_advice="查看复盘"),
|
||||
@@ -982,6 +1151,54 @@ def test_service_rejects_terminal_status_reactivation(isolated_db, terminal_stat
|
||||
service.update_status(signal_id, status="active")
|
||||
|
||||
|
||||
def test_service_status_metadata_preserves_null_contract_and_profile_identity(isolated_db) -> None:
|
||||
service = DecisionSignalService(db_manager=isolated_db)
|
||||
signal = service.create_signal(
|
||||
_payload(
|
||||
source_report_id=365,
|
||||
trace_id="trace-status-profile",
|
||||
decision_profile="aggressive",
|
||||
)
|
||||
)["item"]
|
||||
|
||||
preserved = service.update_status(signal["id"], status="active")
|
||||
assert preserved["metadata"] == signal["metadata"]
|
||||
|
||||
updated = service.update_status(
|
||||
signal["id"],
|
||||
status="closed",
|
||||
metadata={"decision_profile": "balanced", "closed_by": "tester"},
|
||||
replace_metadata=True,
|
||||
)
|
||||
assert updated["decision_profile"] == "aggressive"
|
||||
assert updated["metadata"] == {
|
||||
"decision_profile": "aggressive",
|
||||
"closed_by": "tester",
|
||||
}
|
||||
|
||||
cleared = service.update_status(
|
||||
signal["id"],
|
||||
status="archived",
|
||||
metadata=None,
|
||||
replace_metadata=True,
|
||||
)
|
||||
assert cleared["metadata"] is None
|
||||
|
||||
legacy_fields, _ = service._normalize_payload(
|
||||
_payload(source_report_id=366, trace_id="trace-status-legacy-profile")
|
||||
)
|
||||
legacy_fields["decision_profile"] = None
|
||||
legacy = service.repo.create(legacy_fields)
|
||||
legacy_updated = service.update_status(
|
||||
legacy.id,
|
||||
status="closed",
|
||||
metadata={"decision_profile": "balanced", "closed_by": "tester"},
|
||||
replace_metadata=True,
|
||||
)
|
||||
assert legacy_updated["decision_profile"] is None
|
||||
assert legacy_updated["metadata"] == {"closed_by": "tester"}
|
||||
|
||||
|
||||
def test_service_invalidates_opposing_active_signals(isolated_db) -> None:
|
||||
service = DecisionSignalService(db_manager=isolated_db)
|
||||
old_buy = service.create_signal(
|
||||
@@ -993,6 +1210,14 @@ def test_service_invalidates_opposing_active_signals(isolated_db) -> None:
|
||||
)
|
||||
)["item"]
|
||||
|
||||
with isolated_db.get_session() as session:
|
||||
row = session.get(DecisionSignalRecord, old_buy["id"])
|
||||
row.metadata_json = json.dumps({
|
||||
"decision_profile": "aggressive",
|
||||
"task_id": "old-buy",
|
||||
})
|
||||
session.commit()
|
||||
|
||||
new_sell = service.create_signal(
|
||||
_payload(
|
||||
source_report_id=372,
|
||||
@@ -1005,6 +1230,7 @@ def test_service_invalidates_opposing_active_signals(isolated_db) -> None:
|
||||
assert new_sell["status"] == "active"
|
||||
assert old_after["status"] == "invalidated"
|
||||
assert old_after["metadata"]["task_id"] == "old-buy"
|
||||
assert old_after["metadata"]["decision_profile"] == "balanced"
|
||||
assert old_after["metadata"]["invalidated_by_signal_id"] == new_sell["id"]
|
||||
assert old_after["metadata"]["invalidated_reason"] == "opposite_active_signal:buy->sell"
|
||||
assert old_after["metadata"]["previous_status"] == "active"
|
||||
@@ -1013,6 +1239,92 @@ def test_service_invalidates_opposing_active_signals(isolated_db) -> None:
|
||||
assert [item["id"] for item in latest["items"]] == [new_sell["id"]]
|
||||
|
||||
|
||||
def test_service_opposing_signal_invalidation_is_same_profile_and_null_safe(isolated_db) -> None:
|
||||
service = DecisionSignalService(db_manager=isolated_db)
|
||||
old_balanced_buy = service.create_signal(
|
||||
_payload(
|
||||
source_report_id=3721,
|
||||
trace_id="trace-opposing-balanced-buy",
|
||||
action="buy",
|
||||
decision_profile="balanced",
|
||||
)
|
||||
)["item"]
|
||||
aggressive_sell = service.create_signal(
|
||||
_payload(
|
||||
source_report_id=3722,
|
||||
trace_id="trace-opposing-aggressive-sell",
|
||||
action="sell",
|
||||
decision_profile="aggressive",
|
||||
)
|
||||
)["item"]
|
||||
|
||||
assert service.get_signal(old_balanced_buy["id"])["status"] == "active"
|
||||
assert aggressive_sell["status"] == "active"
|
||||
|
||||
balanced_sell = service.create_signal(
|
||||
_payload(
|
||||
source_report_id=3723,
|
||||
trace_id="trace-opposing-balanced-sell",
|
||||
action="sell",
|
||||
decision_profile="balanced",
|
||||
)
|
||||
)["item"]
|
||||
assert service.get_signal(old_balanced_buy["id"])["status"] == "invalidated"
|
||||
assert service.get_signal(aggressive_sell["id"])["status"] == "active"
|
||||
assert balanced_sell["status"] == "active"
|
||||
|
||||
legacy_buy_fields, _ = service._normalize_payload(
|
||||
_payload(
|
||||
source_report_id=3724,
|
||||
trace_id="trace-opposing-null-buy",
|
||||
action="buy",
|
||||
)
|
||||
)
|
||||
legacy_buy_fields["decision_profile"] = None
|
||||
legacy_buy = service.repo.create(legacy_buy_fields)
|
||||
|
||||
legacy_no_profile_fields, _ = service._normalize_payload(
|
||||
_payload(
|
||||
source_report_id=3727,
|
||||
trace_id="trace-opposing-null-buy-no-profile",
|
||||
action="buy",
|
||||
)
|
||||
)
|
||||
legacy_no_profile_fields["decision_profile"] = None
|
||||
legacy_no_profile_fields["metadata_json"] = json.dumps({"legacy_marker": "keep"})
|
||||
legacy_no_profile = service.repo.create(legacy_no_profile_fields)
|
||||
|
||||
balanced_sell_2 = service.create_signal(
|
||||
_payload(
|
||||
source_report_id=3725,
|
||||
trace_id="trace-opposing-balanced-sell-2",
|
||||
action="sell",
|
||||
decision_profile="balanced",
|
||||
)
|
||||
)["item"]
|
||||
assert service.get_signal(legacy_buy.id)["status"] == "active"
|
||||
|
||||
legacy_sell_fields, _ = service._normalize_payload(
|
||||
_payload(
|
||||
source_report_id=3726,
|
||||
trace_id="trace-opposing-null-sell",
|
||||
action="sell",
|
||||
)
|
||||
)
|
||||
legacy_sell_fields["decision_profile"] = None
|
||||
legacy_sell = service.repo.create(legacy_sell_fields)
|
||||
service._invalidate_opposing_active_signals(legacy_sell, reference_at=legacy_sell.created_at)
|
||||
|
||||
legacy_buy_after = service.get_signal(legacy_buy.id)
|
||||
assert legacy_buy_after["status"] == "invalidated"
|
||||
assert legacy_buy_after["metadata"]["decision_profile"] == "balanced"
|
||||
legacy_no_profile_after = service.get_signal(legacy_no_profile.id)
|
||||
assert legacy_no_profile_after["status"] == "invalidated"
|
||||
assert legacy_no_profile_after["metadata"]["legacy_marker"] == "keep"
|
||||
assert "decision_profile" not in legacy_no_profile_after["metadata"]
|
||||
assert service.get_signal(balanced_sell_2["id"])["status"] == "active"
|
||||
|
||||
|
||||
def test_service_expired_refresh_invalidates_later_opposing_active_signal(isolated_db) -> None:
|
||||
service = DecisionSignalService(db_manager=isolated_db)
|
||||
buy_payload = _payload(source_report_id=376, trace_id="trace-refresh-buy", action="buy")
|
||||
@@ -1034,6 +1346,8 @@ def test_service_expired_refresh_invalidates_later_opposing_active_signal(isolat
|
||||
assert refreshed["created"] is False
|
||||
assert refreshed["item"]["id"] == old_buy["id"]
|
||||
assert refreshed["item"]["status"] == "active"
|
||||
assert refreshed["item"]["decision_profile"] == "balanced"
|
||||
assert refreshed["item"]["metadata"]["decision_profile"] == "balanced"
|
||||
sell_after = service.get_signal(active_sell["id"])
|
||||
assert sell_after["status"] == "invalidated"
|
||||
assert sell_after["metadata"]["invalidated_by_signal_id"] == old_buy["id"]
|
||||
@@ -1061,7 +1375,19 @@ def test_service_does_not_invalidate_neutral_or_terminal_signals(isolated_db) ->
|
||||
assert service.get_signal(old_buy["id"])["status"] == "closed"
|
||||
|
||||
|
||||
def test_service_replaces_corrupt_metadata_during_invalidation(isolated_db) -> None:
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_metadata",
|
||||
[
|
||||
"{not valid json",
|
||||
b"\xff",
|
||||
"[" * 10_000 + "]" * 10_000,
|
||||
],
|
||||
ids=["malformed-text", "invalid-blob", "too-deep"],
|
||||
)
|
||||
def test_service_replaces_corrupt_metadata_during_invalidation(
|
||||
isolated_db,
|
||||
invalid_metadata,
|
||||
) -> None:
|
||||
service = DecisionSignalService(db_manager=isolated_db)
|
||||
old_buy = service.create_signal(
|
||||
_payload(source_report_id=391, trace_id="trace-corrupt-metadata-buy", action="buy")
|
||||
@@ -1069,7 +1395,7 @@ def test_service_replaces_corrupt_metadata_during_invalidation(isolated_db) -> N
|
||||
|
||||
with isolated_db.get_session() as session:
|
||||
row = session.query(DecisionSignalRecord).filter_by(id=old_buy["id"]).one()
|
||||
row.metadata_json = "{not valid json"
|
||||
row.metadata_json = invalid_metadata
|
||||
session.commit()
|
||||
|
||||
new_sell = service.create_signal(
|
||||
@@ -1079,6 +1405,7 @@ def test_service_replaces_corrupt_metadata_during_invalidation(isolated_db) -> N
|
||||
old_after = service.get_signal(old_buy["id"])
|
||||
assert old_after["status"] == "invalidated"
|
||||
assert old_after["metadata"]["metadata_replaced_due_to_invalid_json"] is True
|
||||
assert old_after["metadata"]["decision_profile"] == "balanced"
|
||||
assert old_after["metadata"]["invalidated_by_signal_id"] == new_sell["id"]
|
||||
|
||||
|
||||
@@ -1100,6 +1427,7 @@ def test_service_replaces_non_object_metadata_during_invalidation(isolated_db) -
|
||||
old_after = service.get_signal(old_buy["id"])
|
||||
assert old_after["status"] == "invalidated"
|
||||
assert old_after["metadata"]["metadata_replaced_due_to_non_object"] is True
|
||||
assert old_after["metadata"]["decision_profile"] == "balanced"
|
||||
assert old_after["metadata"]["invalidated_by_signal_id"] == new_sell["id"]
|
||||
|
||||
|
||||
@@ -1187,6 +1515,7 @@ def test_service_propagates_unexpected_invalidation_failures(isolated_db) -> Non
|
||||
action=fields["action"],
|
||||
market=fields["market"],
|
||||
stock_code=fields["stock_code"],
|
||||
decision_profile=fields["decision_profile"],
|
||||
)
|
||||
return DecisionSignalCreateResult(
|
||||
row=row,
|
||||
|
||||
@@ -20,6 +20,21 @@ from src.storage import Base, CURRENT_SCHEMA_VERSION, DatabaseManager, DatabaseS
|
||||
|
||||
class TestStorage(unittest.TestCase):
|
||||
|
||||
@staticmethod
|
||||
def _list_sqlite_indexes(db_path: str, table_name: str) -> dict[str, list[str]]:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
indexes = {}
|
||||
for row in conn.execute(f"PRAGMA index_list({table_name})").fetchall():
|
||||
index_name = row[1]
|
||||
indexes[index_name] = [
|
||||
index_info[2]
|
||||
for index_info in conn.execute(
|
||||
f"PRAGMA index_xinfo({index_name})"
|
||||
).fetchall()
|
||||
if index_info[2] is not None and int(index_info[5]) == 1
|
||||
]
|
||||
return indexes
|
||||
|
||||
@staticmethod
|
||||
def _list_sqlite_unique_indexes(db_path: str, table_name: str) -> dict[str, list[str]]:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
@@ -164,6 +179,264 @@ class TestStorage(unittest.TestCase):
|
||||
|
||||
DatabaseManager.reset_instance()
|
||||
|
||||
def test_fresh_decision_signal_schema_has_profile_indexes(self):
|
||||
DatabaseManager.reset_instance()
|
||||
temp_dir = tempfile.TemporaryDirectory()
|
||||
db_path = os.path.join(temp_dir.name, "fresh_decision_profile.db")
|
||||
|
||||
try:
|
||||
DatabaseManager(db_url=f"sqlite:///{db_path}")
|
||||
|
||||
indexes = self._list_sqlite_indexes(db_path, "decision_signals")
|
||||
self.assertEqual(
|
||||
indexes.get("ix_decision_signals_decision_profile"),
|
||||
["decision_profile"],
|
||||
)
|
||||
self.assertEqual(
|
||||
indexes.get("ix_decision_signal_market_stock_profile_created"),
|
||||
["market", "stock_code", "decision_profile", "created_at"],
|
||||
)
|
||||
self.assertEqual(
|
||||
indexes.get(
|
||||
"ix_decision_signal_report_type_market_stock_profile_action_horizon_phase"
|
||||
),
|
||||
[
|
||||
"source_report_id", "source_type", "market", "stock_code",
|
||||
"decision_profile", "action", "horizon", "market_phase",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
indexes.get(
|
||||
"ix_decision_signal_trace_type_market_stock_profile_action_horizon_phase"
|
||||
),
|
||||
[
|
||||
"trace_id", "source_type", "market", "stock_code",
|
||||
"decision_profile", "action", "horizon", "market_phase",
|
||||
],
|
||||
)
|
||||
finally:
|
||||
DatabaseManager.reset_instance()
|
||||
Config.reset_instance()
|
||||
temp_dir.cleanup()
|
||||
|
||||
def test_decision_signal_profile_migration_adds_column_indexes_and_closed_stats(self):
|
||||
DatabaseManager.reset_instance()
|
||||
temp_dir = tempfile.TemporaryDirectory()
|
||||
db_path = os.path.join(temp_dir.name, "legacy_decision_profile.db")
|
||||
deeply_nested_json = "[" * 10_000 + "]" * 10_000
|
||||
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"""CREATE TABLE decision_signals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stock_code TEXT,
|
||||
market TEXT,
|
||||
source_type TEXT,
|
||||
source_report_id INTEGER,
|
||||
trace_id TEXT,
|
||||
action TEXT,
|
||||
horizon TEXT,
|
||||
market_phase TEXT,
|
||||
created_at DATETIME,
|
||||
metadata_json TEXT
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX ix_decision_signal_report_type_market_stock_action_horizon_phase "
|
||||
"ON decision_signals "
|
||||
"(source_report_id, source_type, market, stock_code, action, horizon, market_phase)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX ix_decision_signal_trace_type_market_stock_action_horizon_phase "
|
||||
"ON decision_signals "
|
||||
"(trace_id, source_type, market, stock_code, action, horizon, market_phase)"
|
||||
)
|
||||
conn.executemany(
|
||||
"""INSERT INTO decision_signals (
|
||||
stock_code,
|
||||
market,
|
||||
source_type,
|
||||
source_report_id,
|
||||
trace_id,
|
||||
action,
|
||||
horizon,
|
||||
market_phase,
|
||||
created_at,
|
||||
metadata_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
[
|
||||
("600519", "cn", "analysis", 1, "trace-1", "buy", "3d", "intraday", "2026-01-01", '{"decision_profile":"balanced"}'),
|
||||
("600519", "cn", "analysis", 2, "trace-2", "buy", "3d", "intraday", "2026-01-01", None),
|
||||
("600519", "cn", "analysis", 3, "trace-3", "buy", "3d", "intraday", "2026-01-01", ""),
|
||||
("600519", "cn", "analysis", 4, "trace-4", "buy", "3d", "intraday", "2026-01-01", " "),
|
||||
("600519", "cn", "analysis", 5, "trace-5", "buy", "3d", "intraday", "2026-01-01", "{not-json"),
|
||||
("600519", "cn", "analysis", 6, "trace-6", "buy", "3d", "intraday", "2026-01-01", sqlite3.Binary(b"\xff")),
|
||||
("600519", "cn", "analysis", 7, "trace-7", "buy", "3d", "intraday", "2026-01-01", "null"),
|
||||
("600519", "cn", "analysis", 8, "trace-8", "buy", "3d", "intraday", "2026-01-01", "[]"),
|
||||
("600519", "cn", "analysis", 9, "trace-9", "buy", "3d", "intraday", "2026-01-01", '"balanced"'),
|
||||
("600519", "cn", "analysis", 10, "trace-10", "buy", "3d", "intraday", "2026-01-01", "1"),
|
||||
("600519", "cn", "analysis", 11, "trace-11", "buy", "3d", "intraday", "2026-01-01", "{}"),
|
||||
("600519", "cn", "analysis", 12, "trace-12", "buy", "3d", "intraday", "2026-01-01", '{"decision_profile":null}'),
|
||||
("600519", "cn", "analysis", 13, "trace-13", "buy", "3d", "intraday", "2026-01-01", '{"decision_profile":""}'),
|
||||
("600519", "cn", "analysis", 14, "trace-14", "buy", "3d", "intraday", "2026-01-01", '{"decision_profile":" "}'),
|
||||
("600519", "cn", "analysis", 15, "trace-15", "buy", "3d", "intraday", "2026-01-01", '{"decision_profile":"reckless"}'),
|
||||
("600519", "cn", "analysis", 16, "trace-16", "buy", "3d", "intraday", "2026-01-01", deeply_nested_json),
|
||||
],
|
||||
)
|
||||
|
||||
with self.assertLogs("src.storage", level="INFO") as logs:
|
||||
DatabaseManager(db_url=f"sqlite:///{db_path}")
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(decision_signals)").fetchall()}
|
||||
rows = conn.execute(
|
||||
"SELECT id, decision_profile FROM decision_signals ORDER BY id"
|
||||
).fetchall()
|
||||
|
||||
self.assertIn("decision_profile", columns)
|
||||
self.assertEqual(rows[0], (1, "balanced"))
|
||||
self.assertTrue(all(profile is None for _, profile in rows[1:]))
|
||||
|
||||
indexes = self._list_sqlite_indexes(db_path, "decision_signals")
|
||||
expected_indexes = {
|
||||
"ix_decision_signals_decision_profile": ["decision_profile"],
|
||||
"ix_decision_signal_market_stock_profile_created": [
|
||||
"market", "stock_code", "decision_profile", "created_at",
|
||||
],
|
||||
"ix_decision_signal_report_type_market_stock_profile_action_horizon_phase": [
|
||||
"source_report_id", "source_type", "market", "stock_code",
|
||||
"decision_profile", "action", "horizon", "market_phase",
|
||||
],
|
||||
"ix_decision_signal_trace_type_market_stock_profile_action_horizon_phase": [
|
||||
"trace_id", "source_type", "market", "stock_code",
|
||||
"decision_profile", "action", "horizon", "market_phase",
|
||||
],
|
||||
}
|
||||
for index_name, index_columns in expected_indexes.items():
|
||||
self.assertEqual(indexes.get(index_name), index_columns)
|
||||
self.assertEqual(
|
||||
indexes.get("ix_decision_signal_report_type_market_stock_action_horizon_phase"),
|
||||
[
|
||||
"source_report_id", "source_type", "market", "stock_code",
|
||||
"action", "horizon", "market_phase",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
indexes.get("ix_decision_signal_trace_type_market_stock_action_horizon_phase"),
|
||||
[
|
||||
"trace_id", "source_type", "market", "stock_code",
|
||||
"action", "horizon", "market_phase",
|
||||
],
|
||||
)
|
||||
|
||||
log_text = "\n".join(logs.output)
|
||||
self.assertIn("candidate_count=16", log_text)
|
||||
self.assertIn("backfilled_count=1", log_text)
|
||||
self.assertIn("guard_skipped_count=0", log_text)
|
||||
self.assertIn("missing_metadata_count=1", log_text)
|
||||
self.assertIn("missing_profile_count=4", log_text)
|
||||
self.assertIn("invalid_json_count=5", log_text)
|
||||
self.assertIn("non_object_count=4", log_text)
|
||||
self.assertIn("invalid_profile_count=1", log_text)
|
||||
self.assertIn("skipped_existing_profile_count=0", log_text)
|
||||
|
||||
DatabaseManager.reset_instance()
|
||||
with self.assertLogs("src.storage", level="INFO") as second_logs:
|
||||
DatabaseManager(db_url=f"sqlite:///{db_path}")
|
||||
second_log_text = "\n".join(second_logs.output)
|
||||
self.assertIn("candidate_count=15", second_log_text)
|
||||
self.assertIn("backfilled_count=0", second_log_text)
|
||||
self.assertIn("guard_skipped_count=0", second_log_text)
|
||||
self.assertIn("missing_metadata_count=1", second_log_text)
|
||||
self.assertIn("missing_profile_count=4", second_log_text)
|
||||
self.assertIn("invalid_json_count=5", second_log_text)
|
||||
self.assertIn("non_object_count=4", second_log_text)
|
||||
self.assertIn("invalid_profile_count=1", second_log_text)
|
||||
self.assertIn("skipped_existing_profile_count=1", second_log_text)
|
||||
finally:
|
||||
DatabaseManager.reset_instance()
|
||||
Config.reset_instance()
|
||||
temp_dir.cleanup()
|
||||
|
||||
def test_decision_signal_profile_migration_runs_when_column_already_exists(self):
|
||||
DatabaseManager.reset_instance()
|
||||
temp_dir = tempfile.TemporaryDirectory()
|
||||
db_path = os.path.join(temp_dir.name, "existing_decision_profile.db")
|
||||
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"""CREATE TABLE decision_signals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stock_code TEXT,
|
||||
market TEXT,
|
||||
source_type TEXT,
|
||||
source_report_id INTEGER,
|
||||
trace_id TEXT,
|
||||
action TEXT,
|
||||
horizon TEXT,
|
||||
market_phase TEXT,
|
||||
created_at DATETIME,
|
||||
metadata_json TEXT,
|
||||
decision_profile VARCHAR(16)
|
||||
)"""
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO decision_signals (metadata_json, decision_profile) VALUES (?, ?)",
|
||||
[
|
||||
('{"decision_profile":"aggressive"}', None),
|
||||
(None, None),
|
||||
('{"decision_profile":"balanced"}', "conservative"),
|
||||
('{"decision_profile":"balanced"}', ""),
|
||||
],
|
||||
)
|
||||
|
||||
with self.assertLogs("src.storage", level="INFO") as logs:
|
||||
DatabaseManager(db_url=f"sqlite:///{db_path}")
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
profiles = conn.execute(
|
||||
"SELECT decision_profile FROM decision_signals ORDER BY id"
|
||||
).fetchall()
|
||||
|
||||
self.assertEqual(
|
||||
profiles,
|
||||
[("aggressive",), (None,), ("conservative",), ("",)],
|
||||
)
|
||||
log_text = "\n".join(logs.output)
|
||||
self.assertIn("candidate_count=2", log_text)
|
||||
self.assertIn("backfilled_count=1", log_text)
|
||||
self.assertIn("missing_metadata_count=1", log_text)
|
||||
self.assertIn("skipped_existing_profile_count=2", log_text)
|
||||
finally:
|
||||
DatabaseManager.reset_instance()
|
||||
Config.reset_instance()
|
||||
temp_dir.cleanup()
|
||||
|
||||
def test_decision_signal_profile_migration_fails_when_column_inspection_fails(self):
|
||||
class BrokenInspector:
|
||||
def has_table(self, _table_name: str) -> bool:
|
||||
return True
|
||||
|
||||
def get_columns(self, _table_name: str):
|
||||
raise RuntimeError("inspection failed")
|
||||
|
||||
DatabaseManager.reset_instance()
|
||||
try:
|
||||
with patch("src.storage.inspect", return_value=BrokenInspector()):
|
||||
with self.assertLogs("src.storage", level="ERROR") as logs:
|
||||
with self.assertRaises(RuntimeError):
|
||||
DatabaseManager(db_url="sqlite:///:memory:")
|
||||
|
||||
self.assertIn(
|
||||
"profile migration cannot continue safely",
|
||||
"\n".join(logs.output),
|
||||
)
|
||||
finally:
|
||||
DatabaseManager.reset_instance()
|
||||
Config.reset_instance()
|
||||
|
||||
def test_schema_migration_record_handles_concurrent_initialization(self):
|
||||
DatabaseManager.reset_instance()
|
||||
temp_dir = tempfile.TemporaryDirectory()
|
||||
|
||||
Reference in New Issue
Block a user