<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: levl</title>
    <description>The latest articles on DEV Community by levl (@levl).</description>
    <link>https://dev.to/levl</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4182828%2F4b18683b-d6f3-4960-8536-9ca033209cfe.jpg</url>
      <title>DEV Community: levl</title>
      <link>https://dev.to/levl</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/levl"/>
    <language>en</language>
    <item>
      <title>Letting an LLM press buy: idempotency keys, fsync and process groups</title>
      <dc:creator>levl</dc:creator>
      <pubDate>Wed, 14 Oct 2026 15:39:48 +0000</pubDate>
      <link>https://dev.to/levl/letting-an-llm-press-buy-idempotency-keys-fsync-and-process-groups-49io</link>
      <guid>https://dev.to/levl/letting-an-llm-press-buy-idempotency-keys-fsync-and-process-groups-49io</guid>
      <description>&lt;p&gt;We recently shipped a way for AI agents (Claude Code, Codex, anything that takes a prompt and reads stdin, or speaks MCP) to act on the live trades of Polymarket wallets and place orders of their own. The data side streams fills to the agent; the trading side is a command the agent calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pmwallets-copytrade order buy &amp;lt;tokenId&amp;gt; &lt;span class="nt"&gt;--usdc&lt;/span&gt; 5 &lt;span class="nt"&gt;--max-price&lt;/span&gt; 0.45 &lt;span class="nt"&gt;--key&lt;/span&gt; &amp;lt;eventId&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The happy path needs little code. Most of what we wrote handles failures that could cost money; those are the cases I'll cover here.&lt;/p&gt;

&lt;p&gt;The failures we planned for were ordinary ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The agent retries a command after it seemed slow.&lt;/li&gt;
&lt;li&gt;The model misspells an argument.&lt;/li&gt;
&lt;li&gt;The process crashes, or the machine loses power, halfway through something.&lt;/li&gt;
&lt;li&gt;The agent CLI starts child processes that outlive it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those, unhandled, turns into either a second order or an order without the limit you meant to set.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A spend ceiling that's actually a ceiling
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--usdc 5&lt;/code&gt; reads as "spend $5". What we wanted was "never spend more than $5".&lt;/p&gt;

&lt;p&gt;A BUY is fill-or-kill at a limit price, and the share count is computed at that limit, not at the current ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// usdc is a ceiling: sized at the limit, the order cannot cost more than it whatever it matches&lt;/span&gt;
&lt;span class="nx"&gt;shares&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;roundBuyShares&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nf"&gt;microToward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usdc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;down&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;UNIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the ask is 0.40 and the agent sets &lt;code&gt;--max-price 0.45&lt;/code&gt;, it gets &lt;code&gt;5 / 0.45&lt;/code&gt; shares. If the book moves up to 0.45 between the check and the match, the cost is still at most $5. A loose &lt;code&gt;--max-price&lt;/code&gt; buys fewer shares, but keeps the order within the budget.&lt;/p&gt;

&lt;p&gt;The rounding took more thought than expected. Prices and amounts are integers in millionths, and the caller's numbers come in as JavaScript floats. Rounding to nearest turns &lt;code&gt;usdc 1.9999999&lt;/code&gt; into 2.00 and &lt;code&gt;minPrice 0.3800001&lt;/code&gt; into 0.38, both of which loosen the caller's limit slightly. So every caller number is rounded in the direction that can't loosen it: a budget, a max price or a share count down, a min price up. It's done on the number's shortest decimal string, exactly, so no binary floating-point digit decides the direction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;microToward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;up&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;down&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;bigint&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)(?:\.(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;))?(?:&lt;/span&gt;&lt;span class="sr"&gt;e&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;+-&lt;/span&gt;&lt;span class="se"&gt;]?\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;))?&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`cannot read &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; as a decimal`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]}${&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shift&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;digits&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;digits&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;down&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;digits&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same rule applies to the market's tick size: a BUY limit is rounded down onto the tick grid, a SELL floor up, and a floor above the highest price the market accepts is refused rather than lowered.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The idempotency key is written before the order leaves
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;claude -p&lt;/code&gt; run that saw a slow response may run the same command again. &lt;code&gt;--key&lt;/code&gt; prevents that retry from sending a second order; the fill's &lt;code&gt;eventId&lt;/code&gt; is a suitable choice.&lt;/p&gt;

