jev-trade

September 20, 2026 · View on GitHub

A simulated high-frequency crypto trading loop that makes its buy/sell calls with Jev, TypeSafe AI's System One model.

Recent prices and volumes for BTC/ETH go in; a typed, confidence-aware decision comes out; a cost-aware execution simulator turns it into P&L you can actually argue with.


One correction to the brief

The task description said Jev's output latency is extremely slow. It is the opposite, and the difference matters for this design.

TypeSafe reports 70–500 ms end-to-end, against 3–329 s for frontier models, because Jev does not generate tokens at all — a parallel sampler emits every answer in one shot. That speed is precisely why it suits "System One" work: fast, intuitive, low-deliberation judgment, the kind a human scalper does at a glance. A genuinely slow model would be unusable here, for reasons this repo measures rather than asserts (see The latency experiment).

The rest of the brief stands, and the design follows it: a fast, natively structured decision model is a good fit for reading a tape.

Everything below still holds if you disagree about the latency. The engine treats latency as a tunable, not an assumption — --extra-latency-ms lets you simulate a model of any speed and watch what it does to the strategy.


What it does

 Kraken / synthetic feed          all arithmetic lives here
        │                    ┌──────────────────────────────┐
        ▼                    │  features.py   returns, vol, │
   ┌─────────┐               │                z-scores,     │
   │  ticks  │──────────────▶│                imbalance     │
   └─────────┘               └──────────────┬───────────────┘

                             ┌──────────────────────────────┐
                             │ discretize.py  numbers→words │
                             └──────────────┬───────────────┘
                                            ▼  state (words only)
                             ┌──────────────────────────────┐
                             │  POST /v1/systemone          │
                             │  6 typed questions, 1 call   │◀── jev-latest
                             └──────────────┬───────────────┘
                                            ▼  typed answers + confidence
                             ┌──────────────────────────────┐
                             │  policy.py    answers→units  │
                             └──────────────┬───────────────┘

                             ┌──────────────────────────────┐
                             │  engine.py    latency, gates │
                             │  execution.py fills, fees    │
                             └──────────────────────────────┘

The governing rule comes from TypeSafe's own jaggedness notes:

"Jev is not a calculator. We strongly recommend implementing any mathematical logic in code."

So code owns every number and Jev owns every judgment. The model is never asked what 76,412.30 minus 76,398.10 is, or whether that gap is large. It is asked whether the tape leans one way, and it answers with a probability distribution over options we defined.

Quickstart

No required dependencies, no build step, Python 3.10+.

python -m jevtrade.cli backtest --baselines     # run and score the loop
python -m jevtrade.cli decide                   # one decision, fully unpacked
python -m jevtrade.cli sweep                    # what latency costs you
python -m jevtrade.cli fetch --symbol ETH       # real bars from Kraken
python -m jevtrade.cli listing --provider mock  # read exchange announcements
python -m jevtrade.cli fx --provider mock       # read central banks, graded on spot FX
python -m jevtrade.cli fx --context --provider mock  # statements read against what was priced
python -m jevtrade.cli fx --dots                    # the dot-plot rule, no model at all
python -m jevtrade.cli fx --presser --provider mock # the press conference, 30 minutes later

--presser reads PDF transcripts, which is the one thing the standard library cannot do: pip install 'jev-trade[pdf]' adds pypdf, and without it that arm reports itself dark and everything else runs unchanged.

Without TYPESAFE_API_KEY the loop runs against an offline stub and says so, loudly, on every report. With a key it calls the real thing:

export TYPESAFE_API_KEY=sk-...
python -m jevtrade.cli models
python -m jevtrade.cli backtest --provider jev

The Jev integration

Three files carry it. They are worth reading in this order.

1. discretize.py — numbers become words

Jev is documented as weaker on numeric representations than semantic ones, so no float ever reaches it. The raw features stay on the decision record for the audit trail; the model sees this:

{
  "instrument": "BTC-USD spot, 250 ms snapshots",
  "price_action": {
    "last_snapshot": "drifting up",
    "last_5_snapshots": "rising",
    "last_15_snapshots": "rising hard",
    "streak": "7 consecutive up ticks",
    "recent_balance": "8 of the last 10 snapshots closed higher",
    "character": "a clean one-way run",
    "versus_session": "far above the session's average traded price"
  },
  "activity": { "volatility": "normal", "traded_volume": "light" },
  "order_book": {
    "resting_size": "heavily weighted to buyers",
    "depth": "thinner than usual",
    "cost_to_cross_the_spread": "small next to a typical move"
  },
  "our_book": { "exposure": "slightly long", "open_position_pnl": "a small gain" }
}

Every bucketed value comes from a closed vocabulary, and a test asserts it.

One choice worth calling out: the spread is described relative to the typical tick move, not in basis points. "Costs about as much as a typical move to cross" is the fact that decides whether a scalp is viable. "3.4 bps" is not a judgment, it is a number, and numbers are our job.

2. questions.py — six judgments, one request

Jev ingests the state once and evaluates every question against it in parallel, so extra questions cost a few tokens and almost no latency (speculative fan-out). One broad "should I trade this?" is therefore split into six atomic ones:

idtypeasks
directionChoiceup / down / unclear
follow_throughChoicecontinuation / reversal / no pattern
setup_qualityScorehow much the parts of the tape agree, on a 4-level rubric
liquidity_okNoulcan we open normal size right now?
disorderlyNoulis this a dislocation to stay out of?
cut_positionNoulshould the open position be reduced?

Choice is relative (which option wins) and Noul is absolute (whether a condition holds). TypeSafe warns these are not comparable to each other, so the policy never mixes their thresholds.

3. policy.py — answers become a quantity

The model never sees a position size. Code combines the six answers with weights that live in a dataclass, so tuning the strategy means changing a number in PolicyConfig, not rewriting a prompt:

edge        = P(up) - P(down)                    # from the Choice distribution
conviction  = setup_quality rescaled to 0..1
multiplier  = follow-through and confidence adjustments
target      = clamp(edge × conviction × multiplier × gain) × max_units

then five hard gates in order — disorderly, cut_position, low_confidence, weak_setup, no_edge — plus an illiquid gate that blocks increases but never blocks an exit. A thin book must not be able to trap us in a position. Every gate has a test.

Reading the output honestly

A backtest is the easiest thing in finance to lie with. Four things to know before believing any number this repo prints.

1. The default provider is not Jev. Jev is a closed API behind an early-access waitlist. Without a key you get MockJevClient, a hand-written scoring function over the same vocabulary the model sees. It reads only the state dict, so it has no oracle access to the simulator's hidden state — but it is not a model, and every report it produces is stamped provider=mock. Numbers from it describe this repo's plumbing and nothing else.

2. The synthetic tape has a known, planted edge. SyntheticFeed runs a hidden AR(1) process that biases the next return and leaks into the observable book imbalance. So book imbalance genuinely predicts the next move, by an amount you set with --alpha. This is deliberate: it makes the harness interpretable. With --alpha 0 there is nothing to find, and a test asserts that a book-reading rule earns nothing on such a tape. Finding an edge in a market that was built to contain one is not evidence about a model.

3. The default run loses money, and that is the correct result. Here is a default backtest:

P&L
  gross              28.90
  fees              -91.60
  net               -62.69   (-0.825% of capital at risk)

Risk
  turnover      915,968  over 250 fills
  break-even fee  0.316 bps   (the strategy is viable only below this taker fee)

Decisions
  hit rate      67.8% over 339 calls   (scored where the decision was formed)

A 67.8% hit rate and still a loss. The edge is worth about 1 bp over an eight-tick hold, while a round trip costs the 0.25 bp spread plus 2 × 1 bp of taker fees. No amount of predictive accuracy survives costs that exceed the edge. That is the central fact of high-frequency trading, and a harness that hid it would be worthless.

The break-even fee line names the number that actually decides viability: at 0.316 bps this strategy needs a top-tier fee schedule or maker rebates, not a retail account. Run --fee-bps 0.2 and it turns a profit; --fee-bps 2 and it is hopeless. Set --fee-bps 0 to see the raw signal with no costs at all.

4. Compare against the baselines, not against zero. --baselines runs rule-based strategies over the identical ticks, identical costs and identical one-tick execution delay:

strategy                 net       gross       fees   fills      maxDD     hit
------------------------------------------------------------------------------
jev (mock)            -62.69       28.90      91.60     250      71.40   67.8%
flat                    0.00        0.00       0.00       0      -0.00       -
buy_and_hold          101.75      102.51       0.76       1      36.27       -
momentum             -233.70      -73.69     160.01     300     249.84       -
mean_reversion       -321.45     -161.43     160.01     300     323.76       -
imbalance            -214.34       -6.73     207.61     264     237.93       -
random               -486.34     -230.20     256.14     241     492.39       -

The decision loop is the only strategy with positive gross P&L — its gating keeps it out of trades the raw imbalance rule takes and loses on. And buy_and_hold wins outright, because this particular seed drifted up. That is a coin flip on the seed, not a benchmark anyone beat.

There is also a calibration table, because calibrated probabilities are Jev's central claim and the policy's confidence thresholds are only as sound as that calibration:

Calibration of P(up | directional)
  bucket      n    predicted   realised
  0.0-0.2    291     0.05       0.34
  0.8-1.0    290     0.95       0.65

That is the mock being badly overconfident, which is exactly what the table is for. Run it against the real model and see.

The latency experiment

sweep holds everything else fixed and varies only how long the decision takes to come back, averaged over several paths:

 added latency  decisions  dropped  skipped   formed  executed      gross        net
------------------------------------------------------------------------------------
          0 ms        742        0        0   63.2%     61.8%      88.44    -107.19
        300 ms        742        0        0   63.2%     61.3%      72.05    -125.66
       1200 ms        742        0        0   63.0%     60.1%      38.38    -156.52
       3000 ms        373        0      369   63.0%     54.3%      12.29    -100.33

