Polytraders Dev Guide
internal
v3 spine Phase 1 · Shared contracts 9 demo-wired · 0 shadow-ready · 0 production-live · 100 pending · 109 total 15/33 infra tasks the plan status board
HomeBy LayerStrategy3.16 CalendarCompression

3.16 CalendarCompression

Strategy Alpha Strategy Trade PLANNED Spec started capital · Direct P8 · Additional strategies pending stub

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.

v3 readiness

Docs27/27
donehow scored
Impl0/15
pendinghow scored
Backtest0/4
pendinghow scored
Runtime0/8
pendinghow scored

A bot is done when all four scores are. What does done mean?

1. Bot Identity

LayerStrategy  Strategy
Bot classAlpha Strategy
AuthorityTrade
StatusPLANNED
ReadinessSpec started
Runs beforeRisk guardrail pipeline
Runs afterResolution tracker / Market scanner
Applies toPairs 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 modeshadow_only
User-visibleAdvanced details only
Developer ownerPolytraders 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.

4. Required Polymarket Inputs

InputSourceRequired?Use
CLOB mid and depth for both legsws_marketYesMeasure calendar spread and available depth per leg.
Market metadata (resolution date, source)clob_publicYesIdentify calendar pairs and verify shared resolution source.
Market status (open, resolved)clob_publicYesSkip resolved legs.

5. Required Internal Inputs

InputSourceRequired?Use
KillSwitch active flagKillSwitchYesAbort all intent emission if KillSwitch active.
Calendar pair catalog (approved near/far pairs)internal configYesRestrict trading to pre-approved related-market pairs.
Builder code bytes32internal configYesInjected into builder field on every signed V2 OrderIntent.

6. Parameter Guide

ParameterDefaultWarningHardWhat it controls
min_gap_bps1507525Minimum calendar spread gap in bps (beyond fair time-decay) required to emit an OrderIntent.
max_days_to_resolve90180365Maximum days until the far-expiry leg resolves. Avoids committing capital in very long-dated illiquid pairs.
require_same_sourceTrueNoneNoneBoth legs of the calendar pair must share the same resolution source. Locked true.
max_position_per_pair400600800Maximum 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

ConditionAction
>= 150 bpsEMIT calendar spread OrderIntent
75–150 bpsWARN CAL_GAP_MARGINAL; halve size
< 25 bpsSKIP — 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

ConditionAction
<= 90 daysNormal pair trading
180–365 daysWARN CAL_LONG_DATED; halve size
> 365 daysHARD_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

ConditionAction
sources differHARD_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

ConditionAction
<= 400 pUSDNormal pair sizing
> 800 pUSDReject 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

  1. Check KillSwitch; if active, emit no OrderIntents.
  2. For each approved calendar pair: verify both legs share the same resolution source.
  3. FETCH clob_public metadata for both legs; check resolution dates and open status.
  4. Compute days_to_far_expiry; if > 365, skip CAL_PAIR_TOO_LONG_DATED.
  5. FETCH ws_market books for both legs; compute near_mid and far_mid.
  6. Compute fair_spread from time-decay model; gap_bps = (actual_spread - fair_spread) * 10000.
  7. IF gap_bps < hard (25 bps): skip CAL_NO_GAP.
  8. IF gap_bps < warning (75 bps): WARN CAL_GAP_MARGINAL; halve size.
  9. Compute per-leg size = min(max_position_per_pair/2, available_depth_per_leg).
  10. 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.58ws_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

CodeSeverityMeaningActionUser-facing message
CAL_SPREAD_TRADEINFOgap_bps >= min_gap_bps, sources match, within date limits. IOC OrderIntent emitted.Emit IOC OrderIntent.A calendar spread trade was placed.
CAL_NO_GAPINFOgap_bps < 25 bps hard floor.Skip; emit sampled DecisionReport.The calendar spread was too small.
CAL_GAP_MARGINALWARNgap_bps 25–75 bps; size halved.Emit at 50% size; log warning.A small calendar spread was found; reduced-size trade placed.
CAL_SOURCE_MISMATCHHARD_REJECTCalendar pair legs resolve from different sources.Skip; no OrderIntent.The two market legs have different resolution sources.
CAL_PAIR_TOO_LONG_DATEDHARD_REJECTFar-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

MetricTypeUnitLabelsMeaning
polytraders_strat_calendarcompression_decisions_totalcountercountverdict, reason_codeTotal evaluation cycles by verdict and reason.
polytraders_strat_calendarcompression_gap_bpshistogrambasis_pointsDistribution of calendar spread gap in bps.
polytraders_strat_calendarcompression_intents_emitted_totalcountercountlegTotal IOC OrderIntents by leg (near/far).
polytraders_strat_calendarcompression_eval_latency_mshistogrammillisecondsLatency from book tick to OrderIntent emit.