&lt;p&gt;The key is claimed on disk and synced before the order is sent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;mkdirDurable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;openSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// exclusive create: two callers can't both win&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;NodeJS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ErrnoException&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EEXIST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;writeSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;claimedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="nf"&gt;fsyncSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;closeSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;syncDir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                             &lt;span class="c1"&gt;// the directory entry too&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These details each came from a specific failure case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;wx&lt;/code&gt; (&lt;code&gt;O_CREAT | O_EXCL&lt;/code&gt;) means two concurrent calls with the same key can't both claim it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fsync&lt;/code&gt; on the file isn't enough for a new file. Its directory entry also has to reach the disk, or a power cut can lose the file entirely. Hence &lt;code&gt;syncDir&lt;/code&gt;, and &lt;code&gt;mkdirDurable&lt;/code&gt;, which syncs each directory level it creates into its parent.&lt;/li&gt;
&lt;li&gt;If the claim can't be synced, it throws and nothing is sent.&lt;/li&gt;
&lt;li&gt;A claim with no recorded result (the process died while sending) comes back to the next caller as &lt;code&gt;status: unknown, verify: true&lt;/code&gt;, not as "free". The order may have filled; the agent is told to check &lt;code&gt;trades&lt;/code&gt; first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To record the result, we write a temporary file, fsync it, rename it and sync the directory. A crash between the claim and the send means that key won't order again, even though nothing was sent. We accepted that missed-trade risk to avoid sending the same order twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Handing fills to the agent at most once
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pmwallets watch --exec "&amp;lt;agent command&amp;gt;"&lt;/code&gt; holds a WebSocket to the fill stream and runs the agent once per batch of fills. The stream itself delivers each fill once, in order, and replays gaps. The question was what happens between the stream and the agent.&lt;/p&gt;

&lt;p&gt;At-least-once is the usual choice for queues. Here, if the agent placed an order and then the process crashed, running the batch again could place it again. We hand each batch over at most once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A fill is appended to &lt;code&gt;queue.jsonl&lt;/code&gt; and synced before the stream cursor moves past it.&lt;/li&gt;
&lt;li&gt;To run a batch, the batch is written to &lt;code&gt;batch-&amp;lt;id&amp;gt;.jsonl&lt;/code&gt;, the queue is rewritten without it, and the batch's eventIds are appended to &lt;code&gt;handed.txt&lt;/code&gt; and synced. Only then is the batch renamed to &lt;code&gt;running-&amp;lt;id&amp;gt;.jsonl&lt;/code&gt; and the command started.&lt;/li&gt;
&lt;li&gt;If the command exits 0, the file is deleted. If it fails or times out, it moves to &lt;code&gt;failed/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;At startup, any &lt;code&gt;running-*&lt;/code&gt; file means the previous process died while the agent ran. It moves to &lt;code&gt;failed/&lt;/code&gt; and is never run again. A &lt;code&gt;batch-*&lt;/code&gt; file that was never started goes back to the front of the queue, unless its ids are already in &lt;code&gt;handed.txt&lt;/code&gt;, in which case it also goes to &lt;code&gt;failed/&lt;/code&gt; for a human.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A fill whose eventId is in &lt;code&gt;handed.txt&lt;/code&gt; is never queued again, even if the stream replays it because the cursor wasn't saved before a crash. &lt;code&gt;handed.txt&lt;/code&gt; is trimmed occasionally, but only of ids at or before the stream cursor as it is on disk (synced first), since those are the only ones the stream can never replay.&lt;/p&gt;

&lt;p&gt;A crash can leave fills in &lt;code&gt;failed/&lt;/code&gt; that nobody acted on. We log those for manual checking and accept that risk with this design.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Stopping the agent means stopping everything it started
&lt;/h2&gt;

&lt;p&gt;An agent CLI is rarely one process. &lt;code&gt;claude -p&lt;/code&gt; and &lt;code&gt;codex exec&lt;/code&gt; start their own children, and a child left running after a timeout might still be about to call &lt;code&gt;order buy&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The command runs in its own process group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;child&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;nodeSpawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stdio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pipe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inherit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inherit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;detached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;win32&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A timeout or a stop sends &lt;code&gt;SIGTERM&lt;/code&gt; to the whole group (&lt;code&gt;process.kill(-pid, ...)&lt;/code&gt;), then &lt;code&gt;SIGKILL&lt;/code&gt; five seconds later. On Windows it's &lt;code&gt;taskkill /T /F&lt;/code&gt;. When the command exits normally, the group is killed anyway, because a grandchild may still be running.&lt;/p&gt;

