diff --git a/DEVLOG.md b/DEVLOG.md index 4caf340..ea90030 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -48,10 +48,13 @@ app/services/strategy_advisor.py(新增);app/services/strategy_service.py 桥机 factorevaluation 上收盘后 make deploy(源码打进镜像),make test 见 ALL SUITES PASS。不动 .env,不需要迁移。上线即全自动(拍板③),随时可在参数页把 PMS_AUTO_STRATEGY_ENABLED 或单独把接力 PMS_AUTO_HANDOFF_ENABLED 关掉,即时生效。 **真机判收** -未判收。开发容器全量单测 ALL SUITES PASS(553 例)。判收建议:部署当天先 curl -X POST 'http://127.0.0.1:38100/api/ops/strategy-attach-scan?dry_run=true' 看它想挂什么、排除原因对不对(尤其 unknown_states 是否为空);次日 09:40 后看 beat 日志 strategy_attach 一跳的 attached/handoffs/paused,页面策略列表出现「自动挂载: 」开头的条目、台账出现 ATTACH 留痕;挂出的网格由 strategy_runner 正常逐档发单、每笔过闸。 +部分判收(2026-08-25 盘中)。桥机 make deploy + make test 见 ALL SUITES PASS;dry-run 试算真机四票读数全部合理:688802.SH 想挂网格但「按投入比例折出 47,250 元买不起一手」被挡(科创板高价票,判定正确);002179.SZ 有在途指令主动让路;其余两票无信号静默跳过;unknown_states 为空(子串归类修对了)。**尚未判收的**:真挂一条(等在途清了重扫或次日 09:40)、边三/边四真机走一遍、strategy_runner 对自动网格的逐档发单。 + +**当日盘中补两处(第二次交付)** +一,科创板一手口径:dry-run 里 688802.SH 暴露全库按 100 股一手,而 688/689 最小申报 200 股——自动网格若生成 per_lot=100 会被券商拒单。advisor 补 lot_of()(688/689→200),买得起一手与 per_lot 下限都按它算(200 仍是 100 的整数倍,runner 的取整不会磨掉)。runner 侧卖出腿对科创板的整百取整仍不完美,见欠账。二,判分读数脚本 scripts/report_strategy_score.py 交付:四段只读统计(样本盘点 / 网格差价按策略内均价配对 / 止盈「卖点之后又跌多少=保住的钱」/ 挂上 vs 名额挡下的对照组涨跌),样本不足三条只报数不下结论,连不上库或取不到现价都说人话。batch17 扩到 36 例,总数 555。 **还欠着什么** -一,判分闭环的读数脚本(report_strategy_score.py:自动挂载 vs 人工挂载 vs 不挂的对照统计)本次未写——留痕字段(ATTACH/HANDOFF/NOTE 台账 + note 约定)已齐,脚本只读统计,等跑出两周数据再交,先欠着。二,探测顺带发现候选池计划日停在 2026-08-21(探测日 08-25,日龄已 2 个交易日以上),疑似上游桥的日更链又停了,且 PMS_PLAN_STALE_TDAYS 可能被调宽过——与本特性无关,单独排查。三,热度阈值 0.80 与日上限 2 都是首版拍的,判分数据出来后用读数回调。 +一,判分脚本已交,但**有意义的读数要等两周样本**——现在跑是空跑冒烟,别拿首周数字调阈值(热度 0.80 / 日上限 2 等数据回调)。二,探测顺带发现候选池计划日停在 2026-08-21(探测日 08-25,日龄已 2 个交易日以上),疑似上游桥的日更链又停了,且 PMS_PLAN_STALE_TDAYS 可能被调宽过——与本特性无关,单独排查。三,科创板卖出腿:strategy_runner 的 _round_lot 全库按 100 股取整,688 票的止盈卖出若折出 100 股会被拒单(低于 200 股只能一次性清)——影响面小(当前仅一只 688 且它连网格都挂不上),排查后单独修。 --- diff --git a/app/services/strategy_advisor.py b/app/services/strategy_advisor.py index ee3fe61..3e4539f 100644 --- a/app/services/strategy_advisor.py +++ b/app/services/strategy_advisor.py @@ -67,6 +67,18 @@ HEAT_MAX_AGE_DAYS = 4 # 热度表末日落后超此自然日 → 热 _RULE_OF_TYPE = {"GRID": R_GRID, "TRAIL": R_TRAIL} +def lot_of(ts_code) -> int: + """最小申报单位: 科创板 (688/689) 200 股起, 其余 100。 + + 2026-08-25 实盘 dry-run 发现: 持仓里有 688802.SH, 全库其他地方一律按 100 股一手 + (strategy_runner._round_lot / sizer), 科创板 200 股起买这条只在这里兜 —— 自动网格 + 的 per_lot 若生成 100, runner 发单会被券商按无效数量拒掉。买得起一手的判断与 + per_lot 下限都按这个数; 200 也是 100 的整数倍, 不会被 _round_lot 磨掉。 + runner 侧卖出腿的整百取整对科创板仍不完美 (见 DEVLOG 欠账), 不在本模块修。""" + s = str(ts_code or "") + return 200 if s.startswith(("688", "689")) else 100 + + def _f(v, d=None): try: return float(v) @@ -86,19 +98,22 @@ def classify_accum(state) -> str: return CLS_UNKNOWN -def grid_params(*, price, support, pressure, band, step_pct, cap_room, cap_ratio): +def grid_params(*, price, support, pressure, band, step_pct, cap_room, cap_ratio, + lot=100): """网格参数自动生成 (方案附录二)。返回 (params, why); params=None 时 why 说明放弃原因。 区间优先锚支撑压力, 锚不住退百分比带; 任何一步不满足 0<下界<中枢<上界 就放弃不硬凑。 + lot=最小申报单位 (科创板 200, 见 lot_of) —— 买得起一手与 per_lot 下限都按它算。 """ p = _f(price, 0.0) if not p or p <= 0: return None, "取不到实时价, 网格区间无从定" + lot = int(lot or 100) cap = _f(cap_room, 0.0) or 0.0 max_capital = round(cap * _f(cap_ratio, 0.5), 2) - if max_capital < p * 100: + if max_capital < p * lot: return None, (f"单股上限余量 {cap:,.0f} 元按投入比例折出 {max_capital:,.0f} 元, " - f"买不起一手, 不挂") + f"买不起一手({lot}股), 不挂") r, s = _f(pressure, 0.0) or 0.0, _f(support, 0.0) or 0.0 upper = round(r * 1.01, 3) if r > p else round(p * (1 + band), 3) lower = round(s * 0.99, 3) if 0 < s < p else round(p * (1 - band), 3) @@ -108,8 +123,8 @@ def grid_params(*, price, support, pressure, band, step_pct, cap_room, cap_ratio step = max(0.005, _f(step_pct, 0.02)) n_below = max(1, int((p - lower) / (p * step))) per_lot = int(max_capital / n_below / p / 100) * 100 - if per_lot < 100: - per_lot = 100 + if per_lot < lot: + per_lot = lot return ({"center": p, "lower": lower, "upper": upper, "step_pct": step, "per_lot": per_lot, "max_capital": max_capital}, "") @@ -544,7 +559,8 @@ def _attach_one(edge, why, p, a, hv, prm, view, black, live_codes, optout, gp, gwhy = grid_params(price=(p.get("price") if p.get("price_ok") else None), support=p.get("support_ref"), pressure=p.get("pressure_ref"), band=prm["grid_band"], step_pct=prm["grid_step_pct"], - cap_room=cap_room, cap_ratio=prm["grid_cap_ratio"]) + cap_room=cap_room, cap_ratio=prm["grid_cap_ratio"], + lot=lot_of(code)) if not gp: out["blocked"].append({"ts_code": code, "edge": edge, "why": gwhy}) if not dry_run: diff --git a/scripts/report_strategy_score.py b/scripts/report_strategy_score.py new file mode 100644 index 0000000..9e3cdd0 --- /dev/null +++ b/scripts/report_strategy_score.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +""" +策略判分读数 · 只读统计 (STRATEGY_AUTO_ATTACH_PLAN.md 判分闭环, 拍板⑤) +====================================================================== +只读, 不写任何表, 随时可跑。回答一个问题: **自动挂载的策略到底有没有挣到钱**。 +三张对照 + 一张盘点: + + 零. 样本盘点 自动策略共几条 (在跑/撤下/接力), 台账留痕几条 —— 先看样本够不够 + 一. 网格差价 每条自动网格: 买了多少/卖了多少/已实现差价/在手浮动 + 二. 止盈判分 每条自动止盈: 触发过没有; 触发的 = 卖点之后又跌了多少 (保住的钱, + 负数=卖飞); 没触发的 = 高水位与武装状态 + 三. 挂/未挂对照 窗口内 ATTACH 成功的票 vs 想挂但被**每日名额**挡下的票, + 各自从决定日到现在的涨跌 —— 名额挡下的是天然对照组 + (资格全同, 只差没轮上); 排除项挡下的 (冻结/买不起/黑名单) + 结构上就不同, 不进对照。 + +运行 (桥机 factorevaluation): + docker compose run --rm pms-web python scripts/report_strategy_score.py [--days 30] + +读数纪律: + - 差价用**策略内均价配对** (卖出收益 = 卖出额 − 卖出股数×买入均价), 不做逐笔 FIFO —— + 月度读数要的是方向和量级, 不是会计账; 逐笔账在 pms_lot 里, 要精算去查它。 + - 现价来自分钟线缓存 (market.get_prices), 收盘后或缓存过期时取不到 → 相应栏目 + 明说「无现价」, 绝不拿旧价冒充。 + - 样本少于三条的段落只列数不下结论 —— 判分要等数据, 不等数据的判分是编故事。 +""" +import argparse +import json +import os +import sys +from datetime import datetime, timedelta + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.db.session import fetch_all # noqa: E402 +from app.repo import pms_repo # noqa: E402 +from app.services.strategy_advisor import ( # noqa: E402 + NOTE_AUTO, NOTE_HANDOFF, MARK_HANDOFF_OUT) + +MIN_SAMPLE = 3 # 少于这个数只报数不下结论 + + +def _f(v, d=0.0): + try: + return float(v) + except (TypeError, ValueError): + return d + + +def _pct(a, b): + """b 相对 a 的涨跌; 任一边取不到返回 None。""" + a, b = _f(a), _f(b) + return (b / a - 1.0) if a > 0 and b > 0 else None + + +def _fmt_pct(x): + return f"{x:+.2%}" if x is not None else "—" + + +def auto_strategies(): + """全部自动策略 (含已归档已撤销), 按 note 前缀识别。""" + rows = pms_repo.list_strategies(limit=1000, include_archived=True) + return [r for r in rows if str(r.get("note") or "").startswith("自动挂载")] + + +def fills_of(strategy_id): + """该策略名下有成交的指令 (exec_qty>0, 不论终态) → 买卖两侧合计。单表。""" + rows = fetch_all( + "SELECT side, exec_qty, exec_avg_price FROM pms_instruction " + "WHERE origin_type = 'strategy' AND origin_id = :sid AND exec_qty > 0", + {"sid": strategy_id}) + agg = {"buy": {"qty": 0, "amt": 0.0, "n": 0}, "sell": {"qty": 0, "amt": 0.0, "n": 0}} + for r in rows: + side = str(r.get("side") or "").lower() + if side not in agg: + continue + q, px = int(r.get("exec_qty") or 0), _f(r.get("exec_avg_price")) + agg[side]["qty"] += q + agg[side]["amt"] += q * px + agg[side]["n"] += 1 + return agg + + +def ledger_window(days): + """窗口内 advisor 留的 ATTACH 痕 (PASS=挂了 / NOTE+名额已满=对照)。单表。""" + since = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S") + rows = fetch_all( + "SELECT ts_code, decided_at, verdict, price_at, reason, ref_id " + "FROM pms_action_ledger WHERE action = 'ATTACH' AND decided_at >= :since", + {"since": since}) + attached, control = [], [] + for r in rows: + if str(r.get("verdict")) == "PASS": + attached.append(r) + elif "名额已满" in str(r.get("reason") or ""): + control.append(r) + return attached, control + + +def current_prices(codes): + try: + from app.services import market + return market.get_prices(sorted(set(c for c in codes if c))) + except Exception as e: # noqa: BLE001 —— redis 不在 (本地跑) 也要能出报告 + print(f" (现价不可用: {type(e).__name__}: {e} —— 浮动与保住栏按无现价处理)") + return {} + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--days", type=int, default=30, help="对照窗口 (自然日, 默认 30)") + args = ap.parse_args() + + print("=" * 66) + print("策略判分读数 %s (只读; 窗口 %d 天)" + % (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), args.days)) + print("=" * 66) + + try: + strats = auto_strategies() + except Exception as e: # noqa: BLE001 —— 连不上库也要说人话, 不甩栈 + print(f"\n✗ 策略表读取失败: {type(e).__name__}: {e}\n" + f" (本脚本要在桥机容器里跑: docker compose run --rm pms-web " + f"python scripts/report_strategy_score.py)") + sys.exit(1) + grids = [s for s in strats if str(s.get("type")).upper() == "GRID"] + trails = [s for s in strats if str(s.get("type")).upper() == "TRAIL"] + handoff_in = [s for s in strats if str(s.get("note") or "").startswith(NOTE_HANDOFF)] + handoff_out = [s for s in strats if MARK_HANDOFF_OUT in str(s.get("note") or "")] + codes = {s.get("ts_code") for s in strats} + + # ---- 零、样本盘点 ---- + print("\n【零】样本盘点") + if not strats: + print(" 还没有任何自动挂载的策略 —— 判分从第一条挂出才开始, 本报告先当空跑冒烟。") + else: + by_status = {} + for s in strats: + by_status[s.get("status")] = by_status.get(s.get("status"), 0) + 1 + print(f" 自动策略共 {len(strats)} 条 (网格 {len(grids)} / 止盈 {len(trails)}), " + f"状态: " + ", ".join(f"{k} {v}" for k, v in sorted(by_status.items()))) + print(f" 接力: 换挂出的止盈 {len(handoff_in)} 条, 被接力撤下的网格 {len(handoff_out)} 条") + px = current_prices(codes) if strats else {} + + # ---- 一、网格差价 ---- + print("\n【一】网格差价 (含已撤下的; 差价=卖出额−卖出股数×买入均价, 均价配对口径)") + if not grids: + print(" 无样本。") + tot_real, tot_float, n_traded = 0.0, 0.0, 0 + for s in grids: + agg = fills_of(s["strategy_id"]) + b, sl = agg["buy"], agg["sell"] + code, sid = s.get("ts_code"), s["strategy_id"] + if b["qty"] == 0 and sl["qty"] == 0: + print(f" {code} {sid} [{s.get('status')}]: 还没成交过一笔") + continue + n_traded += 1 + buy_avg = b["amt"] / b["qty"] if b["qty"] else 0.0 + realized = sl["amt"] - sl["qty"] * buy_avg if sl["qty"] else 0.0 + net_qty = b["qty"] - sl["qty"] + cur = _f(px.get(code)) + floating = net_qty * (cur - buy_avg) if (net_qty > 0 and cur > 0 and buy_avg > 0) else None + tot_real += realized + if floating is not None: + tot_float += floating + print(f" {code} {sid} [{s.get('status')}]: " + f"买 {b['qty']} 股/{b['amt']:,.0f} 元 (均 {buy_avg:.3f}), " + f"卖 {sl['qty']} 股/{sl['amt']:,.0f} 元, 已实现 {realized:+,.0f} 元, " + + (f"在手 {net_qty} 股浮动 {floating:+,.0f} 元" if floating is not None + else f"在手 {net_qty} 股 (无现价, 浮动不算)")) + if grids: + print(f" 合计: 有成交的 {n_traded}/{len(grids)} 条, " + f"已实现 {tot_real:+,.0f} 元, 可算浮动 {tot_float:+,.0f} 元" + + ("" if n_traded >= MIN_SAMPLE else f" (样本不足 {MIN_SAMPLE} 条, 只报数不下结论)")) + + # ---- 二、止盈判分 ---- + print("\n【二】止盈判分 (触发的看「卖点之后又跌了多少」= 保住的钱, 负数=卖飞)") + if not trails: + print(" 无样本。") + n_fired, tot_saved = 0, 0.0 + for s in trails: + agg = fills_of(s["strategy_id"]) + sl = agg["sell"] + code, sid = s.get("ts_code"), s["strategy_id"] + st = s.get("state") or {} + if sl["qty"] > 0: + n_fired += 1 + sell_avg = sl["amt"] / sl["qty"] + cur = _f(px.get(code)) + saved = (sell_avg - cur) * sl["qty"] if cur > 0 else None + if saved is not None: + tot_saved += saved + print(f" {code} {sid} [{s.get('status')}]: 触发过, " + f"卖 {sl['qty']} 股 (均 {sell_avg:.3f}), " + + (f"现价 {cur:.3f} → 保住 {saved:+,.0f} 元" if saved is not None + else "无现价, 保住的钱先不算")) + else: + hw, armed = _f(st.get("high_water")), bool(st.get("armed")) + print(f" {code} {sid} [{s.get('status')}]: 未触发, " + f"高水位 {hw or '—'}, {'已武装' if armed else '未武装 (垫子还没到武装线)'}") + if trails and n_fired: + print(f" 合计: 触发 {n_fired}/{len(trails)} 条, 可算的保住 {tot_saved:+,.0f} 元" + + ("" if n_fired >= MIN_SAMPLE else f" (样本不足 {MIN_SAMPLE} 条, 只报数不下结论)")) + + # ---- 三、挂/未挂对照 ---- + print(f"\n【三】挂/未挂对照 (近 {args.days} 天; 对照组=资格全同只是没轮上名额的票)") + try: + attached, control = ledger_window(args.days) + except Exception as e: # noqa: BLE001 + print(f" ✗ 台账读取失败: {type(e).__name__}: {e}") + attached, control = [], [] + px3 = current_prices([r.get("ts_code") for r in attached + control]) + + def _group(rows, label): + moves = [] + for r in rows: + chg = _pct(r.get("price_at"), px3.get(r.get("ts_code"))) + d = str(r.get("decided_at"))[:10] + print(f" {r.get('ts_code')} {d} 决定价 {_f(r.get('price_at')):.3f} → " + f"{_fmt_pct(chg)}") + if chg is not None: + moves.append(chg) + if moves: + avg = sum(moves) / len(moves) + print(f" {label}: {len(rows)} 次决定, 可算 {len(moves)} 次, 平均 {_fmt_pct(avg)}") + else: + print(f" {label}: {len(rows)} 次决定, 无一次可算 (决定价或现价缺失)") + return moves + + if not attached and not control: + print(" 窗口内没有 ATTACH 留痕 —— 系统还没挂过, 或窗口太短。") + else: + print(" 挂上的:") + m1 = _group(attached, "挂上的") + print(" 名额挡下的 (对照):") + m2 = _group(control, "对照组") + if len(m1) >= MIN_SAMPLE and len(m2) >= MIN_SAMPLE: + print(f" 对照差: 挂上的平均 {_fmt_pct(sum(m1) / len(m1))} vs " + f"没轮上的 {_fmt_pct(sum(m2) / len(m2))} —— 差值为正说明排序在挑对票") + else: + print(f" 两组样本 ({len(m1)}/{len(m2)}) 不足 {MIN_SAMPLE}, 本期不下结论。") + + print("\n判分口径提醒: 本报告只陈述事实读数, 阈值调整 (热度 0.80 / 日上限 2) " + "等两周以上样本再议; 精算逐笔账查 pms_lot 与台账。") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_tests.py b/scripts/run_tests.py index f9fe600..1ee1ac8 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -27,10 +27,11 @@ 在途与配额文案拆分; 减仓不掐策略腿 (12 例) test_batch16_units.py 软归档 archived_at: 单表守卫/只归终态/列表默认排除 (5 例) test_batch17_units.py 策略自动挂载: 定性归类(子串+保守优先)/网格参数生成/ - 连边矩阵/note 约定与冷却推导/接力判定/clear_buypause/ - 编排冒烟(dry_run 滴水不写/名额/边三/边四全链) (34 例) + 科创板一手200/连边矩阵/note 约定与冷却推导/接力判定/ + clear_buypause/编排冒烟(dry_run 滴水不写/名额/边三/ + 边四全链)/判分脚本聚合与对照分组 (36 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例) - 共 553 例 + 共 555 例 任一子集失败即整体失败 (退出码 1)。 """ import os diff --git a/scripts/test_batch17_units.py b/scripts/test_batch17_units.py index 3df1516..a09a4cf 100644 --- a/scripts/test_batch17_units.py +++ b/scripts/test_batch17_units.py @@ -142,6 +142,23 @@ def _(): assert gp and gp["step_pct"] == 0.005, gp +@case("[网格] 科创板一手=200: 买得起判断与 per_lot 下限都按 200 (dry-run 实盘发现)") +def _(): + assert adv.lot_of("688802.SH") == 200 and adv.lot_of("689009.SH") == 200 + assert adv.lot_of("600000.SH") == 100 and adv.lot_of("300750.SZ") == 100 + # 预算够 100 股不够 200 股: 主板挂得出, 科创板必须放弃并把 200 写进理由 + gp, why = adv.grid_params(price=10.0, support=9.0, pressure=11.0, band=0.08, + step_pct=0.02, cap_room=3000.0, cap_ratio=0.5, lot=200) + assert gp is None and "一手(200股)" in why, (gp, why) + gp, _ = adv.grid_params(price=10.0, support=9.0, pressure=11.0, band=0.08, + step_pct=0.02, cap_room=3000.0, cap_ratio=0.5, lot=100) + assert gp and gp["per_lot"] == 100, gp + # 科创板预算充足但每档折出来不足 200 → 抬到 200 (仍是 100 的整数倍, runner 不会磨掉) + gp, _ = adv.grid_params(price=10.0, support=9.0, pressure=11.0, band=0.08, + step_pct=0.02, cap_room=16000.0, cap_ratio=0.5, lot=200) + assert gp and gp["per_lot"] == 200 and gp["per_lot"] % 100 == 0, gp + + # ================================================================ # [三] 连边矩阵 (plan_edge) # ================================================================ @@ -662,6 +679,39 @@ def _(): param_store.get_bool, adv._params = orig, orig_p +@case("[判分] report_strategy_score: 成交聚合按边分侧 / 对照只收名额挡下的 / 涨跌口径空值安全") +def _(): + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import report_strategy_score as rep + orig = rep.fetch_all + try: + rep.fetch_all = lambda sql, p=None: [ + {"side": "buy", "exec_qty": 300, "exec_avg_price": 10.0}, + {"side": "buy", "exec_qty": 100, "exec_avg_price": 11.0}, + {"side": "sell", "exec_qty": 200, "exec_avg_price": 10.8}, + {"side": "weird", "exec_qty": 999, "exec_avg_price": 1.0}, # 未知边丢弃 + ] + agg = rep.fills_of("S_X") + assert agg["buy"] == {"qty": 400, "amt": 4100.0, "n": 2}, agg + assert agg["sell"]["qty"] == 200 and abs(agg["sell"]["amt"] - 2160.0) < 1e-6, agg + + rep.fetch_all = lambda sql, p=None: [ + {"ts_code": "A", "verdict": "PASS", "price_at": 10, "reason": "挂了"}, + {"ts_code": "B", "verdict": "NOTE", "price_at": 9, + "reason": "想挂 accum_grid 但今日新挂名额已满, 留到明天"}, + {"ts_code": "C", "verdict": "NOTE", "price_at": 8, + "reason": "想挂网格但放弃: 买不起一手"}, # 排除项, 不是对照 + ] + att, ctl = rep.ledger_window(30) + assert [r["ts_code"] for r in att] == ["A"], att + assert [r["ts_code"] for r in ctl] == ["B"], ctl + finally: + rep.fetch_all = orig + assert rep._pct(10.0, 11.0) is not None and abs(rep._pct(10.0, 11.0) - 0.1) < 1e-9 + assert rep._pct(None, 11.0) is None and rep._pct(10.0, None) is None + assert rep._fmt_pct(None) == "—" and rep._fmt_pct(0.1) == "+10.00%" + + def main(): passed, failed = 0, [] for name, fn in RESULTS: