Skip to content

Commit 765f5ea

Browse files
committed
Add focused maker no-fill diagnostics
1 parent 4f426ce commit 765f5ea

8 files changed

Lines changed: 557 additions & 11 deletions

File tree

docs/command-reference.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,30 @@ Run a realtime-specific analysis report explaining why opportunities are absent
500500
The `zero_opportunity_diagnosis` section separates actionable near-misses from diagnostic or blocked candidates, so a positive-looking basket that still needs rule promotion will not be treated as executable.
501501
The same report is refreshed by `scripts/run_realtime_analysis_once.sh`; the LaunchAgent `poly_strategy_realtime_analysis_15m` runs it every 15 minutes.
502502

503+
Extract markets from a specific optimization lever and run a focused maker-fee scan:
504+
505+
```bash
506+
.venv/bin/python -m poly_strategy.cli optimization-target-markets data/realtime-monitor-24h-v1-analysis.json \
507+
--lever maker_fee_avoidance \
508+
--top-targets 1 \
509+
--max-markets 120 \
510+
--out data/optimization-target-market-ids.txt
511+
512+
MAX_TARGET_MARKETS=120 TOP=50 scripts/run_maker_focus_from_analysis_once.sh
513+
```
514+
515+
Validate the focused maker candidates against public SELL trade prints:
516+
517+
```bash
518+
HYBRID_SCAN=data/maker-hybrid-scan-focus.json \
519+
TRADES=data/polymarket-data-trades-focus.ndjson \
520+
OUT=data/maker-hybrid-tape-sim-focus.json \
521+
LOCK_DIR=var/locks/maker-hybrid-tape-focus.lock \
522+
scripts/run_maker_hybrid_tape_once.sh
523+
```
524+
525+
`maker-hybrid-tape-sim` remains diagnostic-only, but its report explains why fills did not complete with `rejection_by_reason`, `maker_fill_progress_distribution`, and `top_unfilled_maker_legs`.
526+
503527
Promote only usable opportunities from diagnostic basket candidates:
504528