formed scores each call from the tick it was made on; executed scores the same call from the tick it actually reached the market. They separate two things that get confused:

  • The judgment never degrades. formed is flat at ~63% at every latency. A slow model is not a worse analyst.
  • The market moves on. executed decays to 54.3% — near a coin flip — and gross P&L falls 86%. The answer was right about a tape that no longer exists.

At 3000 ms the loop also skips 369 decision points, because only one request is ever in flight. A slow provider does not merely act late; it gets fewer looks at the market.

Set --deadline-ms 400 and late decisions are dropped outright rather than acted on, which is the only safe policy: a stale read of the tape is worse than no read. Jev's documented 70–500 ms sits inside that budget. A model in the 3–329 s range does not, which is the quantitative version of the correction at the top of this file.

Two safeguards run in code on every tick, independent of the model — a stop loss and a drawdown kill switch. Nothing that requires a network call should be the only thing between a position and a loss.

The live demo

export TYPESAFE_API_KEY=sk-...
python -m jevtrade.server            # http://127.0.0.1:8787

A page that watches Jev trade a live BTC curve, one decision per second.

the live demo

The point of the page is the latency budget. Every snapshot is a 1000 ms window, and the hero tile shows how much of it the decision consumed — typically a third. The bar chart puts one bar per call under a rule line at the deadline: a decision slower than one snapshot describes a book that no longer exists, so it is dropped rather than traded. Measured over a live session:

p50 386 ms   ·   p95 490 ms   ·   0 dropped out of 366 decisions
1093 input tokens per decision = \$0.000046

The right-hand column is the part worth watching: What Jev was shown is the state in words, and What Jev answered is the six typed answers with their probability distributions, updating live. You can watch a Choice swing from up to down and see the gate that follows from it.

This path uses Kraken's ticker, which carries the real best bid and ask with their resting sizes — unlike the 1-minute OHLCV replay above, where the book has to be reconstructed. The feature window is primed from the public trade tape, so the demo starts trading immediately instead of waiting a minute.

Notes on what the P&L means: trades cross the real spread, but no exchange fee is modelled by default (--fee-bps adds one), and it is paper trading against observed prices, not order placement. It is a demo of a decision loop, not a trading result.

--symbol BTC|ETH|SOL   --port 8787        --interval-ms 1000
--max-units 0.05       --fee-bps 0        --provider auto|jev|mock

The API key stays in the server process; the browser never sees it. Without a key the page runs the offline simulator and says so. ?static=1 renders one snapshot instead of holding the stream open, and ?theme=light forces a mode.

tools/record_session.py captures a run's event stream to JSON, which is how the shareable replay of a real session was built — 199 live decisions, three of which came back past the deadline and were dropped.

Market making: where this model actually fits

The directional strategy above is the wrong job for Jev, and the numbers say so — a taker needs a sub-0.32 bp fee to survive, and predicting direction is the one thing a System One model is weakest at.

Market making is the better fit, for a reason worth stating precisely. The mechanical part — quote both sides, earn the spread, lean against inventory — is arithmetic, and a traditional program does it better than any model. What kills a market maker is adverse selection: getting picked off by someone who knows something. Deciding "is now a bad time to be showing this side" is a defensive, low-cardinality judgment over text, and it has to be made in hundreds of milliseconds. That intersection is the niche: too semantic for a rule, too fast for a frontier model.

python -m jevtrade.cli mm --seeds 20                  # the comparison
python -m jevtrade.cli mm --event-impact-bps 0        # the falsifiability run

Jev goes off the quote path

quoting engine (pure code, every tick) ──> bid / ask
        ▲ reads, never waits
   risk posture  {per-side spread, per-side size, ttl}
        ▲ lands ~400 ms later
   Jev ◀── a headline arrives, or the tape turns one-sided

The quoter never blocks on a network call. Jev sets a posture; code sets quotes. This is what makes the latency budget survivable: a stale posture is merely conservative, while a stale directional bet is fatal. If the model is slow or down, the stance defaults to defensive.

It also buys something a volatility trigger structurally cannot do. Knowing the direction lets you pull the side about to be picked off and keep quoting the other — protected and positioned at once. A vol spike has no direction, so it can only widen both sides.

And uncertainty has a safe direction here, which it does not in directional trading. There, low confidence means stand aside and earn nothing. Here it means quote wider and keep earning. Calibrated confidence maps onto a continuum of profitable states rather than an on/off switch.

The experiment

Four arms over identical markets — the price path and event schedule run on their own RNG stream, so every arm sees the same tape:

armwhat it does
naivequotes through everything
volwidens both sides when realised vol spikes — the traditional defence
keywordpauses quoting on a news word — what desks actually run
jevreads the headline and the tape, sets a stance per side

Counterparties are a mix of noise flow (fill probability decays with quote distance) and informed flow (arrives after a material event, trades the way the price is about to move, and crosses only while the quote is cheap relative to the move it already knows about). Without that asymmetry a market-making backtest is meaningless — quoting a random walk always "wins".

Scoring is by markout, the only measure that tells a market maker anything: each fill splits into the half-spread captured at the fill and what the mid did afterwards. The second term is adverse selection.

The keyword arm is built to be a strong competitor: its dictionary catches all six obviously material headlines in both directions. Its only failures are the four deliberately subtle ones and the seven it cannot read — denials and re-reports. Beating a strawman would prove nothing.

Results

20 independent markets, 6,000 ticks each, real jev-latest:

arm           mean net   std error    vs naive
----------------------------------------------
naive            1,755         767          +0
vol              1,500         928        -255
keyword          8,820         900      +7,066
jev              5,829         752      +4,074

paired jev - keyword: -2,991 +- 654  (outside the noise)
jev precision 97%   recall 62%

The keyword rule wins, and the gap is real. Jev's reading is close to perfect — it flags 97% of its stances on genuinely material news, and in a separate check it scored p(denied) = 0.99 on every denial and freshness 0.11 on every re-report — but it acts on only 62% of material events, and in this world a miss costs more than a false alarm.

The falsifiability run makes the trade-off visible. Set --event-impact-bps 0 so headlines print but the price never moves, and every defensive stance is pure cost:

naive           20,479          +0
keyword         13,993      -6,486   (-32%)
jev             19,689        -791   ( -4%)

Jev's false alarms cost eight times less. That is precision, measured.

So there is a crossover, and it sits at how much of the flow is informed:

 toxicity    naive  keyword      jev   jev-kw   +-se  winner
     0.00   20,333   19,092   20,828   +1,736    717  jev
     0.25   15,183   16,876   16,725     -152    916  tie
     0.50   11,303   13,462   12,083   -1,379    470  keyword
     1.00    3,391   10,127    5,266   -4,861    778  keyword
     1.50   -4,757    5,478     -885   -6,363  1,145  keyword

Precision pays when false alarms dominate; recall pays once adverse selection does. If your news feed is mostly noise, reading it is worth a lot. If every event is a real 20 bp move, pausing on everything is hard to beat.

Two things that did not work, recorded because they were predictions: acting harder once the floor is cleared made things worse (the lost spread exceeds the protection), and raising the event rate did not tip the balance toward precision — more news means more material news too.

What this does not show

  • The world is built so that semantics matter. Denials and re-reports are 55% of the event mix by construction. Measured against real feeds (see the next section) the real figure is closer to 95% non-material — noisier than assumed, which favours precision, but on a feed with no forward signal left in it.
  • The "subtle" labels are fiat. Four headlines are written to read as immaterial while the simulator moves the price 22 bp anyway. Jev reads them as immaterial — as would a person. Most of the recall gap is these, so it may be an artifact of my labelling rather than a limit of the model.
  • Jev is a sampler. Re-drawing its answers moved a single configuration by ~30%. Every number here fixes one set of answers across all arms and reports a standard error across markets; treat differences smaller than ~2 standard errors as nothing.
  • Fills, impact and flow are a model, not an exchange. No queue position, no cancel latency, no fee tiers or rebates — and rebates are most of why real market making works.

Does real news flow look like that? (measured)

The market-making experiment rests on one assumption: that a useful share of real headlines are the kind a keyword rule misreads. That is testable, and unlike the synthetic world it has a ground truth nobody has to label — what the price actually did afterwards.

python -m jevtrade.cli news

209 de-duplicated headlines from 11 public crypto RSS feeds over 58 hours, aligned to 5-minute BTC bars. A headline "moved" if BTC made a ≥2σ excursion in the next 15 minutes and was not already moving in the 15 before — because a move already underway is one you are too late to trade. The control throughout is randomly timed fake headlines over the same window.

arm                   alerts   fire  moved    null      z
all headlines            209  100%   5.7%    5.0%   +0.47
keyword rule              67   32%   9.0%    5.1%   +1.37
jev top-40                40   19%   2.5%    5.0%   -0.74

Nothing clears the noise. Crypto headlines as a class are followed by a material move about as often as a randomly chosen moment is.

The crux

That comparison is not yet decisive, because news clusters around busy moments and a busy moment stays busy on its own. So the real test holds recent volatility fixed: each flagged headline is compared against random moments with a comparable move already behind them.

group         condition            n   after    null      z
jev top-40    quiet before        27    0.51    0.53   -0.22
jev top-40    already moving      13    1.71    0.79   +4.12
keyword       quiet before        41    0.55    0.53   +0.30
keyword       already moving      26    1.28    0.78   +3.21

A headline arriving into a quiet tape predicts nothing — z ≈ 0 for both arms, and this is the well-controlled half of the table. The "already moving" rows look strong, but those are headlines reporting a move in progress, and the volatility matching there is coarse enough that the number is not evidence of anything.

The qualitative read tells the same story. Jev's highest-risk headlines are exactly the ones a person would pick out:

0.268   SEC releases long-awaited innovation exemption
0.259   Fed Hikes Rates for the First Time Since 2023, Bitcoin Spikes
0.196   Binance Alert: System Upgrade to Suspend Deposits and Withdrawals
0.176   Bank of Japan Follows Fed and ECB With Rate Hike to 1.25%

The second one says it outright. By the time an article about a Fed decision is written, edited and published to RSS, the spike it describes is history.

What this settles, and what it does not

The distribution assumption is half right, in the direction that matters. Real flow is far noisier than the simulation assumed — roughly 95% of headlines are followed by nothing, against 55% non-material in the synthetic mix. That pushes the operating point deep into the regime where precision beats recall, which is exactly where Jev won and the keyword rule bled (−4% versus −32%). On this feed a keyword rule fires on 32% of everything, so at a ~5% base rate at least six of every seven alerts are false by arithmetic alone.

But the premise underneath it does not survive as stated. There is no tradeable forward signal here to be precise about. Jev reads the headlines correctly — its risk scores top out at 0.27, i.e. it judges almost nothing on this feed to be materially BTC-moving, which the tape agrees with — and reading them correctly is worth nothing when the information has already been priced.

The likely culprit is the data source, not the model. RSS publication timestamps trail the underlying event by minutes; news desks that trade on headlines use millisecond-stamped wire feeds. So the honest scope of this result is: consumer crypto RSS carries no tradeable forward information at 5-minute resolution. It is not a finding about news in general.

Other limits worth holding onto: 209 headlines over one quiet 58-hour window is a small sample and underpowered for rare events; 5-minute bars are coarse for a reaction that happens in seconds; and only BTC was tested. Settling this properly needs a low-latency wire feed with millisecond stamps and an L2 book — which is the experiment to run before building anything on this idea.

Composed judgment: reading an announcement in one second

The news study above put Jev on the wrong end of a trade-off. A headline that moves the market is rare and worth a lot, so whoever trades it can afford five seconds and five cents of a frontier model; a 400 ms answer buys nothing there. Jev's advantage — frontier-grade judgment, cheap, fast — pays where there are many judgments to make and each one is worth little. And, as it turns out, where several of them have to be composed.

Width is free, depth costs a round trip (measured)

Against the real API, from this sandbox, with a ~1k-token document:

questions per request      median latency
   1                          412 ms
   6                          420 ms
  24                          392 ms

5 sequential rounds × 6 questions, each round given the previous answers:
  2.0–2.4 s, about 400 ms per round

Ten judgments cost the same as one if they share a request. The budget is therefore rounds — conditional layers, "ask A, then depending on A ask B" — not judgments. One second buys two rounds from here and perhaps three to five from a colocated box, each carrying dozens of questions. So a judgment tree built on this model should be wide and shallow, which is also what keeps errors from compounding: ten sequential 90%-accurate steps are 35% accurate, two wide ones are not.

The opportunity: exchange listing announcements

Binance publishes listings, delistings and every other notice through one public CMS endpoint with a millisecond releaseDate — the same endpoint the public "listing sniper" bots poll. Those bots are fast enough. What they cannot do is read: they match the title (Will List, Removal) and trade every ticker in it. That fails on exactly the announcements that carry the most money — three tickers where one is the subject, a pair removal that is not a delisting, a "Will Remove the Seed Tag" that the word remove turns into a short, a Seagate (STX) that buys Stacks.

jevtrade/listing/ reads each announcement with a two-round tree:

  • Round one, wide and speculative: what kind of notice is this (listing, more markets for an existing token, delisting, warning tag, housekeeping, tokenized stock), which way it cuts, how big, is it conditional — and, for every ticker the code found in the text, whether that ticker is what the announcement is about rather than a quote currency, collateral or a passing mention. Jev never returns a string, so extraction is code and judgment is the model.
  • Round two, only if a ticker cleared that bar: the direction for each such token specifically, a reversed-framing check ("would a holder have no reason to act?") whose errors are partly independent of the first framing, and whether the substance was already public. No second round, no trade.

Every number is arithmetic on the model's probabilities; the model computes nothing. On six recent, deliberately varied announcements against the real API the reader took 0.89–0.97 s for two rounds (0.45 s when it stopped after one), on 2–4k input tokens — about $0.0001 per announcement.

How it will be graded

python -m jevtrade.cli listing --days 180            # real key: reads with Jev
python -m jevtrade.cli listing --days 30 --provider mock

Every arm turns an announcement into (token, side) signals and every signal is scored the same way: enter at the open of the minute after the release — deliberately up to 59 seconds late for a reader that answers in one — and take the signed log return at 1, 5, 15 and 60 minutes, on Binance if the token still trades there, else OKX, else Coinbase, in that fixed order for every arm. The null is the same tokens and sides at random moments within five days, so whatever drift those tokens had that week the null has too. The release minute itself is reported but never credited to anyone.

Three controls sit beside the reader: the title-matching bot (the incumbent), the same rules over tickers from the body as well (so that seeing the body and judging it are separable), and every mentioned ticker bought (the base rate for being mentioned at all). Model answers are cached on disk per announcement, so the threshold sweep and every re-run score one fixed set of answers rather than re-sampling the model. The run has not been done yet; the numbers will go here when it has.

The same reader, pointed at central banks

The listing study aims the reader at a feed whose incumbent is fast but cannot read. This one aims it at a feed whose incumbent can read and is slow — a human on a desk with two statements side by side — and where the part a machine can already handle is gone before anyone blinks.

The thesis, stated so it can fail. In FX the numeric releases are priced within five minutes: NFP, CPI and the headline rate itself are numbers, every machine on the tape has them at the same millisecond, and the trade is subtraction. The text events are not. A rate-decision statement, a set of minutes, a press conference, a speech, a line about the exchange rate being "excessive and one-sided" — these keep moving price for fifteen to sixty minutes because somebody has to read them first. A model that answers thirty questions about a statement in one 400 ms round is early relative to a fifteen-minute digestion. That window, and nothing wider, is what jevtrade/fx/ tests.

Why this window and not another (measured, one week, small)

Median |move| in bps on the spot pair of the event's currency, over the ForexFactory calendar for 2026-09-14 to 09-18, Yahoo 5-minute bars:

eventsn+5m+15m+30m+60m
High impact, numeric102.25.74.26.6
High impact, text43.312.119.520.0
Medium impact, numeric71.63.61.23.5
Medium impact, text42.35.24.68.2

The caveats are larger than the table: one calendar week, four High-impact text events, and 5-minute bars, which is an anecdote with a standard error, not a result. It is here because it is what motivated building the thing, and because the shape is what the thesis predicts — numeric events flat after five minutes, text events still going at sixty.

The individual cases say the same thing more legibly. The BoJ press conference on 2026-09-18 moved USDJPY 0.6 bp in five minutes and 23.9 bp in fifteen: nothing happens while it is being read, and then it happens. The 2026-09-16 FOMC statement differs from the 2026-07-29 one in 7 of its 9 sentence slots, and the hike itself was on the calendar — 4.00% against 3.75% previous — so the number was not the news. The reading was in the changed sentences: the inflation paragraph went from "elevated relative to the Committee's 2 percent goal, in part reflecting supply shocks" to a flat "Inflation remains elevated," and the vote went from 9–3 with three dissents for a hike to 12–0.

(With the site chrome left in the page, as a first pass did, the same pair of statements reads as 11 changed slots out of 27; the counts above are after the body extraction in documents.py throws the navigation away. Same story, fewer sentences.)

The feeds

sourcereachabledepthtimestamp
federalreserve.gov/json/ne-press.jsonyes4633 rows to 2006US Eastern local, minute; 468 old rows have none
…/ne-speeches.jsonyes1336 rowssame
…/ne-testimony.jsonyes280 rowssame
Fed statement / speech pagesyesimmutable— (body text)
ECB rss/press.htmlyeslast 15 itemsRFC 2822, +0200
BoJ en/rss/whatsnew.xmlyeslast 44 items+0900; links are usually PDFs
BoE rss/newsyeslast 50 items+0100
ForexFactory ff_calendar_thisweek.jsonyesthis week onlyISO with offset
Yahoo v8/finance/chart/{sym}yes60d of 5m, 7d of 1mepoch seconds, UTC
Dukascopy datafeed/{SYM}/…/{HH}h_ticks.bi5yesticks back to 2003ms into the UTC hour
Reuters, Bloomberg, X APIno

Only the Fed has an archive. Everything else is a window onto the last week or two, which is why the Fed is the primary source and the other three are there to show the tree is not Fed-shaped. lastweek and nextweek both 404 on the calendar, so a collect step stores the current week under an ISO-week key and the numeric-surprise baseline is live only for the weeks somebody ran it. No calendar is ever reconstructed for a past week: a consensus invented after the fact is not a consensus, and that arm would win for the wrong reason.

The sign convention

Stated once, in one table, and tested:

issuercurrencyhawkish meanspair tradedhawkish side
FedUSDUSD strengthensEURUSD=Xshort
ECBEUREUR strengthensEURUSD=Xlong
BoEGBPGBP strengthensGBPUSD=Xlong
BoJJPYJPY strengthensJPY=X (USDJPY)short

A hawkish Fed sends EURUSD down and a hawkish BoJ sends USDJPY down, because the currency in question is the quote side of those pairs. Getting this backwards inverts the entire study while leaving every number plausible, so it lives in one dict and has its own test.

The tree

Round one, one request, everything speculative — what kind of text this is (rate decision, minutes, speech or testimony, press conference or interview, FX or intervention comment, data or survey, operational, other), whether it is policy at all, which way it leans for the issuer's own currency, whether there is anything new in it, how big, whether the guidance moved, whether it is a surprise against what the text implies was expected (and against the calendar forecast, when a snapshot covers it), and how far it goes up a five-level intervention ladder: no mention of the exchange rate → officials are watching → moves are "excessive or one-sided" → "ready to take decisive action", or a rate check → intervention announced or confirmed.

