DEV Community

Cover image for How to Build Risk Controls for a Polymarket Trading Bot
Dexoryn
Dexoryn

Posted on

How to Build Risk Controls for a Polymarket Trading Bot

A trading bot does not become safer because it is automated.

In fact, automation can make a bad strategy much more dangerous because software can execute the same mistake hundreds of times without hesitation.

For a Polymarket trading bot, the most important question is therefore not only:

When should the bot trade?

It is also:

When should the bot refuse to trade?

A useful risk layer can control position size, market exposure, price movement, liquidity, duplicate signals, and the number of positions open at the same time.

This article shows how to build those controls in Python.

Why a trading bot needs a risk layer

Imagine a strategy produces this signal:

signal = {
"market": "example-market",
"side": "BUY",
"price": 0.42,
"size": 100
}

Without a risk layer, the execution system might immediately submit it.

That creates a problem.

The strategy may not know that:

  • The portfolio already has significant exposure.
  • The market moved from $0.42 to $0.48.
  • Available liquidity is too low.
  • The bot already processed the same signal.
  • The market is no longer active.
  • The proposed position is too large.
  • The account has reached its daily loss limit.

The risk engine exists between the strategy and execution.

It acts as a gatekeeper.

Start with a maximum position size

The simplest control is a maximum amount that the bot can allocate to one trade.

MAX_TRADE_SIZE = 50

Then:

def check_trade_size(size):
return size <= MAX_TRADE_SIZE

If the strategy requests:

size = 35

the trade passes.

If it requests:

size = 100

the risk layer rejects it.

This sounds basic, but hard limits are valuable because they protect against unexpected strategy behavior.

Position sizing should be separate from risk limits

Your strategy might calculate a position size based on a target wallet, signal strength, or portfolio allocation.

That does not mean the strategy should have final authority over how much money can be traded.

For example:

def calculate_size(target_trade):
return target_trade * 0.05

Then the risk layer applies its own limit:

def apply_max_size(size):
return min(size, MAX_TRADE_SIZE)

This gives you two separate concepts.

Sizing logic decides what the strategy wants.

Risk logic decides what the system allows.

That separation makes the application easier to control.

Add a maximum portfolio exposure

A bot can make every individual trade look reasonable while creating an unreasonable portfolio.

Suppose your maximum capital allocation is $500.

The bot already has:

Market A: $100
Market B: $120
Market C: $80
Market D: $100

Current exposure is:

$400

The strategy now wants to open another $150 position.

The trade itself might be acceptable.

The portfolio is not.

A simple exposure check can prevent it:

MAX_EXPOSURE = 500

def check_exposure(current_exposure, new_trade):
return (
current_exposure + new_trade
<= MAX_EXPOSURE
)

This is an important distinction.

Trade risk and portfolio risk are not the same thing.

Add a per-market limit

You should also prevent the bot from accumulating too much exposure in a single market.

MAX_MARKET_EXPOSURE = 100

Then:

def check_market_exposure(
current_market_exposure,
new_trade
):
return (
current_market_exposure + new_trade
<= MAX_MARKET_EXPOSURE
)

This becomes especially useful when a strategy receives several signals related to the same market.

Without a market-level limit, repeated signals can quietly build a large position.

Don't assume different markets mean different risks

A more advanced system should also consider correlated exposure.

For example, a bot could hold positions in several markets that are all affected by the same underlying event.

The markets are technically different.

The economic risk may not be.

This is where portfolio-level risk becomes more important than simply counting the number of open positions.

A system with ten small positions is not necessarily safer than a system with three.

It depends on what those positions represent.

Add a price-movement check

This is especially important for copy trading.

Suppose the target trader entered at:

$0.40

Your bot receives the signal when the market is:

$0.46

Blindly copying the trade may no longer make sense.

You can define a maximum acceptable price difference:

MAX_PRICE_DEVIATION = 0.03

Then:

def price_is_valid(
target_price,
current_price
):
deviation = abs(
current_price - target_price
)

return deviation <= MAX_PRICE_DEVIATION
Enter fullscreen mode Exit fullscreen mode

