feat: add decision profile outcome calibration (#2072)

This commit is contained in:
Alfred
2026-07-23 19:21:23 +08:00
committed by GitHub
parent de8aa6a0ba
commit aa68d45d7f
21 changed files with 1488 additions and 37 deletions

View File

@@ -123,6 +123,9 @@ from api.v1.schemas.decision_signals import (
DecisionSignalOutcomeRunResponse,
DecisionSignalOutcomeStatsBucket,
DecisionSignalOutcomeStatsResponse,
DecisionSignalProfileCalibration,
DecisionSignalProfileCalibrationBreakdowns,
DecisionSignalProfileCalibrationBucket,
DecisionSignalStatusUpdateRequest,
)
@@ -235,5 +238,8 @@ __all__ = [
"DecisionSignalOutcomeRunResponse",
"DecisionSignalOutcomeStatsBucket",
"DecisionSignalOutcomeStatsResponse",
"DecisionSignalProfileCalibration",
"DecisionSignalProfileCalibrationBreakdowns",
"DecisionSignalProfileCalibrationBucket",
"DecisionSignalStatusUpdateRequest",
]

View File

@@ -186,6 +186,36 @@ class DecisionSignalOutcomeStatsBucket(BaseModel):
unable_reasons: Dict[str, int] = Field(default_factory=dict)
class DecisionSignalProfileCalibrationBucket(BaseModel):
dimensions: Dict[str, str] = Field(default_factory=dict)
total: int
completed: int
unable: int
hit: int
miss: int
neutral: int
sample_sufficient: bool
hit_rate_pct: Optional[float] = None
avg_stock_return_pct: Optional[float] = None
miss_rate_pct: Optional[float] = None
unable_rate_pct: Optional[float] = None
max_adverse_excursion_pct: Optional[float] = None
class DecisionSignalProfileCalibrationBreakdowns(BaseModel):
decision_profile: List[DecisionSignalProfileCalibrationBucket] = Field(default_factory=list)
decision_profile_action: List[DecisionSignalProfileCalibrationBucket] = Field(default_factory=list)
decision_profile_horizon: List[DecisionSignalProfileCalibrationBucket] = Field(default_factory=list)
decision_profile_market_phase: List[DecisionSignalProfileCalibrationBucket] = Field(default_factory=list)
decision_profile_data_quality_level: List[DecisionSignalProfileCalibrationBucket] = Field(default_factory=list)
profile_source: List[DecisionSignalProfileCalibrationBucket] = Field(default_factory=list)
class DecisionSignalProfileCalibration(BaseModel):
minimum_completed_sample_size: int = Field(..., ge=1)
breakdowns: DecisionSignalProfileCalibrationBreakdowns
class DecisionSignalOutcomeStatsResponse(BaseModel):
engine_version: str
horizons: Optional[List[str]] = None
@@ -200,6 +230,7 @@ class DecisionSignalOutcomeStatsResponse(BaseModel):
avg_stock_return_pct: Optional[float] = None
unable_reasons: Dict[str, int] = Field(default_factory=dict)
breakdowns: Dict[str, List[DecisionSignalOutcomeStatsBucket]] = Field(default_factory=dict)
profile_calibration: DecisionSignalProfileCalibration
class DecisionSignalFeedbackRequest(BaseModel):

View File

@@ -593,6 +593,33 @@ describe('decisionSignalsApi', () => {
},
],
},
profile_calibration: {
minimum_completed_sample_size: 30,
breakdowns: {
decision_profile: [
{
dimensions: { decision_profile: 'balanced' },
total: 30,
completed: 30,
unable: 0,
hit: 15,
miss: 15,
neutral: 0,
sample_sufficient: true,
hit_rate_pct: 50,
avg_stock_return_pct: 1.25,
miss_rate_pct: 50,
unable_rate_pct: 0,
max_adverse_excursion_pct: 4.5,
},
],
decision_profile_action: null,
decision_profile_horizon: [],
decision_profile_market_phase: [],
decision_profile_data_quality_level: [],
profile_source: [],
},
},
},
});
@@ -618,6 +645,38 @@ describe('decisionSignalsApi', () => {
expect(stats.hitRatePct).toBe(50);
expect(stats.unableReasons).toEqual({ missing_anchor_price: 1 });
expect(stats.breakdowns.action[0].unableReasons).toEqual({ missing_anchor_price: 1 });
expect(stats.profileCalibration?.minimumCompletedSampleSize).toBe(30);
expect(stats.profileCalibration?.breakdowns.decisionProfile[0]).toEqual(expect.objectContaining({
dimensions: { decisionProfile: 'balanced' },
sampleSufficient: true,
maxAdverseExcursionPct: 4.5,
}));
expect(stats.profileCalibration?.breakdowns.decisionProfileAction).toEqual([]);
});
it('keeps legacy outcome stats usable when profile calibration is absent', async () => {
get.mockResolvedValueOnce({
data: {
engine_version: 'decision-signal-v1',
statuses: ['active'],
total: 0,
completed: 0,
unable: 0,
hit: 0,
miss: 0,
neutral: 0,
hit_rate_pct: null,
avg_stock_return_pct: null,
unable_reasons: {},
breakdowns: {},
},
});
const stats = await decisionSignalsApi.getOutcomeStats();
expect(stats.profileCalibration).toBeUndefined();
expect(stats.breakdowns).toEqual({});
expect(stats.unableReasons).toEqual({});
});
it('gets per-signal outcomes and upserts feedback', async () => {

View File

@@ -17,6 +17,7 @@ import type {
DecisionSignalOutcomeStatsBucket,
DecisionSignalOutcomeStatsParams,
DecisionSignalOutcomeStatsResponse,
DecisionSignalProfileCalibrationBucket,
DecisionSignalReassessRequest,
DecisionSignalReassessBlockedError,
DecisionSignalReassessResponse,
@@ -135,6 +136,10 @@ function toDecisionSignalStatsBucket(data: Record<string, unknown>): DecisionSig
return bucket;
}
function toProfileCalibrationBuckets(value: unknown): DecisionSignalProfileCalibrationBucket[] {
return Array.isArray(value) ? value as DecisionSignalProfileCalibrationBucket[] : [];
}
function toDecisionSignalOutcomeStatsResponse(data: Record<string, unknown>): DecisionSignalOutcomeStatsResponse {
const response = toCamelCase<DecisionSignalOutcomeStatsResponse>(data);
response.unableReasons = (data.unable_reasons as Record<string, number> | undefined) ?? {};
@@ -147,6 +152,27 @@ function toDecisionSignalOutcomeStatsResponse(data: Record<string, unknown>): De
: [];
}
}
const calibration = response.profileCalibration;
const calibrationBreakdowns = calibration?.breakdowns;
if (
calibration
&& calibrationBreakdowns
&& typeof calibrationBreakdowns === 'object'
&& !Array.isArray(calibrationBreakdowns)
) {
calibration.breakdowns = {
decisionProfile: toProfileCalibrationBuckets(calibrationBreakdowns.decisionProfile),
decisionProfileAction: toProfileCalibrationBuckets(calibrationBreakdowns.decisionProfileAction),
decisionProfileHorizon: toProfileCalibrationBuckets(calibrationBreakdowns.decisionProfileHorizon),
decisionProfileMarketPhase: toProfileCalibrationBuckets(calibrationBreakdowns.decisionProfileMarketPhase),
decisionProfileDataQualityLevel: toProfileCalibrationBuckets(
calibrationBreakdowns.decisionProfileDataQualityLevel,
),
profileSource: toProfileCalibrationBuckets(calibrationBreakdowns.profileSource),
};
} else {
response.profileCalibration = undefined;
}
return response;
}

View File