Then, for each of up to twelve sentences that changed since the previous edition — found by difflib on sentence lists, not by the model — two more questions: which way that sentence cuts, and whether the change is substance or rephrasing. With a full diff that is 32 questions in one round, and width is free.

Round two, only if round one found something — a decisive stance, a material sentence change, or intervention language near the top of the ladder. It asks the direction again with the framing reversed ("if you had to take a position for the next hour, which side?"), a holder check ("would a trader long this currency be unaffected?"), and the horizon. Two rephrasings of one question make partly independent errors; that is the cheapest redundancy there is. No second round, no trade — a document whose confirmation never ran scores zero.

Every number is arithmetic on the probabilities: strength is stance × the reversed-framing confirmation × magnitude × policy relevance, lifted a little by surprise and halved by the complement of "new information". The model multiplies nothing.

The baselines and the grading

python -m jevtrade.cli fx --days 60                      # real key: reads with Jev
python -m jevtrade.cli fx --days 60 --provider mock      # offline, keyword stub
python -m jevtrade.cli fx --snapshot-calendar            # store this week's calendar

Four arms are scored the same way. keyword-bot counts hawkish words against dovish ones and trades the difference — the incumbent, and the thing to beat; it reads "the Committee no longer expects to raise rates and will not tighten further" as hawkish. surprise-bot takes the printed rate minus the snapshotted forecast, which is the incumbent that actually wins on numbers, and reports not available for every week without a snapshot. all text trades every document, so the table shows whether central-bank text moves the tape at all relative to nothing happening — if that arm is flat, nothing downstream matters. reader is the tree at a strength threshold.

Every signal enters at the open of the first bar after the published timestamp — up to five minutes late on 5-minute bars, deliberately — and is measured by signed log return at 5, 15, 30 and 60 minutes. The null is the same pair and the same side at random moments within five days, drawn only where the tape has bars: spot FX is shut from about Friday 21:00 to Sunday 21:00 UTC, so a third of naive draws land in a hole, and a 60-minute return computed by bar index across a Friday close would be a 51-hour return in disguise. Horizons are therefore checked against the bars' own timestamps and dropped when the window is not contiguous. Model answers are cached per document and tree version, so the threshold sweep scores one fixed set of answers rather than re-sampling.

Grading on ticks

Five-minute bars were the binding constraint, not the feed. Yahoo serves sixty days of them, which is thirty documents and five reader trades; and a bar has no bid and no ask, so every arm was being graded at a mid price nobody is quoted. Dukascopy publishes free tick files back to 2003 for the majors and fixes both.

The format (probed from this environment, not assumed):

https://datafeed.dukascopy.com/datafeed/{SYMBOL}/{YYYY}/{MM}/{DD}/{HH}h_ticks.bi5

MM is zero-based — January is 00 — while DD and HH are ordinary two-digit fields and HH is the UTC hour. The body is LZMA (lzma.decompress, standard library). Decompressed it is consecutive 20-byte big-endian records, struct.unpack(">IIIff") = (milliseconds since the start of the hour, ask, bid, ask volume, bid volume). Prices are integers scaled by 1e5, or by 1e3 when the quote currency is JPY: 115510 → 1.15510 on EURUSD, 156225 → 156.225 on USDJPY. An hour with no ticks — every weekend hour of seventeen years, most holidays — answers 200 with a zero-byte body, and a date the feed does not have answers 404; both are cached as "no ticks", because a run that re-asks for every Saturday since 2009 spends its afternoon on them. A 5xx or a dropped connection is the opposite: transient, retried with backoff, and never cached, since caching one would turn a bad minute of network into a permanent hole.

The entry rule, stated so the cost is visible. A signal enters on the first tick at or after published + latency, pays the ask to go long and hits the bid to go short, and exits at the mid at the horizon. So the round trip pays half the spread, which is the friendly end of the honest range — a trade that had to hit the other side on the way out would pay spread more. Two new columns come with it:

  • rush — how far the mid moved between the publication timestamp and the entry tick, signed by the side taken. Positive means the move had already gone the reader's way before it could act: that part of the edge belongs to whoever was faster.
  • sprd — the bid/ask spread at entry, in bps. EURUSD is under a bp in London hours and several times that in the minute after a statement, which is exactly the minute every arm here trades in.

A no spread row under each arm gives the same trade measured mid-to-mid — the bar study's number — so the difference between the two lines is what the book costs.

The latency sweep is the whole question in one table. --latency-sweep runs the same signals at 0, 1, 5, 30 and 120 seconds and prints one row each. Zero is the counterfactual nobody has: filled on the first tick after the timestamp itself. One second is a model that answered and hit the button. One hundred and twenty is a person who read the statement. If the rows are flat, speed was not what was being paid for and the reader is buying comprehension, not latency. If they decay, the slope is the price of being slow, in basis points, and it is the number a scalper actually wants. The table sweeps the reader's own signals, falling back to all text when the threshold leaves the reader with fewer than five; whichever it used is named in the caption.

The sample this buys (counted, 2009-01-01 to 2026-09-19, not estimated). The three Fed archives hold 5,237 rows with a minute timestamp in that window; exactly one row in it carries a date and no time of day, and is dropped rather than guessed at. Filtered to the three kinds that can move a currency they are 858 monetary-policy releases, 1,116 speeches and 213 testimonies — 2,187 documents, 150 of them titled "FOMC statement", running 85 to 191 a year. The rest is noise the pt field separates out without a judgment call: 1,185 enforcement actions, 968 banking and consumer regulatory policy items, 548 other announcements, 349 orders on banking applications. So the tick judge turns thirty documents into two thousand, and coverage on the tick side is complete: EURUSD, GBPUSD and USDJPY all go back to 2003.

The body extractor was re-checked on the old pages rather than assumed. The 2015 site rebuild re-templated the whole archive, so a 2009 statement, a 2010 speech and a 2011 testimony all carry the same col-xs-12 col-sm-8 col-md-8 wrapper as a 2026 one. Over all 5,237 documents, one comes back under 200 characters — the 187-character September 2026 Implementation Note, which really is that short. collect reports that count, because "the reader was unconvinced" and "the reader saw nothing" are different results.

python -m jevtrade.cli fx --tape dukascopy --since 2009-01-01 --issuers fed \
    --horizons 1,5,15,30,60 --latency 1.0 --latency-sweep

Everything is cached so the run is resumable: readings per document (keyed on a hash of the round-one state, not just the id — a statement whose previous edition becomes visible in a longer window is a different question and is read again), bodies per URL, tick hours per file as the compressed bytes they arrived as. An interrupted run re-reads what it has and fetches only what is missing.

Fetching was the slow part until the connection was kept open. Measured here: a fresh TLS connection to the feed costs nine to sixteen seconds before the first byte, and the feed answers 503 when several are opened at once (six of eight concurrent requests failed; the same eight one at a time all succeeded), which capped the fetch at about five hour-files a minute. On a connection kept open the next request costs 0.2 s. So the fetch holds one persistent connection per worker (--workers-io; two to four is plenty), tunnelled through the proxy when one is configured, drops it on any transport error and reconnects on the next try. Of the roughly 15,000 hour files the 2009–2026 run needed, 20 stayed unreachable after five tries; a failure is reported and never cached, so the next invocation fetches only those.

The run (real model, 60 days)

python -m jevtrade.cli fx --provider jev --days 60 --out runs/fx-jev-60d.json

Run on 2026-09-19: 30 documents from 2026-07-29 to 09-18 (Fed 11, BoE 11, ECB 5, BoJ 3), 14 went to round two, 8 questions in the median document and 32 with a full statement diff, median 693 ms per document, p90 928 ms, $0.004 of input tokens for the lot. Keyword and reader answers are the same cached set across every row.

arm              signals traded    pre    bar      +5m     +15m     +30m     +60m  hit15    z15
keyword-bot           18     17     -1     +1       -0       -1       +2       +1    50%   -0.5
  s.e.                                             +-1      +-2      +-2      +-3
surprise-bot           0      0     (both snapshotted decisions printed in line with the forecast)
reader >=0.15          6      5     -1     +0       -1       -3       +5       +6    40%   -1.0
  s.e.                                             +-1      +-4      +-6      +-7
all text              30     29     -1     -0       +0       +0       +2       +3    54%   -0.0
  s.e.                                             +-0      +-1      +-2      +-3

