Ulric
Book a call

Eugene, Oregon · one person, whole builds

Insights

A native investing app in Rust that vetoes its own orders

A native investing app in Rust that vetoes its own orders

A spreadsheet will tell you that two option contracts at $5.34 are a $10.68 position. Alpaca's own documentation puts the market value of that exact row at $1,068. The factor between those two numbers is 100, it is not an opinion, and it is the reason the trading terminal I put on GitHub in June reads a symbol before it multiplies anything.

The repository was created on 12 June 2026 and last pushed on 20 June. What is in it is a paper-first trading terminal: a Rust backend on axum, a React front end built into the binary, a native macOS window, research that runs on a local model, and a deterministic risk engine that gets to veto an order the rest of the program wants to place. The case study for it says why it exists, in a sentence I still stand behind: it is a working answer to a question I get a lot, which is whether the studio builds serious native software or only websites.

One thing before anything else. This post is about software. The README's own license line reads "MIT. Provided for educational purposes only; not financial, investment, or trading advice," though the repository does not yet carry a LICENSE file, and I am not going to improve on that disclaimer here. There is no performance claim in this post, no strategy recommendation, and nothing below is advice about your money.

Why would a counselor write a trading terminal?

Because the interesting engineering is not in the market. It is in building something that keeps a rule a person set while they were calm.

I spent a decade in mental health, in residential treatment and then a private practice, and I still work in a county jail. The most useful thing I ever did in that room was help somebody write down, in advance, what they would do at the moment they would least want to do it, and then make that plan hard to argue with later. A risk engine is the same shape with different nouns. You decide the limits when nothing is happening, you write them into code that runs before the order goes out, and you deliberately make the override a separate, visible act rather than a shrug. The README states the design in one line, and the line is the whole architecture: "The LLM proposes; deterministic code disposes." Models generate theses and signals, and a rule-based engine validates every order against hard limits before anything reaches the broker.

That is the whole reason I found this project worth eight days. It is a small, honest testbed for the thing I actually care about, which is what has to sit between a model and an action that cannot be taken back.

What does a spreadsheet get wrong about an options position?

The size of it, by two orders of magnitude, and then which position it is.

Alpaca's options documentation is blunt about the first part: "each option contract is for 100 shares of underlying." The same docs show a position as the API returns it. Here it is abridged to the six fields that matter, and those numbers are worth reading slowly.

{
  "symbol": "PTON240126C00000500",
  "asset_class": "us_option",
  "qty": "2",
  "current_price": "5.34",
  "market_value": "1068",
  "cost_basis": "1210",
  ...
}

Quantity two, price 5.34, market value 1,068. Two times 5.34 is 10.68. The broker's number is a hundred times larger, because the price of an option is quoted per share and the contract delivers a hundred of them. Anyone who keeps positions in a sheet and multiplies the quantity column by the price column, which is exactly right for stock, understates that row by a factor of 100. It does not look like an error. It looks like a rounding line.

So the engine decides what a unit is worth before it does any arithmetic:

pub const OPTION_MULTIPLIER: f64 = 100.0;

let is_opt = is_option(order);
let multiplier = if is_opt { OPTION_MULTIPLIER } else { 1.0 };
let position_value = qty * price * multiplier;
One options row counted two ways. The position from Alpaca's documentation, symbol PTON240126C00000500, quantity 2, current price 5.34, market value 1068. A spreadsheet multiplies quantity by price and gets $10.68. The risk engine detects an OCC symbol, applies the 100 multiplier and gets $1,068.00, matching the broker's market value. Underneath, the underlying function turns the OCC symbol back into PTON so the contract and any shares held are added together before the concentration limit is checked.
The row is the worked example in Alpaca's own options docs, read on 4 September 2026. The engine reads the symbol first because the symbol is where the contract size is hiding.

The second half of the mistake is harder to see and worse. A call on a company and the shares of that company are one bet. Grouped by the symbol column they are two names, neither of which looks large. Under the OCC symbol format the tail of a contract symbol is always six digits of expiry, one character for call or put, and eight digits of strike, which is fifteen characters, however long the root happens to be. So the engine reads from the right:

fn underlying(occ: &str) -> String {
    let s = occ.trim().to_uppercase();
    if is_occ_symbol(&s) {
        let n = s.len();
        s[..n - 15].to_string()
    } else {
        s
    }
}

What comes back is PTON, which is then matched against the open positions, added to whatever of that name is already held, and divided by account equity. That number, not the per-row one, is what the concentration limit is checked against. Concentration is the only rule in the file that can catch a portfolio that is diversified across four tickers and concentrated on one company.

How does the engine decide how many to buy?

From the distance to the stop, never from a dollar amount.

pub fn size_position(equity: f64, risk_per_trade_pct: f64, entry: f64, stop: f64) -> Value {
    let risk_amount = equity * (risk_per_trade_pct / 100.0);
    let per_share_risk = (entry - stop).abs();
    ...
    let mut qty = (risk_amount / per_share_risk).floor() as i64;

The input a person gives it is not "put four thousand dollars into this." It is "if this stops out, I am willing to lose one percent," and the quantity falls out of where the stop is. Two trades sized this way lose the same amount when they are wrong, even when one has a stop forty cents away and the other four dollars. Size the same two from a fixed dollar amount and the loss floats by an order of magnitude without anyone choosing that. The floor is deliberate too: the quantity rounds down, so the sizer can never round up into a bigger loss than the one that was authorized.

Sizing is the calculator. The limits are the gate, and they ship with these defaults in default_limits(): at most 20 percent of equity in one position, 10 open positions, a 5 percent daily loss circuit breaker, 1 percent risk on any single trade, 25 percent concentration in one underlying, a $1 minimum price, and a hard cap of 50 orders in a UTC day as a backstop against a runaway bot.

The terminal's risk screen: four starting profiles named Conservative, Balanced, Aggressive and YOLO, editable hard limits for max position percent, max open positions, max daily loss, max per-trade risk, max concentration and minimum price, a position sizer that takes an entry and a stop, and a kill switch panel reading disengaged.
The risk screen, from the repository's own screenshots, with the account figures across the top blurred out. The account is a paper account, which the badge at the top says, and its stored limits are far looser than the code defaults: that is what the profile cards are for.

Worth naming, since the screenshot and the code disagree: the numbers in those fields are whatever the account has saved, not the defaults in the source. Defaults are the floor for somebody who never opens that screen. The kill switch under them is a single boolean that vetoes every new entry, which exists because at some point you want one control that does not require reading anything.

What happens when the account equity comes back as zero?

Today it is vetoed. For seven days after launch, three of the checks quietly did not run.

Every percentage rule in the engine is guarded, sensibly enough, against dividing by zero: position size against equity, per-trade risk against equity, concentration against equity. Written as equity != 0.0 && ..., that division guard doubles as an approval when nobody meant it to. If the account payload comes back empty, from a transient broker outage or a bad key, equity is 0, and each of those conditions evaluates to false. Not "too big." Not "cannot tell." False, which reads as approved. An audit pass on 19 June added the missing rule:

// Fail closed when we cannot size risk: a 0/unknown equity must NOT silently
// skip the percentage-based checks below.
if is_entry && equity <= 0.0 {
    vetoes.push(json!({"rule": "equity_unavailable", ...}));
}

The part I find worth sitting with is what would have happened before that commit. The order probably still gets stopped, one branch further down, by the buying power line, because an account payload with no equity in it usually has no buying power in it either. The rule that saves you is not the rule you wrote for the job. That is not safety, it is a coincidence with good timing, and it stops being true the first time the shapes of those two failures differ.

The same commit is why the order route refuses to act on missing information at all. Its comment is the policy: "FAIL CLOSED: if any input (account/positions/limits) can't be fetched, we must NOT place an unevaluated order". Entries are blocked, closing orders are still allowed, and the block itself is written to the risk events table so there is a record of a decision rather than a silence. It is the same lesson I got from a verifier that passed a login page as a finished report, in a place where the cost of being wrong is money instead of a wasted morning. A check that cannot run has to answer no. The honest failure mode for a percentage is not zero, it is refusal.

What did Rust and the native window actually cost?

Roughly nine thousand bytes of Rust, and a Gatekeeper warning on first launch.

The repository holds 395,424 bytes of Rust, read from the repository tree on 4 September 2026. The macOS app is 9,406 of them. Everything else is the backend, which is built as a library crate rather than only a binary, and that one line in Cargo.toml is what makes two apps out of one codebase:

[lib]
name = "trading_backend_rs"
path = "src/lib.rs"

The web build runs that library as a binary against MySQL and serves the compiled React app out of the executable itself. The native app links the same library, spawns the same axum server inside its own process, forces the storage to SQLite and points a window at it:

std::env::set_var("DB_BACKEND", "sqlite");
std::env::set_var("SQLITE_PATH", sqlite_path.to_string_lossy().to_string());

let port = free_port();

The port is found by binding to 127.0.0.1:0 and asking the operating system which one it got, then the app polls that port with a TCP connect until the server answers before it opens the window. On quit it flushes a last SQLite to MySQL sync on a dedicated thread with a ten second timeout, so quitting can never hang on a database probe.

The terminal dashboard: tiles labeled equity, buying power, day profit and loss, cash, and open risk, every account figure in them blurred out, with the open risk tile still carrying its own formula, the sum of price minus stop times quantity. A watchlist, a candlestick chart with moving averages, an activity log listing recent buy orders each tagged accepted or warned, a research panel and two bots with on and off switches.
The same UI in both builds, on the same paper account, with its figures blurred out because none of them is part of the mechanism. The activity log merges two streams, the orders themselves and every risk event the engine vetoed or warned on, because in the component's own comment those "must never be silent". The open risk tile carries the formula it is summing rather than asking anyone to trust the number.

The toolkit is Tauri 2, which means the window is a WKWebView. That is the honest cost. Not one control in that screenshot is an AppKit control, so if what you want from "native" is a Mac app that feels like Mail, this is not it. What it does buy is real: one process, one install, no MySQL to set up, a database inside the app's own data directory, and no second front end to maintain. The release profile is tuned for size rather than speed, with link-time optimization on and symbols stripped, because the thing being shipped is a shell.

As for why Rust at all, the least romantic answer is the true one. The repository still contains the original Python service, and the header of the Rust risk engine says what it is: a pure port of backend/services/risk.py. Rewriting behavior I had already watched work is a much better way to learn a language than designing in it, and the payoff was a single binary with the interface baked in. The app is unsigned, so macOS blocks it the first time and the README tells you to open it from the context menu once. That is the price of not paying for a developer certificate on a side project, stated plainly rather than hidden in a troubleshooting note.

What stays on the machine?

The database, the research, the chat, and the keys.

Storage is local by construction: MySQL on localhost for the web build, an embedded SQLite file for the native app, identical schemas, synced both ways. The research worker and the chat default to Ollama at http://localhost:11434, and a cloud research provider is used only when a key is present, which is a smaller version of the provider cascade I run everywhere else. Secrets live in a gitignored .env and, as the README puts it, "they are not bundled into the binary or the .app."

The one place a local model touches the database is the chat, which writes SQL from a plain English question. That path is fenced rather than trusted: the generated statement has to start with select, is checked against a list of seventeen forbidden keywords, and has LIMIT 200 appended if it does not carry a limit of its own. A model that writes queries against your own records is a good feature and a bad thing to leave unsupervised, which is the same instinct behind keeping three small iOS apps entirely on the phone.

What is still wrong with it?

There is not a single test in the repository at that commit, and I did not know that until I sat down to write this post.

Twenty-one Rust files, 386,018 bytes in the backend, zero occurrences of #[test] or #[cfg(test)]. For a project that is mostly UI that would be ordinary. For a function whose entire job is standing between a language model and a broker, it is the wrong place to have spent no time. The behavior is right as far as I can read it, and reading is not evidence.

There is a second boundary in the same file worth stating out loud. An entry is defined as side == "buy", and per-trade risk on an option is computed as the premium paid, which is the most a buyer can lose. Every option order the bots place is a buy, so that assumption holds today. It is an assumption and not a guarantee, and a sold contract, where the credit is small and the exposure above it is not, would walk past a rule that thinks it has already measured the worst case.

So the first test I write will not be a happy path. It is equity = 0.0 with a well formed order, asserting a veto, because that is the case that was silently approved for a week and the only proof I have that it is fixed is that I can see the line.

Common questions

What is the AI Trading Terminal?

An open-source, paper-first trading terminal I published at github.com/erichers/ai-trading-bot in June 2026: a Rust backend on axum, a React interface built into the binary, market data and orders through Alpaca, research on a local model, and a deterministic risk engine that evaluates every order before it is sent. It ships as a web app backed by MySQL and as a native macOS app with embedded SQLite. Its README's license line reads: MIT, provided for educational purposes only, not financial, investment, or trading advice.

Why does an options position need different arithmetic from a stock position?

Because the price is quoted per share and the contract delivers 100 of them. Alpaca's documentation states that "each option contract is for 100 shares of underlying" and shows a two-contract position priced at 5.34 with a market value of 1,068. Multiplying quantity by price, which is correct for stock, understates that row by a factor of 100, so the engine detects an OCC contract symbol first and applies a multiplier of 100 before anything is compared to a limit.

What does it mean for a risk engine to fail closed?

That a check which cannot be computed blocks the order instead of passing it. In this engine an entry is vetoed when account equity is 0 or unavailable, and the order route refuses to place anything at all when the account, the positions or the limits cannot be fetched. Closing orders are still allowed, and every block is written to a risk events table so the decision leaves a record.

Why Rust rather than the Python it started as?

The repository still contains the original Python service, and the Rust risk engine is a direct port of it, which made the language the only variable. What Rust bought was one binary with the compiled interface embedded in it, and a backend built as a library crate so the same code serves the web build against MySQL and runs inside the native macOS app against SQLite.

Is any of this investment advice?

No. The post and the project describe software. There is no performance claim, no strategy recommendation and no advice about anyone's money here, and the repository ships in paper mode by default.

Related

← All insights