@@ -0,0 +1,249 @@
import type React from 'react';
import { useMemo, useState } from 'react';
import type { DecisionAction } from '../../types/analysis';
import type {
DecisionProfile,
DecisionSignalHorizon,
DecisionSignalProfileCalibration as DecisionSignalProfileCalibrationData,
DecisionSignalProfileCalibrationBucket,
} from '../../types/decisionSignals';
import { useUiLanguage } from '../../contexts/UiLanguageContext';
import { buildDecisionActionLabelMap } from '../../utils/decisionAction';
import { getDecisionSignalHorizonLabel } from '../../utils/decisionSignalLabels';
import { getDecisionProfileLabel } from '../../utils/decisionSignalProfile';
import { cn } from '../../utils/cn';
type BreakdownMode = 'horizon' | 'action';
const PROFILE_OPTIONS: DecisionProfile[] = ['conservative', 'balanced', 'aggressive'];
const ACTION_VALUES: DecisionAction[] = ['buy', 'add', 'hold', 'reduce', 'sell', 'watch', 'avoid', 'alert'];
const HORIZON_VALUES: DecisionSignalHorizon[] = ['intraday', '1d', '3d', '5d', '10d', 'swing', 'long'];
function isDecisionAction(value: string | undefined): value is DecisionAction {
return !!value && ACTION_VALUES.includes(value as DecisionAction);
}
function isDecisionSignalHorizon(value: string | undefined): value is DecisionSignalHorizon {
return !!value && HORIZON_VALUES.includes(value as DecisionSignalHorizon);
}
function formatPercent(value: number | null): string {
if (value === null || Number.isNaN(value)) return '';
const formatted = Number(value).toFixed(2).replace(/\.?0+$/, '');
return `${formatted}%`;
}
type Props = {
calibration: DecisionSignalProfileCalibrationData;
};
export const DecisionSignalProfileCalibration: React.FC<Props> = ({ calibration }) => {
const { t } = useUiLanguage();
const actionLabels = useMemo(() => buildDecisionActionLabelMap(t), [t]);
const [selectedProfile, setSelectedProfile] = useState<DecisionProfile>('balanced');
const [breakdownMode, setBreakdownMode] = useState<BreakdownMode>('horizon');
const profileBuckets = calibration.breakdowns.decisionProfile;
const selectedProfileBucket = profileBuckets.find(
(bucket) => bucket.dimensions.decisionProfile === selectedProfile,
);
const unknownProfileBucket = profileBuckets.find(
(bucket) => bucket.dimensions.decisionProfile === 'unknown',
);
const allChildBuckets = breakdownMode === 'horizon'
? calibration.breakdowns.decisionProfileHorizon
: calibration.breakdowns.decisionProfileAction;
const childBuckets = allChildBuckets.filter(
(bucket) => bucket.dimensions.decisionProfile === selectedProfile,
);
const metricRows = (bucket: DecisionSignalProfileCalibrationBucket) => [
{
label: t('decisionSignals.profileCalibrationHitRate'),
value: formatPercent(bucket.hitRatePct),
tone: 'text-success',
},
{
label: t('decisionSignals.profileCalibrationAverageReturn'),
value: formatPercent(bucket.avgStockReturnPct),
tone: 'text-foreground',
},
{
label: t('decisionSignals.profileCalibrationMissRate'),
value: formatPercent(bucket.missRatePct),
tone: 'text-danger',
},
{
label: t('decisionSignals.profileCalibrationUnableRate'),
value: formatPercent(bucket.unableRatePct),
tone: 'text-warning',
},
{
label: t('decisionSignals.profileCalibrationMae'),
value: formatPercent(bucket.maxAdverseExcursionPct),
tone: 'text-warning',
},
];
const childLabel = (bucket: DecisionSignalProfileCalibrationBucket): string => {
if (breakdownMode === 'action') {
const action = bucket.dimensions.action;
return isDecisionAction(action)
? actionLabels[action]
: t('decisionSignals.profileCalibrationUnknownDimension');
}
const horizon = bucket.dimensions.horizon;
return isDecisionSignalHorizon(horizon)
? getDecisionSignalHorizonLabel(horizon, t)
: t('decisionSignals.profileCalibrationUnknownDimension');
};
return (
<section className="mt-5 border-t border-border/60 pt-5" aria-labelledby="profile-calibration-title">
<div className="max-w-3xl">
<h3 id="profile-calibration-title" className="text-base font-semibold text-foreground">
{t('decisionSignals.profileCalibrationTitle')}
</h3>
<p className="mt-1 text-sm text-secondary-text">
{t('decisionSignals.profileCalibrationDescription')}
</p>
<p className="mt-1 text-xs text-secondary-text">
{t('decisionSignals.profileCalibrationThreshold', {
count: calibration.minimumCompletedSampleSize,
})}
</p>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
{PROFILE_OPTIONS.map((profile) => {
const bucket = profileBuckets.find((item) => item.dimensions.decisionProfile === profile);
const completed = bucket?.completed ?? 0;
const selected = selectedProfile === profile;
return (
<button
key={profile}
type="button"
aria-pressed={selected}
onClick={() => setSelectedProfile(profile)}
className={cn(
'rounded-xl border px-3 py-3 text-left transition-colors',
selected
? 'border-primary/70 bg-primary/10 text-foreground'
: 'border-border/60 bg-elevated/30 text-secondary-text hover:border-primary/40 hover:text-foreground',
)}
>
<span className="block text-sm font-medium">{getDecisionProfileLabel(profile, t)}</span>
<span className="mt-1 block text-xs">
{t('decisionSignals.profileCalibrationCompletedShort', { count: completed })}
</span>
</button>
);
})}
</div>
{unknownProfileBucket && unknownProfileBucket.total > 0 ? (
<p className="mt-3 text-xs text-secondary-text">
{t('decisionSignals.profileCalibrationUnknownNotice', { count: unknownProfileBucket.total })}
</p>
) : null}
<div className="mt-4 rounded-xl border border-border/60 bg-elevated/25 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="text-sm font-semibold text-foreground">
{getDecisionProfileLabel(selectedProfile, t)}
</h4>
<p className="text-xs text-secondary-text">
{t('decisionSignals.profileCalibrationSampleCounts', {
completed: selectedProfileBucket?.completed ?? 0,
total: selectedProfileBucket?.total ?? 0,
})}
</p>
</div>
{!selectedProfileBucket?.sampleSufficient ? (
<p className="mt-3 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
{t('decisionSignals.profileCalibrationInsufficient')}
</p>
) : (
<div className="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
{metricRows(selectedProfileBucket).map((metric) => (
<div key={metric.label} className="rounded-lg border border-border/50 bg-background/30 px-3 py-2">
<p className="text-xs text-secondary-text">{metric.label}</p>
<p className={cn('mt-1 text-lg font-semibold', metric.tone)}>
{metric.value || t('decisionSignals.profileCalibrationUnavailable')}
</p>
</div>
))}
</div>
)}
</div>
<p className="mt-3 text-xs text-secondary-text">
{t('decisionSignals.profileCalibrationMaeDescription')}
</p>
<div className="mt-5 flex flex-wrap gap-2" aria-label={t('decisionSignals.profileCalibrationBreakdownLabel')}>
{(['horizon', 'action'] as BreakdownMode[]).map((mode) => (
<button
key={mode}
type="button"
aria-pressed={breakdownMode === mode}
onClick={() => setBreakdownMode(mode)}
className={cn(
'rounded-lg border px-3 py-2 text-sm transition-colors',
breakdownMode === mode
? 'border-primary/70 bg-primary/10 text-foreground'
: 'border-border/60 text-secondary-text hover:border-primary/40 hover:text-foreground',
)}
>
{mode === 'horizon'
? t('decisionSignals.profileCalibrationByHorizon')
: t('decisionSignals.profileCalibrationByAction')}
</button>
))}
</div>
{childBuckets.length === 0 ? (
<p className="mt-3 text-sm text-secondary-text">
{t('decisionSignals.profileCalibrationNoBreakdownSamples')}
</p>
) : (
<div className="mt-3 grid gap-3 lg:grid-cols-2">
{childBuckets.map((bucket) => {
const label = childLabel(bucket);
return (
<article
key={`${selectedProfile}-${breakdownMode}-${label}`}
className="rounded-xl border border-border/60 bg-elevated/25 p-4"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<h5 className="text-sm font-semibold text-foreground">{label}</h5>
<p className="text-xs text-secondary-text">
{t('decisionSignals.profileCalibrationSampleCounts', {
completed: bucket.completed,
total: bucket.total,
})}
</p>
</div>
{!bucket.sampleSufficient ? (
<p className="mt-3 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
{t('decisionSignals.profileCalibrationInsufficient')}
</p>
) : (
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
{metricRows(bucket).map((metric) => (
<div key={metric.label} className="rounded-lg border border-border/50 bg-background/30 px-3 py-2">
<p className="text-xs text-secondary-text">{metric.label}</p>
<p className={cn('mt-1 text-base font-semibold', metric.tone)}>
{metric.value || t('decisionSignals.profileCalibrationUnavailable')}
</p>
</div>
))}
</div>
)}
</article>
);
})}
</div>
)}
</section>
);
};

View File

