Sign in

Your stash has one write-only URL. POST executions to it. trdstash dedupes them, FIFO-matches them into round-trip trades, and renders them on the dashboard.

There is no file upload UI. That is deliberate — this product is for traders who already produce structured data.

This page is the whole manual. The reference below is generated from the spec and covers every field, type, and status code. What's here is the part a schema can't express: what makes two rows the same row, what a re-POST overwrites, and which of the counters is worth alerting on.

Two facts to build on, before anything else:

Retries are safe. Dedupe is the core promise — re-POST the same batch, the same file, the same five years of history, and the second POST inserts nothing. Build error handling around retrying, not around tracking what you already sent.

A 200 does not mean every row landed. Rows are validated one at a time and a bad one is rejected on its own, so the status says the request was accepted, not that the data was. Read the counters. Reading the response below is the part worth getting right.

Get your URL

Sign in, open Settings → Ingest endpoint, and copy the URL. It looks like:

https://trdstash.com/in/8f2a91c4e7b3d05a

The path segment after /in/ is your secret. Anyone holding it can write to your stash. It cannot read your stash — the URL is POST-only, and there is no GET handler on it. Rotate it from the same screen if it leaks; the old token stops working immediately.

Treat it like any other write credential: read it from an environment variable, and don't inline it in the integration, log it, or print it in output the user might paste somewhere public.

The smallest thing that works

Four columns. Symbol, quantity, price, side.

Symbol,Qty,Price,Side
SPY,100,510.22,Buy
SPY,100,511.50,Sell
curl -X POST 'https://trdstash.com/in/YOUR_TOKEN' \
  -H 'Content-Type: text/csv' \
  -H 'User-Agent: my-trading-bot/1.0' \
  --data-binary @trades.csv
{"inserted": 2, "updated": 0, "skipped": 0, "rejected": 0,
 "accounts_created": [], "summary": "Stashed 2."}

Two executions in, one round-trip trade on the dashboard.

Set a User-Agent. Cloudflare blocks Python's default urllib string.

Every column, synonym, and type is in the reference below. A header row is optional only if your columns are in the canonical order shown there; any other order needs one. Header matching ignores case and surrounding whitespace.

Tags

Tags are optional. Everything above works without them, and so does the dashboard. Add them and they become how you slice it — key:value, and yours to define.

strategy:breakout|regime:bull-trend|confidence:A+|session:morning

Pipe-separated in one CSV cell; an array of strings in JSON.

Those rules are enforced, and a tag that breaks them rejects the whole row — the execution never lands, and errors carries the reason. Slug values before you send them rather than hoping; Privat Live costs you the fill, not just the tag.

On the dashboard, different keys are AND'd and values within one key are OR'd. So strategy:breakout|strategy:pullback|regime:bull reads as "breakout or pullback, in a bull regime."

Tags are dumb strings by design. trdstash does not validate that strategy:breakout means anything. That vocabulary is yours.

Which leaves you to invent one. Naming things below is the vocabulary we'd pick, and the reasons — none of it enforced, all of it expensive to change later.

Before you backfill

A two-row curl is forgiving. A five-year history is not, and the difference is worth two minutes.

Run it as a dry run first. Add ?validate=1 (or send X-Dry-Run: 1) and the batch runs for real — accounts resolved, dedupe evaluated, quota checked — then rolls back. You get the identical response with "dry_run": true, and nothing is stored.

curl -X POST 'https://trdstash.com/in/YOUR_TOKEN?validate=1' \
  -H 'Content-Type: text/csv' --data-binary @backfill.csv

The counters are real, not estimated. Two fields in that response tell you whether the backfill will land where you think:

accounts_created should be empty, unless this genuinely is a new account. A name here means your Account value doesn't match what's already stored — and because Account is part of a row's identity, the backfill is about to insert a second copy of history you already have, under a second account. See Dedupe below for the rule.

cross_account_ids should be zero. It counts rows whose ID already exists under a different account — the same fork, seen from the other side.

Neither blocks the write. We report, we don't adjudicate your data. But on a dry run they cost nothing to check, and afterwards they are the only signal you get: a fork produces inserted: 633, rejected: 0, which reads exactly like a correct first import.

If one does fire after the fact, it's repairable without deleting anything — see If something looks wrong.

Dedupe

POST the same file twice and the second POST inserts zero rows. This is the core promise, so you can re-run a backfill without thinking about it.

An execution is identified by you, your account, and the row's key:

  1. Your ID column, when you send one. Send it.
  2. Otherwise a sha256 of account, timestamp, symbol, side, qty, price, commission, trans fee, and ECN fee.

Without an ID, two genuinely separate fills of 100 SPY at the same price, in the same second, in the same account collapse into one. Rare, but real.

Account is part of the identity

Your ID is unique within an account, not across your stash. The same ID arriving under a different Account value is a different execution, and it is inserted rather than deduped.

Account,Symbol,Qty,Price,Side,ID
TR Live,SPY,100,510.22,Buy,alpaca:8f2a91      ← inserted
Privat Live,SPY,100,510.22,Buy,alpaca:8f2a91  ← ALSO inserted, same ID

So a typo, a stale display name in your config, or a renamed account on your side splits your history in two. Treat the Account value as a stable key rather than a label — derive it from the broker account id, not from something a human edits. If you want a prettier name, rename the account in Settings; that changes the display only and leaves ingest matching alone.

What a re-POST changes

Tags and the risk columns are the only mutable fields on a duplicate. Everything else is frozen. A re-POST with a corrected stop fixes the trade, and the response counts it under updated rather than inserted.

Tags update by namespace, and a row only speaks for the namespaces it mentions:

What you send What happens
No Tags column at all Nothing. Stored tags are left alone.
Tags present, with values Each key you send replaces that key's stored tags. Keys you don't send are untouched.
Tags present but empty You're asserting the row has no tags. All of them are cleared.

So a row arriving with strategy:reversal replaces strategy:breakout and leaves note:clean_setup exactly where it was.

The first and third rows are opposite instructions. "I didn't mention tags" and "this row has no tags" used to be the same thing; they aren't. Only the explicit empty cell clears.

In JSON the same three cases are: no tags key, "tags": [...], and "tags": [].

Naming things

Everything below is a recommendation, not a rule. trdstash accepts any non-empty string for Account, any key:value pair that matches the grammar above, and any ID — none of these conventions are validated, and none of them ever cause a rejection.

If you're generating an integration, follow them by default and don't write code that enforces them. Rejecting a user's existing account names or tag vocabulary at the client is worse than accepting a value we'd have named differently. The reason to default to these is that Account values and tag vocabularies are cheap to choose now and expensive to change after a year of history.

Accounts

Use <broker>-<env>-<broker_account_id>. Recommended, not enforced.

alpaca-live-PA3XYZ9K
alpaca-paper-PA7QW21B
ibkr-live-U1234567

Three properties earn their keep:

Derived, never typed. The account id comes from the broker's API — on Alpaca, GET /v2/accountaccount_number. Nothing a human edits appears anywhere in the value. Since Account is part of a row's identity, a stale label in a config file doesn't produce an error; it produces a second copy of your history under a second account.

Environment in the key, not in a tag. Matching is FIFO per symbol and account, so if paper and live share one account a paper fill will close a live position, and the round trip you get back is fiction. A tag can't prevent that. Only the account boundary can.

Ugly on purpose. The readable name lives in Settings, where renaming changes the display and leaves ingest matching alone. The value you POST never has to be pretty — and the moment it is, someone will want to edit it.

Strategy tags

Use one key per dimension, with lowercase values. Recommended, not enforced — the only hard rule is the key:value grammar above.

strategy:orb|variant:tight-stop|regime:bull-trend|session:premarket

rather than:

strategy:orb-tight-stop-bull-premarket

The second is one opaque value. There is no strategy:orb to select, because every ORB trade carries a different composite — so "how does ORB do overall", "does the tight stop help", and "does this only work in trends" all become unanswerable at once. Keys are AND'd and values within a key are OR'd, so splitting costs nothing at write time and buys four filters that compose.

Two conventions inside the value:

Lowercase it. Case survives ingest, so strategy:ORB and strategy:orb are two different strategies on the dashboard.