Alerts

AlertConditionSeverityRunbook
CalendarCompressionStaleLegrate(polytraders_strat_calendarcompression_decisions_total{reason_code='STALE_MARKET_DATA'}[5m]) > 0.1warn#runbook-calcomp-stale
CalendarCompressionKillSwitchrate(polytraders_strat_calendarcompression_decisions_total{reason_code='KILL_SWITCH_ACTIVE'}[1m]) > 0page#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

SituationUser-facing explanation
Calendar spread trade placedTwo related markets with different expiry dates were priced inconsistently. A trade was placed on the underpriced leg.
Source mismatch — no tradeThe two calendar legs resolve from different official sources; the trade was blocked.
Gap too small after feesThe calendar spread was too thin after fees to justify a trade.

18. Failure-Mode Block

main_failure_modeEarly 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_riskTime-decay model underestimates the fair spread, treating a correctly priced pair as mispriced.
false_negative_riskmin_gap_bps too high misses genuine calendar compression opportunities.
safe_fallbackIf ws_market stale for either leg, skip without emitting any OrderIntent.
required_dependenciesws_market, clob_public, internal calendar pair catalog, KillSwitch, internal builder code

19. Failure-Injection Recipes

ScenarioHow to injectExpected behaviourRecovery
ONE_LEG_STALEFreeze ws_market for one calendar leg beyond 10sAutomatic when feed recovers.
SOURCE_MISMATCHOverride test pair with mismatched sourcesAutomatic when correct pair catalog is reloaded.
KILL_SWITCH_ONSet killswitch.active=trueAutomatic 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

AspectSpecification
Execution modelactor-per-pair
Max in-flight20
Idempotency keyintent_id
Per-call timeout (ms)300
Backpressure strategydrop oldest book update per pair when queue > 2
Locking / mutual exclusionper-pair_id mutex for spread state

22. Dependencies

Depends on (must run first)

BotWhyContract
risk.kill_switchChecked first; blocks all intent emission when active.

Emits to (downstream consumers)

External services

ServiceEndpointSLA assumedOn 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

AspectValue
CLOB versionv2
Collateral assetpUSD
EIP-712 Exchange domain version2
Aware of builderCode fieldyes
Aware of negative-risk marketsno
Multi-chain readyno
SDK usedpy-clob-client-v2
Settlement contractCTFExchangeV2
NotesBot 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

FieldValue
spec2.0.0
implementation0.1.0
schema2
releasedNone
planned_releaseQ3-2026

Migration history

DateFromToReasonAction taken
2026-04-28n/av2-specSpec drafted post-CLOB-V2 cutover; bot not yet implementedDesigned against V2 schema (pUSD, builder codes, V2 EIP-712 domain)

26. Acceptance Tests

Unit Tests

TestSetupExpected result
Emit IOC on near leg when gap=180 bps, sources matchmin_gap_bps=150, max_days_to_resolve=90IOC OrderIntent leg=near; reason=CAL_SPREAD_TRADE
Skip when source mismatchleg0.source='AP', leg1.source='Reuters'No OrderIntent; reason=CAL_SOURCE_MISMATCH
Skip when gap < 25 bps hard floorgap_bps=18No OrderIntent; sampled reason=CAL_NO_GAP

Integration Tests

TestExpected result
Full cycle: pair identified → spread computed → IOC OrderIntent on Polygon testnetOrder has builder.code, no feeRateBps, EIP-712 domain v2

Property Tests

PropertyRequired behaviour
Bot never trades when require_same_source=true and sources differAlways true
feeRateBps never present on any signed OrderIntentAlways 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

AlertFirst stepDiagnosisMitigationEscalate 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..

28. Promotion Gates

A bot does not advance to the next readiness state until every gate below is green. Gates are observable from production data — no subjective sign-off.

Promote to Shadow

GateHow measuredThreshold
Unit tests pass including source-mismatch block and long-dated rejectionCI test run100% pass

Promote to Limited live

GateHow measuredThreshold
p99 eval latency < 300ms over 24h shadow runpolytraders_strat_calendarcompression_eval_latency_ms histogramp99 < 300ms

Promote to General live

GateHow measuredThreshold
E2E: pair identified → calendar spread trade → IOC OrderIntent on Polygon testnetE2E testPass

29. Developer Checklist

Ready-to-ship score: 27/27 sections complete · 100%

RequirementStatus
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