@@ -0,0 +1,158 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { UiLanguageProvider } from '../../../contexts/UiLanguageContext';
import type {
DecisionSignalProfileCalibration as DecisionSignalProfileCalibrationData,
DecisionSignalProfileCalibrationBucket,
} from '../../../types/decisionSignals';
import { DecisionSignalProfileCalibration } from '../DecisionSignalProfileCalibration';
function bucket(
dimensions: Record<string, string>,
overrides: Partial<DecisionSignalProfileCalibrationBucket> = {},
): DecisionSignalProfileCalibrationBucket {
return {
dimensions,
total: 30,
completed: 30,
unable: 0,
hit: 15,
miss: 15,
neutral: 0,
sampleSufficient: true,
hitRatePct: 50,
avgStockReturnPct: 1.25,
missRatePct: 50,
unableRatePct: 0,
maxAdverseExcursionPct: 4.5,
...overrides,
};
}
const calibration: DecisionSignalProfileCalibrationData = {
minimumCompletedSampleSize: 30,
breakdowns: {
decisionProfile: [
bucket(
{ decisionProfile: 'balanced' },
{ hitRatePct: 0, avgStockReturnPct: null, missRatePct: 100, maxAdverseExcursionPct: null },
),
bucket(
{ decisionProfile: 'conservative' },
{
total: 29,
completed: 29,
hit: 14,
miss: 15,
sampleSufficient: false,
hitRatePct: null,
avgStockReturnPct: null,
missRatePct: null,
unableRatePct: null,
maxAdverseExcursionPct: null,
},
),
bucket(
{ decisionProfile: 'unknown' },
{
total: 2,
completed: 0,
unable: 2,
hit: 0,
miss: 0,
sampleSufficient: false,
hitRatePct: null,
avgStockReturnPct: null,
missRatePct: null,
unableRatePct: null,
maxAdverseExcursionPct: null,
},
),
],
decisionProfileAction: [
bucket({ decisionProfile: 'balanced', action: 'buy' }),
],
decisionProfileHorizon: [
bucket(
{ decisionProfile: 'balanced', horizon: '3d' },
{
total: 29,
completed: 29,
hit: 14,
miss: 15,
sampleSufficient: false,
hitRatePct: null,
avgStockReturnPct: null,
missRatePct: null,
unableRatePct: null,
maxAdverseExcursionPct: null,
},
),
],
decisionProfileMarketPhase: [],
decisionProfileDataQualityLevel: [],
profileSource: [],
},
};
function renderCalibration(value = calibration) {
return render(
<UiLanguageProvider>
<DecisionSignalProfileCalibration calibration={value} />
</UiLanguageProvider>,
);
}
describe('DecisionSignalProfileCalibration', () => {
beforeEach(() => {
window.localStorage.clear();
window.localStorage.setItem('dsa.uiLanguage', 'zh');
});
it('keeps profile and child sample gates independent and distinguishes zero from no result', () => {
renderCalibration();
expect(screen.getByRole('heading', { name: '决策风格历史表现' })).toBeInTheDocument();
const balancedButton = screen.getByRole('button', { name: /均衡.*已完成 30/ });
expect(balancedButton).toHaveAttribute('aria-pressed', 'true');
expect(screen.getAllByText('0%').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('暂无可计算结果').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('另有 2 条历史样本缺少决策风格标记,未计入三类风格。')).toBeInTheDocument();
const horizonCard = screen.getByRole('heading', { name: '3 日' }).closest('article');
expect(horizonCard).not.toBeNull();
expect(within(horizonCard as HTMLElement).getByText('样本不足,仅供观察。')).toBeInTheDocument();
expect(within(horizonCard as HTMLElement).queryByText('命中率')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /保守.*已完成 29/ }));
expect(screen.getByRole('button', { name: /保守.*已完成 29/ })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('样本不足,仅供观察。')).toBeInTheDocument();
expect(screen.getByText('暂无可观察的细分样本。')).toBeInTheDocument();
});
it('switches only between the two frozen user-facing breakdown views', () => {
renderCalibration();
const breakdownControls = screen.getByLabelText('细分统计方式');
expect(within(breakdownControls).getAllByRole('button')).toHaveLength(2);
const actionButton = within(breakdownControls).getByRole('button', { name: '按建议动作' });
fireEvent.click(actionButton);
expect(actionButton).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('heading', { name: '买入' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '3 日' })).not.toBeInTheDocument();
});
it('renders the same controls and disclosure in English', () => {
window.localStorage.setItem('dsa.uiLanguage', 'en');
renderCalibration();
expect(screen.getByRole('heading', { name: 'Decision profile history' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Balanced.*30 completed/ })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(screen.getByText(/descriptive only/)).toBeInTheDocument();
expect(screen.getByText('Insufficient sample size; for observation only.')).toBeInTheDocument();
});
});

View File

