|
4 | 4 | from __future__ import annotations |
5 | 5 |
|
6 | 6 | import logging |
7 | | -from typing import Optional |
| 7 | +from typing import List, Optional |
8 | 8 |
|
9 | 9 | from fastapi import APIRouter, HTTPException, Query, Security |
10 | 10 | from fastapi.security import APIKeyCookie |
11 | 11 |
|
12 | 12 | from api.v1.schemas.common import ErrorResponse |
13 | 13 | from api.v1.schemas.decision_signals import ( |
14 | 14 | DecisionSignalCreateRequest, |
| 15 | + DecisionSignalFeedbackItem, |
| 16 | + DecisionSignalFeedbackRequest, |
15 | 17 | DecisionSignalItem, |
16 | 18 | DecisionSignalListResponse, |
17 | 19 | DecisionSignalMutationResponse, |
| 20 | + DecisionSignalOutcomeListResponse, |
| 21 | + DecisionSignalOutcomeRunRequest, |
| 22 | + DecisionSignalOutcomeRunResponse, |
| 23 | + DecisionSignalOutcomeStatsResponse, |
18 | 24 | DecisionSignalStatusUpdateRequest, |
19 | 25 | ) |
20 | 26 | from src.auth import COOKIE_NAME |
|
23 | 29 | DecisionSignalService, |
24 | 30 | DecisionSignalStorageError, |
25 | 31 | ) |
| 32 | +from src.services.decision_signal_outcome_service import DecisionSignalOutcomeService |
26 | 33 |
|
27 | 34 |
|
28 | 35 | logger = logging.getLogger(__name__) |
@@ -165,6 +172,121 @@ def list_signals( |
165 | 172 | raise _internal_error("List decision signals failed", exc) |
166 | 173 |
|
167 | 174 |
|
| 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 | + |
168 | 290 | @router.get( |
169 | 291 | "/latest/{stock_code}", |
170 | 292 | response_model=DecisionSignalListResponse, |
@@ -225,6 +347,86 @@ def get_signal(signal_id: int) -> DecisionSignalItem: |
225 | 347 | raise _internal_error("Get decision signal failed", exc) |
226 | 348 |
|
227 | 349 |
|
| 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 | + |
228 | 430 | @router.patch( |
229 | 431 | "/{signal_id}/status", |
230 | 432 | response_model=DecisionSignalItem, |
|
0 commit comments