505529
```bash

docs/pipeline.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,34 @@ The report also includes `strategy_chain_breakdown`, which applies the same logi
4949
- `rule_verification`: a diagnostic basket has high apparent edge but must be verified before promotion.
5050
- `paper_filter_debugging`: stable opportunities exist but fail paper filters; inspect rejection reasons.
5151
- `feed_coverage`: the watchlist has many tokens without current snapshots.
52+
53+
## Focused Maker Fee-Avoidance Loop
54+
55+
When the top blocker is `maker_fee_avoidance`, the next loop is deliberately narrow:
56+
57+
1. Extract the market IDs from `optimization_targets`.
58+
2. Refresh only those books, optionally expanding the full `negRiskMarketID` group.
59+
3. Run `maker-hybrid-scan` on the focused snapshot file.
60+
4. Validate passive fill assumptions with `maker-hybrid-tape-sim`.
61+
5. Read the no-fill diagnostics before making quotes more aggressive.
62+
63+
Useful one-shot commands:
64+
65+
```bash
66+
python3 -m poly_strategy.cli optimization-target-markets \
67+
data/realtime-monitor-24h-v1-analysis.json \
68+
--lever maker_fee_avoidance \
69+
--top-targets 1 \
70+
--max-markets 120 \
71+
--out data/optimization-target-market-ids.txt
72+
73+
MAX_TARGET_MARKETS=120 TOP=50 scripts/run_maker_focus_from_analysis_once.sh
74+
```
75+
76+
The tape report is diagnostic-only. It now includes:
77+
78+
- `rejection_by_reason`: whether candidates fail because maker legs do not fill or because the hedge is no longer profitable.
79+
- `maker_fill_progress_distribution`: how many maker legs filled before rejection.
80+
- `top_unfilled_maker_legs`: repeated unfilled markets, quote levels, spread, distance to ask, and expected edge.
81+
82+
This is the guardrail that prevents treating theoretical maker savings as a tradeable opportunity.

poly_strategy/cli.py

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
)
7575
from poly_strategy.monitoring import IncrementalReplayState, stable_current_opportunities
7676
from poly_strategy.notifications import notify_alerts
77-
from poly_strategy.paper_analysis import analyze_paper_monitor_report
77+
from poly_strategy.paper_analysis import analyze_paper_monitor_report, optimization_target_market_ids
7878
from poly_strategy.paper import opportunity_key, select_paper_trades, trade_to_row, rejection_to_row, opportunity_to_row
7979
from poly_strategy.realtime import (
8080
DEFAULT_WS_MAX_SIZE,
@@ -152,15 +152,38 @@ def main(argv=None) -> int:
152152
print(f"wrote={count} out={args.out}")
153153
return 0
154154
if args.command == "collect-polymarket-binaries":
155-
count = collect_polymarket_binary_snapshots_loop(
156-
Path(args.out),
157-
args.limit,
158-
args.timeout,
159-
args.proxy,
160-
args.interval,
161-
args.iterations,
162-
max_workers=args.book_workers,
163-
)
155+
market_ids = list(args.market_id or [])
156+
if args.market_ids_file:
157+
market_ids.extend(_read_lines(Path(args.market_ids_file)))
158+
if market_ids:
159+
if not args.gamma:
160+
raise ValueError("--gamma is required when collecting specific market IDs")
161+
count = 0
162+
for index in range(args.iterations):
163+
count += collect_polymarket_binary_snapshots_for_market_ids(
164+
Path(args.out),
165+
Path(args.gamma),
166+
market_ids,
167+
args.timeout,
168+
args.proxy,
169+
max_workers=args.book_workers,
170+
skip_book_errors=args.skip_book_errors,
171+
refresh_missing_gamma=args.refresh_missing_gamma,
172+
expand_neg_risk_groups=not args.no_expand_neg_risk_groups,
173+
max_markets=args.max_markets,
174+
)
175+
if index < args.iterations - 1 and args.interval > 0:
176+
time.sleep(args.interval)
177+
else:
178+
count = collect_polymarket_binary_snapshots_loop(
179+
Path(args.out),
180+
args.limit,
181+
args.timeout,
182+
args.proxy,
183+
args.interval,
184+
args.iterations,
185+
max_workers=args.book_workers,
186+
)
164187
print(f"wrote={count} out={args.out}")
165188
return 0
166189
if args.command == "collect-polymarket-trades":
@@ -345,6 +368,21 @@ def main(argv=None) -> int:
345368
else:
346369
print(json.dumps(row, sort_keys=True))
347370
return 0
371+
if args.command == "optimization-target-markets":
372+
report = json.loads(Path(args.analysis).read_text())
373+
market_ids = optimization_target_market_ids(
374+
report,
375+
lever=args.lever,
376+
top_targets=args.top_targets,
377+
max_markets=args.max_markets,
378+
)
379+
if args.out:
380+
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
381+
Path(args.out).write_text("\n".join(market_ids) + ("\n" if market_ids else ""))
382+
print(f"market_ids={len(market_ids)} lever={args.lever} out={args.out}")
383+
else:
384+
print(json.dumps({"market_ids": market_ids, "market_id_count": len(market_ids)}, sort_keys=True))
385+
return 0
348386
if args.command == "maker-scan":
349387
row = maker_scan_report(
350388
Path(args.snapshots),
@@ -1153,6 +1191,17 @@ def _build_parser() -> argparse.ArgumentParser:
11531191
collect_binaries.add_argument("--iterations", type=int, default=1, help="number of collection iterations")
11541192
collect_binaries.add_argument("--interval", type=float, default=0.0, help="seconds between iterations")
11551193
collect_binaries.add_argument("--book-workers", type=int, default=1, help="parallel CLOB book fetch workers")
1194+
collect_binaries.add_argument("--gamma", help="raw Polymarket Gamma NDJSON path when collecting specific market IDs")
1195+
collect_binaries.add_argument("--market-id", action="append", help="Gamma market ID; can be repeated")
1196+
collect_binaries.add_argument("--market-ids-file", help="newline-delimited Gamma market IDs")
1197+
collect_binaries.add_argument("--refresh-missing-gamma", action="store_true", help="fetch missing Gamma metadata by market ID")
1198+
collect_binaries.add_argument("--max-markets", type=int, help="cap collected markets after optional neg-risk expansion")
1199+
collect_binaries.add_argument("--skip-book-errors", action="store_true", help="skip CLOB book errors instead of failing")
1200+
collect_binaries.add_argument(
1201+
"--no-expand-neg-risk-groups",
1202+
action="store_true",
1203+
help="do not expand selected markets to their full known negRiskMarketID group",
1204+
)
11561205

11571206
collect_trades = subparsers.add_parser(
11581207
"collect-polymarket-trades",
@@ -1336,6 +1385,16 @@ def _build_parser() -> argparse.ArgumentParser:
13361385
help="minimum net edge threshold used to classify near misses",
13371386
)
13381387

1388+
optimization_markets = subparsers.add_parser(
1389+
"optimization-target-markets",
1390+
help="extract market IDs from monitor-analysis optimization targets",
1391+
)
1392+
optimization_markets.add_argument("analysis", help="monitor-analysis JSON path")
1393+
optimization_markets.add_argument("--out", help="newline-delimited output path; prints JSON when omitted")
1394+
optimization_markets.add_argument("--lever", default="maker_fee_avoidance", help="target lever, or top/all")
1395+
optimization_markets.add_argument("--top-targets", type=int, default=1, help="number of matching targets to use")
1396+
optimization_markets.add_argument("--max-markets", type=int, help="cap returned market IDs")
1397+
13391398
maker_scan = subparsers.add_parser(
13401399
"maker-scan",
13411400
help="scan latest snapshots for passive maker basket candidates without submitting orders",

poly_strategy/maker.py

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from collections import defaultdict
1+
from collections import Counter, defaultdict
22
import json
33
from datetime import datetime, timezone
44
from itertools import combinations
@@ -668,6 +668,9 @@ def maker_hybrid_tape_sim_report(
668668
"diagnostic_only": True,
669669
"diagnostic_warning": "public trade prints can prove sell-through, but queue position is still uncertain without live order fills",
670670
"by_kind": _hybrid_fill_summary_by_kind(results),
671+
"rejection_by_reason": _hybrid_rejection_summary(results),
672+
"maker_fill_progress_distribution": _maker_fill_progress_distribution(results),
673+
"top_unfilled_maker_legs": _top_unfilled_maker_legs(results, top_n),
671674
"top_completed": sorted(completed, key=_hybrid_result_sort_key)[:top_n],
672675
"top_unique_completed": sorted(unique_completed, key=_hybrid_result_sort_key)[:top_n],
673676
"top_unsafe": sorted(unsafe, key=_hybrid_result_sort_key)[:top_n],
@@ -2571,6 +2574,184 @@ def _hybrid_fill_summary_by_kind(rows: List[dict]) -> list:
25712574
return sorted(summary.values(), key=lambda row: (-row["completed_count"], -row["max_completed_realized_edge_at_cap"], row["kind"]))
25722575

25732576

2577+
def _hybrid_rejection_summary(rows: List[dict]) -> list:
2578+
summary = {}
2579+
for row in rows:
2580+
reason = str(row.get("rejection_reason") or ("completed" if row.get("completed") else "unknown"))
2581+
item = summary.setdefault(
2582+
reason,
2583+
{
2584+
"reason": reason,
2585+
"candidate_observation_count": 0,
2586+
"completed_count": 0,
2587+
"max_expected_edge_at_cap": 0.0,
2588+
"max_expected_edge_per_share": 0.0,
2589+
"max_realized_edge_at_cap": 0.0,
2590+
},
2591+
)
2592+
item["candidate_observation_count"] += 1
2593+
if row.get("completed"):
2594+
item["completed_count"] += 1
2595+
item["max_expected_edge_at_cap"] = max(
2596+
item["max_expected_edge_at_cap"],
2597+
float(row.get("expected_edge_at_cap") or 0.0),
2598+
)
2599+
item["max_expected_edge_per_share"] = max(
2600+
item["max_expected_edge_per_share"],
2601+
float(row.get("expected_edge_per_share") or 0.0),
2602+
)
2603+
item["max_realized_edge_at_cap"] = max(
2604+
item["max_realized_edge_at_cap"],
2605+
float(row.get("realized_edge_at_cap") or 0.0),
2606+
)
2607+
return sorted(
2608+
summary.values(),
2609+
key=lambda row: (
2610+
-row["candidate_observation_count"],
2611+
-row["max_expected_edge_at_cap"],
2612+
row["reason"],
2613+
),
2614+
)
2615+
2616+
2617+
def _maker_fill_progress_distribution(rows: List[dict]) -> list:
2618+
counts = Counter(
2619+
(
2620+
int(row.get("filled_maker_leg_count") or 0),
2621+
int(row.get("maker_leg_count") or 0),
2622+
)
2623+
for row in rows
2624+
)
2625+
distribution = []
2626+
for (filled_count, maker_count), count in counts.items():
2627+
distribution.append(
2628+
{
2629+
"filled_maker_leg_count": filled_count,
2630+
"maker_leg_count": maker_count,
2631+
"candidate_observation_count": count,
2632+
"maker_leg_fill_ratio": filled_count / maker_count if maker_count else 0.0,
2633+
}
2634+
)
2635+
return sorted(
2636+
distribution,
2637+
key=lambda row: (
2638+
row["filled_maker_leg_count"],
2639+
row["maker_leg_count"],
2640+
-row["candidate_observation_count"],
2641+
),
2642+
)
2643+
2644+
2645+
def _top_unfilled_maker_legs(rows: List[dict], top_n: int) -> list:
2646+
if top_n <= 0:
2647+
return []
2648+
summary = {}
2649+
for row in rows:
2650+
unfilled_indices = set(int(index) for index in (row.get("unfilled_maker_indices") or []))
2651+
for index, leg in enumerate(row.get("maker_legs") or []):
2652+
source_index = _source_leg_index(leg, index)
2653+
key = (
2654+
str(leg.get("venue") or ""),
2655+
str(leg.get("market_id") or ""),
2656+
str(leg.get("token") or ""),
2657+
str(leg.get("token_id") or ""),
2658+
str(leg.get("side") or ""),
2659+
float(leg.get("limit_price") or 0.0),
2660+
str(leg.get("quote_mode") or ""),
2661+
int(leg.get("quote_offset_ticks") or 0),
2662+
)
2663+
item = summary.setdefault(
2664+
key,
2665+
{
2666+
"venue": key[0],
2667+
"market_id": key[1],
2668+
"token": key[2],
2669+
"token_id": key[3],
2670+
"side": key[4],
2671+
"limit_price": key[5],
2672+
"quote_mode": key[6],
2673+
"quote_offset_ticks": key[7],
2674+
"best_bid": _float_or_none(leg.get("best_bid")),
2675+
"best_ask": _float_or_none(leg.get("best_ask")),
2676+
"spread": _float_or_none(leg.get("spread")),
2677+
"candidate_observation_count": 0,
2678+
"unfilled_count": 0,
2679+
"max_expected_edge_at_cap": 0.0,
2680+
"max_expected_edge_per_share": 0.0,
2681+
"min_distance_to_best_ask": None,
2682+
"max_improvement_over_best_bid": 0.0,
2683+
},
2684+
)
2685+
item["candidate_observation_count"] += 1
2686+
item["max_expected_edge_at_cap"] = max(
2687+
item["max_expected_edge_at_cap"],
2688+
float(row.get("expected_edge_at_cap") or 0.0),
2689+
)
2690+
item["max_expected_edge_per_share"] = max(
2691+
item["max_expected_edge_per_share"],
2692+
float(row.get("expected_edge_per_share") or 0.0),
2693+
)
2694+
distance = _leg_distance_to_best_ask(leg)
2695+
if distance is not None:
2696+
item["min_distance_to_best_ask"] = (
2697+
distance
2698+
if item["min_distance_to_best_ask"] is None
2699+
else min(item["min_distance_to_best_ask"], distance)
2700+
)
2701+
improvement = _leg_improvement_over_best_bid(leg)
2702+
if improvement is not None:
2703+
item["max_improvement_over_best_bid"] = max(item["max_improvement_over_best_bid"], improvement)
2704+
if source_index in unfilled_indices:
2705+
item["unfilled_count"] += 1
2706+
2707+
rows = []
2708+
for item in summary.values():
2709+
if item["unfilled_count"] <= 0:
2710+
continue
2711+
count = item["candidate_observation_count"]
2712+
item["unfilled_rate"] = item["unfilled_count"] / count if count else 0.0
2713+
rows.append(item)
2714+
return sorted(
2715+
rows,
2716+
key=lambda row: (
2717+
-row["unfilled_count"],
2718+
-row["unfilled_rate"],
2719+
-row["max_expected_edge_at_cap"],
2720+
row["market_id"],
2721+
row["token"],
2722+
),
2723+
)[:top_n]
2724+
2725+
2726+
def _source_leg_index(leg: dict, default: int) -> int:
2727+
try:
2728+
return int(leg.get("source_leg_index", default))
2729+
except (TypeError, ValueError):
2730+
return default
2731+
2732+
2733+
def _leg_distance_to_best_ask(leg: dict) -> Optional[float]:
2734+
distance = _float_or_none(leg.get("distance_to_best_ask"))
2735+
if distance is not None:
2736+
return distance
2737+
best_ask = _float_or_none(leg.get("best_ask"))
2738+
limit_price = _float_or_none(leg.get("limit_price"))
2739+
if best_ask is None or limit_price is None:
2740+
return None
2741+
return best_ask - limit_price
2742+
2743+
2744+
def _leg_improvement_over_best_bid(leg: dict) -> Optional[float]:
2745+
improvement = _float_or_none(leg.get("improvement_over_best_bid"))
2746+
if improvement is not None:
2747+
return improvement
2748+
best_bid = _float_or_none(leg.get("best_bid"))
2749+
limit_price = _float_or_none(leg.get("limit_price"))
2750+
if best_bid is None or limit_price is None:
2751+
return None
2752+
return max(0.0, limit_price - best_bid)
2753+
2754+
25742755
def _unique_tape_completed_rows(rows: List[dict]) -> List[dict]:
25752756
deduped = {}
25762757
for row in rows:

0 commit comments

Comments
 (0)