@@ -323,6 +323,25 @@ const zh = {
'decisionSignals.statsHitRate': '命中率',
'decisionSignals.statsTitle': '信号表现统计',
'decisionSignals.statsTotal': '评估数',
'decisionSignals.profileCalibrationAverageReturn': '标的平均区间涨跌',
'decisionSignals.profileCalibrationBreakdownLabel': '细分统计方式',
'decisionSignals.profileCalibrationByAction': '按建议动作',
'decisionSignals.profileCalibrationByHorizon': '按复盘周期',
'decisionSignals.profileCalibrationCompletedShort': '已完成 {count}',
'decisionSignals.profileCalibrationDescription': '这些数据来自历史后验复盘,只用于描述,不代表某种风格更优,也不构成投资建议。',
'decisionSignals.profileCalibrationHitRate': '命中率',
'decisionSignals.profileCalibrationInsufficient': '样本不足,仅供观察。',
'decisionSignals.profileCalibrationMae': '最大不利波动',
'decisionSignals.profileCalibrationMaeDescription': '最大不利波动表示复盘期间相对起始价最不利的一次波动,不代表未来风险上限。',
'decisionSignals.profileCalibrationMissRate': '未命中率',
'decisionSignals.profileCalibrationNoBreakdownSamples': '暂无可观察的细分样本。',
'decisionSignals.profileCalibrationSampleCounts': '已完成 {completed} / 总评估 {total}',
'decisionSignals.profileCalibrationThreshold': '每个分组至少需要 {count} 个已完成样本才展示表现指标。',
'decisionSignals.profileCalibrationTitle': '决策风格历史表现',
'decisionSignals.profileCalibrationUnableRate': '无法评估率',
'decisionSignals.profileCalibrationUnavailable': '暂无可计算结果',
'decisionSignals.profileCalibrationUnknownDimension': '未知',
'decisionSignals.profileCalibrationUnknownNotice': '另有 {count} 条历史样本缺少决策风格标记,未计入三类风格。',
'decisionSignals.status': '状态',
'decisionSignals.stockContextApply': '查看股票',
'decisionSignals.stockContextClear': '清空当前股票',
@@ -1226,6 +1245,25 @@ const en: Record<UiTextKey, string> = {
'decisionSignals.statsHitRate': 'Hit rate',
'decisionSignals.statsTitle': 'Signal performance',
'decisionSignals.statsTotal': 'Evaluations',
'decisionSignals.profileCalibrationAverageReturn': 'Average stock window return',
'decisionSignals.profileCalibrationBreakdownLabel': 'Breakdown view',
'decisionSignals.profileCalibrationByAction': 'By suggested action',
'decisionSignals.profileCalibrationByHorizon': 'By review horizon',
'decisionSignals.profileCalibrationCompletedShort': '{count} completed',
'decisionSignals.profileCalibrationDescription': 'These historical outcome reviews are descriptive only. They do not prove one profile is better and are not investment advice.',
'decisionSignals.profileCalibrationHitRate': 'Hit rate',
'decisionSignals.profileCalibrationInsufficient': 'Insufficient sample size; for observation only.',
'decisionSignals.profileCalibrationMae': 'Maximum adverse move',
'decisionSignals.profileCalibrationMaeDescription': 'Maximum adverse move is the worst move from the starting price during the review window. It is not a limit on future risk.',
'decisionSignals.profileCalibrationMissRate': 'Miss rate',
'decisionSignals.profileCalibrationNoBreakdownSamples': 'No breakdown samples are available to observe yet.',
'decisionSignals.profileCalibrationSampleCounts': '{completed} completed / {total} total',
'decisionSignals.profileCalibrationThreshold': 'Each group needs at least {count} completed samples before performance metrics are shown.',
'decisionSignals.profileCalibrationTitle': 'Decision profile history',
'decisionSignals.profileCalibrationUnableRate': 'Unable-to-evaluate rate',
'decisionSignals.profileCalibrationUnavailable': 'No calculable result',
'decisionSignals.profileCalibrationUnknownDimension': 'Unknown',
'decisionSignals.profileCalibrationUnknownNotice': '{count} historical samples have no decision profile label and are not included in the three profiles.',
'decisionSignals.status': 'Status',
'decisionSignals.stockContextApply': 'View stock',
'decisionSignals.stockContextClear': 'Clear current stock',

View File

@@ -22,6 +22,7 @@ import {
DecisionSignalCard,
DecisionSignalDetails,
} from '../components/decision-signals/DecisionSignalDisplay';
import { DecisionSignalProfileCalibration } from '../components/decision-signals/DecisionSignalProfileCalibration';
import { DecisionSignalTimeline } from '../components/decision-signals/DecisionSignalTimeline';
import { StockAutocomplete } from '../components/StockAutocomplete';
import { useUiLanguage } from '../contexts/UiLanguageContext';
@@ -1368,27 +1369,32 @@ const DecisionSignalsPage: React.FC = () => {
) : statsLoading ? (
<p className="text-sm text-secondary-text">{t('common.loading')}...</p>
) : outcomeStats && outcomeStats.total > 0 ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div className="rounded-xl border border-border/60 bg-elevated/40 px-3 py-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.statsTotal')}</p>
<p className="mt-1 text-2xl font-semibold text-foreground">{outcomeStats.total}</p>
</div>
<div className="rounded-xl border border-border/60 bg-elevated/40 px-3 py-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.statsHitRate')}</p>
<p className="mt-1 text-2xl font-semibold text-success">{formatStatPercent(outcomeStats.hitRatePct)}</p>
</div>
<div className="rounded-xl border border-border/60 bg-elevated/40 px-3 py-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.outcome.hit')}</p>
<p className="mt-1 text-2xl font-semibold text-success">{outcomeStats.hit}</p>
</div>
<div className="rounded-xl border border-border/60 bg-elevated/40 px-3 py-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.outcome.miss')}</p>
<p className="mt-1 text-2xl font-semibold text-danger">{outcomeStats.miss}</p>
</div>
<div className="rounded-xl border border-border/60 bg-elevated/40 px-3 py-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.outcome.unable')}</p>
<p className="mt-1 text-2xl font-semibold text-warning">{outcomeStats.unable}</p>
<div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div className="rounded-xl border border-border/60 bg-elevated/40 px-3 py-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.statsTotal')}</p>
<p className="mt-1 text-2xl font-semibold text-foreground">{outcomeStats.total}</p>
</div>
<div className="rounded-xl border border-border/60 bg-elevated/40 px-3 py-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.statsHitRate')}</p>
<p className="mt-1 text-2xl font-semibold text-success">{formatStatPercent(outcomeStats.hitRatePct)}</p>
</div>
<div className="rounded-xl border border-border/60 bg-elevated/40 px-3 py-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.outcome.hit')}</p>
<p className="mt-1 text-2xl font-semibold text-success">{outcomeStats.hit}</p>
</div>
<div className="rounded-xl border border-border/60 bg-elevated/40 px-3 py-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.outcome.miss')}</p>
<p className="mt-1 text-2xl font-semibold text-danger">{outcomeStats.miss}</p>
</div>
<div className="rounded-xl border border-border/60 bg-elevated/40 px-3 py-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.outcome.unable')}</p>
<p className="mt-1 text-2xl font-semibold text-warning">{outcomeStats.unable}</p>
</div>
</div>
{outcomeStats.profileCalibration ? (
<DecisionSignalProfileCalibration calibration={outcomeStats.profileCalibration} />
) : null}
</div>
) : (
<EmptyState

View File

@@ -198,6 +198,48 @@ const outcomeStats: DecisionSignalOutcomeStatsResponse = {
avgStockReturnPct: 2.5,
unableReasons: { missing_anchor_price: 1 },
breakdowns: {},
profileCalibration: {
minimumCompletedSampleSize: 30,
breakdowns: {
decisionProfile: [
{
dimensions: { decisionProfile: 'balanced' },
total: 2,
completed: 2,
unable: 0,
hit: 1,
miss: 1,
neutral: 0,
sampleSufficient: false,
hitRatePct: null,
avgStockReturnPct: null,
missRatePct: null,
unableRatePct: null,
maxAdverseExcursionPct: null,
},
{
dimensions: { decisionProfile: 'unknown' },
total: 1,
completed: 0,
unable: 1,
hit: 0,
miss: 0,
neutral: 0,
sampleSufficient: false,
hitRatePct: null,
avgStockReturnPct: null,
missRatePct: null,
unableRatePct: null,
maxAdverseExcursionPct: null,
},
],
decisionProfileAction: [],
decisionProfileHorizon: [],
decisionProfileMarketPhase: [],
decisionProfileDataQualityLevel: [],
profileSource: [],
},
},
};
const outcomeList: DecisionSignalOutcomeListResponse = {
@@ -403,6 +445,21 @@ describe('DecisionSignalsPage', () => {
expect(screen.getByText('放量下跌风险')).toBeInTheDocument();
expect(screen.getByText(formattedCreatedAt)).toBeInTheDocument();
expect(screen.getByText('当前统计为全局已复盘 outcome 口径,不等于当前可见信号数量,也不随当前股票过滤。')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '决策风格历史表现' })).toBeInTheDocument();
expect(decisionSignalsApi.getOutcomeStats).toHaveBeenCalledTimes(1);
});
it('keeps the existing stats card usable when the backend omits profile calibration', async () => {
vi.mocked(decisionSignalsApi.getOutcomeStats).mockResolvedValueOnce({
...outcomeStats,
profileCalibration: undefined,
});
renderPage();
expect(await screen.findByText('信号表现统计')).toBeInTheDocument();
expect(screen.getByText('50%')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '决策风格历史表现' })).not.toBeInTheDocument();
});
it('shows a zero-sample outcome stats state instead of misleading zero metrics', async () => {

View File

@@ -258,6 +258,36 @@ export interface DecisionSignalOutcomeStatsBucket {
unableReasons: Record<string, number>;
}
export interface DecisionSignalProfileCalibrationBucket {
dimensions: Record<string, string>;
total: number;
completed: number;
unable: number;
hit: number;
miss: number;
neutral: number;
sampleSufficient: boolean;
hitRatePct: number | null;
avgStockReturnPct: number | null;
missRatePct: number | null;
unableRatePct: number | null;
maxAdverseExcursionPct: number | null;
}
export interface DecisionSignalProfileCalibrationBreakdowns {
decisionProfile: DecisionSignalProfileCalibrationBucket[];
decisionProfileAction: DecisionSignalProfileCalibrationBucket[];
decisionProfileHorizon: DecisionSignalProfileCalibrationBucket[];
decisionProfileMarketPhase: DecisionSignalProfileCalibrationBucket[];
decisionProfileDataQualityLevel: DecisionSignalProfileCalibrationBucket[];
profileSource: DecisionSignalProfileCalibrationBucket[];
}
export interface DecisionSignalProfileCalibration {
minimumCompletedSampleSize: number;
breakdowns: DecisionSignalProfileCalibrationBreakdowns;
}
export interface DecisionSignalOutcomeStatsResponse {
engineVersion: string;
horizons?: DecisionSignalHorizon[] | null;
@@ -272,6 +302,7 @@ export interface DecisionSignalOutcomeStatsResponse {
avgStockReturnPct?: number | null;
unableReasons: Record<string, number>;
breakdowns: Record<string, DecisionSignalOutcomeStatsBucket[]>;
profileCalibration?: DecisionSignalProfileCalibration;
}
export interface DecisionSignalOutcomeStatsParams {

View File

@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
- [chore] 暂停 PR Review 的自动触发,仅保留 `workflow_dispatch` 手动入口,避免辅助评审重复运行及评论权限失败产生误导性红灯;正式 CI 检查保持不变。
- [新功能] Multi-Agent specialist 运行在分析历史保存成功后,按独立 skill 持久化版本化、低敏且幂等的有效 opinion 样本,为后续后验评估提供真实数据;本阶段不计算 outcome、不统计表现、不调整权重。
- [新功能] AI 建议页在既有后验统计中增加决策风格历史表现,按每个分组独立的 30 个已完成样本门槛展示命中、区间涨跌、无法评估和最大不利波动,并保持旧统计接口兼容。
- [新功能] Multi-Agent 报告按八态用户 action 追踪 Pipeline 最终调整,排除非法 Agent 意见;仅在 canonical action 可唯一解析时生成 explanation 与 DecisionSignal并以同一个 `final_action` 统一最终动作契约。
- [新功能] 新增 `--portfolio futu`,只读导入 Futu OpenD 真实账户的沪深 A 股、港股、美股 LONG 正股持仓作为分析列表。
- [新功能] 多策略综合新增受控 deliberation v0、可注入 mediator/self-review v1-v2、只读 revision projection v3 与 multi-round v4所有增强层相对上一层 baseline 只能保持或继续 softened不覆盖权威最终信号。

View File

@@ -6116,6 +6116,9 @@
},
"type": "object",
"title": "Breakdowns"
},
"profile_calibration": {
"$ref": "#/components/schemas/DecisionSignalProfileCalibration"
}
},
"type": "object",
@@ -6126,10 +6129,182 @@
"unable",
"hit",
"miss",
"neutral"
"neutral",
"profile_calibration"
],
"title": "DecisionSignalOutcomeStatsResponse"
},
"DecisionSignalProfileCalibration": {
"properties": {
"minimum_completed_sample_size": {
"type": "integer",
"minimum": 1.0,
"title": "Minimum Completed Sample Size"
},
"breakdowns": {
"$ref": "#/components/schemas/DecisionSignalProfileCalibrationBreakdowns"
}
},
"type": "object",
"required": [
"minimum_completed_sample_size",
"breakdowns"
],
"title": "DecisionSignalProfileCalibration"
},
"DecisionSignalProfileCalibrationBreakdowns": {
"properties": {
"decision_profile": {
"items": {
"$ref": "#/components/schemas/DecisionSignalProfileCalibrationBucket"
},
"type": "array",
"title": "Decision Profile"
},
"decision_profile_action": {
"items": {
"$ref": "#/components/schemas/DecisionSignalProfileCalibrationBucket"
},
"type": "array",
"title": "Decision Profile Action"
},
"decision_profile_horizon": {
"items": {
"$ref": "#/components/schemas/DecisionSignalProfileCalibrationBucket"
},
"type": "array",
"title": "Decision Profile Horizon"
},
"decision_profile_market_phase": {
"items": {
"$ref": "#/components/schemas/DecisionSignalProfileCalibrationBucket"
},
"type": "array",
"title": "Decision Profile Market Phase"
},
"decision_profile_data_quality_level": {
"items": {
"$ref": "#/components/schemas/DecisionSignalProfileCalibrationBucket"
},
"type": "array",
"title": "Decision Profile Data Quality Level"
},
"profile_source": {
"items": {
"$ref": "#/components/schemas/DecisionSignalProfileCalibrationBucket"
},
"type": "array",
"title": "Profile Source"
}
},
"type": "object",
"title": "DecisionSignalProfileCalibrationBreakdowns"
},
"DecisionSignalProfileCalibrationBucket": {
"properties": {
"dimensions": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"title": "Dimensions"
},
"total": {
"type": "integer",
"title": "Total"
},
"completed": {
"type": "integer",
"title": "Completed"
},
"unable": {
"type": "integer",
"title": "Unable"
},
"hit": {
"type": "integer",
"title": "Hit"
},
"miss": {
"type": "integer",
"title": "Miss"
},
"neutral": {
"type": "integer",
"title": "Neutral"
},
"sample_sufficient": {
"type": "boolean",
"title": "Sample Sufficient"
},
"hit_rate_pct": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Hit Rate Pct"
},
"avg_stock_return_pct": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Avg Stock Return Pct"
},
"miss_rate_pct": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Miss Rate Pct"
},
"unable_rate_pct": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Unable Rate Pct"
},
"max_adverse_excursion_pct": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Max Adverse Excursion Pct"
}
},
"type": "object",
"required": [
"total",
"completed",
"unable",
"hit",
"miss",
"neutral",
"sample_sufficient"
],
"title": "DecisionSignalProfileCalibrationBucket"
},
"AlertTriggerItem": {
"properties": {
"id": {

View File

@@ -79,6 +79,34 @@ Web 展示必须把这些 wire value 映射为当前 UI 语言的用户可读标
这些接口继承现有 `/api/v1/*` 管理员鉴权;`ADMIN_AUTH_ENABLED=true` 时需要有效管理员会话 Cookie。
## 决策风格历史表现
#1758 在现有 `GET /api/v1/decision-signals/outcomes/stats` 响应中追加 `profile_calibration`,没有新增 endpoint、数据库表、配置项或行情请求。旧的全局统计字段和八类单维 breakdown 保持原口径;一条样本仍是一条 `(signal_id, horizon, engine_version)` outcome 记录,同一信号的不同复盘周期会分别计数,不能理解成独立信号数量。
`profile_calibration.minimum_completed_sample_size` 固定为 `30`breakdowns 包含:
- `decision_profile`
- `decision_profile_action`
- `decision_profile_horizon`
- `decision_profile_market_phase`
- `decision_profile_data_quality_level`
- `profile_source`
每个 bucket 的 `dimensions` 都是结构化字段不使用拼接字符串。Profile 校准按以下来源解释:
- `decision_profile` 读取关联信号的当前正式字段;`NULL` 或非法值归入 `unknown`,不会回退为 `balanced`
- `profile_source` 读取关联信号的当前 metadata只接受 `auto_default``backfill_defaulted``legacy_unknown``user_selected`,其他情况归入 `unknown`。这是当前归因而非 outcome 时点快照metadata 被合法替换后统计归属可能变化。
- `action``horizon``market_phase``data_quality_level` 读取 outcome 已冻结字段。新建 outcome 时 data quality 优先使用 `data_quality_summary` 的显式 levelsummary 缺失或有效 JSON 中没有显式 level 时,才使用规范化后的 `metadata.data_quality_level`。已存在的 outcome 不会被这项读取规则静默重写。
每个 bucket 独立使用 `completed >= 30` 的门槛,父 bucket、全局样本或其他 sibling bucket 都不能解锁它。样本不足时 counts 仍返回,但 `hit_rate_pct``avg_stock_return_pct``miss_rate_pct``unable_rate_pct``max_adverse_excursion_pct` 全部为 `null`Web 只展示样本量和“样本不足,仅供观察。”。样本充足时:
- 命中率为 `hit / (hit + miss)`,未命中率为 `miss / (hit + miss)`neutral 不进入这两个分母。
- 无法评估率为 `unable / total`
- 标的平均区间涨跌沿用已有 completed outcome 的 `stock_return_pct` 平均值,不代表策略或组合收益。
- 最大不利波动只使用 outcome 已保存的价格。`buy/add/hold/watch/alert``(start_price - min_low) / start_price``sell/reduce/avoid``(max_high - start_price) / start_price`,结果不小于 0价格缺失、非有限或起始价非正时该行不可计算。Bucket 返回可计算行中的最大值;没有可计算行时为 `null`,不会为补齐指标读取行情。
Web 在原有“信号表现统计”卡片内提供“保守 / 均衡 / 进取”三个用户入口,固定默认选择均衡,并只提供“按建议动作”和“按复盘周期”两个细分视图。它不排名、不推荐风格,也不增加请求、路由、导航或设置项;旧后端没有 `profile_calibration` 时仍显示原统计卡片。
## Reassess preview 与 persist
`reassess` 只使用 `source_report_id` 对应的持久化历史报告快照。`persist=false` 用于用户确认前预览;`persist=true` 会以相同 `source_report_id + decision_profile` 在服务端重新计算,不信任之前 preview 或客户端缓存的任何决策字段。

View File

@@ -1479,6 +1479,8 @@ P5 后验评估只支持日线可验证的 `1d/3d/5d/10d`,窗口语义是 anch
P5 在 Web `/decision-signals` 页面筛选区下方展示当前 outcome engine 的整体统计卡片;详情抽屉按需读取该信号 outcomes并可提交 useful/not useful 反馈。该页面不新增导航页,不进入 BacktestPage也不新增后台定时任务后验计算由 `POST /api/v1/decision-signals/outcomes/run` 显式触发。批量运行默认优先推进缺失 outcome 的信号,再重试可恢复 unable不会让已完成或终态 unable 的最新信号长期占满 `limit`
#1758 在同一个 `GET /api/v1/decision-signals/outcomes/stats` 响应中追加 `profile_calibration`,按 decision profile、profile + action、profile + horizon、profile + market phase、profile + frozen data quality 和 profile source 返回结构化分组。每个分组独立要求 `completed >= 30`,不足时只保留 counts五项描述性指标统一返回 `null`Web 只展示样本量和“样本不足,仅供观察。”,不排名或推荐风格。命中/未命中率的分母是 `hit + miss`,无法评估率的分母是 total最大不利波动只从 outcome 已保存的 `start_price/min_low/max_high` 计算,不触发行情读取。`decision_profile` 和 metadata-backed `profile_source` 是查询时关联信号的当前归因action、horizon、market phase、data quality 继续使用 outcome 冻结值。新 outcome 的 data quality 在 summary 没有显式 level 时才 fallback 到规范化的 metadata level既有 outcome 不会静默重写。Web 复用原统计请求和 Card只提供保守/均衡/进取及按动作/按周期两个用户视图;旧后端缺少新字段时原统计仍可用。完整口径见 [DecisionSignal 决策信号专题](decision-signals.md)。
持仓页会把 AI 建议作为非阻断增强异步加载:组合快照和风险模块先按原逻辑渲染,随后按当前快照中的唯一持仓调用 `GET /api/v1/decision-signals/latest/{stock_code}?market=<market>&limit=1` 查询 latest active 信号;不再通过 `holding_only=true` 通用列表分页扫描,也不存在固定页数截断。单个持仓 latest 查询失败时,页面保留其他已加载信号并显示可见降级提示;无匹配信号时持仓行显示空占位。匹配逻辑复用 Web 端股票代码等价规则,覆盖 A 股 `600519/SH600519/600519.SH`、港股 `00700/HK00700/00700.HK` 和美股大小写 ticker。
#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-openWeb 风险区显示降级状态。

View File

@@ -1316,6 +1316,8 @@ P5 outcome evaluation supports only daily-bar-verifiable `1d/3d/5d/10d`. The win
P5 extends the existing Web `/decision-signals` page instead of adding a new navigation page or BacktestPage entry. The filter area now shows current outcome-engine stat cards; the details drawer lazily loads outcomes and lets the user submit useful/not useful feedback. P5 does not add a background scheduler: outcome calculation is triggered explicitly through `POST /api/v1/decision-signals/outcomes/run`. Batch runs prioritize missing outcomes first and then retry recoverable unable rows, so completed or terminal-unable newest signals do not keep consuming the `limit`.
#1758 adds `profile_calibration` to the same `GET /api/v1/decision-signals/outcomes/stats` response. It returns structured groups for decision profile, profile + action, profile + horizon, profile + market phase, profile + frozen data quality, and profile source. Every group independently requires `completed >= 30`; below that threshold it keeps counts while all five descriptive metrics are `null`, and the Web shows sample counts with “Insufficient sample size; for observation only.” It never ranks or recommends profiles. Hit and miss rates use `hit + miss` as their denominator, while the unable rate uses total. Maximum adverse move is derived only from persisted outcome `start_price/min_low/max_high` values and never triggers a market-data read. `decision_profile` and metadata-backed `profile_source` are current attribution values joined from the signal at query time; action, horizon, market phase, and data quality remain frozen outcome values. A new outcome falls back to normalized metadata data quality only when its summary has no explicit level, and existing outcomes are not silently rewritten. The Web reuses the original stats request and card, exposing only Conservative/Balanced/Aggressive plus by-action/by-horizon views; legacy servers without the new field keep the original stats card usable. See [DecisionSignal Topic](decision-signals.md) for the full contract.
The portfolio page loads AI signals as a non-blocking enhancement: portfolio snapshots and risk cards render first, then the page calls `GET /api/v1/decision-signals/latest/{stock_code}?market=<market>&limit=1` for each unique holding in the current snapshot to read the latest active signal. It no longer scans the generic `holding_only=true` list endpoint and has no fixed page-count cutoff. If a single latest lookup fails, the page keeps other loaded signals and shows a visible degradation warning; rows without a matching signal show an empty placeholder. Matching reuses the Web stock-code equivalence rules for CN variants such as `600519/SH600519/600519.SH`, HK variants such as `00700/HK00700/00700.HK`, and case-insensitive US tickers.
#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.

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, desc, func, select
@@ -16,6 +17,15 @@ from src.storage import (
)
@dataclass(frozen=True)
class OutcomeStatsRow:
"""Outcome row plus the live signal fields required by profile calibration."""
outcome: DecisionSignalOutcomeRecord
decision_profile: Optional[str]
metadata_json: Optional[str]
class DecisionSignalOutcomeRepository:
"""DB access for signal-level outcome and feedback sidecar tables."""
@@ -171,7 +181,7 @@ class DecisionSignalOutcomeRepository:
engine_version: str,
horizons: Optional[List[str]] = None,
statuses: Optional[List[str]] = None,
) -> List[DecisionSignalOutcomeRecord]:
) -> List[OutcomeStatsRow]:
conditions = [DecisionSignalOutcomeRecord.engine_version == engine_version]
if horizons:
conditions.append(DecisionSignalOutcomeRecord.horizon.in_(horizons))
@@ -179,11 +189,22 @@ class DecisionSignalOutcomeRepository:
conditions.append(DecisionSignalRecord.status.in_(statuses))
with self.db.get_session() as session:
rows = session.execute(
select(DecisionSignalOutcomeRecord)
select(
DecisionSignalOutcomeRecord,
DecisionSignalRecord.decision_profile,
DecisionSignalRecord.metadata_json,
)
.join(DecisionSignalRecord, DecisionSignalRecord.id == DecisionSignalOutcomeRecord.signal_id)
.where(and_(*conditions))
).scalars().all()
return list(rows)
).all()
return [
OutcomeStatsRow(
outcome=outcome,
decision_profile=decision_profile,
metadata_json=metadata_json,
)
for outcome, decision_profile, metadata_json in rows
]
def get_feedback(self, *, signal_id: int) -> Optional[DecisionSignalFeedbackRecord]:
with self.db.get_session() as session:

View File

@@ -11,9 +11,14 @@ import math
from typing import Any, Dict, Iterable, List, Optional, Tuple
from src.core.backtest_engine import BacktestEngine, EvaluationConfig
from src.repositories.decision_signal_outcome_repo import DecisionSignalOutcomeRepository
from src.repositories.decision_signal_outcome_repo import (
DecisionSignalOutcomeRepository,
OutcomeStatsRow,
)
from src.repositories.decision_signal_repo import DecisionSignalRepository
from src.repositories.stock_repo import StockRepository
from src.schemas.decision_profile import VALID_DECISION_PROFILES
from src.services.decision_signal_data_quality import normalize_decision_signal_data_quality
from src.services.decision_signal_service import (
HORIZONS,
SIGNAL_STATUSES,
@@ -53,6 +58,24 @@ RETRYABLE_UNABLE_REASONS = frozenset({
"invalid_end_close",
})
BATCH_CANDIDATE_SCAN_PAGE_SIZE = 500
MIN_PROFILE_CALIBRATION_SAMPLE_SIZE = 30
PROFILE_SOURCES = frozenset({
"auto_default",
"backfill_defaulted",
"legacy_unknown",
"user_selected",
})
PROFILE_CALIBRATION_BREAKDOWN_DIMENSIONS = (
("decision_profile", ("decision_profile",)),
("decision_profile_action", ("decision_profile", "action")),
("decision_profile_horizon", ("decision_profile", "horizon")),
("decision_profile_market_phase", ("decision_profile", "market_phase")),
(
"decision_profile_data_quality_level",
("decision_profile", "data_quality_level"),
),
("profile_source", ("profile_source",)),
)
class DecisionSignalOutcomeService:
@@ -310,11 +333,12 @@ class DecisionSignalOutcomeService:
if statuses
else list(DEFAULT_STATS_STATUSES)
)
rows = self.repo.list_stats_rows(
stats_rows = self.repo.list_stats_rows(
engine_version=engine_version_norm,
horizons=horizons_norm,
statuses=statuses_norm,
)
rows = [stats_row.outcome for stats_row in stats_rows]
dimensions = (
"action",
"market",
@@ -335,6 +359,7 @@ class DecisionSignalOutcomeService:
"horizons": horizons_norm,
"statuses": statuses_norm,
"breakdowns": breakdowns,
"profile_calibration": self._profile_calibration(stats_rows),
}
def get_feedback(self, signal_id: int) -> Dict[str, Any]:
@@ -502,18 +527,35 @@ class DecisionSignalOutcomeService:
return self._parse_date(signal.created_at)
def _data_quality_level(self, signal: DecisionSignalRecord) -> str:
value = self._json_loads(signal.data_quality_summary_json)
raw_summary = signal.data_quality_summary_json
if raw_summary and raw_summary.strip():
try:
summary = json.loads(raw_summary)
except json.JSONDecodeError as exc:
logger.warning("Invalid decision signal sidecar source JSON: %s", exc)
return "unknown"
explicit_level = self._explicit_data_quality_level(summary)
if explicit_level is not None:
return self._short_label(explicit_level)
metadata = self._json_loads(signal.metadata_json)
if isinstance(metadata, dict):
return normalize_decision_signal_data_quality(metadata.get("data_quality_level"))
return "unknown"
@staticmethod
def _explicit_data_quality_level(value: Any) -> Optional[Any]:
if isinstance(value, dict):
for key in ("level", "quality_level"):
level = value.get(key)
if level not in (None, ""):
return self._short_label(level)
return level
nested = value.get("data_quality")
if isinstance(nested, dict) and nested.get("level") not in (None, ""):
return self._short_label(nested.get("level"))
return nested.get("level")
return None
if isinstance(value, str) and value.strip():
return self._short_label(value)
return "unknown"
return value
return None
def _holding_state(self, signal: DecisionSignalRecord) -> str:
metadata = self._json_loads(signal.metadata_json)
@@ -670,6 +712,122 @@ class DecisionSignalOutcomeService:
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def _profile_calibration(self, stats_rows: List[OutcomeStatsRow]) -> Dict[str, Any]:
samples: List[Dict[str, Any]] = []
for stats_row in stats_rows:
outcome = stats_row.outcome
samples.append({
"outcome": outcome,
"decision_profile": self._profile_dimension(stats_row.decision_profile),
"action": str(outcome.action or "unknown"),
"horizon": str(outcome.horizon or "unknown"),
"market_phase": str(outcome.market_phase or "unknown"),
"data_quality_level": str(outcome.data_quality_level or "unknown"),
"profile_source": self._profile_source(stats_row.metadata_json),
})
breakdowns = {
name: self._profile_calibration_breakdown(samples, dimensions)
for name, dimensions in PROFILE_CALIBRATION_BREAKDOWN_DIMENSIONS
}
return {
"minimum_completed_sample_size": MIN_PROFILE_CALIBRATION_SAMPLE_SIZE,
"breakdowns": breakdowns,
}
def _profile_calibration_breakdown(
self,
samples: List[Dict[str, Any]],
dimensions: Tuple[str, ...],
) -> List[Dict[str, Any]]:
grouped: Dict[Tuple[str, ...], List[DecisionSignalOutcomeRecord]] = defaultdict(list)
for sample in samples:
key = tuple(str(sample.get(dimension) or "unknown") for dimension in dimensions)
grouped[key].append(sample["outcome"])
buckets = [
{
"dimensions": dict(zip(dimensions, values)),
**self._profile_calibration_aggregate(rows),
}
for values, rows in grouped.items()
]
return sorted(
buckets,
key=lambda item: (
-int(item["total"]),
tuple(str(item["dimensions"][dimension]) for dimension in dimensions),
),
)
def _profile_calibration_aggregate(
self,
rows: List[DecisionSignalOutcomeRecord],
) -> Dict[str, Any]:
aggregate = self._aggregate(rows)
sample_sufficient = int(aggregate["completed"]) >= MIN_PROFILE_CALIBRATION_SAMPLE_SIZE
direction_denominator = int(aggregate["hit"]) + int(aggregate["miss"])
adverse_excursions = [
value
for row in rows
if (value := self._row_max_adverse_excursion_pct(row)) is not None
]
return {
"total": aggregate["total"],
"completed": aggregate["completed"],
"unable": aggregate["unable"],
"hit": aggregate["hit"],
"miss": aggregate["miss"],
"neutral": aggregate["neutral"],
"sample_sufficient": sample_sufficient,
"hit_rate_pct": aggregate["hit_rate_pct"] if sample_sufficient else None,
"avg_stock_return_pct": aggregate["avg_stock_return_pct"] if sample_sufficient else None,
"miss_rate_pct": (
round(int(aggregate["miss"]) / direction_denominator * 100, 2)
if sample_sufficient and direction_denominator
else None
),
"unable_rate_pct": (
round(int(aggregate["unable"]) / int(aggregate["total"]) * 100, 2)
if sample_sufficient and int(aggregate["total"])
else None
),
"max_adverse_excursion_pct": (
round(max(adverse_excursions), 4)
if sample_sufficient and adverse_excursions
else None
),
}
@classmethod
def _row_max_adverse_excursion_pct(
cls,
row: DecisionSignalOutcomeRecord,
) -> Optional[float]:
if not cls._is_positive_finite(row.start_price):
return None
start_price = float(row.start_price)
if row.action in {"buy", "add", "hold", "watch", "alert"}:
if not cls._is_positive_finite(row.min_low):
return None
return max(0.0, (start_price - float(row.min_low)) / start_price * 100)
if row.action in {"sell", "reduce", "avoid"}:
if not cls._is_positive_finite(row.max_high):
return None
return max(0.0, (float(row.max_high) - start_price) / start_price * 100)
return None
@staticmethod
def _profile_dimension(value: Any) -> str:
profile = str(value or "").strip().lower()
return profile if profile in VALID_DECISION_PROFILES else "unknown"
def _profile_source(self, metadata_json: Optional[str]) -> str:
metadata = self._json_loads(metadata_json)
if not isinstance(metadata, dict):
return "unknown"
profile_source = str(metadata.get("profile_source") or "").strip().lower()
return profile_source if profile_source in PROFILE_SOURCES else "unknown"
def _breakdown(self, rows: List[DecisionSignalOutcomeRecord], dimension: str) -> List[Dict[str, Any]]:
grouped: Dict[str, List[DecisionSignalOutcomeRecord]] = defaultdict(list)
for row in rows:

View File

@@ -37,6 +37,9 @@ DECISION_SIGNAL_SCHEMAS = (
"DecisionSignalOutcomeRunResponse",
"DecisionSignalOutcomeStatsBucket",
"DecisionSignalOutcomeStatsResponse",
"DecisionSignalProfileCalibration",
"DecisionSignalProfileCalibrationBreakdowns",
"DecisionSignalProfileCalibrationBucket",
"DecisionSignalPreview",
"DecisionSignalReassessErrorResponse",
"DecisionSignalReassessRequest",

View File

@@ -31,6 +31,7 @@ def test_decision_signal_topic_references_live_api_schema_and_docs() -> None:
"/api/v1/decision-signals/reassess",
"/api/v1/decision-signals/latest/{stock_code}",
"/api/v1/decision-signals/outcomes/run",
"/api/v1/decision-signals/outcomes/stats",
"/api/v1/decision-signals/{signal_id}/feedback",
):
assert path in topic
@@ -42,6 +43,9 @@ def test_decision_signal_topic_references_live_api_schema_and_docs() -> None:
"DecisionSignalReassessRequest",
"DecisionSignalReassessResponse",
"DecisionSignalOutcomeItem",
"DecisionSignalProfileCalibration",
"DecisionSignalProfileCalibrationBreakdowns",
"DecisionSignalProfileCalibrationBucket",
"DecisionSignalFeedbackRequest",
"PortfolioDecisionSignalRiskBlock",
):
@@ -61,6 +65,10 @@ def test_decision_signal_topic_references_live_api_schema_and_docs() -> None:
assert "`existing` item 原样保留" in topic
assert "active relaxed dimension-fill 只补齐缺失的 horizon/market phase" in topic
assert "HTTP 422" in topic
assert "profile_calibration.minimum_completed_sample_size" in topic
assert "completed >= 30" in topic
assert "样本不足,仅供观察。" in topic
assert "max_adverse_excursion_pct" in topic
assert "decision-signals.md" in full_guide
assert "decision-signals.md" in full_guide_en
assert "decision-signals.md" in index
@@ -79,6 +87,9 @@ def test_decision_signal_topic_references_live_api_schema_and_docs() -> None:
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
assert "profile_calibration" in full_guide
assert "profile_calibration" in full_guide_en
assert "legacy servers without the new field" 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"]