Nothing clears the noise, and with five reader trades nothing could: −3 ± 4 bp at fifteen minutes and +6 ± 7 at sixty is a sample size, not a verdict. What the run does settle is that the tree reads the way it was meant to, which with thirty documents is the part worth looking at:

  • The 2026-09-16 FOMC hike came back rate_decision, hawkish at p = 1.00, magnitude 0.86, round two confirmed at 0.71, strength 0.47 — the highest of the run. Short EURUSD. The release bar had already moved −13 bp (the fastest actors' five minutes); the trade then made +10, +29 and +31 bp at 15, 30 and 60 minutes. The number was on the calendar; the text was still moving price an hour later.
  • The 2026-07-29 hold with three hawkish dissents came back hawkish at p = 0.51 with a round-two confirmation of 0.17: strength 0.03, no trade. The keyword bot shorted EURUSD on the dissent language and was down 8 bp at fifteen minutes. The reversed-framing round is what kept the reader out.
  • The ECB's 2026-09-10 decision was read hawkish at p = 1.00, magnitude 0.84 — confident, long EURUSD, and wrong: −13 bp at fifteen minutes, −12 at sixty. The press-conference statement 45 minutes later, also read long, made +9 at sixty. Confidence is not accuracy; one document says nothing about the rate, and this is the one to remember when the sample is larger.
  • Noise was filtered. Court of Directors minutes, an AI-consortium minute, a chair appointment and a 2027 meeting calendar all came back neutral at p ≈ 1.00 with magnitude between 0.01 and 0.05 and were never traded. The keyword bot traded several of them.
  • The BoJ is blind. Its RSS links are PDFs, the body extractor is standard library only, so those documents arrived with empty bodies and the reader classified the 2026-09-18 hike from its title alone (hawkish 0.82, strength 0.03). The issuer with the largest text-driven moves in the motivating table contributed nothing. A PDF reader is the cheapest improvement on this list.
  • The surprise arm is empty because the surprises were. The FOMC printed 4.00% against a 4.00% forecast and the BoE 3.75% against 3.75%. A first cut of this arm shorted GBPUSD on the BoE decision: the calendar match had picked the "MPC Official Bank Rate Votes" row, whose forecast is "3-0-6", and the rate parser had read the 2% inflation target as the rate. Both are fixed and tested; the table above is from after the fix.

The binding constraint is the judge, not the feed. Sixty days of five-minute bars gave thirty documents; the tick judge above lifts the same study to 2,187, on the order of $0.30 of input tokens, and puts the bid/ask and the entry latency into the number instead of leaving them out of it.

The run (real model, Fed 2009–2026, ticks)

python -m jevtrade.cli fx --provider jev --tape dukascopy \
    --since 2009-01-01 --issuers fed --horizons 1,5,15,30,60 \
    --latency 1.0 --latency-sweep --out runs/fx-jev-fed-ticks.json

Run on 2026-09-19: 2,187 documents from 2009-01-06 to 2026-09-18 (858 monetary-policy releases, of which 150 FOMC statements; 1,116 speeches; 213 testimonies), 1,142 went to round two, 11 questions in the median document, median 741 ms per document, p90 929 ms, $0.363 of input tokens, 476 s of wall time for the reading with three workers. Graded on EURUSD ticks: entry on the first tick at or after the release plus one second, paying the ask to go long and the bid to go short, exit at the mid; null of the same side at random moments within five days where the tape has ticks. Events whose hours the feed never served are absent from the traded counts.

arm              signals traded    pre   rush  sprd      +1m      +5m     +15m     +30m     +60m  hit15    z15
keyword-bot         1219   1149     -0     +0   0.6       +0       -0       -1       -1       -1    45%   -0.8
  s.e.                                                   +-0      +-0      +-0      +-0      +-1
reader >=0.15        240    225     +1     +0   0.8       +2       +1       +2       +2       +1    51%   +1.3
  s.e.                                                   +-1      +-1      +-2      +-2      +-2
  null                                                    -0       -0       -0       -1       -1
all text            2187   2101     -0     +0   0.6       -0       -0       -1       -1       -1    46%   -1.7
  s.e.                                                   +-0      +-0      +-0      +-0      +-0

latency sweep (reader >=0.15, net of the half spread):
   entry  traded   rush  sprd    +15m   s.e.    null     z    +60m   s.e.    null     z
      0s     225     +0   0.8      +2      2      -0  +1.5      +1      2      -1  +1.0
      1s     225     +0   0.8      +2      2      -0  +1.3      +1      2      -1  +1.0
      5s     225     +1   0.8      +1      1      -0  +0.9      +0      2      -1  +0.6
     30s     225     +2   0.6      +0      1      -0  +0.2      -1      2      -1  +0.2
    120s     225     +2   0.5      +0      1      -0  +0.5      -0      2      -2  +0.5

threshold sweep (+5m):  thr 0.05: 447 traded, +1 +-1   0.15: 225, +1 +-1   0.25: 109, +3 +-2   0.40: 39, +3 +-4

What seventeen years say, in the order they matter:

  • Reading beats counting words, and neither is a trade. The keyword bot's 1,149 trades come out at −1 ± 0 bp fifteen minutes on; its hawkish calls lose outright, −1.5 ± 1.1 bp at fifteen minutes and −4.5 ± 1.7 at sixty over 207 trades, because "elevated" and "tightening" in a sentence are not a hawkish sentence. The reader's 225 trades come out at +2 ± 2 bp with a hit rate of 51% and z = +1.3 against its null. Two basis points with a standard error of two is not a result anyone should size a book on. The all-text arm at −1 bp (z −1.7) is the base rate: Fed text at large does not move EURUSD in the direction the words point.
  • Whatever is there is gone in thirty seconds. The latency sweep is the scalping question in one table: +2 bp entering at zero or one second, +1 at five, 0 at thirty and at 120, while the rush column — the mid's move between the release and the entry, signed by the reader's side — climbs from 0 to +2 over the same range. The direction the model reads is the direction the first half-minute goes, slightly more often than not, and after that there is nothing left to collect. A 400 ms reader is inside that window; a human is not. The spread at entry averages 0.8 bp, so the half-spread paid is a fifth of the gross.
  • It lives in the statements. FOMC statements: 66 of 150 traded, +3.1 ± 4.2 bp at fifteen minutes and −3.5 ± 6.2 at sixty — the move mean-reverts. Speeches and testimony: 119 traded, −0.1 ± 1.4 — nothing, and for a speech the published timestamp is the scheduled start, not the moment a wire ran the line, so nothing is the honest expectation. Dovish readings did better than hawkish ones (+3.1 ± 2.2 over 130 against +0.2 ± 1.8 over 95). Split by era, 2009–2015 is −1.3 ± 4.4 (51 trades), 2016–2021 +6.1 ± 2.0 (82), 2022–2026 −0.2 ± 2.1 (92): one cell of three at three standard errors is what a post-hoc split produces, not a regime, and it is reported so nobody has to find it.
  • The big calls. Right: 2009-03-18 (long, +136 bp at fifteen minutes, the Treasury-purchase statement), 2020-03-23 (long, +61, unlimited purchases), 2016-12-14 (short, +55, the hike with the dots), 2019-01-30 (long, +46, the "patient" pivot). Wrong: 2009-01-28 (long, −75), 2024-12-18 (long, −70, a cut the market took as hawkish), 2022-11-02 (short, −66 at fifteen minutes and −2 at sixty, the statement read one way and the press conference the other). On the largest statements the reader is right more often than not and wrong by as much when it is wrong.
  • What it read. Neutral 1,349, dovish 550, hawkish 288. Speech or testimony 1,328, operational 305, minutes 287 (286 of them neutral), rate decision 195. The intervention ladder never went above 0.47, on three speeches about Treasury-market liquidity — the Fed does not talk about the dollar, and the tree noticed.

For the scalper who asked: reading central-bank text in under a second is worth about two basis points of EURUSD on the documents the reader chooses, captured inside thirty seconds of a scheduled release, with a standard error the same size as the edge, and mostly on FOMC statements. That is a measurement, not a strategy. What it rules out is the keyword bot; what it rules in is only that a 400 ms reading points the right way for the first half-minute a little more often than not, at $0.36 for seventeen years of it.

Statements in context

The run above buries a negative result. On the 150 FOMC statements the tape moves a lot — mean |move| 15 bp at one minute, 25 at fifteen, 37 at sixty — and the reader's direction is a coin flip: 66 traded, 50% at every horizon, and the highest-strength bucket (14 trades) hits 43%. The three worst calls all have the same shape:

statementwhat the text saidwhat the market did
2024-12-18a cut, and it reads dovishtook it as hawkish — the dots moved 2025 from three cuts to two
2022-11-02read one waythe press conference an hour later read the other
2009-01-28promised purchaseshad already priced them

The diagnosis is not that the model reads badly. It is that a statement is read against what the market expected, and the expectation is not in the statement. The absolute tree asks "is this a surprise?" with nothing to be surprised against. So fx/context.py assembles the expectation and the tree gets a second mode that asks the relative question instead.

What goes in, where it comes from, and what its timestamp has to satisfy. Every item is stamped with the moment it became public, and Context refuses to exist if any of them is at or after the release:

piecesourcetimestamp rule
rates and what they priceH.15 daily package (Treasury constant maturities) plus the federal funds effective ratethe last row strictly before the release's UTC date — the row named after the meeting day prints at 4:15 p.m. ET, two hours after a 2 p.m. statement
the dotsfomcprojtabl{YYYYMMDD}.htm (and fomcprojtable…, which March 2022 uses)concurrent: published at the release's own minute, the one allowed exception, and labelled as such
the previous statementdiff.py's Editions, with a fallback to the last statement when the Fed renamed the releasestrictly earlier
the minutesthe last FOMC minutes press release before the meeting, followed to its HTML pagereleased three weeks after the previous meeting, so strictly earlier
intermeeting communicationthe Fed speech and testimony archivesstrictly inside (previous meeting, this release), open at both ends
the pair into the releasethe cached Dukascopy ticksthe last quote strictly before the release

Numbers become words on the way in, as everywhere else in this repo: the model is told "the six-month bill yields 4.30 percent against an overnight rate of 4.58: the market prices roughly one quarter-point cut within six months", never handed a bill yield and a funds rate to subtract. The raw values stay on the dataclasses. Two sanity rules are code's job and are stated because they bite: baseline.announced_rate reads "at 0 to 1/4 percent" as 1.00, which is the wording of every statement from 2008 to 2015, so a target the effective funds rate contradicts is refused and the sentence falls back to the effective rate; and the longer-run dot is used to check the alignment of the previous SEP's row, because a central tendency printed as a bare number reads as one more median.

The relative questions ride in round one next to the whole absolute tree, because width is free:

idtypeasks
expected_actionChoicehike / hold / cut — what the context says was expected
actual_actionChoicehike / hold / cut — what the statement did (code cross-checks this against the rate it parsed)
relative_stanceChoicemore hawkish / in line / more dovish than the context implies
surprise_channelChoicerate decision, guidance, balance sheet, dots, vote, assessment, nothing
surprise_sizeScorenothing they already had → a nuance → a clear surprise → the kind that sets the day
versus_minutesChoicemore hawkish / consistent / more dovish than the minutes and the speeches

Round two runs only on a decisive relative call or a large surprise, and asks it reversed — "a desk that had read this context, which side of EURUSD for the next hour?" — plus the holder and horizon questions. The signal's sign comes from relative_stance and never from stance: on 2024-12-18 a reader that gets the statement right has to answer dovish to the first question and more hawkish than expected to the second, and that split is the whole point of the mode. Strength is arithmetic: p(relative) × confirm × surprise_size × (1 − priced_in), with no half-measure damper — the point of handing the reader the expectation is that "the market already had this" is a complete answer. The mode has its own cache tag (ctx1), so the absolute reader's v1 answers are reused as they are and never re-asked.

Two numeric baselines, both crude, both labelled. The instrument that actually prices a meeting is the fed funds future, and that is not free. What is free is the six-month bill:

  • bill-surprise — the decision the statement took, minus the one the bill implied. The bill prices the path over six months, so the spread over the effective rate is divided by the four meetings six months holds to get a per-meeting expectation. That division is an assumption and the arm inherits its error.
  • dots-surprise — the change in next year's median dot against the previous SEP, on projection meetings only. The SEP has published a funds-rate median since September 2015 and printed ranges and a histogram before that, so the arm is dark for the first third of the archive — 44 of the 150 statements have readable dots.
python -m jevtrade.cli fx --context --provider jev --tape dukascopy \
    --since 2009-01-01 --horizons 1,5,15,30,60 --latency 1.0 --latency-sweep \
    --context-chars 12000 --out runs/fx-jev-fed-context.json

--context implies --issuers fed and reads statements only. It prints the arms table, the latency sweep for the context reader, the confusion between expected_action/actual_action and the rate the code parsed, the distribution of surprise_channel, and the statements where the absolute and the context readers traded against each other with the tape's verdict next to both. The --out JSON carries every graded signal and, per statement, the timestamp of every context item and both readers' answers.

Offline, --provider mock assembles the same context and answers it with a rule: the pricing sentence for what was expected, the decision verb inside "decided to …" for what happened, the gap between the two for the relative call. That is a rule and not a reading, and it should score like one.

The run (real model, 150 statements, ticks)

Run on 2026-09-20. Context assembled for all 150 (rates 150, minutes 150, previous statement 149, Chair speech 128, dots 44; median 10,210 characters). The absolute reader's answers came from the 2009–2026 run's cache unchanged; the context reader asked 32 questions in the median statement, 43 went to round two, median 537 ms per statement, $0.052 for the lot.

arm              signals traded    pre   rush  sprd      +1m      +5m     +15m     +30m     +60m  hit15    z15
reader-absolute       67     66     +3     +1   1.5       +1       +1       +3       +4       -3    50%   +0.5
  s.e.                                                   +-2      +-3      +-4      +-5      +-6
reader-context        14     13     +5     -1   1.4       +2       -1       -6       -3       -1    38%   -0.6
  s.e.                                                   +-6      +-7      +-8     +-10     +-12
bill-surprise         90     89     -0     +0   1.8       -1       -0       +2       +1       -1    51%   +0.5
  s.e.                                                   +-2      +-3      +-4      +-4      +-5
dots-surprise         31     31     +2     +3   1.8       +8      +14      +19      +18      +14    77%   +3.5
  s.e.                                                   +-4      +-5      +-6      +-6      +-8
all statements       150    149     -2     +1   1.6       -0       -1       -1       -0       -3    49%   -0.3
  s.e.                                                   +-2      +-2      +-3      +-3      +-4

what the model said the statement did, against the rate the code parsed (141 parseable):
  actual_action agrees with the parsed decision 132/141 (94%); expected_action matches what happened 133/141 (94%)
relative stance: in line 98, more dovish than expected 32, more hawkish than expected 20
where it put the surprise: economic assessment 45, vote 32, balance sheet 28, rate decision 19,
  forward guidance 15, nothing 11, projections or dots 0

Three things came out, in order of weight:

  • The context did not fix the coin flip; it turned the reader into an abstainer. Given what the market already had, the model called 98 of 150 statements in line with expectations and cleared the trading threshold on 14. It read the facts right — 94% agreement with the parsed decision, 94% on what was expected — and stayed out of the largest moves in both directions: the ones the absolute reader had got right (2009-03-18, 2020-03-23, 2019-01-30) and the ones it had got wrong (2024-12-18, 2022-11-02, 2009-01-28). The 13 it did trade came out at −6 ± 8 bp at fifteen minutes, five of thirteen right. Thirteen is not a sample; abstaining on 136 is the result.
  • The direction was in the dots, and it is a number. The change in next year's median projection against the previous SEP, on the 31 projection meetings since September 2015 with a readable median: +19 ± 6 bp at fifteen minutes, 24 of 31 right (sign test p = 0.003), +14 ± 5 at five, +14 ± 8 at sixty; without the three largest moves, +14 and 24 of 28. Eighteen shorts, thirteen longs. The move builds over the first quarter-hour (+8 at one minute, +19 at fifteen) rather than printing at the release. This is one arm of five and thirty-one observations, so it is a finding to test forward, not a strategy; but it is the only signed number in this whole section that is not within two standard errors of zero.
  • The reader had the dots and did not use them. The context carried the dots as sentences ("Median projection for end-2025 moved to 3.9% from 3.4%") for 44 statements, and surprise_channel was projections or dots for none of them. It put the surprise in the economic assessment (45), the vote (32) and the balance sheet (28), which are the parts of a statement people argue about and not the parts the tape moved on. Whether that is the question's framing (the dots sat under a "context" heading, the question asked about "the statement") or the model, the next version of the tree should ask about the projections by name.

The bill-implied surprise scored nothing (89 trades, +2 ± 4), which is what a six-month proxy for a one-meeting expectation deserves. The absolute reader on statements alone reproduced its earlier +3 ± 4 at 50%.

For the scalper, this closes the loop from the other side: the semantic reader is right about the words and no better than a coin on the direction, because the direction on statement days is set by a table of numbers released with them, and a table of numbers is not a reading job. Where Jev earned its keep here was in saying nothing surprising 98 times — the abstention is correct far more often than the keyword bot's trades are — and that is a risk filter, not a scalp.

Validating the dots

The run above leaves one signed number outside two standard errors of zero, and it is not the reader: it is a table of numbers released with the statement. The rule, written down before it is tested again and not rewritten afterwards:

On a projection meeting, take the sign of the change in the median federal-funds projection for next year against the previous SEP. Positive is fewer cuts, a stronger dollar: short EURUSD at the statement, out at the horizon.

That is what --context found — +19 ± 6 bp at fifteen minutes, 24 of 31 right, sign test p = 0.003 — over 31 meetings on one pair, chosen from five arms. It is the kind of number that is usually a coincidence, so fx/dots.py and jevtrade fx --dots exist to give it four ways to fail.

python -m jevtrade.cli fx --dots --since 2012-01-01 \
    --pairs EURUSD,USDJPY,GBPUSD --latency-sweep --workers-io 2 \
    --out runs/fx-dots.json

No model is called. The dots are arithmetic, the sign is arithmetic, and the tape does the grading; there is no provider argument and nothing to pay for.

Out of sample means the years before the median was printed. The SEP has carried a funds-rate median row only since September 2015, which is where the rule was found. From January 2012 — the first dot plot — the same page carries a histogram: rate levels down the rows, years across the columns, the number of participants at each level in the cells. The median is arithmetic on that, so the fifteen meetings from 2012-01 to 2015-06 are a genuine hold-out for a rule fitted on the printed ones. Three things about those pages had to be found rather than assumed:

  • Blank cells vanish when the tags are stripped. "0.50 1 2" could be one participant in the first year and two in the third, or one in the second and two in the fourth. So the table is read by walking <tr> and <td> and keeping the column positions, not by flattening the page.
  • The headings change three times. 2012 writes them in Title Case ("Appropriate Pace of Policy Firming"), 2013–2015 in sentence case, and from March 2016 the stub column is "Midpoint of target range or target level" because the target became a range. The parser finds the table by its stub header, which names the funds rate in all three.
  • December 2012 has no projection page at all. Its SEP was published inside the minutes, and its histogram is on fomcminutes20121212epa.htm, where the rows are buckets ("0.38 - 0.62") rather than levels. A bucket is read as the one quarter-point value it contains.

The parser is validated where both exist. On the 44 pages from September 2015 on that print a median row and the histogram, the histogram-derived median reproduces the printed one in 198 of 199 year-cells, 43 of 44 meetings exactly. The single disagreement is the 2026-09-16 longer-run dot: eighteen participants, the ninth and tenth both at 3.25, so the median is exactly 3.25, and the page prints 3.2 — while the same page rounds 0.875 up to 0.9. The two cannot both be right; the histogram is what this study uses, and the disagreement is printed rather than reconciled. Finding that check working also found a real bug: the September 2015 table has a -0.125 row, one participant projecting a negative funds rate, and a parser that only reads unsigned numbers moves that page's 2016 median a whole eighth away from the median printed six inches above it.

The hold-out is thin, and that is the first thing the run will say. Of the fourteen out-of-sample meetings with a predecessor, eight print no change at all: from January 2012 to December 2013 the median projection for next year sat at 0.25 and never moved, because the funds rate was at its floor and the first hike was still two years away. Only six of the fourteen carry a sign, all of them between March 2014 and June 2015. Six trades will not confirm or refute anything; what they can do is fail loudly, and the in-sample count they are being compared against — 31 signed meetings out of 44 — is reproduced exactly by this parser, which is the part that had to be got right first.

Four variants are reported and none of them is ever chosen. The current-year median, the two-years-out median, the longer-run dot, and the sum of the year medians are computed and printed next to the rule, labelled as variants. The point is not to find the best one — that is how a coincidence becomes a strategy — it is to let a reader see whether the rule is one lucky pick out of five.

Three pairs, one convention, in one dict with its own test. A hawkish dot plot is a stronger dollar. The dollar is the quote side of EURUSD and GBPUSD and the base side of USDJPY, so the rule is short EURUSD, short GBPUSD and long USDJPY. Getting that backwards inverts the study while leaving every number plausible, which is why it is USD_SIDE = {"EURUSD": -1, "GBPUSD": -1, "USDJPY": +1} and not a derivation.

The latency sweep asks one question. The move builds over the first quarter hour rather than printing at the release — +8 bp at one minute against +19 at fifteen — so unlike the reader's edge it might survive a human. The sweep runs the same signals at 0, 1, 5, 30, 120 and 300 seconds, and the five-minute row is the question in the form somebody would actually ask it: can a person who opens the projection table and reads one number by hand still catch this?

The forward register. Each run ends by parsing fomccalendars.htm — where an asterisk on the date marks a projection meeting — and printing the rule in one sentence and the projection meetings still ahead. As of 2026-09-20 those are 2026-12-09, 2027-03-17, 2027-06-09, 2027-09-15 and 2027-12-08. The per-meeting records go into the --out JSON keyed by date, and a later run merges into that file rather than recomputing it, so running the same command after each of those dates is the forward test and the file accumulates it.

The run (no model, 58 projection meetings, three pairs, ticks)

Run on 2026-09-20 with --since 2012-01-01 --pairs EURUSD,USDJPY,GBPUSD --latency-sweep. 59 projection tables parsed, 15 from the histogram years and 44 with a printed median; the two 2011 pages carry no funds-rate histogram at all. Histogram against printed median: 198 of 199 year-cells agree over the 44 meetings that have both (the one miss is 2026-09-16's longer-run dot, which the page rounds to 3.2 and the histogram puts at exactly 3.25). Of 58 meetings the rule signs 37; the other 21 printed no change in next year's median, twelve of them at the zero bound in 2012–2013 and 2020–2021.

arm                    signals traded    pre   rush  sprd      +1m      +5m     +15m     +30m     +60m  hit15    z15
EURUSD in-sample            31     31     +2     +3   1.8       +8      +14      +19      +18      +14    77%   +3.4
EURUSD out-of-sample         6      6     +2     +7   2.2      +12      +13      +29      +25      +21    83%   +3.1
EURUSD pooled               37     37     +2     +3   1.9       +9      +14      +20      +19      +15    78%   +4.2
  s.e.                                                         +-4      +-4      +-5      +-5      +-8
USDJPY pooled               37     36     -1     +5   3.0       +8      +12      +22      +21      +14    78%   +5.0
  s.e.                                                         +-4      +-4      +-4      +-5      +-8
GBPUSD pooled               37     36     +0     +3   3.7       +7      +12      +18      +18      +14    75%   +4.2
  s.e.                                                         +-3      +-4      +-4      +-5      +-7

at +15m: hit, mean, s.e., median, two-sided sign test
EURUSD in-sample     31   77%   +19   6   +26   0.003     USDJPY in-sample   30   80%   +21   5   +21   0.001
EURUSD out-of-sample  6   83%   +29   9   +32   0.219     GBPUSD in-sample   30   73%   +17   5   +21   0.016
EURUSD pooled        37   78%   +20   5   +29   0.001     USDJPY pooled      36   78%   +22   4   +23   0.001
                                                          GBPUSD pooled      36   75%   +18   4   +22   0.004

variants on EURUSD, reported and not chosen:
current-year   20   90%   +28   6   p 0.000      two-years-out   43   70%   +16   5   p 0.014
longer-run     21   57%    +6   7   p 0.664      sum-of-years    47   68%   +15   5   p 0.019

latency sweep, EURUSD, 37 signals, net of the half spread:
   entry   rush  sprd    +15m   s.e.     z    +60m   s.e.     z
      0s     +0   2.4     +23      5  +4.8     +18      8  +2.2
      1s     +3   1.9     +20      5  +4.2     +15      8  +1.8
      5s     +9   1.9     +15      4  +3.4      +9      7  +1.1
     30s     +9   0.7     +16      3  +4.4      +9      6  +1.4
    120s    +16   0.4      +8      3  +2.2      +4      6  +0.6
    300s    +18   0.4      +6      3  +1.7      +2      6  +0.3

What the validation says, and what it cannot:

  • The out-of-sample half agrees and is too small to prove anything. Six signed meetings in 2014–2015 (the other nine printed no change, because next year's median sat at 0.25 through the zero bound), five of six right, +29 ± 9 bp. The sign test on six is p = 0.22. It does not contradict the in-sample number; it cannot confirm it either. The pooled 37 is the honest headline: +20 ± 5 bp at fifteen minutes, 29 of 37 right, p = 0.001.
  • The other two pairs agree, and they are one factor. USDJPY 78% and GBPUSD 75% over 36 each, both p ≤ 0.004. That is the dollar moving, seen three times, not three independent tests; what it rules out is a EURUSD-only artefact.
  • The rule is not one pick out of five. Current-year's median does better (90% over 20), two-years-out and the sum do worse but hold (70%, 68%), the longer-run dot does nothing (57%, p = 0.66). The finding is "the near dots moved", and it degrades gracefully away from that.
  • A person can catch it. Entering thirty seconds after the release, time to read a table by hand, still returns +16 ± 3 at fifteen minutes; two minutes late, +8 ± 3; five minutes late, +6 ± 3 with the rush column at +18, meaning most of the move has gone by. So this is a number that a machine reads in a second and a desk reads in half a minute, and the market takes a quarter of an hour to finish pricing. It is, once more, not a reading job.
  • The misses are informative. Eight of 37 on EURUSD: 2018-12-19 (dots down, "autopilot" press conference, dollar up), 2023-03-22 (the SVB meeting), 2021-09-22 (first hike pulled into 2022, dollar down), 2019-09-18, 2018-03-21, 2025-06-18, 2024-03-20, 2014-06-18. The days the rule lost are the days the press conference or the moment mattered more than the table.

The forward register. The rule, the 58 records and the sign convention are in runs/fx-dots-2012-2026.json; the next projection meetings on the FOMC calendar are 2026-12-09, 2027-03-17, 2027-06-09, 2027-09-15 and 2027-12-08. Running the same command after each is the forward test, and the first check is scheduled for the morning after 2026-12-09. A forward tally will be appended here, one row per meeting, with no other change to the rule.

The press conference, thirty minutes later

On 2022-11-02 the statement read one way and the press conference read the other, and the tape followed the press conference: the reader was short EURUSD and −66 bp at fifteen minutes. That day is in the run's list of worst calls, and the statement tree cannot see it, because the thing that moved the price is a different document published half an hour later. fx/presser.py and jevtrade fx --presser are that document.

python -m jevtrade.cli fx --presser --provider jev --tape dukascopy \
    --since 2011-01-01 --latency-sweep --workers-io 2 \
    --out runs/fx-presser.json

The sources. Every press conference since April 2011 has a transcript at …/mediacenter/files/FOMCpresconf{YYYYMMDD}.pdf. Which meetings had one comes off the calendar pages — fomccalendars.htm for the current six years and fomchistorical{YYYY}.htm for one year each before that — both of which link the conference by its date. Counted over 2011 to 2026-09 that is 95 conferences: three in 2011, five in 2012, four a year through 2018, then every meeting from 2019 (nine in 2020, including the two intermeeting briefings). The ninety-fifth is only there because the match is deliberately loose: the Fed's own January 2026 row spells the link fomcpressconf, with two s's.

A transcript is published after the conference it records. So, exactly as the speeches arm already says, this measures "was it worth listening to" and not "could you have traded it". What would make it tradeable is a live speech-to-text feed off the video, and that is not free.

pypdf is an optional extra (pip install 'jev-trade[pdf]'). The rest of this repository is standard library only and stays that way. When pypdf is not importable the module says so once and the transcript arms are reported dark — which is a result, in the same sense as the surprise bot's "not available" — while the arms that need only the start time still run. That path has its own test.

When it starts is a rule, and only half of it is measured. From 2013 the statement is released at 2:00 p.m. ET and the Chair starts at 2:30, so the start is the statement plus thirty minutes; every FOMC statement row in the archive from 2013-03-20 on carries 14:00, and the Fed announced the change on 2013-03-13, but the half hour itself is the published schedule taken on trust. In 2011 and 2012 the statement went out at 12:30 p.m. and the Chair began at 2:15, which is checked: the April 2011 press-conference page prints "FOMC Meeting Statement (Released April 27, 2011 at 12:30 p.m.)" beside "Projections Materials … (Released April 27, 2011 at 2:15 p.m.)", and the projections were released as the conference opened. The archive's own minute for those statements is 12:35 or 12:40 — when the release was posted, not when it was released — which is why the early rule is an absolute time of day and not an offset.

Splitting the transcript is the part most likely to rot, so the marker style is recorded per day. The running header (Page 3 of 26, or a bare 3 of 26 in 2011, plus the dated "… Press Conference FINAL/PRELIMINARY" line) is stripped, and the opening remarks end at the first speaker who is not the Chair. Counted over all 95 transcripts, that speaker is marked three ways and all three are handled and tested: QUESTION. — exactly once, on 2011-04-27, and never again; a reporter's name in capitals (JON HILSENRATH.) from 2011-06-22, 47 times; and a press officer handing over (MICHELLE SMITH. Steve.) from 2020-04-29, 47 times. Two smaller things had to be found the same way: the 2018 transcripts punctuate the Chair's own marker with a colon, and the June 2024 one leaves one space after the stop rather than two, either of which makes a parser start the "opening remarks" somewhere in the middle of the Q&A. With both handled the remarks run 2,400 to 14,100 characters, median 7,700, on all 95.

Each half is capped at 9,000 characters for the state. On the remarks that is almost always the whole thing; on the Q&A, whose median is 41,300, it is a real cut, and what the reader gets is the opening exchanges — where the questions about the path get asked — rather than the hour.

The tree is a third mode (presser, version pc1, its own cache tag, so the statement reader's v1 answers are untouched). Its state is the statement, the dots sentences for the day, the opening remarks and the Q&A. Round one carries the whole absolute tree and adds seven questions:

idtypeasks
remarks_vs_statementChoicemore hawkish / consistent / more dovish than the statement
qa_vs_remarksChoicethe same, for the answers against the Chair's own remarks
pushback_on_pricingNouldid the Chair push back against how the market was pricing the path
presser_stanceChoicehawkish / dovish / neutral for the dollar, the conference as a whole
new_informationNoulrelative to the statement
surprise_sizeScorenothing the statement did not have → the kind that sets the day
dominant_topicChoiceinflation / labor / growth / financial conditions / balance sheet / path of rates / other

Round two runs on a decisive presser_stance or on either half not being consistent — that second door is the whole point of the mode — and asks the direction again reversed ("a desk that had read the statement and the dots and then listened to this: which side for the next hour?"), the holder check and the horizon. The sign comes from presser_stance and never from the statement's stance, which is what lets a 2022-11-02 point the other way. Strength is the context tree's arithmetic: p(stance) × confirm × surprise_size × (1 − priced_in).

Four arms on press-conference days, all on EURUSD ticks and all entered from the moment the Chair started rather than from the release: presser-reader, the pc1 tree; statement-reader, that day's cached absolute reading of the statement carried into the conference — "what if you held the statement's read through it"; dots-rule from the same moment, which asks whether the dots move is still going half an hour on; and all pressers, the keyword bot pointed at the transcript. Each against the same session-matched null, with a latency sweep on the reader.

And a reversal table. Every day where remarks_vs_statement or qa_vs_remarks was not consistent, printed with the tape's move from the release to the Chair's first word and from there to an hour later, side by side. That is the shape of 2022-11-02, and a table is the only way to find out whether that day was one of a kind or one of twenty.

The run (real model, 95 press conferences, ticks)

Run on 2026-09-20. All 95 transcripts since April 2011 fetched and split (median remarks 7,698 characters, median Q&A 41,308, of which the reader saw the first 9,000); 85 went to round two, 33 questions in the median conference, median 1,029 ms per conference, $0.056 for the lot, plus $0.024 for the 95 statement readings this mode asked again. Entry on the first EURUSD tick after the Chair's first word plus one second.

arm                    signals traded    pre   rush  sprd      +1m      +5m     +15m     +30m     +60m  hit15    z15
presser-reader              24     24     +4     +0   0.4       +1       -1       -3       -3       -3    42%   -0.6
  s.e.                                                         +-1      +-2      +-5      +-6      +-8
statement-reader            52     52     +1     +0   0.4       +0       +2      -11      -11       -9    31%   -3.5
  s.e.                                                         +-1      +-2      +-3      +-4      +-5
dots-rule                   37     37     -1     +0   0.3       +1       +1       -5       -4       -1    41%   -0.8
  s.e.                                                         +-1      +-2      +-4      +-5      +-6
all pressers                95     95     +1     +0   0.4       -0       -0       -7       -8       -9    38%   -2.8
  s.e.                                                         +-0      +-1      +-2      +-3      +-3

how they read for the dollar: dovish 52, hawkish 38, neutral 5
what they were about: path of rates 48, inflation 21, balance sheet 8, financial conditions 6
where the conference did not say what the statement said: 12 of 95
  • The reader read the press conference and got nothing from it. 24 trades, −3 ± 5 bp at fifteen minutes, ten of twenty-four right, flat at every latency from zero to five minutes. A transcript is the slowest possible feed for a spoken event, and the reader saw the first fifth of the Q&A; both are reasons, neither is an excuse. The number is nothing.
  • Holding the statement's read into the conference loses, and loses more than chance. The absolute reading of the day's statement, carried from 14:30, comes out at −11 ± 3 bp at fifteen minutes, 16 of 52 right (sign test p = 0.008), −11 ± 4 at thirty, −9 ± 5 at sixty. Split by era it is −3 (47%) over the quarterly conferences of 2011–2018 and −15 (21%) over the every-meeting conferences since 2019. The keyword bot on the transcript itself loses too: −7 ± 2 at fifteen minutes (p = 0.02), −9 ± 3 at sixty (p = 0.007), in both eras. The dots move from the release does not continue through the conference either (−5 ± 4). Whatever the text of the day says, the hour after the Chair starts talking tends to go the other way. Fading the statement's read at 14:30 would have made +11 bp at fifteen minutes on these 52 days; that is the mirror of one arm on one pair, reported here as a pattern registered for the forward test and not as a trade.
  • The reversal table is short. Twelve days of ninety-five where the reader said the remarks or the Q&A did not say what the statement said, 2024-12-18 among them (statement to Chair −69 bp, Chair to an hour later −57). Nine of the twelve are from 2021 on. 2022-11-02 is not in it: the reader called that conference consistent with its statement, which is the day the mode was built for, and it missed it.

For the question that started this section: the words of the press conference are not where the direction is. The direction is in the reversal of whatever the statement's words said — a pattern, not a reading — and the thing a reader can still do here is what it did on the statements: read the facts right and abstain.

What this does not show

Feed latency is the real bottleneck, and this study cannot measure it. A scheduled statement is pollable to the second — the FOMC page goes live at 14:00:00 ET and anyone can be on it — so for rate decisions and minutes the published timestamp is close to honest. For everything unscheduled it is not. A speech is "published" when a web team gets to it; a press-conference remark reaches the tape through a wire headline seconds after it is spoken and hours before anything appears in an RSS feed, and wire feeds are not free. So a positive result on speeches here would say "this text was worth reading", not "you could have traded it", and the honest scope is the scheduled-text subset.

Three more limits worth holding onto. Only the Fed has an archive, so the statistical weight will land on one central bank and one pair. Spot FX has no weekend, which costs sample and biases the surviving events towards weekday sessions. And 5-minute bars are coarse for a reaction whose first leg is measured in seconds, which is why --tape dukascopy exists; the bar path is kept because it is the cheaper check, and because two judges disagreeing about an arm is worth knowing.

Weekend FX perps were probed and left as future work. Crypto venues list 24/7 FX perpetuals, which would cover the events the spot tape sleeps through. Over 11 weekends, Gate's EURUSD_USDT drift across the closed period explains some of Monday's spot gap and not much of it: beta 0.36, R² 0.22, mean |gap| 8.2 bp against mean |drift| 11.7 bp, residual s.d. 9.7 bp. Funding is effectively zero (Bitget's USDJPY perp printed −3.95 bp once in ten days; EURUSD was flat zero throughout). That is enough of a link to be interesting and too loose to grade a signal on, so nothing in jevtrade/fx/ depends on it.

Real market data

python -m jevtrade.cli fetch --symbol BTC --out data/btc_1m.csv
python -m jevtrade.cli backtest --source csv --csv data/btc_1m.csv
python -m jevtrade.cli backtest --source live --symbol ETH   # fetch and run

Kraken's public API gives 720 recent OHLCV bars, no key needed. There is no order book in a bar, so bid/ask and resting sizes are reconstructed from the bar's range and close — an approximation, flagged in the state that Jev sees. On real ETH 1-minute bars the mock scores a 50.2% hit rate: a coin flip, which is what an untuned heuristic on real data should look like, and a useful check that nothing here is rigged.

Testing

python -m pytest -q      # 396 tests

They cover the documented request/response schema, each policy gate, position accounting through a flip, the latency and deadline behaviour, the stop and kill switch, and two honesty checks on the simulator itself: no edge when alpha=0, and an edge when it is switched on.

Layout

filerole
feed.pysynthetic microstructure sim, CSV replay, Kraken fetch
features.pyevery number in the system
discretize.pynumbers → closed-vocabulary words
questions.pythe six typed questions
jev/client.pyPOST /v1/systemone, stdlib only
jev/mock.pyoffline stub, clearly labelled
policy.pyanswers + confidence → target position
engine.pythe loop: latency, deadlines, safeguards
execution.pyfills, spread, impact, fees
metrics.pyP&L, hit rate, calibration, break-even fee
baselines.pyrule strategies for comparison
live.pyKraken live top-of-book, real bid/ask and sizes
server.pythe demo server: trading loop + SSE
web/index.htmlthe demo page
mm/market.pyquoting sim with informed (toxic) flow
mm/events.pysynthetic headlines + the keyword competitor
mm/questions.pythe six headline questions
mm/strategies.pythe four arms
mm/metrics.pymarkout decomposition
news/feeds.pyreal headlines from 11 public RSS feeds
news/label.pylabels each headline by what the tape did next
news/study.pythe null controls and the crux test
listing/announcements.pyBinance's CMS feed, millisecond-stamped
listing/tickers.pycandidate tickers, found by code
listing/reader.pythe two-round judgment tree
listing/baseline.pythe title-matching sniper bot
listing/venues.py1-minute candles from Binance, OKX or Coinbase
listing/study.pyentry rule, horizons, matched null, arms
fx/documents.pyFed archives + ECB/BoJ/BoE RSS, timestamps and bodies
fx/diff.pythe previous statement, and the sentences that changed
fx/reader.pythe two-round tree and the sign convention
fx/baseline.pythe word-counting bot and the rate-surprise bot
fx/tape.pyspot FX bars, gap-aware entry and horizons
fx/ticks.pyDukascopy ticks: the book, the entry latency, the spread
fx/context.pythe pre-release context, every item stamped and checked
fx/dots.pythe SEP histogram, the median, the rule and the forward register
fx/presser.pythe press-conference transcript, split at the first question
fx/study.pythe arms, the session-matched null, the audit
fx/mock.pyoffline stub for the FX questions

TypeSafe also ships first-party SDKs (pip install typesafe-sdk, @typesafe-ai/sdk). This repo speaks HTTP directly so the wire format stays visible, since with a System One model the wire format is the interesting part: there is no parsing step and no schema-repair retry, because the model cannot emit a value outside the schema you declared.

Sources