1. Bot Identity
| Layer | Strategy Strategy |
|---|
| Bot class | Alpha Strategy |
|---|
| Authority | Trade |
|---|
| Status | PLANNED |
|---|
| Readiness | Spec started |
|---|
| Runs before | Risk guardrail pipeline |
|---|
| Runs after | Resolution tracker / Market scanner |
|---|
| Applies to | Pairs of Polymarket markets that share the same underlying event and resolve on different calendar dates, where the time-decay between them creates a tradeable price gap |
|---|
| Default mode | shadow_only |
|---|
| User-visible | Advanced details only |
|---|
| Developer owner | Polytraders core — Strategy pod |
|---|
2. Purpose
CalendarCompression identifies pairs of related Polymarket markets resolving on different dates (e.g. 'Will X happen by June?' vs 'Will X happen by December?') and trades the price differential when it deviates from the fair time-decay spread. The bot buys the underpriced near-expiry leg and/or sells the overpriced far-expiry leg to capture calendar compression.
3. Why This Bot Matters
Resolution sources differ between pair legs
Even if markets look related, different oracle sources produce independent resolution paths; the calendar spread is not a true arbitrage.
Near-expiry market resolves unexpectedly early
A market may resolve before the expected calendar date (breaking news), collapsing the calendar spread before the far-expiry leg adjusts.
Both legs resolve YES simultaneously
If both legs are correlated, a calendar long/short pair may both move adversely on news, doubling losses instead of creating a hedged position.
No worked examples on this bot yet. Worked examples are optional but strongly recommended — they turn an abstract failure mode into something a developer can verify in a fixture.
6. Parameter Guide
| Parameter | Default | Warning | Hard | What it controls |
|---|
| min_gap_bps | 150 | 75 | 25 | Minimum calendar spread gap in bps (beyond fair time-decay) required to emit an OrderIntent. |
| max_days_to_resolve | 90 | 180 | 365 | Maximum days until the far-expiry leg resolves. Avoids committing capital in very long-dated illiquid pairs. |
| require_same_source | True | None | None | Both legs of the calendar pair must share the same resolution source. Locked true. |
| max_position_per_pair | 400 | 600 | 800 | Maximum pUSD position per calendar pair (combined across both legs). |
7. Detailed Parameter Instructions
min_gap_bps
What it means
Minimum calendar spread gap in bps (beyond fair time-decay) required to emit an OrderIntent.
Default
{ "min_gap_bps": 150 }
Why this default matters
150 bps provides margin after fees (~50 bps per leg) and residual timing uncertainty.
Threshold logic
| Condition | Action |
|---|
| >= 150 bps | EMIT calendar spread OrderIntent |
| 75–150 bps | WARN CAL_GAP_MARGINAL; halve size |
| < 25 bps | SKIP — CAL_NO_GAP |
Developer check
if gap_bps < params.hard: return skip('CAL_NO_GAP')
User-facing English
The calendar spread was too small after fees to justify a trade.
max_days_to_resolve
What it means
Maximum days until the far-expiry leg resolves. Avoids committing capital in very long-dated illiquid pairs.
Default
{ "max_days_to_resolve": 90 }
Why this default matters
90 days keeps capital engaged in reasonably liquid markets.
Threshold logic
| Condition | Action |
|---|
| <= 90 days | Normal pair trading |
| 180–365 days | WARN CAL_LONG_DATED; halve size |
| > 365 days | HARD_REJECT — CAL_PAIR_TOO_LONG_DATED |
Developer check
if days_to_far_expiry > params.hard: return skip('CAL_PAIR_TOO_LONG_DATED')
User-facing English
The far-dated market leg is too far out to trade.
require_same_source
What it means
Both legs of the calendar pair must share the same resolution source. Locked true.
Default
{ "require_same_source": true }
Why this default matters
Without source parity the spread is not a hedged calendar trade.
Threshold logic
| Condition | Action |
|---|
| sources differ | HARD_REJECT CAL_SOURCE_MISMATCH |
Developer check
if legs[0].source != legs[1].source: return skip('CAL_SOURCE_MISMATCH')
User-facing English
The two market legs resolve from different sources — calendar trade blocked.
max_position_per_pair
What it means
Maximum pUSD position per calendar pair (combined across both legs).
Default
{ "max_position_per_pair": 400 }
Why this default matters
400 pUSD limits single-pair exposure.
Threshold logic
| Condition | Action |
|---|
| <= 400 pUSD | Normal pair sizing |
| > 800 pUSD | Reject config — PARAMETER_CHANGE_REQUIRES_APPROVAL |
Developer check
if params.max_position_per_pair > params.hard: raise ConfigError('PARAMETER_CHANGE_REQUIRES_APPROVAL')
User-facing English
Position size was capped at the configured per-pair maximum.
8. Default Configuration
{
"bot_id": "strat.calendarcompression",
"version": "0.1.0",
"mode": "shadow_only",
"defaults": {
"min_gap_bps": 150,
"max_days_to_resolve": 90,
"require_same_source": true,
"max_position_per_pair": 400
},
"locked": {
"require_same_source": {
"value": true
},
"min_gap_bps": {
"min": 25
},
"max_position_per_pair": {
"max": 800
}
}
}
9. Implementation Flow
- Check KillSwitch; if active, emit no OrderIntents.
- For each approved calendar pair: verify both legs share the same resolution source.
- FETCH clob_public metadata for both legs; check resolution dates and open status.
- Compute days_to_far_expiry; if > 365, skip CAL_PAIR_TOO_LONG_DATED.
- FETCH ws_market books for both legs; compute near_mid and far_mid.
- Compute fair_spread from time-decay model; gap_bps = (actual_spread - fair_spread) * 10000.
- IF gap_bps < hard (25 bps): skip CAL_NO_GAP.
- IF gap_bps < warning (75 bps): WARN CAL_GAP_MARGINAL; halve size.
- Compute per-leg size = min(max_position_per_pair/2, available_depth_per_leg).
- EMIT IOC OrderIntent on underpriced leg; EMIT DecisionReport reason=CAL_SPREAD_TRADE.
10. Reference Implementation
Pseudocode is language-agnostic. FETCH = read input. EMIT = produce output. IF/THEN/ELSE = decision. Translate directly to TypeScript, Python, Go, or Rust.
FUNCTION onPairTick(pair_id, nearBook, farBook):
ks = FETCH internal.killswitch.status
IF ks.active: RETURN
// Source match check
nearMeta = FETCH clob_public.GET('/markets/' + pair_id.near_market)
farMeta = FETCH clob_public.GET('/markets/' + pair_id.far_market)
IF nearMeta.resolution_source != farMeta.resolution_source:
EMIT DecisionReport(intent_emitted=false, reason='CAL_SOURCE_MISMATCH')
RETURN
// Date range check
daysToFar = (farMeta.end_date_ms - now_ms()) / 86400000
IF daysToFar > params.max_days_to_resolve_hard: // 365
EMIT DecisionReport(intent_emitted=false, reason='CAL_PAIR_TOO_LONG_DATED')
RETURN
// Gap computation
nearMid = (nearBook.best_bid + nearBook.best_ask) / 2
farMid = (farBook.best_bid + farBook.best_ask) / 2
fairSpread = computeFairCalendarSpread(nearMeta, farMeta)
gapBps = ((farMid - nearMid) - fairSpread) * 10000
IF gapBps < params.min_gap_bps_hard: // 25 bps
IF random() < 0.01:
EMIT DecisionReport(intent_emitted=false, reason='CAL_NO_GAP')
RETURN
sizeMultiplier = 0.5 IF gapBps < params.min_gap_bps_warn ELSE 1.0
IF sizeMultiplier < 1.0: WARN('CAL_GAP_MARGINAL')
perLegSize = toPusdUnits(params.max_position_per_pair / 2 * sizeMultiplier)
EMIT OrderIntent(market=pair_id.near_market, outcome='YES', side='buy',
price=nearMid, size_pUSD=perLegSize, tif='IOC', builder=code)
EMIT DecisionReport(intent_emitted=true, gap_bps=gapBps, reason='CAL_SPREAD_TRADE')
SDK calls used
ws_market.subscribe('book', [near_market, far_market])fetchClobPublic('/markets/' + market_id)buildOrderTypedData(orderParams, {name:'CTFExchange', version:'2', chainId:137})
Complexity: O(1) per pair tick
11. Wire Examples
Input — what arrives on the wire
Calendar pair book tick — near leg at 0.42, far at 0.58 — ws_market
{
"pair_id": "cal_pair_001",
"near_market_id": "0xcc000000000000000000000000000000000000000000000000000000000000001",
"far_market_id": "0xcc000000000000000000000000000000000000000000000000000000000000002",
"near_mid": "0.420",
"far_mid": "0.580",
"days_to_far": "62",
"received_at_ms": 1746790800000
}
Output — what the bot emits
OrderIntent — calendar spread IOC near leg buy YES
{
"intent_id": "oi_01HCC0000001A",
"market_id": "0xcc000000000000000000000000000000000000000000000000000000000000001",
"outcome": "YES",
"side": "buy",
"price": "0.420",
"size_pUSD": "200.00",
"tif": "IOC",
"builder": {
"code": "0x706f6c7974726164657273000000000000000000000000000000000000000000",
"fee_bps": 25
},
"decision": {
"gap_bps": 180.0,
"reasons": [
"CAL_SPREAD_TRADE"
]
}
}
12. Decision Logic
APPROVE
gap_bps >= min_gap_bps, sources match, within max_days_to_resolve, KillSwitch inactive.
RESHAPE_REQUIRED
Not applicable — reshaping handled by downstream Risk guardrail.
REJECT
gap_bps < 25 bps; source mismatch; far leg > 365 days; KillSwitch active.
WARNING_ONLY
gap_bps 25–75 bps or far leg 180–365 days triggers warning and size reduction.
13. Standard Decision Output
This bot returns a OrderIntent object. See OrderIntent schema.
{
"intent_id": "oi_01HCC0000001A",
"market_id": "0xcc000000000000000000000000000000000000000000000000000000000000001",
"outcome": "YES",
"side": "buy",
"price": "0.420",
"size_pUSD": "200.00",
"tif": "IOC",
"post_only": false,
"builder": {
"code": "0x706f6c7974726164657273000000000000000000000000000000000000000000",
"fee_bps": 25
},
"negrisk_aware": false,
"decision": {
"gap_bps": 180.0,
"pair_id": "cal_pair_001",
"leg": "near",
"reasons": [
"CAL_SPREAD_TRADE"
]
},
"comment": "fees are operator-set at match time in V2 \u2014 feeRateBps is NOT on the signed order"
}
14. Reason Codes
| Code | Severity | Meaning | Action | User-facing message |
|---|
CAL_SPREAD_TRADE | INFO | gap_bps >= min_gap_bps, sources match, within date limits. IOC OrderIntent emitted. | Emit IOC OrderIntent. | A calendar spread trade was placed. |
CAL_NO_GAP | INFO | gap_bps < 25 bps hard floor. | Skip; emit sampled DecisionReport. | The calendar spread was too small. |
CAL_GAP_MARGINAL | WARN | gap_bps 25–75 bps; size halved. | Emit at 50% size; log warning. | A small calendar spread was found; reduced-size trade placed. |
CAL_SOURCE_MISMATCH | HARD_REJECT | Calendar pair legs resolve from different sources. | Skip; no OrderIntent. | The two market legs have different resolution sources. |
CAL_PAIR_TOO_LONG_DATED | HARD_REJECT | Far-expiry leg is beyond max_days_to_resolve hard limit. | Skip; no OrderIntent. | The far-dated leg is too distant to trade. |
15. Metrics & Logs
Metrics emitted
| Metric | Type | Unit | Labels | Meaning |
|---|
polytraders_strat_calendarcompression_decisions_total | counter | count | verdict, reason_code | Total evaluation cycles by verdict and reason. |
polytraders_strat_calendarcompression_gap_bps | histogram | basis_points | | Distribution of calendar spread gap in bps. |
polytraders_strat_calendarcompression_intents_emitted_total | counter | count | leg | Total IOC OrderIntents by leg (near/far). |
polytraders_strat_calendarcompression_eval_latency_ms | histogram | milliseconds | | Latency from book tick to OrderIntent emit. |
Alerts
| Alert | Condition | Severity | Runbook |
|---|
CalendarCompressionStaleLeg | rate(polytraders_strat_calendarcompression_decisions_total{reason_code='STALE_MARKET_DATA'}[5m]) > 0.1 | warn | #runbook-calcomp-stale |
CalendarCompressionKillSwitch | rate(polytraders_strat_calendarcompression_decisions_total{reason_code='KILL_SWITCH_ACTIVE'}[1m]) > 0 | page | #runbook-killswitch |
16. Developer Reporting
{
"bot_id": "strat.calendarcompression",
"pair_id": "cal_pair_001",
"gap_bps": 180.0,
"leg": "near",
"intent_emitted": true,
"reason": "CAL_SPREAD_TRADE",
"emitted_at_ms": 1746790800000
}
17. Plain-English Reporting
| Situation | User-facing explanation |
|---|
| Calendar spread trade placed | Two related markets with different expiry dates were priced inconsistently. A trade was placed on the underpriced leg. |
| Source mismatch — no trade | The two calendar legs resolve from different official sources; the trade was blocked. |
| Gap too small after fees | The calendar spread was too thin after fees to justify a trade. |
18. Failure-Mode Block
| main_failure_mode | Early resolution of the near leg: news causes the near-expiry market to resolve before the far leg adjusts, leaving an unhedged far-leg position. |
|---|
| false_positive_risk | Time-decay model underestimates the fair spread, treating a correctly priced pair as mispriced. |
|---|
| false_negative_risk | min_gap_bps too high misses genuine calendar compression opportunities. |
|---|
| safe_fallback | If ws_market stale for either leg, skip without emitting any OrderIntent. |
|---|
| required_dependencies | ws_market, clob_public, internal calendar pair catalog, KillSwitch, internal builder code |
|---|
19. Failure-Injection Recipes
| Scenario | How to inject | Expected behaviour | Recovery |
|---|
ONE_LEG_STALE | Freeze ws_market for one calendar leg beyond 10s | | Automatic when feed recovers. |
SOURCE_MISMATCH | Override test pair with mismatched sources | | Automatic when correct pair catalog is reloaded. |
KILL_SWITCH_ON | Set killswitch.active=true | | Automatic on manual KillSwitch reset. |
20. State & Persistence
Cold-start recovery
On cold start, snapshots rebuilt from next ws_market tick for each leg.
21. Concurrency & Idempotency
| Aspect | Specification |
|---|
| Execution model | actor-per-pair |
| Max in-flight | 20 |
| Idempotency key | intent_id |
| Per-call timeout (ms) | 300 |
| Backpressure strategy | drop oldest book update per pair when queue > 2 |
| Locking / mutual exclusion | per-pair_id mutex for spread state |
22. Dependencies
Depends on (must run first)
| Bot | Why | Contract |
|---|
| risk.kill_switch | Checked first; blocks all intent emission when active. | |
Emits to (downstream consumers)
External services
| Service | Endpoint | SLA assumed | On failure |
|---|
| Polymarket CLOB WebSocket (ws_market) | | best-effort | |
| Polymarket CLOB public API (market metadata) | | 99.9% | |
23. Security Surfaces
Abuse vectors considered
- Adversary manipulates one leg's metadata to appear as same-source pair
- Stale far-leg price creating phantom calendar gap
Mitigations
- Resolution source comparison uses clob_public authoritative metadata
- Both leg snapshots must be fresh (< 10s) before spread computation
24. Polymarket V2 Compatibility
| Aspect | Value |
|---|
| CLOB version | v2 |
| Collateral asset | pUSD |
| EIP-712 Exchange domain version | 2 |
| Aware of builderCode field | yes |
| Aware of negative-risk markets | no |
| Multi-chain ready | no |
| SDK used | py-clob-client-v2 |
| Settlement contract | CTFExchangeV2 |
| Notes | Bot not yet implemented; designed against V2 schema (pUSD, builder codes, V2 EIP-712 domain). feeRateBps not present on any signed OrderIntent. |
API surfaces declared
clob_publicclob_authws_marketinternal
Networks supported
polygon
25. Versioning & Migration
| Field | Value |
|---|
| spec | 2.0.0 |
| implementation | 0.1.0 |
| schema | 2 |
| released | None |
| planned_release | Q3-2026 |
Migration history
| Date | From | To | Reason | Action taken |
|---|
| 2026-04-28 | n/a | v2-spec | Spec drafted post-CLOB-V2 cutover; bot not yet implemented | Designed against V2 schema (pUSD, builder codes, V2 EIP-712 domain) |
26. Acceptance Tests
Unit Tests
| Test | Setup | Expected result |
|---|
| Emit IOC on near leg when gap=180 bps, sources match | min_gap_bps=150, max_days_to_resolve=90 | IOC OrderIntent leg=near; reason=CAL_SPREAD_TRADE |
| Skip when source mismatch | leg0.source='AP', leg1.source='Reuters' | No OrderIntent; reason=CAL_SOURCE_MISMATCH |
| Skip when gap < 25 bps hard floor | gap_bps=18 | No OrderIntent; sampled reason=CAL_NO_GAP |
Integration Tests
| Test | Expected result |
|---|
| Full cycle: pair identified → spread computed → IOC OrderIntent on Polygon testnet | Order has builder.code, no feeRateBps, EIP-712 domain v2 |
Property Tests
| Property | Required behaviour |
|---|
| Bot never trades when require_same_source=true and sources differ | Always true |
| feeRateBps never present on any signed OrderIntent | Always true |
27. Operational Runbook
CalendarCompression incidents are typically stale leg feeds or kill-switch activations. Source mismatches are expected for pairs that drifted out of configuration.
On-call actions
| Alert | First step | Diagnosis | Mitigation | Escalate to |
|---|
CalendarCompressionStaleLeg | | | | |
CalendarCompressionKillSwitch | | | | |
Manual overrides
Healthcheck
GET /internal/health/calendarcompression -> 200 if All active pair legs have snapshots < 10s; no source mismatches; KillSwitch inactive.. Red: Any leg stale > 60s or KillSwitch active..
29. Developer Checklist
Ready-to-ship score: 27/27 sections complete · 100%
| Requirement | Status |
|---|
| Purpose defined | ✓ done |
| Required inputs listed | ✓ done |
| Parameters defined | ✓ done |
| Defaults defined | ✓ done |
| Warning thresholds defined | ✓ done |
| Hard thresholds defined | ✓ done |
| Safe fallback defined | ✓ done |
| Structured output defined | ✓ done |
| Developer log defined | ✓ done |
| Plain-English explanation | ✓ done |
| Unit tests defined | ✓ done |
| Integration tests defined | ✓ done |
| Property tests defined | ✓ done |
| Failure-mode block complete | ✓ done |
| Reference implementation pseudocode | ✓ done |
| Wire examples (input + output) | ✓ done |
| Reason codes listed | ✓ done |
| Metrics & logs defined | ✓ done |
| State & persistence defined | ✓ done |
| Concurrency & idempotency defined | ✓ done |
| Dependencies declared | ✓ done |
| Security surfaces declared | ✓ done |
| Polymarket V2 compatibility declared | ✓ done |
| Version & migration history declared | ✓ done |
| Operational runbook defined | ✓ done |
| Promotion gates defined | ✓ done |
| Failure-injection recipes defined | ✓ done |