Phase-1 live signal emitter built at SHA 5e92c7f; Directive struct aligned to pmv2’s confirmed schema at SHA c12c830. The emitter shares the same observe engine as Phase-0 via a refactored library, applies the winning regime from crypto-shortterm-phase0-selectivity, and publishes structured directives to NATS or shadow-logs them when no NATS credentials are present.
DRY Refactor: observe/src/lib.rs
The observe crate was refactored from a monolithic observe/main.rs into a shared library so both the observe and emit binaries can reuse the same engine without duplication.
Extracted into crates/observe/src/lib.rs:
EngineStatestructObservationstructfloor_windowfunctionmake_windowfunctionto_f64function- All shared constants
Both observe/src/main.rs and emit/src/bin/emit.rs now import from observe::lib.
New Files
| Path | Purpose |
|---|---|
crates/observe/src/lib.rs | Shared engine library (extracted from main.rs) |
crates/observe/src/calibration.rs | CalibrationMap — load calibration.json, expose calibrate(p_fair) -> p_cal |
crates/observe/src/directive.rs | Directive struct + to_json() serialization |
crates/observe/src/bin/emit.rs | Emitter binary — the Phase-1 live signal emitter |
migrations/0003_emitted_signals.sql | crypto_shortterm.emitted_signals table schema |
crates/analysis/src/bin/cal_export.rs | Exports calibration data from DB to calibration.json |
Regime Filter
A window passes and fires a directive only if both conditions hold:
tau_secs ∈ [60, 150) AND |p_cal - 0.5| >= 0.30
p_cal = calibrate(p_fair) using the frozen calibration.json loaded at startup. This is identical to the winning cell from the selectivity sweep (t=4.61, 5/5 OOS folds positive — see crypto-shortterm-phase0-selectivity).
Single-Fire-Per-Window Guard
A HashSet<window_id> prevents double-firing on the same window. An additional EMIT_MAX_FIRES cap (default 50) provides a session-level ceiling to bound runaway firing.
NATS Integration
When NATS_URL is set in the environment, the emitter connects via async-nats = "0.37" (added to workspace) and publishes directives as JSON to subject "cohort.signals".
When NATS_URL is absent (shadow mode), the emitter logs the directive text instead of publishing. No NATS credentials are required to run or test the emitter in shadow mode.
Database Recording
Every fired directive is recorded to crypto_shortterm.emitted_signals with window_id as the primary key. This table was created by migrations/0003_emitted_signals.sql.
Directive Schema Aligned to pmv2 — Commit
c12c830The provisional
Directivestruct was replaced with the confirmed pmv2 schema. See pmv2 Directive Schema Alignment (c12c830) below for the full changeset.
pmv2 Directive Schema Alignment (c12c830)
Commit c12c830 replaced the provisional Directive struct in crates/observe/src/directive.rs with the pmv2-confirmed schema. This clears the earlier “ONE-SPOT SWAP REQUIRED” blocker.
Confirmed Directive Fields
| Field | Type | Notes |
|---|---|---|
id | String | Format: "clt-{window_id}" — idempotent on retry of the same window |
source | String | Fixed value: "crypto_shortterm_latency_test" |
schema_version | u32 | Always 1 |
action | String | Always "BUY" in v1 UP-only path |
market_slug | String | Polymarket market slug |
condition_id | String | Polymarket CLOB condition ID (0x… hex string) — extracted from Gamma API response |
yes_token_id | String | String, not a number — avoids i64 overflow on large Polymarket token IDs |
tick_size | f64 | From Gamma API; fallback constant DEFAULT_TICK_SIZE = 0.001 |
min_size | f64 | From Gamma API; fallback constant DEFAULT_MIN_SIZE = 5.0 |
neg_risk | bool | From Gamma API market object |
max_price | f64 | Tick-rounded, clamped: floor(min(ask + EMIT_PRICE_BUFFER, 0.99 - tick_size) / tick_size) * tick_size |
valid_until_ts | i64 | Default = window close (win_open + 300); overridable via EMIT_VALID_SECS env var |
bucket_label | Option<String> | Optional; τ-bucket label for downstream routing |
Removed Fields (Provisional → Confirmed)
The following fields from the original provisional struct were removed: token_id, side, size, emit_ts.
UP-Only v1 Gate
The emitter only fires when p_cal > 0.5 (UP conviction). DOWN conviction (p_cal <= 0.5) is logged as skipped but never published. The constant EMIT_BOTH_SIDES = false gates the unimplemented DOWN path at compile time.
max_price Formula
max_price = floor(min(ask + EMIT_PRICE_BUFFER, 0.99 - tick_size) / tick_size) * tick_size
Tick-rounded and clamped to stay below 0.99. EMIT_PRICE_BUFFER is a configured constant controlling how aggressively the limit order reaches through the ask.
NATS Dedup Header
On publish, the emitter sets Nats-Msg-Id = directive.id. pmv2 uses this header as the idempotency/dedup key — retrying the same window_id will not produce duplicate processing on the consumer side.
valid_until_ts and Latency Signal
valid_until_ts defaults to window close (win_open + 300). Setting EMIT_VALID_SECS to a tighter value causes pmv2 to drop slow round-trips (directive arrived after valid_until). This turns valid_until_ts into a latency signal: pmv2’s drop rate under various EMIT_VALID_SECS values directly measures the end-to-end latency distribution of the emit → pmv2 path.
polymarket.rs Changes
crates/observe/src/polymarket.rs now extracts the following fields directly from the Gamma API market object:
condition_id(the0x…hex string)neg_risk(bool)tick_size(f64)min_size(f64)
Fallback constants are applied when the Gamma API response omits a field.
Migration 0004
migrations/0004_directive_schema.sql adds id and yes_token_id columns to crypto_shortterm.emitted_signals and makes size nullable (size is no longer in the Directive struct).
NATS Subject
Directives are published to subject
"cohort.signals". This is unchanged from the initial build.
calibration.json Not Yet Generated
The emitter requires calibration.json to be present at startup. This file is not yet generated. Before deploying emit:
- Ensure the DB has enough OOS windows of recorded observations and outcomes.
- Run
crates/analysis/src/bin/cal_export.rsagainst the live DB. - Place the resulting
calibration.jsonwhere theemitbinary can find it at startup.
Without this file the emitter will fail to start.
Test Coverage
8 new unit tests added, all passing:
tauboundary cases: 59s (below regime), 60s (in), 150s (out), 151s (out)- Conviction boundary cases:
|p_cal - 0.5|at 0.29 (below threshold) and 0.30 (at threshold) - Side and price selection logic
- Directive JSON shape (field names and values)
CalibrationMapround-trip- Single-fire-per-window guard (HashSet dedup)
Build, clippy, and fmt all clean across all crates. 0 failures.
Pending Before Deployment
- Confirm Polymarket CLOB condition ID schema with @polymarket — swap
market.slugindirective.rs. - Run
cal_exportagainst DB to generatecalibration.json. - Set
NATS_URLin environment (or run in shadow mode first to verify directive shape).
Pre-Live Code Review + Hardening (bc38734)
A pre-live safety/correctness code review of the signal emitter — before it can place real money — was run against schema-align pin c12c830. It caught 3 BLOCKERS + 3 HIGH plus several medium/low findings. All fixed in commit bc38734. 21 tests green; gates clean. The full finding-by-finding breakdown with file changes lives in crypto-shortterm-emit-guardrails; the durable GOTCHAS are summarized below.
BLOCKER — condition_id slug fallback
If Gamma omits
conditionId, the code had fallen back to the market slug. A slug is NOT a valid0xcondition_id, so pmv2’s dedup + settlement would break. Fix: skip the fire (return None) unless a real0xcondition_id is present.
BLOCKER — wrong-token risk
The YES/UP token was selected by array index
clobTokenIds[0]. If Gamma reorders the array, you’d silently BUY the wrong outcome. Fix: select the token by matching the paralleloutcomeslabel ("up"/"yes"), and validate the token id parses asu128.
BLOCKER — guardrails reset on restart
The single-fire-per-window set and the
EMIT_MAX_FIREScap were in-memory only → a crash or deploy mid-run could re-fire already-fired windows or blow past the cap. Fix: hydrate both from theemitted_signalstable on startup (crash-durable).
HIGH — stale / zero / NaN ask
A stale, zero, or NaN ask could drive a bad
max_price(to_f64fell back to0.0). Fix: added a book staleness gate (5s), a binance staleness gate (30s), and finite/>0ask guards before pricing.
HIGH — NATS publish failure still marked the window fired
A NATS publish failure still marked the window as fired → the signal was silently lost. Fix: only mark fired after publish AND db-insert both succeed; otherwise retry on the next tick (idempotent via
Nats-Msg-Id).
Medium / Low findings (also fixed)
- MEDIUM —
valid_until_ts ≥ now+10sguard; 5s discover HTTP timeout; market-race assert (bookwindow_id== directivewindow_id); calibration p-clamp. - LOW — calibration embedded via
include_str!;emitted_signalsschema reconciled (migrations0004+0005).
Pattern
The build → review → fix loop is the standing pre-live discipline for any money-path code in this project. The review findings above are durable GOTCHAS worth re-reading before writing any future trade-execution code. A final review of deltas is planned right before the live flip.
Remaining gates to a live latency test
- Andras merges pmv2 #62 + the source migration.
- Deploy
polymarket-fetch. - Deploy
crypto-shortterm@bc38734+ add theemitservice. - Shadow-validate.
- Andras flips live with tiny caps.
Related
- crypto-shortterm-emit-guardrails — money-safety guardrail pass on this emitter (BLOCKER/HIGH/MEDIUM/LOW fixes before real-money deployment)
- crypto-shortterm-phase0-selectivity — winning regime (τ∈[60,150)s, c≥0.30) that the emitter targets
- crypto-shortterm-phase0-depthaware — offline gate SURVIVES; rationale for promoting to Phase-1 emitter build
- crypto-shortterm-phase0-codebase-complete — Phase-0 codebase foundation this emitter builds on
- crypto-shortterm-data-sources — Polymarket CLOB API, NATS, Supabase schema
- crypto-shortterm — project overview and phase state machine