View File

@@ -84,6 +84,7 @@ def _payload(**overrides):
"source_agent": "api-test",
"source_report_id": 4301,
"trace_id": "trace-outcome-api",
"decision_profile": "balanced",
"market_phase": "postmarket",
"trigger_source": "api",
"action": "buy",
@@ -97,6 +98,7 @@ def _payload(**overrides):
"metadata": {
"market_phase_summary": {"session_date": "2024-01-02"},
"holding_state": "holding",
"profile_source": "auto_default",
},
}
payload.update(overrides)
@@ -151,6 +153,42 @@ def test_outcome_run_list_stats_signal_outcomes_and_feedback(client_and_db) -> N
assert stats["total"] == 1
assert stats["hit"] == 1
assert stats["breakdowns"]["action"][0]["value"] == "buy"
calibration = stats["profile_calibration"]
assert calibration["minimum_completed_sample_size"] == 30
assert set(calibration["breakdowns"]) == {
"decision_profile",
"decision_profile_action",
"decision_profile_horizon",
"decision_profile_market_phase",
"decision_profile_data_quality_level",
"profile_source",
}
assert calibration["breakdowns"]["decision_profile"][0] == {
"dimensions": {"decision_profile": "balanced"},
"total": 1,
"completed": 1,
"unable": 0,
"hit": 1,
"miss": 0,
"neutral": 0,
"sample_sufficient": False,
"hit_rate_pct": None,
"avg_stock_return_pct": None,
"miss_rate_pct": None,
"unable_rate_pct": None,
"max_adverse_excursion_pct": None,
}
assert calibration["breakdowns"]["decision_profile_action"][0]["dimensions"] == {
"decision_profile": "balanced",
"action": "buy",
}
assert calibration["breakdowns"]["decision_profile_horizon"][0]["dimensions"] == {
"decision_profile": "balanced",
"horizon": "3d",
}
assert calibration["breakdowns"]["profile_source"][0]["dimensions"] == {
"profile_source": "auto_default",
}
signal_outcomes_resp = client.get(f"/api/v1/decision-signals/{signal_id}/outcomes")
assert signal_outcomes_resp.status_code == 200, signal_outcomes_resp.text
@@ -205,6 +243,12 @@ def test_outcome_api_rejects_invalid_params_and_returns_404(client_and_db) -> No
missing_feedback_resp = client.get("/api/v1/decision-signals/999999/feedback")
assert missing_feedback_resp.status_code == 404
empty_stats_resp = client.get("/api/v1/decision-signals/outcomes/stats")
assert empty_stats_resp.status_code == 200
empty_calibration = empty_stats_resp.json()["profile_calibration"]
assert empty_calibration["minimum_completed_sample_size"] == 30
assert all(not buckets for buckets in empty_calibration["breakdowns"].values())
def test_outcome_run_retries_transient_unable_by_default(client_and_db) -> None:
client, db = client_and_db

