feat: add decision signal reassess preview (#1877)

This commit is contained in:
Alfred
2026-07-02 18:56:08 +08:00
committed by GitHub
parent 0f57e4e2e5
commit 2e4c03e52d
16 changed files with 1725 additions and 29 deletions
+65
View File
@@ -21,6 +21,8 @@ from api.v1.schemas.decision_signals import (
DecisionSignalOutcomeRunRequest,
DecisionSignalOutcomeRunResponse,
DecisionSignalOutcomeStatsResponse,
DecisionSignalReassessRequest,
DecisionSignalReassessResponse,
DecisionSignalStatusUpdateRequest,
)
from src.auth import COOKIE_NAME
@@ -30,6 +32,13 @@ from src.services.decision_signal_service import (
DecisionSignalStorageError,
)
from src.services.decision_signal_outcome_service import DecisionSignalOutcomeService
from src.services.decision_signal_reassess_service import (
DecisionSignalReassessService,
DecisionSignalReassessUnsupportedOperationError,
DecisionSignalSourceReportNotFoundError,
DecisionSignalUnsupportedReportSnapshotError,
DecisionSignalUnsupportedReportTypeError,
)
logger = logging.getLogger(__name__)
@@ -63,6 +72,13 @@ def _not_found(exc: Exception) -> HTTPException:
)
def _error(status_code: int, exc: Exception, *, error: str) -> HTTPException:
return HTTPException(
status_code=status_code,
detail={"error": error, "message": str(exc)},
)
def _internal_error(message: str, exc: Exception) -> HTTPException:
logger.error("%s: %s", message, exc, exc_info=True)
return HTTPException(
@@ -291,6 +307,55 @@ def get_outcome_stats(
raise _internal_error("Get decision signal outcome stats failed", exc)
@router.post(
"/reassess",
response_model=DecisionSignalReassessResponse,
responses={
**AUTH_RESPONSE,
400: {"model": ErrorResponse, "description": "重评估请求不支持或历史报告不适用"},
404: {"model": ErrorResponse, "description": "来源历史报告不存在"},
422: {"model": ErrorResponse, "description": "请求体校验失败"},
500: {"model": ErrorResponse, "description": "重评估失败"},
},
summary="预览决策风格重评估",
description=(
"基于 source_report_id 对应的持久化历史报告快照生成 decision_profile preview;"
"P3a 仅支持 persist=false,不写入 DecisionSignal。"
),
operation_id="reassessDecisionSignalPreview",
)
def reassess_signal(request: DecisionSignalReassessRequest) -> DecisionSignalReassessResponse:
if request.persist:
raise _error(
400,
DecisionSignalReassessUnsupportedOperationError(
"Persisting reassessed decision_profile signals requires decision_profile "
"to be promoted to a first-class field."
),
error="unsupported_operation",
)
service = DecisionSignalReassessService()
try:
return DecisionSignalReassessResponse(
**service.reassess(
source_report_id=request.source_report_id,
decision_profile=request.decision_profile,
persist=request.persist,
)
)
except DecisionSignalSourceReportNotFoundError as exc:
raise _error(404, exc, error="source_report_not_found")
except DecisionSignalUnsupportedReportTypeError as exc:
raise _error(400, exc, error="unsupported_report_type")
except DecisionSignalUnsupportedReportSnapshotError as exc:
raise _error(400, exc, error="unsupported_report_snapshot")
except DecisionSignalReassessUnsupportedOperationError as exc:
raise _error(400, exc, error="unsupported_operation")
except Exception as exc:
raise _internal_error("Reassess decision signal preview failed", exc)
@router.get(
"/latest/{stock_code}",
response_model=DecisionSignalListResponse,
+49 -1
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from api.v1.schemas.market_phase import MarketPhaseValue
from src.schemas.decision_action import DecisionAction
@@ -21,6 +21,7 @@ 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):
@@ -56,6 +57,45 @@ class DecisionSignalCreateRequest(BaseModel):
report_language: Optional[Literal["zh", "en", "ko"]] = None
class DecisionSignalReassessRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
source_report_id: int = Field(..., gt=0)
decision_profile: DecisionProfile
persist: bool = False
class DecisionSignalWarning(BaseModel):
code: str
message: Optional[str] = None
params: Optional[Dict[str, Any]] = None
class DecisionSignalGuardrailResult(BaseModel):
raw_action: str
final_action: str
passed: bool
violations: List[str] = Field(default_factory=list)
adjustments: List[str] = Field(default_factory=list)
adjusted: bool
class DecisionSignalPreview(BaseModel):
action: str
score: Optional[int] = None
confidence: Optional[float] = None
horizon: Optional[str] = None
entry_low: Optional[float] = None
entry_high: Optional[float] = None
stop_loss: Optional[float] = None
target_price: Optional[float] = None
invalidation: Optional[str] = None
reason: Optional[str] = None
risk_summary: Optional[str] = None
watch_conditions: Optional[str] = None
metadata: Dict[str, Any]
class DecisionSignalStatusUpdateRequest(BaseModel):
status: DecisionSignalStatus
metadata: Optional[Dict[str, Any]] = None
@@ -205,6 +245,14 @@ class DecisionSignalMutationResponse(BaseModel):
created: bool
class DecisionSignalReassessResponse(BaseModel):
preview: DecisionSignalPreview
item: Optional[DecisionSignalItem] = None
created: bool = False
warnings: List[DecisionSignalWarning] = Field(default_factory=list)
blocked_reason: Optional[str] = None
class DecisionSignalListResponse(BaseModel):
items: List[DecisionSignalItem] = Field(default_factory=list)
total: int
@@ -204,6 +204,69 @@ describe('decisionSignalsApi', () => {
expect(response.items[0].stockCode).toBe('HK00700');
});
it('reassesses preview with fixed persist false and opaque preview metadata', async () => {
post.mockResolvedValueOnce({
data: {
preview: {
action: 'watch',
score: 72,
confidence: null,
horizon: '3d',
entry_low: 1680,
stop_loss: 1600,
metadata: {
decision_profile: 'aggressive',
data_quality_level: 'medium',
scoring_breakdown: { raw_action: 'buy' },
guardrail_result: {
raw_action: 'buy',
final_action: 'watch',
passed: false,
violations: ['missing_confidence'],
adjustments: ['action_downgraded_by_guardrail'],
adjusted: true,
},
},
},
item: null,
created: false,
warnings: [
{
code: 'action_blocked_by_guardrail',
params: { raw_action: 'buy', final_action: 'watch' },
},
],
blocked_reason: 'actionable_signal_blocked_by_guardrail',
},
});
const response = await decisionSignalsApi.reassess({
sourceReportId: 3001,
decisionProfile: 'aggressive',
});
expect(post).toHaveBeenCalledWith('/api/v1/decision-signals/reassess', {
source_report_id: 3001,
decision_profile: 'aggressive',
persist: false,
});
expect(response.preview.entryLow).toBe(1680);
expect(response.preview.metadata).toEqual({
decision_profile: 'aggressive',
data_quality_level: 'medium',
scoring_breakdown: { raw_action: 'buy' },
guardrail_result: {
raw_action: 'buy',
final_action: 'watch',
passed: false,
violations: ['missing_confidence'],
adjustments: ['action_downgraded_by_guardrail'],
adjusted: true,
},
});
expect(response.blockedReason).toBe('actionable_signal_blocked_by_guardrail');
});
it('rejects malformed list responses instead of treating missing items as empty', async () => {
get.mockResolvedValueOnce({
data: {
+31
View File
@@ -17,6 +17,8 @@ import type {
DecisionSignalOutcomeStatsBucket,
DecisionSignalOutcomeStatsParams,
DecisionSignalOutcomeStatsResponse,
DecisionSignalReassessRequest,
DecisionSignalReassessResponse,
DecisionSignalStatusUpdateRequest,
} from '../types/decisionSignals';
@@ -52,6 +54,19 @@ function toDecisionSignalMutationResponse(data: Record<string, unknown>): Decisi
return response;
}
function toDecisionSignalReassessResponse(data: Record<string, unknown>): DecisionSignalReassessResponse {
const response = toCamelCase<DecisionSignalReassessResponse>(data);
const rawPreview = data.preview as Record<string, unknown> | undefined;
if (!rawPreview || typeof rawPreview !== 'object') {
throw new Error('DecisionSignal reassess response preview must be an object');
}
response.preview.metadata = (rawPreview.metadata as Record<string, unknown> | undefined) ?? {};
if (data.item) {
response.item = toDecisionSignalItem(data.item as Record<string, unknown>);
}
return response;
}
function toDecisionSignalListResponse(data: Record<string, unknown>): DecisionSignalListResponse {
const response = toCamelCase<DecisionSignalListResponse>(data);
if (!Array.isArray(data.items)) {
@@ -157,6 +172,14 @@ function toSnakeOutcomeRunPayload(payload: DecisionSignalOutcomeRunRequest): Rec
});
}
function toSnakeReassessPayload(payload: DecisionSignalReassessRequest): Record<string, unknown> {
return {
source_report_id: payload.sourceReportId,
decision_profile: payload.decisionProfile,
persist: false,
};
}
function toListParams(params: DecisionSignalListParams = {}): Record<string, string | number | boolean> {
return omitUndefined({
market: params.market,
@@ -263,6 +286,14 @@ export const decisionSignalsApi = {
return toDecisionSignalListResponse(response.data);
},
async reassess(payload: DecisionSignalReassessRequest): Promise<DecisionSignalReassessResponse> {
const response = await apiClient.post<Record<string, unknown>>(
'/api/v1/decision-signals/reassess',
toSnakeReassessPayload(payload),
);
return toDecisionSignalReassessResponse(response.data);
},
async updateStatus(
signalId: number,
payload: DecisionSignalStatusUpdateRequest,
+26
View File
@@ -274,7 +274,20 @@ const zh = {
'decisionSignals.planQuality.unknown': '未知',
'decisionSignals.pricePlan': '价格计划',
'decisionSignals.profile': '风格',
'decisionSignals.profile.aggressive': '进取',
'decisionSignals.profile.balanced': '均衡',
'decisionSignals.profile.conservative': '保守',
'decisionSignals.reason': '理由',
'decisionSignals.reassessBlockedNote': '该预览已被风控约束为非进攻展示动作。',
'decisionSignals.reassessBlockedTitle': '预览被风控阻断',
'decisionSignals.reassessPreview': '生成预览',
'decisionSignals.reassessProfile': '重评估风格',
'decisionSignals.reassessRawFinal': '原始/最终',
'decisionSignals.reassessSource': '来源报告 #{id}',
'decisionSignals.reassessTitle': '决策风格重评估预览',
'decisionSignals.reassessUnsupported': '该信号不支持重评估',
'decisionSignals.reassessUnsupportedTitle': '缺少来源报告',
'decisionSignals.reassessWarnings': '风控提示',
'decisionSignals.refresh': '刷新',
'decisionSignals.returnPct': '区间收益',
'decisionSignals.riskSummary': '风险',
@@ -1021,7 +1034,20 @@ const en: Record<UiTextKey, string> = {
'decisionSignals.planQuality.unknown': 'Unknown',
'decisionSignals.pricePlan': 'Price plan',
'decisionSignals.profile': 'Profile',
'decisionSignals.profile.aggressive': 'Aggressive',
'decisionSignals.profile.balanced': 'Balanced',
'decisionSignals.profile.conservative': 'Conservative',
'decisionSignals.reason': 'Reason',
'decisionSignals.reassessBlockedNote': 'This preview is constrained to a non-aggressive display action.',
'decisionSignals.reassessBlockedTitle': 'Preview blocked by guardrail',
'decisionSignals.reassessPreview': 'Generate preview',
'decisionSignals.reassessProfile': 'Reassess profile',
'decisionSignals.reassessRawFinal': 'Raw / final',
'decisionSignals.reassessSource': 'Source report #{id}',
'decisionSignals.reassessTitle': 'Decision profile reassess preview',
'decisionSignals.reassessUnsupported': 'This signal does not support reassess',
'decisionSignals.reassessUnsupportedTitle': 'Missing source report',
'decisionSignals.reassessWarnings': 'Guardrail warnings',
'decisionSignals.refresh': 'Refresh',
'decisionSignals.returnPct': 'Window return',
'decisionSignals.riskSummary': 'Risk',
+219 -27
View File
@@ -1,6 +1,6 @@
import type React from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Activity, BarChart3, RefreshCw, Search } from 'lucide-react';
import { Activity, BarChart3, RefreshCw, Search, ShieldCheck } from 'lucide-react';
import { decisionSignalsApi } from '../api/decisionSignals';
import { getParsedApiError, type ParsedApiError } from '../api/error';
import {
@@ -30,8 +30,10 @@ import type {
DecisionSignalMarket,
DecisionSignalOutcomeItem,
DecisionSignalOutcomeStatsResponse,
DecisionSignalReassessResponse,
DecisionSignalSourceType,
DecisionSignalStatus,
DecisionProfile,
} from '../types/decisionSignals';
import { cn } from '../utils/cn';
import { buildDecisionActionLabelMap } from '../utils/decisionAction';
@@ -76,6 +78,10 @@ type SelectedSignal = {
source: 'list' | 'latest' | 'timeline';
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
const MARKET_OPTIONS: DecisionSignalMarket[] = ['cn', 'hk', 'us', 'jp', 'kr', 'tw'];
const ACTION_OPTIONS: DecisionAction[] = ['buy', 'add', 'hold', 'reduce', 'sell', 'watch', 'avoid', 'alert'];
const PHASE_OPTIONS: MarketPhaseValue[] = ['premarket', 'intraday', 'lunch_break', 'closing_auction', 'postmarket', 'non_trading', 'unknown'];
@@ -83,6 +89,7 @@ const SOURCE_OPTIONS: DecisionSignalSourceType[] = ['analysis', 'agent', 'alert'
const STATUS_OPTIONS: DecisionSignalStatus[] = ['active', 'expired', 'invalidated', 'closed', 'archived'];
const STATUS_ACTIONS: Array<PendingStatusChange['status']> = ['closed', 'invalidated', 'archived'];
const REASSESS_PROFILES: DecisionProfile[] = ['conservative', 'balanced', 'aggressive'];
const STATUS_LABEL_KEYS: Record<DecisionSignalStatus, UiTextKey> = {
active: 'decisionSignals.active',
@@ -245,11 +252,16 @@ const DecisionSignalsPage: React.FC = () => {
const [selectedFeedbackLoading, setSelectedFeedbackLoading] = useState(false);
const [selectedFeedbackError, setSelectedFeedbackError] = useState<ParsedApiError | null>(null);
const [feedbackSaving, setFeedbackSaving] = useState(false);
const [reassessProfile, setReassessProfile] = useState<DecisionProfile>('balanced');
const [reassessResponse, setReassessResponse] = useState<DecisionSignalReassessResponse | null>(null);
const [reassessLoading, setReassessLoading] = useState(false);
const [reassessError, setReassessError] = useState<ParsedApiError | null>(null);
const requestIdRef = useRef(0);
const statsRequestIdRef = useRef(0);
const latestRequestIdRef = useRef(0);
const timelineRequestIdRef = useRef(0);
const detailRequestIdRef = useRef(0);
const reassessRequestIdRef = useRef(0);
const selectedSignalIdRef = useRef<number | null>(null);
const statusUpdateInFlightRef = useRef(false);
@@ -390,6 +402,47 @@ const DecisionSignalsPage: React.FC = () => {
});
}, [selected]);
const appliedSourceReportId = parseSourceReportId(appliedFilters.sourceReportId);
const selectedSourceReportId = selected?.item.sourceReportId ?? undefined;
const reassessSourceReportId = selected ? selectedSourceReportId : appliedSourceReportId;
const reassessContextKey = [
selected ? `selected:${selected.item.id}` : 'source',
reassessSourceReportId ?? '',
reassessProfile,
].join(':');
useEffect(() => {
reassessRequestIdRef.current += 1;
setReassessResponse(null);
setReassessError(null);
setReassessLoading(false);
}, [reassessContextKey]);
const handleReassess = useCallback(async () => {
if (!reassessSourceReportId) return;
const requestId = reassessRequestIdRef.current + 1;
reassessRequestIdRef.current = requestId;
setReassessLoading(true);
setReassessError(null);
try {
const response = await decisionSignalsApi.reassess({
sourceReportId: reassessSourceReportId,
decisionProfile: reassessProfile,
persist: false,
});
if (reassessRequestIdRef.current !== requestId) return;
setReassessResponse(response);
} catch (err) {
if (reassessRequestIdRef.current !== requestId) return;
setReassessResponse(null);
setReassessError(getParsedApiError(err));
} finally {
if (reassessRequestIdRef.current === requestId) {
setReassessLoading(false);
}
}
}, [reassessProfile, reassessSourceReportId]);
const handleApplyFilters = (event: React.FormEvent) => {
event.preventDefault();
setAppliedFilters(filters);
@@ -530,6 +583,136 @@ const DecisionSignalsPage: React.FC = () => {
}
}, [feedbackSaving, selected]);
const renderReassessPanel = () => {
const preview = reassessResponse?.preview ?? null;
const metadata = preview?.metadata ?? {};
const guardrail = isRecord(metadata.guardrail_result) ? metadata.guardrail_result : null;
const rawAction = typeof guardrail?.raw_action === 'string' ? guardrail.raw_action : null;
const finalAction = typeof guardrail?.final_action === 'string' ? guardrail.final_action : null;
const passed = typeof guardrail?.passed === 'boolean' ? guardrail.passed : null;
return (
<div className="rounded-xl border border-border/60 bg-elevated/30 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">{t('decisionSignals.reassessTitle')}</h3>
</div>
<p className="mt-1 text-xs text-secondary-text">
{reassessSourceReportId
? t('decisionSignals.reassessSource', { id: reassessSourceReportId })
: t('decisionSignals.reassessUnsupported')}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<select
className="input-surface input-focus-glow h-10 rounded-xl border bg-transparent px-3 text-sm"
value={reassessProfile}
onChange={(event) => setReassessProfile(event.target.value as DecisionProfile)}
aria-label={t('decisionSignals.reassessProfile')}
disabled={!reassessSourceReportId || reassessLoading}
>
{REASSESS_PROFILES.map((profile) => (
<option key={profile} value={profile}>
{t(`decisionSignals.profile.${profile}` as UiTextKey)}
</option>
))}
</select>
<button
type="button"
className="btn-secondary inline-flex h-10 items-center justify-center gap-2"
onClick={() => void handleReassess()}
disabled={!reassessSourceReportId || reassessLoading}
>
<RefreshCw className={cn('h-4 w-4', reassessLoading ? 'animate-spin' : '')} />
{t('decisionSignals.reassessPreview')}
</button>
</div>
</div>
{!reassessSourceReportId ? (
<InlineAlert
className="mt-3"
variant="warning"
title={t('decisionSignals.reassessUnsupportedTitle')}
message={t('decisionSignals.reassessUnsupported')}
/>
) : null}
{reassessError ? <ApiErrorAlert className="mt-3" error={reassessError} /> : null}
{preview ? (
<div className="mt-4 space-y-3">
{reassessResponse?.blockedReason ? (
<InlineAlert
variant="warning"
title={t('decisionSignals.reassessBlockedTitle')}
message={reassessResponse.blockedReason}
/>
) : null}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border border-border/50 bg-background/40 p-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.action')}</p>
<p className="mt-1 text-sm font-semibold text-foreground">{actionLabels[preview.action]}</p>
</div>
<div className="rounded-lg border border-border/50 bg-background/40 p-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.score')}</p>
<p className="mt-1 text-sm font-semibold text-foreground">{preview.score ?? '-'}</p>
</div>
<div className="rounded-lg border border-border/50 bg-background/40 p-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.confidence')}</p>
<p className="mt-1 text-sm font-semibold text-foreground">{preview.confidence ?? '-'}</p>
</div>
<div className="rounded-lg border border-border/50 bg-background/40 p-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.horizon')}</p>
<p className="mt-1 text-sm font-semibold text-foreground">{preview.horizon ?? '-'}</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border border-border/50 bg-background/40 p-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.entryRange')}</p>
<p className="mt-1 text-sm text-foreground">
{preview.entryLow || preview.entryHigh
? `${preview.entryLow ?? '-'} ~ ${preview.entryHigh ?? '-'}`
: '-'}
</p>
</div>
<div className="rounded-lg border border-border/50 bg-background/40 p-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.stopLoss')}</p>
<p className="mt-1 text-sm text-foreground">{preview.stopLoss ?? '-'}</p>
</div>
<div className="rounded-lg border border-border/50 bg-background/40 p-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.targetPrice')}</p>
<p className="mt-1 text-sm text-foreground">{preview.targetPrice ?? '-'}</p>
</div>
<div className="rounded-lg border border-border/50 bg-background/40 p-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.reassessRawFinal')}</p>
<p className="mt-1 text-sm text-foreground">{rawAction ?? '-'} {'->'} {finalAction ?? '-'}</p>
</div>
</div>
<div className="space-y-2 text-sm text-secondary-text">
{passed === false ? (
<p className="font-medium text-warning">{t('decisionSignals.reassessBlockedNote')}</p>
) : null}
{preview.invalidation ? <p><span className="text-foreground">{t('decisionSignals.invalidation')}:</span> {preview.invalidation}</p> : null}
{preview.reason ? <p><span className="text-foreground">{t('decisionSignals.reason')}:</span> {preview.reason}</p> : null}
{preview.riskSummary ? <p><span className="text-foreground">{t('decisionSignals.riskSummary')}:</span> {preview.riskSummary}</p> : null}
{preview.watchConditions ? <p><span className="text-foreground">{t('decisionSignals.watchConditions')}:</span> {preview.watchConditions}</p> : null}
</div>
{reassessResponse?.warnings.length ? (
<div className="rounded-lg border border-warning/30 bg-warning/10 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-warning">{t('decisionSignals.reassessWarnings')}</p>
<ul className="mt-2 list-disc space-y-1 pl-4 text-sm text-secondary-text">
{reassessResponse.warnings.map((warning, index) => (
<li key={`${warning.code}-${index}`}>{warning.message || warning.code}</li>
))}
</ul>
</div>
) : null}
</div>
) : null}
</div>
);
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
@@ -635,6 +818,12 @@ const DecisionSignalsPage: React.FC = () => {
</form>
</Card>
{!selected && appliedSourceReportId ? (
<Card padding="md">
{renderReassessPanel()}
</Card>
) : null}
<Card title={t('decisionSignals.statsTitle')} subtitle={t('decisionSignals.statsDescription')} padding="md">
{statsError ? (
<ApiErrorAlert
@@ -831,32 +1020,35 @@ const DecisionSignalsPage: React.FC = () => {
width="max-w-3xl"
>
{selected ? (
<DecisionSignalDetails
item={selected.item}
outcomes={selectedOutcomes}
outcomesLoading={selectedOutcomesLoading}
outcomesError={selectedOutcomesError?.message ?? null}
feedback={selectedFeedback}
feedbackLoading={selectedFeedbackLoading}
feedbackSaving={feedbackSaving}
feedbackError={selectedFeedbackError?.message ?? null}
onFeedbackSubmit={handleFeedbackSubmit}
actions={STATUS_ACTIONS.map((status) => (
<button
key={status}
type="button"
className="btn-secondary !px-3 !py-1.5 !text-xs"
onClick={() => setPendingStatus({
item: selected.item,
status,
message: t(STATUS_ACTION_CONFIRM_KEYS[status]),
})}
disabled={statusUpdating || selected.item.status === status}
>
{t(STATUS_ACTION_LABEL_KEYS[status])}
</button>
))}
/>
<div className="space-y-4">
{renderReassessPanel()}
<DecisionSignalDetails
item={selected.item}
outcomes={selectedOutcomes}
outcomesLoading={selectedOutcomesLoading}
outcomesError={selectedOutcomesError?.message ?? null}
feedback={selectedFeedback}
feedbackLoading={selectedFeedbackLoading}
feedbackSaving={feedbackSaving}
feedbackError={selectedFeedbackError?.message ?? null}
onFeedbackSubmit={handleFeedbackSubmit}
actions={STATUS_ACTIONS.map((status) => (
<button
key={status}
type="button"
className="btn-secondary !px-3 !py-1.5 !text-xs"
onClick={() => setPendingStatus({
item: selected.item,
status,
message: t(STATUS_ACTION_CONFIRM_KEYS[status]),
})}
disabled={statusUpdating || selected.item.status === status}
>
{t(STATUS_ACTION_LABEL_KEYS[status])}
</button>
))}
/>
</div>
) : null}
</Drawer>
@@ -9,6 +9,7 @@ import type {
DecisionSignalListResponse,
DecisionSignalOutcomeListResponse,
DecisionSignalOutcomeStatsResponse,
DecisionSignalReassessResponse,
} from '../../types/decisionSignals';
import DecisionSignalsPage from '../DecisionSignalsPage';
@@ -21,6 +22,7 @@ vi.mock('../../api/decisionSignals', () => ({
getFeedback: vi.fn(),
putFeedback: vi.fn(),
updateStatus: vi.fn(),
reassess: vi.fn(),
},
}));
@@ -164,6 +166,35 @@ const emptyFeedback: DecisionSignalFeedbackItem = {
source: null,
};
const reassessResponse: DecisionSignalReassessResponse = {
preview: {
action: 'watch',
score: 72,
confidence: null,
horizon: '3d',
entryLow: 1680,
stopLoss: 1600,
reason: 'preview reason',
metadata: {
decision_profile: 'balanced',
data_quality_level: 'medium',
scoring_breakdown: { raw_action: 'buy' },
guardrail_result: {
raw_action: 'buy',
final_action: 'watch',
passed: false,
violations: ['missing_confidence'],
adjustments: ['action_downgraded_by_guardrail'],
adjusted: true,
},
},
},
item: null,
created: false,
warnings: [{ code: 'action_blocked_by_guardrail' }],
blockedReason: 'actionable_signal_blocked_by_guardrail',
};
function renderPage() {
return render(
<UiLanguageProvider>
@@ -196,6 +227,7 @@ beforeEach(() => {
source: 'web',
});
vi.mocked(decisionSignalsApi.updateStatus).mockResolvedValue({ ...signal, status: 'invalidated' });
vi.mocked(decisionSignalsApi.reassess).mockResolvedValue(reassessResponse);
});
describe('DecisionSignalsPage', () => {
@@ -319,6 +351,108 @@ describe('DecisionSignalsPage', () => {
});
});
it('reassesses from the selected signal source report without triggering list lookup', async () => {
renderPage();
await screen.findByText('贵州茅台');
fireEvent.click(screen.getByRole('button', { name: '查看 贵州茅台 AI 建议详情' }));
expect(await screen.findByText('决策风格重评估预览')).toBeInTheDocument();
vi.mocked(decisionSignalsApi.list).mockClear();
fireEvent.click(screen.getByRole('button', { name: '生成预览' }));
await waitFor(() => {
expect(decisionSignalsApi.reassess).toHaveBeenCalledWith({
sourceReportId: 3001,
decisionProfile: 'balanced',
persist: false,
});
});
expect(decisionSignalsApi.list).not.toHaveBeenCalled();
expect(await screen.findByText('actionable_signal_blocked_by_guardrail')).toBeInTheDocument();
expect(screen.getByText('buy -> watch')).toBeInTheDocument();
expect(screen.getByText('action_blocked_by_guardrail')).toBeInTheDocument();
});
it('reassesses from an existing source report id filter without a selected signal', async () => {
window.history.pushState({}, '', '/decision-signals?sourceReportId=3001');
vi.mocked(decisionSignalsApi.list).mockResolvedValueOnce(listResponse([], 0));
renderPage();
expect(await screen.findByText('决策风格重评估预览')).toBeInTheDocument();
vi.mocked(decisionSignalsApi.list).mockClear();
fireEvent.click(screen.getByRole('button', { name: '生成预览' }));
await waitFor(() => {
expect(decisionSignalsApi.reassess).toHaveBeenCalledWith({
sourceReportId: 3001,
decisionProfile: 'balanced',
persist: false,
});
});
expect(decisionSignalsApi.list).not.toHaveBeenCalled();
});
it('disables reassess when no source report id is available', async () => {
vi.mocked(decisionSignalsApi.list).mockResolvedValueOnce(listResponse([
makeSignal({ sourceReportId: null }),
]));
renderPage();
await screen.findByText('贵州茅台');
fireEvent.click(screen.getByRole('button', { name: '查看 贵州茅台 AI 建议详情' }));
await waitFor(() => {
expect(screen.getAllByText('该信号不支持重评估').length).toBeGreaterThan(0);
});
expect(screen.getByRole('button', { name: '生成预览' })).toBeDisabled();
});
it('does not fallback to page source report id for a selected signal without source report id', async () => {
window.history.pushState({}, '', '/decision-signals?sourceReportId=3001');
vi.mocked(decisionSignalsApi.list).mockResolvedValueOnce(listResponse([
makeSignal({ sourceReportId: null }),
]));
renderPage();
await screen.findByText('决策风格重评估预览');
fireEvent.click(screen.getByRole('button', { name: '查看 贵州茅台 AI 建议详情' }));
await waitFor(() => {
expect(screen.getAllByText('该信号不支持重评估').length).toBeGreaterThan(0);
});
expect(screen.getByRole('button', { name: '生成预览' })).toBeDisabled();
expect(decisionSignalsApi.reassess).not.toHaveBeenCalled();
});
it('ignores stale reassess responses after switching the selected signal', async () => {
const nextSignal = makeSignal({
id: 8,
stockCode: '000001',
stockName: '平安银行',
sourceReportId: 3002,
});
const pending = deferredPromise<DecisionSignalReassessResponse>();
vi.mocked(decisionSignalsApi.list).mockResolvedValueOnce(listResponse([signal, nextSignal], 2));
vi.mocked(decisionSignalsApi.reassess).mockReturnValueOnce(pending.promise);
renderPage();
await screen.findByText('贵州茅台');
fireEvent.click(screen.getByRole('button', { name: '查看 贵州茅台 AI 建议详情' }));
fireEvent.click(await screen.findByRole('button', { name: '生成预览' }));
fireEvent.click(screen.getByRole('button', { name: '查看 平安银行 AI 建议详情' }));
await act(async () => {
pending.resolve({
...reassessResponse,
preview: { ...reassessResponse.preview, reason: 'stale A preview' },
});
});
expect(screen.queryByText('stale A preview')).not.toBeInTheDocument();
});
it('queries latest active signals by stock code', async () => {
renderPage();
await screen.findByText('贵州茅台');
+36
View File
@@ -123,6 +123,42 @@ export interface DecisionSignalMutationResponse {
created: boolean;
}
export interface DecisionSignalWarning {
code: string;
message?: string | null;
params?: Record<string, unknown> | null;
}
export interface DecisionSignalReassessRequest {
sourceReportId: number;
decisionProfile: DecisionProfile;
persist?: false;
}
export interface DecisionSignalReassessPreview {
action: DecisionAction;
score?: number | null;
confidence?: number | null;
horizon?: DecisionSignalHorizon | null;
entryLow?: number | null;
entryHigh?: number | null;
stopLoss?: number | null;
targetPrice?: number | null;
invalidation?: string | null;
reason?: string | null;
riskSummary?: string | null;
watchConditions?: string | null;
metadata: Record<string, unknown>;
}
export interface DecisionSignalReassessResponse {
preview: DecisionSignalReassessPreview;
item?: DecisionSignalItem | null;
created: false;
warnings: DecisionSignalWarning[];
blockedReason?: string | null;
}
export interface DecisionSignalListResponse {
items: DecisionSignalItem[];
total: number;
+1
View File
@@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [改进] 台股三大法人 fetcher 韧性加固:(1) 接入熔断器(复用 `realtime_types.CircuitBreaker`,按市场 twse/tpex 分流,连续失败 3 次→冷却 ~5min→半开探测),TWSE/TPEx 端点异常时快速跳过网络往返并 fail-open,避免端点故障时每档个股都付 timeout+throttle;(2) TPEx OpenAPI 仅服务最新交易日,调用方传入与服务日期不符的明确日期时改为 fail-open(返回无数据),避免静默返回错日资料。
- [修复] 台股(tw)市场阶段(`market_phase`)新增收盘集合竞价识别:`_CLOSING_AUCTION_WINDOW_MINUTES` 缺 `tw` 键时 `.get(market, 0)` 得零宽窗口,TWSE/TPEx 13:25–13:30 的 5 分钟收盘竞价此前永远无法判定为 `closing_auction`(收盘前一刻仍 `intraday`、13:30 直接 `postmarket`);补 `"tw": 5` 修正,附阶段边界回归测试。仅 tw 加项,cn/hk/us 与 jp/kr 行为不变。
- [新功能] 新增 AI 建议决策风格重评估预览接口与页面预览。
## [3.24.1] - 2026-06-28
+28
View File
@@ -56,9 +56,36 @@ Web 展示必须把这些 wire value 映射为当前 UI 语言的用户可读标
- `POST /api/v1/decision-signals/outcomes/run`:显式触发后验评估。
- `GET /api/v1/decision-signals/outcomes`、`GET /api/v1/decision-signals/outcomes/stats`、`GET /api/v1/decision-signals/{signal_id}/outcomes`:查询后验结果与统计。
- `GET/PUT /api/v1/decision-signals/{signal_id}/feedback`:查询或写入 useful / not useful 反馈。
- `POST /api/v1/decision-signals/reassess`:基于来源历史报告预览不同决策风格下的信号,不写库。
这些接口继承现有 `/api/v1/*` 管理员鉴权;`ADMIN_AUTH_ENABLED=true` 时需要有效管理员会话 Cookie。
## Reassess preview
`reassess` 第一版只做 preview,不创建或更新 `DecisionSignal`。
请求只支持:
```json
{
"source_report_id": 123,
"decision_profile": "aggressive",
"persist": false
}
```
契约边界:
- `source_report_id` 是唯一事实来源,重评估只读取对应持久化历史报告快照。
- 不支持 `signal_id`,也不接受客户端提交 `action`、`score`、`confidence`、价格、metadata 或 guardrail 结果;额外字段会被请求校验拒绝。
- `persist=true` 当前固定返回 HTTP 400,错误码为 `unsupported_operation`。保存重评估结果依赖后续将 `decision_profile` 字段化。
- 重评估不会静默抓取实时行情,也不会用当前市场数据补齐历史快照。
- 历史报告不存在、非个股报告或快照缺少结构化决策输入时,分别返回明确错误。
- data quality 会归一为 `high`、`medium`、`low`、`poor`、`unknown`,guardrail 只使用归一化后的等级。
- `guardrail_result` 是机器审计数据,记录 raw/final action、是否通过、violations 和 adjustments;`warnings` 是用户可读摘要,测试和客户端逻辑应优先依赖稳定 `code`。
- blocked preview 仍是 HTTP 200,UI 必须突出 `blocked_reason`,不能把它当作普通可执行信号。
- aggressive 不是模型采样温度语义,也不会自动生成三套 profile 信号。
## Web 展示
Web 入口位于 `/decision-signals`:
@@ -71,6 +98,7 @@ Web 入口位于 `/decision-signals`:
- P1 不提供 profile filter;`decision_profile` 仍只存在于 metadata 中,不能可靠 server-side 过滤。历史缺失或非法 profile 的信号在 Web 中显示为 `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 或时间线,也不提供保存按钮。
- Web 只能把信号标记为 `closed`、`invalidated` 或 `archived`,不提供 terminal 状态恢复为 active。
- 历史报告详情不再内嵌展示报告绑定的 `source_type=analysis` 信号,也不会因打开报告详情触发 `source_report_id` 信号查询;需要查看报告来源信号时统一进入 `/decision-signals` 页面按来源报告 ID 精确筛选,或打开 `/decision-signals?sourceReportId=<recordId>` deep link。该筛选和 deep link 都会使用 `source_type=analysis + source_report_id` 的精确查询,以保留旧报告的 best-effort 懒回填入口。
- 持仓页异步查询每个唯一持仓的 latest active 信号,单只查询失败只显示降级提示,不阻断组合快照或其他持仓信号。
+244
View File
@@ -0,0 +1,244 @@
# -*- coding: utf-8 -*-
"""Minimal deterministic decision-profile policy for reassess preview."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
from src.schemas.decision_action import DecisionAction
from src.services.decision_signal_data_quality import DecisionSignalDataQuality
MIN_ACTIONABLE_CONFIDENCE = 0.5
PROFILE_POLICY_VERSION = "decision-profile-v1"
SIGNAL_GENERATION_VERSION = "decision-profile-reassess-v1"
SCORING_VERSION = "decision-profile-scoring-v1"
PRICE_RELATIONSHIP_VIOLATION_CODES = frozenset(
{
"entry_range_invalid",
"stop_loss_not_below_target_price",
"stop_loss_not_below_entry_high",
"target_price_not_above_entry_low",
}
)
@dataclass(frozen=True)
class DecisionSignalCandidate:
action: DecisionAction
score: Optional[int] = None
confidence: Optional[float] = None
horizon: Optional[str] = None
entry_low: Optional[float] = None
entry_high: Optional[float] = None
stop_loss: Optional[float] = None
target_price: Optional[float] = None
invalidation: Optional[str] = None
reason: Optional[str] = None
risk_summary: Optional[str] = None
watch_conditions: Optional[str] = None
market_phase: Optional[str] = None
@dataclass(frozen=True)
class GuardrailResult:
raw_action: DecisionAction
final_action: DecisionAction
passed: bool
violations: list[str] = field(default_factory=list)
adjustments: list[str] = field(default_factory=list)
adjusted: bool = False
def as_dict(self) -> dict[str, object]:
return {
"raw_action": self.raw_action,
"final_action": self.final_action,
"passed": self.passed,
"violations": list(self.violations),
"adjustments": list(self.adjustments),
"adjusted": self.adjusted,
}
@dataclass(frozen=True)
class PolicyResult:
candidate: DecisionSignalCandidate
guardrail_result: GuardrailResult
warnings: list[dict[str, object]]
blocked_reason: Optional[str]
scoring_breakdown: dict[str, object]
def apply_decision_profile_policy(
candidate: DecisionSignalCandidate,
*,
decision_profile: str,
data_quality_level: DecisionSignalDataQuality,
) -> PolicyResult:
"""Apply the P3a minimal profile policy and guardrail to a snapshot candidate."""
normalized_candidate = _apply_profile_bias(candidate, decision_profile)
guardrail = _apply_guardrail(normalized_candidate, decision_profile, data_quality_level)
final_candidate = _replace_action(normalized_candidate, guardrail.final_action)
warnings = _warnings_for_guardrail(guardrail)
blocked_reason = (
"actionable_signal_blocked_by_guardrail"
if not guardrail.passed
else None
)
scoring_breakdown = {
"raw_action": candidate.action,
"final_action": guardrail.final_action,
"decision_profile": decision_profile,
"score": candidate.score,
"confidence": candidate.confidence,
"data_quality_level": data_quality_level,
"policy": "minimal_deterministic",
}
return PolicyResult(
candidate=final_candidate,
guardrail_result=guardrail,
warnings=warnings,
blocked_reason=blocked_reason,
scoring_breakdown=scoring_breakdown,
)
def _apply_profile_bias(candidate: DecisionSignalCandidate, decision_profile: str) -> DecisionSignalCandidate:
horizon = candidate.horizon
if not horizon:
horizon = "intraday" if candidate.action == "alert" or candidate.market_phase == "intraday" else "3d"
return DecisionSignalCandidate(
action=candidate.action,
score=candidate.score,
confidence=candidate.confidence,
horizon=horizon,
entry_low=candidate.entry_low,
entry_high=candidate.entry_high,
stop_loss=candidate.stop_loss,
target_price=candidate.target_price,
invalidation=candidate.invalidation,
reason=candidate.reason,
risk_summary=candidate.risk_summary,
watch_conditions=candidate.watch_conditions,
market_phase=candidate.market_phase,
)
def _apply_guardrail(
candidate: DecisionSignalCandidate,
decision_profile: str,
data_quality_level: DecisionSignalDataQuality,
) -> GuardrailResult:
violations: list[str] = []
adjustments: list[str] = []
raw_action = candidate.action
final_action = raw_action
passed = True
if raw_action in ("buy", "add"):
if not candidate.horizon:
violations.append("missing_horizon")
if not candidate.invalidation and candidate.stop_loss is None:
violations.append("missing_invalidation_or_stop_loss")
if data_quality_level in ("poor", "unknown"):
violations.append("insufficient_data_quality")
if candidate.confidence is None:
violations.append("missing_confidence")
elif candidate.confidence < MIN_ACTIONABLE_CONFIDENCE:
violations.append("confidence_below_actionable_threshold")
if decision_profile == "aggressive" and not candidate.invalidation:
violations.append("aggressive_missing_explicit_invalidation")
if decision_profile == "aggressive" and candidate.horizon == "long":
violations.append("aggressive_horizon_long_not_allowed")
violations.extend(_price_violations(candidate))
if raw_action in ("buy", "add") and violations:
has_price_violation = any(code in PRICE_RELATIONSHIP_VIOLATION_CODES for code in violations)
final_action = "alert" if has_price_violation else "watch"
adjustments.append("action_downgraded_by_guardrail")
passed = False
adjusted = raw_action != final_action or bool(adjustments)
return GuardrailResult(
raw_action=raw_action,
final_action=final_action,
passed=passed,
violations=violations,
adjustments=adjustments,
adjusted=adjusted,
)
def _price_violations(candidate: DecisionSignalCandidate) -> list[str]:
violations: list[str] = []
if (
candidate.entry_low is not None
and candidate.entry_high is not None
and candidate.entry_low > candidate.entry_high
):
violations.append("entry_range_invalid")
if (
candidate.stop_loss is not None
and candidate.target_price is not None
and candidate.stop_loss >= candidate.target_price
):
violations.append("stop_loss_not_below_target_price")
if (
candidate.stop_loss is not None
and candidate.entry_high is not None
and candidate.stop_loss >= candidate.entry_high
):
violations.append("stop_loss_not_below_entry_high")
if (
candidate.target_price is not None
and candidate.entry_low is not None
and candidate.target_price <= candidate.entry_low
):
violations.append("target_price_not_above_entry_low")
return violations
def _replace_action(candidate: DecisionSignalCandidate, action: DecisionAction) -> DecisionSignalCandidate:
return DecisionSignalCandidate(
action=action,
score=candidate.score,
confidence=candidate.confidence,
horizon=candidate.horizon,
entry_low=candidate.entry_low,
entry_high=candidate.entry_high,
stop_loss=candidate.stop_loss,
target_price=candidate.target_price,
invalidation=candidate.invalidation,
reason=candidate.reason,
risk_summary=candidate.risk_summary,
watch_conditions=candidate.watch_conditions,
market_phase=candidate.market_phase,
)
def _warnings_for_guardrail(guardrail: GuardrailResult) -> list[dict[str, object]]:
warnings: list[dict[str, object]] = []
if guardrail.adjusted:
warnings.append(
{
"code": "action_adjusted_by_guardrail",
"params": {
"raw_action": guardrail.raw_action,
"final_action": guardrail.final_action,
},
}
)
if not guardrail.passed:
warnings.append(
{
"code": "action_blocked_by_guardrail",
"params": {
"raw_action": guardrail.raw_action,
"final_action": guardrail.final_action,
"violations": list(guardrail.violations),
},
}
)
return warnings
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""Normalize decision-signal data quality inputs for reassess guardrails."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, Iterable, Literal
DecisionSignalDataQuality = Literal["high", "medium", "low", "poor", "unknown"]
_QUALITY_ALIASES: dict[str, DecisionSignalDataQuality] = {
"high": "high",
"good": "high",
"medium": "medium",
"usable": "medium",
"ok": "medium",
"fair": "medium",
"low": "low",
"limited": "low",
"partial": "low",
"degraded": "low",
"stale": "low",
"fallback": "low",
"poor": "poor",
"missing": "poor",
"unavailable": "poor",
"fetch_failed": "poor",
"not_supported": "poor",
"unknown": "unknown",
}
_QUALITY_SEVERITY: dict[DecisionSignalDataQuality, int] = {
"unknown": 0,
"high": 1,
"medium": 2,
"low": 3,
"poor": 4,
}
def normalize_decision_signal_data_quality(value: Any) -> DecisionSignalDataQuality:
"""Normalize explicit quality levels without inferring quality from completeness."""
known_levels = [
level
for level in _explicit_quality_levels(value)
if level != "unknown"
]
if not known_levels:
return "unknown"
return max(known_levels, key=lambda level: _QUALITY_SEVERITY[level])
def _normalize_scalar(value: Any) -> DecisionSignalDataQuality:
if value is None:
return "unknown"
if isinstance(value, str):
text = value.strip().lower()
if not text:
return "unknown"
return _QUALITY_ALIASES.get(text, "unknown")
return "unknown"
def _explicit_quality_levels(value: Any) -> Iterable[DecisionSignalDataQuality]:
scalar = _normalize_scalar(value)
if scalar != "unknown":
yield scalar
return
if not isinstance(value, Mapping):
yield "unknown"
return
for key in ("level", "quality_level", "status", "data_quality", "quality"):
if key in value:
yield from _explicit_quality_levels(value.get(key))
for key in ("quote", "daily_bars", "technical"):
if key in value:
yield from _explicit_quality_levels(value.get(key))
overview = value.get("analysis_context_pack_overview")
if isinstance(overview, Mapping):
yield from _explicit_quality_levels(overview.get("data_quality"))
@@ -0,0 +1,323 @@
# -*- coding: utf-8 -*-
"""Preview-only decision-profile reassessment from persisted analysis history."""
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any, Optional
from src.schemas.decision_action import build_action_fields
from src.services.decision_profile_policy import (
PROFILE_POLICY_VERSION,
SCORING_VERSION,
SIGNAL_GENERATION_VERSION,
DecisionSignalCandidate,
apply_decision_profile_policy,
)
from src.services.decision_signal_data_quality import normalize_decision_signal_data_quality
from src.storage import AnalysisHistory, DatabaseManager
from src.utils.data_processing import parse_json_field
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."
)
class DecisionSignalSourceReportNotFoundError(Exception):
"""Raised when the requested source report does not exist."""
class DecisionSignalUnsupportedReportTypeError(Exception):
"""Raised when the source report is not a stock analysis report."""
class DecisionSignalUnsupportedReportSnapshotError(Exception):
"""Raised when the persisted report snapshot is insufficient for reassess."""
class DecisionSignalReassessUnsupportedOperationError(Exception):
"""Raised when the request asks for a future reassess operation."""
class DecisionSignalReassessService:
"""Build preview-only reassess responses without touching DecisionSignal rows."""
def __init__(self, db: Optional[DatabaseManager] = None) -> None:
self.db = db or DatabaseManager.get_instance()
def reassess(
self,
*,
source_report_id: int,
decision_profile: str,
persist: bool = False,
) -> dict[str, Any]:
if persist:
raise DecisionSignalReassessUnsupportedOperationError(UNSUPPORTED_PERSIST_MESSAGE)
record = self.db.get_analysis_history_by_id(source_report_id)
if record is None:
raise DecisionSignalSourceReportNotFoundError(f"source report not found: {source_report_id}")
raw_result = _parse_mapping(getattr(record, "raw_result", None))
context_snapshot = _parse_mapping(getattr(record, "context_snapshot", None))
candidate = _build_candidate(record, raw_result, context_snapshot)
data_quality_level = normalize_decision_signal_data_quality(
_first_present(
_nested_get(context_snapshot, ("analysis_context_pack_overview", "data_quality")),
_nested_get(context_snapshot, ("data_quality",)),
_nested_get(raw_result, ("analysis_context_pack_overview", "data_quality")),
_nested_get(raw_result, ("data_quality",)),
)
)
policy = apply_decision_profile_policy(
candidate,
decision_profile=decision_profile,
data_quality_level=data_quality_level,
)
preview_candidate = policy.candidate
metadata = {
"decision_profile": decision_profile,
"profile_source": "user_selected",
"profile_policy_version": PROFILE_POLICY_VERSION,
"signal_generation_version": SIGNAL_GENERATION_VERSION,
"scoring_version": SCORING_VERSION,
"scoring_breakdown": policy.scoring_breakdown,
"data_quality_level": data_quality_level,
"guardrail_result": policy.guardrail_result.as_dict(),
}
preview = {
"action": preview_candidate.action,
"score": preview_candidate.score,
"confidence": preview_candidate.confidence,
"horizon": preview_candidate.horizon,
"entry_low": preview_candidate.entry_low,
"entry_high": preview_candidate.entry_high,
"stop_loss": preview_candidate.stop_loss,
"target_price": preview_candidate.target_price,
"invalidation": preview_candidate.invalidation,
"reason": preview_candidate.reason,
"risk_summary": preview_candidate.risk_summary,
"watch_conditions": preview_candidate.watch_conditions,
"metadata": metadata,
}
return {
"preview": preview,
"item": None,
"created": False,
"warnings": policy.warnings,
"blocked_reason": policy.blocked_reason,
}
def _build_candidate(
record: AnalysisHistory,
raw_result: Mapping[str, Any],
context_snapshot: Mapping[str, Any],
) -> DecisionSignalCandidate:
report_type = str(getattr(record, "report_type", "") or "").strip().lower()
if report_type == "market_review":
raise DecisionSignalUnsupportedReportTypeError("source report is not a stock analysis report")
raw_code = str(getattr(record, "code", "") or "").strip()
market = _infer_market(raw_code)
if not raw_code or not market:
raise DecisionSignalUnsupportedReportSnapshotError("source report has no supported stock identity")
action_fields = build_action_fields(
operation_advice=_first_present(
raw_result.get("operation_advice"),
getattr(record, "operation_advice", None),
),
explicit_action=raw_result.get("action"),
report_type=report_type,
report_language=raw_result.get("report_language"),
)
action = action_fields.get("action")
if not action:
raise DecisionSignalUnsupportedReportSnapshotError("source report has no structured decision action")
sniper_points = _extract_persisted_sniper_points(record, raw_result)
entry_low, entry_high = _entry_range(
sniper_points.get("ideal_buy"),
sniper_points.get("secondary_buy"),
)
market_phase = _extract_market_phase(raw_result, context_snapshot)
return DecisionSignalCandidate(
action=action,
score=_score_from_value(_first_present(raw_result.get("sentiment_score"), getattr(record, "sentiment_score", None))),
confidence=_confidence_from_level(raw_result.get("confidence_level")),
horizon=_extract_horizon(raw_result, context_snapshot, market_phase, action),
entry_low=entry_low,
entry_high=entry_high,
stop_loss=sniper_points.get("stop_loss"),
target_price=sniper_points.get("take_profit"),
invalidation=_extract_invalidation(raw_result),
reason=_first_text(
getattr(record, "analysis_summary", None),
raw_result.get("analysis_summary"),
raw_result.get("buy_reason"),
raw_result.get("key_points"),
),
risk_summary=_extract_risk_summary(raw_result),
watch_conditions=_extract_watch_conditions(raw_result),
market_phase=market_phase,
)
def _parse_mapping(value: Any) -> Mapping[str, Any]:
parsed = parse_json_field(value)
return parsed if isinstance(parsed, Mapping) else {}
def _first_present(*values: Any) -> Any:
for value in values:
if value not in (None, "", [], {}):
return value
return None
def _first_text(*values: Any) -> Optional[str]:
for value in values:
if isinstance(value, list):
joined = ";".join(str(item).strip() for item in value if str(item or "").strip())
if joined:
return joined
text = str(value or "").strip()
if text:
return text
return None
def _nested_get(value: Mapping[str, Any], path: tuple[str, ...]) -> Any:
current: Any = value
for key in path:
if not isinstance(current, Mapping):
return None
current = current.get(key)
return current
def _infer_market(code: str) -> Optional[str]:
text = str(code or "").strip().upper()
if not text:
return None
if text.startswith("HK") or text.endswith(".HK"):
return "hk"
if (
text.endswith((".SH", ".SZ", ".BJ"))
or (len(text) == 8 and text[:2] in {"SH", "SZ", "BJ"} and text[2:].isdigit())
):
return "cn"
if text.endswith((".T", ".JP")):
return "jp"
if text.endswith((".KS", ".KQ")):
return "kr"
if text.endswith((".TW", ".TWO")):
return "tw"
if text.isdigit() and len(text) == 6:
return "cn"
if re.fullmatch(r"[A-Z]{1,5}(?:\.[A-Z]{1,2})?", text):
return "us"
return None
def _score_from_value(value: Any) -> Optional[int]:
try:
score = int(float(value))
except (TypeError, ValueError):
return None
return score if 0 <= score <= 100 else None
def _confidence_from_level(value: Any) -> Optional[float]:
key = str(value or "").strip().lower()
mapping = {
"高": 0.8,
"high": 0.8,
"中": 0.6,
"medium": 0.6,
"mid": 0.6,
"低": 0.4,
"low": 0.4,
}
return mapping.get(key)
def _extract_persisted_sniper_points(record: AnalysisHistory, raw_result: Mapping[str, Any]) -> dict[str, Optional[float]]:
raw_points = find_sniper_points(raw_result) or {}
return {
"ideal_buy": _first_price(getattr(record, "ideal_buy", None), raw_points.get("ideal_buy")),
"secondary_buy": _first_price(getattr(record, "secondary_buy", None), raw_points.get("secondary_buy")),
"stop_loss": _first_price(getattr(record, "stop_loss", None), raw_points.get("stop_loss")),
"take_profit": _first_price(getattr(record, "take_profit", None), raw_points.get("take_profit")),
}
def _first_price(*values: Any) -> Optional[float]:
for value in values:
parsed = parse_sniper_value(value)
if parsed is not None:
return parsed
return None
def _entry_range(ideal_buy: Optional[float], secondary_buy: Optional[float]) -> tuple[Optional[float], Optional[float]]:
if ideal_buy is not None and secondary_buy is not None and ideal_buy > secondary_buy:
return secondary_buy, ideal_buy
return ideal_buy, secondary_buy
def _extract_market_phase(raw_result: Mapping[str, Any], context_snapshot: Mapping[str, Any]) -> Optional[str]:
return _first_text(
_nested_get(context_snapshot, ("market_phase_summary", "phase")),
_nested_get(raw_result, ("market_phase_summary", "phase")),
_nested_get(raw_result, ("dashboard", "phase_decision", "phase")),
)
def _extract_horizon(
raw_result: Mapping[str, Any],
context_snapshot: Mapping[str, Any],
market_phase: Optional[str],
action: str,
) -> Optional[str]:
explicit = _first_text(
raw_result.get("horizon"),
raw_result.get("holding_period"),
_nested_get(raw_result, ("dashboard", "phase_decision", "horizon")),
_nested_get(raw_result, ("dashboard", "battle_plan", "horizon")),
)
if explicit in {"intraday", "1d", "3d", "5d", "10d", "swing", "long"}:
return explicit
phase = market_phase or _nested_get(context_snapshot, ("market_phase_summary", "phase"))
return "intraday" if action == "alert" or phase == "intraday" else "3d"
def _extract_invalidation(raw_result: Mapping[str, Any]) -> Optional[str]:
return _first_text(
raw_result.get("invalidation"),
raw_result.get("invalid_condition"),
_nested_get(raw_result, ("dashboard", "phase_decision", "invalidation")),
_nested_get(raw_result, ("dashboard", "battle_plan", "invalidation")),
)
def _extract_risk_summary(raw_result: Mapping[str, Any]) -> Optional[str]:
return _first_text(
raw_result.get("risk_summary"),
raw_result.get("risk_warning"),
_nested_get(raw_result, ("dashboard", "intelligence", "risk_alerts")),
)
def _extract_watch_conditions(raw_result: Mapping[str, Any]) -> Optional[str]:
return _first_text(
raw_result.get("watch_conditions"),
_nested_get(raw_result, ("dashboard", "phase_decision", "watch_conditions")),
_nested_get(raw_result, ("dashboard", "battle_plan", "action_checklist")),
)
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
from src.services.decision_profile_policy import DecisionSignalCandidate, apply_decision_profile_policy
def test_policy_keeps_valid_snapshot_action_without_profile_upgrade() -> None:
result = apply_decision_profile_policy(
DecisionSignalCandidate(
action="hold",
score=52,
confidence=0.6,
horizon=None,
market_phase=None,
),
decision_profile="aggressive",
data_quality_level="medium",
)
assert result.candidate.action == "hold"
assert result.candidate.horizon == "3d"
assert result.guardrail_result.passed is True
assert result.guardrail_result.adjusted is False
def test_policy_blocks_buy_with_missing_confidence_as_safe_display_action() -> None:
result = apply_decision_profile_policy(
DecisionSignalCandidate(
action="buy",
score=70,
confidence=None,
horizon="3d",
stop_loss=10,
target_price=15,
),
decision_profile="balanced",
data_quality_level="medium",
)
assert result.guardrail_result.raw_action == "buy"
assert result.guardrail_result.final_action == "watch"
assert result.candidate.action == "watch"
assert result.guardrail_result.passed is False
assert "missing_confidence" in result.guardrail_result.violations
assert result.blocked_reason
assert {warning["code"] for warning in result.warnings} >= {"action_blocked_by_guardrail"}
def test_policy_requires_explicit_invalidation_for_aggressive_buy() -> None:
result = apply_decision_profile_policy(
DecisionSignalCandidate(
action="buy",
score=70,
confidence=0.7,
horizon="3d",
stop_loss=10,
target_price=15,
),
decision_profile="aggressive",
data_quality_level="medium",
)
assert result.candidate.action == "watch"
assert "aggressive_missing_explicit_invalidation" in result.guardrail_result.violations
def test_policy_blocks_aggressive_buy_with_long_horizon_without_silent_cap() -> None:
result = apply_decision_profile_policy(
DecisionSignalCandidate(
action="buy",
confidence=0.7,
horizon="long",
invalidation="跌破趋势线",
stop_loss=10,
target_price=15,
),
decision_profile="aggressive",
data_quality_level="medium",
)
assert result.candidate.action == "watch"
assert result.candidate.horizon == "long"
assert "aggressive_horizon_long_not_allowed" in result.guardrail_result.violations
def test_policy_records_price_relationship_violations() -> None:
result = apply_decision_profile_policy(
DecisionSignalCandidate(
action="add",
confidence=0.7,
horizon="3d",
invalidation="跌破趋势线",
entry_low=20,
entry_high=18,
stop_loss=19,
target_price=17,
),
decision_profile="balanced",
data_quality_level="medium",
)
assert result.guardrail_result.final_action == "alert"
assert result.guardrail_result.adjusted is True
assert "entry_range_invalid" in result.guardrail_result.violations
assert "stop_loss_not_below_target_price" in result.guardrail_result.violations
+284 -1
View File
@@ -6,6 +6,7 @@ from __future__ import annotations
import os
import sys
import time
import json
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -22,7 +23,7 @@ except ModuleNotFoundError:
import src.auth as auth
from api.app import create_app
from src.config import Config
from src.storage import DatabaseManager, DecisionSignalRecord, PortfolioAccount, PortfolioPosition, utc_naive_now
from src.storage import AnalysisHistory, DatabaseManager, DecisionSignalRecord, PortfolioAccount, PortfolioPosition, utc_naive_now
@contextmanager
@@ -1212,3 +1213,285 @@ def test_dedup_distinguishes_market_for_same_symbol(client_and_db) -> None:
assert hk_resp.status_code == 200, hk_resp.text
assert hk_resp.json()["created"] is True
assert hk_resp.json()["item"]["id"] != us_resp.json()["item"]["id"]
def _decision_signal_count(db: DatabaseManager) -> int:
with db.session_scope() as session:
return session.query(DecisionSignalRecord).count()
def _save_reassess_history(
db: DatabaseManager,
*,
code: str = "600519",
report_type: str = "full",
operation_advice: str | None = "买入",
raw_result: dict | str | None = None,
context_snapshot: dict | str | None = None,
sentiment_score: int | None = 72,
stop_loss: float | None = 1600,
take_profit: float | None = 1850,
) -> int:
raw_payload = raw_result
if isinstance(raw_payload, dict):
raw_payload = json.dumps(raw_payload, ensure_ascii=False)
context_payload = context_snapshot
if isinstance(context_payload, dict):
context_payload = json.dumps(context_payload, ensure_ascii=False)
with db.session_scope() as session:
row = AnalysisHistory(
query_id="query-reassess-test",
code=code,
name="贵州茅台",
report_type=report_type,
sentiment_score=sentiment_score,
operation_advice=operation_advice,
trend_prediction="震荡上行",
analysis_summary="趋势改善但需要风控。",
raw_result=raw_payload,
context_snapshot=context_payload,
ideal_buy=1680,
secondary_buy=1700,
stop_loss=stop_loss,
take_profit=take_profit,
)
session.add(row)
session.flush()
return int(row.id)
def _valid_reassess_raw(**overrides) -> dict:
raw = {
"action": "buy",
"operation_advice": "买入",
"sentiment_score": 72,
"confidence_level": "中",
"analysis_summary": "趋势改善但需要确认。",
"risk_warning": "跌破关键支撑需退出。",
"dashboard": {
"battle_plan": {
"sniper_points": {
"ideal_buy": 1680,
"secondary_buy": 1700,
"stop_loss": 1600,
"take_profit": 1850,
},
"action_checklist": ["放量突破", "资金流转正"],
},
"phase_decision": {
"watch_conditions": ["量能维持"],
},
},
}
raw.update(overrides)
return raw
def _valid_reassess_context() -> dict:
return {
"market_phase_summary": {"phase": "intraday"},
"analysis_context_pack_overview": {
"data_quality": {"level": "usable"},
},
}
def test_reassess_persist_true_rejects_before_db_lookup(client_and_db, monkeypatch) -> None:
client, db = client_and_db
def fail_service(*_args, **_kwargs):
raise AssertionError("DecisionSignalReassessService must not be instantiated for persist=true")
monkeypatch.setattr("api.v1.endpoints.decision_signals.DecisionSignalReassessService", fail_service)
before = _decision_signal_count(db)
response = client.post(
"/api/v1/decision-signals/reassess",
json={
"source_report_id": 999999,
"decision_profile": "aggressive",
"persist": True,
},
)
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."
)
assert _decision_signal_count(db) == before
@pytest.mark.parametrize(
"payload",
[
{"decision_profile": "balanced", "persist": False},
{"source_report_id": 0, "decision_profile": "balanced", "persist": False},
{"source_report_id": 1, "decision_profile": "reckless", "persist": False},
],
)
def test_reassess_schema_validation_errors(client_and_db, payload) -> None:
client, _db = client_and_db
response = client.post("/api/v1/decision-signals/reassess", json=payload)
assert response.status_code == 422
@pytest.mark.parametrize(
"extra_field",
[
"signal_id",
"action",
"score",
"confidence",
"horizon",
"invalidation",
"stop_loss",
"target_price",
"metadata",
"scoring_breakdown",
"guardrail_result",
],
)
def test_reassess_forbids_extra_fields(client_and_db, extra_field) -> None:
client, _db = client_and_db
response = client.post(
"/api/v1/decision-signals/reassess",
json={
"source_report_id": 1,
"decision_profile": "balanced",
"persist": False,
extra_field: "not-supported",
},
)
assert response.status_code == 422
def test_reassess_error_mapping(client_and_db) -> None:
client, db = client_and_db
missing = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": 999999, "decision_profile": "balanced", "persist": False},
)
assert missing.status_code == 404
assert missing.json()["error"] == "source_report_not_found"
market_review_id = _save_reassess_history(
db,
report_type="market_review",
raw_result=_valid_reassess_raw(),
context_snapshot=_valid_reassess_context(),
)
non_stock = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": market_review_id, "decision_profile": "balanced", "persist": False},
)
assert non_stock.status_code == 400
assert non_stock.json()["error"] == "unsupported_report_type"
insufficient_id = _save_reassess_history(
db,
code="600519",
operation_advice=None,
raw_result={"analysis_summary": "仅有摘要,不能推断动作"},
context_snapshot=_valid_reassess_context(),
)
insufficient = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": insufficient_id, "decision_profile": "balanced", "persist": False},
)
assert insufficient.status_code == 400
assert insufficient.json()["error"] == "unsupported_report_snapshot"
unsupported_market_id = _save_reassess_history(
db,
code="NOT_A_VALID_US_SYMBOL",
raw_result=_valid_reassess_raw(),
context_snapshot=_valid_reassess_context(),
)
unsupported_market = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": unsupported_market_id, "decision_profile": "balanced", "persist": False},
)
assert unsupported_market.status_code == 400
assert unsupported_market.json()["error"] == "unsupported_report_snapshot"
def test_reassess_success_preview_is_read_only_and_uses_opaque_metadata(client_and_db, monkeypatch) -> None:
client, db = client_and_db
record_id = _save_reassess_history(
db,
raw_result=_valid_reassess_raw(),
context_snapshot=_valid_reassess_context(),
)
monkeypatch.setattr(
"src.services.decision_signal_service.DecisionSignalService.create_signal",
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("create_signal must not be called")),
)
monkeypatch.setattr(
"src.services.decision_signal_service.DecisionSignalService.list_signals",
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("list_signals must not be called")),
)
monkeypatch.setattr(
"src.services.analysis_context_builder._build_quote_block",
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("reassess must not rebuild quote context")),
)
before = _decision_signal_count(db)
response = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": False},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["item"] is None
assert payload["created"] is False
assert payload["preview"]["action"] == payload["preview"]["metadata"]["guardrail_result"]["final_action"]
assert payload["preview"]["metadata"]["decision_profile"] == "balanced"
assert payload["preview"]["metadata"]["profile_source"] == "user_selected"
assert payload["preview"]["metadata"]["signal_generation_version"] == "decision-profile-reassess-v1"
assert payload["preview"]["metadata"]["scoring_version"] == "decision-profile-scoring-v1"
assert "scoring_breakdown" in payload["preview"]["metadata"]
assert payload["preview"]["metadata"]["data_quality_level"] == "medium"
assert payload["preview"]["entry_low"] == 1680
assert payload["preview"]["stop_loss"] == 1600
assert _decision_signal_count(db) == before
def test_reassess_service_has_no_live_market_provider_imports() -> None:
source = (Path(__file__).resolve().parents[1] / "src/services/decision_signal_reassess_service.py").read_text(
encoding="utf-8"
)
assert "data_provider" not in source
assert "yfinance" not in source
assert "akshare" not in source
assert "build_decision_signal_payload_from_report" not in source
def test_reassess_confidence_missing_buy_is_blocked_preview_not_persisted(client_and_db) -> None:
client, db = client_and_db
record_id = _save_reassess_history(
db,
raw_result=_valid_reassess_raw(confidence_level=None),
context_snapshot=_valid_reassess_context(),
)
before = _decision_signal_count(db)
response = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "aggressive", "persist": False},
)
assert response.status_code == 200, response.text
payload = response.json()
guardrail = payload["preview"]["metadata"]["guardrail_result"]
assert guardrail["raw_action"] == "buy"
assert guardrail["final_action"] in {"watch", "alert"}
assert guardrail["passed"] is False
assert "missing_confidence" in guardrail["violations"]
assert payload["blocked_reason"]
assert payload["warnings"]
assert {warning["code"] for warning in payload["warnings"]} >= {"action_blocked_by_guardrail"}
assert _decision_signal_count(db) == before
@@ -0,0 +1,31 @@
from __future__ import annotations
import pytest
from src.services.decision_signal_data_quality import normalize_decision_signal_data_quality
@pytest.mark.parametrize(
("value", "expected"),
[
({"level": "good"}, "high"),
({"data_quality": {"level": "usable"}}, "medium"),
({"analysis_context_pack_overview": {"data_quality": {"level": "limited"}}}, "low"),
({"status": "fetch_failed"}, "poor"),
({"quote": "high", "daily_bars": "fetch_failed"}, "poor"),
({"level": "good", "technical": {"status": "degraded"}}, "low"),
({"not_quality": "good", "fields": ["many"]}, "unknown"),
(None, "unknown"),
],
)
def test_normalize_decision_signal_data_quality_only_uses_explicit_quality(value, expected) -> None:
assert normalize_decision_signal_data_quality(value) == expected
def test_normalize_decision_signal_data_quality_outputs_known_enum_values() -> None:
values = [
normalize_decision_signal_data_quality(value)
for value in ("good", "usable", "partial", "poor", {"unknown": "shape"})
]
assert set(values) <= {"high", "medium", "low", "poor", "unknown"}