If the deviation is too large, the bot skips the trade.

The important principle is that a signal can become invalid after it is generated.

Check liquidity before executing

Price movement is only one part of execution.

The bot should also consider available liquidity.

Suppose the best ask is $0.42.

That sounds attractive.

But perhaps only 10 shares are available there.

Your bot wants to buy 200.

The actual average execution price could be considerably higher.

A simple liquidity check might be:

MIN_LIQUIDITY = 100

def check_liquidity(available_liquidity):
return available_liquidity >= MIN_LIQUIDITY

A more sophisticated implementation should estimate the expected average execution price for the intended order size.

That connects the risk engine directly to the order book.

Prevent duplicate trades

Event-driven systems need idempotency.

Consider this sequence:

Trade detected
Trade processed
Order submitted
Same event received again
Second order submitted

You now have twice the intended position.

Create an identifier for every processed event:

def create_event_id(trade):
return (
f"{trade['wallet']}-"
f"{trade['market']}-"
f"{trade['timestamp']}-"
f"{trade['side']}"
)

Then maintain processed events:

processed_events = set()

def is_new_event(event_id):
if event_id in processed_events:
return False

processed_events.add(event_id)
return True
Enter fullscreen mode Exit fullscreen mode

For production software, this state should be stored durably rather than relying only on an in-memory set.

Otherwise a restart can cause the application to forget what it already processed.

Add a maximum number of open positions

Another useful control is a maximum number of simultaneous positions.

MAX_OPEN_POSITIONS = 10

Then:

def can_open_position(open_positions):
return len(open_positions) < MAX_OPEN_POSITIONS

This prevents a strategy from expanding indefinitely when many signals arrive at once.

It is particularly useful for bots monitoring multiple wallets or markets.

Add a cooldown

Sometimes a strategy can generate repeated signals for the same market.

A cooldown can prevent the bot from repeatedly entering within a short period.

COOLDOWN_SECONDS = 60

A simple implementation:

last_trade_time = {}

def cooldown_finished(market, now):
previous = last_trade_time.get(market)

if previous is None:
    return True

return (
    now - previous
    >= COOLDOWN_SECONDS
)
Enter fullscreen mode Exit fullscreen mode

This is not appropriate for every strategy.

A high-frequency strategy may require very different behavior.

The point is that the control should be intentional rather than accidental.

Add a daily loss limit

A bot should also have a point where it stops trading.

For example:

MAX_DAILY_LOSS = 50

Then:

def daily_limit_reached(realized_pnl):
return realized_pnl <= -MAX_DAILY_LOSS

If the limit is reached:

if daily_limit_reached(realized_pnl):
disable_trading()

This prevents a malfunctioning or poorly performing strategy from continuing to accumulate losses simply because it continues receiving signals.

Use a central risk engine

Once you have several rules, putting them into random parts of the code becomes difficult to maintain.

A dedicated class is cleaner:

class RiskEngine:

def approve(self, trade, portfolio, market):

    if trade.size > MAX_TRADE_SIZE:
        return False

    if portfolio.exposure + trade.size > MAX_EXPOSURE:
        return False

    if market.exposure + trade.size > MAX_MARKET_EXPOSURE:
        return False

    if not market.active:
        return False

    if not price_is_valid(
        trade.target_price,
        market.current_price
    ):
        return False

    if not check_liquidity(
        market.available_liquidity
    ):
        return False

    return True
Enter fullscreen mode Exit fullscreen mode

The strategy can then ask:

if risk_engine.approve(
trade,
portfolio,
market
):
execute_trade(trade)

This architecture creates a clear boundary.

The strategy proposes.

The risk engine decides.

The execution layer acts.

Every rejection should be logged

A risk engine is only useful if you can understand what it is doing.

Instead of:

return False

without any explanation, record the reason.

For example:

return {
"approved": False,
"reason": "Maximum market exposure reached"
}

Then your logs might contain:

13:02:14 | BUY | APPROVED
13:03:21 | BUY | REJECTED | price moved
13:04:07 | BUY | REJECTED | exposure limit
13:05:43 | BUY | REJECTED | insufficient liquidity

This makes debugging and strategy evaluation much easier.

Dry-run mode should use the same risk engine

One of the best ways to test a trading bot is to run the entire decision process without sending real orders.

The important part is that dry-run mode should not bypass risk controls.

It should go through exactly the same checks:

approved = risk_engine.approve(
trade,
portfolio,
market
)

if not approved:
log_rejection()
elif DRY_RUN:
log_simulated_trade()
else:
execute_trade()

This allows you to observe how the system would behave before real execution.

Current open-source Polymarket bot implementations use dry-run configurations and position/exposure controls for this type of testing.

What happens when the API fails?

Risk management is not only about market conditions.

Software failures matter too.

Imagine the bot cannot retrieve the latest market state.

Should it trade anyway?

Usually, the safer answer is no.

You can implement a fail-closed approach:

market = get_market()

if market is None:
return reject(
"Market state unavailable"
)

The same principle can apply when:

  • The order book is stale.
  • Authentication fails.
  • The WebSocket disconnects.
  • Position data cannot be retrieved.
  • The local state is inconsistent.
  • The market status is unknown.

A trading bot should not assume that missing information means everything is fine.

Risk controls should be configurable

Avoid scattering constants throughout your code.

Instead:

RISK_CONFIG = {
"max_trade_size": 50,
"max_exposure": 500,
"max_market_exposure": 100,
"max_open_positions": 10,
"max_price_deviation": 0.03,
"max_daily_loss": 50
}

This makes it easier to test different configurations.

You can also keep strategy configuration separate from infrastructure configuration.

For example:

strategy_config
risk_config
execution_config
api_config

That separation becomes increasingly useful as the project grows.

Risk management is not a guarantee of profit

This distinction is important.

A risk engine can prevent certain trades.

It cannot make a bad strategy profitable.

It can reduce exposure.

It cannot predict the future.

It can reject trades when liquidity is poor.

It cannot guarantee good execution.

It can stop trading after a loss threshold.

It cannot guarantee that the remaining capital will be profitable.

Risk management exists to control how the system can lose, not to guarantee that it will win.

A practical decision function

The final decision can remain simple:

def evaluate_trade(
trade,
portfolio,
market
):

checks = [
    check_trade_size(trade.size),
    check_exposure(
        portfolio.exposure,
        trade.size
    ),
    check_market_exposure(
        market.exposure,
        trade.size
    ),
    market.active,
    price_is_valid(
        trade.target_price,
        market.current_price
    ),
    check_liquidity(
        market.available_liquidity
    ),
    can_open_position(
        portfolio.open_positions
    )
]

return all(checks)
Enter fullscreen mode Exit fullscreen mode

The individual rules can become much more sophisticated later.

The architecture does not need to become complicated immediately.

How this applies to Polymarket copy trading

For copy trading, the risk engine becomes particularly important because the bot is responding to someone else's actions.

The target wallet generates a signal.

Your system still needs to determine:

  • Whether the market is active
  • Whether the price has moved
  • Whether sufficient liquidity exists
  • How much to copy
  • Whether your portfolio has enough capacity
  • Whether the event is new
  • Whether the trade fits your risk limits

That is why a copy-trading bot should never be designed as:

copy_everything(target_wallet)

A better abstraction is:

observe_target()
evaluate_signal()
check_risk()
execute_if_allowed()

The difference is small in code.

It is enormous in system behavior.

Final thoughts

The most important component of an automated trading system may be the code that prevents it from trading.

A strategy can generate opportunities.

An API can submit orders.

A WebSocket can provide real-time information.

But without a risk layer, all of those capabilities can work against you.

For a Polymarket bot, start with simple controls:

Maximum trade size.

Maximum portfolio exposure.

Maximum market exposure.

Price-deviation limits.

Liquidity checks.

Duplicate-event protection.

Open-position limits.

Daily loss limits.

Fail-closed behavior.

Then test those controls in dry-run mode before connecting live execution.

The objective is not to build a bot that trades as often as possible.

It is to build one that knows when not to trade.

Top comments (0)