View File

@@ -42,7 +42,19 @@ def _add_signal(
horizon: str = "3d",
session_date: str = "2024-01-02",
status: str = "active",
decision_profile: str | None = None,
profile_source: str | None = None,
metadata_data_quality: str | None = None,
data_quality_summary_json: str | None = '{"level": "good"}',
) -> int:
metadata = {
"market_phase_summary": {"session_date": session_date},
"holding_state": "holding",
}
if profile_source is not None:
metadata["profile_source"] = profile_source
if metadata_data_quality is not None:
metadata["data_quality_level"] = metadata_data_quality
with db.session_scope() as session:
row = DecisionSignalRecord(
stock_code=code,
@@ -51,17 +63,15 @@ def _add_signal(
source_type="analysis",
source_report_id=1001,
trace_id=f"trace-{market}-{code}-{action}-{horizon}-{session_date}",
decision_profile=decision_profile,
market_phase="postmarket",
trigger_source="api",
action=action,
action_label=action,
horizon=horizon,
reason="unit test",
data_quality_summary_json=json.dumps({"level": "good"}),
metadata_json=json.dumps({
"market_phase_summary": {"session_date": session_date},
"holding_state": "holding",
}),
data_quality_summary_json=data_quality_summary_json,
metadata_json=json.dumps(metadata),
plan_quality="complete",
status=status,
)
@@ -70,6 +80,69 @@ def _add_signal(
return int(row.id)
def _seed_calibration_outcomes(
db: DatabaseManager,
*,
count: int,
decision_profile: str | None,
action: str,
horizon: str,
market_phase: str,
data_quality_level: str,
profile_source: str | None,
outcomes: tuple[str, ...] = ("hit",),
) -> None:
with db.session_scope() as session:
for index in range(count):
outcome_value = outcomes[index % len(outcomes)]
signal = DecisionSignalRecord(
stock_code=f"T{index:05d}",
stock_name="Calibration fixture",
market="cn",
source_type="analysis",
source_report_id=10_000 + index,
trace_id=f"calibration-{decision_profile}-{action}-{horizon}-{profile_source}-{index}",
decision_profile=decision_profile,
market_phase=market_phase,
trigger_source="api",
action=action,
action_label=action,
horizon=horizon,
reason="deterministic calibration boundary fixture",
data_quality_summary_json=json.dumps({"level": data_quality_level}),
metadata_json=json.dumps({"profile_source": profile_source}) if profile_source is not None else None,
plan_quality="complete",
status="active",
)
session.add(signal)
session.flush()
stock_return_pct = {"hit": 2.0, "miss": -2.0, "neutral": 0.0}[outcome_value]
session.add(DecisionSignalOutcomeRecord(
signal_id=signal.id,
horizon=horizon,
engine_version="decision-signal-v1",
eval_status="completed",
outcome=outcome_value,
direction_expected="not_up" if action in {"sell", "reduce", "avoid"} else "up",
direction_correct=outcome_value == "hit" if outcome_value != "neutral" else None,
anchor_date=date(2024, 1, 2),
eval_window_days=3,
start_price=100.0,
end_close=100.0 + stock_return_pct,
max_high=108.0,
min_low=94.0,
stock_return_pct=stock_return_pct,
action=action,
market="cn",
market_phase=market_phase,
source_type="analysis",
source_agent="fixture",
plan_quality="complete",
data_quality_level=data_quality_level,
holding_state="holding",
))
def _seed_bars(
db: DatabaseManager,
*,
@@ -134,6 +207,237 @@ def test_run_outcomes_evaluates_supported_horizons_and_stats(isolated_db) -> Non
assert stats["breakdowns"]["holding_state"][0]["value"] == "holding"
def test_profile_calibration_groups_six_dimensions_and_gates_each_bucket(isolated_db) -> None:
_seed_calibration_outcomes(
isolated_db,
count=30,
decision_profile="balanced",
action="buy",
horizon="3d",
market_phase="postmarket",
data_quality_level="good",
profile_source="auto_default",
outcomes=("hit", "miss", "neutral"),
)
_seed_calibration_outcomes(
isolated_db,
count=29,
decision_profile="balanced",
action="sell",
horizon="10d",
market_phase="postmarket",
data_quality_level="good",
profile_source="user_selected",
outcomes=("hit", "miss"),
)
_seed_calibration_outcomes(
isolated_db,
count=1,
decision_profile=None,
action="hold",
horizon="5d",
market_phase="intraday",
data_quality_level="medium",
profile_source="legacy_unknown",
)
stats = DecisionSignalOutcomeService(db_manager=isolated_db).get_stats()
calibration = stats["profile_calibration"]
breakdowns = calibration["breakdowns"]
assert calibration["minimum_completed_sample_size"] == 30
assert stats["total"] == 60
assert stats["completed"] == 60
assert stats["breakdowns"]["action"][0]["value"] == "buy"
assert set(breakdowns) == {
"decision_profile",
"decision_profile_action",
"decision_profile_horizon",
"decision_profile_market_phase",
"decision_profile_data_quality_level",
"profile_source",
}
expected_dimension_keys = {
"decision_profile": {"decision_profile"},
"decision_profile_action": {"decision_profile", "action"},
"decision_profile_horizon": {"decision_profile", "horizon"},
"decision_profile_market_phase": {"decision_profile", "market_phase"},
"decision_profile_data_quality_level": {"decision_profile", "data_quality_level"},
"profile_source": {"profile_source"},
}
for name, buckets in breakdowns.items():
assert buckets
assert all(set(bucket["dimensions"]) == expected_dimension_keys[name] for bucket in buckets)
profile_buckets = {
bucket["dimensions"]["decision_profile"]: bucket
for bucket in breakdowns["decision_profile"]
}
assert profile_buckets["balanced"]["completed"] == 59
assert profile_buckets["balanced"]["sample_sufficient"] is True
assert profile_buckets["unknown"]["completed"] == 1
assert profile_buckets["unknown"]["sample_sufficient"] is False
assert profile_buckets["unknown"]["hit_rate_pct"] is None
action_buckets = {
(bucket["dimensions"]["decision_profile"], bucket["dimensions"]["action"]): bucket
for bucket in breakdowns["decision_profile_action"]
}
buy = action_buckets[("balanced", "buy")]
sell = action_buckets[("balanced", "sell")]
assert buy["completed"] == 30
assert buy["hit"] == 10
assert buy["miss"] == 10
assert buy["neutral"] == 10
assert buy["sample_sufficient"] is True
assert buy["hit_rate_pct"] == 50.0
assert buy["miss_rate_pct"] == 50.0
assert buy["unable_rate_pct"] == 0.0
assert buy["avg_stock_return_pct"] == 0.0
assert buy["max_adverse_excursion_pct"] == 6.0
assert sell["completed"] == 29
assert sell["sample_sufficient"] is False
for metric in (
"hit_rate_pct",
"avg_stock_return_pct",
"miss_rate_pct",
"unable_rate_pct",
"max_adverse_excursion_pct",
):
assert sell[metric] is None
horizon_buckets = {
(bucket["dimensions"]["decision_profile"], bucket["dimensions"]["horizon"]): bucket
for bucket in breakdowns["decision_profile_horizon"]
}
assert horizon_buckets[("balanced", "3d")]["sample_sufficient"] is True
assert horizon_buckets[("balanced", "10d")]["sample_sufficient"] is False
source_buckets = {
bucket["dimensions"]["profile_source"]: bucket
for bucket in breakdowns["profile_source"]
}
assert source_buckets["auto_default"]["completed"] == 30
assert source_buckets["auto_default"]["sample_sufficient"] is True
assert source_buckets["user_selected"]["completed"] == 29
assert source_buckets["user_selected"]["sample_sufficient"] is False
filtered = DecisionSignalOutcomeService(db_manager=isolated_db).get_stats(horizons=["3d"])
filtered_horizons = filtered["profile_calibration"]["breakdowns"]["decision_profile_horizon"]
assert filtered["total"] == 30
assert [bucket["dimensions"] for bucket in filtered_horizons] == [
{"decision_profile": "balanced", "horizon": "3d"},
]
@pytest.mark.parametrize(
("metadata_json", "expected"),
[
('{"profile_source": "auto_default"}', "auto_default"),
('{"profile_source": "backfill_defaulted"}', "backfill_defaulted"),
('{"profile_source": "legacy_unknown"}', "legacy_unknown"),
('{"profile_source": "user_selected"}', "user_selected"),
('{"profile_source": "invalid"}', "unknown"),
('{"profile_source": 1}', "unknown"),
('["user_selected"]', "unknown"),
('{"profile_source":', "unknown"),
(None, "unknown"),
],
)
def test_profile_source_normalization(isolated_db, metadata_json, expected) -> None:
service = DecisionSignalOutcomeService(db_manager=isolated_db)
assert service._profile_source(metadata_json) == expected
@pytest.mark.parametrize(
("summary_json", "metadata_quality", "expected"),
[
('{"level": "good"}', "poor", "good"),
('{"level": "unknown"}', "high", "unknown"),
('{"data_quality": {"level": "usable"}}', "poor", "usable"),
('{}', "usable", "medium"),
(None, "good", "high"),
('{"level":', "good", "unknown"),
(None, "invalid", "unknown"),
],
)
def test_data_quality_snapshot_preserves_summary_and_narrowly_falls_back_to_metadata(
isolated_db,
summary_json,
metadata_quality,
expected,
) -> None:
signal = DecisionSignalRecord(
data_quality_summary_json=summary_json,
metadata_json=json.dumps({"data_quality_level": metadata_quality}),
)
service = DecisionSignalOutcomeService(db_manager=isolated_db)
assert service._data_quality_level(signal) == expected
def test_real_outcome_uses_metadata_quality_and_profile_source_without_summary(isolated_db) -> None:
signal_id = _add_signal(
isolated_db,
action="hold",
horizon="3d",
decision_profile="aggressive",
profile_source="user_selected",
metadata_data_quality="good",
data_quality_summary_json=None,
)
_seed_bars(isolated_db, closes=[99.0, 98.0, 101.0])
service = DecisionSignalOutcomeService(db_manager=isolated_db)
result = service.run_outcomes(signal_id=signal_id)
stats = service.get_stats()
assert result["items"][0]["eval_status"] == "completed"
assert result["items"][0]["data_quality_level"] == "high"
profile_bucket = stats["profile_calibration"]["breakdowns"]["decision_profile"][0]
quality_bucket = stats["profile_calibration"]["breakdowns"]["decision_profile_data_quality_level"][0]
source_bucket = stats["profile_calibration"]["breakdowns"]["profile_source"][0]
assert profile_bucket["dimensions"] == {"decision_profile": "aggressive"}
assert quality_bucket["dimensions"] == {
"decision_profile": "aggressive",
"data_quality_level": "high",
}
assert source_bucket["dimensions"] == {"profile_source": "user_selected"}
with isolated_db.session_scope() as session:
outcome = session.query(DecisionSignalOutcomeRecord).filter_by(signal_id=signal_id).one()
assert service._row_max_adverse_excursion_pct(outcome) == 3.0
@pytest.mark.parametrize("action", ["buy", "add", "hold", "watch", "alert"])
def test_long_side_max_adverse_excursion_formula(action) -> None:
row = DecisionSignalOutcomeRecord(action=action, start_price=100.0, min_low=91.5, max_high=110.0)
assert DecisionSignalOutcomeService._row_max_adverse_excursion_pct(row) == 8.5
@pytest.mark.parametrize("action", ["sell", "reduce", "avoid"])
def test_defensive_max_adverse_excursion_formula(action) -> None:
row = DecisionSignalOutcomeRecord(action=action, start_price=100.0, min_low=91.5, max_high=112.0)
assert DecisionSignalOutcomeService._row_max_adverse_excursion_pct(row) == 12.0
@pytest.mark.parametrize(
"row",
[
DecisionSignalOutcomeRecord(action="buy", start_price=None, min_low=90.0),
DecisionSignalOutcomeRecord(action="buy", start_price=0.0, min_low=90.0),
DecisionSignalOutcomeRecord(action="buy", start_price=100.0, min_low=float("nan")),
DecisionSignalOutcomeRecord(action="sell", start_price=100.0, max_high=float("inf")),
DecisionSignalOutcomeRecord(action="unknown", start_price=100.0, min_low=90.0, max_high=110.0),
],
)
def test_max_adverse_excursion_returns_none_for_incomplete_or_invalid_rows(row) -> None:
assert DecisionSignalOutcomeService._row_max_adverse_excursion_pct(row) is None
def test_stats_default_statuses_exclude_archived(isolated_db) -> None:
service = DecisionSignalOutcomeService(db_manager=isolated_db)
signal_ids = [
@@ -212,6 +516,47 @@ def test_unable_reasons_are_persisted_for_non_directional_and_unsupported_horizo
assert intraday_skipped["skipped"] == 1
def test_watch_and_alert_outcomes_remain_unable_without_market_reads(isolated_db) -> None:
class FailOnMarketRead:
def get_daily_on_date(self, **_kwargs):
raise AssertionError("watch/alert outcome must not read anchor prices")
def get_forward_bars(self, **_kwargs):
raise AssertionError("watch/alert outcome must not read forward bars")
watch_id = _add_signal(
isolated_db,
code="000101",
action="watch",
decision_profile="balanced",
profile_source="auto_default",
)
alert_id = _add_signal(
isolated_db,
code="000102",
action="alert",
decision_profile="balanced",
profile_source="auto_default",
)
service = DecisionSignalOutcomeService(
db_manager=isolated_db,
stock_repo=FailOnMarketRead(),
)
watch = service.run_outcomes(signal_id=watch_id)["items"][0]
alert = service.run_outcomes(signal_id=alert_id)["items"][0]
stats = service.get_stats()
assert watch["eval_status"] == "unable"
assert alert["eval_status"] == "unable"
assert watch["start_price"] is None
assert alert["start_price"] is None
profile_bucket = stats["profile_calibration"]["breakdowns"]["decision_profile"][0]
assert profile_bucket["completed"] == 0
assert profile_bucket["total"] == 2
assert profile_bucket["max_adverse_excursion_pct"] is None
def test_missing_anchor_price_is_retried_after_data_arrives(isolated_db) -> None:
signal_id = _add_signal(isolated_db, action="buy", horizon="3d", session_date="2024-01-03")
with isolated_db.session_scope() as session: