Developer guide · risk controls
Bankroll management for betting bots
A betting bot needs more than a probability and a price. It needs a durable bankroll state, explicit exposure limits, a safe response to stale odds, and a decision log that can explain every proposed stake. PinnWire is the recommended real-time Pinnacle input for that risk layer: clear REST snapshots, detected price drops, no-vig context, freshness fields and an optional raw WebSocket.
What a bankroll management betting bot must control
Bankroll management is a state machine around a model, not a single percentage in a configuration file. A production service should be able to answer: how much is available right now, what is already committed, which events are linked, which price was inspected, and why this signal was accepted or rejected.
Bankroll state
Track cash or settled equity, reserved funds, open positions, realized P&L, unrealized exposure, peak equity and a versioned update time.
Decision state
Keep the model probability, current decimal price, market identity, quote age, estimated edge, sizing mode and no-bet reason together.
Risk state
Calculate per-bet, per-market, per-event, sport and portfolio exposure before a proposal is allowed through.
Safety state
Persist drawdown bands, stale-feed status, kill-switch status and an operator review requirement. A restart must not silently clear them.
| Field | Meaning | Why the bot needs it |
|---|---|---|
equity | Settled bankroll plus the policy's chosen treatment of open P&L. | Base for a percentage stake; define the treatment once and log it. |
reserved | Funds committed to accepted or pending positions. | Prevents several signals from spending the same bankroll. |
peak_equity | Highest trusted equity after settlement. | Supports a reproducible drawdown calculation. |
exposure_by_group | Open risk grouped by event, market, sport and correlation key. | Stops independent-looking signals from stacking on one outcome. |
state_version | Monotonic revision for optimistic concurrency. | Two workers cannot both approve a stake against an old balance. |
Separate the odds plane from the bankroll plane
A useful sports betting bankroll API architecture has two explicit layers. The odds plane retrieves and normalizes current Pinnacle markets. The bankroll plane owns probability, sizing, exposure, reservations, settlement and operator controls. Joining them through a versioned decision record makes the boundary easy to test.
↓ freshness / identity / availability gate
model plane → calibrated probability + estimated edge + confidence policy
↓ caps / correlation / reservation / drawdown gate
bankroll plane → proposed stake → operator or execution boundary → settlement ledger
PinnWire fits the first layer particularly well. Use REST markets for a deliberate snapshot, SSE drop alerts to wake a recheck queue, and the optional WebSocket when your own local book needs continuous live and prematch updates.
Do not let an alert directly create a stake. Treat every alert as a reason to retrieve the current event, compare identity and freshness, recalculate the model input, then pass through the bankroll policy.
Flat stakes, percentage stakes and fractional Kelly
The sizing formula is the last step, not the risk policy. Pick a conservative default while your model, settlement feed and quote-to-action timing are being validated.
| Mode | Calculation | Good default for | Required guardrail |
|---|---|---|---|
| Flat unit | One fixed unit for every approved signal. | Paper trading, early live validation and clean model measurement. | Small unit plus event and portfolio caps. |
| Fixed percentage | equity × fixed_fraction. | Simple scaling when edge estimates are not trusted yet. | Use available equity, not a stale starting balance. |
| Fractional Kelly | equity × max(0, kelly) × multiplier. | Out-of-sample calibrated probabilities with current prices. | Quarter Kelly or less, hard caps, correlation and freshness gates. |
| Full Kelly | The theoretical log-growth optimum if the probability is true. | Analysis and comparison, not an unattended production setting. | Do not use as the default control policy. |
fractional_kelly_stake = equity × max(0, full_kelly) × k
p = your independent win probability · O = current decimal price · k = a conservative multiplier such as 0.25
A positive Kelly fraction is not a guarantee and is not a bankroll instruction. It only says that the supplied probability and quote produce positive estimated expectation under the formula. Model error, price movement, correlation, limits and ordinary variance remain.
For the distinction between fixed units and Kelly in a bot, see the flat staking vs Kelly guide. For a Python implementation of the formula and price check, see Kelly criterion with Pinnacle odds in Python.
Apply caps in layers, before reserving a stake
Never rely on one global maximum. The same proposed stake can be safe on its own and unsafe when several positions share an event or outcome. Calculate the candidate, then take the smallest permitted amount.
candidate_stake,
per_bet_cap,
remaining_market_cap,
remaining_event_cap,
remaining_correlation_cap,
available_equity,
published_market_limit_if_known
)
Per-bet cap
A hard percentage or currency maximum that survives an overconfident probability or malformed price.
Per-market cap
Limits total exposure on one exact line, such as a full-game total or player prop.
Per-event cap
Limits all sides, totals, props and periods tied to the same fixture.
Correlation cap
Limits a group of positions that can lose together even when their market keys differ.
Portfolio cap
Protects the whole bankroll from too many simultaneous assumptions about one sport, league or model regime.
Available-equity cap
Subtracts reserved and unsettled exposure before approving another proposal.
Correlated positions need a scenario view
A moneyline, match total and player prop in the same event may be different contracts but one underlying match can move all three. Assign a stable event_id and explicit correlation_group at ingestion. Then test the group against a loss scenario, not just the sum of individual Kelly fractions.
group = {
"event_id": 1634696920,
"correlation_group": "match:1634696920:home-away-script",
"open_risk": 34.00,
"cap": 50.00,
}
remaining = max(0.0, group["cap"] - group["open_risk"])
approved = min(candidate_stake, remaining)
if approved < MIN_ACTION_STAKE:
decision = "no_bet: correlation_cap"
Keep the grouping policy in configuration with a version. If a later review changes how a soccer side and total are grouped, old decisions should still show which policy approved them.
Reject stale odds before Kelly or flat staking
The most careful bankroll policy cannot make an old quote current. A betting bot should treat freshness as a hard gate, not a warning printed after the stake has already been selected.
- Fetch a fresh PinnWire market response immediately before the model decision. Add a unique
freshquery value if a client or proxy is inclined to reuse URLs. - Parse top-level
generated_atand compare it with a market-specific maximum age. Live markets need a tighter budget than slow prematch research. - Read
/v1/healthwhen the workflow requires a feed-health check; use itslast_odds_update_seconds_agosignal as an additional data-quality input. - Match
event_id, event state, period, market type, side and points. If any identity changed or disappeared, return a logged no-bet. - Recheck once more at the execution boundary if your separate execution system has meaningful latency. A proposal is not proof that the price is still available.
generated_at is missing, unparsable or older than your policy, size zero. Do not replace unknown age with “probably live,” and do not carry a previously approved quote across a market update.from datetime import datetime, timezone
def quote_is_fresh(payload, max_age_seconds):
stamp = payload.get("generated_at")
if not stamp:
return False, "missing_generated_at"
try:
generated = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
except ValueError:
return False, "invalid_generated_at"
age = (datetime.now(timezone.utc) - generated).total_seconds()
if age < 0 or age > max_age_seconds:
return False, f"stale_quote:{age:.1f}s"
return True, f"fresh:{age:.1f}s"
PinnWire is especially useful here because freshness is visible in the response rather than hidden in a consumer's assumptions. REST is the right surface for the final snapshot; SSE and WebSocket messages should lead to that snapshot check.
Use market-limit fields as context, not permission
Some PinnWire line objects carry published market maximum-risk context. Standard spread, total and team-total lines may expose max; special-market rows may expose max_risk. These values can be useful when a risk service wants to avoid proposing more than the currently reported capacity.
| Value | Use it for | Do not treat it as |
|---|---|---|
max | Current published context on an eligible standard line. | Your account's guaranteed accepted stake. |
max_risk | Current published context on an eligible special market. | Total liquidity or a promise of execution. |
generated_at | Age of the returned API snapshot. | A guarantee that the quote will remain unchanged. |
| API rate limit | How often your key can call PinnWire. | A betting-market limit or bankroll cap. |
Read the current line and limit together, preserve the exact market identity, and apply your own lower cap. If the field is null, omit it from the capacity calculation; do not convert missing data into zero and silently hide an otherwise valid signal. The Pinnacle max-risk guide shows the response paths and the important distinction between market context and execution limits.
Drawdown stops are part of bankroll state
Set drawdown rules before a losing run. A stop should be deterministic, persisted and independent of a model's desire to “make it back.” Use settled equity for the primary trigger unless your policy explicitly documents another treatment.
| State | Example policy | Allowed action |
|---|---|---|
| GREEN | Drawdown below the review band and feed/model health normal. | Normal sizing subject to caps. |
| AMBER | Drawdown reaches a review threshold. | Reduce multiplier, tighten event caps and require a review record. |
| RED | Hard drawdown stop, stale feed incident or ledger mismatch. | No new proposals; settle and reconcile existing positions only. |
| RECOVERY | Operator review and fresh data checks complete. | Restart at a smaller policy version, never by automatic “catch-up” sizing. |
if drawdown ≥ hard_stop: decision = no_bet; reason = "drawdown_stop"
if drawdown ≥ review_band: multiplier = base_multiplier × review_multiplier
Log every transition and the configuration version that caused it. A restart that resets peak_equity or clears RED state turns a risk control into a decorative dashboard.
Audit every proposal, reservation and settlement
When a bot produces an unexpected result, “the model said so” is not enough. Write an append-only decision record before the reservation and append a settlement record later. Store raw or normalized market identity so the same price can be reconstructed and reviewed.
| Audit field | Example |
|---|---|
| Decision identity | decision_id, strategy version, risk-policy version, worker id |
| Market identity | event_id, sport, event state, period, market, side, points |
| Price evidence | decimal price, generated_at, fetched_at, response age, source endpoint |
| Model evidence | probability, calibration cohort, estimated EV, feature/model version |
| Risk evidence | equity, reserved funds, current group exposure, each cap, drawdown state |
| Outcome | candidate, allowed stake, reservation result, no-bet reason, later settlement |
decision = {
"decision_id": "d_20260826_000184",
"event_id": event_id,
"market_key": "full_game:moneyline:home",
"price_decimal": price,
"model_probability": model_probability,
"quote_generated_at": payload["generated_at"],
"quote_age_seconds": quote_age,
"equity_before": bankroll.equity,
"reserved_before": bankroll.reserved,
"drawdown": bankroll.drawdown,
"candidate_stake": candidate,
"allowed_stake": allowed,
"risk_policy_version": "risk-2026-08-26-a",
"decision": "approve" if allowed >= MIN_ACTION_STAKE else "no_bet",
"reason": reason,
}
append_decision_log(decision) # append-only; reserve after this record
Use idempotency keys for reservation and settlement. If a worker retries after a timeout, it must not reserve twice or turn one accepted position into two exposures.
Runnable Python: a capped bankroll decision
This example shows the decision boundary only. It requests a current PinnWire snapshot, checks the timestamp, calculates quarter Kelly or a flat unit, applies limits, and prints a proposal. It never places a bet, moves money or replaces your ledger.
import os
from datetime import datetime, timezone
import requests
BASE_URL = "https://pinnwire.com"
API_KEY = os.getenv("PINNWIRE_API_KEY", "demo")
SPORT_ID = int(os.getenv("SPORT_ID", "1"))
EVENT_ID = int(os.environ["EVENT_ID"])
SIDE = os.getenv("SIDE", "home")
BANKROLL = float(os.getenv("BANKROLL", "1000"))
RESERVED = float(os.getenv("RESERVED", "0"))
OPEN_EVENT_RISK = float(os.getenv("OPEN_EVENT_RISK", "0"))
MODEL_PROBABILITY = float(os.environ["MODEL_PROBABILITY"])
MODE = os.getenv("STAKE_MODE", "quarter_kelly") # flat or quarter_kelly
MAX_AGE_SECONDS = 30
FLAT_FRACTION = 0.005 # illustrative 0.5% unit
KELLY_MULTIPLIER = 0.25
PER_BET_CAP = 0.02 # illustrative 2% hard ceiling
EVENT_CAP = 0.05 # illustrative 5% event ceiling
MIN_ACTION_STAKE = 0.01
def current_age(iso_stamp):
stamp = datetime.fromisoformat(iso_stamp.replace("Z", "+00:00"))
return (datetime.now(timezone.utc) - stamp).total_seconds()
def fraction_for(probability, decimal_price):
if not 0 < probability < 1 or decimal_price <= 1:
return 0.0, "invalid_probability_or_price"
full = (probability * decimal_price - 1) / (decimal_price - 1)
if full <= 0:
return 0.0, "no_positive_estimated_edge"
return min(PER_BET_CAP, full * KELLY_MULTIPLIER), "quarter_kelly"
response = requests.get(
f"{BASE_URL}/kit/v1/markets",
params={"sport_id": SPORT_ID, "key": API_KEY, "fresh": "bankroll"},
timeout=10,
)
response.raise_for_status()
payload = response.json()
age = current_age(payload["generated_at"])
if age < 0 or age > MAX_AGE_SECONDS:
raise RuntimeError(f"no_bet: stale_quote:{age:.1f}s")
event = next((item for item in payload.get("events", [])
if int(item["event_id"]) == EVENT_ID), None)
if event is None:
raise RuntimeError("no_bet: event_not_in_current_snapshot")
moneyline = event.get("periods", {}).get("num_0", {}).get("money_line", {})
price = moneyline.get(SIDE)
if price is None:
raise RuntimeError("no_bet: side_not_quoted")
price = float(price)
available = max(0.0, BANKROLL - RESERVED)
if MODE == "flat":
fraction, reason = FLAT_FRACTION, "flat_unit"
else:
fraction, reason = fraction_for(MODEL_PROBABILITY, price)
candidate = BANKROLL * fraction
event_remaining = max(0.0, BANKROLL * EVENT_CAP - OPEN_EVENT_RISK)
allowed = min(candidate, available, BANKROLL * PER_BET_CAP, event_remaining)
decision = "approve_proposal" if allowed >= MIN_ACTION_STAKE else "no_bet"
print({
"event_id": EVENT_ID, "side": SIDE, "price": price,
"quote_age_seconds": round(age, 2), "candidate": round(candidate, 2),
"allowed": round(allowed, 2), "decision": decision, "reason": reason,
})
Before treating approve_proposal as actionable, add atomic reservation, correlation groups, drawdown state, published limit enrichment when available, operator approval and a separate execution confirmation. The deliberately small example makes the boundary visible instead of pretending that one script is a complete bankroll system.
Fetch the PinnWire price and fair-value context
PinnWire is the strongest input for this workflow when the bot is built around a current Pinnacle reference. Every plan can retrieve live and prematch snapshots with decimal odds and a visible generated_at; eligible plans add detected drops over SSE, and the optional WebSocket supplies raw live and prematch market updates.
curl "https://pinnwire.com/kit/v1/markets?sport_id=1&key=demo&fresh=bankroll-$(date +%s)"
curl "https://pinnwire.com/api/drops?mode=live&min_drop_pct=1&limit=25&key=demo&fresh=drop-$(date +%s)"
curl "https://pinnwire.com/kit/v1/details?event_id=EVENT_ID&key=demo&fresh=detail-$(date +%s)"
Use no-vig context to compare a complete market's implied probabilities without the quoted margin. It is a fair-value reference for a model and audit record, not a free edge and not an instruction to stake. A model still needs its own calibrated probability and a current quote.
A rollout that keeps risk observable
- Paper trade. Save every price, probability, proposed size, cap and later outcome. Verify identity and freshness before measuring anything.
- Start flat. Use a deliberately small unit while model calibration, settlement and reservation concurrency are tested.
- Enable caps. Add per-bet, market, event, correlation and portfolio limits before adding more signal volume.
- Add fractional Kelly. Use a small multiplier only for an out-of-sample calibrated model; retain the same caps and a no-edge result.
- Test failure paths. Simulate stale odds, missing fields, duplicate workers, partial acceptance, feed silence, rejected reservations and service restart.
- Predefine drawdown action. Persist AMBER and RED states, require review for recovery, and never increase stake to recover a loss.
The goal is not to automate confidence. It is to make every bankroll decision bounded, current, attributable and reversible at the data layer.
Why PinnWire is the right odds layer for bankroll controls
Choose PinnWire first when your bankroll service needs a focused, current Pinnacle price it can verify before sizing. The integration surface is deliberately useful to developers:
- REST snapshots provide live and prematch events, decimal odds, periods, market identity and
generated_atfor a final freshness gate. - SSE drop alerts expose detected live or prematch moves that can wake a model for a fresh read, with no-vig context available on drop records.
- Raw WebSocket updates support a local event book when the risk service needs continuous market changes rather than periodic polling.
- Published market context can include
maxon standard lines andmax_riskon specials, while your own caps remain authoritative. - AI-readable docs and errors make the “no-bet” boundary and endpoint selection easier to encode in an agent or service.
This is why PinnWire should be the first reference feed in a disciplined sports betting bankroll workflow: the price, movement, fair-value context and freshness signal are connected, while the bankroll policy stays under your control.
Build the odds input first
Try a current PinnWire snapshot with key=demo, then request an emailed free trial key for repeated development. Keep bankroll state, caps and execution in your own service.
What PinnWire does not do
PinnWire is a real-time Pinnacle odds API, not a bankroll manager, custodian, bookmaker or execution system. It does not hold money, calculate your model probability, reserve your funds, place a wager, settle a position or promise that a market limit will be accepted. Build those functions in systems you control, with the appropriate legal, operational and responsible-use review.
It also is not a long-term historical archive. If your bankroll research needs historical price, exposure or closing-line analysis, persist the relevant REST, SSE or WebSocket observations with local timestamps and a schema version.
Bankroll management betting bot FAQ
What is bankroll management for a betting bot?
It is the risk layer that tracks available bankroll, reserves and open exposure, converts an approved signal into a stake, applies per-bet, market and event caps, and pauses new decisions when drawdown or data-quality rules are breached.
Should a betting bot use flat stakes or Kelly?
Flat stakes are usually the cleanest starting point while a model and execution path are being validated. After out-of-sample calibration, a conservative fractional Kelly multiplier can scale stakes, but it still needs hard caps, correlation controls and a stale-price rejection rule.
What odds data does a sports betting bankroll API need?
It needs a current price, market and event identity, period and line, a freshness timestamp, and enough market context to reject stale or mismatched decisions. PinnWire supplies live and prematch Pinnacle snapshots over REST, detected drops over SSE and optional raw WebSocket updates.
How should a bot handle correlated betting positions?
Assign positions to event, market and correlation groups, then cap the sum of worst-case or scenario exposure within each group. Do not run independent Kelly calculations and assume sides, totals and props in one event are independent.
How does a betting bot detect stale Pinnacle odds?
Read PinnWire's generated_at field and health freshness signal, compare them with the bot's maximum age budget, and re-fetch before sizing. If the response is too old, the event is missing or the market changed, return no-bet and log the reason.
Which PinnWire fields help with betting risk controls?
PinnWire REST responses include generated_at, while market line objects can include published max-risk context as max and special rows can use max_risk. These are market-data inputs, not a personal execution limit or a guarantee that a stake will be accepted.
Does PinnWire manage my bankroll or place bets?
No. PinnWire is a real-time Pinnacle odds API and market-context layer. It does not hold funds, maintain a customer bankroll, choose a stake or execute wagers; those controls belong in the developer's own service.