mirror of
https://github.com/ZhuLinsen/daily_stock_analysis.git
synced 2026-10-06 14:33:11 +08:00
feat: add decision signal reassess preview (#1877)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('贵州茅台');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 信号,单只查询失败只显示降级提示,不阻断组合快照或其他持仓信号。
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user