"""Analytics-facing DB API grouped by read concerns.""" import json from datetime import datetime from app.db.altcoin_db import ( _classify_recommendation_result, _derive_execution_fields, _is_actionable_execution_status, _is_executed_trade, ) from app.db.schema import get_conn def get_screening_history(hours=24, limit=100): """获取最近 N 小时的筛选记录。""" conn = get_conn() rows = conn.execute( """ SELECT * FROM screening_log WHERE layer='细筛' AND julianday(?) - julianday(scan_time) < ? ORDER BY score DESC, scan_time DESC LIMIT ? """, (datetime.now().isoformat(), hours / 24.0, limit), ).fetchall() conn.close() return [dict(r) for r in rows] def _loads_json(value, fallback): try: if isinstance(value, str) and value.strip(): return json.loads(value) if value: return value except Exception: pass return fallback def get_observation_candidates(limit=50): """Return current coarse-screen observation candidates for the watch pool.""" conn = get_conn() try: limit = max(1, min(int(limit or 50), 200)) except Exception: limit = 50 rows = conn.execute( """ SELECT * FROM coin_state WHERE state != '过期' ORDER BY score DESC, detected_at DESC LIMIT ? """, (limit,), ).fetchall() conn.close() items = [] for row in rows: r = dict(row) detail = _loads_json(r.get("detail_json"), {}) signals = detail.get("signals") if not isinstance(signals, list): signals = [] price = float(detail.get("price") or detail.get("current_price") or 0) market_context = detail.get("market_context") if isinstance(detail.get("market_context"), dict) else {} derivatives_context = detail.get("derivatives_context") if isinstance(detail.get("derivatives_context"), dict) else {} sector_context = detail.get("sector_context") if isinstance(detail.get("sector_context"), dict) else {} observe_tier = "weak" if int(r.get("score") or 0) < 4 else "strong" reason = "粗筛观察候选,等待确认层给出当前触发和完整入场计划" items.append({ "id": f"obs:{r.get('symbol')}", "symbol": r.get("symbol"), "rec_time": r.get("detected_at"), "rec_state": r.get("state"), "rec_score": int(r.get("score") or 0), "entry_price": price, "current_price": price, "stop_loss": 0, "tp1": 0, "tp2": 0, "sector": r.get("sector") or detail.get("sector") or "", "signals": signals, "status": "active", "action_status": "观察", "execution_status": "observe", "execution_label": "观察候选", "execution_reason": reason, "display_bucket": "watch_pool", "lifecycle_state": "watching", "entry_triggered": 0, "entry_plan": { "entry_action": "观察", "entry_method": reason, "entry_price": price, "current_price": price, }, "observe_tier": observe_tier, "observe_reason": reason, "direction": detail.get("direction") or "多头启动", "market_context": market_context, "derivatives_context": derivatives_context, "sector_context": sector_context, "recommendation_result": "pending", "recommendation_result_label": "观察候选", "source": "coin_state", }) return { "items": items, "summary": { "total": len(items), "candidate_count": len(items), "source": "coin_state", "note": "初筛观察池,不计入推荐绩效", }, "has_more": False, } def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, with_meta=False): """获取推荐列表。""" conn = get_conn() version = str(version or "").strip() try: limit = max(1, min(int(limit or 50), 500)) except Exception: limit = 50 try: offset = max(0, int(offset or 0)) except Exception: offset = 0 result_where = """(status IN ('hit_tp1', 'hit_tp2', 'stopped_out') OR (COALESCE(max_pnl_pct, 0) >= 5) OR (COALESCE(pnl_pct, 0) <= -3 OR COALESCE(max_drawdown_pct, 0) <= -5))""" version_where = " AND strategy_version=?" if version else "" params = [version] if version else [] total = None summary = None version_counts = [] if decision_only: if with_meta: total = conn.execute( """ SELECT COUNT(*) FROM ( SELECT symbol FROM recommendation WHERE """ + result_where + version_where + """ GROUP BY symbol ) """, tuple(params), ).fetchone()[0] summary_row = conn.execute( """ SELECT COUNT(*) AS total, SUM(CASE WHEN status IN ('hit_tp1','hit_tp2') OR COALESCE(max_pnl_pct,0) >= 5 THEN 1 ELSE 0 END) AS success_count, SUM(CASE WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN 1 ELSE 0 END) AS failure_count, SUM(CASE WHEN status IN ('hit_tp1','hit_tp2') OR COALESCE(max_pnl_pct,0) >= 5 THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0) WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN COALESCE(pnl_pct,0) ELSE 0 END) AS total_pnl, MAX(CASE WHEN status IN ('hit_tp1','hit_tp2') OR COALESCE(max_pnl_pct,0) >= 5 THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0) WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN COALESCE(pnl_pct,0) ELSE 0 END) AS best_pnl, AVG(CASE WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN COALESCE(pnl_pct,0) END) AS avg_failure_pnl FROM ( SELECT r.* FROM recommendation r JOIN ( SELECT symbol, MAX(id) AS max_id FROM recommendation WHERE """ + result_where + version_where + """ GROUP BY symbol ) latest ON latest.max_id = r.id ) """, tuple(params), ).fetchone() summary = dict(summary_row) if summary_row else {} vc_rows = conn.execute( """ SELECT COALESCE(r.strategy_version, '') AS version, COUNT(*) AS count FROM recommendation r JOIN ( SELECT symbol, MAX(id) AS max_id FROM recommendation WHERE """ + result_where + """ GROUP BY symbol ) latest ON latest.max_id = r.id WHERE COALESCE(r.strategy_version, '') != '' GROUP BY r.strategy_version """ ).fetchall() version_counts = [{"version": row["version"], "count": row["count"]} for row in vc_rows] rows = conn.execute( """ SELECT r.*, lpc.price AS latest_cache_price, lpc.updated_at AS latest_cache_updated_at FROM recommendation r LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol JOIN ( SELECT symbol, MAX(id) AS max_id FROM recommendation WHERE """ + result_where + version_where + """ GROUP BY symbol ) latest ON latest.max_id = r.id ORDER BY r.rec_time DESC LIMIT ? OFFSET ? """, tuple(params + [limit, offset]), ).fetchall() else: where = "WHERE strategy_version=?" if version else "" if with_meta: total = conn.execute("SELECT COUNT(*) FROM recommendation " + where, tuple(params)).fetchone()[0] rows = conn.execute( """ SELECT * FROM recommendation """ + where + """ ORDER BY rec_time DESC LIMIT ? OFFSET ? """, tuple(params + [limit, offset]), ).fetchall() conn.close() result = [] for row in rows: item = dict(row) rec_result, rec_result_label = _classify_recommendation_result(item) item["recommendation_result"] = rec_result item["recommendation_result_label"] = rec_result_label _derive_execution_fields(item) result.append(item) if not with_meta: return result return { "items": result, "total": int(total or 0), "limit": limit, "offset": offset, "has_more": offset + len(result) < int(total or 0), "summary": summary or {}, "version_counts": version_counts, } def get_stats(): """获取统计数据:胜率、平均盈亏、实时收益、推荐成败概览、排行榜、净值曲线与生命周期""" conn = get_conn() all_rows = conn.execute("SELECT * FROM recommendation ORDER BY rec_time DESC").fetchall() raw_active_rows = conn.execute("SELECT * FROM recommendation WHERE status='active' AND COALESCE(display_bucket,'watch_pool') != 'history' ORDER BY rec_time DESC").fetchall() raw_active_dedup_rows = conn.execute(""" SELECT r.* FROM recommendation r JOIN ( SELECT symbol, MAX(id) AS max_id FROM recommendation WHERE status='active' AND COALESCE(display_bucket,'watch_pool') != 'history' GROUP BY symbol ) latest ON latest.max_id = r.id ORDER BY r.rec_time DESC """).fetchall() total_count = len(all_rows) raw_active_count = len(raw_active_rows) now = datetime.now() def classify_recommendation(row): result, _ = _classify_recommendation_result(dict(row)) return result def success_tier(row): max_pnl_pct = row["max_pnl_pct"] or 0 if max_pnl_pct >= 20: return "big" if max_pnl_pct >= 10: return "medium" if max_pnl_pct >= 5: return "small" return "none" def lifecycle_stage(row): action_status = row["action_status"] or "持有" result = classify_recommendation(row) if result == "success": return "已验证成功" if result == "failed": return "已验证失败" if action_status in ("衰减", "反转"): return "进入衰减" if action_status in ("可即刻买入", "等回踩"): return "等待入场" return "持仓观察" def safe_hours_between(start_text, end_dt): try: start_dt = datetime.fromisoformat(start_text) return round((end_dt - start_dt).total_seconds() / 3600, 1) except Exception: return None def compact_item(row): item = dict(row) rec_result, rec_result_label = _classify_recommendation_result(item) item["recommendation_result"] = rec_result item["recommendation_result_label"] = rec_result_label derived = _derive_execution_fields(item) hold_hours = safe_hours_between(row["rec_time"], now) last_track_delay = safe_hours_between(row["last_track_time"], now) if row["last_track_time"] else None return { "symbol": row["symbol"], "rec_time": row["rec_time"], "entry_price": row["entry_price"], "current_price": row["current_price"], "pnl_pct": row["pnl_pct"] or 0, "max_pnl_pct": row["max_pnl_pct"] or 0, "max_drawdown_pct": row["max_drawdown_pct"] or 0, "action_status": row["action_status"] or "持有", "initial_action": derived["initial_action"], "execution_status": derived["execution_status"], "execution_label": derived["execution_label"], "execution_reason": derived["execution_reason"], "recommendation_result": classify_recommendation(row), "success_tier": success_tier(row), "lifecycle_stage": lifecycle_stage(row), "hold_hours": hold_hours, "track_delay_hours": last_track_delay, "market_context": derived["market_context"], "derivatives_context": derived["derivatives_context"], "sector_context": derived["sector_context"], } active_dedup_rows = [] for row in raw_active_dedup_rows: item = dict(row) rec_result, rec_result_label = _classify_recommendation_result(item) item["recommendation_result"] = rec_result item["recommendation_result_label"] = rec_result_label derived = _derive_execution_fields(item) if _is_actionable_execution_status(derived.get("execution_status")): active_dedup_rows.append(row) active_count = len(active_dedup_rows) success_count = 0 failed_count = 0 pending_count = 0 closed_count = 0 win_count = 0 realized_count = 0 realized_pnl_sum = 0 success_tier_counts = {"small": 0, "medium": 0, "big": 0} closed_dedup_rows = conn.execute(""" SELECT r.* FROM recommendation r JOIN ( SELECT symbol, MAX(id) AS max_id FROM recommendation WHERE status IN ('hit_tp1', 'hit_tp2', 'stopped_out') GROUP BY symbol ) latest ON latest.max_id = r.id ORDER BY r.rec_time DESC """).fetchall() for row in closed_dedup_rows: status = row["status"] if status in ("hit_tp1", "hit_tp2"): success_count += 1 tier = success_tier(row) if tier in success_tier_counts: success_tier_counts[tier] += 1 elif status == "stopped_out": failed_count += 1 else: pending_count += 1 if status in ("hit_tp1", "hit_tp2", "stopped_out", "expired"): closed_count += 1 if (row["pnl_pct"] or 0) > 0: win_count += 1 realized_dedup = [r for r in closed_dedup_rows if r["status"] in ("hit_tp1", "hit_tp2", "stopped_out")] realized_count = len(realized_dedup) realized_pnl_sum = sum((r["pnl_pct"] or 0) for r in realized_dedup) exec_buy_now = 0 exec_wait = 0 exec_observe = 0 for row in raw_active_dedup_rows: item = dict(row) rec_result, rec_result_label = _classify_recommendation_result(item) item["recommendation_result"] = rec_result item["recommendation_result_label"] = rec_result_label derived = _derive_execution_fields(item) es = derived.get("execution_status", "") if es == "buy_now": exec_buy_now += 1 elif es == "wait_pullback": exec_wait += 1 elif es == "observe": exec_observe += 1 executed_active_dedup_rows = [r for r in active_dedup_rows if _is_executed_trade(dict(r))] held_rows = executed_active_dedup_rows held_count = len(held_rows) held_pnl_avg = round(sum((r["pnl_pct"] or 0) for r in held_rows) / held_count, 2) if held_count else 0 held_win_count = sum(1 for r in held_rows if (r["pnl_pct"] or 0) > 0) held_win_rate = round(held_win_count / held_count * 100, 1) if held_count else 0 active_pnl_sum = round(sum((r["pnl_pct"] or 0) for r in executed_active_dedup_rows), 2) active_avg_pnl = round(active_pnl_sum / len(executed_active_dedup_rows), 2) if executed_active_dedup_rows else 0 active_max_pnl = round(max([(r["pnl_pct"] or 0) for r in executed_active_dedup_rows], default=0), 2) active_min_pnl = round(min([(r["pnl_pct"] or 0) for r in executed_active_dedup_rows], default=0), 2) active_success_count = sum(1 for r in executed_active_dedup_rows if classify_recommendation(r) == "success") active_failed_count = sum(1 for r in executed_active_dedup_rows if classify_recommendation(r) == "failed") active_pending_count = sum(1 for r in executed_active_dedup_rows if classify_recommendation(r) == "pending") top_gainer = compact_item(max(executed_active_dedup_rows, key=lambda r: r["pnl_pct"] or -9999)) if executed_active_dedup_rows else None top_loser = compact_item(min(executed_active_dedup_rows, key=lambda r: r["pnl_pct"] or 9999)) if executed_active_dedup_rows else None biggest_explosion = compact_item(max(executed_active_dedup_rows, key=lambda r: r["max_pnl_pct"] or -9999)) if executed_active_dedup_rows else None highest_risk = compact_item(min(executed_active_dedup_rows, key=lambda r: r["max_drawdown_pct"] or 9999)) if executed_active_dedup_rows else None lifecycle_items = [compact_item(r) for r in executed_active_dedup_rows] longest_holding = max(lifecycle_items, key=lambda x: x.get("hold_hours") or -1) if lifecycle_items else None fastest_winner_candidates = [x for x in lifecycle_items if x.get("recommendation_result") == "success"] fastest_winner = min(fastest_winner_candidates, key=lambda x: x.get("hold_hours") or 999999) if fastest_winner_candidates else None decay_candidates = [x for x in lifecycle_items if x.get("lifecycle_stage") == "进入衰减"] decay_watch = decay_candidates[0] if decay_candidates else None points_24h = [] rows_24h = conn.execute(""" SELECT substr(track_time, 1, 13) || ':00:00' AS bucket, AVG(pnl_pct) AS avg_pnl, COUNT(*) AS sample_count FROM price_tracking WHERE julianday(?) - julianday(track_time) <= 1.0 GROUP BY bucket ORDER BY bucket ASC """, (now.isoformat(),)).fetchall() for row in rows_24h: points_24h.append({ "time": row["bucket"], "avg_pnl": round(row["avg_pnl"] or 0, 2), "sample_count": row["sample_count"] or 0, }) points_7d = [] rows_7d = conn.execute(""" SELECT substr(track_time, 1, 10) AS bucket, AVG(pnl_pct) AS avg_pnl, COUNT(*) AS sample_count FROM price_tracking WHERE julianday(?) - julianday(track_time) <= 7.0 GROUP BY bucket ORDER BY bucket ASC """, (now.isoformat(),)).fetchall() for row in rows_7d: points_7d.append({ "time": row["bucket"], "avg_pnl": round(row["avg_pnl"] or 0, 2), "sample_count": row["sample_count"] or 0, }) recommendation_success_rate = round(success_count / (success_count + failed_count) * 100, 1) if (success_count + failed_count) else 0 avg_pnl_pct = round(realized_pnl_sum / realized_count, 2) if realized_count else 0 actionable_contexts = [] for row in active_dedup_rows: derived = _derive_execution_fields(dict(row)) actionable_contexts.append({ "market": derived.get("market_context") or {}, "derivatives": derived.get("derivatives_context") or {}, "sector": derived.get("sector_context") or {}, }) def avg_from_context(group_key, field): values = [] for ctx in actionable_contexts: value = (ctx.get(group_key) or {}).get(field) if isinstance(value, (int, float)): values.append(float(value)) if not values: return 0 avg = sum(values) / len(values) if abs(avg) < 0.01: return round(avg, 3) return round(avg, 1) hot_sector_counter = {} for ctx in actionable_contexts: sector_ctx = ctx.get("sector") or {} for sector in sector_ctx.get("hot_sectors") or []: hot_sector_counter[sector] = hot_sector_counter.get(sector, 0) + 1 market_context_overview = { "actionable_sample_count": len(actionable_contexts), "avg_turnover_acceleration_1h": avg_from_context("market", "turnover_acceleration_1h"), "avg_turnover_acceleration_4h": avg_from_context("market", "turnover_acceleration_4h"), "avg_volume_24h": avg_from_context("market", "volume_24h"), "avg_funding_rate": avg_from_context("derivatives", "funding_rate"), "avg_top_trader_long_pct": avg_from_context("derivatives", "top_trader_long_pct"), "avg_top_trader_long_short_ratio": avg_from_context("derivatives", "top_trader_long_short_ratio"), "hot_sector_count": len(hot_sector_counter), "top_hot_sectors": [ {"sector": sector, "count": count} for sector, count in sorted(hot_sector_counter.items(), key=lambda item: (-item[1], item[0]))[:5] ], } conn.close() return { "total_recommendations": total_count, "active_count": active_count, "raw_active_count": raw_active_count, "closed_count": closed_count, "win_count": win_count, "win_rate": round(win_count / closed_count * 100, 1) if closed_count else 0, "avg_pnl_pct": avg_pnl_pct, "success_count": success_count, "failed_count": failed_count, "pending_count": pending_count, "recommendation_success_rate": recommendation_success_rate, "active_pnl_sum": active_pnl_sum, "active_avg_pnl": active_avg_pnl, "active_max_pnl": active_max_pnl, "active_min_pnl": active_min_pnl, "active_success_count": active_success_count, "active_failed_count": active_failed_count, "active_pending_count": active_pending_count, "live_overview": { "actionable_count": active_count, "executed_trade_count": len(executed_active_dedup_rows), "executed_pnl_sum": active_pnl_sum, "executed_avg_pnl": active_avg_pnl, "actionable_pnl_sum": active_pnl_sum, "actionable_avg_pnl": active_avg_pnl, "buy_now_count": exec_buy_now, "wait_pullback_count": exec_wait, "observe_count": exec_observe, "held_count": held_count, "held_pnl_avg": held_pnl_avg, "held_win_rate": held_win_rate, "actionable_success_count": active_success_count, "actionable_failed_count": active_failed_count, "actionable_pending_count": active_pending_count, "raw_active_count": raw_active_count, }, "history_overview": { "success_count": success_count, "failed_count": failed_count, "recommendation_success_rate": recommendation_success_rate, "avg_pnl_pct": avg_pnl_pct, "realized_count": realized_count, }, "market_context_overview": market_context_overview, "success_tier_counts": success_tier_counts, "leaderboard": { "top_gainer": top_gainer, "top_loser": top_loser, "biggest_explosion": biggest_explosion, "highest_risk": highest_risk, }, "equity_curve": { "last_24h": points_24h, "last_7d": points_7d, }, "lifecycle_summary": { "longest_holding": longest_holding, "fastest_winner": fastest_winner, "decay_watch": decay_watch, }, "result_definition": { "success": "仅统计实际命中止盈的推荐:status=hit_tp1 或 hit_tp2", "failed": "仅统计实际触发止损的推荐:status=stopped_out", "pending": "其余样本仅作为未兑现/观察中处理,不在顶部历史统计单独展示", "avg_pnl_pct": "历史均盈亏仅基于真实兑现样本计算:hit_tp1 / hit_tp2 / stopped_out", "live_pnl": "实时收益只统计已经执行/触发入场的交易;等回踩计划和观察信号不纳入收益" }, "success_tier_definition": { "small": "小成功:最大涨幅 5%~10%", "medium": "中成功:最大涨幅 10%~20%", "big": "大成功:最大涨幅 >=20%" }, "lifecycle_definition": { "hold_hours": "从推荐发出到当前的持续小时数", "track_delay_hours": "距离最近一次价格跟踪的延迟小时数", "lifecycle_stage": "等待入场 / 持仓观察 / 进入衰减 / 已验证成功 / 已验证失败" }, } def get_review_stats(conn_provider=None, iteration_logs_getter=None, iteration_summary_getter=None): """获取复盘统计概览。""" from app.db.review_queries import get_strategy_iteration_logs, get_strategy_iteration_summary conn_factory = conn_provider or get_conn logs_getter = iteration_logs_getter or get_strategy_iteration_logs summary_getter = iteration_summary_getter or get_strategy_iteration_summary conn = conn_factory() revision_started_at = "" try: from app.config.config_loader import get_meta meta = get_meta() or {} revision_started_at = (meta.get("strategy_revision_started_at") or "").strip() except Exception: revision_started_at = "" reviews = conn.execute("SELECT * FROM review_log ORDER BY review_time DESC").fetchall() missed = conn.execute("SELECT * FROM missed_explosions ORDER BY detect_time DESC LIMIT 20").fetchall() signals = conn.execute("SELECT * FROM signal_performance ORDER BY hit_rate DESC").fetchall() conn.close() return { "reviews": [dict(r) for r in reviews], "signal_performance": [dict(s) for s in signals], "missed_explosions": [dict(m) for m in missed], "iteration_logs": logs_getter(limit=30), "iteration_summary": summary_getter(days=30), "strategy_revision_started_at": revision_started_at, } def get_cron_run_logs(limit=50, job_name=None): """获取 cron 运行日志列表。""" conn = get_conn() sql = """ SELECT * FROM cron_run_log {where_clause} ORDER BY started_at DESC, id DESC LIMIT ? """ params = [] where_clause = "" if job_name: where_clause = "WHERE job_name = ?" params.append(job_name) params.append(limit) rows = conn.execute(sql.format(where_clause=where_clause), tuple(params)).fetchall() conn.close() result = [] for row in rows: item = dict(row) try: item["summary_json"] = json.loads(item.get("summary_json") or "{}") except Exception: item["summary_json"] = {} result.append(item) return result def get_cron_run_summary(hours=24): """获取 cron 运行汇总统计。""" conn = get_conn() rows = conn.execute( """ SELECT * FROM cron_run_log WHERE julianday(?) - julianday(started_at) <= ? ORDER BY started_at DESC, id DESC """, (datetime.now().isoformat(), hours / 24.0), ).fetchall() conn.close() logs = [] job_stats = {} total_runs = 0 success_runs = 0 error_runs = 0 total_duration = 0 for row in rows: item = dict(row) try: item["summary_json"] = json.loads(item.get("summary_json") or "{}") except Exception: item["summary_json"] = {} logs.append(item) total_runs += 1 total_duration += item.get("duration_ms") or 0 if item.get("run_status") == "success": success_runs += 1 else: error_runs += 1 job = item.get("job_name") or "unknown" stat = job_stats.setdefault( job, { "job_name": job, "runs": 0, "success_runs": 0, "error_runs": 0, "avg_duration_ms": 0, "last_status": "", "last_result_status": "", "last_started_at": "", "last_finished_at": "", "last_error_message": "", }, ) stat["runs"] += 1 if item.get("run_status") == "success": stat["success_runs"] += 1 else: stat["error_runs"] += 1 stat["avg_duration_ms"] += item.get("duration_ms") or 0 if not stat["last_started_at"]: stat["last_status"] = item.get("run_status", "") stat["last_result_status"] = item.get("result_status", "") stat["last_started_at"] = item.get("started_at", "") stat["last_finished_at"] = item.get("finished_at", "") stat["last_error_message"] = item.get("error_message", "") for stat in job_stats.values(): stat["success_rate"] = round(stat["success_runs"] / stat["runs"] * 100, 1) if stat["runs"] else 0 stat["avg_duration_ms"] = round(stat["avg_duration_ms"] / stat["runs"]) if stat["runs"] else 0 overall = { "hours": hours, "total_runs": total_runs, "success_runs": success_runs, "error_runs": error_runs, "success_rate": round(success_runs / total_runs * 100, 1) if total_runs else 0, "avg_duration_ms": round(total_duration / total_runs) if total_runs else 0, } return { "overall": overall, "job_stats": sorted(job_stats.values(), key=lambda x: x["job_name"]), "recent_logs": logs[:20], } __all__ = [ "get_all_recommendations", "get_observation_candidates", "get_cron_run_logs", "get_cron_run_summary", "get_review_stats", "get_screening_history", "get_stats", ]