Name variants, don't number them. variant:tight-stop tells you what you were comparing; variant:3 sends you looking for a changelog you never wrote. Name the trait rather than the edit — tight-stop stands alone, where removed-gap-filter describes a diff from whatever came before and stops identifying anything by the fourth variant. You give up the ordering a number carries, but executions are timestamped, so chronology is already in the data.

Whatever you pick, keep it stable. Renaming momo to momentum halfway through leaves you with two strategies forever — accounts can be merged in Settings, tags cannot.

Execution IDs

Use <broker>:<the broker's own id for that fill> — one id per fill. Recommended, not enforced, but the per-fill half is the closest thing on this page to a real rule.

alpaca:20260824000000000::a4f9c2e1

The namespace keeps another broker's ids from ever colliding with these. The per-fill part matters more: a partially filled order produces several executions sharing one order id, so keying on the order silently collapses real fills into one and reports them as duplicates. Nothing errors. The history is just quietly wrong.

If your broker lets you stamp your own id on an order — Alpaca's client_order_id — that field is the place to carry strategy out with the order, so the tags come back attached to the fills instead of being reconstructed afterwards. integrations/alpaca.py has a worked version.

Reading the response

A 200 does not mean every row landed. Rows are validated individually — check rejected and read errors, which carries a row number and a reason for each failure, capped at 50.

skipped is computed as rows − inserted − updated. It is not a tally of anything, so inserted + updated + skipped + rejected always equals the rows you sent, arithmetically, whatever the server did. It cannot detect a problem, so it isn't worth alerting on.

Use the precise fields instead. skipped == unchanged + duplicate_in_batch, and those two want opposite reactions: unchanged is the healthy steady state, duplicate_in_batch means your source emitted the same execution twice inside one request.

For a scheduled sync, the two fields worth alerting on are accounts_created and cross_account_ids — both empty when things are healthy. A third check worth building yourself: a high inserted ratio for an account you've synced before is the signature of rows landing under a new identity.

detail breaks the counters down by the id you sent, or the derived hash:... key for rows with no ID. Sampled to the first 50 per bucket, with truncated saying whether anything was cut. Add ?detail=full for the complete mapping — worth it when verifying a large one-off backfill, which is exactly when a sample is useless.

Writing the sync loop

Retry the whole batch. Dedupe makes it free. Don't build a cursor that tracks what you've already sent — it will be wrong at exactly the moment that matters, and it's solving a problem the server already solved.

Don't retry a rejected row unchanged. It failed validation and will fail the same way forever. Surface the errors entries — each carries a row number and a reason — and let a human fix the source.

On 429, back off and read which kind. Either you're over the per-token rate limit, where retrying after a minute works, or over the monthly quota, where it won't help until the cycle turns. Current ceilings are in the reference below.

On 413, split the file. There's a per-POST row cap; batch under it rather than retrying the same body.

Alert on accounts_created and cross_account_ids. Not on skipped. A steady-state sync leaves both empty forever, so a non-empty one is the only signal you get that your history has started forking.

If something looks wrong

Trades aren't appearing. Check inserted. If it's 0 and skipped is high, you already sent them.

An account you didn't expect showed up, holding trades you already had. Your Account value changed between runs. Fix it at the source so the next sync stops splitting, then in Settings → Accounts pick merge into… on the stray account and choose the real one.

Merging moves the executions across, drops the ones already there as the duplicates they are, and re-runs the matcher — so a trade whose entry landed under one label and exit under the other is paired back into one round trip. Nothing else is lost. Renaming is not the same thing: it changes the display label only, so the history stays split.

Everything re-inserted after I changed something. The only fields you can change on a stored row are tags and the risk columns. Change Account, timestamp, symbol, side, qty, price, or any fee on a row with no ID, and it's a different execution by definition.

One trade split into several. Matching is FIFO per symbol and account. A partial close opens a new trade for the remainder.

Timestamps are off by hours. Times are UTC. trdstash does not convert from your local zone.

No R-multiples on the dashboard. Risk must be on the opening row of the position, not the closing one. The Edge panel shows its own coverage — "R on 142 of 1,284" — so you can see how much of your book carries risk data.

A trade shows no R even though you sent a stop. Your stop was on the wrong side of entry. A stop there is a data error; trdstash flags the trade and records no risk rather than inverting the number.