Skip to content

Commit b9d602d

Browse files
committed
feat: add decision signal outcomes and feedback
1 parent ee14c39 commit b9d602d

29 files changed

Lines changed: 6193 additions & 1834 deletions

api/v1/endpoints/decision_signals.py

Lines changed: 203 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,23 @@
44
from __future__ import annotations
55

66
import logging
7-
from typing import Optional
7+
from typing import List, Optional
88

99
from fastapi import APIRouter, HTTPException, Query, Security
1010
from fastapi.security import APIKeyCookie
1111

1212
from api.v1.schemas.common import ErrorResponse
1313
from api.v1.schemas.decision_signals import (
1414
DecisionSignalCreateRequest,
15+
DecisionSignalFeedbackItem,
16+
DecisionSignalFeedbackRequest,
1517
DecisionSignalItem,
1618
DecisionSignalListResponse,
1719
DecisionSignalMutationResponse,
20+
DecisionSignalOutcomeListResponse,
21+
DecisionSignalOutcomeRunRequest,
22+
DecisionSignalOutcomeRunResponse,
23+
DecisionSignalOutcomeStatsResponse,
1824
DecisionSignalStatusUpdateRequest,
1925
)
2026
from src.auth import COOKIE_NAME
@@ -23,6 +29,7 @@
2329
DecisionSignalService,
2430
DecisionSignalStorageError,
2531
)
32+
from src.services.decision_signal_outcome_service import DecisionSignalOutcomeService
2633

2734

