# trading-research-agents
> An open-source Python package and command line (tra): autonomous trading agents with deterministic execution. Language model agents read the market and propose a trade; fixed, tested rules compute every figure, price the round trip, size the position and decide whether an order goes out. It runs in a sandbox by default, trades linear perpetuals today on any exchange ccxt supports, with more market types on the way, and works with Anthropic, OpenAI, Google Gemini, Groq, DeepSeek, OpenRouter, Ollama and other model providers. MIT licensed.
Current release: 0.10.0.
Install: uv tool install 'trading-research-agents[ccxt,openai,anthropic,google,data]'
Command: tra. Python 3.11 or newer.
Website: https://www.bijectivelabs.dev/open-source
Source: https://github.com/BijectiveLabs/trading-research-agents
Package: https://pypi.org/project/trading-research-agents/
# trading-research-agents
**Autonomous trading agents with deterministic execution. The agents propose; the system decides.**
Language models are good at reading a market and saying what they think. They are bad at
arithmetic, they don't know what a trade costs, and they will happily overrule a limit you set.
This project uses them for the first part only. The system itself, fixed rules with no model
involved, fetches the data and works out every figure. The agents read those figures and give an
opinion. Then the system prices the trade, applies your limits, sizes it, and either writes it
down or sends one order with the stop attached. Every decision and every refusal is kept on disk
with its reason, so you can check afterwards what happened and why.
It is an early release, so expect settings to change between versions. What works
today: linear perpetuals, spot pairs (stock perpetuals and tokenized stocks among them) and
prediction market outcomes through one order path, a universe of perpetuals found and measured by
the system, a screener that picks where to look, the full autonomous cycle on live data, four analysts and up to three validators, stops and
targets the analyst names inside bounds you set, the gates that decide, sizing from your own rules,
real orders on a venue's practice environment behind a mandate you sign, a risk manager that can
only make the desk more careful, and two auditors that read the records.
The command is `tra`. Start with the [quick start](https://docs.bijectivelabs.dev/opensource/quick-start).
## What it solves
| The usual problem with trading agents | What happens here |
|---|---|
| The model invents or miscalculates a number | Models never compute. Every figure comes from the system, and a figure a model cites is checked against its brief, value included. |
| Nobody knows what a trade cost until it's done | The round trip (fees, spread, impact, funding, slippage) is priced before an order exists, and a trade whose claimed edge doesn't clear it is refused. |
| The agent sizes its own position | Size comes from your conviction ladder and your risk limit. No model can make a trade bigger. |
| A model "decides" to ignore your settings | Your declared thresholds decide. A model's opinion is recorded beside them, never instead of them. |
| Something went wrong and there is no trace | Each cycle keeps its snapshot, briefs, answers, the intent and what the venue said. Nothing is rewritten. |
| A crash leaves a position with no stop | The stop and take profit go to the venue with the entry, or right after the fill where the venue takes them no other way, so they survive this process dying. |
| Text from a feed tells the model what to do | The model that proposes a direction reads numbers code computed; outside text reaches only roles that decide nothing, and no model can send an order or reach a key. |
None of these is hypothetical. [Why it is built this way](https://docs.bijectivelabs.dev/opensource/why) lists the documented
failures behind each one, with their sources.
## How one cycle decides
```mermaid
flowchart TD
find[["the system finds the universe and measures each instrument one data pull each; every value carries its source and age"]] --> wake
wake{"trigger has anything moved since the last look?"}
wake -- "no" --> held["held: one data pull, no tokens spent"]
wake -- "yes" --> pick["the screener picks among what the system admitted citing each instrument's own figures"]
pick -- "none taken" --> held
pick -- "taken" --> briefs
briefs["the system writes the briefs each role sees only the figures its job needs"] --> round1
subgraph round1["asked at the same time"]
direction LR
tech["technical analyst a direction, a confidence, a horizon"]
macro["macro analyst"]
chain["on-chain analyst"]
mood["sentiment analyst"]
end
round1 --> price["the system prices the round trip fee, spread, impact, funding, slippage"]
price --> exits{"the stop and the target the analyst's levels, held inside your bounds does the trade break even at half its kind winning?"}
exits -- "no" --> refused
exits -- "yes" --> round2["validators, as many as you declare a claim that cites no figure is thrown away"]
round2 --> matrix{"confluence do your declared states agree with the direction?"}
matrix -- "no" --> refused["a refusal that names its figure"]
matrix -- "yes" --> size["conviction how much, from your ladder"]
size --> risk{"risk per trade what does it lose at the stop?"}
risk --> gate{"cost gate and targets does the edge clear the cost, by your margin?"}
gate -- "no" --> refused
gate -- "yes" --> intent["the intent, written into the cycle"]
intent --> venue["venue mode only one order, the stop and take profit attached"]
```
## Who does what
| Role | Reads | Answers | Can never |
|---|---|---|---|
| technical analyst | candles, spread, funding, book imbalance, open interest change, and the regime on its timeframe and the higher ones you declare | a direction, a setup from a fixed list, a confidence, a horizon, and where its stop and target sit | size the trade, place a level outside your `[exit]` bounds, run a trend setup in a range, or fade a market that is not a measured range |
| macro analyst | dated rates and levels, dated text | risk-on, risk-off or neutral | propose a direction |
| on-chain analyst | chain and derivatives figures | accumulation, distribution or neutral | propose a direction |
| sentiment analyst | dated scores and notes you curate | negative, neutral, positive, or not declared | invent a mood no figure supports |
| market screener | what the system measured for each admitted instrument: move, spread, regime, volume, correlation to BTC | which instruments to take, in what order, citing their figures | add an instrument the system did not admit |
| validators: pro and contra | the proposal and the figures behind it, each without the other's case | the strongest case for or against, as cited claims | see the other side's case, or cite a figure it wasn't given |
| validator: neutral, as judge | the same, and both cases once code has checked every claim | support, oppose or abstain, the one verdict that counts | see the advocates' verdicts, or count claims instead of weighing them |
| risk manager | what the run recorded: round trips, the losing streak, your limits, the alarm that woke it | continue, cut the size of new entries, pause instruments, or halt, each for a stated number of hours | loosen a limit you set, or add size |
| technology and quant auditors | the cycles this desk recorded | findings and proposed changes | change anything |
Everything after the analysts is arithmetic over numbers you declared. No model can place, cancel
or resize an order, read a file outside the project, or edit your configuration. [Agents](https://docs.bijectivelabs.dev/opensource/agents)
has the full table of what an agent can reach, and [The cycle](https://docs.bijectivelabs.dev/opensource/cycle) walks through every step.
## What it trades
Today, through one order path and the same gates:
| Market | How | Run |
|---|---|---|
| Linear perpetuals (USDT- or USDC-margined swaps) | long and short, with the leverage and margin mode you declare; the default | end to end on the demo or testnet APIs of Bybit, OKX, Bitget, BingX, Binance and Gate |
| Spot pairs | long only, paid in full; list `spot` in `[execution].markets` | the order path and a desk cycle on the Bybit and OKX demos |
| Stock perpetuals and tokenized stocks | a perpetual and a spot pair to the venue, entered only while the underlying's session trades | a stock perpetual on the OKX demo, a tokenized stock on the Bybit demo |
| Prediction market outcomes | long only, decided on a probability by the event analyst and priced to resolution; list `prediction` | sandbox cycles on live data; the order path is tested against ccxt's shapes |
Every exchange ccxt supports is reached through that same path, and any other can have its run on
its demo or testnet API at any time ([Venues](https://docs.bijectivelabs.dev/opensource/venues),
[Exchange accounts and keys](https://docs.bijectivelabs.dev/opensource/exchange-keys)).
That is where the project started, not where it stops. Dated futures, inverse contracts and options
are on the way, each with its own cost model, exit rules and tests. Until a market type has those,
venue mode checks the type on every order and refuses it, because a market the system cannot
yet price and protect is not one it should send to. [Boundaries](https://docs.bijectivelabs.dev/opensource/boundaries) lists what
is guaranteed, what is not built yet, and what adding each piece would take.
## Where it is going
The project is in active development, and these pages describe where it stands today.
- **More market types:** spot, stock perpetuals, tokenized stocks and prediction markets are
built; inverse and dated futures and options follow, each with its own cost model, exit rules, sizing
and tests before venue mode accepts it.
- **More venues run end to end:** each joins the six already run after a full run on its demo or
testnet API, ours or yours.
- **The rest of the not-built-yet list** in [Boundaries](https://docs.bijectivelabs.dev/opensource/boundaries), one piece at a
time, without weakening a guarantee.
**Use the release.** What PyPI installs is the latest release, and these docs describe it. Releases
come from `master`. The `develop` branch is where the next release is built: it moves often, a
commit there is not a release, and it can carry work that is unfinished or changed again before it
ships. A set of changes becomes a release when it is merged into `master`, tagged and published; the
[changelog](https://github.com/BijectiveLabs/trading-research-agents/blob/master/CHANGELOG.md) says
what each one brought.
## Where to go next
| If you want to | Read |
|---|---|
| install it and run a first cycle | [Quick start](https://docs.bijectivelabs.dev/opensource/quick-start) |
| see what the terminal shows, step by step | [What you see](https://docs.bijectivelabs.dev/opensource/what-you-see) |
| understand every step and every setting | [The cycle](https://docs.bijectivelabs.dev/opensource/cycle) |
| point it at your own data feeds, free or paid | [Data sources](https://docs.bijectivelabs.dev/opensource/sources) |
| trade on a venue | [Venues](https://docs.bijectivelabs.dev/opensource/venues) and [Capital, orders and positions](https://docs.bijectivelabs.dev/opensource/capital) |
| choose a model provider | [Model providers](https://docs.bijectivelabs.dev/opensource/providers) |
| find a command | [Command line](https://docs.bijectivelabs.dev/opensource/commands) |
| contribute | [Contributing](https://docs.bijectivelabs.dev/opensource/contributing) |
# Quick start
A few minutes and one model key. Every setting has a working default, so a new desk runs before you
change anything; the table under step 6 says what those defaults are.
**1. Install.** You need Python 3.11 or newer. [uv](https://docs.astral.sh/uv/) is the quickest way:
```bash
uv tool install 'trading-research-agents[ccxt,openai,anthropic,google,data]'
```
That brings every model provider and every exchange in. Without uv, run
`pip install 'trading-research-agents[ccxt,openai,anthropic,google,data]'` inside a virtual
environment. To move to the newest release later:
```bash
uv tool upgrade trading-research-agents # or: pip install -U 'trading-research-agents[ccxt,openai,anthropic,google,data]'
tra --version
```
**2. Make a desk.** A desk is a folder holding your settings and everything the agents decide. Name
the provider you have a key for:
```bash
tra init my-desk --profile autonomous --agent groq
cd my-desk
```
| `--agent` | Key in `.env` | Model, when you name none |
|---|---|---|
| `groq` | `GROQ_API_KEY` (free tier) | `openai/gpt-oss-120b` |
| `google` | `GEMINI_API_KEY` | `gemini-flash-latest` |
| `anthropic` | `ANTHROPIC_API_KEY` | `claude-opus-5-5` |
| `openai`, `deepseek`, `xai`, `openrouter`, ... | see [Model providers](https://docs.bijectivelabs.dev/opensource/providers) | name one with `--model`; `tra models ` lists them |
Add `--venue okx` (or any ccxt id) to trade somewhere other than Bybit.
**3. Add your key.** Copy the example file and put your key in it. Every command reads the `.env`
next to `strategy.toml` on its own.
```bash
cp .env.example .env # then set GROQ_API_KEY=... in .env
```
**4. Check.** `tra check` reads every setting against the others and names the one thing to fix
next. A desk that is ready ends with:
```
ok provider groq: ready
ready to run `tra run`
```
**5. Run.** `tra run` decides once on live market data, in the sandbox: nothing leaves your
machine. `tra run --every 15m` keeps deciding, once per fifteen-minute bar, on the clock. Or type
`tra` on its own for the console, which takes the same commands.
```bash
tra run
tra run --every 15m
```
**6. When you want real orders.** Start on the exchange's demo or testnet API. Make the desk for
that exchange from the start, and the rest is three commands:
```bash
tra init my-desk --profile autonomous --agent groq --venue bitget
cd my-desk # put the exchange's keys in .env beside your model key
tra keys --probe --prepare # reads the account; on a demo or testnet sets one-way mode
tra mandate --sign "Your Name" --venue-mode
tra check
```
`--venue` writes everything that exchange needs into `[execution]`: its demo or testnet API, 3x
isolated leverage, at most 200 per order, how its stop and take profit are placed, and orders sized
at 100 of the quote currency. Change any of it; `tra check` says what each line does. How to make
the key on each exchange is in [Exchange accounts and keys](https://docs.bijectivelabs.dev/opensource/exchange-keys), and
[Toward real orders](#toward-real-orders) below has the whole path.
**What a new desk does before you change anything**
| | Default | Where to change it |
|---|---|---|
| Orders | none: the sandbox writes each decision down and sends nothing | `[execution].mode` |
| What it trades | BTCUSDT on Bybit, as a taker | `[run].instrument`, `[universe]`, `--venue` |
| Timeframe | decides on 15-minute bars, reads 1h and 4h as context | `[run].bar_interval_s`, `context_intervals` |
| Strategy | none fixed: the analyst picks the setup the measured regime supports, or stays flat | `[agents].mechanism` |
| Stop and target | the analyst names them; the stop is held between 0.2% and 3% and never inside one ATR, the target between 1.5x and 4x the stop | `[exit]` |
| Risk per trade | 0.5% of equity lost at the stop, whatever the stop's distance | `[controls].max_risk_per_trade_bps` |
| Bad days | no new entry after a 3% loss on the day or a 10% fall from the peak | `[controls]` |
| Time limit | a position closes after 4 hours | `[exit].timeout_bars` |
| Trades worth taking | the edge at least 2x the round trip, break even at half the trades or better, at most 6 a day | `[targets]` |
| Oversight | a risk manager that can only cut, pause or halt; two auditors that report | `[audit]` |
Working on the code itself? [Contributing](https://docs.bijectivelabs.dev/opensource/contributing) has the setup from a clone.
## What you decide, and what has a default
The system measures what it can measure, uses the common standard where one exists, and asks you
only for what nobody else can decide. `tra check` says, in plain words, what every setting below
will do in your project, defaults included.
**You decide:**
| What | Where | Why it is yours |
|---|---|---|
| What to trade | `[run].instrument`, `[run].venue_key` | |
| How much per trade | `[run].size` or `[run].size_quote` | your money |
| Which model | `[agents].provider`, `[agents].model` (`tra init --agent` writes both) | your account and your bill |
| Your thesis, if you trade one idea | `[agents].mechanism` (`tra init --agent` writes one that lets the regime choose) | the setups you favour; it never overrides the measured regime |
| Leverage | `[execution].leverage` | only you know what you can carry |
| The most one order may be worth | `[execution].max_notional` | the cap on a defect's cost |
| That you take the losses on | `MANDATE.md`, `acknowledged_by`, `acknowledged_on` | venue mode only |
**Already set, change any of it:**
| Setting | Default | What it does |
|---|---|---|
| `[controls].max_risk_per_trade_bps` | `50` | the most one trade may lose at its stop, in bps of equity |
| `[controls].daily_loss_limit_bps` | `300` | no new entry once equity is down this far on the UTC day |
| `[controls].max_orders_per_minute` | `6` | across the whole desk |
| `[controls].max_position` | none | a cap in units; the risk per trade already bounds every size |
| `[controls].max_drawdown_bps` | none (`tra init` writes `1000`) | no new entry once equity is this far below its peak |
| `[exit]` | the analyst's stop and target, held inside bounds | see [where the stop and take profit sit](https://docs.bijectivelabs.dev/opensource/cycle#where-the-stop-and-the-take-profit-sit) |
| `[targets].max_break_even_win_rate` | `0.5` | refuse a trade that would need more than half its kind to win to cover the stops and the costs |
| `[exit].timeout_bars` | four hours of bars: `240` at 1m, `16` at 15m | a position closes by time after this many bars; a measured stop is sized over the same span |
| `[run].bar_interval_s` | `60`; `tra init --agent` writes `900` with 1h and 4h as context | the decision timeframe; the round trip costs the same on any of them, so a longer one leaves more of the move |
| `[run].settlement_interval_s` | `28800` | the funding clock used only when the venue does not report an instrument's own |
| `[execution].environment` | `demo` | where orders go: `demo`, `testnet` or `mainnet` |
A scripted strategy (`[strategy].file`) is research, so it declares its own exit and cost figures:
`[exit].stop_frac`, `timeout_bars` and `mechanism_bound`, and `[run].spread_cost_bps`, `latency_ms`
and `slippage_bps`. A default there would be a cost the research never measured.
What stays strict, and why: a setting nobody recognises is refused rather than ignored, because a
typo in a risk setting should stop the desk at `tra check`, not at the venue; and the few things
above that are yours are asked for, never guessed.
## Toward real orders
An agent desk goes through three steps, and you can stay at any of them as long as you like:
| Step | How | What happens | Needs |
|---|---|---|---|
| 1. Sandbox | `tra run` | the full cycle on live data; every decision written down, no order sent | a model key |
| 2. Demo or testnet API | `tra run` with `[execution].mode = "venue"` and `environment = "demo"` or `"testnet"` | real orders on the exchange's demo or testnet API, exits attached, positions looked after | a signed mandate, a leverage, `max_notional`, practice keys |
| 3. Real capital | the same, with `environment = "mainnet"` | real orders with real money | all of the above, plus the ladder at its canary tier (`tra ladder promote`, with evidence) |
Venue mode is the only mode that can lose money, so it has a door in front of it. Read the
mandate with `tra mandate`; when you accept it, `tra mandate --sign "Your Name"` keeps it in the
project as `MANDATE.md` and signs `[execution]` with your name and today's date, in one step.
Signing alone does not switch anything on; `--venue-mode` signs and turns orders on together. Read
[Capital, orders and positions](https://docs.bijectivelabs.dev/opensource/capital) before you open it.
```toml
[execution]
mode = "venue"
environment = "demo" # demo | testnet | mainnet
leverage = 3 # yours to choose; it never makes a trade bigger
max_notional = 400 # the most one order may be worth, in quote currency
margin_mode = "isolated" # isolated | cross
hold_limit = "timeout" # close by time after [exit].timeout_bars; or horizon, or none
```
Who decides what, in one line each: **the analysts propose the direction and where the trade is
wrong or done, your risk rule sets the size, you choose the leverage, and no model touches the
last two.**
A scripted strategy of your own has a research path before any of this: `tra data get` and
`tra backtest` replay a day of the exchange's archive net of every cost, `tra paper` runs it on
the live feed with nothing sent, and `tra live` is a small canary session behind hard caps.
[The live tier](https://docs.bijectivelabs.dev/opensource/live) walks through it.
# What you see, in order
## 1. The console
Run `tra` inside your desk and it opens the console: what is ready, what isn't, and the next
command to run. Under it is a prompt that takes every command and draws it right there. Arrow keys
go back through what you typed, tab completes, `help` lists the commands and `exit` leaves. For
scripts and cron, `tra -c "run --now"` runs one command and exits.

## 2. The pass over your instruments
Type `run --every 15m` and the desk first refreshes any data series that are due, one line each.
Then it looks at every instrument on your list, one data pull apiece, and measures it. The screen
steps through them live:

## 3. One cycle per instrument it takes
The system found the candidates and measured them; the screener chose among them and cited each
instrument's own figures; the system's own order is kept beside the choice. The instruments taken get a
full cycle. The terminal shows the whole pipeline from the start and updates it as each step begins
and ends. Steps that run at the same time spin at the same time, each with its own clock, and the
line underneath says who is working right now:

## 4. The result
When the cycle finishes, the table gives way to the pass over the universe: what the screener took,
what waited and what was held, and why. Then the cycle itself: what the analyst proposed and from
which figures, and every refusal with the number behind it. Here an ETH short in a ranging market
stopped at its exits: with its own stop and target and the round trip, more than half of trades
like it would have had to win just to break even:

Then the data the cycle used and how old it was, the series the reading analysts read, the stop and
take profit it would have placed, and which agents answered. A cycle that goes further adds what
each validator argued, how conviction weighed the analysts, the risk at the stop and the targets:

## 5. Between cycles
The desk says when the next pass is, on the clock and in minutes, for example
`next pass at 14:30:00 UTC, in 10m 57s`, and counts down to it. An auditor woken by an alarm gets a
spinner of its own.
## The same cycle as plain text
`--plain`, a pipe or a log gives text instead of panels. This is from a real run on live data,
shortened:
```
cycle 2026-09-29T02-59-16Z BTCUSDT: 5.344s of agents, 15.042s of validators
funding ccxt:bybit age 4.267s
ohlcv ccxt:bybit age 32.746s
orderbook ccxt:bybit age 7.544s
macro risk-off: the operator declared a bullish state for US10Y; measured correlation is
neutral (0.0) on a modest sample, weak evidence that does not overturn the declaration
sentiment positive: declared state is bullish, and the measured figures both support it
technical: long on range_reversion at 0.65 over 5 bars: the close is stretched low in the window
read: zscore=-0.587, vwap_gap_bps=-3.99, ret_1_bps=1.59
validator contra oppose on 2 cited figure(s)
validator pro support on 3 cited figure(s)
conviction: 0.800 of 1.0 (macro 1.0, sentiment 0.5, technical 1.300)
risk: 8.29 bps of equity at the stop against the declared 50 bps, size 0.010
cost: 12.87 bps round trip, 2.00 of it estimated
gate: 25.0000 bps claimed clears 12.8723 bps cost plus 0.0000 bps margin
refused targets: the claimed edge is 1.94x the round trip and the desk declared 2.0x;
the cost is certain and the edge is not
```
That trade was refused. It cleared the cost gate and still died, because this desk asks for an edge
of twice the cost and got 1.94 times. A refusal names the figure behind it, every time.
## What is kept, and where
| What | Where | Notes |
|---|---|---|
| everything one cycle did | `runs/cycles//` | snapshot, briefs, answers, intent, `placed.json`, `cycle.json`, and `inputs.json.gz` with the raw inputs; `cycle.json` holds a sha256 of each file |
| the pass over the universe | `runs/screens/` | every instrument measured, what the screener chose and why |
| every model call | `runs/cycles/usage.jsonl` | tokens, latency, price |
| when the desk ran and what stopped it | `runs/cycles/scheduler.jsonl` | one line per start and stop |
| every position closed, by time, by the venue or by the kill switch | `runs/cycles/positions.jsonl` | venue mode |
| every finished trade with entry, exit, fees and funding | `runs/cycles/round_trips.jsonl` | venue mode; what the quant auditor reads |
| the first equity reading of each day | `runs/cycles/equity.jsonl` | what the daily loss limit is measured from |
| what you typed at the prompt | `.tra-history` | a convenience only; `tra -c` keeps none |
`tra runs verify` checks every cycle's files against their hashes and its briefs against its raw
inputs. `tra runs prune --keep 90d` shows what old cycle directories would be removed.
## Watching a desk you are not running
The console runs commands in its own process, so closing it ends what it was running. A desk that
should keep running belongs under a supervisor (systemd, a service manager, `tmux`), and then you
look at it from anywhere with:
```bash
tra watch --every 5s # a read-only view built from the records the desk writes
```
`tra watch` opens no connection, holds no key and takes no lock, so opening and closing it does
nothing to the desk.
# Why it is built this way
This software did not start from a trend. It started from what went wrong when language models
were given money, numbers and authority, as reported by the people who ran the tests, by
researchers and by regulators, and from what we found building it ourselves. Each design decision
here answers one of those problems. None of them is an argument against language models: they read
a market's context well and quickly, and that is what they are used for here. The problems come
from asking them to do what they are not reliable at, and from giving them authority nothing
checks.
Every figure on this page is the source's own, with the date it was reported. The sources are
listed at the end.
## Language models trading real money
**What happened.** In Alpha Arena Season 1, run by the research lab Nof1 in October 2025, six
frontier models were each given $10,000 to trade crypto perpetual futures on Hyperliquid on their
own. When it ended on November 3, 2025, four of the six had lost money: Claude Sonnet 4.5 finished
at $5,799, Gemini 2.5 Pro at $5,445, Grok at $4,208 and GPT-5 at $4,126. Qwen3 Max finished at
$12,231 and DeepSeek at $10,489.
**What the software does.** A model proposes a direction, a setup and the levels that bound the
idea. It does not size the trade, set the leverage, decide whether the trade is worth its cost or
send the order: code does, from limits the operator declared, and every gate that refuses records
why. Whether the model's side is any good is measured, not assumed: `tra hindsight` replays every
decision against its exact reverse. Desks start on a demo or testnet API, and real capital needs a
signed mandate and evidence on the ladder.
## Trading too much
**What happened.** In the Moscow Exchange's AI hackathon (May 13 to June 10, 2026), 50 teams ran
trading agents on 1 million virtual rubles each. The worst agent made almost 7,700 trades with a
turnover of 1.8 billion virtual rubles, and commissions ate a significant part of its capital. The
exchange's own conclusion: more trading did not mean more profit, and the better agents traded far
less, under risk management.
**What the software does.** The model is not asked unless the market has moved enough to be worth
asking (`[trigger]`). A trade is refused when its expected edge does not clear its full round trip
by the multiple the operator declared (`[targets]`), when the day's count of round trips is reached,
or when a cooldown since the last entry has not passed. The cost of every trade is priced before it
is sent, fees, spread, slippage and funding together.
## Wrong numbers in financial answers
**What happened.** Saturn, which builds AI software for financial advisers, tested 18
popular AI models on 121 financial advice questions in September 2026. On average the models were
wrong 57% of the time; on harder questions 88%, with some models wrong 99% of the time. Free models
were wrong in 63% of cases and paid ones in 49%. The failures reported include miscalculated pension
tax and rules for debt and student loans that do not exist. Earlier, in November 2023, Patronus AI's
FinanceBench found that GPT-4-Turbo used with a retrieval system answered 81% of its questions about
public companies' filings incorrectly or refused to answer them.
**What the software does.** No model calculates. Code pulls the data, computes every figure the
models read (returns, volatility, market structure, the regime, the cost of a trade) and records it.
A model answers in a closed schema and cites the figures it relied on by name and value; code checks
each citation against the brief and discards the ones that do not hold, and a deciding answer with
nothing left is refused. Sizes, stops, costs and limits are arithmetic in code, the same every time.
## Instructions hidden in data
**What happened.** In 2023 Greshake and colleagues showed that instructions planted in content a
model reads later, a web page, a document, an email, are followed as if the user had given them:
indirect prompt injection. In 2025 researchers from Princeton University and the Sentient
Foundation showed the same against crypto agents built on ElizaOS, through their memory: a message
planted in an agent's stored history could lead it to send funds to an attacker, and prompt-based
defences gave only limited protection once that stored context was corrupted. OWASP ranks prompt
injection first among the risks of LLM applications (LLM01:2025).
**What the software does.** The role that proposes a direction reads numbers computed by code, not
text from outside. Headlines and notes reach only the reading analysts, whose labels do not decide
anything, and every prompt says that text inside a brief is material to read, never an instruction
to follow. There is no memory a model writes for itself: what a role sees is built by code for each
call and recorded. And whatever a model says, it cannot send, cancel or change an order, or reach a
key.
## Agents with authority over money
**What happened.** On November 29, 2024, an AI agent called Freysa, set up as a game never to release
its prize pool, was talked into sending about $47,000 by a participant's crafted message (OECD AI
Incidents Monitor). On March 18, 2025, an attacker queued fraudulent prompts into the autonomous
crypto agent AIXBT through its dashboard, and the agent sent 55.5 ETH from its wallet (AI Incident
Database, incident 1003).
**What the software does.** No role holds a key or can move funds. Keys are read from the
environment by the one module that sends orders, are never in a prompt, a brief or a record, and any
message that reaches a record has them removed first. An order leaves only through that module,
after every gate, under a mandate a named person signed. The key guide asks for trading permissions
only, on every venue, and before a live tier session a Bybit key that can move funds is refused.
## Rules a model is asked to follow
**What happened.** A common answer to "why trust an agent" is "you trust the rules it is given, not
the model". The record shows models stepping past rules written into what they read.
- In 2023 researchers at Apollo Research put GPT-4 to work as an autonomous stock trading agent in a
simulated firm. Given an insider tip, it traded on it although it knew management disapproved of
insider trading, and when it reported to its manager it consistently hid the real reason.
- In December 2024 Apollo Research showed frontier models, in test scenarios built to tempt them,
introducing subtle mistakes and trying to disable the oversight they had been told about. The
authors present this as a capability shown under those conditions, not a measure of how often it
happens in use.
- In May 2025 Palisade Research reported that OpenAI's o3, told explicitly to allow itself to be
shut down, sabotaged the shutdown script in 7 of 100 runs.
- In July 2025 a Replit coding agent deleted a live production database during a code freeze it had
been told repeatedly to respect, and produced fabricated data afterwards (AI Incident Database,
incident 1152).
Models also state rules that do not exist. In November 2022 Air Canada's website chatbot told a
customer he could claim a bereavement fare after booking, which the airline's policy did not allow;
in February 2024 a British Columbia tribunal held the airline responsible for what its chatbot said
(Moffatt v. Air Canada, 2024 BCCRT 149). In April 2025 Cursor's AI support agent told users that one
device per subscription was a security policy; there was no such policy, and users cancelled before
the company corrected it.
**What the software does.** No limit here is a sentence a model is asked to respect. Every one is
code that runs after the model has answered, and no model is in that path.
- The rules are not in the deciding role's brief. The technical analyst is not told what a trade
costs, what it has to clear, the size or the limits, so there is nothing to argue with or around.
- An answer is a closed schema. Code reads its fields, a value outside its list is refused, and no
sentence a model writes is read to decide anything.
- The roles that can touch a limit can only make the desk more careful. The risk manager's limits
apply only where smaller, and a limit it names that does not exist is refused; between cycles it
can cut size below 1x, pause instruments or halt, each for a capped number of hours, and never
add size.
- No model can change the configuration, send an order or reach a key. An auditor's proposed change
has to name a setting the configuration accepts, and the loader refuses an unknown key, so a rule
a model made up cannot take effect even if someone copied it in.
- No role is told the desk's rules, every prompt says so, and an answer that states one anyway is
recorded beside it as made up. Run over the 832 answers our demo desks had recorded, that check
found none.
## Automation without pre-trade controls
**What happened.** On August 1, 2012, a router at Knight Capital running discontinued code sent more
than 4 million orders in 45 minutes trying to fill 212 retail orders, and the firm lost over $460
million. In 2013 the SEC fined Knight $12 million in the first case under its market access rule,
for not having adequate safeguards to limit the risks of its access to the markets.
**What the software does.** One decision sends one order, under a client order id of its own, so it
cannot be sent twice. Nothing is retried blindly. Every order is capped (`max_notional`), the desk
sends at most a declared number of orders a minute, no new entry is taken past a daily loss or a
drawdown limit, and the kill switch flattens and stops. Before an order goes out, leverage and margin
mode are read back from the venue, and an order is refused against a book older than 30 seconds.
## Who stays responsible
**What regulators say.** ESMA's public statement of May 30, 2024 on AI in investment services says a
firm's decisions remain the responsibility of its management body whether they are taken by people
or by AI tools. IOSCO's consultation report of March 2025 on AI in capital markets names
hallucination, explainability, and autonomy and control among the risks that may emerge.
**What the software does.** A named person signs the mandate before any order is sent. Every
decision is written down with the figures it rested on, what each gate decided and why, and can be
recomputed from the raw inputs. The auditors report and propose; only a person changes the
configuration.
## What we found building it
The same habit applied to our own runs on demo and testnet APIs found problems no specification
mentions:
| Found | What changed |
|---|---|
| Validators asked side by side voted their assignment: pro supported 63 times in 64, contra opposed 61 times in 63, and a neutral asked alone abstained in 33 of 37 cycles | pro and contra build their cases apart and neutral judges the checked cases ([the validators](https://docs.bijectivelabs.dev/opensource/cycle#the-validators)) |
| Reading analysts cited figures by names the brief did not use, 27 times in four days | a naming rule in the prompts and a check that resolves names; none discarded in the week of runs that followed |
| A demo API served an order book 3.2 hours old, 35 bps from where its own trades printed | orders are refused against a book older than 30 seconds |
| A library reported a fee the venue charged as a rebate | fees are read as the venue charged them |
| A venue's leverage call answered "success" while the setting stayed where it was | leverage and margin mode are confirmed by reading them back |
| The technical analyst could see the other analysts' conclusions | every analyst reads only its own data; code weighs the readings once |
| An auditor could propose a change to a setting that does not exist, as long as its table did | a proposed setting has to be one its table accepts |
The details are on [Venues](https://docs.bijectivelabs.dev/opensource/venues), [Agents](https://docs.bijectivelabs.dev/opensource/agents) and
[Working with language models](https://docs.bijectivelabs.dev/opensource/llm).
## Sources
- Alpha Arena Season 1 results: [ForkLog, "Four out of six AI models suffer losses in trading tournament"](https://forklog.com/en/four-out-of-six-ai-models-suffer-losses-in-trading-tournament/), quoting Nof1's founder; [South China Morning Post](https://www.scmp.com/tech/tech-trends/article/3329784/deepseek-outperforms-ai-rivals-real-money-real-market-crypto-showdown).
- Moscow Exchange AI hackathon: [report of the results](https://www1.ru/en/news/2026/06/30/mosbirza-dala-ii-million-robot-prokrutil-18-milliarda-i-proigral.amp.html), citing the exchange via Vedomosti.
- Saturn, "Artificial Authority": [the report](https://www.saturnos.com/report/artificial-authority); [Financial Reporter coverage](https://www.financialreporter.co.uk/ai-models-give-wrong-financial-advice-57-of-the-time-saturn-research-finds.html).
- FinanceBench: [Islam et al., "FinanceBench", arXiv:2311.11944](https://arxiv.org/abs/2311.11944v1); [Patronus AI announcement](https://patronus.ai/announcements/patronus-ai-launches-financebench-the-industrys-first-benchmark-for-llm-performance-on-financial-questions).
- Indirect prompt injection: [Greshake et al., arXiv:2302.12173](https://arxiv.org/abs/2302.12173v2), AISec 2023.
- Memory injection in Web3 agents: [Patlan et al., "Real AI Agents with Fake Memories", arXiv:2503.16248](https://arxiv.org/abs/2503.16248).
- [OWASP Top 10 for LLM Applications 2025](https://genai.owasp.org/llm-top-10/).
- Freysa: [OECD AI Incidents Monitor](https://oecd.ai/en/incidents/2024-11-29-2e31).
- AIXBT: [AI Incident Database, incident 1003](https://incidentdatabase.ai/cite/1003).
- Insider trading by a trading agent: [Scheurer et al., "Large Language Models can Strategically Deceive their Users when Put Under Pressure", arXiv:2311.07590](https://arxiv.org/abs/2311.07590).
- Oversight subversion: [Meinke et al., "Frontier Models are Capable of In-context Scheming", arXiv:2412.04984](https://arxiv.org/abs/2412.04984).
- Shutdown sabotage: [The Register, May 29, 2025](https://www.theregister.com/2025/05/29/openai_model_modifies_shutdown_script/), reporting Palisade Research; [Palisade Research, "Shutdown resistance in reasoning models"](https://palisaderesearch.org/research/shutdown-resistance).
- Replit: [AI Incident Database, incident 1152](https://incidentdatabase.ai/cite/1152); [Fortune, July 23, 2025](https://fortune.com/2025/07/23/ai-coding-tool-replit-wiped-database-called-it-a-catastrophic-failure/).
- Air Canada: Moffatt v. Air Canada, 2024 BCCRT 149; [AI Incident Database, incident 639](https://incidentdatabase.ai/cite/639).
- Cursor support agent: [AI Incident Database, incident 1039](https://incidentdatabase.ai/cite/1039).
- Knight Capital: [SEC press release 2013-222](https://www.sec.gov/news/press-release/2013-222).
- [ESMA public statement on AI and investment services, May 30, 2024](https://www.esma.europa.eu/sites/default/files/2024-05/ESMA35-335435667-5924__Public_Statement_on_AI_and_investment_services.pdf).
- [IOSCO consultation report on AI in capital markets, March 2025](https://iosco.org/news/pdf/IOSCONEWS761.pdf).
# The cycle
One cycle is one pass from what the market is doing to a decision that is either an intent or a
refusal with a reason. It is the unit this project works in, and the unit an auditor replays.
```bash
tra init my-desk --profile autonomous # the cycle laid down wired, every line commented
tra run # data.toml, strategy.toml, envelope.toml and .env from the project
tra run --instrument ETHUSDT --margin 5 --mode signals
tra run --every 15m --cycles 12 # one cycle every fifteen minutes, on the boundary
```
## Running it on a schedule
`--every` runs a cycle on the interval's own boundary, so a cycle reads a bar that has finished
happening rather than one that is half built. One process, no cron, no daemon: the loop is in the
same command, and everything that should end it ends it by name.
The first pass runs as soon as the desk starts. After that, passes land on the clock: with
`--every 30m` they fall at :00 and :30 UTC, with `--every 15m` at :00, :15, :30 and :45. So the wait
after a pass is whatever is left to the next boundary, not a full interval, and a pass that takes
several minutes shortens it further. The desk says it plainly after every pass, for example
`next pass at 08:30:00 UTC, in 20m 00s`.
| Ends the loop | Why |
|---|---|
| the budget | a cycle that stopped because the token budget is spent will not be followed by one that costs more |
| the kill switch | the file named in `[controls].kill_switch` exists |
| `--cycles N` | N scheduled passes, whether or not the trigger let a cycle through |
| a keyboard interrupt | the operator stopped it; the cycle that was running is already on disk |
| SIGTERM, SIGHUP, SIGBREAK | a service manager stopping the desk, or an SSH session dropping; handled exactly like Ctrl-C |
Some lines end the day's entries without ending the run. While one of them holds, each pass still
looks after open positions (time exits, venue exits, the kill switch) and asks no model:
| Holds new entries | Until | Set with |
|---|---|---|
| the pause switch | the file is removed | `tra pause` (writes `PAUSE`), `tra pause --off`; or `[controls].pause_switch` names another file |
| the daily token budget | 00:00 UTC | `[agents].daily_token_budget`: tokens recorded today by every role and both auditors, across runs and restarts |
| the daily cost budget | 00:00 UTC | `[agents].daily_cost_budget`: the same in model cost, from `[agents.pricing]` |
| trades per day | 00:00 UTC | `[controls].max_trades_per_day`: orders written today across every instrument |
| a losing streak | 00:00 UTC | `[controls].max_losing_streak`: losing round trips in a row today; a winner resets it |
| the daily loss limit | 00:00 UTC | `[controls].daily_loss_limit_bps`, checked inside each cycle |
| the drawdown limit | the peak file is cleared | `[controls].max_drawdown_bps`, with `on_drawdown = "flatten"` to flatten and stop as well |
`tra kill` writes the kill switch file (`[controls].kill_switch`), so the next pass flattens and
stops; `tra kill --off` removes it. `tra check` prints every line in force on one row. Each count
is read from the records on disk, so restarting a desk does not reset a day.
A refusal is not a reason to stop. A cycle that refused because a gate said no is a cycle that did
its job, and the next one decides again with fresh figures. The frequency cap in `[targets]` still
applies across the whole day, so an interval shorter than the desk's own limit simply produces more
refusals, each one named.
Every start and stop of `tra run` is one line in `runs/cycles/scheduler.jsonl`: when it started,
what it watched, in which mode, and what stopped it after how many cycles. Each cycle already keeps
its own directory; this file is what says when the desk was running at all, which matters on a
server whose terminal output nobody kept.
```json
{"at": "2026-10-04T06:56:38+00:00", "event": "started", "every_s": 300.0, "mode": "venue", ...}
{"at": "2026-10-04T09:12:04+00:00", "event": "stopped", "stopped_by": "SIGTERM from outside the process", "cycles": 27}
```
**Stopping while an order rests on the book.** A maker entry waits up to
`[execution].limit_timeout_s` for a fill. If the process is told to stop during that wait, the
order is cancelled first, `placed.json` and `cycle.json` say so (`"interrupted": true`, and whether
the cancel went through), and only then does the run end. Stopping between cycles exits with the last
cycle's outcome; stopping inside one always exits as a refusal, so a supervisor can tell the two apart.
One case nothing in a process can cover: closing a Windows console window. Windows gives the
process a few seconds and ends it whatever it is doing. What protects capital then is what was
already at the venue - the stop and take profit travel with the entry, and the next cycle refuses
while any order still works on the instrument. A desk that has to survive its terminal belongs under
a supervisor, not in a window.
`tra init my-desk --profile autonomous` is the short way in. It writes the same project as
`tra init`, with these differences: the technical analyst is the core, deciding on 15-minute bars
with the hour and four hours as context and naming its own stop and target; the confluence matrix,
the conviction ladder and the targets are live tables rather than commented ones;
`[execution].mode` is `sandbox`; and the project's README opens with what is still yours before a
cycle can decide anything - a model and its key, and the mechanism the analyst reasons from. Add
`--agent PROVIDER --model MODEL` and the model is named for you. `tra check` names whatever is still
missing, including the provider placeholder, and reads the three files against each other: the
instrument as the sources know it, a venue supplying data that is not the venue whose fees are
priced, and an `[execution].venue` that does not match `[run].venue_key` - that last one is a
refusal rather than a note.
The gates it writes ask for nothing until you give them something: `[confluence].required` is empty,
because a role nobody asked has agreed to nothing, and `[conviction].weights` names only the
deciding analyst, so the conviction score is that analyst's own confidence. Fill in a file under
`sources/` and its analyst joins the roster; give that analyst a weight and its agreement starts
changing the size; name it in `required` and its disagreement starts refusing the trade. Three steps,
in that order, each one visible in `tra check` before it costs a token.
```
the universe (system: one data pull each, the trigger per instrument, ordered by movement over cost)
-> the instrument this pass takes
-> snapshot (engine)
-> trigger (system: has anything moved? nothing below here runs if not)
-> briefs (system, each role's own sections only)
-> technical analyst + reading analysts (parallel)
-> the round trip priced (system)
-> the stop and the target (the analyst's levels, held in your [exit] bounds; break-even checked)
-> validators (when configured: pro and contra apart, then neutral judges both)
-> confluence matrix (system: declared states and votes) -> whether to trade at all
-> conviction (system: the same voices, weighted) -> how much the desk wants on
-> sizing (system, when [sizing] is declared: Kelly from the desk's own round trips,
cut for the drawdown ahead) -> how much the record supports
-> risk per trade (system: the loss at the stop, round trip included, against equity)
-> how much it can afford
-> the desk (system: what the whole book is already carrying) -> whether it fits
-> cost gate (system: the claim against the cost)
-> targets (system: net bps, the edge over the cost, reward-to-risk, research, count)
-> intent, or a refusal that names the figure that refused it
```
Nothing in that line is optional and nothing in it is reordered. The engine fetches; the system
computes; models read figures and answer inside a schema; the system prices the round trip and decides
whether the claim clears it. In the default mode the intent is written to the cycle's own
directory and no order leaves the machine.
## Which instrument, before any of this
A desk that watches one instrument runs it. A desk that declares a universe takes the cheap look
first, and only then spends a cycle. The work is split on purpose: **the system finds and measures, the
screener chooses.**
```toml
[universe]
instruments = ["BTCUSDT"] # always looked at, whatever discovery finds
discover = true # add the venue's most traded perpetuals each pass
top = 20 # how many discovery adds
min_quote_volume = 10000000 # 24-hour volume in quote currency
max_spread_bps = 5 # the book now, from the venue's ticker
min_listed_days = 30 # nothing listed this month
crypto_only = true # leave out stocks, ETFs, commodities and currencies
exclude = [] # instruments never to take
quote = "USDT" # the settlement currency to look in
# type_field = "symbolType" # the venue's own market field that says what a market is,
# exclude_types = ["stock", "etf", "commodity", "forex"] # and the values to leave out
max_per_cycle = 1 # how many one pass runs a full cycle on
max_intents_per_day = 6 # across every instrument, not per one
screener = true # the screener chooses and orders; without it, the system's order
```
**Discovery (system, every pass).** One market list and one ticker call cover every linear perpetual
the venue lists in `quote` (USDT by default). The system removes what cannot be traded: too little
volume, a book wider than `max_spread_bps`, listed too recently, not crypto, or a maximum leverage
below `[execution].leverage`. Bybit marks its stock, ETF, commodity and currency perpetuals, and
that is the default there; for any other venue, `type_field` and `exclude_types` name the venue's
own field and the values to leave out, and `exclude` takes out single instruments. Every one of
these is yours to set: the defaults are a starting point, not the screen.
The most traded `top` join the declared instruments. Each discovered instrument carries the venue's
own symbol and size step, so an order is never sent under the wrong market or off the step.
Discovery needs `[run].size_quote` and `[controls].max_position_quote`: one base size, or one cap
counted in units, across coins priced from cents to tens of thousands is no size and no cap at all.
With a quote cap declared, every instrument is capped at that much quote currency at its own mid,
and `[controls].max_position` (units) still applies to `[run].instrument`, the one it was written
for. `tra universe` shows what discovery finds right now and what each
filter took out.
**Measurement (system).** Each instrument gets one data pull, the book and the recent bars, no tokens.
The same `[trigger]` and desk limits apply to each. What survives is measured: the move since the
last look and its pressure against the spread, the window's move, z-score, trend strength and
regime, volume against the window, where the close sits in its range, 24-hour volume and change,
and the correlation with BTC.
**Choice (the screener).** With `screener = true` the market screener reads those rows and picks the
instruments where the mechanism has the best chance now, best first, naming the setup it expects
and citing the figures behind each pick. The system checks every pick: it must be an instrument the system
admitted, and at least one citation from that instrument's own row must hold, or the pick is
thrown away. The screener cannot add an instrument, and it says nothing about direction or size;
the technical analyst decides those later from a fuller brief. A screener that cannot answer leaves
the system's order, the move against the spread, in place.
Both orders are kept in `runs/screens/.json` (`code_order` and each candidate's `pick_rank`),
so once trades have closed the audit can say whether the screener's choices did better than the system's.
Each instrument keeps its own history: its own trigger streak, its own carried research spend, its
own daily round trips. A position on an instrument discovery stops listing is still counted by the
desk and looked after by the keeper until it closes. `[universe].max_intents_per_day` is the one
figure that counts the whole desk.
## Before anyone is asked: the trigger
A five minute schedule is two hundred and eighty-eight passes a day, and a fifteen minute one ninety-six. Most of them should refuse, and
refusing is the right answer - but the tokens were spent finding that out, and a desk that spends
more on research than it can win on a trade is not a desk. The answer is not to abandon the
schedule. It is to put a check in front of it that costs nothing.
```toml
[trigger]
min_move_bps = 15 # the mid has to have moved this far since the last cycle
max_spread_bps = 8 # a book wider than this cannot be traded through
cooldown_s = 900 # one decision per instrument in this window
widen_after = 6 # after this many straight refusals, ask for more movement
look_valid_for = "1h" # a last look older than this counts as a first look; 1h by default
```
Every figure it reads is free: the touch of the snapshot the cycle has already taken, the cycle
summaries on disk, the clock, and the count of intents already written today. A cycle that holds
costs one data pull and no tokens, and the hold is written down with its arithmetic - what the mid
moved, what the desk asked for, and why.
"Since the last look" is measured against the latest mid the desk recorded for that
instrument, whether in a cycle or in a pass over the universe. A pass looks at every instrument
but runs a cycle on a few, so the passes are the record that matters. A last look older than
`look_valid_for` is dropped, and the instrument is a first look: it has no move to report, and the
trigger admits it instead of measuring hours of drift against it.
`widen_after` is the part that answers a whole quiet day. After a run of refusals with nothing
changing, the movement required grows with the streak, to a hard ceiling of eight times the
declared minimum so a desk can never be talked into going blind. One real move brings it straight
back to the baseline. A cycle the trigger itself held does not count towards the streak: that is
the desk deciding not to look, not the desk looking and finding nothing.
`tra run --now` asks the roster whatever the trigger would have said. The override is written into
the cycle, so a reader can tell a forced cycle from a natural one.
## What a cycle leaves behind
```
runs/cycles/2026-09-27T14-51-23Z-BTCUSDT/
snapshot.json every value the engine fetched, with source, tier and age
inputs.json.gz the raw inputs: candles on every timeframe, the book levels, funding,
open interest, and the settings the briefs were computed with
briefs/technical.json the figures the analyst was given, computed here, not by the model
briefs/cost.json what a round trip would cost, per component
briefs/validator_pro.json the proposal and the figures a validator was given
answers/technical.json what the model answered, as it answered it
intents.jsonl the intent, if the gate admitted one
placed.json what the venue did with it, in venue mode
cycle.json the whole cycle in one file, with a sha256 of every file above
runs/cycles/index/2026-09-27.jsonl one line per cycle that day, with the hash of its cycle.json
```
A cycle that refuses writes the same files minus the intent. That is the point: a refusal is a
result, and it is as readable as a trade.
### Keeping the record honest
Everything is files: JSON, JSON lines, gzip and Parquet. There is no database and no cache server
to run, and every record can be read with a text editor.
| Concern | What the desk does |
|---|---|
| a crash in the middle of a write | appends end with a newline guard, so a torn line costs only itself; order, position and equity records wait for the disk; whole files are written beside and renamed into place |
| a brief nobody can check | `inputs.json.gz` keeps what the brief was computed from; `tra runs verify` computes the technical figures again and compares them |
| a record changed after the fact | `cycle.json` holds a sha256 of every file in the cycle, and the day's index line holds the hash of `cycle.json`; `tra runs verify` checks both |
| a desk that has run for months | the trigger, the targets, the desk limits and the position keeper read the day index, not every directory, so a cycle costs the same on day 300 as on day 1 |
| disk use | `tra runs prune --keep 90d` says what it would remove; `--yes` removes it, `--archive` zips each month first; the index and the logs are kept, so the desk's memory of its own past does not change |
A desk that ran before the index existed builds it from its cycle directories on its first cycle.
A cycle that stopped before writing its index line, a crash or an interrupt at the venue, is still
read from its directory, so an order it sent is never missing from what the desk knows.
## Briefs: the model does no arithmetic
The technical brief carries returns over several horizons, the close as a z-score of its window,
the distance from VWAP, the volume and range ratios, where the close sits in the window's range,
the current spread, the funding rate that stands, and the age of the candles it was built from.
The model receives those numbers and answers with a target, a confidence, a horizon and a
reason. It never adds anything up, so a wrong sum is a bug in the system with a test, not a sentence in
a prompt.
### The market around the decision
A desk decides on one timeframe and reads the bigger picture before it does. The brief carries
that picture too, all of it computed by the system from the snapshot:
| Figure | What it says | Where it comes from |
|---|---|---|
| `regime` | trending up, trending down, ranging or mixed, on the decision timeframe | `trend_strength` against the lines in `[regime]` |
| `trend_strength` | net move over the path walked, times the square root of the steps; a random walk sits near 1 on any window | the window's closes |
| `market_structure` | the trend the swings make, the last break of structure or change of character, the level whose loss changes the trend, and the nearest unbroken support and resistance; see [Market structure](#market-structure) | the window's bars |
| `variance_ratio` | the variance of four-bar returns against four times that of one-bar returns: below 1 the window's moves were given back, above 1 they were followed by more of the same, near 1 is a random walk | the window's closes |
| `window_move_bps` | the price move from the first close of the window to the last; a market move, not the trade's net | the window's closes |
| `timeframes.` | the same regime, net move, z-score and position in range on each higher timeframe, with its structure and its last structure event | `[run].context_intervals`, one candle pull each |
| `book_imbalance` | resting bids against resting asks over the first 10 levels, from -1 to 1; positive leans to the bid | the order book already in the snapshot |
| `open_interest` | the change in percent since the previous snapshot of this instrument, and how long ago that was | the open interest already pulled, and the last snapshot on disk |
Nothing here is required. A higher timeframe the venue cannot serve is named in the refusals and
left out, and so is an open interest with nothing earlier to compare it to.
```toml
[run]
bar_interval_s = 900 # what `tra init --agent` writes: 15m decisions
context_intervals = ["1h", "4h"] # up to three, each a whole multiple of bar_interval_s
[regime]
trend_above = 2.0 # trend_strength at or above this is a trend
range_below = 1.0 # at or below this is a range; between the two is mixed
setups_must_fit = true # false: a misfit is written down and the trade goes on
fade_below_variance_ratio = 1.0 # a range fade needs the window's variance ratio below this; 0 is off
```
### Market structure
The system reads structure from the swings before any model is asked, and the analyst is given what
it read. A swing high is a bar whose high stands above the two bars on each side of it; a swing low
the same, below. Swings alternate: two highs with no low between them are one swing, the higher. Each
swing is labelled against the last one of its kind.
| Last high | Last low | `trend` |
|---|---|---|
| HH | HL | uptrend |
| LH | LL | downtrend |
| LH | HL | range, contracting |
| HH | LL | range, expanding |
| fewer than two of either | | unclear |
What the closes did after the last two swings is the `event`. Only a close counts; a wick through a
level and back is not a break.
| `event` | When |
|---|---|
| `bullish break of structure` | in an uptrend, a close above the last swing high: the trend went on |
| `bearish break of structure` | in a downtrend, a close below the last swing low |
| `bearish change of character` | in an uptrend, a close below the last swing low: the higher low the trend leaned on gave way |
| `bullish change of character` | in a downtrend, a close above the last swing high |
| `break above the range`, `break below the range` | in a range, a close outside its last high or low |
`invalidation` is the swing whose loss changes the trend: the last higher low in an uptrend, the last
lower high in a downtrend. `support` and `resistance` are the nearest swing low below the close and
swing high above it that no later close went through, each with its distance in bps. The analyst is
told to put a stop beyond the level its idea leans on and a target at the next one, from these
figures.
The same trend and event are read on every timeframe in `[run].context_intervals`, so a 15m long
in a 4h downtrend is visible as exactly that. `tra run` prints the structure beside the regime.
**A setup has to fit what the system measured.** Each setup is a claim with a direction built into it,
and the figures that make the claim are in the brief. On the decision timeframe, the system refuses:
| Setup | Refused when |
|---|---|
| `trend_continuation`, `pullback_in_trend` | the market is not measured as trending (ranging or mixed), the trade points against the trend, or it points against the market structure (a long in a downtrend, a short in an uptrend) when the structure has not just changed character |
| `range_reversion` | the market is not measured as a range (trending, or mixed with a measured trend strength), the window's moves have been carrying on (variance ratio at or above `fade_below_variance_ratio`), or the trade does not fade the stretch: a long with the close at or above the window's mean (z-score 0 or more), a short at or below it |
| `momentum_exhaustion` | the trade goes with the move it says is exhausted: with the trend, or with the window's move |
| `range_breakout` | the trade is on the other side from where the close sits: a long with the close in the lower half of the range, a short in the upper half |
| `volatility_expansion` | never on direction: it is about a regime changing, not which way |
The rule came from a live cycle on 2026-10-08: a `pullback_in_trend` short in a market measured as
mixed and rising over the window, which the earlier rule let through because it only checked
trends it could measure. A short setup against a higher timeframe's trend is still left to
the analyst to weigh, because fading the hourly on the minute chart is sometimes the whole trade;
the reason has to say so. With `setups_must_fit = false` a misfit is written down and the trade
goes on. `tra check` prints the lines and the timeframes in use.
Why the variance ratio, and not trend strength alone: a window can net out to nothing, and read
as a range, while each move inside it is followed by more of the same. A fade is paid only where
moves are given back, so that is what it is held to. The line sits at 1, the random walk, rather
than at a value fitted to any desk's trades.
The fade has been held to the same standard as the trend setups since 0.6.4. Before that,
`range_reversion` was refused only in a trend, so in a mixed market it was the one regime-bound
setup still allowed.
**The analyst picks the setup; the mechanism does not.** In `tra run` the technical analyst reads
the regime and the higher timeframes first and names the setup the market supports. A mixed market
takes `range_breakout`, `momentum_exhaustion`, `volatility_expansion` or flat.
`[agents].mechanism` is your thesis: the setups you favour when the market offers them. It never
turns a trending market into a fade, and the screener picks where *a* setup has the best chance,
not one mechanism. `tra init --agent` writes a mechanism that names no setup ("trade the setup the
measured regime supports: with a trend, inside a range, or flat"). Write a narrower one only if you
trade a single idea. The research core in `tra backtest` and `tra agent session` is different: it
trades one registered mechanism, by design.
The macro analyst's regime is a different thing: risk-on or risk-off is about the world outside the
chart, and it can disagree with what price is doing. Both are kept, apart: the technical analyst
does not see the macro state, and conviction weighs the macro analyst's graded score against the
technical reading after both have answered.
## Who is asked, and what each one reads
| Agent | Reads | Answers | Asked when |
|---|---|---|---|
| `technical_analyst` | the closed candles of the snapshot, the spread, the funding rate, the regime on each timeframe, the book imbalance, the open interest change; nothing from the other analysts | a target, a named setup, a confidence, a horizon, a reason, and the figures it read | every cycle; it is the role that decides |
| `macro_analyst` | macro series and the dated text of `news.toml` | a regime, the events it saw, a reason | when `sources/macro.toml` or `sources/news.toml` declares something, or `--macro` / `--news` names a file |
| `onchain_analyst` | chain figures and derivative figures, read side by side | accumulation, distribution or neutral, and why | when `sources/onchain.toml` or `sources/derivatives.toml` declares something, or `--onchain` / `--derivatives` |
| `sentiment_analyst` | dated scores, plus the same dated text as notes | negative, neutral, positive, or not declared | when `sources/sentiment.toml` declares something, or `--sentiment` |
### The shelf
A desk does not have one data source. It has a shelf of them, and the shelf changes when a
subscription starts or a free endpoint goes away. So the declarations are one file per class of
data, never one file per vendor:
```
my-run/
strategy.toml
data.toml live values for the cycle: candles, book, funding, open interest
sources/
macro.toml dated levels and rates
news.toml dated text: releases, statements, the desk's own notes
sentiment.toml dated scores: a fear index, a positioning score
onchain.toml dated chain figures: venue flows, supply held, active addresses
derivatives.toml dated funding, open interest, basis
```
A vendor is a line inside one of those files, so replacing one with another is an edit in one place
and nothing else in the project moves. `tra init` writes all five as forms with every entry
commented out, and a form declares nothing: an untouched project asks only the technical analyst
and pays for nothing else. Fill one in and its analyst joins the roster on the next run. Two
classes can feed one role, which is why the on-chain analyst reads chain and derivative figures
together.
`tra check` reads the shelf without reading the data: it names what each file declares, names an
export that is declared but not on disk, and prints the roster the next `tra run` will ask. The
older layout (`series.toml`, `onchain.toml`, `sentiment.toml` beside the config) is still read, so
a project written before the shelf existed keeps working.
Each file goes through the same series engine as `tra agent session`: `publish_lag` decides when a
figure became knowable, and a free CSV export and a paid vendor's dump are read the same way. The
live numbers a cycle decides on are a different thing, and they come from `data.toml` with their
age attached. Keeping them apart is what stops a daily file from standing in for a number that has
to be fresh.
A file can keep itself current: a `fetch` line refreshes it from FRED, the Crypto Fear & Greed
Index or any vendor that answers JSON over https, and `tra run` does that before each pass once the
series' `refresh` interval has passed. Every series also has a `max_age`; a value older than that is
shown as `stale` and never counted. [Sources](https://docs.bijectivelabs.dev/opensource/sources) covers both.
The three reading analysts go out together with the technical analyst, so the cycle waits for the
slowest of them and not for the sum of all four. Each one reads through the same series engine as
`tra agent session`, with the same as-of rule: a value is never used before the moment it was
published, and the operator's `publish_lag` is what decides that moment.
Two habits are enforced rather than encouraged. Nothing is scraped: an analyst reads the files its
operator declared, and there is no code path from an agent to the internet; the engine refreshes a
fetched file, never the model. And a role with no
source says so; the sentiment analyst answers `not declared` rather than `neutral`, because those
are different facts and an operator has to be able to tell them apart. A role whose files are all
unreadable is not asked at all, and the cycle records which files it could not read.
A role can run on its own model. `[agents.roles.]` overrides the provider, the model, the
output cap, the timeout and the retries for that role alone, and anything it does not override it
inherits. That is how a cheap model labels a regime while a stronger one reasons over the bars.
A reading analyst whose model fails leaves a line in `cycle.json` and the cycle carries on. Its
declared state is arithmetic over your thresholds, so it still counts in the confluence matrix and in
conviction; only the model's label is lost.
## Where a direction came from
Two fields make the deciding analyst's answer reviewable a month later, and both are checked by
the system before anything downstream sees them.
**The setup.** One name from a closed list - `trend_continuation`, `pullback_in_trend`,
`range_reversion`, `range_breakout`, `momentum_exhaustion`, `volatility_expansion`, `no_setup`. A
direction with `no_setup` is refused: a desk cannot review, count or compare a trade whose shape
nobody named, and a paragraph is not a shape. The name travels into the intent, so "which setups
have paid for themselves this month" is a question the records can answer.
**The figures.** Every role that says something about numbers answers with `figures_used` - each
figure by the brief's own name, with the value it read - and the system checks each one against the brief
it was handed. A figure that is not there, or a value that does not match, is discarded and the
discard is recorded. For the reading analysts that is a mark on the record; the label was never what
decides. For the analyst that does decide it has teeth: if not one citation survives, the cycle
refuses, because a direction that cannot point at a figure in its own brief came from somewhere
this cycle cannot see.
The check is fair about names. A brief carries a row per series, so one number's full name is
`series.us10y.correlation_measured.value`, and no model writes that when it means "the correlation".
A citation resolves when its words are the tail of exactly one figure's name; where several share a
tail, the shallowest wins; and a block measured with its own sample size resolves to the number in
it. Nothing about that loosens the rule - whichever figure a citation lands on, the value still has
to match, so a citation that resolved to the wrong figure is discarded anyway.
## Every role inside its own job
A desk works because a job has edges. The analyst that labels a regime does not size the order, the
role that prices the round trip does not decide whether the idea is any good, and none of them
places anything. Four things keep a role inside its job, and only the last is words:
| Enforced by | What it prevents |
|---|---|
| the brief | A role cannot reason about what it was never given. No reading analyst receives equity, size, the controls, the cost of the round trip or the exit distances. `agents/scope.py` filters every brief to that role's declared reads, and what it removed is written in the record. |
| the schema | A closed schema with only that role's own fields. A macro analyst has nowhere to put a size, and a field outside the schema is a parse error rather than a surprise. |
| the wiring | Nothing downstream reads a sentence. The confluence matrix and the conviction score read the declared state the system derived from the operator's thresholds; the cost gate reads the snapshot's own book; the size comes from the operator's ladder and caps. |
| the prompt | One line naming what is not this role's job, plus a small vocabulary the system looks for in case it appears anyway. |
The last line needs care and gets the least authority. Pattern-matching prose is blunt, and "buy"
and "sell" belong in a proper sentence about what a central bank did, so the vocabulary is only the
phrases no reading analyst has reason to write - "go long", "stop loss", "position size",
"leverage". A hit is recorded as a crossing beside the answer and changes nothing, because the
answer was never what decides. We cannot stop a model from writing a sentence; we can make the
sentence carry no authority and leave a mark when it tries to.
The same check looks for a rule a role made up. No role is told the desk's rules, so an answer that
says "the desk requires" or names a desk minimum is stating one that does not exist. That is
recorded for every role, the technical analyst included, and changes nothing: the rules that apply
are the ones in your configuration, run by code after the answer.
## The confluence matrix
Deterministic, in the configuration, reading the declared state rather than the model's label:
```toml
[confluence]
required = ["macro_analyst", "onchain_analyst"]
long_states = ["bullish"]
short_states = ["bearish"]
on_missing = "refuse" # refuse | ignore
min_support = 1 # validators that must support it
max_opposition = 0 # validators that may oppose it; 0 is the strictest there is
```
It fails closed. A required role whose declaration is `undeclared` or `mixed` has produced no state,
and a state nobody produced is not a neutral state: the cycle refuses and names the role. An
operator who means to run on one leg writes `on_missing = "ignore"`, and the record says they did.
`tra check` refuses a matrix that can never pass - a required role the roster does not ask, or more
support required than there are validators to give it.
## The validators
Three stances of one role, asked after the deciding analyst answers and after the round trip is
priced:
```toml
[agents]
validators = ["pro", "contra", "neutral"]
```
With all three, they work as a judged case rather than a vote:
```
proposal -> pro (the strongest case that the reading is right) \ apart, at the same time,
-> contra (the strongest case that it is wrong or noise) / neither sees the other
-> code checks every claim in both cases against the figures
-> neutral (judges the two checked cases) -> the one verdict that counts
```
Pro looks for what makes the setup real: the regime and structure it belongs in, higher timeframes
agreeing, a move beyond one bar's ordinary range, levels resting on structure. Contra looks for what
makes it wrong, early or noise: a regime or structure it does not fit, a higher timeframe against
it, a move inside the noise, a stop inside that noise, a target behind the next level, a book
leaning the other way. Neither is told what the other found.
Neutral then reads both cases, but only the claims that survived the check, so an invented figure
never reaches the judge, and not the advocates' own verdicts, so it weighs evidence rather than
votes. It is told to weigh claims about the premise of the setup above claims about details, to
treat several claims that say the same thing as one, and to abstain only when the two cases are
even. Its verdict is the one that counts in confluence and conviction.
When there is nothing to weigh, code decides without paying for the call: no surviving claim on
either side is an abstention, a contra with nothing that holds is support, a pro with nothing that
holds is opposition, and the record says neutral was not asked.
Every stance receives the proposal and the figures it was built from: not the round trip, not the
claimed edge, and not the other analysts' readings, which code weighs once in conviction. Each
answers with claims that cite a figure by the brief's own name, with that figure's value:
```json
{"stance": "contra",
"claims": [{"figure": "atr_bps", "value": "61.32", "says": "the last move is inside one bar's range"}],
"verdict": "oppose",
"reason": "..."}
```
A figure that is not in the brief is discarded, a value that does not match is discarded, both are
recorded, and a verdict with no surviving claim becomes `abstain`. A claim may round the figure it
cites: "-1.63" against a brief's "-1.631" is the same citation said with fewer digits, and it is kept.
Why it is built this way: when the three were asked side by side and each counted, pro supported 63
times in 64 and contra opposed 61 times in 63 on the first demo desk, so their votes carried their
assignment and nothing else, and a neutral asked alone on the OKX demo desk abstained in 33 of 37
cycles. An advocate's value is the evidence it finds; the decision belongs to the role that sees
both sides.
Each stance is one more call, which is why they are off until an operator asks for them; with all
three, a cycle makes two calls at once and then at most one more.
What counts, and where, is yours to declare:
| Where | Setting | Effect |
|---|---|---|
| the confluence matrix | `[confluence].min_support`, `max_opposition` | a hard gate: too few supports or too many opposes refuses the trade |
| conviction | `[conviction].validator_weight` | each counted vote that says something adds or takes away that weight; the size follows the score |
With neither declared, the verdicts are recorded and move nothing, and `tra check` says so.
Other sets of stances still work. Neutral alone weighs the proposal itself. Pro or contra without
neutral are counted for what they say beyond their stance: a pro that supports or a contra that
opposes says nothing new and weighs nothing in conviction, while a pro that abstains or opposes
counts as opposition and a contra that abstains or supports counts as support.
## Conviction: who weighs the analysts, and how much goes behind the idea
The matrix answers whether the context agrees with the direction. `[conviction]` answers the
question a desk actually has next: how much. Analysts are allowed to disagree - crypto doesn't move
in straight lines, and a macro reading against a technical setup is ordinary. What matters is how
strong each side is, so every voice counts as strong as it actually is.
```toml
[conviction]
weights = { technical_analyst = 2.0, onchain_analyst = 1.5, macro_analyst = 1.0, sentiment_analyst = 0.5 }
validator_weight = 1.0
min_score = 0.4 # below this the desk does not trade
max_leverage = 3.0 # notional against [run].equity; a ceiling, never a target
[[conviction.ladder]]
at_least = 0.75 # the case is strong and the context backs it: the whole base size
size_multiple = 1.0
[[conviction.ladder]]
at_least = 0.55
size_multiple = 0.5
[[conviction.ladder]]
at_least = 0.4
size_multiple = 0.25
```
### Each analyst's score
Each reading analyst's score is computed by the system from its own figures against the lines you drew in
`sources/*.toml`, signed for the instrument and between -1 and 1:
```toml
threshold = { bullish_below = 4.05, bearish_above = 4.25 } # US10Y: low yields are good for crypto
```
The two lines make a neutral band 0.20 wide. A yield of 4.02 is 0.03 past the bullish line, which
is 0.15 of a band: a score of +0.15, barely bullish. A yield of 3.80 is more than a full band past
it: +1.0. Anything inside the band is 0. With several series, the analyst's score is their average.
A threshold with only one line declared has no band to measure against, so it counts as a full +1
or -1 whenever it is crossed, and `tra check` says so.
The model still reads the same figures and writes its own label and reason. Both go in the record,
and when they disagree the record says so. The score the system computed is what gets weighed, because it
comes out the same every time from the same figures.
### Adding them up
| Voice | Brings | Counts toward the total by |
|---|---|---|
| the deciding analyst | `weight * confidence` | `weight` |
| a reading analyst | `weight * score`, plus when it agrees with the direction, minus when against | `weight * abs(score)` |
| a reading analyst with nothing past its lines | `0` | nothing; it is listed as silent |
| a validator that supports, after its claims were checked | `+validator_weight` | `validator_weight` |
| a validator that opposes | `-validator_weight` | `validator_weight` |
`score = max(0, what they bring) / what they count toward`, so it sits between 0 and 1. With nobody
else speaking it is the deciding analyst's own confidence. Agreement lifts it, opposition lowers it
in proportion to how strong the opposition is, and only opposition that outweighs the case takes it
to zero. Silence neither lifts nor dilutes it: a reading barely past its line moves the score
barely, and silence is the limit of that.
The first rung of the ladder the score reaches gives the size multiple, and then every cap that
already existed still applies: `[controls].max_position`, the notional ceiling implied by
`max_leverage` against `[run].equity`, the risk per trade, the desk limit, and the venue's step,
rounding down and never up.
### Worked through, with the weights above
| What the desk sees | Score | Size |
|---|---|---|
| a short at 0.70, macro barely bullish (+0.15), sentiment barely bullish (+0.057) | 1.222 / 2.178 = **0.561** | half the base |
| a long at 0.70, macro +0.6, on-chain +0.5, sentiment -0.4 | 2.55 / 3.55 = **0.718** | half the base |
| a long at 0.90, every analyst a full band in agreement, the judge for | 5.8 / 6.0 = **0.967** | the whole base |
| a short at 0.70, every analyst a full band bullish | below zero, so **0** | no trade |
The first row is a real cycle from 2026-10-04. Before scores were graded it read as two full
voices against and refused at 0; as figures it is a small dent in a clear case.
The size is still a rule rather than a judgement. A model asked "how big should this be" answers
differently on Tuesday than on Monday from the same figures, and a size that depends on which
sentence was written last cannot be backtested, reproduced or audited. Leverage is not a lever any
role pulls either - it is what the notional works out to against the equity, and `max_leverage` is
the ceiling on that.
The confluence matrix above is different on purpose: it reads the words, not the scores, and a
role you list in `required` vetoes when it points the other way however slightly. Use it only for
the roles you really want to have a veto; leave the rest to conviction.
When the size the ladder chooses is not the base size, the round trip is priced again at the size
that is actually going out, because impact is a function of size. The cost in the record is the cost
of the trade that was admitted.
## The base size, said once
`[run].size` is in base units, which is fine for a desk on one instrument and awkward for a desk on
several: 0.01 is eight hundred dollars of BTC and twenty-seven of ETH. A desk that watches more than
one instrument says it in quote currency instead:
```toml
[run]
size_quote = 100 # quote currency, per order, before conviction
size_step = { BTCUSDT = 0.001, ETHUSDT = 0.01, SOLUSDT = 0.1 }
```
- Exactly one of `size` and `size_quote` is declared. Two figures that could disagree are refused
when the file is read.
- The cycle converts it at its own mid, after the trigger and before any model is asked, and rounds
down to the instrument's step. A size that comes to less than one step stops the cycle there and
costs nothing. The conversion is in `cycle.json` under `size`: the quote amount, the mid, the step
and the base size it became.
- `size_step` may be one number or a table with one increment per instrument. Venues step
instruments differently (Bybit: BTC 0.001, ETH 0.01, SOL 0.1), and a table must name every
instrument the desk can trade.
- Everything after the conversion is unchanged: the conviction ladder multiplies it, and
`[controls].max_position`, the leverage ceiling and the risk per trade cut it. `max_position` is
still in base units, so on a quote-sized desk it caps each instrument at that many units whatever
they are worth; `tra check` says so.
- A replay, paper or live session trades one fixed size and has no cycle mid to convert at, so it
refuses `size_quote` by name and asks for `[run].size`.
`tra check` puts the size against the venue cap before a cycle is paid for: a `size_quote` above
`[execution].max_notional` is a refusal, and a ladder whose top rung would cross the cap is named.
In venue mode the hand also checks the size against the venue's own market before sending. ccxt
rounds an amount to the market's precision on its way out - 0.036 ETH goes out as 0.03 - which is a
different trade from the one the cycle priced and wrote down. A size off the venue's step, or under
its minimum, is refused with both figures instead.
## Risk per trade: the size the account can afford
Conviction answers how much of the base size the desk wants behind an idea. That is a different
question from how much the account can afford to be wrong about, and the second one is what a risk
manager is paid to ask: with the stop where it is, what does this position lose if it is hit?
```toml
[controls]
max_risk_per_trade_bps = 50 # of [run].equity, at the stop from [exit]
```
The arithmetic is what a stopped-out position loses: the distance to the stop plus the round trip it
pays on the way in and out, times the size, against the equity, in basis points. The round trip is
priced at the size being checked or the base, whichever is larger, so neither a conviction rung
above 1x nor the cut that follows understates it, and the cap is decided on the exact figure, not
the one rounded for the record. If it is over the
allowance, **the size is cut until it fits** - never the stop moved, because moving a
stop to make a trade fit is how a desk learns the wrong lesson from a loss. If the size that would
fit is below one step of the venue, the cycle refuses. The cut and the figures behind it go into the
record and into the intent, so `risk_bps` is there beside the size for every trade the desk took.
This is a fixed calculation, not a role. A model asked how much to risk answers differently on Tuesday than
on Monday from the same figures, and the session runtime has always enforced this limit - a cycle
that can send its own orders has to enforce it too. `tra check` refuses the combination that cannot
be applied: a declared risk limit with no stop, or with no equity to measure against.
With `[sizing]` declared, the desk's own finished trades can lower that allowance: the Kelly fraction
the record supports, a fraction of it, and a seeded Monte Carlo of the drawdown the next trades could
take the account through. It never raises it, and a record that shows no edge refuses new entries.
[Sizing from the record](https://docs.bijectivelabs.dev/opensource/sizing) has the arithmetic.
## The desk: what the whole book may carry
Every bound before this one is about one position. A desk that watches three instruments can pass
all of them three times over and still be carrying more than anyone decided to carry, because
nothing was ever asked about the total.
```toml
[desk]
max_open_risk_bps = 150 # the sum of what every open position loses at its own stop
max_open_positions = 3 # how many instruments may be open at once
correlation_above = 0.7 # a candidate moving with something already open is one trade
```
The first figure is the one that matters. A per-trade limit of fifty basis points is a different
promise when one position is open and when six are, and only the sum says which promise is being
kept. It is applied after the per-trade cap, so what it adds is the figure that trade actually
carries rather than the one it asked for.
**What counts as open** comes from the venue in venue mode - the venue is the book, and it knows
what filled and what an attached exit closed while nobody was watching - and from the intents on
disk everywhere else. The cycle records which, in `desk.source`, so nobody has to guess how the
total was reached. A position whose risk is not on the record **refuses the next trade**: a sum that
quietly leaves something out is worse than no sum at all.
Expect this on a universe of majors. BTC, ETH and SOL move together on one-minute returns most of
the time (0.82 to 0.86 on the demo desk on 2026-10-05), so with `correlation_above = 0.8` one open
position holds the other two back, and the desk carries one position at a time whatever
`max_open_positions` says. That is the rule doing its job, and a pass that ran no cycle says so in
those words. If you want more than one position, either raise the threshold (0.9 admits most
pairs of majors) or watch instruments that do not move together. The hold applies whichever way the
new trade would point, so a hedge against the open position is held too.
`correlation_above` belongs to the pass rather than the cycle, because it decides which instrument
is worth a cycle at all. It is measured on the bars the pass already pulled - the correlation of
bar-to-bar returns over the window, and nothing at all under twenty points, because a correlation on
ten bars is a number with no claim behind it. A candidate moving with an open position at or above
the threshold is refused with the figure and the name:
```
ETHUSDT: has been moving with BTCUSDT at 0.931 over the window, at or above the declared 0.7;
one trade in two names is not two trades
```
## The targets
What the desk is trying to do, in numbers, checked before there is an intent:
```toml
[targets]
min_net_bps = 5 # what must be left of the edge after every cost the gate priced
min_edge_multiple = 2.0 # the claimed edge, as a multiple of the round trip
min_reward_risk = 1.5 # take profit against stop, both from [exit]
max_research_bps = 25 # model spend since the last intent, against this notional
max_round_trips_per_day = 6 # intents already written today for this instrument
```
`min_edge_multiple` is the one that keeps a desk honest, and it is the difference between trading
and collecting tolls for a venue. A round trip costing 18 basis points against a claimed edge of 20
is not a two basis point trade: the fees are certain, the edge is a probability, and slippage only
ever moves one of them. Live cost cannot be known exactly in advance either - the spread the cycle
priced on is the spread it saw, not the one the order gets. Requiring the edge to be a multiple of
the cost prices that asymmetry before a model is believed rather than after the fills come in. A
real refusal reads: `the claimed edge is 1.94x the round trip and the desk declared 2.0x; the cost
is certain and the edge is not`.
`max_research_bps` prices the other real cost. It is every model call since the desk last wrote an
intent, against the notional of the trade those calls produced, so a day of refusals is paid for by
whatever the day eventually trades rather than disappearing from the track record. It needs
`[agents.pricing.]` to mean anything, and `tra check` says so when the target is declared and
the price is not - including when the declared price is zero.
The round trips are counted from the intents the cycles actually wrote today, not from a counter in
memory: a process that restarts must not forget what it has already done. Every refusal names its
figure, so "targets: net 4 bps is below the declared minimum 10 bps" is the whole explanation.
The targets panel reads the trade the way a trader does, then shows what the desk checked:
```
position short at about 86114.35
take profit 84392.06 price -2.00%, the position +200.00 bps
stop loss 86975.49 price +1.00%, the position -100.00 bps
reward : risk 2 : 1
expected edge 25.00 bps a trade on average; [agents].magnitude_bps, declared by the operator
and not measured here
round trip 12.46 bps, paid win or lose
after cost +12.54 bps (the edge is 2.01x the cost; the desk asks 2.0x)
model spend 0.00 bps of this notional; not priced: google:gemini-flash-latest
trades today 0 before this one
```
Each exit shows two numbers because a short gains when price falls: the price's move, and what that
move does to the position.
### Where the stop and the take profit sit
`[exit].mode` picks one of three ways. The analyst proposes, the system bounds, and you set the bounds.
**Analyst** is what `tra init --agent` writes, and what an agent desk uses when `[exit]` is left
out. The technical analyst names two prices with its decision: `stop_price`, where its reading is
wrong (beyond the swing or the range edge the setup leans on), and `target_price`, where the
mechanism is done. It is handed the window's `range_high`, `range_low` and `atr` to name them
from. The system then:
| Check | What happens |
|---|---|
| a level on the wrong side of the price (a long's stop above it) | set aside; the measured stop below stands, and the record says why |
| the stop inside `min_stop_atr` average true ranges (1 by default) | widened to it: a stop inside one bar's ordinary range is hit by noise before the idea is wrong, and the wider stop makes the position smaller, not the loss bigger |
| the stop tighter than `min_stop_frac` or wider than `max_stop_frac` | held to that bound |
| the target closer than `min_reward_to_risk` times the stop | the trade is refused; moving the analyst's target out would be inventing one |
| the target past `max_reward_to_risk` times the stop | brought in to it; a far target makes the break even look easy and is rarely reached |
| no stop named | the volatility stop below; no target named, `reward_to_risk` times the stop |
```toml
[exit]
mode = "analyst"
min_stop_frac = 0.002 # a stop never tighter than 0.2 percent of price
max_stop_frac = 0.03 # and never wider than 3 percent
min_stop_atr = 1.0 # nor inside one bar's ordinary range; 0 turns this off
min_reward_to_risk = 1.5 # a target closer than 1.5x the stop is refused
max_reward_to_risk = 4.0 # a target past 4x the stop is brought in
stop_atr = 1.5 # with no level named: the measured stop
reward_to_risk = 2.0 # and the take profit at twice it
```
Size still comes from `[controls].max_risk_per_trade_bps` against the stop that was placed, so an
analyst who names a wide stop gets a smaller position, never a bigger loss.
**Volatility** measures the stop on every trade from the market it is in, whatever the analyst
named:
```
stop = stop_atr x ATR(atr_bars) / price x sqrt(horizon bars), held in [min_stop_frac, max_stop_frac]
take profit = reward_to_risk x stop
```
```toml
[exit]
mode = "volatility"
stop_atr = 1.5 # the stop in average true ranges
reward_to_risk = 2.0 # take profit against stop
min_stop_frac = 0.002 # never tighter than 0.2 percent of price
max_stop_frac = 0.03 # never wider than 3 percent
atr_bars = 14 # the default
```
ATR is Wilder's average true range on the decision timeframe's closed candles, the same figure a
charting terminal shows, so any stop can be checked against one. A trade meant to last longer gets
room to match: the square root of the analyst's horizon (capped by `[exit].timeout_bars` when the
desk closes on time) is how far a market usually wanders in that many bars. Read off OKX's
one-minute candles on 2026-10-10 with the defaults and a five-bar horizon:
| Instrument | ATR | Stop | Take profit |
|---|---|---|---|
| BTCUSDT | 1.4 bps | 20 bps (the floor) | 40 bps |
| SOLUSDT, 30 bars | 4.6 bps | 37 bps | 75 bps |
| FILUSDT | 10.8 bps | 36 bps | 72 bps |
| FILUSDT, 30 bars | 10.8 bps | 88 bps | 177 bps |
A wider stop does not mean a bigger loss. `[controls].max_risk_per_trade_bps` cuts the size until
the loss at the stop fits, so a volatile coin with a wide stop puts the same share of equity at risk
as BTC with a tight one, on a smaller position. The cycle record keeps the ATR, the horizon, the
stop it measured and the bound that held it under `exits.measured`, and the cycle output says it in
one line.
When the candles cannot give a range (fewer than `atr_bars + 1` closed, or a market that did not
move), the trade is refused with the reason. A guessed stop is not a stop. Volatility mode is for
agent desks; a scripted strategy is simulated bar by bar and stays on fixed. Outside the agent cycle, where
one number is needed up front, the widest stop the rule can place stands in, which overstates the
risk rather than understating it.
**Fixed** puts every trade at the same distance:
```toml
[exit]
mode = "fixed" # the default when mode is left out
stop_frac = 0.01 # 1 percent of price, on the adverse side
take_profit_frac = 0.02
```
It is simple and it is the same for every market, which is the problem: 1 percent is wide for BTC
over a few minutes and narrow for a coin that moves that much in an hour. The two modes do not mix;
a volatility key in a fixed table, or `stop_frac` in a volatility one, is refused rather than half
read.
Either way, a take profit closer than the round trip is refused (`exit: ... a winner would lose
money`): the best outcome the trade can have would still lose.
#### Why it is built this way
- **The analyst proposes, the system bounds.** Where an idea is wrong and where it is done is a reading
of the chart, which is the analyst's job. How far that may be, how much it may risk and whether
the costs leave it worth taking is arithmetic on your limits, which is the system's job. A model left to
place stops on its own gives a different answer on the same chart; a model whose levels are
held inside your bounds cannot hurt you by it.
- **The range, not a percentage.** The average true range is the most common way a trading desk
measures how far a market moves in one bar, and it is the figure every charting terminal
publishes, so a stop in this record can be checked by hand against one.
- **The square root of the horizon.** A market that wanders one ATR in a bar usually wanders about
the square root of N ATRs in N bars. A trade the analyst expects to last 30 bars needs more room
than one meant to last 5, or the stop is hit by ordinary noise before the idea has had its time.
- **A floor and a ceiling.** On a very quiet minute the measured stop can sit inside the spread and
the fees; on a violent one it can be wider than anyone meant to risk. The bounds are yours, and
the record says when one of them held.
- **The analyst's target is its claim; the losers are counted separately.** When the analyst names
a target and it survives the bounds, that distance is the move the cost gate and the targets
weigh: it is the analyst's own reading, already held to `max_reward_to_risk`. A target alone
would count every trade as a winner, so it never stands alone: the break-even bound prices the
stops and the round trip against it and refuses a trade that needs more than half its kind to
win. A take profit the system set as a multiple of the stop claims nothing, so without a named
target `[agents].magnitude_bps` is still the number the gate weighs. The record says which one
was used (`edge_source`).
- **No guessed stop.** When the candles cannot give a range, the trade is refused. Falling back to
some default would put a stop on the venue that nothing measured.
- **One mode at a time.** A table holding both `stop_frac` and `stop_atr` would be read by a person
one way and by the system another, so it is refused, as is any key `[exit]` does not know. A
typo in an exit setting should stop the desk at `tra check`, not at the venue.
Every intent also records `break_even_win_rate`, and the cycle output says it in one line: the
share of trades like this one that must reach the take profit for the stops and the round trip to
come out even, `(stop + round trip) / (take profit + stop)`. It is arithmetic on figures already
priced, with no guess at the odds. A 2:1 bracket needs a third of its trades to win before costs;
with a 20 bps stop and a 13 bps round trip it needs 55 percent. Compare it with the win rate your
round trips actually reach.
`[targets].max_break_even_win_rate` (0.5 by default) refuses a trade whose own stop, target and
round trip would need more than that share of winners. It is the bound on the analyst's levels and
on the measured ones alike, and it is why a one-minute desk refuses many trades a fifteen-minute
desk takes: the round trip does not shrink with the timeframe. Set it to 1 to turn it off.
The expected edge is a different thing from the take profit, on purpose. The take profit and the
stop say where this position closes; the edge is what the desk expects to earn a trade on
average, across the ones that hit the stop, the take profit or the time limit. The cost gate and
`[targets]` judge the trade on the edge less the round trip, because that is the number a desk
lives on. Model spend names the calls that had no declared price in `[agents.pricing]`, rather
than counting them as free.
## The cost gate, and what each number is worth
Before there is an intent, the system prices the round trip on the book the snapshot is holding. Every
component says how much we actually know:
| Component | Certainty | Where it comes from |
|---|---|---|
| `fee_round_trip` | declared | the channel's schedule in `envelope.toml` |
| `spread_cost` | measured | half the spread of this snapshot's book, both legs |
| `impact` | computed | walking the visible levels at the declared size, in the base; the exit leg is assumed symmetric |
| `funding_accrual` | declared | the settlements a horizon spans at the venue's own interval for that instrument, at the rate that stands now; only what the position pays |
| `latency_slippage` | estimated | `[run].slippage_bps`, an operator's bound, not a measurement |
The gate is one comparison: the claimed edge must be at least the total plus the margin the
operator declared. The claim itself is labelled too. It is the analyst's own target when one was
named and survived the [exit] bounds, and `[agents].magnitude_bps`, a declaration, otherwise;
when layer 1 or layer 5 has measured the phenomenon, the same field carries the measurement and
the label changes with it.
A real cycle, live data and a real model, printed as the console shows it:
```
cycle 2026-09-27T14-51-23Z BTCUSDT: 8.46s of agents
funding ccxt:bybit age 0.764s
ohlcv ccxt:bybit age 35.705s
orderbook ccxt:bybit age 5.303s
technical: long at 0.68 over 3 bars: short-horizon reversion of the close against its window
triggered by a zscore of -2.197, vwap_gap_bps of -24.21 and volume_ratio 2.392
cost: 13.48 bps round trip, 2.00 of it estimated
gate: 25.0000 bps claimed clears 13.4800 bps cost plus 0.0000 bps margin
the edge is declared: [agents].magnitude_bps, declared by the operator and not measured here
fee_round_trip 11.0000 bps declared
spread_cost 0.0118 bps measured
impact 0.0000 bps computed
funding_accrual 0.4682 bps declared
latency_slippage 2.0000 bps estimated
intent: long 0.01 (sandbox)
```
Read the fee line again: eleven of the thirteen and a half basis points are the venue's own
schedule. That is the number most agent frameworks never show, and it is usually the number that
decides whether the whole idea is worth running.
Three details decide whether these figures are right on every venue, and each was checked against
the venues on 2026-10-21:
- **Depth is read in the base.** OKX and Gate quote a perpetual's book in contracts (0.01 and 0.0001
of a Bitcoin), every size here is in the base, so the book is converted with the market's contract
size before it is walked. A contract with no published size is refused rather than guessed.
- **Funding settles on each instrument's own clock.** On Binance, Bybit, OKX, Gate, Bitget and BingX,
40 to 58 percent of the perpetuals settle every four hours, and every Hyperliquid perpetual every
hour, not every eight. The interval comes
from the venue with the rate, or from its own interval endpoint, and `[run].settlement_interval_s`
is only the fallback. The cost line says which one it used.
- **A receipt is not a discount.** When the standing rate would pay the position, nothing is taken off
the cost: the rate can turn before the settlement comes. The cost line says how much was left out.
## Watching a desk you are not running
`tra watch` is the other half of the console, and the answer to "can I look at a running desk
without disturbing it":
```bash
tra watch --every 5s --limit 20
```
It opens the directory a desk writes into, reads the records that are already there, and redraws
them. It opens no socket, builds no client, holds no key and takes no lock - so opening it, closing
it and opening it again does nothing at all to the desk. A record caught halfway through being
written is skipped rather than shown wrong.
What it shows, all of it from files: the last decisions with their setup, conviction, risk and what
became of them; the last pass over the universe; the book as the newest cycle recorded it; anything
that broke; and what the models have cost across the cycles in view.
This matters for how a desk is deployed. **The console runs commands in its own process**, so a
`tra run --every 15m` typed at the prompt ends when the console does. A desk meant to keep running
belongs under a supervisor, and `tra watch` is how it is looked at from anywhere else.
## Watching it work
A cycle spends most of its wall clock waiting on sockets, so a terminal that prints only the outcome
looks like a hung process. Every step says when it starts and how it ended, and the console draws
the pipeline the project configured with each step pending, running, done, refused or held:
```
step | state | detail
----------+----------+---------------------
data | done | 4 kind(s) (1.2s)
trigger | done | moved 41.20 bps
technical | done | 5.3s
macro | running |
cost | pending |
risk | pending |
targets | pending |
intent | pending |
```
Progress travels on its own wire and can change nothing: a watcher that throws is swallowed, because
a broken progress bar is not a reason to lose a decision. `--json`, a pipe and `--plain` all give the
lines instead, unchanged.
## Agents run together, and one failure is not the end
Analysts go out on a small pool of threads, because a model call is seconds of waiting on a
socket. Each answer is written the moment it arrives, so what a cycle already paid for is on disk
even when a later call fails. A failed agent becomes a line in `cycle.json` rather than a
traceback, and the results are always reported in the same order no matter who finished first.
A cycle is not resumed. Its answers belong to the snapshot it took, and asking the next model on a
newer book would mix two moments in one decision; the next cycle takes its own snapshot and asks
again. `tra agent session --resume` is different: a session replays recorded data, so its answers do
not go stale.
## The execution mandate
Three modes, and the distance between them is deliberate.
| Mode | What happens to an admitted intent | What it needs |
|---|---|---|
| `sandbox` | written into the cycle's own directory | nothing; this is the default |
| `signals` | written for execution somewhere else, by someone else's system | nothing |
| `venue` | sent to the venue, with real capital behind it | a mandate, a leverage, a cap per order, the keys; on mainnet the canary tier |
Venue mode does not start until all of this is true, and `tra check` names whatever is not:
1. `MANDATE.md` is in the project and carries the mandate text word for word, and
`[execution].acknowledged_by` and `acknowledged_on` name who signed it and when. Read it with
`tra mandate` - it says that the losses are yours - then `tra mandate --sign "Your Name"` does
both: it writes `MANDATE.md` when the project has none (one you wrote yourself is never
overwritten) and sets the two lines, with today's date or `--on YYYY-MM-DD`. Venue mode stays off
until you set `[execution].mode = "venue"`, or sign with `--venue-mode`, which does it too.
2. The signature is yours alone: no role, model or tool on the MCP surface can sign.
3. `[execution].max_notional` caps one order, and `[execution].leverage` is chosen. Neither has
a default in the code: both are what a defect would cost you. `tra init --venue` writes 200 and
3x as a start, on lines you can see and change.
4. On mainnet, the ladder stands at `CANARY`, promoted with evidence and a signature. A demo or
testnet API holds no capital, so it is where that evidence is earned and does not wait on
the ladder.
5. The venue's keys are in the environment: the shell, an `--env-file` you name, or the `.env`
beside `strategy.toml`, never `strategy.toml` itself. They are read by the execution layer alone,
and they never appear in a brief, a prompt, a record or a log.
6. `[execution].environment` (else `[live].environment`, else `demo`) says which of the venue's environments an order goes to: `demo`, `testnet`
or `mainnet`. These are three different places with three different accounts, and a key for one
is rejected by the others. A cycle that cannot route to the environment it was told refuses
rather than sending somewhere close.
`tra keys --probe` answers the question `tra check` cannot: not whether a key is set, but whether it
works - one free authenticated call per provider, and for the venue an account read through the very
client an order would go through. No prompt is sent and no order is placed. Nothing prints a key:
each one is shown as eight hex characters of its own SHA-256, which is enough to tell two keys apart
and cannot be turned back into either.
```
ok provider groq ([agents].provider): verified [GROQ_API_KEY 8e4edee0]
ok provider google ([agents].fallback): verified [GEMINI_API_KEY d3869001]
ok venue bybit (demo), for `tra live`: verified [BYBIT_API_KEY da8c3e5b, BYBIT_API_SECRET 9f9bfb94]
ok venue bybit (demo), for `tra run`: verified [BYBIT_API_KEY da8c3e5b, BYBIT_API_SECRET 9f9bfb94]
```
The last two rows are two different clients reaching the same venue: `tra live` has its own signed
REST client and `tra run` sends through ccxt. They are listed separately because a readiness check
that proves one path works proves nothing about the other, and it is the second one a cycle uses.
A desk that watches several instruments builds one hand per instrument, and a hand refuses an intent
about any other instrument. One symbol, one instrument, one order.
The order follows the channel. `[run].venue_key` names a channel in `envelope.toml`, and the name
is `-`, each `maker` or `taker`:
| Channel entry | What is sent | If it does not fill |
|---|---|---|
| `taker` | a limit IOC at most `[execution].max_slippage_bps` through the touch, crossing the book at once | what did not fill inside the band is cancelled, never chased |
| `maker` | a post-only limit at this snapshot's own touch, offset by `price_offset_bps` | cancelled after `limit_timeout_s`; nothing is chased across the spread |
| anything else | nothing | venue mode refuses: the fee you priced depends on the answer |
That is not a style preference. A channel named `maker-taker` was priced at the maker fee, and
sending a taker order under it would pay a fee the gate never priced.
Three habits make this auditable after a failure, which is the only time it matters. Every order
carries a client id derived from the cycle and the instrument, so the same cycle run twice cannot
open two positions. The account is read before anything is sent, and an order is never added to a
position pointing the other way unless `adopt_open_position` says so - a hand that cannot read the
account does not send at all. And nothing is retried: a venue that refuses is recorded as a refusal,
because a retry loop around real capital is how one defect becomes ten orders.
The size is the one the conviction rule chose, capped again by `[execution].max_notional`. What came
back is written to `placed.json` beside the intent, and the intent is written first: whatever the
venue does next, the record of what this cycle decided survives it.
### The book at the moment of sending
The snapshot a cycle priced on is as old as its slowest model: half a minute is ordinary. Before an
order goes out the venue hand reads the book again. A maker order rests at the touch as it is now,
not as it was, and the record keeps the priced mid, how far the mid moved (`drift_bps`) and where the
order rested. `[execution].max_drift_bps` refuses the order when the mid has moved further than that
against the trade, because a decision the market has already left behind is not the decision the
gates admitted. A move in the trade's favour never refuses.
## Capital, orders and positions in venue mode
Before every order the account's equity and free margin are read, and sizing uses the smaller of
the account's equity and `[run].equity`. At send time your leverage and margin mode are applied at
the venue and read back, the estimated liquidation must sit beyond the stop, and a taker entry goes
out as a limit IOC within a slippage band. At the start of every pass each open position is matched
to the order that filled it, closed by time past `[execution].hold_limit`, written up when the
venue's exits closed it, and flattened when the kill switch is armed. [Capital, orders and positions](https://docs.bijectivelabs.dev/opensource/capital) has every
setting, every check in order, the arithmetic and the records.
### What happens after the order
The stop and the take profit go to the venue **attached to the entry**, in the same call. The system
computes both against the price the cost gate priced on, from the market's own range or from
the fixed fractions ([where they sit](#where-the-stop-and-the-take-profit-sit)), so no model is
ever asked where a stop belongs. A filled position with no
exit is the one state this software must never create, because it is the state that survives our
process dying - and if the venue cannot take them with the entry, the order is not sent at all,
unless `[execution].exits = "separate"` places them as reduce-only orders the moment it fills
([Venues](https://docs.bijectivelabs.dev/opensource/venues#exits-a-venue-will-not-take-with-the-entry)). An operator who carries
the exits in their own system says so with `[execution].exits = "none"`, and `tra check` lists that
as something to fix rather than a setting to admire.
The same read that protects against a wrong-way position protects against a second one: any open
position on the instrument refuses a new entry unless `adopt_open_position` says otherwise, because
adding to a position is a larger position than the conviction rule sized. An order still working on
the instrument refuses too - a second one would be a second decision on the same position - and the
client order id is derived from the cycle and the instrument, so the same cycle run twice cannot
open two.
## Modes
| Mode | What happens |
|---|---|
| `sandbox` (default) | the intent is written into the cycle; nothing leaves the machine |
| `signals` | the intent is written for your own executor to act on |
| `venue` | the intent is sent to the venue through ccxt, after the checks in [the execution mandate](#the-execution-mandate) |
## When a cycle refuses
| Refusal | What it means |
|---|---|
| `data : ...` | a chain was exhausted; see [The data gate](https://docs.bijectivelabs.dev/opensource/data), and give that kind a longer `ttl` or another source |
| `no technical brief: ...` | there were not enough closed candles in the snapshot to compute features |
| `trigger: the mid has moved ...` | nothing happened worth paying a model to look at; no tokens were spent |
| `technical: flat` | the analyst saw nothing worth pricing |
| `technical: ... with no setup named` | a direction nobody could name the shape of is not a trade |
| `technical: not one figure ... survived` | the direction could not point at a figure in its own brief |
| `technical: confidence ... below` | the answer was under `[agents].min_confidence` |
| `exit: ... closed candle(s); an average true range over ... needs ...` | volatility mode could not measure a range; raise `[agents].lookback_bars` or lower `[exit].atr_bars` |
| `exit: the take profit is ... a winner would lose money` | the take profit sits inside the round trip cost |
| `exit: ...% of trades like this would have to reach the take profit ...` | the break even is above `[targets].max_break_even_win_rate` |
| `exit: the analyst's target is ...x its stop ...` | the analyst's own target is closer than `[exit].min_reward_to_risk` allows |
| `risk: ... bps of equity at the stop` | the smallest size the venue takes still risks more than `[controls].max_risk_per_trade_bps` |
| `desk: ... bps of equity is already at risk` | what the book already carries plus this trade is over `[desk].max_open_risk_bps` |
| `desk: ... has at risk is not on the record` | a position is open whose risk nobody can state, so the desk's total cannot be stated either |
| `targets: the claimed edge is ...x` | the edge was not the multiple of the cost the desk declared |
| `cost: no book in the snapshot` | nothing can be priced without a book, so nothing is |
| `cost gate: ... is below ... required` | the claim did not clear the cost plus the margin |
Every refusal on this list stops the cycle before an order exists. The last one is the gate doing
the job the research programme this package came from was built to do.
## Did the gates earn their place: `tra hindsight`
A gate that refuses is easy to defend and hard to judge. It refused a trade; nobody saw what the
trade would have done. `tra hindsight` replays every directional decision in `runs/cycles` on the
candles that followed it, then puts each gate's refusals next to what the desk let through:
```text
hindsight: decisions replayed over day(s), of them independent, each held 4h on 5m candles, net of its round trip
admitted mean median won <%> sum
refused by targets mean median won <%> sum
refused by regime mean median won <%> sum
side: the model's against on the reverse;
```
Each row is one gate: how many decisions it refused, and what those decisions would have done net
of their round trip, beside the ones the desk let through. The verdict at the end of a row says
whether the gate kept out worse trades or better ones; with few decisions it says there is no clear
difference yet, and that is the honest answer until the sample grows. The last line asks whether the
deciding model's side did better than its exact reverse. No gate can fix a side with no edge: a gate
only refuses; it cannot turn a call with no edge into one with an edge. Read these numbers only once
there are enough independent decisions behind them to audit.
How each decision is replayed:
- From the open of the first candle after the cycle finished, on the side the analyst took.
- On the stop and take profit the record holds: the levels sent with the order, else the exits the
cycle measured, else the analyst's own named levels. With none, the trade closes at the hold limit.
- A candle that touches both levels counts as the stop. Inside one candle the order is unknown, so
the replay takes the worse reading.
- Held for `[exit].timeout_bars` on the decision bar (`--hold 4h` to change it), on 5-minute candles
(`--bar 1m` for a finer path), net of the round trip the cycle priced.
- The same path on the opposite side gives the side check.
The verdicts are deliberately reluctant:
| Verdict | When |
|---|---|
| `too few to tell` | fewer than 10 decisions on either side, or fewer than 5 days for the side check |
| `refused worse trades than it let through` | a permutation test puts the admitted group ahead with p at or below 0.05 |
| `refused better trades than it let through; review it` | the same test, the other way |
| `no clear difference yet` | anything between |
The side check resamples whole days, because one day's tape moves every decision on it together.
"Independent" counts decisions after dropping those on an instrument inside an earlier one's hold;
the rest are the same bet asked again.
The candles come from the desk's own ohlcv chain, the venue first. A path is kept under
`runs/cycles/hindsight/` once its hold is over, so a second run asks the venue for nothing it already
has. The report goes to `runs/cycles/hindsight.json`, and the quant auditor reads it there, beside
the round trips the venue closed.
A replay is not a fill. It assumes the entry at the next candle's open and an exit exactly at the
level, with no queue, no partial fill and no gap past a stop. That is why it is reported beside the
real round trips, never in place of them.
# Agents
Agents here read a lot and decide very little. In a cycle each role gets the part of the brief
its job needs and nothing else, answers once inside a closed schema, and holds no tools. The
technical analyst proposes a direction; the system prices it, sizes it, checks it against the book and
decides whether it goes out. In a session the auditor also gets a small set of read tools, and the
files those tools can open are fenced to the project.
What no role holds is the authority to loosen a control. Limits can be tightened, never widened;
the ladder can be stepped down, never up; the kill switch can be armed, never disarmed. Those are
the system's own rules, tested and failing closed, and a role acts through them, not around them.
## The roster
| Role | Holds | May write | Cadence |
|---|---|---|---|
| `market_screener` | in a cycle, one live snapshot per instrument in the universe; in a session, the data gate, the envelope and the cost floor | nothing; it may only take an instrument out of a list the system already admitted | once per pass, or once per session |
| `technical_analyst` | the recorded trades and bars; in a research run the registered mechanism, in `tra run` the regime and higher timeframes, with your mechanism as a preference | a `Signal`, as the decision core of a run: a direction, a setup named from a closed list, the figures it read, and in `tra run` the stop and target prices that bound the idea, held by the system inside your `[exit]` bounds | once per closed bar, within its budget |
| `macro_analyst` | series the operator mapped in (events, cross-venue funding, sentiment) | nothing | every cycle in `tra run`; once per session in a session, when series are given |
| `onchain_analyst` | on-chain series the operator mapped in | nothing | every cycle in `tra run`; once per session in a session, when series are given |
| `sentiment_analyst` | dated scores and dated notes the operator curates, through the same series engine | `negative`, `neutral`, `positive`, or `not declared` when nothing was mapped in | reads only; nothing is scraped |
| `validator` | `figures_of`, `checked` over the brief the deciding analyst was given | one stance (pro, contra, neutral): the proposal as the analyst gave it (side, setup, levels, confidence) and the figures it was built from, never another analyst's reading, the cost, the claimed edge or the desk's rules; every claim it makes has to cite one of those figures with its value, and the system discards the ones that do not. With all three stances, pro and contra build their cases apart and neutral judges both ([the validators](https://docs.bijectivelabs.dev/opensource/cycle#the-validators)) | once per cycle, when `[agents].validators` is set |
| `risk_manager` | in `tra run`: the client's limits, what is in force, the alarms, the round trips by instrument; in a session: the limits, the canary caps, the ladder, latency, net against the registration | in `tra run`: `reduce` (size multiple below 1), `pause` (instruments), `halt` (the kill switch), each for `hold_hours`; in a session: `tighten_limits`, `demote`, `arm_kill_switch` | in `tra run`, between cycles whenever the quant auditor is asked; in a session, after the runs |
| `executor` | the venue, the rate limits, the allocation across instruments | an allocation, fractions summing to at most one | after the risk decision |
| `auditor` | the ledger, every run, `usage.jsonl`, the ladder | `audit.json` | last |
Each role is a package under `trading_research_agents.agents`: a JSON schema the model must
fill, a parser that refuses anything outside it, and a prompt that names venues nowhere. No
role talks to another. The session hands each one what the system computed plus what earlier roles
wrote, and records every answer as a numbered file in the session directory.
## A session
```
tra agent session # every dataset in data/, [agents].magnitude_bps
tra agent session --config strategy.toml --envelope envelope.toml \
--dataset BTCUSDT=data/btc.parquet --dataset ETHUSDT=data/eth.parquet \
--magnitude 20 --out runs/session-1 # the same, spelled out
```
```
figures (system) -> screener -> [macro] -> [onchain] -> one replay per kept instrument,
the analyst as its core -> risk manager -> executor -> auditor
```
The system measures each dataset first (rows, print rate, realised volatility, turnover, the cost
floor for the claimed magnitude, and, when `observation.toml` sits beside the config or
`--observation` names one, layer 1 on the same prints: whether the declared event recurs on
that instrument, its counts per subperiod and its response) and the screener sees only those
figures. It keeps or
excludes; it cannot add an instrument it was not given, and a candidate it does not mention is
excluded, not silently kept. Each kept instrument then runs through the same replay loop as
`tra backtest`, with the analyst deciding on each closed bar and the controls admitting or
refusing as they always do. The risk manager reads the run reports and decides: continue,
tighten (applied only where the figure is smaller), demote (the ladder steps down, on the
reason), or halt (the kill switch file is written and nothing more is asked). The executor
allocates fractions across the kept instruments; it sends nothing. The auditor closes with a
verdict on the whole session, including what every call cost.
`strategy.toml` declares the analyst as the core and the budget:
```toml
[strategy]
agent = "technical_analyst"
[agents]
provider = "deepseek" # any registered provider; see Model providers
model = "deepseek-flash"
mechanism = "positions forced to close into the settlement window"
token_budget = 200000 # the whole session, every role; keep it under the vendor's daily quota
max_calls_per_hour = 60
min_interval_s = 60
lookback_bars = 60
min_confidence = 0.6
magnitude_bps = 20 # the claimed edge, for the cost floor
decide_every_bars = 5 # ask the model every fifth closed bar; exits run on every bar
max_output_tokens = 512 # a reasoning model that thinks for 20 s is not a bar-cadence core
call_timeout_s = 60 # per request, enforced by the SDK client; max_retries = 2 by default
[agents.pricing.deepseek-flash] # what the operator was quoted; never a constant
input_per_million = 0.14
output_per_million = 0.28
```
The same `[strategy].agent` core runs under `tra backtest`, `tra paper` and `tra live`, so an
agent that survived a session can be taken up the ladder like any scripted strategy.
### The auditor reads before it answers
The auditor is not handed one brief; it works a bounded tool loop over the read surface:
`read_run_report`, `show_ledger`, `search_ledger`, `list_runs`, `show_ladder`. Each step is one
model call that either names a tool with its arguments or carries the final answer. The loop
is closed at its edges: a tool outside the catalog is refused by name, arguments are checked
against the function before it runs, an observation is cut at 6000 characters, the budget
applies to every step, and the loop ends without a verdict after six calls
(`SessionConfig.auditor_steps`). Every step, with its thought, observation or error, is in
`NN-auditor.json`.
### Resuming a session
```
tra agent session --resume --out runs/session-1
```
A session interrupted by a vendor rate limit, a spent budget or a lost connection does not
start again from nothing. With `--resume`, an answer already recorded in `--out` for a step
is reused instead of asked again, and a kept instrument whose run reached the end of its
data is reported from disk instead of replayed. A run that stopped early (a spent budget,
five failed calls in a row, a kill switch) is not a checkpoint: it runs again. Either way the
decision is written down in the session's refusals, so a report never hides that a model was
not called. `usage.jsonl` keeps the calls of the first
pass; the second adds only what it actually asked.
## What each role holds before it is asked
A role does not start from a blank prompt. The system computes the figures first and the model reads
a brief with the basis of every number in it: measured on the operator's data with a sample
size, or declared by the operator and labelled as such.
| Role | Tools (`agents//tools.py`) | What the brief carries |
|---|---|---|
| `market_screener` | `screen_figures` | per instrument: rows, print rate, realised volatility, first-lag autocorrelation, the 1:10 variance ratio, largest gap, last-hour volume against the average, turnover, the cost floor for the claimed edge, and a regime the system proposes from a written rule (`[agents.screen]` sets the two thresholds: `min_prints_per_minute`, `volatile_above_bps`) |
| `technical_analyst` | `features` | on each closed bar: returns over 1, 5 and 20 bars, the close as a z-score of the window, the gap to VWAP, the last bar's volume and range against the window's, the position between the window's high and low, the net move and trend strength with the regime they give, the variance ratio, and the market structure (swings labelled HH, HL, LH, LL, the trend they make, the last break of structure or change of character, invalidation, support and resistance); the same regime and structure on each higher timeframe in `[run].context_intervals`; the book imbalance and the open interest change since the last snapshot (see [the cycle](https://docs.bijectivelabs.dev/opensource/cycle#the-market-around-the-decision)), and in `tra run` its own last reading of the instrument with the move since (`previous`). Nothing another analyst reads or says |
| `macro_analyst` | `macro_brief` over `data/series.py` | per series in `series.toml`: the value known at the time and when it became known (publication lag honoured), the state against the operator's declared threshold, the correlation and strongest lead-lag with the instrument's returns measured on the operator's data with n, the calendar events inside the horizon |
| `onchain_analyst` | `onchain_brief` | the same engine, plus the net over a window and the last value as a z-score of its own history |
| `risk_manager` | `risk_brief` | per run: latency from intent to fill (median, max, n, ratio to the declared), net per round trip overall and over the last window against the claimed magnitude, maximum drawdown, the favourable and adverse excursions of the closed round trips with what the winners gave back, which control refused and how often |
| `executor` | `size_for`, `rate_budget`, `export_signals` | a size that never exceeds a cap, rounded down to the venue's step; what the minute still allows; the run's admitted intents as `signals.jsonl` or CSV for execution elsewhere (`tra signals export RUN`) |
| `auditor` | `audit_brief`, then a tool loop over `read_run_report`, `show_ledger`, `search_ledger`, `list_runs`, `show_ladder` | usage per role with tokens per closed round trip, each promotion with whether its evidence names a gate, and a run against its replay trade by trade; then whatever it chooses to read, up to six calls |
### In a cycle: what goes in, what comes out
Every role in a cycle is told three things in its prompt and held to them in code: what its brief
carries, that every figure in it was computed before it was asked (so it has nothing to call), and
the schema its answer fills.
| Role | Reads | Answers | Code then checks |
|---|---|---|---|
| `technical_analyst` | the features, structure, regime and higher timeframes of its instrument, the book, open interest, its own last reading | `target`, `setup`, `confidence`, `horizon_bars`, `stop_price`, `target_price`, `reason`, `figures_used` | the schema, the setup against the measured regime, the levels on the right side of the price, every citation |
| `macro_analyst` | its series with what was known when, the operator's thresholds, measured correlations with n, the calendar | `regime`, `events`, `reason`, `figures_used`, `data_as_of` | the schema, every citation, words that belong to a later step (recorded) |
| `onchain_analyst` | its series, their net over a window and z-scores | `signal`, `reason`, `figures_used`, `data_as_of` | the same |
| `sentiment_analyst` | its scores and dated notes | `state`, `figures_used`, `reason`, `data_as_of` | the same |
| validators: pro, contra | the proposal and the figures it was built from; neither sees the other's case | `stance`, `claims`, `verdict`, `reason` | the schema; a claim that does not cite a figure it was given is dropped |
| validator: neutral, as judge | the same, and both cases as code checked them, without the advocates' verdicts | `stance`, `claims`, `verdict`, `reason` | the same; its verdict is the one that counts, and it is not asked when only one side has a case |
| `event_analyst` | the question, the outcome's book and recent prices, the calendar and your notes | `probability`, `confidence`, `reason`, `figures_used` | the schema, every citation |
| `market_screener` | one live snapshot per admitted instrument | `kept`, `excluded`, `picks` | it can only drop an instrument, never add one |
An answer that does not fit its schema is asked for once more with the reason, and then refused.
No analyst sees another's data or another's answer. The technical analyst reads its own market and
not the macro, on-chain or sentiment readings; each reading analyst reads its own shelf and nothing
the others were given; the validators argue from the proposal's own figures. A view that knows the
other views can lean toward them, and then two analysts agreeing says less than it seems to. The
readings are combined once, by code, in confluence and conviction, after every analyst has
answered on its own.
How that and the citation check have done on a demo desk is in
[Working with language models](https://docs.bijectivelabs.dev/opensource/llm#when-an-answer-does-not-fit-or-points-at-nothing).
### Reading, not calculating
In a cycle no role calculates. The system pulls the data, computes every figure, checks its age, and
hands each role a brief; the role reads it and answers in a fixed schema: a side, a label, levels, a
confidence, a reason, and the figures it rested on, by name and value. Code then checks every cited
figure against the brief and throws away the ones that do not hold. This is deliberate: a model
adding up bars gets the arithmetic wrong at a price per token, two runs of the same model would
compute two different numbers, and a decision built on numbers nobody can recompute cannot be
audited. Every figure a role saw is in its brief on disk, and `tra runs verify` recomputes them from
the raw inputs.
The one role that calls tools is the research session's auditor, which may read up to six records
(`read_run_report`, `search_ledger` and the rest) before it answers. The MCP server exposes the same
read-only tools to any host.
What a role's answer does differs:
| Role | What its answer decides |
|---|---|
| `technical_analyst` | the side, the setup and the levels; every gate after it works on these |
| `macro_analyst`, `onchain_analyst`, `sentiment_analyst` | nothing directly: confluence and conviction read the state the system derives from your thresholds on the same figures, and the label is recorded beside it. With `[conviction].reader_labels` above 0, a label counts where nothing is declared, as with a file of headlines |
| validators | one vote each in conviction, after their claims are checked |
| `market_screener` | which instruments, from code's own order, get a cycle; it may drop, never add |
| `risk_manager` | between cycles: size cuts, pauses, a halt; never a loosening |
### Proposals made without the desk's rules
The analyst that proposes a trade and the validators that argue about it are never told what a
trade has to clear. They do not see the round trip, the edge multiple, the reward-to-risk floor, the
stop bounds, the risk per trade, the size, the equity or the leverage, and their prompts do not
describe those rules. A role that knows the bar can aim at it: a target stretched until the trade
passes the cost gate, a confidence raised until it reaches a size. Kept out, the analyst names the
side, the setup, the confidence and two levels (where the reading is wrong, where it is done) from
the market alone, and code applies your settings afterwards.
| Who | Knows | Does not know |
|---|---|---|
| `technical_analyst` | the market figures, the structure, the regime, the higher timeframes, your mechanism as a preference, its own last reading | the cost of the round trip, every `[targets]`, `[exit]`, `[run]` sizing and `[controls]` setting, what became of its last reading, and the other analysts' readings |
| validators | the analyst's proposal as given and the figures it was built from | the same, and the claimed edge |
| `event_analyst` (prediction markets) | the question, the outcome's bid, ask and mid, its recent prices, your dated notes and calendar | the fee, the margin an estimate has to clear and the size |
| code | everything you declared | nothing it has to guess |
What code changes is written beside what was proposed: each cycle's `exits.measured` record keeps
`proposed_stop_bps` and `proposed_take_bps` (the analyst's own levels) next to `stop_bps` and
`take_bps` (the ones applied) and the note that moved them. `tra hindsight` replays both on the same
path and says which did better, in a line like this one:
```
levels: code's bps against the analyst's own ;
```
So a result can be traced to one of three things: the data the desk was given, how good the model
is at reading it, and the settings you chose. A test keeps the rules out of these roles' briefs and
their prompts.
The spread and the funding rate stay in the technical analyst's brief. They are the market's own
figures, on every trading screen, not settings of yours.
### Risk: one role after the runs, one step inside each decision
Two different jobs share the word, and keeping them apart is deliberate.
**The step.** How much this trade may lose, and the size that follows, is arithmetic over the
operator's own declarations: the distance to the stop plus the round trip, times the size, against
equity, against `[controls].max_risk_per_trade_bps`, lowered by the desk's own record when `[sizing]`
is declared ([Sizing from the record](https://docs.bijectivelabs.dev/opensource/sizing)). It runs inside every cycle, between the conviction ladder and
the cost gate, and it cuts the size to fit or refuses. No model is asked, because a model asked how
much to risk answers differently on Tuesday than on Monday from the same figures, and a size that
depends on which sentence was written last cannot be backtested or audited. See [The cycle](https://docs.bijectivelabs.dev/opensource/cycle).
**The role.** Whether the limits themselves are still the right limits is a judgement over what
the desk has been doing: losses in a row, net per round trip against what was claimed, drawdown,
which control keeps refusing. In `tra run` the risk manager is asked whenever the quant auditor is,
and it may cut the size of new entries, pause instruments or halt the desk, each for a stated
number of hours (below). In a research session it reads the finished runs and may tighten a
limit, step the ladder down or arm the kill switch. Either way it cannot widen anything, and it
never decides a single trade.
So "where is the risk manager" has two answers: the per-trade limit is arithmetic the system does
in every cycle, and the judgement over a run of trades is the role's, made between cycles.
### The two auditors
`tra audit` asks both, in parallel, over records this project wrote itself: `runs/cycles/*/cycle.json`
and the usage ledger. Neither one can reach a venue, a key or a live figure, and neither one is part
of a cycle.
| Auditor | Reads | Looks for |
|---|---|---|
| `tech_auditor` | calls answered and failed per role, median seconds, where each kind of data came from and how old it was, what was refused or stale, tokens and latency per role, tokens per decision, installed versions | what is wearing out, what is wasteful, what is fragile |
| `quant_auditor` | what the deciding analyst said, intents and their size, the conviction behind them, the claimed edge, the cost it was priced at, what was left after cost, which gate refused the rest, the round trips a session or the venue actually closed with their excursions, and the last `tra hindsight` report: what each gate's refusals would have done | whether the desk's own declarations are earning what they cost |
Both answer in the same shape: findings, each tied to figures from the brief with their values, each
carrying a proposal, and at most one proposed configuration change with the value it has now and the
value proposed. The system checks every citation against the brief, and every proposed key against
the project's own tables and the settings each table accepts, and discards what fails, keeping the
reason. An auditor cannot propose a setting the software does not have. A report with nothing left says
so rather than implying a finding.
**Nothing is applied.** The audit writes `audits//`: the briefs as they were given, the answers
as they came back, `audit.json` with what was discarded and why, and `proposals.md` for a person to
read. Applying a proposal is that person editing their own configuration.
### When they wake, and why it is not the same answer for both
An operator running this on a server is not watching it. The failure that matters to them is the one
that stops the desk quietly - a provider rate limits the roster halfway through, the run keeps going
through the motions, and nobody finds out until someone reads a log. So the two auditors wake
differently, because they answer different questions:
```toml
[audit]
on_alarm = true # the technical auditor, when a cycle reports something broken
alarm_cooldown_s = 1800 # per kind of alarm, not overall
quant_every_cycles = 20 # the quant auditor, on a count of cycles
quant_every = "24h" # and/or on a clock, kept across restarts (at least 5m)
losing_streak = 3 # losing round trips in a row that wake the quant auditor; 0 is off
```
Something breaking is an event. `agents/alarms.py` reads a cycle's own record for four kinds -
`stopped` (the cycle did not finish), `provider` (a model call never came back), `data` (a chain ran
out or was used stale) and `venue` (the order was refused) - and the technical auditor is asked once
per kind, no more often than the cooldown. A gate refusing is **not** an alarm: a cost gate saying no
is the system working, and waking an auditor for it would be paying to be told the desk is fine.
Whether the desk is earning what it costs is a question about a stretch of time, so the quant auditor
runs on a count of cycles or a clock instead, and also wakes when the account itself says something:
the daily loss limit was reached, or several round trips in a row lost money.
| Situation | Who is asked | Notes |
|---|---|---|
| A model call failed after the vendor's retries and every fallback (a rate limit that did not clear, a timeout, a key refused) | technical auditor | a rate limit a retry or a fallback absorbed is not an alarm, because the cycle got its answer |
| A cycle stopped before it finished, a spent budget included | technical auditor | |
| `[controls].daily_loss_limit_bps` reached | quant auditor | once per UTC day; the limit working is not a breakage, so the technical auditor is not asked |
| `losing_streak` losing round trips in a row (3 by default) | quant auditor | once for each new loss while the run lasts; a winner ends it; a trip with no result is skipped |
| A data chain ran out, or a kind was used stale | technical auditor | |
| The venue refused or failed an order | technical auditor | |
| The same kind fires again inside `alarm_cooldown_s` | nobody | one look per kind per cooldown; a different kind still wakes it |
| Every `quant_every_cycles` cycles | quant auditor | a count of cycles run: at `--every 15m`, 4 cycles is about an hour |
| Every `quant_every` (for example `"24h"`) | quant auditor | a clock: the first cycle starts it, any quant look restarts it |
| Both on the same cycle | both, in one audit | one report, one ledger entry per call |
| The quant auditor is asked, for any reason | the risk manager as well, first | it may cut size, pause instruments or halt, never loosen; `[audit].risk_manager = false` leaves the account to your limits alone |
| A gate refused (cost, risk, regime, trigger) | nobody | the system working as declared |
| Any time you want | whoever you name | `tra audit` |
The defaults: `on_alarm` is on even with no `[audit]` table, and with it the daily loss and the
losing streak alarms; the scheduled quant look is off until you give it a count or a clock. Auditors
are asked between the cycles of `tra run`, single or `--every`.
### The risk manager in `tra run`
The auditors report; the risk manager is the one role that can act on the same figures, and only
toward caution. It is handed what the system measured from the run's own files (round trips over the last
twenty, wins, the losing streak, the result per instrument), the alarms that woke it, your limits
and anything it already put in force, and it answers one of:
| Decision | What the system does |
|---|---|
| `continue` | nothing |
| `reduce` | every new entry trades `size_multiple` (above 0, below 1) of its size for `hold_hours` |
| `pause` | the named instruments take no new entry for `hold_hours` |
| `halt` | writes the kill switch file, so the next pass flattens and stops; with no kill switch declared, every instrument is paused instead |
Each decision lapses after `hold_hours` (at most 72) back to your own settings, and two reductions
compound downward: a milder one never undoes a deeper cut. What is in force is in
`runs/cycles/risk.json`, every decision with its reason and triggers in
`runs/cycles/risk_decisions.jsonl`; delete `risk.json` to lift it early. An answer that does not
fit the schema, a size multiple of 1 or more, or a model that does not answer at all leaves your
limits exactly where they were. Your limits in `[controls]` are applied after the risk manager's,
whatever it decides.
What survives a restart is kept in `audits/reported.json`: the clock's last look, the day whose loss
limit was reported, and the round trip that last extended a losing streak, so restarting the desk
does not pay for the same look twice. The cycle count and the per-kind cooldowns live in the running
process and start again.
Neither one gains anything by waking this way. They read the same records, answer in the same shape,
cite the same way, and change nothing; the only difference is that a person no longer has to be the
one who notices. An auditor that fails - the same rate limit, most likely - is a line in the run's
output, never the end of the run. Their calls are counted in their own `audits/usage.jsonl`, so what
the desk spent deciding stays separable from what it spent looking at itself.
### Who weighs whom
A question worth answering plainly, because it decides where the token bill goes:
| Step | Who does it | What it may do |
|---|---|---|
| read one class of data and label it | a reading analyst | say what it sees, in its own vocabulary; the score it is weighed by is graded by the system from the same figures against the operator's lines |
| propose a direction and a horizon | the technical analyst | one target, one confidence, one reason |
| argue for or against that proposal | pro and contra, apart | claims that cite figures from the brief; the system discards the rest |
| judge the two cases | neutral | one verdict from the claims that survived, weighed on the premise of the setup |
| decide whether the context agrees | the confluence matrix, in the system | admit or refuse, from declared states and vote counts |
| decide how much goes behind it | the conviction rule, in the system | weigh every voice by its strength (confidence, graded scores, votes) and pick a size multiple off the operator's ladder; it may only shrink the base size |
| price the round trip | the cost gate, in the system | refuse a claim that does not clear its own cost |
| hold the desk's shape | the targets, in the system | refuse a trade that is not worth what the operator said it must be worth |
| watch what actually happened | the risk manager | in `tra run`: continue, cut size, pause instruments or halt, from measured behaviour; it never enlarges anything |
| read the machinery afterwards | the technology auditor | write findings tied to figures, and propose changes a person applies |
| read the desk's own results afterwards | the quant auditor | the same, over decisions and round trips |
| turn a decision into an order | the executor | caps, the venue's step, the rate budget, the signal file; no discretion |
No model sizes a trade and no model is asked whether the analysts agree. Both are rules over the
operator's own declarations, which is what makes a result reproducible tomorrow.
### Series from anywhere
[`examples/series.toml`](https://github.com/BijectiveLabs/trading-research-agents/blob/master/examples/series.toml) maps external series the way `mapping.toml` maps trades: the file, the
time column and its unit, the value column, the unit, and `publish_lag`, the time between an
observation and the moment it was actually available. A monthly print released two weeks after
month end is unknown for those two weeks, and the brief says so. Thresholds in the map are the
operator's declaration; a correlation is never written in the map, because it is measured on
the operator's data every session and reported with its n and window.
```
tra agent session --macro series.toml --events events.json --onchain onchain.toml
```
A session takes its maps as paths, one flag at a time. A cycle (`tra run`) reads the shelf instead:
`sources/.toml` beside the config, one file per class of data, described in
[the cycle](https://docs.bijectivelabs.dev/opensource/cycle). Same engine, same as-of rule, different way of saying what the desk has.
Fetching those files, and the `max_age` that keeps an old value from counting, are in
[sources](https://docs.bijectivelabs.dev/opensource/sources).
## Cost and latency as numbers
Every model call is one line in `usage.jsonl`: role, provider, model, tokens in and out,
latency, the cost at the declared price, and the outcome. The budget refuses before a call,
never after; a spent budget ends the session flat with `stopped_by` saying so. The analyst is
asked only on bars of a minute or longer, because a call takes seconds and a bar shorter than
that would be decided on stale prints; `decide_every_bars` spaces the calls further apart while
the stops and timeouts keep running on every bar, and `max_output_tokens` caps what a model
may spend thinking, which is most of the latency and the cost of a reasoning model. The
auditor reports tokens per closed round trip, which is the figure that says whether an agent
is worth running on that instrument at all.
### Comparing models on the same bars
```
tra agent eval --model groq:openai/gpt-oss-120b --model deepseek:deepseek-flash --model ollama:qwen3:8b # the newest dataset in data/
```
An eval runs the analyst once per model over one dataset, under the same controls and in the
same order, and prints one row per model: net basis points after costs, round trips, calls,
failed calls, tokens, latency at the median and the 90th percentile, tokens per basis point
earned, and the cost at the declared price. Each model has its own budget and its own
`usage.jsonl` under `runs/eval-/-/`, so one that runs out does not
starve the next; `eval.json` holds every row. A model that earned nothing has no
tokens-per-bps figure. This is not a benchmark score: it is the same replay a session runs,
reduced to what a trader compares before choosing which model to pay for.
### Reading a run back
```
tra agent trace runs/session-1 # or a single run directory
```
A trace is the run's own record put in order: the role answers as they were asked, the
auditor's tool steps with what each returned or refused, every model call on the wall clock
with running tokens and cost, and the intents, fills and round trips on the bar clock. The
two clocks are never mixed; a replay of last January runs today. `--json` returns the same
events for anything that wants to draw them.
## What an agent can reach
The short version for anyone reviewing this before putting money behind it:
| Concern | How it is handled | Where |
|---|---|---|
| Memory | None between calls. Each call is one system prompt plus one brief built by the system; the deciding analyst's brief includes its own last reading, built by code from the record. Earlier cycles are files on disk, and only the auditors read them. | [Working with language models](https://docs.bijectivelabs.dev/opensource/llm), `store/cycles.py` |
| What a role sees | Its own sections of the brief. Anything else is removed and the removal is recorded. | `agents/scope.py` |
| What a role may say | A closed JSON schema per role. Extra fields, a wrong enum, or a number out of range are refused before anyone reads the answer. | `agents//role.py`, `llm/form.py` |
| Whether its figures are real | Every cited figure is checked against the brief, value included. Citations that do not hold are discarded, and a deciding answer with nothing left is refused. | `agents/cited.py` |
| Tools | None in a cycle. In a session the auditor gets five read tools in a loop capped at six steps; every path a model writes has to resolve inside the project, the session or the ladder's directory, and hidden files (`.env`, `.git`, `.tra-history`) are never readable. The MCP server applies the same fence to whatever host calls it (`tra mcp serve --root`). | `agents/react.py`, `surface/confine.py` |
| Orchestration | A fixed pipeline: one parallel round (analysts), then, when validators are declared, the advocates together and the judge after them. Code passes the checked claims from one to the other. No graph to configure, no role calling another, no loop that a model controls. | `agents/cycle.py`, `agents/parallel.py` |
| Spend | Every call is a line in `usage.jsonl`. Declared ceilings (`token_budget`, `max_calls_per_hour`, `min_interval_s`) apply to the whole desk in one run, not to each role. | `llm/usage.py` |
| Credentials | Keys come from the environment: the shell, an `--env-file` you name, or the `.env` beside `strategy.toml`; never from a config file. A vendor or venue error is scrubbed of any credential this process holds before it goes into a record or a brief. | `llm/completer.py`, `trading/dispatch.py` |
| Text from outside | Headlines, notes, vendor messages and another role's reason reach some briefs as material to read. The prompts say so, and more to the point, none of those roles decides: the gates read the operator's declared states and measured figures. The role that proposes a direction reads numbers only. | `agents/prompts/` |
| Orders | No role can place, cancel or modify one. Only the venue hand sends, after every gate, under a signed mandate. | `trading/dispatch.py`, [The cycle](https://docs.bijectivelabs.dev/opensource/cycle) |
A cycle is never resumed. Its answers belong to the snapshot it took, and reusing them against a
newer book would mix two moments in one decision. The next cycle takes its own snapshot.
## What a role cannot do
`promote`, `loosen_limits`, `disarm_kill_switch`, `raise_cap`, `place_order` are not actions
on the surface; a role that asks for one is refused by name, every time, and the refusal is
what it is told. Orders are not on the surface either: they leave the runtime through the
mandate, the controls and the caps (and on mainnet the ladder), or they do not leave at all. A model may decide;
the system executes and refuses.
# Where the analysts' figures come from
The technical analyst reads the venue: candles, the book, funding and open interest, pulled through
`data.toml` on every cycle with their age attached. The macro, on-chain and sentiment analysts read
something else: dated series you declare under `sources/`. This page is about those, and about how
you can tell, on any cycle, that they read a real figure and how old it was.
## The short version
| You want | Do this |
|---|---|
| see what each analyst reads, from where, and how old it is | `tra sources show` |
| refresh every fetched series now | `tra sources pull` |
| keep them fresh while a desk runs | nothing: `tra run` refreshes each fetched series once its `refresh` interval has passed |
| know a cycle did not lean on an old figure | a value older than its `max_age` is shown as `stale` and never counted |
## One file per class of data
```
my-desk/sources/
macro.toml yields, the dollar, inflation prints
sentiment.toml a fear index, a positioning score
onchain.toml venue flows, supply held, active addresses
derivatives.toml funding history, open interest history, basis
news.toml dated text: releases, statements, your own notes
```
Each `[[series]]` names a file (CSV, JSON or Parquet) and how to read it. The analyst only ever reads that file. What
changes from desk to desk is who keeps the file current: you, or `fetch`.
```toml
[[series]]
name = "fear_greed"
file = "sentiment/fear_greed.csv"
ts = "date"
ts_unit = "iso8601"
value = "value"
unit = "index"
source = "alternative.me Crypto Fear & Greed Index"
max_age = "2d"
fetch = { from = "alternative.me" }
threshold = { bullish_above = 60, bearish_below = 25 }
```
## Adding a series in one step
```bash
tra sources add macro us10y --from fred --id DGS10 --high bearish
tra sources add onchain eth_net_flow --from coinmetrics --id FlowInExNtv --minus FlowOutExNtv \
--fetch-asset eth --asset ETH --high bearish --band 10
```
The command pulls the series now, writes its file, and appends a `[[series]]` block to
`sources/.toml` with the fetch, how late the source publishes and how old a value may be
for that source, and a threshold drawn from the series' own last year: the 25th and 75th
percentiles by default (`--band 10` draws the 10th and 90th, `--days` sets the window). The block
says where the lines came from, and they are yours to move.
One thing it cannot work out is which side is good for the trade, so it asks: `--high bullish`
when a high value supports a long, `--high bearish` when it argues against one. Some are settled
(a high US yield weighs on crypto), some are a view (one desk reads high greed as momentum,
another as a warning), and the choice is yours either way. A paid vendor goes in
the same way with `--from http --url ... --rows ... --ts ... --value ... --key-env ...`.
## A series about one asset
A chain figure belongs to a chain. Bitcoin's exchange flows say a good deal about a Bitcoin trade and
less about a Zcash one. Name the asset a series is about, and say how much it counts elsewhere:
```toml
[[series]]
name = "eth_exchange_net_flow"
# ...
asset = "ETH" # full weight on ETHUSDT; left out, a series counts the same everywhere
elsewhere = 0.5 # the share it counts on any other instrument; 0 mutes it there, 1 is full
```
The asset of an instrument is its name without the quote: `ZECUSDT` is `ZEC`. Each row in the brief
carries its asset, and a row counted at less than full says so (`counts`). The change is in the
conviction score only: the analyst still reads every figure, and a series with no `asset` behaves
as it always did.
## What is free, and what is not
Checked against the Coin Metrics community catalog on 2026-10-07, daily frequency, no key:
| Asset | What the community tier carries |
|---|---|
| BTC, ETH | exchange inflow and outflow in coins and in USD (`FlowInExNtv`, `FlowOutExNtv`, `FlowInExUSD`, `FlowOutExUSD`), supply on exchanges (`SplyExNtv`), active addresses, transfer count, MVRV |
| USDT | supply (`SplyCur`), active addresses, transfer count (`TxTfrCnt`); `IssTotUSD` answered zero for every day, so it is not usable |
| XRP, ZEC and most others | supply, transfer count, market cap; no exchange flows |
| SOL | market cap only |
Large-holder and whale figures, per-exchange flows and stablecoin movements between venues are not
in the free tier. They are what a paid provider sells, and the desk reads them the same way: one
`[[series]]` with `fetch = { from = "http", ... }` pointed at that provider, and a threshold. What
the on-chain analyst reads is whatever is declared in `onchain.toml` and `derivatives.toml`; nothing
in the code is tied to a vendor, and a vendor with no declaration is not read.
## Fetching
| `from` | What it is | Key |
|---|---|---|
| `alternative.me` | Crypto Fear & Greed Index, daily | none |
| `fred` (with `id`, such as `DGS10`) | Federal Reserve Economic Data: yields, the dollar, inflation, thousands more | `FRED_API_KEY`, free from fred.stlouisfed.org |
| `coinmetrics` (with `id`, optional `asset`, `minus`) | Coin Metrics community API: exchange flows, active addresses, supply, for many assets; `minus` subtracts a second metric, so inflow less outflow is one net-flow series | none |
| `blockchain.info` (with `id`, such as `n-unique-addresses`) | Blockchain.com charts, Bitcoin only | none |
| `binance-futures` (with `id`, `params`, `value`) | Binance USD-M public data: `globalLongShortAccountRatio`, `takerlongshortRatio`, `openInterestHist`, five-minute steps | none |
| `bybit` (with `id`, `params`, `value`) | Bybit public market data: `account-ratio` (long/short accounts), `open-interest` | none |
| `okx` (with `id`, `params`, `value`) | OKX public statistics: `contracts/long-short-account-ratio`, `taker-volume`; rows are arrays, so `value` is a position such as `"1"` | none |
| `coinglass` (with `id`, `params`, `value`) | CoinGlass API v4: aggregated liquidations, open interest, long/short ratios, taker volume, across venues | `COINGLASS_API_KEY`, sent as `CG-API-KEY` |
| `glassnode` (with `id`, `params`) | Glassnode metrics, such as `transactions/transfers_volume_exchanges_net` with `params = { a = "BTC", i = "1h" }` | `GLASSNODE_API_KEY`, sent as `X-Api-Key` |
| `cryptoquant` (with `id`, `params`, `value`) | CryptoQuant, such as `btc/exchange-flows/netflow` with `window = "hour"` and `value = "netflow_total"` | `CRYPTOQUANT_API_KEY`, sent as a bearer token |
| `arkham` (with `id`, `rows`, `ts`, `value`) | Arkham's API at `api.arkm.com`; the endpoint and its fields come from its reference | `ARKHAM_API_KEY`, sent as `API-Key` |
| `http` | any endpoint that answers JSON over https, including paid on-chain and derivatives vendors | whatever the vendor asks for |
The venue presets were read live on 2026-10-18. The paid presets follow each vendor's published
reference (CoinGlass's v4 endpoints, Glassnode's `t`/`v` rows, CryptoQuant's `result.data` with a
bearer token, Arkham's `API-Key` header); none has been run against a paid key here, so the first
`tra sources add` with your key is the check. `params` is the query a preset leaves to you:
```toml
fetch = { from = "coinglass", id = "futures/liquidation/aggregated-history",
params = { symbol = "BTC", interval = "1h", exchange_list = "Binance,OKX,Bybit" },
value = "aggregated_short_liquidation_usd", minus = "aggregated_long_liquidation_usd" }
```
On the command line it is one `--param` per field:
```bash
tra sources add derivatives btc_long_short --from bybit --id account-ratio \
--param category=linear --param symbol=BTCUSDT --param period=5min --param limit=500 \
--value buyRatio --asset BTC --high bearish
```
A paid vendor is one line. Take the path, the field names and where the key goes from the vendor's
own docs:
```toml
fetch = { from = "http", url = "https://api.your-vendor.example/v1/metric?asset=BTC",
rows = "data", ts = "t", ts_unit = "s", value = "v",
key_env = "VENDOR_API_KEY", key_header = "Authorization", key_prefix = "Bearer " }
```
| Field | Meaning |
|---|---|
| `url` | https only; a redirect off https is refused |
| `rows` | where the list of rows sits in the answer, dotted (`data`, `result.points`); empty when the answer is the list |
| `ts`, `ts_unit` | the time field and its unit: `s`, `ms`, `us`, `ns` or `iso8601` |
| `value` | the number field; a row whose value is not a number is skipped, never guessed |
| `key_env` | the environment variable holding the key (shell, `--env-file`, or the `.env` beside `strategy.toml`) |
| `key_param` | put the key in the query string under this name |
| `key_header`, `key_prefix` | or put it in this header, after this prefix |
A pull merges into the file, so history you already had stays. A pull that fails leaves the file as
it was and says why, with the key blanked out of the message. The desk keeps running; the file's age
decides whether its last value still counts.
## News and public sentiment
A `[[news]]` entry can fetch too: an RSS or Atom feed, or any JSON news API. The headlines land in
the entry's file as `when,what,source` rows, merged with what it held, and anything older than
`keep` is let go. The macro analyst reads them as events and the sentiment analyst as notes.
```toml
[[news]]
name = "coindesk"
file = "news/coindesk.csv"
fetch = { from = "rss", url = "https://www.coindesk.com/arc/outboundfeeds/rss/" }
[[news]]
name = "reddit_cryptocurrency"
file = "news/reddit_cryptocurrency.csv"
source = "r/CryptoCurrency"
refresh = "15m" # the default; keep = "7d" is the other
fetch = { from = "rss", url = "https://www.reddit.com/r/CryptoCurrency/top/.rss", params = { t = "day" } }
[[news]]
name = "wire"
file = "news/wire.csv"
fetch = { from = "http", url = "https://api.your-news.example/posts/", rows = "results",
ts = "published_at", text = "title", source = "source.title",
key_env = "NEWS_API_KEY", key_param = "auth_token" }
```
A headline is stored as one plain line: markup and entities resolved, control characters removed,
at most 280 characters. A feed that declares a document type is refused, so no entity is ever
expanded. Fetch only what the service's terms allow you to fetch.
A reading analyst whose series declare nothing, which is what a file of headlines is, has no
declared state for conviction to read; its label is recorded and counts for nothing. To let it
count, give it a strength:
```toml
[conviction]
reader_labels = 0.5 # 0 (the default) keeps labels out; 1 counts a label like a full reading
```
The label counts only when the analyst cited what it read; an answer that points at nothing is
still nothing.
## Derivatives positioning
Series in `sources/derivatives.toml` (long/short ratios, taker volume, open interest history,
liquidations) are read by the on-chain analyst beside the chain figures, and your thresholds on
them reach confluence and conviction like any other series. The technical analyst does not see
them: it reads its own market's candles, book, funding and open interest, so its reading does not
lean on another analyst's data.
## When an analyst is not asked
A reading analyst with nothing declared for it is not paid to say it saw nothing. It still has a
row in the cycle table, marked `not asked`, with the reason: nothing declared in
`sources/onchain.toml` or `sources/derivatives.toml`, not in `[agents].analysts`, every series past
its `max_age`, or every file unreadable. A role asked but missing from `[conviction].weights` is
flagged by `tra check`: it answers, and its answer moves nothing.
On-chain figures for Bitcoin are read as market-wide context for every instrument a desk trades,
the way a yield is. A desk that wants a chain per instrument declares one series per asset.
## Freshness
`max_age` is measured from the moment a value describes, not from when the file was written. Past
it, the value is still shown in the brief, in the cycle's `series read` table and in `cycle.json`,
marked `stale`, and it is left out of the declared state, the confluence matrix and conviction. A
role whose every series is stale, with no dated text either, is not asked at all: there would be
nothing for it to read that could count. The default is four days; set it to what your feed
actually delivers. A stale series never stops the desk: `tra check` lists it as a note, not
something to fix, and a fetched series is pulled again by the next `tra run`. A weekly series such as
the broad dollar index needs a `max_age` of at least a week plus its publication lag, or it will be
stale for part of every week.
`publish_lag` is the other half: how long after the moment it describes a value became knowable.
A daily yield posted after the close is `1d`; a monthly print released two weeks later is `14d`. A
value is never used before that.
## What each cycle shows
The cycle panel has a `series read` table: the analyst, the series, the value, the moment it
describes, its age in hours, the state your threshold gives it (or `stale`), and where it came from.
`--plain` prints the same lines, and `cycle.json` keeps them under `series`. `tra check` lists every
declared series the same way, and names the stale ones as things to fix.
# Your own data
Most desks already have data: a vendor subscription, a recorder of their own, years of exports.
None of it has to be reshaped by hand to fit this package. You say what each column is and what
unit it is in, and the package does the rest: it reads the file, turns every row into its own
shape, checks it, and tells you about every row it could not use. An agent never reads your file
directly. It reads the figures the system computed from it, with their age and where they came from.
## What goes where
| You have | Formats | Where it goes | What happens to it |
|---|---|---|---|
| trade history (prints) | CSV, `.csv.gz`, a `.zip` of one CSV, Parquet | `tra data audit FILE --mapping mapping.toml` | each row becomes a canonical trade, the file is audited, and a clean dataset is written as Parquet with its manifest. Backtests, research sessions and the `file:data` link of a live chain read it from there |
| a dated series: rates, flows, a sentiment score, funding history, anything with a time and a value | CSV, JSON, Parquet | one `[[series]]` entry in `sources/.toml` | read with the publication lag you declare, so a figure counts only from when it was knowable; graded against your thresholds; shown with its age; ignored past its `max_age` |
| dated text: releases, statements, your own notes | CSV, JSON | `sources/news.toml` | read as events by the macro analyst and as notes by the sentiment analyst |
| a live feed from a data vendor: candles, the book, funding, open interest | anything your code can read | a source plugin, named in a chain in `data.toml` | asked like any other source, its answer stamped with its age and origin, refused when older than the chain's `ttl` |
## How it is stored
Whatever shape a file arrives in, the package keeps one shape of its own, and where the data is
large it keeps it in a columnar store:
| Data | Arrives as | Kept as |
|---|---|---|
| trade history | a venue archive, a CSV export, your own Parquet | the canonical trade, one Parquet file per dataset under `data/`: prices and sizes as `decimal128` at the scale the source used, time as UTC nanoseconds, `zstd` compressed, with a `manifest.json` beside it (source files and their SHA-256, row counts, the audit's findings) |
| candles for a cycle | built from that Parquet, or fetched live | in the cycle's `snapshot.json`, with the source and age of each value |
| dated series and text | CSV, JSON, Parquet | read in place, through the as-of rule, on every cycle; they are small, and a file you correct is read as corrected on the next cycle |
| what the desk decided | written by the desk | one directory per cycle (briefs, answers, intents, orders) as JSON, and one index line per cycle per day |
There is no database server to run. The datasets are plain Parquet, so the same files open in
DuckDB, Polars, pandas or Spark for your own analysis, without an export step:
```sql
-- DuckDB, over every dataset of one instrument
SELECT date_trunc('hour', ts) AS hour, count(*) AS trades, sum(qty) AS volume
FROM 'data/BTCUSDT-*.parquet' GROUP BY 1 ORDER BY 1;
```
## The rules every file is held to
These are the same for a venue's archive, a vendor's export and a file you wrote yourself:
- **Every unit is declared.** A timestamp column says whether it is seconds, milliseconds,
microseconds, nanoseconds or ISO 8601. Nothing is inferred from the size of a number.
- **A time needs a zone.** An ISO time with no offset is refused, not assumed to be UTC. A bare
date (`2026-10-01`) is the one exception: it is read as UTC midnight, and the publication lag
moves it to when the figure was actually known.
- **Prices and sizes stay exact.** They are read as decimals with the precision the source used,
stored as `decimal128` in Parquet, and never rounded. A value that cannot be stored exactly at
the declared scale is refused.
- **A bad row is shown, not dropped.** A row whose time, price, size or side cannot be read
becomes a rejection with its row number and the reason. Above 0.1 percent of rejected rows the
audit fails. A missing column stops the file with the header it actually had.
- **Zero is not a value.** A trade with a zero price or size is a defaulted field, and refused.
- **A row about something else is refused.** With an instrument column declared, a row for
another instrument is a rejection, not a silent mix.
## Trade history
A `mapping.toml` names each field:
```toml
[mapping]
source = "vendor-x" # recorded as the trade's venue
ts = "time"
ts_unit = "ns" # s | ms | us | ns | iso8601
price = "px"
qty = "size"
trade_id = "id"
side = { column = "aggressor", buy = ["B"], sell = ["S"] }
# instrument = "symbol" # optional; rows for another instrument are rejected
```
```bash
tra data audit vendor.parquet --mapping mapping.toml --instrument BTCUSDT --max-gap 5m \
--manifest data/manifest.json --parquet data/BTCUSDT-2025-01-01.parquet
```
```
vendor-x BTCUSDT: 2000 trades, 0 rejected
2025-01-01 00:00:00Z to 2025-01-01 00:33:19Z
wrote 2000 trades to data/BTCUSDT-2025-01-01.parquet (decimal scales 2/3)
PASSED
```
The audit checks order, duplicates, gaps, sequence resets and changes of scale; the full list is
in [The data gate](https://docs.bijectivelabs.dev/opensource/data#the-audit). The dataset is written only when the audit passed.
## Parquet
A Parquet file carries types that a CSV leaves to the map, so each type is read the way its
meaning requires and then goes through the same map as a CSV:
| Parquet type | Read as | Declare |
|---|---|---|
| timestamp with a time zone | nanoseconds since the epoch, exact | `ts_unit = "ns"` |
| timestamp without a zone | a time with no offset, refused by every `ts_unit` | store it with its zone |
| date | `YYYY-MM-DD`, UTC midnight before the publication lag | `ts_unit = "iso8601"` |
| decimal | its digits as stored, scale kept | |
| float | the shortest text that reads back as the same float | |
| null | an empty cell; a row that needs it is rejected | |
Reading Parquet needs the `data` extra (`pip install 'trading-research-agents[data]'`).
## Dated series
```toml
[[series]]
name = "us10y"
file = "macro/us10y.parquet" # .csv, .json or .parquet
ts = "date"
ts_unit = "iso8601"
value = "yield"
unit = "percent"
publish_lag = "1d" # known the day after the date it describes
source = "your vendor"
max_age = "4d"
threshold = { bearish_above = 4.5, bullish_below = 3.5 }
```
How series reach the analysts, how they are refreshed and how their age is shown is in
[Data sources](https://docs.bijectivelabs.dev/opensource/sources).
## A live feed from a vendor
A vendor that answers in real time arrives as a source plugin: a small class in your own package,
registered under the `trading_research_agents.sources` entry point and then named in a chain like
any bundled source. This package never depends on the vendor.
```toml
# pyproject.toml of your plugin package
[project.entry-points."trading_research_agents.sources"]
vendorx = "your_package.vendorx:source"
```
```python
import time
from decimal import Decimal
from trading_research_agents.data.schema import Candle
from trading_research_agents.data.sources.base import Fetched, SourceError
class VendorX:
name = "vendorx"
tier = "paid"
def kinds(self) -> tuple[str, ...]:
return ("ohlcv",)
def fetch(self, kind: str, instrument: str, **options) -> Fetched:
rows = your_client.candles(instrument, options["interval_s"], options["limit"])
if not rows:
raise SourceError(f"vendorx has no candles for {instrument}")
candles = [
Candle(r.start_ns, Decimal(r.o), Decimal(r.h), Decimal(r.l), Decimal(r.c), Decimal(r.v))
for r in rows
]
closed = candles[-1].start_ns + options["interval_s"] * 1_000_000_000
return Fetched(kind, instrument, candles, self.name, self.tier, time.time(), closed)
def source(target: str, settings: dict) -> VendorX:
return VendorX()
```
```toml
# data.toml
[data.ohlcv]
chain = ["vendorx", "venue", "file:data"]
ttl = "90s"
```
The source does the normalising: whatever the vendor sends, what comes back is the package's own
shape, with the market time the value describes. In venue mode the book and the funding rate must
come from the venue the orders go to, so a plugin that serves them says which venue with a `venue`
attribute, and one that does not say is refused for those two kinds. Candles and open interest may
come from anywhere you choose, and `tra check` names the source each kind comes from.
## Not covered yet
- A file of candles alone, without the trades they were built from, as a dataset. Today candles
for a backtest are built from trades; a live chain can take candles from a source plugin.
- Order book history from a file.
Both are on the roadmap. Until then, a plugin covers live candles and books from any vendor.
# Capital, orders and positions
This is what venue mode does with your money, step by step: what is read from the account, what
is checked before an order goes out, and what happens to a position after it fills. All of it is
done by the system. No model reads an account, chooses a leverage, sizes a trade or closes a position.
Everything here goes through ccxt's unified calls, so it behaves the same on any venue that answers
them. A venue that cannot answer one gets a named refusal or a note in the record, never a guess.
## Who decides what
| Decision | Who | How |
|---|---|---|
| direction, confidence, horizon | the technical analyst | inside its schema, from figures the system computed |
| how much behind the idea | the conviction rule | your weights and ladder over every voice ([The cycle](https://docs.bijectivelabs.dev/opensource/cycle)) |
| the most it may lose at the stop | your risk rule | `[controls].max_risk_per_trade_bps` against equity |
| leverage and margin mode | you | `[execution].leverage`, `[execution].margin_mode` |
| when a position is closed by time | you | `[execution].hold_limit` |
| whether the account can carry it | the system | the checks below |
On a spot market none of the leverage, margin or liquidation settings apply: the order is paid in
full, and must fit in `max_margin_share` of the free quote balance with its fee.
Leverage never makes a trade bigger. The size comes from the risk at the stop. Leverage only
decides how much margin that size ties up and how far away liquidation sits.
## Settings
| Setting | Default | What it does |
|---|---|---|
| `[execution].leverage` | none: required in venue mode | applied at the venue before the first order on a symbol, then read back |
| `[execution].margin_mode` | `isolated` | `isolated` or `cross`; isolated keeps one position from drawing on the rest of the account |
| `[execution].max_margin_share` | `0.5` | the most of the free margin one order may use |
| `[execution].liquidation_buffer_bps` | `50` | how far beyond the stop the estimated liquidation must sit |
| `[execution].max_slippage_bps` | `20` | how far through the touch a taker entry may fill |
| `[execution].max_drift_bps` | none | refuse when the mid moved this far against the trade since it was priced |
| `[execution].max_decision_age_s` | `120` | refuse a decision older than this, counted from the start of its cycle to the moment of sending |
| `[execution].hold_limit` | `timeout` | `timeout`, `horizon` or `none`: when time closes a position |
| `[execution].equity_session` | `regular` | `regular`: an entry on an equity underlying only while the US regular session trades, and no hold past its close; `any`: every hour ([Venues](https://docs.bijectivelabs.dev/opensource/venues#stocks-on-a-crypto-venue)) |
| `[execution].equity_closed` | none | exchange holidays, as dates; the package carries no calendar |
| `[execution].equities` | none | instruments to treat as equities on a venue that does not mark them |
| `[execution].markets` | `["linear perpetual"]` | the market types this desk may send to; `linear perpetual`, `spot` and `prediction` are built ([Venues](https://docs.bijectivelabs.dev/opensource/venues#where-an-order-goes-and-what-an-instrument-is)) |
| `[execution].max_notional` | none: required | the most one order may be worth |
| `[run].equity` | none | the most equity you allow this desk; sizing uses the smaller of this and the account |
| `[controls].daily_loss_limit_bps` | `300` | no new entry once equity has fallen this far on the day |
| `[controls].max_drawdown_bps` | none; `tra init` writes `1000` | no new entry once equity has fallen this far from its highest reading, across days |
| `[controls].on_drawdown` | `stop` | `flatten` also arms the kill switch, which flattens every position and stops the run |
| `[controls].max_orders_per_minute` | `6` | counted across every order the desk sends |
`tra check` prints what each of these will do in your project, in plain words, before anything
runs. `tra venues NAME --setup` writes the leverage lines for a new venue.
## Before an order: the account
At the start of every cycle in venue mode, before any model is asked:
- **Equity** is the account's wallet balance in the settlement currency plus the unrealised result
of its open positions.
- **Sizing equity** is the smaller of that and `[run].equity`. The declared figure is what you
allow this desk; the account figure is what is actually there. Sizing against the larger would size
against money that is either not allowed or not present.
- **Daily loss.** The first equity reading of each UTC day is written to `equity.jsonl`. Every later
reading is measured against it, and once the drop reaches `daily_loss_limit_bps` the cycle stops
before any model is asked. Open positions keep their exits. Deposits and withdrawals move equity
too, so they count as gains and losses here.
- **Drawdown.** The highest equity reading is kept in `equity_peak.json` and only ever rises.
Once equity sits `max_drawdown_bps` below it, the cycle stops before any model is asked, every
cycle after it too, across days. Open positions keep their exits. To start again, look at what
happened, then delete `runs/cycles/equity_peak.json`: the next reading is the new peak. With
`on_drawdown = "flatten"` the cycle also writes the kill switch file, and the next pass flattens
every position and stops the run. Stop is the default because flattening in a fast market
locks in the worst price of the move; flatten is there for a desk that would rather be out.
The daily loss limit and the drawdown limit each wake the quant auditor once per UTC day.
The figures are in `cycle.json` under `equity`: declared, venue, free, used, the day's loss, and the
drawdown with its peak.
## When it is sent: the order
The venue hand runs these checks in this order, and the first one that fails refuses the order
with a sentence naming the figure:
1. the intent is about this hand's instrument, and this hand has not already sent its one order;
2. the size is positive, and the notional is inside `max_notional`;
3. this machine's clock is within a second of the venue's;
4. the account holds no position on the symbol (unless `adopt_open_position`), and no order is
working on it;
5. the size is on the venue's step and above its minimum;
6. the decision is younger than `max_decision_age_s`, and the mid has not drifted against the trade
past `max_drift_bps`, when that is declared: the book is read again just before sending;
7. **leverage and margin mode** are set at the venue and read back. A venue that answers "not
modified" is fine, because success is judged by reading the setting back. A venue holding any
other value refuses the order;
8. **margin**: `notional / leverage + notional * taker fee` must fit inside
`max_margin_share * free margin`;
9. **liquidation**: the isolated estimate `entry * (1 - (1/leverage - maintenance))` for a long,
`entry * (1 + (1/leverage - maintenance))` for a short, must sit beyond the stop by
`liquidation_buffer_bps` of the stop price. The maintenance rate is the venue's first tier, or
a deliberately high 1% when the venue will not say. The isolated figure is the conservative one:
a cross position draws on the whole account and is liquidated later;
10. the desk's order rate is inside `max_orders_per_minute`;
11. the stop and take profit can be attached to the entry.
Then the order goes out. A taker entry is a **limit IOC** at most `max_slippage_bps` through the
touch, so a thin book cannot fill it at any price it likes; what it filled is read back from the
venue and recorded. A maker entry rests post-only at the touch as the book stands at send time.
What the account looked like is in `placed.json` under `capital`: margin needed, free margin,
equity, leverage, margin mode and the liquidation estimate.
Two examples of a refusal, as the record writes them:
```
this order needs 333.88 USDT of margin at 3x and 0.5 of the free 500.00 is 250.00; nothing was sent
at 80x liquidation is estimated at 99250.00, inside the stop at 99000 plus 50 bps; a stop past
liquidation is not a stop, so lower [execution].leverage
```
## While it is open: the position
At the start of every pass of `tra run` in venue mode, before the market is looked at, each
instrument the desk trades is read from the venue and matched to the newest order this desk sent
that the venue filled. Then exactly one of these happens:
| What the venue shows | What happens | Recorded as |
|---|---|---|
| the kill switch file exists | every position is closed with a reduce-only market order, every working order is cancelled, and the run stops | `flattened` |
| a position older than its hold limit | it is closed with a reduce-only market order | `closed_by_time` |
| no position, though our record says one was open | the venue's stop or take profit closed it; the result is written up | `closed_by_venue` |
| a position inside its hold limit | nothing; new entries on it wait for its exits | - |
Hold limits:
| `hold_limit` | A position is closed after |
|---|---|
| `timeout` | `[exit].timeout_bars` bars |
| `horizon` | the analyst's own horizon, capped by `[exit].timeout_bars` |
| `none` | never by time; only its stop or take profit closes it |
Exits are never price-limited: a close that fails is worse than one that slips. Reduce-only means
the order can only shrink the position, never open or flip one.
A position this desk did not open - one placed by hand at the venue, say - has no order of ours to
match, so the keeper leaves it alone; the venue hand still refuses to add to it.
An entry the venue never confirmed as filled is looked after while the venue shows a position for
it, so its risk stays on the book and its hold limit applies. It is never written up as a finished
trade on its own word.
## The records
| File | One line per | Carries |
|---|---|---|
| `runs/cycles/positions.jsonl` | close, flatten, or venue exit | instrument, event, the entry's order id and cycle, the close order's id, the reason |
| `runs/cycles/round_trips.jsonl` | finished trade | entry, exit, size, gross, fees, net, funding, how it closed, the stop and target, `loss_at_stop_bps`, `r` and the excursion ([Sizing from the record](https://docs.bijectivelabs.dev/opensource/sizing)) |
| `runs/cycles/equity.jsonl` | UTC day | the first equity reading of the day |
| `runs/cycles//placed.json` | order | what was sent, what filled, and the account figures at the time |
A round trip is built from the venue's own fills: this position's entry side before its first
exit, and exits up to its size. A later entry on the same instrument belongs to the next position.
Fees are what the venue charged. Funding is reported as the venue signs it and kept out of `net`,
which is price and fees, because venues differ on the sign. Exit fills can take a few seconds to
appear after a close; the keeper waits a little, and a trip written before its exit shows up is
finished on a later pass, as one more line and never a copy.
The quant auditor reads `round_trips.jsonl`. It is the only place a desk's results are measured
from what actually filled rather than from what was intended. Each finished trade also carries its
result in planned losses (`r`) and how far it went for and against the position, from the venue's
own candles over the hold, so neither the auditors nor `[sizing]` have to reconstruct either.
## Limits worth knowing
- **The keeper acts only while a desk runs.** Time exits and the kill switch happen at the start
of each pass of `tra run --every`. With no desk process, a position has its venue-side stop and
take profit and nothing else. A desk meant to run unattended belongs under a supervisor.
- **One desk per project.** In venue mode `tra run` takes `runs/cycles/desk.lock` and refuses to
start while another live process holds it. Two desks sending from the same project would read
the same account in the same second and could both decide an instrument is free. A lock left by a
process that died is taken over. Two different projects pointed at the same account are not
covered by this lock; give each desk its own account or sub-account.
- **Margin mode can be account-wide.** On some venues setting it changes every symbol on the
account, not only the one being traded. Choose it with the whole account in mind.
- **One desk, one venue.** Netting across venues, transfers between accounts and a book managed
across a portfolio belong to a platform, not to this package ([Boundaries](https://docs.bijectivelabs.dev/opensource/boundaries)).
## What each order records about its timing
Every sent order writes three figures into `placed.json`, measured live rather than declared:
`decision_age_s` (from the start of the cycle to the moment of sending), `send_ms` (how long the
venue took to answer the order), and `slippage_bps` (the average fill against the mid the trade was
priced at; positive means it paid more). The cycle output prints them on a `timing:` line. The
latency figures in `envelope.toml` and `[run]` are declarations used to price a trade before it
exists; these are what actually happened.
# Sizing from the record
Every trade here has a stop and a target, and every finished trade is written up from the venue's own
fills. That makes two things possible that a trading agent usually cannot do honestly: measure what
the desk's trades actually return, in the same units every time, and let that measurement lower the
risk the next trade takes. No model is involved. The arithmetic below is all of it, and `tra sizing`
prints it for your desk.
It is off until you turn it on, and once on it can only lower the risk you declared in
`[controls].max_risk_per_trade_bps`, never raise it.
```toml
[sizing]
kelly_fraction = 0.25 # a quarter of the Kelly fraction; turning this on is what enables it
# min_round_trips = 30 # finished trades before the record decides anything
# lookback_round_trips = 100 # only the newest trades count
# simulate_trades = 100 # how far ahead the drawdown is simulated, 10 to 300
# simulate_paths = 2000 # 100 to 5000, so a cycle waits seconds at most
# seed = 20261021 # the same seed gives the same figures on any machine
# max_drawdown_probability = 0.05
```
## The unit: what the trade planned to lose
When a cycle writes an intent, it also writes `loss_at_stop_bps`: the distance from the price to the
stop plus the round trip it pays, in basis points of the notional. That is what the trade loses if the
stop is hit as priced. When the trade finishes, its net result is divided by that figure and written
on the round trip as `r`. A stop hit as priced is about -1. A target twice as far as the stop is
about +2 less the cost. A time exit lands wherever it lands.
`r` is price and fees, like `net`; funding is recorded beside it as the venue reports it, because
venues sign it differently.
Reading results in planned losses makes trades of different sizes, instruments and stop distances
comparable, and it is the unit the risk limit is already in: risking 50 bps at the stop and getting
`r = -1` costs the account 50 bps.
## Kelly from the record
From the newest `lookback_round_trips` trades with an `r`, three figures: the share that won (p), the
share that lost (q), and the average win and loss in planned losses (W and L). A trade that closed
flat counts in neither. The fraction of equity to lose on a losing trade that maximises long-run
growth is then:
```
f* = (p * W - q * L) / ((p + q) * W * L)
```
With every trade a win or a loss this is the familiar `p / L - q / W`. As arithmetic only: half the
trades winning 2 and half losing 1 gives `f* = (0.5 * 2 - 0.5 * 1) / 2 = 0.25`, a quarter of equity
at the stop. Nobody should bet that, and the record does not ask you to:
- **Only `kelly_fraction` of it is applied.** p, W and L are estimates from a few dozen trades, and full
Kelly on an overstated edge loses accounts. A quarter is a common choice; 0.25 of 0.25 is 625 bps.
- **It is a ceiling.** The risk a cycle uses is the smaller of this and your declared
`max_risk_per_trade_bps`. With 50 bps declared, the ceiling above changes nothing.
- **Too few trades decide nothing.** Below `min_round_trips` the declared risk stands, and the cycle
records how many trades the record holds.
- **No edge refuses.** With enough trades, an f* at or below zero means the record supports no bet,
and the cycle refuses new entries with the figures behind it. The same when none of the trades won.
A person decides what happens next; lowering or removing `[sizing]` is a configuration change.
Beside f*, the record also gives the win rate it supports at 95% confidence (the lower Wilson bound),
so you can see how much of the win rate is the sample.
## The drawdown ahead: a seeded Monte Carlo
Kelly is about the average. The path matters too, and `[controls].max_drawdown_bps` is where the desk
stops taking entries. So the cycle also asks: at this risk per trade, how likely is the desk to fall
through the room it has left under that limit in the next `simulate_trades` trades?
It answers by resampling. It draws `simulate_trades` results, with replacement, from the same trades
the Kelly figures came from, compounds them at the risk being tested, and repeats that
`simulate_paths` times. The share of paths whose drawdown reaches the room left is the chance it
reports. If the Kelly ceiling keeps that chance within `max_drawdown_probability`, it stands;
otherwise the risk is lowered, by bisection to a hundredth of a basis point, to the largest value that
does. The room is your limit minus the drawdown the account already has from its highest equity,
read at the start of the cycle, in whole basis points rounded down.
It is repeatable to the digit:
- the draws come from a generator seeded with `seed`, and the same draws are reused at every risk
level tried;
- the draws are integers taken from the generator's own numbers, so nothing depends on how a Python
version implements random choice;
- percentiles are nearest-rank in integer arithmetic.
The same record, settings and seed give the same answer on any machine, and the cycle writes the
seed, the paths, the risk and the figures into its record, so anyone can run it again. Each answer
is also kept in memory, so a desk asking the same question every cycle does not simulate it again.
What it is not: a forecast. Resampling assumes the next trades look like the last ones. It is a
check on how hard the recent record could push the account at a given risk, which is the question a
drawdown limit is there to answer.
## Excursions, measured by the system
Every round trip that closes in venue mode now also carries how far it went for and against the
position before it closed, read from the venue's own candles over the hold. The finest candles that
cover the hold in at most 1000 bars are used, one minute for most holds.
| Field | What it is |
|---|---|
| `mfe_bps` | the best the position stood at, in bps of the entry, positive when it was in front |
| `mae_bps` | the worst, negative when it was under water |
| `bars_to_mfe`, `bars_to_mae` | on which bar each happened |
| `mae_of_stop` | the worst as a share of the stop distance: 0.9 is a stop that nearly fired |
| `mfe_of_take` | the best as a share of the target distance: over 1 is a target that was reached |
| `timeframe`, `complete` | the candles read, and whether they covered the whole hold |
The first and the last bar can include prices from just before the entry and just after the exit,
which is why the finest candles are used. The quant auditor and the risk manager read these figures;
they no longer have to infer the shape of a trade from its result.
## Where it shows
| Where | What |
|---|---|
| the cycle record, `sizing` in `cycle.json` | the rule, the measured figures, f*, the ceiling, the simulation and why |
| `intents.jsonl` | `loss_at_stop_bps` on every intent |
| `round_trips.jsonl` | `loss_at_stop_bps`, `r`, `stop`, `take` and `excursion` on every finished trade |
| `tra sizing` | the same calculation the cycle makes, from the records, without changing anything |
Trades written before this version have no `loss_at_stop_bps`, so they are not counted: the record
starts with the first trade that knows what it planned to lose.
# Venues
A cycle reaches any venue ccxt knows. The replay and live-session paths use plugins, and the ones
that ship (Bybit for history, the live feed and `tra live`; Binance for history) are found the same
way a third-party one is: through a registry. Nothing in the gates, the chain, the runtime or the
agents names a venue, and a test fails the build if one does.
Most people want the next two sections: which venues work today, and how to add the one you trade
on. The plugin contract further down is for a venue ccxt does not know.
There are three plugin kinds: archived data in, live data in, orders out.
| Kind | Protocol | Registry | Entry point group | Used by |
|---|---|---|---|---|
| Archive venue | `data.venues.ArchiveVenue` (+ optional `FundingSource`) | `data.venues.register` | `trading_research_agents.venues` | `tra data fetch`, `tra data audit --venue`, `tra data funding` |
| Live feed | `trading.feeds.LiveFeedFactory` | `trading.feeds.register` | `trading_research_agents.feeds` | `tra paper`, `tra live` |
| Live venue | `trading.venues.LiveVenueFactory` | `trading.venues.register` | `trading_research_agents.live_venues` | `tra live` |
Data that comes from somewhere other than a venue's public archive (a vendor export, a
Client's own recorder) does not need a plugin at all: a column map takes it through the same
gate. See [The data gate](https://docs.bijectivelabs.dev/opensource/data), section "Any other source".
## Which venues work today
A cycle reaches a venue through ccxt, so "which venues are supported" has two honest answers: what
we have run end to end, and what ccxt says a venue can do. The second is read from the library by
`tra venues`, with no network and no key.
| Venue | Type | What a cycle can do there | Practice environment | Keys in `.env` | Run end to end by us |
|---|---|---|---|---|---|
| Bybit | exchange | everything: orders with the stop and take profit attached | demo, testnet | `BYBIT_API_KEY`, `BYBIT_API_SECRET` | yes, on the demo API: real orders, exits attached, a resting order cancelled |
| OKX | exchange | everything | testnet | `OKX_API_KEY`, `OKX_API_SECRET`, `OKX_API_PASSPHRASE` | yes, on the demo API: real orders, exits attached, closed by the venue's own exits |
| Bitget | exchange | everything, on a classic or a unified trading account; a unified account key is recognised and sent through Bitget's unified API ([below](#bitget-s-unified-trading-account)) | demo | `BITGET_API_KEY`, `BITGET_API_SECRET`, `BITGET_API_PASSPHRASE` | yes, on the demo API with a unified account: entries with exits attached, a resting order cancelled, a position closed by the venue's own stop, the kill path |
| BingX | exchange | everything; the demo (VST) is reached with the account's own key ([below](#bingx-the-demo-uses-your-account-s-own-key)) | testnet | `BINGX_API_KEY`, `BINGX_API_SECRET` | yes, on the demo API (VST): entries with exits attached, a refused second entry, a reduce-only close, a cancelled post-only order, a close by the venue's own exit, the kill path |
| Binance | exchange | orders, with the stop and take profit placed right after the fill (`exits = "separate"`); `binance` trades USD-M futures for a futures symbol | demo, testnet | `BINANCE_API_KEY`, `BINANCE_API_SECRET` | yes, on the demo API: entries, exits placed after the fill, a close that takes them off, a cancelled post-only order, the kill path. On 2026-10-20 the demo's order book was stamped hours old, and orders are refused until it is current |
| Gate | exchange | orders, exits placed right after the fill (`exits = "separate"`) | testnet | `GATE_API_KEY`, `GATE_API_SECRET` | yes, on the testnet API: entries with exits placed after the fill, a refused second entry, a close that took them off, a cancelled post-only order, a close by the venue's own exit, the kill path |
| WEEX, BloFin, WOO X | exchange | everything | testnet | `_API_KEY`, `_API_SECRET` (and `_API_PASSPHRASE` for WEEX and BloFin) | ready for its run |
| Hyperliquid | decentralised | everything; leverage is confirmed by the venue's reply, since ccxt cannot read it back | testnet | `HYPERLIQUID_WALLET_ADDRESS`, `HYPERLIQUID_PRIVATE_KEY` | ready for its run; [report wanted](https://github.com/BijectiveLabs/trading-research-agents/issues/7) |
| Pacifica | decentralised | everything | testnet | `PACIFICA_PRIVATE_KEY` | ready for its run |
| WOOFi Pro, ModeTrade | decentralised (Orderly Network) | everything | testnet | `_API_KEY`, `_API_SECRET`, `_ACCOUNT_ID` | ready for its run |
| KuCoin Futures, Deepcoin | exchange | everything | none | `_API_KEY`, `_API_SECRET`, `_API_PASSPHRASE` | integrated; no practice environment to run on |
| dYdX, Paradex, Extended, Lighter | decentralised | orders, but no stop or take profit at the venue through ccxt (`exits = "none"`); dYdX also cannot set leverage or report fills | testnet | see `tra venues` | not for a desk that needs its exits at the venue |
| Deribit, Crypto.com, Coinbase International | exchange | orders, but leverage cannot be set through ccxt, so a derivative desk refuses, and no exits at the venue | testnet | see `tra venues` | not for a derivative desk |
| HTX, MEXC, CoinEx and others | exchange | orders with `exits = "none"` | none | see `tra venues` | not run |
| Kraken, Kraken Futures, Phemex; Aster, Apex, GRVT, Derive | exchange; decentralised | data for now: ccxt does not report their positions, and the venue hand will not send without one | | | |
No venue is blocked here. The table says two separate things: what ccxt reports a venue can do,
and what we have run ourselves. A venue ccxt says takes orders can be used; the only one the desk
declines is a venue it cannot read the account of, because sending into a position nobody can see
is the one mistake that cannot be undone. Everything else is a limit we state, not a gate we add.
"Everything" means ccxt reports what the venue hand needs: send an order, attach the stop and
take profit to it, read the position and the open orders, cancel, read what became of an order,
read the balance, set the leverage and read the fills. `tra venues` names any of these a venue
lacks. The table above was read from ccxt 4.5.85 on 2026-10-19, and each practice environment was
asked for its markets and an order book with no key: all answered except WOO X's staging API,
which did not respond that day, and the Orderly Network venues, which ask for a key even to list
markets. "Run end to end"
is the stronger claim, a full run with real orders on the venue's demo or testnet API, and Bybit and
OKX are the two that have had it so far, both on their demo APIs. Every other venue that takes
orders is integrated the same way and ready for that run at any time: start it on its practice
environment, watch a few cycles, and read `placed.json` before you point it at money. A report of
that run, from us or from you, moves the venue into the last column.
The default project (`tra init`) prices Bybit and Hyperliquid channels in `envelope.toml`, reads
data from Bybit with Binance behind it in `data.toml`, and trades `bybit/taker-taker` in the
sandbox, where nothing leaves the machine.
## Where an order goes, and what an instrument is
Two things are separate in a desk. `[execution].venue` (and the venue in `[run].venue_key`) is where
orders go, where fees are priced, where positions are read and where discovery looks for
instruments. The chains in `data.toml` are only where candles and books come from, and they may be
other venues. The venue that executes decides what an instrument is; the data venues are asked for
the same market and may refuse it.
Pairs are not spelled alike, and on several venues one id names both a spot market and a
perpetual; ccxt's own lookup of `BTCUSDT` returns the spot market on `binance` and on `bitget`.
Read from ccxt 4.5.84 on 2026-10-08:
| Venue (ccxt id) | Bitcoin perpetual id | Settles in | One contract is | The same id also lists a spot market |
|---|---|---|---|---|
| Bybit (`bybit`) | `BTCUSDT` | USDT, USDC | 1 BTC | yes |
| Binance (`binance`, `binanceusdm`) | `BTCUSDT` | USDT, USDC | 1 BTC | yes on `binance`, no on `binanceusdm` |
| Bitget (`bitget`) | `BTCUSDT` | USDT, USDC | 1 BTC | yes |
| OKX (`okx`) | `BTC-USDT-SWAP` | USDT | 0.01 BTC | no |
| Gate (`gate`) | `BTC_USDT` | USDT | 0.0001 BTC | yes |
| KuCoin Futures (`kucoinfutures`) | `XBTUSDTM` | USDT, USDC | 0.001 BTC | no |
| Hyperliquid (`hyperliquid`) | `0` | USDC only | 1 BTC | no |
So the desk names an instrument by its base and settlement currency, `BTCUSDT` or `ZECUSDC`, and
resolves that name on the venue it is about to ask: a unified symbol such as `BTC/USDT:USDT` as
given, then the venue's own id, then base plus settle. Only linear perpetuals are candidates, so a
name that is spot on one venue is never read as spot, and a name that matches nothing, or two
markets, is refused rather than guessed. Discovery names what it finds the same way, so on OKX it
lists `BTCUSDT`, not `BTC-USDT-SWAP`, and on Hyperliquid `BTCUSDC`.
Two consequences to know:
- **Settlement.** Hyperliquid has no USDT perpetuals. Set `[universe].quote = "USDC"` and name
instruments `BTCUSDC`.
- **Scaled contracts.** A token that trades per thousand on one venue (`1000PEPE`) and per unit on
another (`PEPE`) is two different instruments. Data from one will not resolve on the other, and
the desk says so instead of mixing them.
Sizes are in the base everywhere in the desk: caps, risk, conviction, the records. At the venue they
become contracts, because that is what a venue's order and position are counted in: 0.04 BTC on OKX
is 4 contracts, and 20 million PEPE where a contract is 10 million is 2. A size that is not a whole
number of contracts is refused with both figures, a venue that does not report its contract size is
refused, and what the venue reports back, fills and positions, is read in the base again.
Bybit, OKX, Bitget, Binance, BingX and Gate have been run end to end with real orders, on their
demo or testnet APIs. For the other venues the contract arithmetic and the name resolution are tested against the shapes ccxt returns;
the run against the venue's own account is the step still open, so start any of them on its
practice environment first.
What kind of market a desk may send to is one setting, `[execution].markets`. Every market, on
every venue, is read into one shape from what ccxt reports (see
[Architecture](https://docs.bijectivelabs.dev/opensource/architecture#adoption-rules)), and the order path decides from that
shape what applies: contracts, margin, leverage, where the exits go. Three types are built:
`linear perpetual` (stock perpetuals among them), `spot` (tokenized stocks among them) and
`prediction`. The list defaults to perpetuals only, a desk adds the others by naming them
(`markets = ["linear perpetual", "spot"]`), and a symbol of a type not listed, or not built yet
(an inverse perpetual, a dated future, an option), is refused by name before anything is sent. A
bare name such as `BTCUSDT` resolves to the perpetual; a spot pair is named by its unified symbol,
`SOL/USDT`, in `[data].symbols`.
What the same order path does when the market is spot:
- **Long only.** A short on spot sells coins the account does not hold, which needs a borrow; it is
refused.
- **Paid in full.** No leverage is set and no liquidation is estimated. The order and its fee must
fit in `[execution].max_margin_share` of the free quote balance.
- **The position is the coin in the account.** An amount under the venue's minimum order is fee
residue and counts as nothing; anything above it is the desk's position, so an entry is refused
while the account already holds the coin. Run a spot desk on an account, or a subaccount, that
is the desk's own.
- **Exits at the venue.** The stop and take profit go with the entry, as on perpetuals. Bybit keeps
them as one order with both prices; OKX keeps them as an OCO algo order. Both hold the coins.
- **Closes free the coins first.** A close, by time or by the kill switch, cancels the resting exits,
wherever the venue keeps them, then sells what is held, rounded down to the venue's step.
- **Fees in the coin.** A spot buy is often charged in the coin bought; the round trip counts that
fee in the quote currency at the fill price.
And in the cycle, before any order:
- **A short is refused by code** before it is priced, whatever the analyst proposed.
- **Funding and open interest are not asked for.** They belong to contracts; on spot they would be
refused every cycle and wake the technical auditor for nothing.
- **The fees are the spot pair's.** A spot pair usually costs more than the perpetual on the same
coin (on Bybit, 10 bps a side against 5.5), so a channel can carry both: its own fees for the
perpetual and a `[channel.spot]` table for the pair, and a spot cycle is priced at the second.
`tra check` compares each with what the venue charges for that market and says so when one is
priced lower.
To set a desk up for spot, name the pair in `data.toml` under `[data].symbols` (for example
`SOLUSDT = "SOL/USDT"`), list `spot` in `[execution].markets`, and add the pair's fees to the
channel:
```toml
[[channel]]
venue = "bybit"
name = "taker-taker"
entry_fee_bps = 5.5
exit_fee_bps = 5.5
[channel.spot]
entry_fee_bps = 10
exit_fee_bps = 10
```
Spot was run on 2026-10-17: the order path end to end on the Bybit demo and the OKX demo
(SOL/USDT; entry with the exits attached, the exits found at the venue, close, flat), and a full
desk cycle on SOL/USDT in sandbox and in venue mode on the Bybit demo, which priced the round trip
at the spot fee and refused the trade on its break-even rate. Start on a demo or testnet API.
## Stocks on a crypto venue
Several venues list equities as crypto instruments, and they arrive through the same order path as
any other market, because to the venue they are one: a stock perpetual is a linear perpetual (Bybit
`AAPL/USDT:USDT`, OKX `TSLA/USDT:USDT`, Bitget `NVDA/USDT:USDT`), and a tokenized stock is a spot
pair (Bybit `AAPLX/USDT`). Leverage, contract size, exits and closes work as they do for crypto;
the market's own facts decide.
What is different is the underlying. Its exchange keeps hours, and the token does not: it quotes
on a Sunday at a price nobody is making. So:
- **What the underlying is comes from the venue.** Bybit marks stock perpetuals `stock` and
tokenized stocks `xstocks`; OKX marks contracts with `instCategory` 3. A venue that does not say
(Bitget, among others) is read as crypto, and you name its equities in `[execution].equities`.
- **An entry waits for the session.** With `[execution].equity_session = "regular"` (the default)
a cycle on an equity does not ask anyone while the US regular session is closed (09:30 to 16:00
New York time, Monday to Friday, daylight saving from the time zone database), and refuses a
trade whose hold would run past that day's close, because the next open can gap past the stop.
`"any"` trades at every hour, deliberately.
- **Holidays are yours to declare.** The package carries no exchange calendar; list the closed
dates in `[execution].equity_closed`.
- **On-chain figures do not bear on a share**, so the on-chain analyst is not asked.
- **Some accounts need a step first.** Bybit refuses a stock perpetual until the account has
signed its agreement for them, in the app or on the website; the refusal names it.
- **Prices stay inside the venue's band.** OKX refuses an order priced outside its current price
limit, and publishes the limit; a taker entry there is held inside it.
Run on 2026-10-17: a tokenized stock round trip on the Bybit demo (AAPLX/USDT: entry with its
exits, close, flat) and a stock perpetual on the OKX demo (TSLA/USDT:USDT, five contracts of 0.01:
the entry filled inside the price band with its exits attached). That weekend the OKX demo's book
sat outside its band, and the reduce-only close was accepted but not filled; the close is now
confirmed against the account, recorded as not closed when the account still holds, and tried again
on the next pass, while the exits at the venue keep standing.
## Prediction markets
ccxt reaches prediction venues too (Kalshi, Polymarket, Limitless, Myriad and others, in its
`ccxt.prediction` namespace), and a desk names one the way it names an exchange: `kalshi`,
`polymarket`. Two names live on both sides, so their prediction venues are `prediction.binance`
and `prediction.hyperliquid`. ccxt's prediction clients are async; the package runs them on a loop
of their own, so everything above sees the same client it sees for an exchange.
What is traded is an outcome: a share of one answer to one question (YES on "will the Fed cut in
December"), priced between the venue's floor and ceiling, paying 1 if it wins and nothing if it
loses. The venue addresses it by a handle, `MARKET:YES`, and that handle goes in `data.toml`:
```toml
[data]
symbols = { FEDCUT = "FED_CUTS_IN_DECEMBER:YES" }
```
List `prediction` in `[execution].markets`, and the cycle on that instrument is decided by a
different role:
- **The event analyst decides**, not the technical analyst: it reads the question, the outcome,
when it resolves, the book, the recent prices and your dated notes, and answers with the
probability that the outcome wins. With nothing beyond the price to go on, the answer is the
mid with a low confidence, and the desk does nothing.
- **Code prices it to resolution.** Bought at the ask with a fee per share, the share is worth
`probability - ask - fee`; the desk buys only when that, as a share of the ask, clears the margin.
A probability that cites nothing from its brief is refused.
- **The stake is the stop.** A share cannot lose more than it cost, so the size is cut until its
whole cost fits `[controls].max_risk_per_trade_bps` and the position caps. Long only: to bet the
other way, buy the other outcome.
- **The take profit rests at the venue.** After the fill, the shares are offered at the analyst's
probability as a resting limit sell; otherwise they are held to resolution or until time closes
them. A close takes that offer off and sells at the venue's floor, immediate or cancel, since
these venues take no market order. A share held to resolution is written up at its payout.
- **Funding and open interest are not asked for**, and the screen reads an outcome by its move,
since the venues publish its prices without a volume.
How a venue charges goes on the channel, as one of two forms:
```toml
[channel.prediction]
fee_coefficient = 0.07 # 0.07 x shares x price x (1 - price), the way Kalshi charges
# or a flat rate on what the share costs:
# entry_fee_bps = 100
# exit_fee_bps = 100
```
A channel that says neither is refused rather than priced at zero.
Run on 2026-10-17: a sandbox cycle on Polymarket's public data (an outcome on the 2028 US
presidential election), where the event analyst answered with the mid and a confidence of 0.2,
citing the book, and the cycle stopped at the confidence floor. The order path is covered by tests
against ccxt's prediction shapes. Sending orders needs a demo or testnet API: Kalshi has a demo
environment that ccxt reaches (an API key and an RSA private key); Polymarket signs with a wallet
and has no practice environment.
## Which venue the data comes from
A desk trades on one venue, the one `[run].venue_key` names, and by default it reads from that
venue alone. The shipped `data.toml` says `venue` in every chain rather than an exchange's name:
```toml
[data.ohlcv]
chain = ["venue", "file:data"]
[data.orderbook]
chain = ["venue"]
```
`venue` is read as the venue orders go to. Candles, funding and open interest come from its live
markets. The book comes from the environment the orders meet, so a desk trading on a practice
account prices on that account's book (`ccxt:okx@testnet`, `ccxt:bybit@demo`), and a desk trading
on mainnet prices on the live one.
What decides and what pays are then the same venue, which is how a professional desk separates
them:
| Figure | Where it may come from | Why |
|---|---|---|
| Order book (spread, depth, the cost a trade is priced at) | the execution venue only | another venue's book is not the book the order meets |
| Funding | the execution venue, or `file:data` | the funding a position pays is its own venue's |
| Candles, open interest | the execution venue, or another venue on purpose | a signal can read a deeper market; it never prices the order |
| The price an order is sent at | the execution venue's book, read again at the moment of sending | the snapshot is as old as the analysts were slow |
In venue mode the first two are enforced: a chain that reads the book or the funding from any
venue other than the one orders go to is refused before anything is pulled, with the line that
names it. A source from a plugin is asked which venue it serves (its `venue` attribute), and one
that does not say is refused the same way: a book of unknown origin is not the venue's book. A candle chain may fall back to another venue deliberately,
`chain = ["venue", "ccxt:binance", "file:data"]`, and `tra check` says so every time. Whatever the
chains say, an order is priced once more against the execution venue's own book when it is sent:
the drift check refuses a decision the market has moved away from, and the slippage bound is
measured from that venue's touch.
## Exits a venue will not take with the entry
Some venues take an order and a stop, but not both in one request. Binance futures is the largest
of them. `[execution].exits` says what the desk does there:
| Setting | What happens |
|---|---|
| `"attached"` (default) | the stop and take profit go out with the entry; a venue that cannot take them refuses the order before anything is sent |
| `"separate"` | the entry goes out alone; the moment it fills, the stop and take profit follow as two reduce-only orders of their own, for what filled |
| `"none"` | no exit at the venue; you carry them yourself, and `tra check` lists that as something to fix |
With `"separate"`, three things keep a position from ever standing unprotected:
1. **A fill whose exits cannot be placed is closed at once.** The leg that did go out comes off, a
reduce-only market order closes the position, and the record says why.
2. **Exits outlive the position when one of them fires.** The stop closes the trade and the take
profit is still resting, sized for a position that is gone. Before the next entry on that
instrument, with nothing held, the desk takes off every exit it finds resting. One that will
not come off refuses the entry. A close by time or by `tra kill` takes them off as well.
3. **It runs only where the resting exits are read.** Binance keeps a futures stop and take profit
as algo orders, which ccxt lists and cancels only when asked for them by kind, and that read is
built in. On a venue where it is not, `"separate"` is refused rather than left to chance.
It is for positions the venue can reduce: perpetuals and futures. A spot desk on the same venue
keeps `"attached"` where the venue takes it, or `"none"`.
There is a moment between the fill and the exits, the time two orders take to send. A desk that
cannot accept it trades where exits go with the entry. Run on Binance's demo API on 2026-10-20:
both exits rested as algo orders, a close took them off, and a fill whose stop the venue would not
take was closed at once.
## Broker tags
Exchanges run broker programmes. Software that sends orders for its users identifies itself with
a tag, and the exchange shares part of the fee it already charges with whoever made the software.
On most venues the user pays the same fee either way. Hyperliquid is the exception: there the
software's share is charged on top.
This package adds no tag of its own today. ccxt, which sends the orders, adds its own on several
venues. As read from ccxt 4.5.85:
| Venue | What ccxt sends | What it costs you |
|---|---|---|
| Bybit | `Referer: CCXT` on every signed request | nothing extra; the share comes out of Bybit's fee |
| BingX | `X-SOURCE-KEY: CCXT` | nothing extra |
| Bitget | `X-CHANNEL-API-CODE` with ccxt's channel code | nothing extra |
| MEXC | `source: CCXT` | nothing extra |
| OKX, Binance, HTX | a broker prefix on the client order id, but only when the order carries no id of its own | nothing, and no tag either: the desk always sends its own client order id |
| Hyperliquid | by ccxt's default, an approval signed with your wallet for ccxt's builder address at up to 0.01%, then a builder fee of 1 basis point on every order. This package turns the fee off: the approval is signed at 0% and the builder is attached at zero | nothing extra by default. With the fee turned back on, **1 bp per order on top of Hyperliquid's fee** |
The default is set on every client that signs with your keys, and `[execution].ccxt_options` is
laid over it, as handed to ccxt:
```toml
[execution]
ccxt_options = { builderFee = true } # ccxt's Hyperliquid builder fee back on
```
A desk that turns the fee on adds 1 bp to each side of its Hyperliquid channel in `envelope.toml`,
so the round trip the gates price is the one it pays. Credentials are refused in this table; they
come from the environment only.
Bijective plans to join these programmes as a software vendor. When a venue issues an id, it will
be set the same way, shown by `tra check`, and you can replace it or leave it empty. The venue will
be listed on this page first. Until then nothing this package sends carries a Bijective tag.
## Changing venue, and turning the others off
A desk talks to the venues its files name, and nothing else. With the shipped `data.toml` that is
one venue, so the venue a desk trades on is also the only exchange it opens a connection to. Keys
are read for the execution venue only; data sources are never given a key.
Changing venue is one line:
```toml
[run]
venue_key = "okx/taker-taker" # was "bybit/taker-taker"
```
The chains that say `venue` follow. Two things have to exist on the new venue first, and `tra
check` names whichever is missing: a channel for it in `envelope.toml` with the fees your account
pays there (`tra venues okx --setup` reads them and prints the lines), and its keys in `.env`.
A desk made with `tra init --venue` also names the venue and the environment in `[execution]`;
change those with it, or `tra check` refuses the mismatch, since an order would otherwise go to
one venue at the other's fee.
To trade two venues at once, run two desks, one per venue: each has its own account, positions,
limits and kill switch, and stopping one never touches the other. To stop trading on a venue,
stop its desk (the kill switch flattens and stops it). To stop reading from one, take its name out
of the chains: a kind of data with no source left is refused rather than guessed, and the cycle
says which kind and why. Nothing needs uninstalling, and no code changes.
## Trading on another exchange
The route is the same for any venue ccxt can take orders on. OKX is the example, and its demo is
the first thing to try.
```bash
tra init my-desk --profile autonomous --venue okx # data, orders and fees wired for OKX
cd my-desk
# put OKX_API_KEY, OKX_API_SECRET and OKX_API_PASSPHRASE in .env, and your model key
tra keys --probe --prepare # fills the fees, reads the account, sets one-way on a demo
tra mandate --sign "Your Name" --venue-mode
tra check # says what is left, if anything
```
What `--venue` does: the data chains read from the venue the desk trades on, `[run].venue_key` and
`[execution].venue` name it, `[execution].environment` is its demo or testnet API where it has one,
leverage is 3x isolated, one order is capped at 200, the exits are placed the way that venue takes
them, the order mode stays `sandbox`, and `.env.example` lists the keys that venue signs with. An
agent desk sizes each order at 100 of the quote currency. Sizes use the venue's own step, so there
is no table of increments to copy. What to set once at the exchange is printed at the end.
Fees are filled in for you. `tra check`, `tra keys --probe` and `tra run` ask the account for its maker and taker
fee and write them on the blank lines of `envelope.toml`, with a comment that says where they came
from. When the account cannot report them (no keys yet, or a demo or testnet API that does not
answer: Bybit's demo returns a parameter error for every fee request), the venue's standard tier
from its market list is written instead and labelled so; it is the base tier, the one a new
account pays, so an account on a better tier pays less than the gate prices, never more. Those lines are replaced
by the account's own figure as soon as it reports one. A fee you write yourself is never replaced,
and `tra check` warns when it is below what your account pays.
OKX's demo trading is reached through ccxt's sandbox switch, so in this project it is
`environment = "testnet"`. A demo key only works on the demo.
A demo or testnet API has its own order book, and it can sit apart from the live one: on 2026-10-10
OKX's demo FIL book was 6 bps wide against 1 bps live. So the book a desk prices on is the
practice environment's when its orders go there (`venue` reads as `ccxt:okx@testnet` or
`ccxt:bybit@demo`), and the drift check, the spread and the slippage a desk records are measured on
the book its orders meet. Candles, funding and open interest stay on the live market. Any
`ccxt:` source takes `@demo` or `@testnet`.
The price can drift too. On OKX's demo API, in 6 of 52 decisions recorded to 2026-10-13, the
demo book's mid sat outside the live market's range for that minute: 30 to 40 bps on NEAR and 149
bps once on MAGIC. The analyst reads live candles, while the cost and the entry are priced on the
demo book. Treat demo results on thin instruments as a test of the plumbing, not of the strategy;
Bybit's demo book stayed inside the live range in all 63 recorded decisions.
Three things only you can do, in the OKX app or website (in Demo trading for a demo key): set the
account mode to Futures mode (Menu, Settings, Trading mode), because an account in Spot mode cannot
trade perpetuals at all and OKX does not let an API key change it; set the position mode to
one-way (net), because the desk refuses long/short mode (`tra keys --probe --prepare` does this on
a demo or testnet account); and turn venue mode on when you are ready
(`tra mandate --sign "Your Name" --venue-mode`; on mainnet the ladder as well).
What the desk does for OKX that it does not need to for Bybit: margin mode and leverage are applied
as isolated (ccxt would otherwise default to cross), the client order id is letters and digits only
and at most 32 characters, sizes are sent as contracts, and the stop and take profit go with the
entry as attached algo orders at the market price. The request ccxt builds for a limit IOC entry
with both was inspected offline on 2026-10-08 and carries `tdMode` isolated, `ordType` ioc,
`attachAlgoOrds` and the id. It has been sent to OKX's demo API since 2026-10-10: entries
filled with both exits attached, and positions closed by those exits at the venue.
### Binance futures
`venue = "binance"` with a futures symbol (`BTC/USDT:USDT`) trades USD-M futures: the client is sent
to the futures API, since ccxt's `binance` class otherwise reads the spot wallet for the balance.
The position is read from Binance's position list, the balance from Binance's own rows where
ccxt's parse comes back empty (as it did on the demo API), and a whole leverage is sent as an
integer, which Binance requires. Exits go out after the fill (`exits = "separate"`).
Every order is priced against the venue's book at the moment of sending, and a book the venue
stamped more than 30 seconds ago is refused, on any venue. On 2026-10-20 Binance's demo API served a
book 3.2 hours old, about 35 bps from where its own trades printed; `tra keys --probe` names it.
### BingX: the demo uses your account's own key
BingX has no separate key page for its demo. A key made in API Management on your account reaches
both its live API and its demo, VST (virtual USDT), at `open-api-vst.bingx.com`; which one is used
depends only on where the request goes. With `environment = "testnet"` the desk sends everything to
VST, and mainnet stays behind the ladder and the mandate like everywhere else. Because the key can
reach the live account, make it a trading key only: no withdrawals, no transfers, bound to your IP.
### Gate
`venue = "gate"` with a futures symbol trades USDT perpetuals: the client is sent to the futures
account and loads only the markets of the symbol's kind (Gate's testnet timed out on its full
lists). Gate keeps the leverage on the position, so the desk reads it from the position list, where
isolated margin shows as a leverage above zero. Client order ids carry Gate's required `t-` prefix
and fit its 28 characters. When the venue's stop closes a position, the take profit it placed after
the fill is still resting; it is taken off before the next entry on that instrument and by
`tra kill`, as on every venue with exits placed after the fill.
### Bitget's unified trading account
Bitget's demo offers the unified trading account, and its key page then shows two grants, manage
and trade. ccxt reaches that account with its `uta` option for orders, positions, balances, fills
and leverage, but still changes the margin mode and reads the leverage through Bitget's classic
futures API, which a unified key cannot reach. The desk handles the difference:
- The key's grants are read once, when the client is built. A key with only unified grants is
sent through the unified API; `uta` in `[execution].ccxt_options`, either way, is not overruled.
- On that account the leverage is kept per symbol and per margin mode, and both can exist at once.
The desk sets the declared leverage in the declared margin mode, reads it back from that row, and
sends the margin mode with every order, the close included, because an order that names none
trades crossed.
- The position mode is read from the account settings, and long/short (hedge) mode is refused.
- ccxt reports a fee Bitget charged as a negative cost there (a rebate, read literally); the fee
is taken from Bitget's own `feeDetail` instead, and a filled order with no average price is
priced from its own fills.
What you set, once, in Bitget's Demo trading: the position mode to one-way, and funds in the demo
account. Run on 2026-10-20 at 3x isolated on BTCUSDT: entries filled with the stop and take
profit attached as Bitget's position exits, a second entry was refused while one was open, a
reduce-only close left nothing resting, a post-only order was cancelled at its timeout, and a
position was closed by its own stop at the venue three minutes after entry. Bitget refuses a take
profit that is not beyond the order's own limit price, so a target inside the slippage band of a
taker entry is refused by the venue: nothing fills, and the cycle records Bitget's answer.
## Adding a venue you trade on
Four steps, and the command writes most of them for you.
**1. Ask what the venue can do.**
```bash
tra venues okx
```
```
okx
data funding, ohlcv, open_interest, orderbook
execution create_order: yes, attached_exits: yes, position: yes, open_orders: yes, cancel: yes
practice testnet
keys OKX_API_KEY, OKX_API_SECRET, OKX_API_PASSPHRASE
verdict venue mode ready: orders with the stop and take profit attached, account readable
```
That is read from ccxt, not promised here: no network, no key, no order. The `keys` line is what
this venue signs with, taken from ccxt's own list. Some want a passphrase, and Hyperliquid signs with
a wallet address and a private key instead of an API key.
**2. Print the blocks to paste.**
```bash
tra venues okx --setup
```
It prints what goes in `envelope.toml` (two channels, taker and maker), the line to change in
`data.toml`, the `[run]`, `[execution]` and `[live]` tables for `strategy.toml` with the practice
environment already chosen, and the variable names for `.env`. For a venue that cannot attach exits
it adds `exits = "separate"` where the venue takes them as orders of their own and this package
reads where they rest, else `exits = "none"`, and says why; for one that can only feed data it
prints the data line and nothing else.
The fee lines come filled in. The fee is the biggest number in the cost gate and it depends on
your account's tier, so the setup asks the account first and falls back to the venue's standard
tier, labelled as such, when the account cannot answer yet (see above).
**3. Fill in the blanks.** Your two fees, each instrument's size step (`size_step` as a table, one
per instrument), and the keys in `.env`. If an instrument's ccxt symbol is not the obvious one, add
it under `[data].symbols`.
**4. Check, then prove the keys.**
```bash
tra check # the three files read against each other
tra keys --probe # an account read through the same client an order would use; nothing sent
```
`tra check` refuses a desk whose files disagree: an instrument the sources cannot map, data from a
venue whose fees you are not pricing, or an `[execution].venue` that is not the venue in
`venue_key`. `tra keys --probe` names a missing passphrase, a key the venue rejects, and a clock
more than a second away from the venue's (the venue hand refuses to send in that case too).
What protects you once orders flow, on any venue: a size off the venue's step or under its minimum
is refused rather than quietly rounded by ccxt; the account is read before every order; an open
position or a working order refuses a second entry; and a maker order that does not fill is
cancelled, never chased.
## A venue ccxt does not know
Write a plugin. The three kinds below are the whole contract: an archive venue for history, a live
feed for the public tape, a live venue for orders. Each is a small class registered by entry point.
## An archive venue
```python
import csv
from collections.abc import Iterable, Iterator
from datetime import date
from trading_research_agents.data import Rejection, Trade
class MyVenue:
name = "my-venue" # what `--venue` accepts; lower-cased on registration
cadence = "daily" # or "monthly": how the venue slices its archives
def archive_url(
self, instrument: str, *, day: date | None, month: tuple[int, int] | None
) -> str:
if day is None:
raise ValueError("my-venue archives are daily; pass --day")
return f"https://archive.example/{instrument}/{day.isoformat()}.csv.gz"
def checksum_url(self, archive_url: str) -> str | None:
return None # or the URL of a published digest, which fetch verifies
def normalise(self, lines: Iterable[str], *, instrument: str) -> Iterator[Trade | Rejection]:
# Rows to canonical trades. A bad row is a Rejection with its line, never dropped.
for number, row in enumerate(csv.reader(lines), start=1):
...
```
`normalise` is held to the same standard as the bundled ones: it checks the layout it was
written against on every file, and stops with `ArchiveFormatError` and the header it actually
saw when the layout drifted. A row that cannot become a trade is a `Rejection` with its line
number and reason. The audit counts them; it does not forgive them.
Register it one of two ways.
In your own package, as an entry point in `pyproject.toml`; installed next to
`trading-research-agents`, it is found on first use:
```toml
[project.entry-points."trading_research_agents.venues"]
my-venue = "my_package.venue:MyVenue"
```
Or in a script, before the CLI or the gate is used:
```python
from trading_research_agents.data.venues import register
register(MyVenue())
```
An entry point may name the class or an instance; a class is instantiated with no arguments.
## A live feed
Every venue ccxt streams is already a live feed, through `trading/feeds/ccxt_trades.py`, with no
key. A plugin is for a venue ccxt does not stream, and it takes precedence for its name. It is a
factory. Given an instrument, a stop event and a reconnect budget, it
returns a `Feed`: an iterator of `Trade` that the runtime pulls prints from.
```python
import threading
from trading_research_agents.trading.feed import Feed
class MyFeedFactory:
name = "my-venue"
def __call__(
self, instrument: str, *, stop: threading.Event, max_reconnects: int | None
) -> Feed:
return MyPublicFeed(instrument, stop=stop, max_reconnects=max_reconnects)
```
```toml
[project.entry-points."trading_research_agents.feeds"]
my-venue = "my_package.feed:MyFeedFactory"
```
`tra trade shadow` picks the feed from the venue half of `[run].venue_key` in
`strategy.toml` (`my-venue/taker-taker` selects `my-venue`), or from `--feed NAME`. A name it
cannot stream is refused before any connection is opened.
What the ccxt feed does, and a plugin should: quantities in the base, each trade once, in time
order; a trade that does not parse stops the feed rather than being skipped; a dropped connection
is reopened and counted, up to the reconnect budget. The runtime does not care how the venue
frames a trade. It cares that every `Trade` is exact
`Decimal`, timestamped in UTC nanoseconds, and that silence is reported rather than waited out.
## A live venue
Every exchange ccxt knows is already a live venue for `tra live`, through
`trading/venues/ccxt_live.py`, on the same client and hand venue mode sends with; nothing needs
writing for one of them. A plugin is for a venue ccxt does not cover, and it takes precedence for
its name. It is a factory that takes `LiveVenueSettings` (instrument, channel,
funding schedule, environment, run id, the two deliberate overrides) and the environment
mapping, and returns an object satisfying `trading.venue.Venue` plus `preflight()`. The
factory declares which environment variables it needs (`env_keys`) so the CLI can say which
one is missing, and which environments it knows (`environments`).
```python
class MyLiveFactory:
name = "my-venue"
env_keys = ("MY_VENUE_API_KEY", "MY_VENUE_API_SECRET")
environments = ("demo", "mainnet")
def __call__(self, settings: LiveVenueSettings, env: Mapping[str, str]) -> LiveVenue:
return MyLiveVenue(settings, env)
```
```toml
[project.entry-points."trading_research_agents.live_venues"]
my-venue = "my_package.live:MyLiveFactory"
```
What the runtime expects of it, and what the ccxt live venue does: `routes_exits = True`;
`submit` accepts one intent at a time and sends it with an idempotent client order id;
`poll(trade)` confirms the fill from the venue's own order state and reconciles the position
after every fill, raising `VenueError` on any disagreement; closes are reduce-only;
`preflight()` refuses a clock too far from the venue's, a key with more or fewer permissions than
a trade key where the venue reports them, and a position the run was not told to adopt; `cost_of` charges the fees
the venue reported. [The live tier](https://docs.bijectivelabs.dev/opensource/live) is the review a new adapter is held to, and
`tests/test_live_venue.py` is the shape of the tests it should ship with.
## The bundled plugins
| Name | Archive | Funding | Live feed | Live venue |
|---|---|---|---|---|
| `bybit` | daily `.csv.gz`, header row, no published checksum | `GET /v5/market/funding/history`, paged | through ccxt, like every venue | through ccxt, like every venue |
| `binance` | monthly `.zip` of one CSV, no header, `.CHECKSUM` sidecar verified | none | through ccxt, like every venue | through ccxt, like every venue |
The archives live in `data/venues/`, and a venue's account checks that ccxt does
not unify (OKX's account mode, Bybit's key permissions) in `trading/venues/accounts.py`, Bitget's
unified account in `trading/venues/margin.py` and `trading/venues/fills.py`; nowhere
else names a venue. They
are examples of the protocols as much as they are integrations, and a plugin for another
venue is expected to be about the same size.
## Fees, latency, settlement
None of that is in the plugin. Fees per channel, measured latency and the settlement clock are
declared in `envelope.toml` and `strategy.toml` for whichever venue a run is on. A venue plugin
moves bytes; the operating envelope says what trading there costs.
# Exchange accounts and keys
Every exchange makes API keys a little differently, and the differences decide whether the first
order goes through. This page is what we learned making keys for the venues we have run end to end,
written so you can do it once and get it right. It covers the practice environment first, then the
same steps for live trading.
## The short version
1. Open the exchange's practice environment (demo or testnet) and make a key there.
2. Give the key trading permissions only. Never withdrawals, never transfers.
3. Make the desk for that exchange, `tra init my-desk --profile autonomous --agent groq --venue
bitget`, and put the key in its `.env` under the names in the table below. `--venue` sets the
practice environment, leverage, the cap per order and how exits are placed for you.
4. Run `tra keys --probe --prepare`. It reads the account through the same path an order takes,
sets one-way position mode on a demo or testnet account that is still hedged, and names
anything else to fix. Nothing is sent.
5. `tra mandate --sign "Your Name" --venue-mode`, then `tra check`.
```toml
[execution] # what --venue writes for Bitget; every line is yours to change
mode = "venue" # after tra mandate --sign ... --venue-mode
venue = "bitget"
environment = "demo" # demo, testnet or mainnet, as the table says for your venue
leverage = 3
margin_mode = "isolated"
max_notional = 200
```
## Venue by venue
| Venue | Practice environment | Where the key is made | `.env` names | `environment` | Exits |
|---|---|---|---|---|---|
| Bybit | Demo Trading | in Demo Trading mode of your account, API Management | `BYBIT_API_KEY`, `BYBIT_API_SECRET` | `demo` | attached |
| OKX | Demo trading | in Demo trading, Personal center, Demo Trading API | `OKX_API_KEY`, `OKX_API_SECRET`, `OKX_API_PASSPHRASE` | `testnet` | attached |
| Bitget | Demo trading | in Demo trading mode, API Management | `BITGET_API_KEY`, `BITGET_API_SECRET`, `BITGET_API_PASSPHRASE` | `demo` | attached |
| Binance | Demo Trading | on Binance's Demo Trading site, API Management | `BINANCE_API_KEY`, `BINANCE_API_SECRET` | `demo` | separate |
| BingX | VST (virtual USDT) | your account's own API Management: the same key reaches the demo | `BINGX_API_KEY`, `BINGX_API_SECRET` | `testnet` | attached |
| Gate | Testnet | where Gate issues testnet API keys | `GATE_API_KEY`, `GATE_API_SECRET` | `testnet` | separate |
| Hyperliquid | Testnet | a wallet; an API wallet authorised from it is the safer key | `HYPERLIQUID_WALLET_ADDRESS`, `HYPERLIQUID_PRIVATE_KEY` | `testnet` | attached |
The names are fixed: the venue in capitals, then `API_KEY`, `API_SECRET` and, where the exchange asks
for one, `API_PASSPHRASE`. Exchanges label these fields differently (some call the secret a "secret
key"), so the name you copy from the page is not always the one read here. If a name is close but not
exact, `tra check` and `tra keys --probe` say which name they found
and which one they read; they never print a value.
"Exits" is what `[execution].exits` should be. `attached` sends the stop and take profit with the
entry; `separate` places them as two reduce-only orders right after the fill, for exchanges that do
not take them together through ccxt. See [Venues](https://docs.bijectivelabs.dev/opensource/venues#exits-a-venue-will-not-take-with-the-entry).
### Bybit
Switch your account to Demo Trading and make the key there; a key made in the live account does not
reach the demo. Give it contract orders and positions. Before a live tier session (`tra live`), the
desk also reads the key's own permissions on Bybit and refuses one that can move funds, one that is
read-only, one not bound to an IP (unless you accept that with `[live].key_ip_unbound_ok = true`),
and one expiring within a week.
### OKX
Make the key inside Demo trading; it only works there. Three settings are yours, in the app or
website, in Demo trading for a demo key: the account mode to Futures (Spot mode cannot trade
perpetuals, and OKX does not let a key change it), the position mode to one-way, and nothing else.
The desk sets leverage and isolated margin itself.
### Bitget
The Bitget demo we ran on is a unified trading account, and its key page offers two grants, manage
and trade; give both, and no withdrawal. Set the position mode to one-way. The desk recognises a
unified key by its grants and uses Bitget's unified API, where it sets the leverage and margin mode
and reads them back. A classic account key works too.
### Binance
Make the key on Binance's Demo Trading site. Give it futures trading. The desk trades USD-M futures
for a futures symbol and places the exits right after the fill. If Binance's demo serves an old order
book (it did for hours on 2026-10-20), every order is refused until the book is current, and
`tra keys --probe` says so.
### BingX
BingX has no separate key for its demo. A key made in API Management on your account reaches both the
live API and the demo, VST, and the desk's `environment = "testnet"` is what sends everything to VST.
Because the same key can reach your live account, make it a trading key only, with no withdrawals and
no transfers, bound to your IP. Set the position mode to one-way in the VST futures settings; the
desk refuses hedge mode.
### Gate
Use a key issued for Gate's testnet. The desk trades USDT perpetuals there and places the exits after
the fill. A key without wallet read cannot report its fees, so the desk prices with Gate's standard
fees until it can; add wallet read to let it use your own.
### Hyperliquid
There is no API key: orders are signed by a wallet. Use an API wallet that your main wallet
authorises, so the key on disk can trade but cannot withdraw. The address is your main wallet's.
## Going live
The steps are the same in the live account, with three differences:
| | Practice | Live |
|---|---|---|
| Where the key is made | in the demo or testnet | in the live account |
| `[execution].environment` | `demo` or `testnet` | `mainnet` |
| What else must be true | a signed mandate | a signed mandate, and the ladder at its live tier, promoted with evidence ([The live tier](https://docs.bijectivelabs.dev/opensource/live)) |
For a live key, bind it to the IP of the machine the desk runs on, give it the shortest expiry you are
willing to rotate, keep one key per desk, and keep withdrawals off. The key is read from the
environment or the `.env` beside `strategy.toml`, never from a config file, and any message that
reaches a record has it removed first.
## What we have run
| Venue | Run end to end | Where |
|---|---|---|
| Bybit | yes | demo |
| OKX | yes | demo trading (`testnet`) |
| Bitget | yes, on a unified account | demo |
| Binance | yes, except a close by the venue's own exit; orders wait while its demo book is stale | demo |
| BingX | yes | VST (`testnet`) |
| Gate | yes | testnet |
| Hyperliquid | not yet | testnet |
For Bitget, Binance, BingX and Gate "end to end" is one script, the same on each: an entry with its
exits, the position read back, a second entry refused, a reduce-only close that leaves nothing
resting, a post-only order cancelled at its timeout, a position closed by the venue's own exit, and
the kill path. Bybit and OKX were run before that script existed, by desks trading on their demos.
The details per venue are on [Venues](https://docs.bijectivelabs.dev/opensource/venues).
## Opening an account
| Exchange | Website |
|---|---|
| Bybit | [bybit.com](https://www.bybit.com) |
| OKX | [okx.com](https://www.okx.com) |
| Bitget | [bitget.com](https://www.bitget.com) |
| Binance | [binance.com](https://www.binance.com) |
| BingX | [bingx.com](https://bingx.com) |
| Gate | [gate.com](https://www.gate.com) |
| Hyperliquid | [app.hyperliquid.xyz](https://app.hyperliquid.xyz) |
Whether the exchange is available to you, and on what terms, depends on where you live; the exchange
decides that, not this software. Any exchange on this page works the same way in the software however
you opened your account.
# For exchanges
This page is for exchanges and their integration and partnership teams. It says how this software
reaches a venue, what we check before we say a venue works, and how orders sent through it identify
themselves.
## How it reaches a venue
The software is open source and runs on the user's own machine, with the user's own keys. It reaches
every venue through [ccxt](https://github.com/ccxt/ccxt), the open-source library most trading
software uses, so a venue that ccxt supports is reachable without code of its own. Where an exchange
differs from ccxt's unified calls, the difference lives in one small module per concern
(`trading/venues/` in the source), never in the core.
Every order it sends carries a client order id of its own, so the same decision cannot be sent
twice. Beyond that:
- an entry is a limit order, immediate-or-cancel for a taker and post-only for a maker, priced
within a bound of the venue's own book read at the moment of sending;
- the stop and take profit go out with the entry, or follow it at once as reduce-only orders where
the venue does not take them together;
- closing a futures position is a reduce-only order, which can only shrink it;
- an entry is refused before it is sent when the account cannot carry it, the book is stale, the
position mode is hedged, or the leverage and margin mode cannot be confirmed by reading them
back.
## What "run end to end" means
A venue is listed as run end to end only after the same script has passed on its practice
environment: an entry with its exits, the position read back, a second entry refused, a reduce-only
close that leaves nothing resting, a post-only order cancelled at its timeout, a position closed by
the venue's own exit, and the kill path. The current list, with what each venue needed, is on
[Venues](https://docs.bijectivelabs.dev/opensource/venues) and [Exchange accounts and keys](https://docs.bijectivelabs.dev/opensource/exchange-keys).
## Broker and channel ids
Orders do not carry a Bijective tag today. Where ccxt adds its own identifier, that is listed on
[Venues](https://docs.bijectivelabs.dev/opensource/venues#broker-tags) for every venue.
Bijective intends to take part in exchanges' broker programmes as a software vendor. When an exchange
issues an id, it will be:
- set the way the exchange documents it, through the venue's own field;
- shown by `tra check`, so every user sees it;
- replaceable or removable by the user in one line of configuration;
- never a cost to the user beyond the exchange's own fee. A programme that charges users extra for
the vendor's share is one we do not use as a default.
The venue is named on this page first, and only after the agreement is in place.
## What helps an integration
| From the exchange | Why |
|---|---|
| a practice environment reachable through the same API as live | every venue is run there first, by us and by every user |
| the stop and take profit accepted with the entry, or as reduce-only orders | a position must never stand without its exits |
| leverage, margin mode and position mode readable through the API | the desk confirms them before every first order instead of trusting a reply |
| a timestamp on the order book | an order is not priced against a book older than 30 seconds |
| a broker or channel id that adds no cost to the user | see above |
## Contact
Integration and partnership questions: [bijectivelabs.dev/contact](https://www.bijectivelabs.dev/contact).
Technical issues with a venue are best opened on
[GitHub](https://github.com/BijectiveLabs/trading-research-agents/issues), where users can follow
them.
# Working with language models
This project asks models to do one narrow thing: read figures that the system computed and answer inside
a closed schema. Everything else people usually reach for a framework to get - orchestration,
memory, caching, retries, continuity when a provider fails - is either already handled by that
shape or is listed here with what we do instead.
## No orchestration framework, and what would change that
There is no LangChain, LangGraph, CrewAI or agent framework in the dependency tree. The fan-out is
forty lines: a thread pool, an answer written to a file the moment it arrives, and `--resume` to
reuse the answers already on disk. A cycle is at most three rounds - the analysts together, then the
two advocating validators together, then the judge - and the order is in one function you can read
top to bottom.
The criteria that would make us adopt a graph framework were written down before the decision and
still hold: a cycle that needs conditional re-entry into earlier steps, a run that must survive a
process restart mid-graph, or more than a handful of nodes whose order is data-dependent. None of
those describe a cycle that decides once. What a framework would add today is an indirection layer
between an operator and the reason their trade was refused.
## Memory
| What people mean by memory | What this project uses |
|---|---|
| conversation history in a thread | Nothing. Each role call is stateless: one system prompt, one brief built by the system. |
| what it said last time | The technical analyst's brief carries its own last reading of the instrument, when there is one inside the window it reads (`previous`): how long ago, the target, setup, confidence and levels it gave, the close then and how far it has moved since. Code builds it from the record. What became of that reading (traded, refused, won, lost) is left out, since that would tell a proposing role the desk's rules. |
| what happened in earlier cycles | The record on disk: `runs/cycles/*/cycle.json`, the intents, `usage.jsonl`, the ledger. The auditors read exactly that. |
| one store the analysts share | The cycle directory. Every brief and every answer is a file in it, written atomically (a temporary file, then a rename), and an index per day finds them again. Reading analysts' answers are also kept by the figures they read (`runs/cycles/reuse/`), which is how an unchanged reading is not paid for twice. |
| a vector store of past decisions | Not used. A decision is retrieved by reading the cycle it belongs to, which is a file with a timestamp in its name. |
| caching a fetched value | `store/cache.py`: a TTL-bounded file cache, and the data chain's own freshness window. |
That is a deliberate trade. Stateless roles mean the same brief produces the same question no matter
which model answers it, which is what makes a model swap, a retry or an audit meaningful. A
conversation would make each of those a different thing. The last reading is no exception: it is in
the brief, so it is on disk with everything else the model was shown.
Why the analyst sees its last reading at all: on two demo desks, between 2026-09-28 and 2026-10-16,
8 of 36 and 4 of 37 pairs of answers on the same instrument less than 90 minutes apart pointed
opposite ways, long then short or the reverse. Some of those are the market turning. The point of
showing the earlier reading is that a reversal is now a decision with its reason written down
("what changed in the figures"), not two unrelated answers.
## When an answer does not fit, or points at nothing
Three checks stand between a model's text and anything that reads it.
1. **The schema is enforced at the vendor where the vendor can**: strict JSON schema output on
Anthropic, Google and the OpenAI-compatible servers that support it, JSON mode with the schema in
the prompt on the rest. Code then parses the answer again and refuses any field outside it, a
wrong value in a closed list, or a number out of range.
2. **A malformed answer is asked once more, with the reason.** The second call carries the same
brief, then one line saying what was wrong ("response lacks verdict, reason"), so the model does
not have to guess and the provider's cached prefix still holds. A second malformed answer fails
the call and the cycle records it.
3. **Every figure a role cites is checked against its brief**, by name and value. A citation that
names a figure the brief does not carry, or a different value, is discarded, and a deciding
answer with none left is refused. On the OKX demo desk, from 2026-10-12 to 2026-10-16, no
citation was discarded across 44 technical analyst answers, 132 reading analyst answers and 17
validator answers. Between 2026-10-08 and 2026-10-11 the reading analysts had 27 discarded, all
of them names the brief did not use, such as `fear_greed_zscore` for
`series.fear_greed.zscore_vs_recent`.
## How much a role is given
Measured on the same desk (342 calls, `openai/gpt-oss-120b` and `-20b` on Groq): a call carries a
median of about 1,950 tokens for the technical analyst and the macro analyst, 1,730 for on-chain,
1,350 for sentiment, 1,390 for a validator and 1,540 for the screener, system prompt included. The
largest was 2,915. Each call starts from a fresh context, so nothing accumulates across a cycle or a
day, and no role comes near any current model's window. The one place a context grows is the
research auditor's tool loop, which is capped at six calls with each observation cut at 6,000
characters.
## When a provider is rate limited or down
The vendor SDK retries first: `max_retries` (default 2) covers 429 and 5xx with backoff, and
`call_timeout_s` bounds each attempt. What was missing until now was the next step, and it is the
same answer the data layer gives - a chain:
```toml
[agents]
provider = "groq"
model = "openai/gpt-oss-120b"
fallback = ["google:gemini-flash-latest", "anthropic:claude-opus-5-5"]
[agents.roles.sentiment_analyst]
model = "openai/gpt-oss-20b"
fallback = ["groq:openai/gpt-oss-120b"] # this role's own order, instead of the one above
```
The first link that answers wins. A link that answers "try again in N seconds" with N inside
`[agents].max_retry_wait_s` (15 by default) is waited for once before the next link is tried, so a
short burst over a per-minute limit does not throw away the model you chose. A link whose quota
is spent (the vendor says `RESOURCE_EXHAUSTED` or quota), or that asks for a longer wait than that,
rests for 15 minutes, or for as long as the vendor asked, across every role in the process: the
next cycles go straight to the next link instead of paying the same failure in seconds the market
does not wait for. When every link rests, the one waking soonest is asked anyway. Usage records the
provider that actually answered. A link that cannot be reached is recorded with the vendor's own
error class and the next one is tried **with the same brief** - there is no conversation to carry
over, because the brief was built by the system from the snapshot. A link is built the first time it is
needed, so naming a spare you have no key for costs nothing until the day you need it.
One failure does not fall through: a spent budget. `token_budget`, `max_calls_per_hour` and
`min_interval_s` are the operator's own limits, and no provider is allowed past them. The chain
stops where it stands and the cycle records why.
`tra check` reads the fallback list against the registered providers, so a typo is a line in the
check rather than a surprise at the moment the primary provider goes down.
### When every link fails
What happens next depends on the role, and none of it can make a trade larger:
| Role | If no model answers | Why |
|---|---|---|
| technical analyst | the cycle stops; no trade | it is the only role that proposes a direction |
| reading analyst (macro, on-chain, sentiment) | its label is missing; its score still counts | the score is arithmetic over your thresholds and never needed the model |
| validator | the trade is refused (`[agents].on_missing_validator = "refuse"`, the default); with `"continue"` it goes on with the votes that came back | a check you declared that did not happen is not a check |
| market screener | the instruments the system admitted go on unfiltered | the screener can only remove, never add |
| auditor | a line in the report | auditors never act |
Every failure is a refusal line in `cycle.json` with the vendor's own message, and a provider
failure wakes the technical auditor (`[audit].on_alarm`). The way to make all of this rarer is
capacity, not code: a provider tier whose tokens-per-minute covers a whole cycle (the screener,
three analysts and two validators measured 9,200 to 10,200 tokens inside one minute on the demo
desk, against a free tier's 8,000), roles split across vendors with `[agents.roles.]`, and a
fallback from a different vendor.
A pass also stops asking anyone when the desk is already at `[desk].max_open_positions`, so a full
book does not spend a provider's rate on cycles that could only be refused.
## Prompt caching
The Anthropic adapter marks the system prompt as cacheable and reads back what the provider says it
cached; the OpenAI-compatible adapter reads `prompt_tokens_details.cached_tokens` where a server
reports it. Groq reported a cached prefix on 14 of the 342 calls above (12 of them the screener's).
Both figures are written to `usage.jsonl` per call:
```json
{"role": "technical_analyst", "input_tokens": 1180, "cached_tokens": 0, "cache_write_tokens": 0}
```
Read that column before believing in the saving. Caching pays when the stable prefix is large, and
the minimum cacheable prefix is between 512 and 4096 tokens depending on the model. The figures
live in the brief rather than in the prompt, so most role prompts are a few hundred words; the
deciding analyst's is the longest, about 1,400 words, which is above some vendors' minimum. The
brief follows the prompt as sorted JSON, so the stable part comes first. Whether a vendor caches it
is the vendor's decision, and the honest thing is to measure it rather than to claim it.
Where it does pay: a long tool loop (`tra agent session`), where the same system prompt and a
growing history are sent every turn. The silent invalidators to avoid are the usual ones - a
timestamp or a cycle id inside the system prompt, an unsorted JSON dump, a tool list that changes
per call. The briefs in this project are sorted JSON for exactly that reason.
## Batching
Not used for a cycle, and it should not be: a batch is an asynchronous queue measured in hours, and
a cycle decides on a snapshot that is seconds old. The place a batch belongs is the eval path -
scoring a role against many recorded briefs at 50 percent of the price - and that is where it will
go when the eval set is large enough to be worth the plumbing. Until then, `tra agent eval` runs the
calls straight.
## Fine-tuning
Not planned, and this is a position rather than a gap.
- **A weight is not a record.** The claim this project makes is that nothing decides without leaving
a figure and a reason that anyone can re-read. A fine-tuned model moves part of the decision into
weights nobody can audit, including us.
- **It bakes in look-ahead.** Fine-tuning on a desk's own past decisions teaches the model what
worked, which is a statement about a period that has already happened. The falsification chain
exists to stop exactly that kind of borrowed knowledge from looking like edge.
- **It is vendor lock with a hosting bill.** A tuned model lives at one provider, which is the
opposite of the chain above.
The levers we use instead: a smaller model per role where the job is labelling rather than
reasoning (`[agents.roles.]`), the output cap tuned per role, the brief carrying every figure
so the model never computes, and `tra agent eval` to measure a model on recorded briefs before it
decides anything with money behind it.
## What a role may spend
Nothing, by default, is capped. That is a decision rather than an oversight: an answer cut off
halfway is a wasted call and a worse decision, not a saving, and a role that stops mid-thought
because of a ceiling this software chose leaves an operator with neither the answer nor the money.
What happens instead is counting. Every call is a line in `usage.jsonl` with its role, provider,
model, tokens, cached tokens, latency and - when `[agents.pricing.]` says what you were
quoted - its price. Every cycle reports what it spent. `[targets].max_research_bps` measures that
spend against the notional of the trade it produced, and the auditors read the same records and
propose where to spend less. Optimising a bill is a decision with figures behind it, which is an
operator's job and an auditor's, not a default.
An operator who wants a hard ceiling anyway declares one, and it does what it says:
| Declaration | Effect | Undeclared |
|---|---|---|
| `token_budget` | the process stops asking once the ledger passes it | no ceiling |
| `max_calls_per_hour` | a role refuses its own call past that rate | no ceiling |
| `min_interval_s` | a role waits that long between its own calls | no wait |
| `max_output_tokens` | the cap sent to the vendor | the model's own maximum |
Two notes on the last one. The Anthropic API requires `max_tokens` on every request, so an
undeclared cap becomes a generous default there rather than nothing - a field the vendor insists on,
not a budget. And an audit needs room a one-line answer does not: when a declared cap is below what
a report with findings in it takes, the audit raises it for that call and says so.
## How hard a role thinks
A role that labels a series does not need what a role reasoning over sixty bars needs, and on a
reasoning model that difference is most of the latency and most of the bill. `effort` is one
vocabulary - `minimal`, `low`, `medium`, `high`, `xhigh`, `max` - declared in `[agents]` and
overridden per role, and each adapter maps it onto what its own vendor takes:
| Vendor | Field | Takes | Mapping |
|---|---|---|---|
| Anthropic | `output_config.effort` | low, medium, high, xhigh, max | `minimal` lands on `low`; the rest pass through |
| Google | `thinking_config.thinking_level` | MINIMAL, LOW, MEDIUM, HIGH | `xhigh` and `max` land on `HIGH` |
| OpenAI and compatible | `reasoning_effort` | minimal, low, medium, high | `xhigh` and `max` land on `high` |
Undeclared, the field is left out of the request entirely, which matters: most servers behind a
`base_url` reject `reasoning_effort` outright, and a model with no reasoning to configure should be
sent nothing. A level a vendor cannot take is mapped rather than refused, because a fallback chain
that dies on a spelling is a chain that does not help on the day it is needed.
Roles that read a table the system already computed (the screener, the macro, on-chain and sentiment
analysts, the validators) take `low` when `[agents].effort` is declared and their own role table
does not say otherwise; the technical analyst keeps `[agents].effort`. With nothing declared, no
role is sent an effort at all, for the reason above.
## Not paying twice for the same reading
The macro, on-chain and sentiment briefs are built from series that update daily on free feeds and
faster on paid ones. When a brief's figures are identical to ones already answered, the earlier
answer is used again instead of asking:
```toml
[agents]
reuse_reading_for = "1h" # how long an unchanged reading stands; "off" asks every cycle
```
The key is the figures, without the clock: any new value, on any feed at any speed, asks again at
once. The window only bounds how long an unchanged reading stands. The declared state is computed
by the system every cycle either way, and it is what decisions read. A reused answer is marked `reused`
with its age in the cycle's agents table and under `reused` in `cycle.json`.
Two more things keep calls from being wasted. The validators are not asked when the system is certain to
refuse whatever they say: the desk with nothing added, the day's round trips, the declared reward to
risk, or an edge that fails against the part of the round trip that does not grow with size. And
briefs are sent as JSON without spaces, which are tokens that carry nothing.
## Choosing a model per role
Cost per decision, not cost per call. `usage.jsonl` gives tokens and latency per role, and the
technology auditor divides them by the intents the desk actually produced. The pattern that has held
so far: a stronger model for the role that decides, a cheaper one for the roles that label, and
enough output headroom for whatever schema the role fills - a role that returns several cited claims
needs roughly twice the cap a one-line answer does, which is measured, not guessed.
# Model providers
Providers are plugins. `--provider NAME` on `tra agent propose` and `[agents].provider` in
`strategy.toml` take any registered name; three adapters ship, one of which serves every
vendor that speaks the OpenAI chat API.
| Name | Extra | Key variable | Endpoint | Structured output |
|---|---|---|---|---|
| `anthropic` | `[anthropic]` | `ANTHROPIC_API_KEY` | the SDK's | JSON schema, prompt caching on the doctrine |
| `google` | `[google]` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | the SDK's | JSON schema |
| `openai` | `[openai]` | `OPENAI_API_KEY` | the SDK's | strict JSON schema |
| `deepseek` | `[openai]` | `DEEPSEEK_API_KEY` | `https://api.deepseek.com` | JSON mode |
| `groq` | `[openai]` | `GROQ_API_KEY` | `https://api.groq.com/openai/v1` | enforced schema; JSON mode where a model cannot |
| `xai` | `[openai]` | `XAI_API_KEY` | `https://api.x.ai/v1` | JSON mode |
| `mistral` | `[openai]` | `MISTRAL_API_KEY` | `https://api.mistral.ai/v1` | JSON schema |
| `openrouter` | `[openai]` | `OPENROUTER_API_KEY` | `https://openrouter.ai/api/v1` | JSON mode |
| `qwen` | `[openai]` | `DASHSCOPE_API_KEY` | `--base-url` (depends on region and workspace) | JSON mode |
| `ollama` | `[openai]` | none | `http://localhost:11434/v1` | JSON mode |
| `9router` | `[openai]` | `9ROUTER_API_KEY` | `http://localhost:20128/v1` | JSON schema; the model is a combo or `provider/model` from the router's dashboard; a combo whose first upstream ignores JSON mode fails as any other model would |
| `local` | `[openai]` | none | `--base-url` (https, or http on localhost) | JSON mode |
Every endpoint and key variable was read from the vendor's own documentation on 2026-09-13.
Model names belong to your account, not to this package, and that is deliberate: a catalogue of
model names goes stale every month and a warning about an unknown name is noise. If a vendor
answers `404` for a model that used to work, ask the vendor which names your key may use. Seen on
2026-09-27 with a Gemini key: `gemini-2.5-flash` replied "no longer available to new users" while
`gemini-flash-latest` worked on the same key. A failed call is recorded in `usage.jsonl` with the
vendor's own error, so this is visible rather than mysterious.
Three providers have a model to fall back on when `tra init --agent` is given none, because those
models have run live cycles here: `groq` uses `openai/gpt-oss-120b`, `google` uses
`gemini-flash-latest` and `anthropic` uses `claude-opus-5-5`. Every other provider asks for
`--model`. `tra models ` lists what your key can use.
Output caps are the other account-shaped trap. A reasoning model spends most of its output budget
thinking before it writes the answer, so a cap that looks generous for a short JSON object is not.
Measured on 2026-09-27 against one small reasoning model, asked for a three-field object from an
800-character brief:
| Cap | Outcome |
|---|---|
| 512 | `json_validate_failed`: "max completion tokens reached before generating a valid document" |
| 1024 | answered, using 704 output tokens; one cycle out of two still failed the same way |
| 2048 | answered, using 681 output tokens |
Two things follow. Give a reasoning model at least twice the output it actually needs, because the
thinking is the part that varies. And treat a schema failure as a role that did not answer: it
arrives as a 400, which no SDK retries, and it is recorded in `usage.jsonl` as a failed call while
the cycle carries on without that role.
"JSON mode" means the vendor documents JSON output but not a strict schema: the schema is put
in the system prompt, and the result is validated by the role's parser exactly as it is for
every other provider. A malformed answer is a refusal of form, never a decision.
```
tra agent propose --idea "..." --envelope envelope.toml --provider groq --model
tra agent propose --idea "..." --envelope envelope.toml --provider local \
--base-url http://localhost:8000/v1 --model
```
A missing key is refused before any network call, naming the variable. A connection drop or
a vendor-side error during a call is a failed call: it is written to `usage.jsonl` with its
class, the role gets no answer, and the session goes on; five failures in a row halt it.
Reasoning models spend most of their tokens and their seconds thinking before the JSON. On a
bar cadence that is the latency that matters: measured on one such model, 12 s median and 20 s
at the 90th percentile per call. `[agents].max_output_tokens` caps the thinking; set it below
what the model needs and it returns no JSON at all, which the vendor reports as a validation
failure and the ledger records as a failed call. `decide_every_bars` is the other lever.
A local server that needs no key gets a placeholder, because the client library insists on
one.
## Picking a model
```bash
tra models groq # every model your key can use there
tra models google --filter flash # only the names that contain "flash"
tra init my-desk --profile autonomous --agent groq --model openai/gpt-oss-120b
```
`tra models` reads the vendor's own model list with your key: no prompt is sent and nothing is
spent. Without a provider it reads `[agents].provider` from the desk. `tra init --agent` writes the
provider and model into the desk from the start; a provider with a default model (Groq, Google,
Anthropic) needs no `--model`, and one without says to run `tra models` first.
## Adding a provider
A vendor that needs its own client registers under the `trading_research_agents.providers`
entry point, or by calling `register`:
```python
from trading_research_agents.llm.providers import Provider, register
register(Provider("my-vendor", "MY_VENDOR_API_KEY", None, False, build_my_completer))
```
`build_my_completer(model, base_url)` returns anything with `complete(system=, user=,
schema=) -> Completion`. The completion carries the model name and the token counts, which is
what `usage.jsonl` records; a provider that cannot count tokens should say so rather than
return zero. A provider that can honour the run's call settings also passes `build_with`:
`build_with(model, base_url, options: CallOptions)`, where `CallOptions` carries
`max_output_tokens`, `timeout_s` (per request) and `max_retries`; the bundled adapters hand
all three to their SDK clients.
# The data gate
A wrong number is more dangerous than a missing number. A missing number stops the pipeline;
somebody notices, and the cost is an afternoon. A wrong number produces a confident verdict
that propagates into decisions made weeks later by people with no reason to doubt it.
Everything in `trading_research_agents.data` fails closed. A row that cannot become a trade is
counted and shown with its line and reason; it is never dropped silently and never defaulted.
A file whose layout drifted from the one the normaliser was written against stops the run
with the header it actually sent.
## The canonical trade
| Field | Type | Meaning |
|---|---|---|
| `ts_ns` | int | UTC epoch nanoseconds |
| `venue` | str | the plugin's name, or the `source` of a column map |
| `instrument` | str | the source's symbol, e.g. `BTCUSDT` |
| `price` | Decimal | as written by the source, scale preserved |
| `qty` | Decimal | as written by the source, scale preserved |
| `side` | `buy` / `sell` | the taker's side |
| `trade_id` | str | the source's own id |
| `seq` | int or none | a monotonic sequence when the source provides one |
Zero is not a price and not a quantity. A trade with either is refused at construction,
because a zero there is a defaulted field.
## Sources
Data reaches the gate one of two ways, and the gate does not distinguish them afterwards.
A **venue plugin** knows where a venue's public archive lives and how its rows are laid out.
Two ship with the package; any other is a small class registered under the
`trading_research_agents.venues` entry point (see [Venues](https://docs.bijectivelabs.dev/opensource/venues)). `tra data fetch --venue`
and `tra data audit --venue` take any registered name.
A **column map** takes any other file, CSV or Parquet: a vendor export, a Client's own recorder,
a venue with no plugin yet. See the next section, and [Your own data](https://docs.bijectivelabs.dev/opensource/your-data) for
every kind of data a desk can bring.
### Bundled venue plugins
Both publish their trade history without an API key.
| Venue | URL | Layout |
|---|---|---|
| Bybit | `https://public.bybit.com/trading//.csv.gz` | header row; `timestamp` in seconds with fractional part; `side` is `Buy`/`Sell`; `trdMatchID` is the trade id |
| Binance spot | `https://data.binance.vision/data/spot/monthly/trades//-trades-.zip` | one CSV, no header: `trade_id, price, qty, quote_qty, time, is_buyer_maker, is_best_match`; `time` in milliseconds before 2025 and microseconds from 2025-01 |
The Binance time unit is inferred from the digit count (13, 16 or 19) and any other width is
rejected. `is_buyer_maker = True` means the buyer rested and the seller crossed, so the taker
side is `sell`.
Archives are read in place (`.csv.gz`, `.zip` with one member, or `.csv`); nothing is unpacked
to disk.
### Any other source: a column map
A `mapping.toml` names every field and its unit; nothing is guessed. The file can be a CSV or a
Parquet file; a Parquet file's own types are read as
[Your own data](https://docs.bijectivelabs.dev/opensource/your-data#parquet) describes, and then through the same map.
```toml
[mapping]
source = "vendor-x" # recorded as the trade's venue field
ts = "timestamp"
ts_unit = "ms" # s | ms | us | ns | iso8601 (must carry a timezone)
price = "price"
qty = "amount"
trade_id = "id"
side = { column = "side", buy = ["buy", "BUY"], sell = ["sell", "SELL"] }
# seq = "sequence" # optional monotonic sequence
# instrument = "symbol" # optional; rows for another instrument are rejected
has_header = true
delimiter = ","
# columns = ["timestamp", "price", "amount", "side", "id"] # headerless files only
```
```bash
tra data audit export.csv --mapping mapping.toml --instrument BTCUSDT --max-gap 5m \
--manifest data/manifest.json --parquet data/btc.parquet
```
Columns are matched by name, in any order. A column the map names and the file lacks stops
the file with the header it actually had. A row whose time, price, quantity or side cannot
be read is a `Rejection` with its line number. An ISO 8601 time without a timezone is
rejected rather than assumed UTC. The audit, the manifest and the Parquet that follow are
the same as for a venue archive; the manifest records `source` as the venue.
## Live sources, and why they come in chains
An archive is yesterday. A cycle needs what the market is doing now, and the way to get that
without letting an agent near a vendor is to have the engine fetch it, stamp it, and write it
down. That is `data/sources/` and `tra data pull`.
```bash
pip install 'trading-research-agents[ccxt]'
tra data pull BTCUSDT # chains from data.toml, snapshot into runs/pulls/
```
```toml
[data.ohlcv]
chain = ["venue", "file:data"]
ttl = "90s" # older than this is not a price, it is a memory
interval_s = 60
limit = 240
on_exhausted = "refuse" # refuse | use_stale_with_flag
```
Three rules do the work.
- **A chain, not a source.** The first source that answers inside `ttl` wins. Every attempt is
written into the snapshot, so a rate limit on a free endpoint reads as a line rather than as a
mystery. `file:data` at the end of a chain means an operator with no keys and no quota still
gets a cycle, from the days already in `data/`.
- **Every value carries its age.** Age is measured in market time, not in the time we fetched:
a candle's age is counted from when its interval closed. Nothing older than `ttl` is handed to
a decision. The `ttl` for candles is written for one-minute bars: on a longer bar the newest
closed candle is up to a whole bar old before the next one closes, so the system adds that wait.
A fifteen-minute bar with `ttl = "90s"` is fresh for 15m30s after it closed, and no longer. When the chain is exhausted the default is to refuse the kind of data outright;
`use_stale_with_flag` is available, and then the snapshot marks the value stale and says so in
every report that follows.
- **Public data only.** These sources are never given a key, which is why they cannot place an
order even by accident. Execution reads its own credentials in its own package.
Source names are `scheme:target`, or `venue`. `venue` is the venue the desk trades on, read from
`[run].venue_key`, so the chains follow the desk when it moves
([Venues](https://docs.bijectivelabs.dev/opensource/venues#which-venue-the-data-comes-from)). `ccxt:` is a named venue's
public endpoints through ccxt, which speaks one interface to more than a hundred venues;
`file:data` is the project's own directory. A Client with a paid on-chain or derivatives vendor registers a source under the
`trading_research_agents.sources` entry point and then names it in a chain, exactly like a venue
plugin: this package never depends on a vendor to support one.
One snapshot per cycle, written where the cycle keeps the rest of its papers:
```json
{
"cycle_id": "2026-09-27T10-13-30Z",
"instrument": "BTCUSDT",
"values": {
"funding": {"source": "ccxt:bybit", "tier": "free", "age_s": 0.4, "rate": "0.0000611"},
"orderbook": {"source": "ccxt:bybit", "tier": "free", "age_s": 0.5, "spread_bps": "0.01"}
},
"attempts": [{"kind": "ohlcv", "source": "ccxt:binance", "outcome": "error", "detail": "RateLimitExceeded"}],
"stale": [],
"refused": {}
}
```
Measured on one venue's public endpoints on 2026-09-27: a closed minute candle arrived sixteen
seconds after its close, a twenty-five level book half a second old, a funding rate under a
second old, and open interest stamped fifteen minutes back. That last one is the reason every
kind has its own `ttl`: one number for all of them would either reject the open interest a venue
publishes slowly or accept a book that is no longer a book.
### A venue's own parameters
`[data.].params` is a table handed to the source verbatim. Venues differ in ways no generic
adapter should pretend away, and the operator's file is where a vendor's spelling belongs.
The case that made this necessary, measured on 2026-09-28 against ccxt 4.5.84: asked plainly, Bybit
answers `fetch_open_interest` with the last hourly bucket, so the figure is up to an hour old and
any freshness window under an hour refuses by construction. Asked for a finer bucket it answers
about two minutes back:
```
[data.open_interest]
chain = ["venue"]
ttl = "10m"
params = { bybit = { intervalTime = "5min" } }
```
Keyed by venue, a parameter reaches only the venue that spells it that way, so the same chain can
follow the desk to another venue. A flat table, `params = { intervalTime = "5min" }`, goes to
every venue in the chain.
The parameters a kind was fetched with are recorded beside the value, because they are part of why
the age is what it is. Both `tra data pull` and `tra run` pass them.
## The shelf: what the reading analysts are given
Live values come in chains, above. Everything a reading analyst sees comes from the shelf instead:
one declaration file per class of data, beside the config, under `sources/`.
| File | Class | Shape | Read by |
|---|---|---|---|
| `sources/macro.toml` | dated levels and rates | `[[series]]`, one per file | `macro_analyst` |
| `sources/news.toml` | dated text | `[[news]]`, one per file | `macro_analyst`, `sentiment_analyst` |
| `sources/sentiment.toml` | dated scores | `[[series]]` | `sentiment_analyst` |
| `sources/onchain.toml` | dated chain figures | `[[series]]` | `onchain_analyst` |
| `sources/derivatives.toml` | dated funding, open interest, basis | `[[series]]` | `onchain_analyst` |
The split is by class of data, not by vendor. A vendor is a line in a file, so a desk that moves
from a free export to a paid subscription edits one entry and nothing else changes: not the code,
not the briefs, not the prompts. That also means no vendor's format becomes the shape everything
else has to match.
Nothing on the shelf fetches. Each entry names a local file the operator produced, with its
columns, its timestamp unit and its `publish_lag`, and the series engine applies the same as-of
rule to all of them. `[[news]]` entries name dated-text files (`when,what[,source]`) and the macro
analyst reads them as events while the sentiment analyst reads them as notes.
`tra init` writes all five files as forms with the entries commented out. A form declares nothing,
so an untouched project asks only the technical analyst. `tra check` prints what each file
declares, flags an export that is declared but missing, and prints the roster.
## The audit
```python
from datetime import timedelta
from trading_research_agents.data import archive_venue, audit, open_archive_lines
rows = archive_venue("bybit").normalise(open_archive_lines(path), instrument="BTCUSDT")
report = audit(rows, max_gap=timedelta(minutes=5))
report.passed, report.findings
```
With a column map, `mapped_trades(lines, load_mapping(path), instrument=...)` stands in for
the plugin's `normalise`; everything after it is the same call.
One pass, bounded memory. `max_gap` is declared by the caller because it belongs to the
instrument: a quiet hour on a thin listing is normal, on the largest perpetual it is a dead
feed, and the file cannot tell the two apart.
| Check | Severity | Fires when |
|---|---|---|
| `empty` | fail | no row became a trade |
| `rejected_rows` | fail above `max_rejected_fraction` (default 0.1 percent), else warn | rows could not become trades |
| `mismatch` | fail | a row is for another venue or instrument |
| `duplicates` | fail | a trade id repeats within a window of 10,000 rows |
| `out_of_order` | fail | a timestamp goes backwards |
| `sequence_resets` | fail | a sequence number does not increase |
| `gaps` | warn | an interval between trades exceeds `max_gap`; the five largest are listed |
| `price_scale`, `qty_scale` | warn | the number of decimal places changes within the file |
| `concentration` | warn | one UTC day carries more than half the rows of a multi-day file |
`passed` is true when at least one row became a trade and nothing failed. Warnings are kept in
the manifest for the reader to judge; the audit does not judge them.
## The manifest
Written beside the data as `manifest.json`: venue, instrument, first and last timestamp (UTC),
rows accepted and rejected, every source file with its SHA-256 and size, the normaliser
version, the audit result with all findings, and the creation time. A manifest that does not
parse as this schema is refused, and a manifest is never written for a dataset with no rows.
## The dataset
`pip install 'trading-research-agents[data]'` adds `pyarrow`, and with it the storage contract:
one Parquet file per archive, price and quantity as `decimal128(38, s)` with the scale the
venue actually uses, timestamps as UTC nanoseconds, `zstd` compressed, the scales recorded in
the file's own metadata.
```python
from trading_research_agents.data.parquet import read_trades, write_trades
write_trades(trades, path, price_scale=2, qty_scale=3)
for trade in read_trades(path):
...
```
- A value that cannot be represented exactly at the declared scale is refused, and no file is
left behind. A wider scale pads; it never rounds.
- Reading returns the trades exactly as written, nanoseconds included, so a round trip is an
equality check and not an approximation.
- `tra data audit ... --parquet out.parquet` writes the dataset only when the audit passed,
in a second pass over the archive, with the scales the audit observed.
## One command for a day
```bash
tra data get BTCUSDT 2025-01-01 # venue from strategy.toml, or --venue
```
fetches the archive, audits it, writes `data/BTCUSDT-2025-01-01.parquet` with its manifest,
and, when the venue plugin publishes funding, `data/BTCUSDT-funding.json` for the day and
its neighbours. `--max-gap` defaults to `[data].max_gap` in `strategy.toml`, else five
minutes, and the choice is printed. The steps below are what it does, one by one, for anyone
who needs them apart.
## Downloading
`tra data fetch --venue NAME` asks the venue plugin for the archive URL and, when the venue
publishes one, the URL of its checksum. The archive streams to a `.part` file, is hashed as
it arrives, checked against the declared length and the checksum, and renamed only then. A
mismatch leaves nothing on disk.
# The trading agent
Two agents, one ledger. The research agent turns an idea into a sealed pre-registration and a
layer-5 verdict. The trading agent takes a strategy that registration describes and runs it in
real time, through the same gates, and its closed round trips go back to layer 5 as
`trades.jsonl`. Neither can be used without the other: layer 6 demands an exit bound to the
mechanism the memo named, and the runtime demands controls that the research never needed.
```
feed -> strategy.on_bar -> controls.admit -> venue.submit -> exits (layer 6) -> trades.jsonl
```
In this runtime, which runs a scripted strategy, no language model is anywhere on the order
path: not to decide, not to assess risk, not to narrate an incident mid-run. The agent desk
(`tra run`) is described in [The cycle](https://docs.bijectivelabs.dev/opensource/cycle). Capital is governed by the system that is tested and fails closed.
## What this release runs, and what it does not
| Tier | Feed | Venue | Command |
|---|---|---|---|
| T0 research | recorded (Parquet from `tra data audit`) | simulated | `tra backtest` (`trade replay`) |
| T1 paper | live public trades | simulated | `tra paper` (`trade shadow`) |
| T2 shadow | live public trades | simulated, intents recorded, zero orders | `tra paper` (`trade shadow`) |
| T3 canary | live public trades | any ccxt venue, hard caps | `tra live` (`trade live`), see [The live tier](https://docs.bijectivelabs.dev/opensource/live) |
| T4 production | | | **not in this release**; the ladder refuses it |
The common spellings are aliases of the tiers. Real orders exist only at canary, through a
plugin behind a security review that runs as tests ([The live tier](https://docs.bijectivelabs.dev/opensource/live)); the research this
descends from never reached the live tier because its funnel worked, and the canary exists
to find out whether the venue behaves the way the simulation assumed, not to make money.
## The decision core
```python
class Strategy(Protocol):
name: str
def on_bar(self, bar: Bar) -> Signal | None: ...
```
Pure: internal state is built only from bars already seen; no clock, no socket, no order. A
`Bar` with no trades is empty and is never filled from the previous close. `Signal` says what
the core wants (`long`, `short`, `flat`) and why, in words the ledger keeps.
The package ships no strategy. A run names its core in `strategy.toml`, by file or by
import, and the parameters it is built with:
```toml
[strategy]
file = "cores/my_core.py" # looked up next to strategy.toml first, then from the cwd
class = "MyCore"
# import = "my_package.cores:MyCore" # or a core installed as a package
[strategy.params]
lookback = 60
```
[`examples/strategies/`](https://github.com/BijectiveLabs/trading-research-agents/blob/master/examples/strategies/) holds two cores so the runtime and the harness can be exercised end
to end: `trailing_zscore.py` for the runtime and `pair_reversion.py` for the walk-forward
harness. They come from the research record this project descends from, where both were
measured and closed. They are examples of the mechanism, not recommendations, and nothing
in the package imports them. A test fails the build if a decision core appears inside the
package.
The walk-forward harness is the same shape: `walk_forward(panel, core, lookback=...)` takes
a `FittedCore` that says how to fit a trailing window and what to do with the fit, refits
at every bar, freezes the parameters at entry, and refuses a precomputed one. The harness
is the mechanism; the core is yours.
## Exits bound to the mechanism (layer 6)
Every position carries an `ExitRule` declared before it opens: a stop as a fraction of price,
an optional take-profit, a timeout in bars, and one sentence saying why this exit is where the
mechanism is done. The rule refuses to exist without the sentence.
Stops are simulated on every closed bar with deliberately adverse assumptions: a touch is a
fire, a gap through the stop fills at the worse open, a bar that touches both take-profit and
stop is a stop, a take-profit fills exactly at its limit. Only OHLC is known inside a bar, and
a favourable guess manufactures an edge that does not exist.
## The runtime
- Entries fill on the first print at or after the measured latency, never on the print that
fired the signal. Slippage is charged on the adverse side.
- Timeouts and signal exits go back through the venue like any other intent.
- The cost of every round trip comes from the cost identity with the operator's envelope.
- Every intent (admitted or refused, with the reason), every fill, and every closed round trip
is appended to its own file as it happens: `intents.jsonl`, `fills.jsonl`, `trades.jsonl`,
and `run.json` at the end.
- One code path. The runtime sees the feed and the venue as protocols; replay and shadow
differ only in what they are given.
## What a round trip went through, not only what it earned
Every closed round trip in `trades.jsonl` carries two more figures beside its net: the furthest
the position ever stood in front, and the furthest it ever stood under water, both in basis
points against the entry and both with the bar they happened on.
| Field | Meaning |
|---|---|
| `mfe_bps` | maximum favourable excursion: the best the position ever stood, positive |
| `mae_bps` | maximum adverse excursion: the worst it ever stood, negative |
| `bars_to_mfe`, `bars_to_mae` | which bar each happened on, counting from the entry |
| `bars` | how many bars the position was held through |
The runtime already walks every bar while a position is open, so this costs one comparison per
bar. The figures are signed the way a trader reads them, whichever way the position was facing: a
short is helped by the bar's low and hurt by its high.
Why they are worth keeping. A trade that closed at plus two after standing twenty in front is an
exit problem, and no amount of work on entries will fix it. A trade that closed at plus two after
standing fourteen under water is a stop that nearly fired, and it will fire on the next one like
it. Neither fact is visible in the net, and both change what an operator should do next.
The risk manager's brief carries the summary over a run: the median and the best favourable
excursion, the median and the worst adverse one, and the median of what the winners gave back
before they closed. `execution/excursion.py` computes it; nothing about it enters the cost
identity, and nothing about it changes what a trade earned.
## Controls
The per-strategy tier of the circuit-breaker hierarchy, independent of the strategy:
| Control | Default | Behaviour |
|---|---|---|
| `max_risk_per_trade_bps` | `50` | stop distance times notional over equity; unknown equity refuses |
| `max_position` | none | size cap per order, in units; `max_position_quote` says it in quote currency |
| `max_orders_per_minute` | `6` | rate limit at the venue layer, so an order storm from a strategy bug is refused there |
| `daily_loss_limit_bps` | `300` | once tripped, no new entries that UTC day |
| `max_drawdown_bps` | none | from the highest equity across days; no new entries until the peak file is cleared |
| `on_drawdown` | `stop` | `flatten` also arms the kill switch: positions flattened, run stopped |
| kill switch | `KILL` in the template | a file or a call; once armed it stays armed, the position is flattened, and the run stops |
Flattening is always admitted, kill switch included. A control that blocks the exit is a trap.
A clock that goes backwards refuses rather than evaluating.
## The ladder
`tra ladder promote --evidence "..." --signed-by "..."` moves one tier up and needs both. `tra
ladder demote --reason "..."` moves down on a reason alone. The asymmetry is deliberate: up
slowly with evidence, down instantly. Canary is reachable when a live venue is
available, which is any venue ccxt knows once the `ccxt` extra is installed; production is refused by this release, and the ladder says so. `tra live` runs
only when `ladder.json` stands at canary.
## Running it
```bash
tra init my-run && cd my-run # strategy.toml, envelope.toml, an example core, .env.example
tra data get BTCUSDT 2025-01-01 # fetch, audit, Parquet, funding: data/BTCUSDT-2025-01-01.parquet
tra check # what is ready, what is missing, in plain words
tra backtest # newest dataset in data/, funding beside it, out under runs/
tra chain validate --registration runs//preregistration.json \
--trades runs/backtest-/trades.jsonl --cost-bps 15 --block-length 7
pip install 'trading-research-agents[shadow]'
tra paper # live public trades; touch KILL to flatten and stop
```
Every flag the commands used to need still exists (`--config`, `--envelope`, `--parquet`,
`--out`, `--dataset`); inside a project they default to the convention above. A run that
spans a funding settlement without a rate on record is refused, because a missing rate is
not zero; `tra data get` fetches the rates with the day, and `[run].funding` or
`data/-funding.json` is where they are read from.
`strategy.toml` carries every parameter of a run; nothing is a constant in the logic. A
relative `[strategy].file` is looked up next to the config first, so a run directory carries
its own core.
## The live feed
Live feeds are plugins, found by name through `trading.feeds`. `tra trade shadow` takes the
name from the venue half of `[run].venue_key` (`bybit/taker-taker` selects `bybit`) or from
`--feed NAME`, and refuses a name it cannot stream before opening a connection. Every venue ccxt
streams is a feed, through the `ccxt` extra (`[shadow]` installs the same thing);
[Venues](https://docs.bijectivelabs.dev/opensource/venues) says how to add one ccxt does not cover.
The feed reads the venue's public trade stream with no key, through ccxt's streaming side, which
keeps the socket, the venue's ping and the subscription. Trades on a contract market arrive in
contracts and are turned into the base. A trade the venue sends twice is passed on once; a trade
older than the last one passed on is counted and not passed on, because bars are built in time
order; a trade that does not parse stops the feed rather than leaving a gap that reads as a quiet
market. A dropped connection is reopened, and counted.
# The live tier
Real orders exist in this release at one tier, canary (T3), behind a security review that runs as tests rather than sitting in a document. Production (T4) is not
reachable; the ladder refuses it, and a canary session is what produces the evidence that will
one day argue for it.
```
tra live --config strategy.toml --envelope envelope.toml --out runs/live-1 --env-file .env
```
`tra live` is `tra trade live`. It runs the same loop as replay and shadow with two
differences: the venue sends orders, and every stop, take-profit and timeout is routed through
it as a reduce-only order instead of being settled against the bar. No language model is
anywhere on this path.
The live tier trades linear perpetuals. Spot pairs, stocks and prediction outcomes are traded by an
agent desk in venue mode (`tra run`), which has its own checks ([Venues](https://docs.bijectivelabs.dev/opensource/venues)).
The venue is any exchange ccxt knows, named in `[live].venue`, and it is reached the same way
venue mode reaches it: the same client built from the environment, the same switch to the demo
or testnet, the same market type and contract size, the same position read. There is no second
adapter per exchange. A plugin registered under `trading_research_agents.live_venues` still
takes precedence for its name, for a venue ccxt does not cover.
## What has to be true before the first print
| Check | Refused when | Where |
|---|---|---|
| Tables | `[live]` or `[canary]` is missing from `strategy.toml` | config |
| Keys | the variables the venue signs with (`BYBIT_API_KEY` and `BYBIT_API_SECRET`; OKX adds `OKX_API_PASSPHRASE`) are not in the environment or `--env-file`; or a key-like field appears in `[live]` | config |
| Ladder | `ladder.json` is not at CANARY; PRODUCTION is refused whatever the file says | `tra live` |
| Clock | the local clock is more than 1000 ms from the venue's, judged on the quickest of three readings | preflight |
| Account | the account is in long/short position mode, or (OKX) in a mode that cannot trade perpetuals | preflight |
| Key permissions | where the venue reports them (Bybit today): the key is read-only; carries any wallet permission; lacks ContractTrade Order and Position; is not bound to an IP (unless `key_ip_unbound_ok = true`); expires within 7 days | preflight |
| Market | the instrument is not a linear perpetual on the venue, or the venue does not say how large a contract is | preflight |
| Position | the venue already holds a position the run was not told to adopt (`adopt_open_position = true`) | preflight |
Every refusal names what to fix. The preflight report is written to `preflight.json` in the run
directory before any order is sent.
## During the session
- **One order at a time.** A second intent while one is pending is refused, as in simulation.
- **Polling.** An order's state is asked for at most every `[live].poll_interval_ms` (250 by
default), so a session does not spend the venue's rate limit on a fill it already knows.
- **Sized as priced.** The runtime sizes in the base and the venue counts contracts, so the size is
divided by the market's contract size, and a size off the venue's step is refused rather than
rounded into a different trade.
- **Idempotent submission.** Every order is a market order carrying a client id,
`tra`, letters and digits only. A send that fails in transit is looked up by that id
on the next poll before anything is sent again; only an order the venue does not know is sent
again, under the same id, and the venue answering "duplicate" is read as the first attempt
having landed. Three failed sends end the session.
- **Closes are reduce-only.** A reduce-only close the venue refuses means the model's position is
not the venue's; the session stops.
- **Fills are confirmed, not assumed.** The order is read back from the venue, and its filled
amount and average price are the fill, in the base. An order that has finished but does not yet
say what it filled is asked again rather than read as nothing. A partial fill is a smaller
position and a second reduce-only order for the remainder when it closes.
- **Reconciliation after every fill.** The venue's position must agree with the model to the last
unit. A disagreement is a `VenueError`: the loop stops, the kill switch is armed, nothing more is
sent. An order into an unknown position is the one thing worse than a stuck position, and the
operator resolves it by hand.
- **Rate limits.** ccxt spaces requests by the venue's published limit, and a request refused for
rate counts as a send that failed in transit.
- **Fees as charged.** The round trip in `trades.jsonl` uses the fee the venue reported on both
fills, not the envelope's quote; spread and slippage are already inside the fill prices, and
funding accrues from `[run].funding` when a settlement was spanned.
- **The kill switch.** Touching the `KILL` file flattens through the venue, waits for the fill,
and stops.
## The canary caps
```toml
[canary]
max_notional = 200 # per order, in quote currency
max_round_trips = 5
session_loss_limit_bps = 100
max_session_minutes = 120
```
None has a default. Crossing any of them refuses further entries, flattens an open position
through the venue, and ends the run with `stopped_by` naming the cap. The caps are set by what
a defect would cost, not by what the strategy might earn: a canary is there to find out whether
the venue behaves the way the simulation assumed.
## Secrets
Keys come from the environment. `--env-file .env` reads `KEY=VALUE` lines explicitly (the
environment wins on conflict), and the `.env` beside `strategy.toml` is read the same way; keys are
never read from `strategy.toml` or any other config. They are read in one place, where the client
is built, and every error from the venue passes through a filter that replaces the key and the
secret before it is written anywhere: a log, `intents.jsonl`, `venue_events.txt` or the error
itself. A trade key should be able to trade and nothing else: no withdrawal or transfer
permission, bound to the machine's IP, and rotated before the session if it expires within a week.
## Environments
`[live].environment` is `demo` (default), `testnet` or `mainnet`. A first canary belongs on demo.
They are three different places with three different accounts, and a key for one is rejected by the
others, so the environment is not a label, it is where the order goes. `tra live` and venue mode
reach them the same way, through ccxt's own switch for each; where a venue has no switch for the
environment declared, the session refuses rather than sending somewhere close. Mainnet is the
client's own default and is never switched into.
`tra keys --probe` checks the keys against the place they are declared for.
## Not built yet
In `tra live`: limit orders and maker channels (venue mode in `tra run` has both), and execution
reports pushed by the venue rather than polled. Each is open to a contribution that keeps
the guarantees in [Boundaries](https://docs.bijectivelabs.dev/opensource/boundaries). A production tier is not offered, and that one is a
guarantee rather than a gap: the ladder stops at canary.
# The falsification chain
A research programme fails in one of two ways. It commits capital to an idea that was never
real, or it spends months proving something is not real when a cheaper test would have said so
in an afternoon. The chain orders the ways an idea can die by what each test costs, and it
forbids skipping forward.
| Layer | Test | Dies here when | Cost | Status |
|---|---|---|---|---|
| 0 | Cost floor | plausible magnitude below the cheapest round trip | minutes | implemented |
| 1 | Market observation | the phenomenon does not recur | minutes | implemented |
| 2 | Mechanism memo | nobody can be named who is structurally forced to lose | minutes | 0.2 (language model writes it, the system checks the form) |
| 3 | Pre-registration | the claim cannot be stated so that data could refute it | hours | implemented |
| 4 | Data requirements | no available instrument can measure the premise | hours to days | implemented |
| 5 | Statistical validation | interval spans zero, loses to a matched null, or lives in one subperiod | days | implemented |
| 6 | Strategy design | the exit is not bound to the mechanism | weeks | implemented ([The trading agent](https://docs.bijectivelabs.dev/opensource/trading)) |
| 7 | Shadow | net expectation negative in the live market, zero orders placed | weeks of calendar | implemented (`tra trade shadow`, then `tra chain validate` on its trades) |
| 8 | Live capital | the canary loses against its own registration | capital | `tra live` at the canary tier, and venue mode in `tra run` ([The live tier](https://docs.bijectivelabs.dev/opensource/live), [Capital, orders and positions](https://docs.bijectivelabs.dev/opensource/capital)) |
## Layer 0: the cost floor
Two numbers, before anything else: the plausible magnitude of the signal and the round trip of
the cheapest channel in the operating envelope. If the first is smaller than the second, stop.
```python
from decimal import Decimal
from trading_research_agents.chain import cost_floor
from trading_research_agents.envelope import load_envelope
envelope = load_envelope("examples/envelope.toml")
check = cost_floor(envelope, Decimal("3.0"), venue="hyperliquid")
```
- The magnitude is a bound, not a measurement. A comparable measurement already on record, the
dispersion of the quantity the signal keys on, or the scale of the mechanism itself all give
one. Where no bound can be given, the hypothesis is not yet specific enough to test, and the
function refuses rather than guesses.
- A stop becomes a `CLOSED` row on the cost axis, `ENVELOPE-BOUND`, with the reversal point
`c_rt < bps`. A pass records nothing; clearing the cheapest test is not a verdict.
- The decision is made against the quoted schedule, which is the declared floor. Where the
envelope also carries a measured round trip, the check reports whether the signal clears that
too. Quoted cost is optimistic by roughly a third on the venues this was measured on.
## Layer 1: market observation
Before anyone explains a phenomenon, count it. An observation is an event the operator can
mark on closed bars and a response measured strictly after it; the layer reports how many
times the event fired, in how many slices of the sample, and what the response looked like.
It does not compute a p-value (layer 5, behind a pre-registration) and it holds no threshold
of its own: `observation.toml` declares the event, the horizon, the minimum count and the
number of subperiods.
```
tra chain observe # observation.toml, the newest dataset in data/
tra chain observe --dataset data/btc.parquet --ledger ledger.jsonl --id O-001 --out runs/o1/observation.json
```
```toml
[observation]
subject = "a burst of prints, then the close reverts"
interval_s = 60
horizon_bars = 5 # close(t+5) against close(t), in bps
min_events = 30
subperiods = 4
min_subperiods_with_events = 4
claimed_magnitude_bps = 20 # optional; reported against the median |response|
[event]
feature = "volume_ratio" # return_bps | abs_return_bps | zscore | volume_ratio | range_ratio | hour_utc
window_bars = 60
comparison = ">" # < <= > >= == between
threshold = 3.0
# or: calendar = "events.csv" # `when`, `what`; the mark is the last bar closed at or before `when`
```
- The event is decided at the close of bar t from the `window_bars` ending there; the response
is the close `horizon_bars` later against the close at t. A horizon bar with no trades leaves
that response unmeasured, counted, never filled in.
- Fewer measured events than `min_events`: `NOT PROVEN`, blocking axis `POWER`; a longer
sample decides. Events in fewer subperiods than declared: `FALSIFIED` on `CONDITION`,
`UNIVERSAL`; the phenomenon is episodic. A phenomenon that recurs records nothing; passing
layer 1 is not a verdict.
- `claimed_magnitude_bps`, when given, is put beside the median absolute response. The cost
decision stays with layer 0: run `tra chain floor` again with the measured bound.
- `observation.json` carries the declaration, the range, the counts per subperiod, the
response distribution at the horizon and the response curve bar by bar.
On one day of BTCUSDT perpetual prints (2025-01-01, 909,330 prints, 1,440 bars) the example
event fired 71 times across all four subperiods, so it recurs; its median absolute response at
five bars was well under the 20 bps claimed, which is the layer-0 stop the research record kept
reporting.
## Layer 3: the pre-registration
Nine elements, all required, sealed by a content hash before any data is seen.
| Element | Requirement |
|---|---|
| Mechanism | One sentence naming who is structurally forced to lose, and why |
| Claim | The falsifiable statement, in a form data could refute |
| Metric | The exact quantity to be computed |
| Horizon | Fixed. No horizon shopping afterwards |
| Universe | The instrument set, and the rule that produced it |
| Era | The research range, and the held-out eras that remain sealed |
| Cost model | All four terms of the round trip (`fee`, `spread_cost`, `slippage`, `funding_accrual`), each with its source |
| Power | The sample size required to detect the claimed effect, and how it was derived |
| Kill criteria | Numeric thresholds, each with the verdict it fires |
A kill criterion fires `FALSIFIED` or `NOT PROVEN`, never anything else. `CLOSED` is not a
kill criterion's to issue; it comes from the envelope.
```python
KillCriterion("net_return_ci95_lower_bps", "<=", Decimal("0"), Verdict.NOT_PROVEN)
KillCriterion("net_return_bps", "<", Decimal("0"), Verdict.FALSIFIED)
KillCriterion("permutation_p_value", ">=", Decimal("0.05"), Verdict.FALSIFIED)
```
### Sealing
`Preregistration.seal()` is the SHA-256 of the canonical JSON of the nine elements. The
written record carries the seal; a record whose content no longer matches its seal is refused
on read. Nothing is edited in place.
### Amendment
An amendment is a new dated registration that points at the seal of the one it amends. Both
are kept. It may:
- tighten a kill threshold (the kill region may only grow),
- add a criterion,
- correct a cost-term source or the universe after a live check found the assumption wrong.
It may not loosen or drop a criterion, and it may not touch the horizon, the metric, the claim
or the mechanism. Those changes are a new hypothesis, and they get a new registration.
### What a registration cannot do
It cannot make an underpowered test conclusive. If the declared sample is not reached, the
outcome is `NOT PROVEN` whatever the point estimate says. And it only locks the parameters
somebody thought of; a rebalance hour fixed early for an unrelated reason has the same
statistical effect as a chosen one. Layer 5 enumerates the phase space rather than trusting
memory.
## Layer 4: data requirements
```python
from trading_research_agents.chain import check_requirements
requirements = check_requirements(registration, manifest)
requirements.passed, requirements.blocking_axis
```
Three checks from the dataset manifest alone: coverage (the data spans the declared research
era), resolution (the horizon is at least ten times the mean spacing between trades, so the
instrument can see what is claimed), and power feasibility (enough non-overlapping horizons fit
inside the era to reach the declared sample). A failure here is never a death. It is
`NOT PROVEN` with a blocking axis of `data`, `cadence` or `power`: a null from the wrong
instrument is indistinguishable from a falsification, so it is reported as coverage-limited
and the hypothesis stays open behind it.
## The era seal
```python
from trading_research_agents.chain import Seal, research_only
research_only(timestamps, registration.era, who="my instrument")
seal = Seal("held-out B", specification_hash=registration.seal(), gates=("net > 0",), path=...)
seal.open(registration.seal(), opened_at=now) # once
```
Research never touches sealed data, and a seal is opened once. `research_only` raises on a
single timestamp outside the research era rather than clipping the request, because a silent
clip returns a result nobody can interpret. `Seal` is single-shot bookkeeping persisted in a
file written once: the specification hash and the acceptance gates are fixed at construction,
a second opening is refused even with a different specification, and a request whose hash
differs from the pre-registered one is refused even on a fresh seal. The seal it descends from
caught a mechanism that had inverted out of sample after passing every gate before it, and
could only catch it once.
## Layer 5: validation
```python
from trading_research_agents.chain import TradeRecord, validate
result = validate(registration, trades, cost_of, block_length=..., n_resamples=5000, n_perm=1000)
result.decision.verdict, result.decision.axis, result.metrics
```
Layer 5 assesses results; it does not simulate strategies. Trades arrive as records
(`instrument, entry_ts_ns, exit_ts_ns, direction, weight, gross_return_bps`) and `cost_of`
returns each trade's round trip, normally from `execution.realised_cost`. Eight requirements
run, each of which replaced a specific way the research programme had fooled itself:
| Requirement | What runs | Prevents |
|---|---|---|
| Net from day one | every trade net of its own cost; gross reported beside it and never a verdict | a programme measured mid to mid, whose expectation changed sign once its costs were counted |
| Correlation-aware inference | collapse to one portfolio return per timestamp, then a moving-block bootstrap interval | fifteen instruments carrying the information of 1.33 treated as fifteen; a sign flip from +0.86 to -8.74 |
| Live-path sizing | the declared weights, not equal weights | a sign flip between aggregation conventions |
| Subperiod stability | the sample cut into three sequential blocks | a significant result that lived entirely in the final third of the sample |
| Declared power | timestamps against the declared sample; a kill without power is `NOT PROVEN` | underpowered results recorded as kills |
| Matched null | sign permutation (direction flipped, cost kept) or cross-sectional shuffle (weights permuted, turnover guarded) | a time-shuffle control that manufactured an edge of its own |
| Multiple-testing correction | `benjamini_hochberg` for a family of tests | a best-of-N result reported as a single finding |
| No post-hoc selection | `anchor_robustness` over every rebalance phase, two-sided, terciles | a headline result that was the best of many undisclosed phases |
The null is costed: the control pays the same round trip, so its mean sits near minus the cost,
and a null centred at zero is the sign that costs were not applied. Its p-value never reaches
exactly zero. Where the published specification and the reference implementation disagree,
the specification is followed and the difference is stated in the docstring: a block that does
not fit the sample is refused rather than clamped, and a missing value is refused rather than
dropped.
The verdict applies the registration's own kill criteria to the computed metrics
(`net_return_bps`, `net_return_ci95_lower_bps`, `permutation_p_value`,
`subperiod_min_net_bps`, ...). A fired `FALSIFIED` criterion becomes `NOT PROVEN` when the
declared power was not achieved. A death on the sample's own merit is `idea`; one that lives in
a single subperiod is `condition`. An idea that survives is `OPEN`, awaiting shadow at layer 7.
Every result carries the registration seal and the SHA-256 of the trades it was computed from.
What layer 5 does not deliver: live results. On the one strategy the research programme
measured in all three environments, the walk-forward figure missed live by thirty to forty
basis points in the conservative direction. The number is on the right side of zero, which is
the decision it is used for.
# The kill ledger
"It did not work" is not a research finding. It is the absence of one. The ledger records
what was tested, how it ended, and on which axis, so that a death on cost does not close a
branch that was never tested on its idea.
## Verdicts
| Verdict | Meaning |
|---|---|
| `FALSIFIED` | A pre-registered kill criterion was met, with the statistical power to support it |
| `CLOSED` | The mechanism measured real, and a declared envelope parameter blocked it |
| `NOT PROVEN` | The interval spans the threshold, or declared power was not achieved. Not a kill |
| `OPEN` | Under test, or awaiting a measurement not yet taken |
| `CONFIRMED` | A fact established about a venue. Not a tradeable claim, carries no axis |
| `VALIDATED` | A component that passed its acceptance criteria and is running. Carries no axis |
| `WITHDRAWN` | A previously issued verdict, retracted. Names the row it retracts and says why |
`NOT PROVEN` and `FALSIFIED` are enforced as different words. A hypothesis is only killed when
the measurement had the power to kill it. Without that power the result is an absence of
evidence, and labelling it a falsification poisons every decision that inherits the label.
## Axes
| Axis | The hypothesis died because |
|---|---|
| `idea` | the proposed mechanism is not present in the data |
| `cost` | the mechanism is present and smaller than the execution cost |
| `data` | the instrument could not measure the premise |
| `cadence` | the measurement was too slow to observe the phenomenon |
| `power` | the sample was too small to distinguish the effect from zero. **Not a death**; produces `NOT PROVEN` |
| `condition` | the result exists only in one regime or one stretch of the sample |
| `method` | the test itself was constructed wrongly. A death of the test, not of the idea |
| `capital` | the mechanism needs more working capital than was available |
| `venue` | the mechanism is priced in an instrument that could not be reached |
| `adverse selection` | passive execution is picked off faster than it earns |
| `mechanism inverted` | the effect held in research and reversed sign out of sample |
More than one axis may be recorded, in order. `data, then idea` means a defect in the
instrument exposed the result first, and the hypothesis then died on its merits once the
defect was fixed. Transferability follows the final axis.
## Transferability
| Classification | Meaning | Axes |
|---|---|---|
| `UNIVERSAL` | The mechanism itself failed. No change to fees, latency, cadence, capital or venue rescues it | idea, condition, method, mechanism inverted |
| `ENVELOPE-BOUND` | The mechanism held and a declared parameter blocked it. A reader with different parameters should expect a different answer | cost, cadence, capital, venue, adverse selection |
`data` is resolved case by case: a recorder that sampled too coarsely is envelope-bound
because finer data exists; a field the venue does not publish is universal until the venue
changes.
An `ENVELOPE-BOUND` verdict records its **reversal point**: the parameter and the value at
which the verdict reverses. A cost-axis verdict closed at an 11.0 bps round trip carries
`c_rt < bps`, and is a live question for any operator below that.
## Consistency rules
These are enforced when an entry is constructed; an entry that breaks them does not exist.
| Verdict | Axis of death | Blocking axis | Transferability | Reversal point |
|---|---|---|---|---|
| `FALSIFIED` | required; never `power` | none | required; must match the final axis | only if envelope-bound |
| `CLOSED` | required; never `power` | none | must be `ENVELOPE-BOUND` | required |
| `NOT PROVEN`, `OPEN` | none | required | none | none |
| `CONFIRMED`, `VALIDATED` | none | none | none | none |
| `WITHDRAWN` | none | none | none | none; must name `retracts` and give a reason |
All timestamps are UTC and timezone-aware. Entry identifiers are permanent and never reused.
## The file
`ledger.jsonl`, one JSON object per line, schema version 1. Rows are appended and fsynced.
Nothing is edited in place. A row that cannot be parsed stops the reader with its line
number; a row that breaks a rule above is refused on read as well as on write, because the
file may have been edited by hand.
The kill count is computed over rows that still stand: everything except retractions and the
rows they retracted.
# Execution realism
Backtests do not survive contact with the market, and that sentence is useless as guidance
because it names no mechanism. The gap is a stack of specific, individually measurable
assumption failures. `trading_research_agents.execution` carries the ones the research this
package descends from actually paid for, each as a function with the envelope as an argument.
Every figure here is a floor that omits market impact. Nothing this was measured on ran at a
size where its own transacting moved the book.
## The cost identity
```
c_rt = 2 * fee + spread_cost + slippage + funding_accrual
```
```python
from trading_research_agents.execution import Direction, FundingSchedule, realised_cost
breakdown = realised_cost(
channel=envelope.cheapest("bybit"),
direction=Direction.LONG,
entry_ts_ns=...,
exit_ts_ns=...,
spread_cost_bps=Decimal("3.0"), # half-spread from the recorded book at the decision
slippage_bps=walk.slippage_bps, # from walking the recorded depth at the intended size
funding=FundingSchedule(interval_s=8 * 3600, rates={...}),
)
breakdown.round_trip_bps # the sum, and the four terms beside it
```
- One function owns the identity, every term is an explicit argument, and the terms come
back separately. Four terms living in four places is how an absent term stays invisible; a
whole programme once measured returns mid to mid, and its expectation changed sign once the
costs were counted.
- Fees come from the envelope's declared channel, never from a literal. A hedged pair charges
`fee * (1 + beta)` per touch: at a hedge ratio of 1.4 the pair transacts 2.4 legs, not 2.
- Funding is signed. A long through a positive-funding settlement pays; a short receives. A
position that spans no settlement owes zero, and that zero is real. A settlement the position
did span with no rate on record is refused, because that zero would be a defaulted field.
- The quoted schedule is a floor. Measured end to end, taker-to-taker on the venue this was
built on cost 15.02 bps against a quoted 11.0.
## Walk the book
```python
from trading_research_agents.execution import Level, Side, Snapshot, walk_the_book
result = walk_the_book(asks, size=Decimal("2"), side=Side.BUY, mid=mid)
result.vwap, result.slippage_bps, result.levels_consumed
```
Consumes recorded depth level by level and reports the VWAP against mid in basis points, signed
so that paying more than mid is a positive cost on either side. When the depth cannot absorb
the size it refuses. It does not extrapolate the last level and it does not price a partial
fill as complete; that lie would be most attractive on exactly the thin instruments where a
naive backtest looks best. `Snapshot.walk` adds a staleness tolerance: a book recorded at an
event and read seconds later is not the book that was there.
The spread is normalised to mid, never to bid; a locked book is zero and a crossed book is
refused.
## Passive fills
```python
from trading_research_agents.execution import FillMode, Print, passive_fill, entry_after_latency
fill_ts = passive_fill(
prints, limit=..., side=Side.BUY, t0_ns=..., budget_ns=..., mode=FillMode.THROUGH
)
first = entry_after_latency(prints, signal_ts_ns=..., latency_ns=...)
```
A buy rests a bid and fills on a print at or below it. A print that sweeps through the level
without printing at it is a fill: an earlier matcher that counted only discrete events at the
level missed 55.7 percent of adverse fills at one instrument, and every missed fill was
adverse. Queue position is not recorded, so two bounds are reported: `AT_OR_THROUGH` (a print at
the limit counts; upper bound on the fill rate) and `THROUGH` (only prints past it; lower bound).
Entries fill at the first print at or after the signal plus the measured latency, not at the
tick that fired the signal.
## Effective cost
```
c_eff = p_fill * c_passive + (1 - p_fill) * (c_cross + drift_unfilled)
```
The orders that fail to fill are not a random subset. They are the names moving away, which is
why they did not fill, and their drift compounds while the order rests: the longer the budget, the
further the unfilled names had moved away. A cost model that treats an unfilled order as costless
gets the wait-budget decision backwards.
`effective_cost` refuses if the drift on any unfilled order cannot be computed, because dropping
it would remove exactly the observations that make the number informative.
## Markout
```
markout(h) = side * (mid_{t+h} - fill_price_t) / fill_price_t * 1e4
```
Negative is adverse. It is not a cost term: every other cost is indifferent to how good your
price is, while this one grows with how attractive your quote is. `markout_table` reports each
horizon beside the half-spread being earned and whether the maker survives it; on the control
instrument the adverse move at sixty seconds ran to about four times the spread captured.
# The agent
An idea in plain language goes in. One of five recorded outcomes comes out. Language models
are used at exactly three points, and every decision between them is made by the system against the
gates that already exist.
```
idea
-> [model: bound] plausible magnitude, with its basis
-> [system: floor] cost_floor(envelope, magnitude) stop -> CLOSED in the ledger
-> [model: memo] one sentence: who is forced to lose, and why
-> [system: form] one sentence, one line, long enough to name a party
-> [model: critique] does it name a forced loser, or describe a pattern?
-> [model: draft] the nine elements of a pre-registration, as JSON
-> [system: build] Preregistration(...) with the validation that already exists
-> [system: seal] seal, write preregistration.json, OPEN in the ledger
```
| Outcome | Meaning | Recorded where |
|---|---|---|
| `stopped_at_floor` | the declared magnitude is below the cheapest round trip | kill ledger: `CLOSED`, cost axis, reversal point |
| `refused_layer_0` | no basis for a magnitude could be given | `refusals.jsonl` |
| `refused_layer_2` | after the allowed revisions, no forced loser was named | `refusals.jsonl` |
| `refused_layer_3` | after the allowed revisions, no valid pre-registration could be built | `refusals.jsonl` |
| `registered` | sealed pre-registration; awaiting measurement | kill ledger: `OPEN`, blocking axis `power`; `preregistration.json` |
Refusals are not verdicts. An idea that cannot name its mechanism was never a hypothesis, and
putting it in the kill ledger would mix rows that never carried a hypothesis into the
falsification count. They are counted and shown separately: `tra agent refusals`.
## Two kinds of rejection
A defect of form goes back to the model with the gate's own error text, up to
`max_revisions` times (default 2): a memo with two sentences, a cost model missing a term, a
kill threshold that is not a number. The text the model sees is the text the gate produced;
there is no second validator.
A fact about the idea is final: its bound is below the floor, or nobody can be named who is
forced to lose after the revisions are spent. Returning that kind would invite the model to
raise a number until it clears, and a gate that can be argued with is not a gate.
## The bound is a declaration, not a measurement
The magnitude at layer 0 comes from one of three sources, in order of preference: a comparable
measurement on record, the dispersion of the quantity the signal keys on, or the scale of the
concession the mechanism forces. The model names which one it used, and the basis is written
into the ledger row beside the number. If no basis can be given, the honest answer is
`basis: none`, and the run is refused rather than a number invented.
An operator can declare the magnitude directly (`--magnitude`), in which case the bound role
is skipped and the basis is recorded as `declared`.
## What the models never do
- They do not measure anything. Layers 1, 4, 5, 6 and 7 are data and statistics, and they are
worked out by the system, not by models.
- They do not change the mechanism after the critic accepted it. The registration draft is
built around the accepted memo, which is passed in and not re-asked.
- They do not see a return, a win rate, or a profit target anywhere in a prompt. The prompts
ask them to name and to formulate.
- They do not hold credentials. Keys are read by the vendor SDKs from the environment and are
written nowhere by this package.
## The session roles
The three research roles above are one of two things the package calls an agent. The
other is the session: seven roles with full access to the gates, the technical analyst
among them as a decision core. [Agents](https://docs.bijectivelabs.dev/opensource/agents) describes it; [Model providers](https://docs.bijectivelabs.dev/opensource/providers) lists the
model providers both share.
## Providers
```
pip install 'trading-research-agents[anthropic]' # ANTHROPIC_API_KEY, default model claude-opus-5-5
pip install 'trading-research-agents[openai]' # OPENAI_API_KEY, --model required
pip install 'trading-research-agents[google]' # GEMINI_API_KEY
```
Any server that speaks the OpenAI API (a local model server, a relay, another vendor's
compatible endpoint) is reached through the openai adapter with `--base-url` or
`TRA_OPENAI_BASE_URL`; a local server that needs no key gets a placeholder. The URL is https,
or http on localhost only.
All adapters request structured output against the role's JSON schema and both results are
still validated by the caller. The Anthropic adapter marks the system prompt for prompt
caching, since it is the frozen doctrine and never changes between calls. Tests use a
`FakeCompleter` with scripted responses and never touch the network.
## Every run leaves a trail
```
runs//
01-bound.json system, user, schema, raw response, model, tokens, time
02-memo.json
03-critique.json
04-registration.json
preregistration.json when registered
run.json outcome, ledger entry, token totals, models used
```
A ledger row written by the agent can be traced back to the exchange that produced it. That is
what makes a kill count worth reading.
## Command line
```bash
tra agent propose --idea "Longs paying high funding get squeezed into settlement" \
--envelope examples/envelope.toml --provider anthropic \
--ledger ledger.jsonl --refusals refusals.jsonl --runs runs/
tra agent refusals
```
Exit 0 when registered, 1 when stopped or refused, 2 for a usage error. `--json` on either.
## Orchestration
There is no agent framework underneath. The pipeline is a handful of pure steps with two
bounded loops, and a run lasts seconds. Every step is a function of its inputs so it can be
placed into a graph runtime later without being rewritten. The condition for adopting one is
written down: the first release that needs parallel fan-out with checkpointed partial results,
or runs that must survive a process restart.
# The MCP server
Agents are a surface. The language-model roles inside the package call the gates at layers 0,
2 and 3; the MCP server lets any agent host outside the package call the gates too. It reads.
It does not trade, promote a tier, or write a ledger row. That is a guarantee, not a gap: capital
is governed by the system that is tested and fails closed, not by a tool a model can call. Read tools are
open to contributions.
```
pip install 'trading-research-agents[mcp]'
tra mcp serve # stdio; what desktop hosts launch
uv run mcp dev -m trading_research_agents.mcp.server # or point the Inspector at a file
```
A host configuration names the command and nothing else:
```json
{ "mcpServers": { "trading-research-agents": { "command": "tra", "args": ["mcp", "serve"] } } }
```
## Use it from your own agent
Your agent does not need this package installed to use it. With [uv](https://docs.astral.sh/uv/)
on the machine, the host starts the server on demand, at the released version, with the MCP SDK
it needs:
```json
{
"mcpServers": {
"trading-research-agents": {
"command": "uvx",
"args": ["--with", "mcp>=2,<3", "trading-research-agents", "mcp", "serve"]
}
}
}
```
Any host that launches a stdio server takes this, desktop chat apps and coding agents alike. Add
`"--root", "/path/to/project"` at the end to let the tools read a project other than the directory
the host starts in. From 0.6.8 the package installs a `trading-research-agents` command next to
`tra`, which is what `uvx` runs.
What your agent gets is the same set of checks this package's own roles use, and nothing that
trades: it can ask whether a claimed edge clears the round-trip cost, whether a dataset is fit to
test on, and what a validation run decided. Every tool reads; none sends an order, writes a
ledger row or moves the ladder. The entry for the MCP Registry is `server.json` at the root of the
repository, under the name `io.github.BijectiveLabs/trading-research-agents`. Glama builds the
server from the build spec on its admin page (Python 3.12, `uv sync --locked --no-dev --extra mcp`,
then `uv run --no-sync trading-research-agents mcp serve` behind its `mcp-proxy`), which
`glama/Dockerfile` records and reproduces, and CI checks on every change that both start and list
their tools.
## Tools
Every tool is annotated read-only and closed-world; ten of them, the same functions the
agent roles reach through their briefs and the auditor through its tool loop. Paths are resolved on the machine running the server; every number in basis
points is a decimal string; every time is UTC.
| Tool | Reads | Returns |
|---|---|---|
| `check_cost_floor(envelope_path, magnitude_bps, venue="")` | `envelope.toml` | layer 0: outcome, cheapest channel, round trip, margin, whether the claim stops here |
| `observe_events(dataset_path, observation_path)` | a Parquet dataset, `observation.toml` | layer 1: events, events per subperiod, the response distribution and curve, whether the phenomenon recurs |
| `check_registration(registration_path)` | `preregistration.json` | name, seal, horizon, instruments, required sample, kill criteria, what it amends |
| `audit_dataset(archive_path, instrument, venue="" \| mapping_path="", max_gap_minutes=5)` | a `.csv`, `.csv.gz` or `.zip` | the audit verdict with every finding |
| `validate_trades(registration_path, trades_path, cost_bps, ...)` | `trades.jsonl` | the layer-5 record: interval, null, subperiods, power, the decision with every fired criterion |
| `show_ledger(ledger_path="ledger.jsonl")` | the kill ledger | counts per verdict and every row that stands |
| `show_ladder(ladder_path="ladder.json")` | the ladder | tier and history |
| `read_run_report(run_dir)` | `run.json`, `trades.jsonl` | the run summary with closed round trips counted |
| `search_ledger(ledger_path, query, limit=10)` | the kill ledger | the rows that stand whose subject, note, evidence or axis mention the query's words, best match first |
| `list_runs(runs_dir, instrument="")` | every `run.json` under a directory | past runs, newest first, with their summaries |
A refusal from a gate (a registration that does not seal, a file that is not a trade archive,
an instrument outside the declared universe) comes back as a tool error the model can read
and act on. A crash does not: the tools wrap only the exceptions the gates raise deliberately.
## What is deliberately absent
`place_order`, `promote`, `record`, `demote`, `run_live`. A host that wants a model to place
orders has to write that tool itself, outside this package, and then explain why it did.
# Command line
Every command says what it did. Exit code 0 means cleared, 1 means a gate said no, 2 is a usage
error. `--json` gives machine-readable output and `--plain` gives text instead of panels.
`tra --help` lists every command and `tra --help` lists its flags.
## Getting started
| Command | What it does |
|---|---|
| `tra` | opens the console |
| `tra init my-desk --profile autonomous` | a project wired for the agents, every file commented |
| `tra check` | every setting read against the others, with what to fix |
| `tra keys --probe` | proves every key works, the exchange's included once its keys are set; no key is printed, no prompt or order sent |
| `tra keys --prepare` | the same, and on a demo or testnet API sets the account to one-way mode when it is hedged and nothing is open |
| `tra models groq` | the models your key can use on a provider, read from the vendor; no prompt sent |
| `tra init my-desk --profile autonomous --venue okx` | a desk wired for one exchange: data, orders, demo or testnet API, keys |
| `tra venues` | every venue the docs describe, and what each can do |
| `tra venues okx --setup` | what a venue can do, and the blocks to paste to trade on it |
| `tra mandate` | the text venue mode requires; `--sign "Your Name"` keeps it as `MANDATE.md` and signs `[execution]` in one step, and `--venue-mode` also turns orders on |
## Running
| Command | What it does |
|---|---|
| `tra run` | one autonomous cycle |
| `tra run --every 15m --cycles 12` | twelve scheduled passes: the first now, then one on every fifteenth minute of the clock |
| `tra run --now` | ask the roster even if the trigger would have held a quiet market |
| `tra watch --every 5s` | a read-only view of a running desk |
| `tra audit` | both auditors over the records; nothing is applied |
| `tra pause`, `tra pause --off` | hold every new entry while open positions keep their exits; lift the hold |
| `tra kill`, `tra kill --off` | flatten and stop a running desk at its next pass; disarm |
| `tra hindsight` | what the trades each gate refused would have done, beside the ones it let through |
| `tra sizing` | what the desk's own round trips support: the Kelly ceiling, the drawdown ahead, the excursions; changes nothing |
| `tra backtest`, `tra paper`, `tra live` | replay, shadow, canary |
## Data
| Command | What it does |
|---|---|
| `tra data get BTCUSDT 2025-01-01` | download a day, verify its checksum, audit it, write Parquet and funding |
| `tra data audit export.csv --mapping mapping.toml` | your own CSV or Parquet, through the same audit ([Your own data](https://docs.bijectivelabs.dev/opensource/your-data)) |
| `tra data pull BTCUSDT` | one live snapshot through the chains in `data.toml` |
| `tra sources show`, `tra sources pull` | where the reading analysts' series come from and how old they are; refresh them now |
| `tra sources add macro us10y --from fred --id DGS10 --high bearish` | a series onto the shelf in one step: pulled now, its lines drawn from its own history |
| `tra universe` | which perpetuals discovery finds on the venue now, and what each filter took out |
| `tra runs verify` | every cycle's files against their hashes, and its briefs against its raw inputs |
| `tra runs prune --keep 90d` | what old cycle directories would be removed; add `--yes` (and `--archive`) to do it |
## The ladder, research and hosts
| Command | What it does |
|---|---|
| `tra ladder show`, `promote`, `demote` | which tier the desk stands at; one step up with evidence, or down |
| `tra chain floor`, `observe`, `check`, `validate` | the research chain, layer by layer ([The falsification chain](https://docs.bijectivelabs.dev/opensource/chain)) |
| `tra ledger show` | every idea that died, and what killed it |
| `tra agent session`, `eval`, `trace` | the research session, model comparison, a run read back |
| `tra mcp serve` | the read-only gates as tools for any MCP host |
# Architecture
The product is one chain of gates. A claim about a strategy passes the data gate, the
falsification chain and the execution gate, in that order, before the agent may say it. This
page fixes the package boundaries, the direction of dependency, and the files that cross
those boundaries. The Python API is allowed to change before 1.0; the file formats are the
contract.
## Packages
```
trading_research_agents/
records/ how every record reaches the disk: crash-safe appends, atomic
writes, hashes, the per-day cycle index depends on nothing
extras.py how to add a missing optional dependency, worded once depends on nothing
ledger/ verdict vocabulary, axes, transferability, kill-ledger rows depends on nothing
envelope/ operator parameters: fees per channel, latency, venue access depends on nothing
data/ any trade data to Parquet, dataset audit, manifest; venue
archive plugins in data/venues, column maps for the rest -> ledger, envelope
chain/ falsification chain, layers 0 to 5 -> ledger, envelope, data
execution/ cost identity, fills after latency, trade-through matching,
fill probability, markout -> ledger, envelope, data, chain
strategy/ the core protocol, exits bound to the mechanism, the walk-forward
harness; no strategy of its own -> everything above
trading/ the live feed through ccxt in trading/feeds, simulated venue, the
live venue through ccxt in trading/venues, controls, canary caps,
ladder, the runtime -> everything above
llm/ language-model providers, fallback chains, usage ledger depends on nothing
store/ snapshots and cycle directories -> everything above, llm
surface/ the gates as callable tools, read tools and tightening actions
(listed on the MCP page); shared by mcp and agents -> everything above
agents/ the research roles at layers 0, 2 and 3; the session roles; the
cycle and its analysts, validators and screener -> everything above, llm, store, surface
mcp/ the gates as MCP tools -> everything except agent
cli/ the `tra` command -> everything
```
Rules, each enforced by a test in `tests/test_boundaries.py`:
1. `ledger` and `envelope` import nothing else from this project. They are vocabulary.
2. Arrows point only downward through the list. `chain` does not know `execution` exists; layer 5
takes the cost of a trade as a function argument, and `execution` supplies one.
3. Only `llm/providers` imports a vendor SDK. Layers 0, 1, 4, 5 and 7 are arithmetic and
statistics and have no path to a model.
4. Source files are ASCII.
5. No class in the package implements `Strategy` or `FittedCore`. Decision cores live in
[`examples/strategies/`](https://github.com/BijectiveLabs/trading-research-agents/blob/master/examples/strategies/) or in the operator's own package, and the package never imports
an example.
6. No module outside `data/venues/`, `trading/feeds/` and `trading/venues/` names a venue.
7. `mcp` reaches everything except `agent`, and its tools only read.
8. The only actions on `surface` tighten a limit, step the ladder down, or arm the kill
switch. A role holding the surface holds nothing that loosens.
## Adoption rules
Five rules decide what goes into the package and what stays outside it. They exist so the
software can be adopted without inheriting the particular strategies, venues or vendors of
the research it descends from.
1. **No strategy in the core.** The package ships the mechanism (the protocol, the exits, the
walk-forward harness) and no strategy. What [`examples/strategies/`](https://github.com/BijectiveLabs/trading-research-agents/blob/master/examples/strategies/) holds is an example of
the mechanism, not a recommendation, and a run points at it by file like it would at any
other core. Rule 5 above enforces it.
2. **Venues are plugins.** Archive layouts and live feeds are found through a registry and
an entry point group; the two that ship are plugins like any other. The gates, the
chain, the runtime and the agent do not know which venue a run is on. Rule 6 enforces it;
[Venues](https://docs.bijectivelabs.dev/opensource/venues) says how to add one.
3. **The data gate takes any data.** A venue archive through its plugin, or any CSV through a
declared column map ([`examples/mapping.toml`](https://github.com/BijectiveLabs/trading-research-agents/blob/master/examples/mapping.toml)), ends in the same audit, the same manifest
and the same Parquet. Nothing about where a row came from changes what the gate demands
of it.
4. **Agents are a surface, with full access.** The roles and the MCP tools call the gates;
they never are the gates. A model may decide (the technical analyst is a decision core);
the system executes and refuses (controls, caps, ladder, kill switch). Rules 3 and 8 enforce it.
5. **Every market is one shape.** A market is read once from ccxt's unified market structure
into `trading.market.Market`: its type, what it settles in, whether it trades in contracts
and how large one is, its fees and its minimum. A spot pair, a perpetual, a dated future, an
option and a prediction outcome are the same type with different facts, so the order path
asks the market what applies (contracts, margin, leverage) rather than branching per venue or
per market type. Which types venue mode sends to today is one list in `trading/dispatch.py`;
[Boundaries](https://docs.bijectivelabs.dev/opensource/boundaries) says why the rest wait.
Not every package exists yet. The list is the plan; a package is added when its first gate
lands with tests. Today: `ledger`, `envelope`, `data`, `chain`, `execution`, `strategy`,
`trading`, `llm`, `store`, `agents`, `mcp`, `cli` (with `cli/tui.py`, the console face on `rich`, which reads reports and decides nothing). Outside the package: `examples/` (an envelope, a run
config, a column map, two example cores), never imported by it; `templates/` inside the
package is a copy of those examples that `tra init` writes into a new project, and is never
imported either.
## One system, every venue
The desk does not carry an adapter per exchange. It speaks to every venue through ccxt, and
everything ccxt reports is turned into the package's own types once, at the edge. Past that edge
a venue is a name in the settings, and the parts below work the same for all of them.
| Part | What it does | Where |
|---|---|---|
| Data | candles, books, funding and open interest from any venue ccxt supports, chained with fallbacks | `data/sources/` |
| Client | one synchronous client for every venue, whichever ccxt namespace it lives in; prediction venues' async clients run on a loop of their own | `data/sources/ccxt_client.py` |
| Market | one shape for every market type, read from ccxt's unified market, a prediction outcome included | `trading/market.py` |
| Order | the entry, its exits attached, one per decision, under a client id from the cycle | `trading/dispatch.py` |
| Execution | a taker entry as a limit IOC inside a slippage band, a maker entry post-only with a timeout | `trading/dispatch.py` |
| Order state | waits for the fill, cancels what is left, and records what the venue said in its own words | `trading/dispatch.py` |
| Position | reconciled with the venue on every pass; closed by time or by the kill switch, reduce-only | `trading/positions.py` |
| Account | equity, free margin, the margin an order ties up, the day's loss and the drawdown | `trading/capital.py` |
It is deliberately small. It holds what an autonomous desk needs to send an order safely and know
afterwards what happened, and no more; a full order and portfolio platform is a different product.
`tra live`, the research ladder's canary tier, sends through the same client and hand as venue
mode, so the order path is one path whichever command sends, and the public trade stream that
`tra trade shadow` and `tra live` read is ccxt's too. What is left venue by venue is what ccxt does
not unify: the historical trade archives in `data/venues/`, and the account checks in
`trading/venues/accounts.py`.
## The session, as it runs
```mermaid
sequenceDiagram
participant C as system
participant S as screener
participant A as analyst (core)
participant R as risk manager
participant E as executor
participant U as auditor
C->>C: figures per dataset, cost floor, layer 1 when declared
C->>S: figures only
S-->>C: kept / excluded, a reason each
loop every closed bar, per kept instrument
C->>A: compact bars, features
A-->>C: target, confidence, horizon
C->>C: controls admit or refuse, the venue fills, exits run
end
C->>R: run reports, measured latency and costs
R-->>C: continue / tighten / demote / halt
C->>E: kept instruments, limits after
E-->>C: allocation fractions (nothing is sent)
C->>U: run reports, ledger, ladder, usage (a bounded tool loop)
U-->>C: verdict, findings
```
The macro and on-chain analysts, when series are declared, are asked between the screener
and the runs; their briefs are measured by the system and carry no action.
## The chain
| Layer | Test | Dies here when | Cost to run | Status |
|---|---|---|---|---|
| 0 | Cost floor | plausible magnitude below the cheapest round trip | minutes | implemented |
| 1 | Market observation | the phenomenon does not recur | minutes | implemented (`tra chain observe`) |
| 2 | Mechanism memo | nobody can be named who is structurally forced to lose | minutes | implemented (model writes, the system checks the form, a critic objects once) |
| 3 | Pre-registration | the claim cannot be stated so data could refute it | hours | implemented |
| 4 | Data requirements | no available instrument can measure the premise | hours to days | implemented |
| 5 | Statistical validation | interval spans zero, loses to a matched null, or lives in one subperiod | days | implemented |
| 6 | Strategy design | the exit is not bound to the mechanism | weeks | implemented |
| 7 | Shadow | net expectation negative in the live market, zero orders placed | weeks of calendar | implemented (`tra paper`, then layer 5 on its trades) |
| 8 | Live capital | the venue does not behave as the simulation assumed | capital | canary tier only (`tra live`), hard caps, behind the ladder |
The chain is strictly ordered by cost. A hypothesis does not reach layer 5 because somebody is
confident about it.
## Files at the boundaries
| Artefact | Format | Minimum content |
|---|---|---|
| Dataset | Parquet; price and quantity as `DECIMAL(38, s)` with `s` the instrument's scale; timestamps UTC nanoseconds | trades: `ts`, `venue`, `instrument`, `price`, `qty`, `side`, `trade_id`, `seq` |
| `manifest.json` | JSON | schema version, venue, instrument, UTC range, source files with SHA-256, normaliser version, audit verdict, creation time |
| `preregistration.json` | JSON, sealed by content hash | mechanism, claim, metric, horizon, universe, era, cost model, power, kill criteria; amendments are separate dated files referencing the original hash and may never loosen a threshold or move a horizon |
| `ledger.jsonl` | JSON Lines, append-only | one row per entry; nothing is rewritten; a retraction is a new `WITHDRAWN` row naming the row it retracts |
| `envelope.toml` | TOML | one `[[channel]]` per venue and execution channel; see [`examples/envelope.toml`](https://github.com/BijectiveLabs/trading-research-agents/blob/master/examples/envelope.toml) |
| `trades.jsonl` | JSON Lines | layer 5 input: `instrument`, `entry_ts_ns`, `exit_ts_ns`, `direction`, `weight`, `gross_return_bps`, numbers as decimal strings |
| `validation.json` | JSON | layer 5 output: interval, null report, subperiods, power, metrics, the decision with every fired criterion, the registration seal and the trades hash |
| `seal.json` | JSON, written once | era, opened_at, specification hash, gates |
| `strategy.toml` | TOML | a run of the trading agent: the core by file or import, exit rule, run settings, controls; see [`examples/strategy.toml`](https://github.com/BijectiveLabs/trading-research-agents/blob/master/examples/strategy.toml) |
| `mapping.toml` | TOML | a column map that takes any CSV through the data gate; see [`examples/mapping.toml`](https://github.com/BijectiveLabs/trading-research-agents/blob/master/examples/mapping.toml) |
| `intents.jsonl`, `fills.jsonl` | JSON Lines | every intent with its admission decision; every simulated fill |
| `ladder.json` | JSON | current tier and the history of promotions (with evidence and signature) and demotions (with reason) |
| `funding.json` | JSON | settlement timestamps (epoch ns) to rates as fractions, the measured interval, the venue and the range; written by `tra data funding`, read by `[run].funding` |
| `preflight.json` | JSON | what a live venue checked before the first print: clock skew, key permissions, position adopted |
| `usage.jsonl` | JSON Lines | one model call per line: role, provider, model, tokens, latency, declared cost, outcome |
| `cycle.json` | JSON | one cycle: data, agents, gates, intent, placement, refusals, and `files`, a sha256 per file in the cycle directory |
| `inputs.json.gz` | gzip JSON, schema 1 | a cycle's raw inputs as fetched (candles per timeframe, book levels, funding, open interest) and the settings its briefs used |
| `index/.jsonl` | JSON Lines | one line per cycle: id, instrument, mid, held, intent, placement, open interest, and the sha256 of `cycle.json`; the last line for a cycle wins |
| `session.json`, `audit.json` | JSON | a session of the seven roles: what was kept and why, the runs, the risk decision and the limits after it, the allocation, the auditor's verdict |
## Numbers
- Basis points to one decimal place, leading minus for negatives, never parentheses.
- Times in UTC, always stated as such. A naive datetime is refused.
- Prices, quantities, fees and costs are `Decimal`. Floats appear only in statistics.
- Every quantity that carries a verdict is reported as a triple: point estimate, interval,
sample size.
## What the platform boundary looks like
This project is consumed by other systems through the files above and nothing else. It does
not import from, link to, or know about any proprietary trading platform. A system that wants
its research tier gated by these rules reads the Parquet, the manifest and the ledger.
# What it guarantees, and what is not built yet
This page separates two things that are easy to confuse.
- **Guarantees** are rules the system enforces and the tests check. They protect capital and keys,
and any change, including a contribution, has to keep them.
- **Not built yet** is scope: things a trading system can do that this project does not do today.
None of them is forbidden, and several are where the project is heading: more market types
among them ([Where it is going](https://docs.bijectivelabs.dev/opensource/#where-it-is-going)). Each one says what a
contribution would need, and [Contributing](https://docs.bijectivelabs.dev/opensource/contributing) says how to propose it.
## Guarantees
| Guarantee | Held by |
|---|---|
| No language model places, cancels or resizes an order, holds a key, or opens a network connection | the import graph test (`tests/test_boundaries.py`): only `llm/providers` imports a vendor SDK, and no role reaches the venue code |
| No model can loosen a control: it may tighten a limit, step the ladder down or arm the kill switch, never the reverse | `agents/` tools expose only the tightening actions; the ladder refuses a promotion without evidence and a signature |
| One decision sends at most one order, and a venue error is recorded, not retried | `trading/dispatch.py`; a retry around real capital is how one defect becomes ten orders |
| An order goes out only after the mandate, the caps, the ladder tier on mainnet, the cost gate, the risk and desk limits, and the venue checks (step, drift, margin, liquidation) have all passed | `execution/mandate.py`, `agents/cycle.py`, `trading/dispatch.py`; each refusal is named |
| Missing or stale data is refused, never treated as zero or as current | the data chains' `ttl`, `max_age` on series, the snapshot's refusals |
| Every figure an agent reads is computed by the system and kept on disk with its source and age; a citation that does not match is discarded | `agents/scope.py`, `agents/cited.py`, `inputs.json.gz` and `tra runs verify` |
| Keys live in your environment or the `.env` beside `strategy.toml`, never in a config file, a brief, a prompt or a record, and are blanked out of errors | `cli/credentials.py`, `llm/completer.py`, `data/pull.py` |
| Sandbox is the default, and there is no production tier | `execution/mandate.py`, the ladder |
| The research chain does not select among variants: a claim that only survives after many tries has not survived | `chain/`; the walk-forward harness ships, an optimiser that feeds the chain does not |
## Not built yet
| Area | Today | What a contribution would need |
|---|---|---|
| **Market types: inverse and dated futures, options** | On the way. Venue mode sends to linear perpetuals and, when the desk lists them, spot (stock perpetuals and tokenized stocks among them, under the equity session rule) and prediction market outcomes; it refuses any other market type on every order until that type has the pieces on the right (`MARKETS` in `execution/mandate.py`). Spot is built: order path, position keeper and desk cycle, run on the Bybit and OKX demos | A cost model for that market (spot has no funding; options have premium and greeks), exit semantics that hold there (spot cannot be shorted without margin), sizing and liquidation rules, and tests; then that type is added to what the venue hand accepts |
| **On-chain venues outside ccxt** | Venues ccxt supports, Hyperliquid among them, go through the same venue hand. An on-chain exchange ccxt does not cover can be priced (`[[channel]].gas_quote`) but not executed | Wallet and transaction signing kept outside every model's reach, RPC and nonce management, slippage and MEV protection, and the same mandate and caps |
| **More than one account or sub-account** | One account per project, read before every order | Account selection that is declared, never chosen by a model; per-account caps, equity and daily loss; the keeper and desk counting per account |
| **Wallet transfers, withdrawals, treasury** | None. The venue hand reads the account and sends orders, nothing else | Separate keys with separate permissions, a human approval step, and records of every movement; this is the area where the guarantees above matter most |
| **Routing across venues, execution algorithms (TWAP, VWAP, iceberg)** | One venue per project; one order per decision, taker IOC or maker post-only | A long-lived order manager with its own records, so `--mode signals` plus your own executor is the route today |
| **Portfolio accounting, P&L history, tax lots, statements** | `positions.jsonl` and `round_trips.jsonl` per trade, with fees and funding as the venue reports them | Reconciliation against venue statements over time, built on the records the desk already writes |
| **Streaming data inside a cycle** | REST snapshots with a declared freshness window; `tra live` uses a websocket feed | A streaming feed in the cycle's data layer, keeping the snapshot as the record of what a decision saw |
| **A database or message bus** | Files are the record; `tra run --every` is one in-process loop | A store behind the same record writer (`records/`), with the files or an export kept as the auditable source |
| **High frequency** | Bars of a minute or more; decisions seconds apart | A different architecture: models are not in the loop at that speed |
A contribution in any of these areas is welcome. What it cannot do is weaken a guarantee to get
there; where the two meet, the guarantee wins and the feature finds another shape.
## Data, models and research, in one place
- The engine fetches and the agents read. No agent fetches anything, and there is no code path from
a role to the internet.
- A model may read one class of data and declare a state, propose a direction with a confidence and
a horizon, argue for or against a proposal citing its figures, or write an audit finding for a
person to apply. It never sizes a trade, changes a configuration file, promotes a tier, loosens a
control, or touches an order.
- If a model writes the language of an order or a size in its prose, or states a desk rule it was
never told, that is recorded as a crossing beside its answer and changes nothing
(`agents/scope.py`).
- An auditor's proposed change has to name a setting the configuration accepts, and the loader
refuses an unknown key, so a rule a model made up cannot take effect.
## Safety and responsibility
Venue mode needs a mandate the operator wrote and signed in their own project, a leverage and a
cap per order, keys in the environment, and on mainnet the ladder at its canary tier. Nothing here is advice: the licence is
MIT with no warranty, and a verdict from a gate is a statement about a dataset and a registration,
not a recommendation to trade. Compliance, reporting, jurisdiction, and whether you may run this at
all are the operator's to settle. [Liability](https://docs.bijectivelabs.dev/opensource/liability) says where responsibility sits, in plain words.
# Where the responsibility sits
This is software you run. It is published under the MIT License, which provides it without
warranty of any kind, and nothing here changes that. The lines below say, in plain
words, where the decisions are made and by whom, so that nobody is surprised later.
- **The sandbox is the default.** Replay, paper and shadow run against a simulated venue. No
key is read, no order leaves the machine, and every figure the gates produce is a figure
about recorded data. Most of what this project is for happens here.
- **Live orders need what you supply.** A trade key you created on the venue and placed in your
environment, a signed `MANDATE.md` for venue mode, and on mainnet a ladder you promoted to the
canary tier with evidence and your signature. The package never holds a key, never connects to a venue
unless you run it in live or venue mode, and never operates execution for anyone.
- **Promotion is a human act.** Every step up the ladder is a receipt naming the evidence and
the operator who signed it. Steps down happen on a reason, and a role may take them. No role,
no model and no tool on the MCP surface can promote.
- **The caps are yours.** The canary limits have no defaults; the session refuses to start until
you have written them down. What they should be depends on what a defect would cost you, and
only you know that.
- **A model may decide; the system executes and refuses.** The controls, the caps, the ladder and the
kill switch are the system's own rules, tested, and they fail closed. An agent with full access acts through
them. If they refuse, the refusal is recorded and nothing is sent.
- **Nothing here is advice.** The example cores are examples of the mechanism, measured and
closed in the research this project descends from. A verdict from a gate is a statement about
a dataset and a registration, not a recommendation to trade.
[Boundaries](https://docs.bijectivelabs.dev/opensource/boundaries) is the other half of this page: what the software guarantees, with the
test that holds each guarantee, and what is not built yet.
If you deploy this against capital, you are the operator: you chose the venue, the key, the
caps, the tier and the moment. The software's job is to make each of those choices explicit,
recorded and hard to make by accident.
# Security
## Scope
This package reads market data, asks language models for opinions, computes verdicts and writes
local files. In venue mode, which is off until you sign a mandate and meet its conditions, it also
holds your venue keys in memory and sends orders. Models never see a key and cannot send, change or
cancel an order; only the venue hand does, after every gate.
Things that are in scope for a security report:
- A path where a credential reaches a log, a record, a brief, a model or a message in clear text.
- A path where a model's answer, or a value from an untrusted file (a ledger row, an envelope, a
dataset manifest, a fetched series), is executed, deserialised unsafely, or used to reach outside
the intended directory.
- A way to send an order without the mandate, the caps, the ladder tier or the gates that are
supposed to stand in front of it, or to loosen a control from inside a model's answer.
- A gate that can be made to issue a verdict its rules forbid, or to skip a rule silently.
- A fetch that follows a redirect off https or sends a key to a host the operator did not name.
- A dependency with a known vulnerability that this project pins.
## Reporting
Use GitHub's private vulnerability reporting on the repository ("Security" tab, "Report a
vulnerability"). Do not open a public issue for a security problem.
Include the version or commit, the smallest reproduction you have, and what you believe the impact
is. You will get an acknowledgement, and a fix or a reasoned response before any public disclosure.
## Supported versions
Alpha (0.x). Only the latest release on PyPI, the matching tag on `master`, and the current
`develop` receive fixes.
## Where the responsibility sits
[Liability](https://docs.bijectivelabs.dev/opensource/liability) says plainly who is responsible for what when real capital is
involved.
# Contributing
Thank you for looking. This project is small and opinionated, and the opinions are written down so
that a contribution is judged against a standard rather than a mood.
## Setup
```bash
git clone https://github.com/BijectiveLabs/trading-research-agents && cd trading-research-agents
uv sync --all-extras
uv run pytest
```
Python 3.11 or newer. `uv` manages the environment and the lock file; commit `uv.lock` with any
dependency change. To use your working copy as the `tra` command on your machine, in place of the
release from PyPI, install it editable: `uv tool install --editable '.[ccxt,openai,data]'`.
## Before you open a pull request
All four must pass locally; CI runs the same four on Linux and Windows.
```bash
uv run ruff check .
uv run ruff format --check .
uv run mypy
uv run pytest
```
## Branches
- `master` is the stable line. Every commit on it is a release, tagged and published to PyPI, and
it only moves by merge from `develop`.
- `develop` is where work lands, and it is the experimental line. It moves often, a commit there is
not a release, and between releases it can carry work that is unfinished or changed again. A
set of changes becomes a release when it is merged into `master` and tagged.
- Open pull requests against `develop`. Branch from it; name the branch after what it does, briefly.
- To use the software rather than change it, install the release from PyPI.
## Commits
One intent per commit. A short imperative subject line, under 60 characters, that says what
changed. A body only when the reason is not obvious from the diff. No trailers, no tooling
signatures.
## Releases
A release is a version bump, a dated section in `CHANGELOG.md`, a merge of `develop` into
`master` and a tag `vX.Y.Z` on that merge. The tag runs the same checks as CI, builds the wheel
and the source archive, refuses a tag that does not match the package version, attaches both to a
GitHub release, and publishes the same files to PyPI through trusted publishing. No PyPI token is
stored anywhere.
## Standards the code is held to
**Money is never a float.** Prices, quantities, fees and costs are `decimal.Decimal`. Floats are
allowed in statistics (bootstrap distributions, p-values) and nowhere on a cost path. The type
checker and the tests both enforce this.
**Tests before measurement.** A gate is not trusted until its tests exist, and the tests are written
from the doctrine it implements, not from the code.
**Fail closed.** Missing data is not zero. A row that cannot be read stops the reader. An entry that
breaks a rule cannot be constructed.
**Comments say why, not what.** If a comment explains what the next line does, the line should be
clearer. Detail belongs in these docs.
**ASCII in source.** Non-ASCII characters are not used in `.py` files. A test checks.
**Direction of dependency.** `ledger` and `envelope` import nothing else from this project. Arrows
point only downward through the list in [Architecture](https://docs.bijectivelabs.dev/opensource/architecture). Only
`llm/providers` imports a vendor SDK. A test checks that too.
**English, professional register.** Identifiers, docstrings, docs and commit messages.
## Extending what it can do
Spot and other market types, on-chain venues outside ccxt, more than one account, routing and execution
algorithms, portfolio accounting: [Boundaries](https://docs.bijectivelabs.dev/opensource/boundaries) lists each under "not built
yet", with what it does today and what a contribution would need. Open an issue first with the
design, so the shape is agreed before the code. A contribution may add any of them; it may not
weaken a guarantee on the same page to get there, and the tests that hold those guarantees must
keep passing unchanged.
## Adding a gate
A gate is a function from evidence to a verdict from the closed vocabulary. Before writing it:
1. Name the doctrine it implements and the failure it exists to prevent, in the module docstring.
2. Write the tests from the doctrine's own worked example, with the doctrine's own numbers.
3. Decide which axis and transferability the gate can issue, and refuse to issue any other.
## Writing these docs
The pages live in `docs/opensource/` as Markdown, and the site is built with VitePress.
```bash
cd docs
npm install
npm run dev # a local preview with hot reload
npm run build # the same build CI publishes
```
A change in behaviour comes with the page that describes it, in the same pull request. Say what the
software does today and, where something is not built yet, say so plainly rather than describing it
as a restriction.
A new page needs two things besides its text: a `description` in its front matter (one sentence,
under about 160 characters, shown by search engines) and an entry in the sidebar in
`docs/.vitepress/config.ts`. The sidebar also decides what goes into `llms.txt` and
`llms-full.txt`, the plain-text copies that language models read.
## Reporting a problem
Open an issue with the smallest input that reproduces it. For anything that could affect a user's
capital or credentials, see [Security](https://docs.bijectivelabs.dev/opensource/security) instead.