&lt;p&gt;Two things went wrong here before they were fixed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The pending &lt;code&gt;SIGKILL&lt;/code&gt; timers weren't cancelled when the command ended on its own. Five seconds later they could hit a different process that had been given the same pid.&lt;/li&gt;
&lt;li&gt;A process can leave its group (&lt;code&gt;setsid&lt;/code&gt;), and after a crash of the watcher itself, there's no parent left to remember the group.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the second, every batch's command gets &lt;code&gt;PMW_BATCH_FILE=&amp;lt;absolute path&amp;gt;&lt;/code&gt; in its environment. That doubles as a marker. On Linux, startup scans &lt;code&gt;/proc/*/environ&lt;/code&gt; for the marker of any &lt;code&gt;running-*&lt;/code&gt; batch and stops those groups before handing anything new out. On other systems, where that can't be checked, a running batch with no pid record makes startup refuse with instructions, instead of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Lock files that are never reused
&lt;/h2&gt;

&lt;p&gt;We need one watcher per directory to avoid sending every fill to the agent twice. The classic pid lock file (create it, delete it on exit, take it over if the pid is dead) has a race. Starter A reads the lock, sees a dead pid, and pauses. Starter B also sees it dead, deletes it, creates its own and starts running. A wakes up, deletes B's live lock and creates its own, leaving two watchers running.&lt;/p&gt;

&lt;p&gt;The fix was to stop reusing names. Locks are generations, &lt;code&gt;watch.lock.0&lt;/code&gt;, &lt;code&gt;watch.lock.1&lt;/code&gt;, and so on. The highest number is the lock. A new one is created with exclusive create, as highest + 1, only when the current highest is released or its owner is dead. Releasing writes &lt;code&gt;{ released: true }&lt;/code&gt; into the file instead of deleting it, and old generations are never removed. A starter that paused on an old scan tries to create a number that already exists and gets &lt;code&gt;EEXIST&lt;/code&gt;. Each file is a few bytes, one per start.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Rejecting misspelt arguments
&lt;/h2&gt;

&lt;p&gt;Over MCP, a model calls &lt;code&gt;order_buy&lt;/code&gt; with a JSON object. If it sends &lt;code&gt;max_price&lt;/code&gt; instead of &lt;code&gt;maxPrice&lt;/code&gt;, a lenient server ignores the unknown field and the order goes out with the default limit, which is the current ask, whatever that is. MCP hosts don't have to validate against the schema, so the server does, strictly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`unknown argument &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; (allowed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...type checks; null is rejected with "leave it out instead"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The error lists the allowed names so the model can fix the call. The command line also rejects unknown flags such as &lt;code&gt;--max_price&lt;/code&gt;. A flag with no value, like &lt;code&gt;--key --all&lt;/code&gt;, returns an error; otherwise it could place an order under the key &lt;code&gt;--all&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Secrets are removed before anything is serialized
&lt;/h2&gt;

&lt;p&gt;Exchange errors sometimes echo the request back, and the request can contain credentials. Anything written to a key record, a log line or stderr goes through &lt;code&gt;redact()&lt;/code&gt; first. It walks the whole value, keys included, and replaces every known secret value plus anything shaped like an API key or a &lt;code&gt;user:pass@&lt;/code&gt; URL. It runs on the object before &lt;code&gt;JSON.stringify&lt;/code&gt;, so field names are covered as well as values.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding failure cases in review
&lt;/h2&gt;

&lt;p&gt;Many of the failure cases above came out of an adversarial review loop with Codex over the diff. We asked it specifically for crash windows, races and partial writes, and successive rounds found more cases to fix. The data CLI went from 0.6.0 to 0.6.5 on the day it shipped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;0.6.1: cancel pending &lt;code&gt;SIGKILL&lt;/code&gt;s once the command is over; keep a batch recorded as handed but never started in &lt;code&gt;failed/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;0.6.2: sync the stream cursor before forgetting handed ids; &lt;code&gt;new-fills&lt;/code&gt; moves its cursor only once the answer is complete.&lt;/li&gt;
&lt;li&gt;0.6.3: a new queue or handed file is created durably, directory synced, before a fill counts as queued.&lt;/li&gt;
&lt;li&gt;0.6.4: cut a half-written last line off the queue and the handed record at start (otherwise the next append is glued onto the fragment and lost with it); one &lt;code&gt;new-fills&lt;/code&gt; queue per file however its path is spelt.&lt;/li&gt;
&lt;li&gt;0.6.5: on Linux, find a batch's processes by the environment marker even when there's a pid record, and stop any that left the group when the batch ends.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trading CLI's 0.4.1 was the redaction fix in section 7.&lt;/p&gt;

&lt;p&gt;None of those would show up in a normal test run. They need a crash at one specific line, or two processes starting within milliseconds of each other. What made the review useful was asking a narrow question: where can a crash between two lines cause an order to be sent twice, or a fill to be handed over twice?&lt;/p&gt;

&lt;h2&gt;
  
  
  Does it work
&lt;/h2&gt;

&lt;p&gt;In a production test, 57 real fills reached the agent command between 0.01 and 0.16 seconds after they were pushed. A headless &lt;code&gt;claude -p&lt;/code&gt; took about 11 seconds to read a fill, look up the market and place a dry-run order; &lt;code&gt;codex exec&lt;/code&gt; took about 27. Most of the time went into the model run. That delay is another reason we start the trading side in dry-run mode, where orders are simulated against the live book and nothing is sent.&lt;/p&gt;

&lt;p&gt;For a similar setup, I'd check the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Make the money limit a hard ceiling, computed at the limit price, and round caller numbers in the safe direction.&lt;/li&gt;
&lt;li&gt;Claim idempotency keys durably before the side effect, and report a claim with no recorded result as "unknown", never as free to reuse.&lt;/li&gt;
&lt;li&gt;Pick at-most-once when the consumer has side effects, and keep what fell through somewhere a human will look.&lt;/li&gt;
&lt;li&gt;Stop the whole process group and leave a marker you can find after the watcher crashes.&lt;/li&gt;
&lt;li&gt;Use a new lock file name for each generation.&lt;/li&gt;
&lt;li&gt;Reject unknown arguments so a model's typo can't silently change an order.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code is MIT-licensed: &lt;code&gt;src/watch.ts&lt;/code&gt; and &lt;code&gt;src/mcp.ts&lt;/code&gt; in &lt;a href="https://github.com/polymarketwallets/pmwallets-node" rel="noopener noreferrer"&gt;polymarketwallets/pmwallets-node&lt;/a&gt;, and &lt;code&gt;src/agent.ts&lt;/code&gt; in &lt;a href="https://github.com/polymarketwallets/polymarket-copy-trading-bot" rel="noopener noreferrer"&gt;polymarketwallets/polymarket-copy-trading-bot&lt;/a&gt; on GitHub.&lt;/p&gt;

&lt;p&gt;Nothing here is financial advice, and Polymarket's regional restrictions apply to placing orders.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I help maintain &lt;a href="https://pmwallets.com" rel="noopener noreferrer"&gt;PMWallets&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>typescript</category>
      <category>node</category>
      <category>reliability</category>
    </item>
    <item>
      <title>Let your AI agent follow Polymarket traders: Claude Code, Codex, Cursor and MCP</title>
      <dc:creator>levl</dc:creator>
      <pubDate>Wed, 14 Oct 2026 15:39:41 +0000</pubDate>
      <link>https://dev.to/levl/let-your-ai-agent-follow-polymarket-traders-claude-code-codex-cursor-and-mcp-1ca</link>
      <guid>https://dev.to/levl/let-your-ai-agent-follow-polymarket-traders-claude-code-codex-cursor-and-mcp-1ca</guid>
      <description>&lt;p&gt;When a Polymarket trader I follow makes a trade, I want an AI agent to see it, look at the market, and decide whether to do anything about it.&lt;/p&gt;

&lt;p&gt;Agents like Claude Code or Codex can read a fill and reason about a market, but they don't work well as long-running listeners. They exit when their task is done. In this setup, a small process holds the live connection and queues fills. It starts the agent once per batch with the fills on stdin; the agent handles the batch and exits.&lt;/p&gt;

&lt;p&gt;Everything here uses two command-line tools that print JSON, and both also run as MCP servers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pmwallets&lt;/code&gt;: the data side. Leaderboard, trader stats, subscriptions, live fills.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pmwallets-copytrade&lt;/code&gt;: the trading side. Market and order book lookups, balance, positions, and orders on Polymarket.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-g&lt;/span&gt; pmwallets pmwallets-copytrade     &lt;span class="c"&gt;# Node.js 20+&lt;/span&gt;
&lt;span class="c"&gt;# or&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;pmwallets pmwallets-copytrade  &lt;span class="c"&gt;# same commands&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You need a PMWallets API key (created on &lt;a href="https://pmwallets.com/keys" rel="noopener noreferrer"&gt;pmwallets.com/keys&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PMW_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pmw_...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pick traders to follow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pmwallets leaderboard &lt;span class="nt"&gt;--period&lt;/span&gt; 30d &lt;span class="nt"&gt;--minWinLo&lt;/span&gt; 0.55 &lt;span class="nt"&gt;--category&lt;/span&gt; politics &lt;span class="nt"&gt;--limit&lt;/span&gt; 20
pmwallets entity 7KQ2MF9X4B1C        &lt;span class="c"&gt;# one trader's stats, by handle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;minWinLo&lt;/code&gt; is the lower bound of the 95% confidence interval on a trader's win rate. A trader who won 3 bets out of 3 has a 100% win rate and a lower bound well under 0.5, because three trades say very little. Filtering on the lower bound pushes lucky streaks down the list.&lt;/p&gt;

&lt;p&gt;Then follow one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pmwallets subscribe 7KQ2MF9X4B1C     &lt;span class="c"&gt;# billed per hour while subscribed&lt;/span&gt;
pmwallets subscriptions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Getting fills to the agent
&lt;/h2&gt;

&lt;p&gt;There are two ways, depending on whether you can keep a process running.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pull: &lt;code&gt;new-fills&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;If your agent runs on a schedule (a cron job, a loop inside the agent, a chat agent you poke now and then), call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pmwallets new-fills
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returns the fills since the previous call, each one once. The position is kept in &lt;code&gt;~/.pmwallets/mcp-cursor.json&lt;/code&gt;, or wherever &lt;code&gt;PMW_MCP_STATE&lt;/code&gt; points. One reader per file: if two agents poll, give each its own &lt;code&gt;PMW_MCP_STATE&lt;/code&gt;, or they will split the fills between them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Push: &lt;code&gt;watch --exec&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;If you can keep a process up, &lt;code&gt;watch&lt;/code&gt; holds a live connection and runs your command for each batch of fills:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pmwallets watch &lt;span class="nt"&gt;--batch-sec&lt;/span&gt; 30 &lt;span class="nt"&gt;--exec&lt;/span&gt; &lt;span class="s1"&gt;'claude -p "Fills from traders I follow are on stdin. For each BUY above $1000 in a market ending after 2026-12-31, read the market with pmwallets-copytrade market, then buy $5 with --key &amp;lt;eventId&amp;gt; if the price is below 0.6. One line per fill: what you did." --allowedTools "Bash(pmwallets-copytrade:*)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fills arrive on the command's stdin as JSON lines, and the same lines are in a file named by &lt;code&gt;$PMW_BATCH_FILE&lt;/code&gt;. Fills that come in while the agent is still running go into the next batch. &lt;code&gt;--batch-sec 30&lt;/code&gt; waits 30 seconds after the first fill so that a burst becomes one agent run instead of ten.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--allowedTools "Bash(pmwallets-copytrade:*)"&lt;/code&gt; lets headless Claude run the trading CLI. Without it, Claude either can't run anything or you end up granting far more than it needs.&lt;/p&gt;

&lt;p&gt;For other agents, the command looks similar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Codex: its default sandbox has no network, so the trading CLI can't reach Polymarket&lt;/span&gt;
&lt;span class="nt"&gt;--exec&lt;/span&gt; &lt;span class="s1"&gt;'codex exec --sandbox danger-full-access "...same instructions..."'&lt;/span&gt;
&lt;span class="c"&gt;# Gemini CLI&lt;/span&gt;
&lt;span class="nt"&gt;--exec&lt;/span&gt; &lt;span class="s1"&gt;'gemini -p "..." --yolo'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;--exec&lt;/code&gt;, &lt;code&gt;watch&lt;/code&gt; just prints one JSON line per fill, which is handy for piping into your own script.&lt;/p&gt;

&lt;p&gt;A fill looks like this (trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"eventId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"137:78012345:0x…:0x…:212"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"side"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BUY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"taker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.570000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tokenId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1043…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"usdcAmount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1824.000000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sharesAmount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3200.000000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"market"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"question"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"outcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Yes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"endDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-03T00:00:00Z"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ws"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"receivedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-14T09:12:03.417Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw &lt;code&gt;usdc&lt;/code&gt; and &lt;code&gt;shares&lt;/code&gt; fields are integers in millionths. The &lt;code&gt;*Amount&lt;/code&gt; fields are the same values as decimals, so the agent doesn't have to divide by a million (models do get this wrong). The market question and outcome are attached, so the agent can tell what was bought without looking it up.&lt;/p&gt;

&lt;p&gt;A batch is handed to the command at most once. If the agent crashes, times out, or the machine reboots mid-run, that batch goes to &lt;code&gt;pmw-watch/failed/&lt;/code&gt; and is never run again, because the agent may already have placed an order. Those batches need to be checked by hand. A restart picks up the stream where it stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Placing orders
&lt;/h2&gt;

&lt;p&gt;First write a config. It starts in dry-run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pmwallets-copytrade init        &lt;span class="c"&gt;# writes config.yaml, mode: dry-run&lt;/span&gt;
pmwallets-copytrade check       &lt;span class="c"&gt;# checks keys and account, places nothing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In dry-run, every order is simulated against the live order book and nothing is sent. That is the mode to leave the agent in for a while.&lt;/p&gt;

&lt;p&gt;The commands an agent needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pmwallets-copytrade market &amp;lt;tokenId&amp;gt;      &lt;span class="c"&gt;# question, outcomes, prices, order book&lt;/span&gt;
pmwallets-copytrade balance
pmwallets-copytrade positions
pmwallets-copytrade order buy &amp;lt;tokenId&amp;gt; &lt;span class="nt"&gt;--usdc&lt;/span&gt; 5 &lt;span class="nt"&gt;--max-price&lt;/span&gt; 0.45 &lt;span class="nt"&gt;--key&lt;/span&gt; &amp;lt;eventId&amp;gt;
pmwallets-copytrade order sell &amp;lt;tokenId&amp;gt; &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--min-price&lt;/span&gt; 0.40 &lt;span class="nt"&gt;--key&lt;/span&gt; &amp;lt;eventId&amp;gt;:exit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;order&lt;/code&gt; commands follow these rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A BUY is fill-or-kill: all of it at &lt;code&gt;--max-price&lt;/code&gt; or better, or nothing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--usdc&lt;/code&gt; sets the maximum spend. The share count is &lt;code&gt;usdc / max-price&lt;/code&gt;, so the order can't cost more than &lt;code&gt;--usdc&lt;/code&gt; whatever it matches. It may spend less than that amount. Without &lt;code&gt;--max-price&lt;/code&gt; the limit is the current best ask.&lt;/li&gt;
&lt;li&gt;A SELL is fill-and-kill and is capped at what the account holds.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--key&lt;/code&gt; is an idempotency key. Pass the fill's &lt;code&gt;eventId&lt;/code&gt;. The same key never sends a second order, so an agent that retries after a timeout doesn't double up.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;status: unknown&lt;/code&gt; (or &lt;code&gt;verify: true&lt;/code&gt;) means the order may have filled. The agent should check &lt;code&gt;trades&lt;/code&gt; before sending another one. Exit codes: 0 filled or dry-run, 2 not traded, 3 unknown.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'd explicitly require &lt;code&gt;--key&lt;/code&gt; in the prompt, since agents may retry after a slow response. Without a key, that retry sends a second order.&lt;/p&gt;

&lt;p&gt;When you switch the config to &lt;code&gt;mode: live&lt;/code&gt;, give it a Polymarket account of its own with only what you're willing to lose in it. Polymarket doesn't accept orders from some countries (including the US and the UK); &lt;code&gt;pmwallets-copytrade check&lt;/code&gt; tells you whether your machine's location can trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP instead of a shell
&lt;/h2&gt;

&lt;p&gt;For Claude Desktop, Cursor, Windsurf, Cline and similar, the same tools are available as MCP servers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pmwallets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pmwallets"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"PMW_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pmw_..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"copytrade"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pmwallets-copytrade"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--config"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/abs/path/config.yaml"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;new_fills&lt;/code&gt; tool shares its position with &lt;code&gt;pmwallets new-fills&lt;/code&gt;, so you can mix the two, but the one-reader-per-file rule still applies.&lt;/p&gt;

&lt;p&gt;The trading MCP server rejects arguments it doesn't know. If a model sends &lt;code&gt;max_price&lt;/code&gt; instead of &lt;code&gt;maxPrice&lt;/code&gt;, the call fails with an error that lists the allowed names, rather than quietly dropping the limit and buying at whatever the ask is.&lt;/p&gt;

&lt;h2&gt;
  
  
  No shell, no MCP
&lt;/h2&gt;

&lt;p&gt;For Dify, Coze, n8n or a custom GPT, import the OpenAPI spec at &lt;code&gt;https://pmwallets.com/openapi.json&lt;/code&gt; and call the REST API with an &lt;code&gt;x-api-key&lt;/code&gt; header. Fills can also arrive as signed webhooks. Placing orders from there is up to you; the trading CLI needs a machine to run on.&lt;/p&gt;

&lt;h2&gt;
  
  
  How fast is it
&lt;/h2&gt;

&lt;p&gt;Two measurements from testing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A headless &lt;code&gt;claude -p&lt;/code&gt; took about 11 seconds to read one fill, look up the market and place a dry-run order. &lt;code&gt;codex exec&lt;/code&gt; took about 27 seconds for the same thing.&lt;/li&gt;
&lt;li&gt;In a production test, 57 real fills reached the agent command between 0.01 and 0.16 seconds after PMWallets pushed them, each with its market question and outcome attached.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of the delay comes from the agent. If you care about getting the same price as the trader you follow, waiting 10 to 30 seconds for a model matters: in fast markets the price will have moved. For that case I'd use a fixed-rule bot (&lt;code&gt;pmwallets-copytrade run&lt;/code&gt;). The agent setup is more useful when a task needs reading, such as skipping sports or only acting if the question mentions a specific event. It can also summarise what the people you follow did today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to check before going live
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run in dry-run for a while and read what the agent did.&lt;/li&gt;
&lt;li&gt;Put the key rule in the prompt: every order gets &lt;code&gt;--key &amp;lt;eventId&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;--usdc&lt;/code&gt; small and set &lt;code&gt;--max-price&lt;/code&gt; yourself rather than letting the model pick one.&lt;/li&gt;
&lt;li&gt;Look in &lt;code&gt;pmw-watch/failed/&lt;/code&gt; after any crash.&lt;/li&gt;
&lt;li&gt;Check whether you're allowed to trade on Polymarket from where you are.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is financial advice, and copying a trader is not the same as being that trader: you buy later, at a different price, and pay fees.&lt;/p&gt;

&lt;p&gt;The guide to hand your agent is printed by &lt;code&gt;npx pmwallets skill&lt;/code&gt;, and the same material is at &lt;a href="https://pmwallets.com/sdk#agents" rel="noopener noreferrer"&gt;pmwallets.com/sdk&lt;/a&gt;. Package versions at the time of writing: &lt;code&gt;pmwallets&lt;/code&gt; 0.6.5 and &lt;code&gt;pmwallets-copytrade&lt;/code&gt; 0.4.1.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I help maintain &lt;a href="https://pmwallets.com" rel="noopener noreferrer"&gt;PMWallets&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>tutorial</category>
      <category>polymarket</category>
    </item>
    <item>
      <title>Two Polymarket strategies that look fine until you add fees and a queue</title>
      <dc:creator>levl</dc:creator>
      <pubDate>Tue, 13 Oct 2026 18:18:49 +0000</pubDate>
      <link>https://dev.to/levl/two-polymarket-strategies-that-look-fine-until-you-add-fees-and-a-queue-5g4p</link>
      <guid>https://dev.to/levl/two-polymarket-strategies-that-look-fine-until-you-add-fees-and-a-queue-5g4p</guid>
      <description>&lt;p&gt;Filling at the mid and leaving out fees makes it pretty easy to get a profitable-looking backtest for a short-horizon Polymarket strategy. I wanted to see how much two simple ideas change once the backtest pays the taker fee, walks the book for depth, and makes resting orders wait in the queue.&lt;/p&gt;

&lt;p&gt;Both tests ran on one day (2026-09-08) of Polymarket's BTC 5-minute Up/Down markets. The data has order book snapshots and deltas for the UP and DOWN tokens, trade prints, and the Chainlink stream the markets settle on. The book is rebuilt from snapshots and from deltas thinned to a capture cadence, so it approximates the depth at each moment rather than replaying the exchange exactly.&lt;/p&gt;

&lt;h2&gt;
  
  
  An 82% win rate that loses money
&lt;/h2&gt;

&lt;p&gt;In the last 60 seconds of each market, the first strategy buys whichever side is priced higher, but only between 55¢ and 95¢:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;outcometick&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Strategy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;outcometick.backtest&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;load_sample&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Favourite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Strategy&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_market_open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;market&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;market&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;close_ts_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_tick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;close&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60_000&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="n"&gt;book&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;book&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;down&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;best&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;best&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DOWN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;down&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="n"&gt;side&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;down&lt;/span&gt; &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DOWN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;down&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;side&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;side&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;notional&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Favourite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;load_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;polymarket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  market-days          1
  net pnl              -$15.62
  trades               108
  win rate             82.4%
  brier                0.1397
  edge/contract        0.7¢
  max drawdown         -$284.51
  fees                 -$52.90  (venue schedule, estimated per book level)
  lost to slippage     -$4.22
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It won 82.4% of 108 trades, and the edge before fees was 0.7¢ per contract. Polymarket charges takers &lt;code&gt;C × rate × p × (1 − p)&lt;/code&gt; with a rate set per market, and that came to $52.90 for the day, more than the strategy made before fees. When you buy at 80–95¢, a high win rate is mostly what you paid for. Here, the 82.4% win rate still left a net loss of $15.62.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maker quotes that fill at the wrong moments
&lt;/h2&gt;

&lt;p&gt;For the second test, I tried resting orders to avoid the taker fees. This strategy quotes 25 shares of UP at the best bid and 25 of DOWN at one minus the best ask. It requotes at most once a second and exits inventory with a passive order one tick above entry, with 150 ms order latency and 100 ms cancel latency.&lt;/p&gt;

&lt;p&gt;To be filled, a resting order has to wait until trades have used up the size queued ahead of it at that price. Once it fills, a markout measures how far the mid has moved against you 1s, 10s and 60s later, and at settlement.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  trades               1105
  net pnl              -$26,133.42
rested 117061 orders, maker fill rate 0.0377 by size
median queue ahead at join 94.57, at fill 0.0
markout     1s: -0.0463 per share over 44594 fills
markout    10s: -0.0408 per share over 43760 fills
markout    60s: -0.0397 per share over 33437 fills
markout settle: -0.0462 per share over 44606 fills
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here a trade is one position from open to close, so the 1,105 trades were built from about 44,600 individual maker fills. The 1s markout averaged −4.6¢ a share and was still about −4.6¢ at settlement. A quote this simple tends to get filled when the other side knows something, for example when BTC has just moved on spot and the 5-minute market hasn't repriced yet. Those fills lost far more than the spread earned.&lt;/p&gt;

&lt;p&gt;Only 3.8% of the quoted size filled, and the median fill had about 95 shares ahead of it when the order joined the queue. If the backtest had filled each quote as soon as the price touched it, this would have looked like a strategy that earns the spread on every fill.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist for any Polymarket backtest
&lt;/h2&gt;

&lt;p&gt;When I look at a Polymarket backtest, these are the things I check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fees per market.&lt;/strong&gt; The taker fee depends on price and on each market's own rate. Assuming a flat fee, or no fee at all, overstates the returns of any strategy that crosses the spread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Book depth.&lt;/strong&gt; Fill an order level by level against the book as it stood at that moment, and report the size the book didn't have instead of filling it at the best price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency.&lt;/strong&gt; Match the order against the book as it was when the order would have arrived, not when the signal fired.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A queue for resting orders.&lt;/strong&gt; A price touch is not a fill: trades still have to use up the size ahead of your order. Skipping that wait makes maker strategies look far better than they are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Markouts.&lt;/strong&gt; For maker orders, I also check how the mid moves after a fill. The 1s markout helps explain the PnL by showing whether the price moved against the order almost immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settlement from the official outcome.&lt;/strong&gt; These markets settle on a Chainlink stream, and the market's own config says which one. Settling on a spot price you picked yourself can mislabel close markets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Out-of-sample days.&lt;/strong&gt; I wouldn't draw conclusions from this one day. Rank parameters on earlier days, then test the winners on later days that weren't used for ranking.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproducing this
&lt;/h2&gt;

&lt;p&gt;The code for these tests is in &lt;code&gt;quickstart.py&lt;/code&gt; and &lt;code&gt;maker_quotes.py&lt;/code&gt; in &lt;a href="https://github.com/outcometick/polymarket-backtest" rel="noopener noreferrer"&gt;polymarket-backtest&lt;/a&gt;. Install it with &lt;code&gt;pip install outcometick&lt;/code&gt;; &lt;code&gt;load_sample()&lt;/code&gt; downloads the same day used here. Each run took about two minutes on an 8-core machine.&lt;/p&gt;

&lt;p&gt;The backtester is MIT-licensed, and there's also a &lt;a href="https://github.com/outcometick/polymarket-backtest-js" rel="noopener noreferrer"&gt;JS version&lt;/a&gt;. The sample data is free under CC BY 4.0, requires no key or signup, and is also available on &lt;a href="https://www.kaggle.com/datasets/ligengxin96/polymarket-predict-fun-tick-data" rel="noopener noreferrer"&gt;Kaggle&lt;/a&gt; and &lt;a href="https://huggingface.co/datasets/Ligengxin96/polymarket-predict-fun-tick-data" rel="noopener noreferrer"&gt;Hugging Face&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For the out-of-sample check above, use &lt;code&gt;sweep(..., holdout=0.3)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you find a maker variant with a non-negative 1s markout on a different day, I'd like to see it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I help maintain this backtester and the &lt;a href="https://outcometick.com" rel="noopener noreferrer"&gt;outcometick&lt;/a&gt; dataset it reads.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>polymarket</category>
      <category>python</category>
      <category>trading</category>
      <category>backtesting</category>
    </item>
  </channel>
</rss>