2835
logger = logging.getLogger(__name__)
@@ -165,6 +172,121 @@ def list_signals(
165172
raise _internal_error("List decision signals failed", exc)
166173

167174

175+
@router.post(
176+
"/outcomes/run",
177+
response_model=DecisionSignalOutcomeRunResponse,
178+
responses={
179+
**AUTH_RESPONSE,
180+
400: {"model": ErrorResponse, "description": "请求字段非法"},
181+
404: {"model": ErrorResponse, "description": "信号不存在"},
182+
422: {"model": ErrorResponse, "description": "请求体校验失败"},
183+
500: {"model": ErrorResponse, "description": "后验计算失败"},
184+
},
185+
summary="触发决策信号后验评估",
186+
description=(
187+
"显式触发 signal-level outcome 计算;默认按当前 engine_version 幂等跳过已评估键,"
188+
"force=true 会重算并覆盖同一 signal_id+horizon+engine_version。"
189+
),
190+
operation_id="runDecisionSignalOutcomes",
191+
)
192+
def run_outcomes(request: DecisionSignalOutcomeRunRequest) -> DecisionSignalOutcomeRunResponse:
193+
service = DecisionSignalOutcomeService()
194+
try:
195+
return DecisionSignalOutcomeRunResponse(
196+
**service.run_outcomes(
197+
signal_id=request.signal_id,
198+
horizons=request.horizons,
199+
force=request.force,
200+
market=request.market,
201+
stock_code=request.stock_code,
202+
action=request.action,
203+
source_type=request.source_type,
204+
status=request.status,
205+
limit=request.limit,
206+
)
207+
)
208+
except DecisionSignalNotFoundError as exc:
209+
raise _not_found(exc)
210+
except ValueError as exc:
211+
raise _bad_request(exc)
212+
except Exception as exc:
213+
raise _internal_error("Run decision signal outcomes failed", exc)
214+
215+
216+
@router.get(
217+
"/outcomes",
218+
response_model=DecisionSignalOutcomeListResponse,
219+
responses={
220+
**AUTH_RESPONSE,
221+
400: {"model": ErrorResponse, "description": "查询参数非法"},
222+
422: {"model": ErrorResponse, "description": "查询参数校验失败"},
223+
500: {"model": ErrorResponse, "description": "查询失败"},
224+
},
225+
summary="查询决策信号后验结果",
226+
description="分页查询 signal-level outcome;默认只查当前 signal 后验 engine_version。",
227+
operation_id="listDecisionSignalOutcomes",
228+
)
229+
def list_outcomes(
230+
signal_id: Optional[int] = Query(None, gt=0),
231+
horizon: Optional[str] = Query(None),
232+
engine_version: Optional[str] = Query(None),
233+
eval_status: Optional[str] = Query(None),
234+
outcome: Optional[str] = Query(None),
235+
page: int = Query(1, ge=1),
236+
page_size: int = Query(20, ge=1, le=100),
237+
) -> DecisionSignalOutcomeListResponse:
238+
service = DecisionSignalOutcomeService()
239+
try:
240+
return DecisionSignalOutcomeListResponse(
241+
**service.list_outcomes(
242+
signal_id=signal_id,
243+
horizon=horizon,
244+
engine_version=engine_version,
245+
eval_status=eval_status,
246+
outcome=outcome,
247+
page=page,
248+
page_size=page_size,
249+
)
250+
)
251+
except ValueError as exc:
252+
raise _bad_request(exc)
253+
except Exception as exc:
254+
raise _internal_error("List decision signal outcomes failed", exc)
255+
256+
257+
@router.get(
258+
"/outcomes/stats",
259+
response_model=DecisionSignalOutcomeStatsResponse,
260+
responses={
261+
**AUTH_RESPONSE,
262+
400: {"model": ErrorResponse, "description": "查询参数非法"},
263+
422: {"model": ErrorResponse, "description": "查询参数校验失败"},
264+
500: {"model": ErrorResponse, "description": "统计失败"},
265+
},
266+
summary="查询决策信号后验统计",
267+
description="默认统计当前 engine_version,且排除 archived 信号。",
268+
operation_id="getDecisionSignalOutcomeStats",
269+
)
270+
def get_outcome_stats(
271+
horizons: Optional[List[str]] = Query(None),
272+
engine_version: Optional[str] = Query(None),
273+
statuses: Optional[List[str]] = Query(None),
274+
) -> DecisionSignalOutcomeStatsResponse:
275+
service = DecisionSignalOutcomeService()
276+
try:
277+
return DecisionSignalOutcomeStatsResponse(
278+
**service.get_stats(
279+
horizons=horizons,
280+
engine_version=engine_version,
281+
statuses=statuses,
282+
)
283+
)
284+
except ValueError as exc:
285+
raise _bad_request(exc)
286+
except Exception as exc:
287+
raise _internal_error("Get decision signal outcome stats failed", exc)
288+
289+
168290
@router.get(
169291
"/latest/{stock_code}",
170292
response_model=DecisionSignalListResponse,
@@ -225,6 +347,86 @@ def get_signal(signal_id: int) -> DecisionSignalItem:
225347
raise _internal_error("Get decision signal failed", exc)
226348

227349

350+
@router.get(
351+
"/{signal_id}/outcomes",
352+
response_model=DecisionSignalOutcomeListResponse,
353+
responses={
354+
**AUTH_RESPONSE,
355+
404: {"model": ErrorResponse, "description": "信号不存在"},
356+
422: {"model": ErrorResponse, "description": "路径参数校验失败"},
357+
500: {"model": ErrorResponse, "description": "查询失败"},
358+
},
359+
summary="查询单个决策信号后验结果",
360+
description="返回指定 signal_id 在当前 engine_version 下的后验结果。",
361+
operation_id="listDecisionSignalOutcomesBySignal",
362+
)
363+
def list_signal_outcomes(signal_id: int) -> DecisionSignalOutcomeListResponse:
364+
service = DecisionSignalOutcomeService()
365+
try:
366+
return DecisionSignalOutcomeListResponse(**service.list_signal_outcomes(signal_id))
367+
except DecisionSignalNotFoundError as exc:
368+
raise _not_found(exc)
369+
except Exception as exc:
370+
raise _internal_error("List decision signal outcomes failed", exc)
371+
372+
373+
@router.get(
374+
"/{signal_id}/feedback",
375+
response_model=DecisionSignalFeedbackItem,
376+
responses={
377+
**AUTH_RESPONSE,
378+
404: {"model": ErrorResponse, "description": "信号不存在"},
379+
422: {"model": ErrorResponse, "description": "路径参数校验失败"},
380+
500: {"model": ErrorResponse, "description": "查询失败"},
381+
},
382+
summary="查询决策信号用户反馈",
383+
description="没有反馈时返回 feedback_value=null;信号不存在时返回 404。",
384+
operation_id="getDecisionSignalFeedback",
385+
)
386+
def get_feedback(signal_id: int) -> DecisionSignalFeedbackItem:
387+
service = DecisionSignalOutcomeService()
388+
try:
389+
return DecisionSignalFeedbackItem(**service.get_feedback(signal_id))
390+
except DecisionSignalNotFoundError as exc:
391+
raise _not_found(exc)
392+
except Exception as exc:
393+
raise _internal_error("Get decision signal feedback failed", exc)
394+
395+
396+
@router.put(
397+
"/{signal_id}/feedback",
398+
response_model=DecisionSignalFeedbackItem,
399+
responses={
400+
**AUTH_RESPONSE,
401+
400: {"model": ErrorResponse, "description": "请求字段非法"},
402+
404: {"model": ErrorResponse, "description": "信号不存在"},
403+
422: {"model": ErrorResponse, "description": "请求体或路径参数校验失败"},
404+
500: {"model": ErrorResponse, "description": "更新失败"},
405+
},
406+
summary="写入决策信号用户反馈",
407+
description="按 signal_id upsert 最新 useful/not_useful 反馈。",
408+
operation_id="putDecisionSignalFeedback",
409+
)
410+
def put_feedback(signal_id: int, request: DecisionSignalFeedbackRequest) -> DecisionSignalFeedbackItem:
411+
service = DecisionSignalOutcomeService()
412+
try:
413+
return DecisionSignalFeedbackItem(
414+
**service.put_feedback(
415+
signal_id,
416+
feedback_value=request.feedback_value,
417+
reason_code=request.reason_code,
418+
note=request.note,
419+
source=request.source,
420+
)
421+
)
422+
except DecisionSignalNotFoundError as exc:
423+
raise _not_found(exc)
424+
except ValueError as exc:
425+
raise _bad_request(exc)
426+
except Exception as exc:
427+
raise _internal_error("Put decision signal feedback failed", exc)
428+
429+
228430
@router.patch(
229431
"/{signal_id}/status",
230432
response_model=DecisionSignalItem,

api/v1/schemas/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,17 @@
109109
)
110110
from api.v1.schemas.decision_signals import (
111111
DecisionSignalCreateRequest,
112+
DecisionSignalFeedbackItem,
113+
DecisionSignalFeedbackRequest,
112114
DecisionSignalItem,
113115
DecisionSignalListResponse,
114116
DecisionSignalMutationResponse,
117+
DecisionSignalOutcomeItem,
118+
DecisionSignalOutcomeListResponse,
119+
DecisionSignalOutcomeRunRequest,
120+
DecisionSignalOutcomeRunResponse,
121+
DecisionSignalOutcomeStatsBucket,
122+
DecisionSignalOutcomeStatsResponse,
115123
DecisionSignalStatusUpdateRequest,
116124
)
117125

@@ -210,8 +218,16 @@
210218
"AlertTriggerListResponse",
211219
# decision signals
212220
"DecisionSignalCreateRequest",
221+
"DecisionSignalFeedbackItem",
222+
"DecisionSignalFeedbackRequest",
213223
"DecisionSignalItem",
214224
"DecisionSignalListResponse",
215225
"DecisionSignalMutationResponse",
226+
"DecisionSignalOutcomeItem",
227+
"DecisionSignalOutcomeListResponse",
228+
"DecisionSignalOutcomeRunRequest",
229+
"DecisionSignalOutcomeRunResponse",
230+
"DecisionSignalOutcomeStatsBucket",
231+
"DecisionSignalOutcomeStatsResponse",
216232
"DecisionSignalStatusUpdateRequest",
217233
]

api/v1/schemas/decision_signals.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
DecisionSignalPlanQuality = Literal["complete", "partial", "minimal", "unknown"]
1818
DecisionSignalHorizon = Literal["intraday", "1d", "3d", "5d", "10d", "swing", "long"]
1919
DecisionSignalMarket = Literal["cn", "hk", "us"]
20+
DecisionSignalOutcomeStatus = Literal["completed", "unable"]
21+
DecisionSignalOutcomeValue = Literal["hit", "miss", "neutral"]
22+
DecisionSignalFeedbackValue = Literal["useful", "not_useful"]
23+
DecisionSignalFeedbackSource = Literal["web", "api"]
2024

2125

2226
class DecisionSignalCreateRequest(BaseModel):
@@ -57,6 +61,110 @@ class DecisionSignalStatusUpdateRequest(BaseModel):
5761
metadata: Optional[Dict[str, Any]] = None
5862

5963

64+
class DecisionSignalOutcomeRunRequest(BaseModel):
65+
signal_id: Optional[int] = Field(None, gt=0)
66+
horizons: Optional[List[DecisionSignalHorizon]] = None
67+
force: bool = False
68+
market: Optional[DecisionSignalMarket] = None
69+
stock_code: Optional[str] = Field(None, json_schema_extra={"maxLength": 32})
70+
action: Optional[DecisionAction] = None
71+
source_type: Optional[DecisionSignalSourceType] = None
72+
status: Optional[DecisionSignalStatus] = None
73+
limit: int = Field(100, ge=1, le=500)
74+
75+
76+
class DecisionSignalOutcomeItem(BaseModel):
77+
id: int
78+
signal_id: int
79+
horizon: str
80+
engine_version: str
81+
eval_status: str
82+
outcome: Optional[str] = None
83+
direction_expected: Optional[str] = None
84+
direction_correct: Optional[bool] = None
85+
unable_reason: Optional[str] = None
86+
anchor_date: Optional[str] = None
87+
eval_window_days: Optional[int] = None
88+
start_price: Optional[float] = None
89+
end_close: Optional[float] = None
90+
max_high: Optional[float] = None
91+
min_low: Optional[float] = None
92+
stock_return_pct: Optional[float] = None
93+
action: Optional[str] = None
94+
market: Optional[str] = None
95+
market_phase: Optional[str] = None
96+
source_type: Optional[str] = None
97+
source_agent: Optional[str] = None
98+
plan_quality: Optional[str] = None
99+
data_quality_level: Optional[str] = None
100+
holding_state: str
101+
created_at: Optional[str] = None
102+
updated_at: Optional[str] = None
103+
104+
105+
class DecisionSignalOutcomeRunResponse(BaseModel):
106+
items: List[DecisionSignalOutcomeItem] = Field(default_factory=list)
107+
evaluated: int
108+
created: int
109+
updated: int
110+
skipped: int
111+
engine_version: str
112+
113+
114+
class DecisionSignalOutcomeListResponse(BaseModel):
115+
items: List[DecisionSignalOutcomeItem] = Field(default_factory=list)
116+
total: int
117+
page: int
118+
page_size: int
119+
120+
121+
class DecisionSignalOutcomeStatsBucket(BaseModel):
122+
dimension: str
123+
value: str
124+
total: int
125+
completed: int
126+
unable: int
127+
hit: int
128+
miss: int
129+
neutral: int
130+
hit_rate_pct: Optional[float] = None
131+
avg_stock_return_pct: Optional[float] = None
132+
unable_reasons: Dict[str, int] = Field(default_factory=dict)
133+
134+
135+
class DecisionSignalOutcomeStatsResponse(BaseModel):
136+
engine_version: str
137+
horizons: Optional[List[str]] = None
138+
statuses: List[str] = Field(default_factory=list)
139+
total: int
140+
completed: int
141+
unable: int
142+
hit: int
143+
miss: int
144+
neutral: int
145+
hit_rate_pct: Optional[float] = None
146+
avg_stock_return_pct: Optional[float] = None
147+
unable_reasons: Dict[str, int] = Field(default_factory=dict)
148+
breakdowns: Dict[str, List[DecisionSignalOutcomeStatsBucket]] = Field(default_factory=dict)
149+
150+
151+
class DecisionSignalFeedbackRequest(BaseModel):
152+
feedback_value: DecisionSignalFeedbackValue
153+
reason_code: Optional[str] = Field(None, json_schema_extra={"maxLength": 64})
154+
note: Optional[str] = Field(None, json_schema_extra={"maxLength": 1000})
155+
source: DecisionSignalFeedbackSource = "api"
156+
157+
158+
class DecisionSignalFeedbackItem(BaseModel):
159+
signal_id: int
160+
feedback_value: Optional[DecisionSignalFeedbackValue] = None
161+
reason_code: Optional[str] = None
162+
note: Optional[str] = None
163+
source: Optional[DecisionSignalFeedbackSource] = None
164+
created_at: Optional[str] = None
165+
updated_at: Optional[str] = None
166+
167+
60168
class DecisionSignalItem(BaseModel):
61169
id: int
62170
stock_code: str

0 commit comments

Comments
 (0)