feat(polymarket): edge-band + spread-band bet-selection gates (default no-op)#999
Conversation
Add operator-tunable horizon / edge-band / spread-band gates to the
Polymarket trader. Each gate defaults to a no-op so behaviour is
identical to `main` until activated in env config. The Omen trader
(`trader_pearl`) is byte-identical from a config perspective and the
gates' semantics are guarded by the CLOB code path.
- horizon (sampling.py): optional `min_bets_closing_days` floor
consolidated into the existing lower bound via `max(safe_offset, ...)`,
liveness-aware via `kpi_is_met` so a too-tight window cannot starve
the staking activity target. The blacklist side-effect stays tied to
the true safety floor only. Mirrored read-only in
`_classify_processable_bet` with a new `out_of_open_min` reason.
- edge (kelly_criterion.py): widen the CLOB pre-filter from
`edge < min_edge` to `outside [min_edge, max_edge]`. `max_edge`
defaults to 1.0 (no-op) and rides inside `strategies_kwargs`. The
FPMM edge gate is untouched.
- spread (decision_receive.py): preserve the live CLOB bids alongside
the asks, then gate after `strategy_vote` is known so the chosen
side's book drives the check. A missing/empty book passes through
silently. Defaults `[0.0, 1.0]` = no-op.
Activation is operator-side: set the env vars in
`polymarket_trader/service.yaml` (or `${SAMPLE_BETS_CLOSING_DAYS}` /
`${MIN_BETS_CLOSING_DAYS}` / `${POLYMARKET_SPREAD_MIN}` /
`${POLYMARKET_SPREAD_MAX}`) and inject `max_edge` into
`STRATEGIES_KWARGS`. `trader_pearl/service.yaml` is intentionally
left at defaults; only its agent CID line updates from
`autonomy packages lock`.
3832/3832 dev-package tests pass (incl. 5 new horizon-floor tests,
3 new classifier-mirror tests, 5 new spread-gate tests, 4 new
edge-band tests). isort, black, flake8, mypy, pylint (no new
warnings vs main), and darglint clean. `autonomy analyse fsm-specs`
reports no diff (no FSM changes). `autonomy packages lock` and
`autonomy analyse docstrings` run.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Address reviewer's critical finding on iteration 1: the kpi_is_met guard
on the horizon floor was a safe-but-inert crutch — the agent races to N=8
mech predictions per epoch with kpi_is_met=False, so apply_horizon was
False on every KPI-counting prediction; the +20pp horizon lift the gate
exists to capture was therefore forfeited in practice.
① Supply-aware horizon (sampling.py)
- processable_bet / _classify_processable_bet take apply_horizon kwarg
(default True) instead of consulting self.kpi_is_met internally
- _sample orchestrates a last-resort relaxation: after strict +
multi-bets passes, if pool is still empty AND min_bets_closing_days
is configured, retry once with apply_horizon=False
- new horizon_relaxed flag added to the [POLYSTRAT] filter log line
- effect: filter binds on real KPI-counting predictions; liveness is
still safe (relaxes only on actual scarcity)
② Misconfig diagnostic (models.py)
- logger.warning at __init__ when
min_bets_closing_days > sample_bets_closing_days (loud diagnostic,
not fatal, per plan §9.4)
Tests
- rework 5 TestHorizonFloorGate cases around apply_horizon (not
kpi_is_met); add test_kpi_is_met_is_not_the_relax_trigger regression
- rework TestClassifyProcessableBet horizon mirrors around
apply_horizon kwarg; add test_classify_kpi_is_met_does_not_relax_
classifier regression
- 3 new TestSample tests for _sample two-pass: strict pass binds,
relax fallback fires on empty pool (asserts horizon_relaxed=True in
log), relaxation branch skipped when floor=0
- 2 new TestDecisionMakerParams caplog tests for the misconfig warning
(fires when min>sample; silent when min<=sample)
3839/3839 dev-package tests pass. Lint pipeline green: isort, black,
flake8, mypy, darglint, autonomy analyse fsm-specs (no diff), autonomy
analyse docstrings (no update), autonomy packages lock, check-hash.
trader_pearl/service.yaml content remains semantically untouched
(1-line agent: CID cascade only).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Iteration 3 of T0002. The close-vs-resolution reconciliation (maxedge report Section 4) showed the 5-8d horizon ROI lives in oracle-resolution time (res_ts - bet_ts), which is hindsight not observable at bet time. The live gate can only filter on market close (openingTimestamp): the close-time [5,8]d band has 10 bets and Jaccard 0.000 with the winning res-time band. A gate is valid only if its variable is known at prediction time, so the horizon knob is dropped. The supply question was moot: MARKETS_TIME_WINDOW_DAYS=4 makes the 5-8d close band empty by construction anyway. Reverts ALL horizon machinery from c361c16 + 80d3384: - sampling.py: back to main (removes the consolidated lower bound, the apply_horizon kwarg, the _sample empty-pool fallback, the classifier out_of_open_min mirror) - models.py: removes min_bets_closing_days _ensure + the min>sample misconfig warning + the module logger (keeps polymarket_spread_min/max) - skill.yaml x2 / aea-config.yaml / polymarket_trader/service.yaml: removes the min_bets_closing_days param (keeps polymarket_spread_*) - tests: removes all horizon-only tests (TestHorizonFloorGate, the _sample two-pass cases, the classifier-mirror horizon cases, the caplog misconfig tests, the min_bets fixture entry) MARKETS_TIME_WINDOW_DAYS left untouched (no fetch-window change once the horizon knob is gone). Final PR ships two Polymarket-only, default-no-op gates: - edge band (kelly_criterion.py, CLOB gate only; FPMM untouched) - spread band (decision_receive.py, live-CLOB chosen-side best_ask-best_bid) 3824/3824 dev-package tests pass. Lint green: isort, black, flake8, mypy, pylint (no new warnings vs main), darglint. autonomy analyse fsm-specs no diff; analyse docstrings no update; autonomy packages lock clean; check-hash OK. trader_pearl/service.yaml content byte-identical (1-line agent CID cascade only). The dropped horizon work is preserved in .claude/plans/horizon-knob-dropped.md with a revive path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…election-gates # Conflicts: # packages/packages.json # packages/valory/agents/trader/aea-config.yaml # packages/valory/services/polymarket_trader/service.yaml # packages/valory/services/trader_pearl/service.yaml
OjusWiZard
left a comment
There was a problem hiding this comment.
Automated multi-perspective review (code / tests / comments / silent-failure / type-design / simplify). No critical bugs — defaults are inert for well-formed books, vote==0 → YES side selection is correct, and plumbing is consistent. Two themes worth fixing before merge: (1) an inverted band on either gate silently turns the agent into a no-trade zombie with no startup error, and (2) two test gaps let a side-selection regression ship green. Inline comments below.
DIvyaNautiyal07
left a comment
There was a problem hiding this comment.
Two findings below sit on lines outside any diff hunk, so GitHub can't anchor them inline. Captured here so they don't get lost.
P2 — New knobs are undocumented for operators (README.md line ~253, STRATEGIES_KWARGS table)
The STRATEGIES_KWARGS key table omits max_edge. The new POLYMARKET_SPREAD_MIN / POLYMARKET_SPREAD_MAX env vars aren't documented anywhere operator-facing either. Since both gates are default-no-op and only useful when operators flip them, the value of the PR depends on operators knowing the knobs exist. Add max_edge to the STRATEGIES_KWARGS table and add the spread env vars to the env-var section.
P2 — max_edge missing from the diagnostic dump (kelly_criterion.py line ~293)
The startup info.append line f"max_bet: {max_bet}, n_bets: {n_bets}, min_edge: {min_edge}" was not updated to include max_edge. An operator configuring a narrow band (e.g. max_edge=0.15) won't see it confirmed in the run info until the rejection message fires. One-token fix:
- info.append(f"max_bet: {max_bet}, n_bets: {n_bets}, min_edge: {min_edge}")
+ info.append(f"max_bet: {max_bet}, n_bets: {n_bets}, "
+ f"min_edge: {min_edge}, max_edge: {max_edge}")|
Reviewed the branch locally (ran the suite — 202 passing, 100% coverage on the 3 changed modules; 1. (medium) Crossed/locked book trips the spread gate even at defaults — breaks the no-op invariant. if spread >= 0 and not lo <= spread <= hi:
...plus a dedicated test alongside the empty-book one. 2. (low) No ordering validation on the bands. 3. (nit) Broken doc link. The description links to |
Address reviewer findings on the edge-band + spread-band gates: - Reject inverted bands up front: spread band raises ValueError at param init; edge band returns a no-trade (error-level) instead of silently skipping every bet. - Edge-rejection message is now directional (< min_edge / > max_edge), matching the FPMM path; add max_edge to the startup diagnostic dump. - Spread gate: guard order-book price parsing (a null/unparseable price no longer stalls the round) and emit a warning when the gate is bypassed under a non-default band; document that crossed books are intentionally rejected. - Tests: assert side-selection follows strategy_vote (distinct YES/NO books), cover the below-band case, parse-bypass, empty-book paths, and that inverted bands raise; assert the new params in test_full_init. - README: document max_edge and POLYMARKET_SPREAD_MIN/MAX. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- test_models.py: apply tomte isort (case_sensitive=True, order_by_type=False) so the model-import block is correctly ordered. - _setup_clob_with_book: collapse the helper docstring to a single line so darglint (short strictness) does not require per-param documentation. - re-lock affected dev packages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the thorough review — all findings are addressed in Inverted-band guards (both gates) — the spread band raises Diagnostics — edge-rejection message is directional again ( Spread gate — Tests — side-selection now asserts the verdict follows Docs — Deferred: hoisting |
|
Re-reviewed after All three of my findings are resolved:
LGTM once the dead link is dropped/added. Nice work hardening the gates. |
DIvyaNautiyal07
left a comment
There was a problem hiding this comment.
Re-reviewed the hardening commits (e010047, b44cf78). All P1s from the previous round are resolved with new regression tests; P2s and one nit are addressed too. Approving — one trivial follow-up nit below, non-blocking.
Resolved
- Inverted spread band —
models.py:442-447raisesValueErrorat param init;test_inverted_spread_band_raisescovers it. - Inverted edge band —
kelly_criterion.py:296-302short-circuits via_no_trade();test_inverted_edge_band_rejectedcovers it. - Untested YES/NO side-selection —
_setup_clob_with_booknow keys_fetch_orderbookon token id (books = {"token_yes_123": ob, "token_no_123": ob_no}, line 1163) andtest_spread_gate_side_selection_follows_voteflips the vote across distinct YES/NO books and asserts the verdict flips with it. Mutation gap closed. - Untested spread lower-bound —
test_spread_gate_below_band_skipscoversspread=0.01, spread_min=0.02. - Operator-facing docs —
README.mdaddsmax_edgeto theSTRATEGIES_KWARGStable and a dedicatedPOLYMARKET_SPREAD_MIN/MAXsection with the "agent refuses to start on inverted band" guarantee. max_edgein diagnostic dump — included in the startupinfo.appendatkelly_criterion.py:292-295.- Rejection-message regression —
kelly_criterion.py:368-379now branches by which bound tripped; the< min_edgephrasing matches the FPMM path. - Crossed-book "no-op" mismatch — behavior unchanged, but now explicit: README documents it, code comment calls out that crossed books are intentionally rejected as bad data, and
test_spread_gate_crossed_book_rejected_under_defaultpins it. - Silent bypass on fetch failure —
decision_receive.py:561-566logs WARNING on bypass only when a non-default band is configured; default-config users stay quiet, operators who tightened the band hear about it. Good compromise. max_edgeplumbing asymmetry — addressed via runtime check inrun()(option b from the original review).- Two-ternary collapse —
decision_receive.py:535-540is a single tuple unpack.
Acceptable to defer
_setup_clob_with_bookstill duplicates_setup_behaviour. Fine to leave for a separate cleanup PR.
New nit (non-blocking)
decision_receive.py:548 catches (ValueError, TypeError) but not KeyError. The new test_spread_gate_unparseable_price_bypasses covers the null-price case (string "None" -> float("None") raises ValueError), but if a book level dict ever lacks the "price" key, a["price"] raises KeyError and the gate crashes the round instead of bypassing. Trivial one-token addition:
except (ValueError, TypeError, KeyError) as exc:Approving.
OjusWiZard
left a comment
There was a problem hiding this comment.
Approving after a multi-perspective re-review of the hardened code (HEAD b44cf78). Two prior hardening rounds closed all consensus issues: both gates now validate min <= max, the order-book float() parse is wrapped in try/except, side-selection is covered by a discriminative test, and crossed-book/inverted-band cases are tested. Code-review, silent-failure, type-design, and simplify passes found nothing at the reporting threshold. Remaining items are non-blocking polish (warning-branch test assertions, one stale models.py comment, log-context tweaks). LGTM.
Correct the activation values to match the analysis: the min_edge FLOOR is a dead knob (raising it does nothing for ROI); the lever is the CAP. min_edge : 0.05 -> 0.01 (revert — keep the floor low, as in prod) max_edge : 0.10 (cap — the actual ROI lever, kept) POLYMARKET_SPREAD_MIN : 0.02 -> 0.0 (revert — no spread floor) POLYMARKET_SPREAD_MAX : 0.05 (cap — reject wide/illiquid spreads, kept) Net vs #999: add max_edge=0.1 and cap spread at 0.05. No floors raised. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gy CID dagacha's P1: file_hash_to_strategies still mapped kelly_criterion to the pre-999 CID (bafybeididlci...), whose strategy ignores max_edge — so the max_edge=0.15 cap added by this PR was inert at runtime (the deployed agent downloads the strategy by hash, not the repo file). Repoint to the #999 CID (bafybeidme2u...) and re-lock. - polymarket_trader/service.yaml: activates the max_edge cap (functional) - trader_pearl/service.yaml: consistency only — Omen is FPMM, max_edge is CLOB-only and unset there, so behaviour is unchanged; this just stops Omen deploying the stale pre-999 strategy - aea-config.yaml: agent default (the merged #999 left this stale too) autonomy packages lock --check passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion-values Activate edge + spread gate values (forward-test) — companion to #999
Summary
Adds two optional, default-no-op, Polymarket-only bet-selection gates to the trader, so an operator can restrict betting to the market conditions an out-of-sample ROI analysis found least-bad. Both gates are inert until explicitly configured via env, so behaviour on
mainis unchanged. The Omen trader (trader_pearl) is behaviourally untouched.Gates shipped
customs/kelly_criterion/kelly_criterion.py) — the CLOB pre-filter is widened fromedge < min_edgeto a bandedge outside [min_edge, max_edge].max_edgerides inside the existingstrategies_kwargsdict and defaults to1.0(edge =p − best_askis always ≤ 1.0, so1.0= no-op). The FPMM (Omen) edge gate is untouched — the band is CLOB-only.decision_maker_abci/behaviours/decision_receive.py) — skips a bet when the chosen side's live-CLOB bid-ask spread (best_ask − best_bid) is outside[polymarket_spread_min, polymarket_spread_max]. The gate keeps the CLOBbids(previously discarded), fires afterstrategy_voteis known (so it reads the correct side's book), and guards a missing/empty book (passes through silently). Defaults0.0 / 1.0widen the band to[0, 1]= no-op.New params (
polymarket_spread_min,polymarket_spread_max) are plumbed end-to-end (decision_maker_abci/skill.yaml,trader_abci/skill.yaml, agentaea-config.yaml,polymarket_trader/service.yaml,models.py).max_edgeneeds no plumbing — it lives in theSTRATEGIES_KWARGSJSON blob.Activation (operator config, Polymarket service only)
Defaults stay at the no-op values; operators flip the knobs via env when intentionally experimenting.
Scope & invariants
polymarket_trader/service.yaml.trader_pearl/service.yamlis content byte-identical (the only diff is the cascadedagent:CID fromautonomy packages lock, since both services share thevalory/traderagent)._is_profitable→Event.UNPROFITABLE→BlacklistingRound.autonomy analyse fsm-specsreports no diff. No new rounds/events/states.A note on the dropped horizon gate
This work originally explored a third gate — a 5–8-day time-to-resolution horizon floor. It was removed before this PR after a close-vs-resolution reconciliation: the +20pp ROI the analysis found lives in oracle-resolution time (
res_ts − bet_ts), which is not observable at bet time, whereas a live gate can only filter on market close (openingTimestamp). The close-time 5–8d band had ~10 historical bets with 0.000 Jaccard overlap against the resolution-time winners, so a close-time horizon gate would not reproduce the ROI. The horizon machinery is fully reverted here (sampling stage unchanged frommain); the investigation is retained outside the PR for a possible future revisit.Testing
test_kelly_criterion.pyand new spread-gate tests intest_decision_receive.py, covering on/off and the empty-book guard).max_edge=1.0never trips the neworbranch; any spread in[0,1]passes).isort,black,flake8,mypy,pylint,darglint.autonomy analyse fsm-specsno diff;autonomy analyse docstringsno update;autonomy packages lockclean;check-hashOK.Forward-test caveat (pre-activation, not a merge blocker)
The spread lift in the backing analysis was measured on the Gamma snapshot
market_spread; this gate uses the live-CLOB chosen-sidebest_ask − best_bid. The two measures may differ, so the expected lift is not guaranteed to transfer — confirm on a live forward-test before any operator activation of the spread band. Both gates default to no-op, so this is a pre-activation check.Live mainnet smoke test
Beyond the unit tests, both gates were validated end-to-end against live Polygon mainnet — a real
superforcaster-polymarket-v1mech prediction + the live CLOB order book — by running the agent viamake run-agentwith bet/collateral txs blocked by guards and a halt that prints the gate verdict (no bet tx is ever sent). This directly exercises the forward-test caveat above: it confirms the spread gate reads and gates on the live-CLOB chosen-sidebest_ask − best_bid. Full methodology + reproduction (guards patch, env, EOA-gas gotcha, knob matrix):.claude/testing/polymarket-bet-selection-gates-smoke-test.md.Method: one live market sampled (
2363872), three runs changing one knob at a time (the other left at its no-op default), reading theBET DECISIONverdict each run.min_edgeEach gate independently flips
bet → no-betwhen its own knob is tightened (other knob off), confirming both bands genuinely gate against the live book and that the defaults are inert. Guards held every run: zero bet/collateral txs, only the authorized mech requests settled. The gate code needed no change.🤖 Generated with Claude Code