Metadata-Version: 2.5
Name: dollarsmore-faro
Version: 0.11.12
Summary: tastytrade order-execution consumer / SDK: drains order intents off Redis Streams and places them
Project-URL: Homepage, https://github.com/dollarsmore/faro
Project-URL: Repository, https://github.com/dollarsmore/faro
Project-URL: Issues, https://github.com/dollarsmore/faro/issues
Author: Robert Krzysztoforski
License-Expression: MIT
License-File: LICENSE
Keywords: broker,orders,redis,tastytrade,trading
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Office/Business :: Financial :: Investment
Requires-Python: >=3.11
Requires-Dist: dollarsmore-wire<0.7,>=0.6.0
Requires-Dist: redis<9,>=8.1.0
Requires-Dist: requests<3,>=2.34.2
Requires-Dist: sentry-sdk<3,>=2.66.1
Description-Content-Type: text/markdown

<p align="center">
  <img src="assets/faro-readme-header.png" alt="faro — a tastytrade order-execution consumer" width="100%">
</p>

# faro

A tastytrade order-execution consumer (and the seed of a small tastytrade SDK).
`faro` drains order intents off a Redis Stream and places them against
tastytrade, reporting results back on a results stream. It owns a minimal,
in-house tastytrade REST client (not a third-party broker library), so the
credential and money path is auditable end to end.

The wire schema it speaks is [`wire`](../wire) (`dollarsmore-wire`); a producer
emits `OrderIntent`s, `faro` consumes them and emits `ResultEvent`s.

## Install

```bash
pip install dollarsmore-faro
# or
uv add dollarsmore-faro
```

The distribution is `dollarsmore-faro`; the import package is `faro`.

## Run

```bash
python -m faro                 # consumes intents, places against the tastytrade production broker
python -m faro --account ... --redis-url redis://host:6379/0 --consumer faro-1
```

Credentials and wiring come from the environment (never baked into the image):

| Variable | Purpose |
|----------|---------|
| `TASTYTRADE_CLIENT_SECRET` | OAuth2 client secret |
| `TASTYTRADE_REFRESH_TOKEN` | OAuth2 refresh token |
| `TASTYTRADE_ACCOUNT` | account number (optional if exactly one account) |
| `REDIS_URL` | Redis connection (default `redis://localhost:6379/0`) |
| `FARO_CASEKEEPER_DIR` | audit-log directory (default `/app/casekeeper`) |
| `FARO_ARCHIVE_DIR` | stream-archive directory (default `/app/archive`) |
| `FARO_PRODUCER_HEALTH_KEY` | Redis key carrying the producer's heartbeat; **unset leaves the dead-man inert** |
| `FARO_PRODUCER_SILENCE_S` | producer silence that arms the dead-man flatten (default `90`) |
| `FARO_SIM_LIFECYCLE_STREAM` | Redis stream carrying the producer's sim fill/TP lifecycle; unset leaves the divergence response inert |
| `FARO_SIM_GRACE_S` | grace before a sim-vs-broker divergence is acted on (default `7`) |

The last four have a matching CLI flag (`--producer-health-key`, `--producer-silence-s`,
`--sim-lifecycle-stream`, `--sim-grace-s`). The two keys default to unset, so the dead-man
flatten and the divergence response described below stay dormant until they are configured.

A second process, `python -m faro.archive`, tails every order/ops stream into a
durable per-day archive and trims the streams so Redis stays bounded.

## Runtime mode

`faro` reads the Redis key `faro:mode` each loop. Only the exact value `live` arms real
placement; an absent or unrecognised key reads as `dry` (fail-safe), so a fresh deploy never
trades until an operator deliberately sets `live`. In `dry` mode every placement is sent to the
broker's `dry-run` endpoint, which validates the order (buying power, fees, structure) without
executing it, while cancels and the corrective flatten are suppressed. The switch is hot (no
restart), and flipping `live`→`dry` while positions are open cancels any working orders and
flattens any held position at market first.

Two exceptions to that suppression, both exposure-reducing only. A `live`→`dry` flatten that
cannot confirm the account flat records an owed flatten; while that marker stands, `faro` keeps
cancelling the working bracket and market-closing the held position on a fixed retry, even
though it is reporting `dry` mode, until the account is confirmed flat or a bounded number of
attempts is exhausted (`FLATTEN_OWED_*` alerts track it). Opening is never forced: a forced
close re-reads the live holding and places nothing when that read fails.

Within one pass a held position is closed once. The immediate retries exist to catch a position
that appears between the cancel and the read, and to re-send a close the broker REFUSED outright;
any close whose fate is unknown, including one that errored or raised after leaving `faro`, is
treated as placed and is not re-sent, because a market order that has not filled yet still
reports the holding and sending against it again would sell the same contracts twice. A flatten
performed while applying a mode change also stands the owed-flatten retry down for that pass, so
the two callers cannot each place a close milliseconds apart.

Re-sending against a position that is still held is the job of the owed-flatten retry, which is
spaced and bounded. The mode-change path takes exactly one corrective flatten, on the pass that
first sees the flip, and then hands the re-sending over: from there it only re-reads whether the
account has come flat. So a disengage that will not clear spends the same
`FLATTEN_OWED_ABANDONED` budget as any other owed flatten rather than re-attempting on every
pass, and the flip to dry still lands the moment the account reads flat, however it got there
(including flattened by hand at the broker, with no restart).

The retry is bounded in three independent ways, because a forced close is the one thing `faro`
does against the operator's stated mode. It retires only on evidence that its own reason has
passed, and the marker records which reason that is: a `live`→`dry` disengage is moot the moment
the operator asks for `live` again, so that aborts an unfinished disengage outright
(`FLATTEN_OWED_CANCELLED`, and `faro` resumes managing the position, whose book entry was never
reset), whereas a dead-man flatten retires only once the producer heart-beats again. Mode alone
never drops a dead-man marker: the producer being silent is exactly when nobody else is managing
the position. A marker written by an older `faro`, which carried no reason, reads as a disengage.
It spends a fixed attempt budget that is returned only by *reading* the marker gone, so
a clear that silently fails still ends at `FLATTEN_OWED_ABANDONED` rather than forcing a close
every retry forever; a new incident carrying a different reason gets a fresh budget. Once the
budget is spent `faro` stops forcing but keeps re-checking, so a position flattened by hand
clears the marker (`FLATTEN_OWED_RECOVERED`) the moment the account reads flat, without a
restart: from applied-dry no mode change would flatten it (arming is blocked by the marker, and
a dry request early-returns), so that re-check is the only in-band way out. And an owed flatten
blocks arming, so the two surfaces cannot disagree. A `RECOVERED` reports whether `faro` actually
placed or cancelled anything (`flatten`) or merely found the account already flat
(`flatten:confirmed-flat`), so the daily `/compare` gate does not grade a no-op settle as money moving.

Arming `dry`→`live` is gated. `faro` refuses to arm while a broker holding **or a resting order**
cannot be attributed to its own book, while intents are still unacked, while a reconciler
discrepancy is unresolved, or while a flatten is still owed; the blocking field is named in the
`MODE_ENGAGE_BLOCKED` alert, and any reading that faults counts as blocking. A holding its own
book accounts for (the usual restart-mid-trade case) does **not** block, so `faro` resumes
managing the position it already owns, and neither do `faro`'s own corrective flatten orders.
Disengaging is never gated.

The health record `faro` publishes gained `unattributed_positions`, `unattributed_orders` and
`flatten_owed` in 0.10.0, and a consumer that derives its own arming view from that record (the
operator dashboard does) will read a record without them as unreadable evidence and refuse to
arm. Deploy `faro` **before** any such consumer, never after.

> **`faro` assumes it owns the trading account exclusively.** Reconciliation treats any
> position or order it did not place as a discrepancy, so in live mode it may auto-close
> such a position, and the `live`→`dry` flatten cancels and closes **everything** in the
> account — including orders or trades placed manually in the broker UI. Do not hand-trade
> the same account.

## Sim divergence

The producer runs its own model of each trade and can book a fill or a take-profit the
broker never gave, because a modelled exit off a bar's extreme is not the same thing as a
resting limit reaching the front of the queue. Left alone the two books drift apart: the
model believes it is flat and sends nothing further, while a real position stays open with
no exit behind it.

With `FARO_SIM_LIFECYCLE_STREAM` configured, `faro` watches that stream and acts once the
broker leg has lagged the model past `FARO_SIM_GRACE_S`. A modelled entry the broker never
filled cancels the resting bracket (`MISS_FILL`); a modelled take-profit whose real limit is
still unfilled cancels the bracket and closes the position at market (`MISS_TP`), which
accepts slippage in exchange for the two books agreeing. Both raise an alert on `ops:alerts`
recording the action taken. The market close is registered for fill confirmation under the
horizon of the book entry it resolves, so the record that proves the position closed can
never expire while `faro` still believes the position is open.

## Lifecycle

An order flows from a producer's intent on `orders:intents` to a terminal result on
`orders:results`. An `OpenBracketIntent` is placed as one OTO bracket at tastytrade (a limit
**entry** with a pre-staged **take-profit**); the entry then fills or expires, and a filled
position is closed by its take-profit or by a producer `CLOSE`. The whole map:

<p align="center">
  <img src="assets/lifecycle-overview.gif" alt="faro trade lifecycle, every path" width="100%">
</p>

Each path, watched live:

<table>
  <tr>
    <td width="50%"><img src="assets/lifecycle-take-profit.gif" alt="take-profit fills" width="100%"><br><b>Take-profit</b>: the entry fills, the pre-staged TP fills, the OTO self-closes.</td>
    <td width="50%"><img src="assets/lifecycle-not-filled.gif" alt="entry never fills" width="100%"><br><b>Not filled</b>: the entry limit never trades and expires at session end; nothing at risk.</td>
  </tr>
  <tr>
    <td width="50%"><img src="assets/lifecycle-stop-close.gif" alt="producer closes the position" width="100%"><br><b>Close</b>: a producer <code>CLOSE</code> cancels the resting TP and flattens the position.</td>
    <td width="50%"><img src="assets/lifecycle-rejected.gif" alt="broker rejects the order" width="100%"><br><b>Rejected</b>: the broker refuses the placement; no order works.</td>
  </tr>
</table>

A bracket can also be pulled before it fills (a producer `CANCEL`), and the `live`→`dry`
kill-switch cancels working orders and flattens any position at once. The animations are rendered
from [`tools/lifecycle.py`](tools/lifecycle.py) (`python -m tools.lifecycle`), not shipped in the
package.

## Layout

Two layers live side by side; the seam is where a future fuller SDK grows:

- **SDK core** — `session.py` (OAuth2 refresh-token session, never logs tokens,
  refuses non-tastyworks hosts), `client.py` (`TastytradeClient` REST),
  `symbols.py` (OCC option symbols), `executor.py` (`BrokerExecutor` port +
  `TastytradeExecutor` adapter — the swappable broker seam).
- **Consumer app** — `stream.py` (Redis Streams transport: intents / results /
  dead-letter), `service.py` (`FaroService` dispatch loop, crash-tolerant with
  PEL recovery), `fill_tracker.py` (polls the broker to confirm fills, records the
  actual price/time, emits `FILLED`), `book.py` (faro's believed state per trade),
  `reconciler.py` (diffs the book against the broker's positions, alerts on
  `ops:alerts`, mints a guarded corrective close), `sim_reconciler.py` (acts when
  the producer's simulated fill or take-profit outruns the broker's real leg),
  `casekeeper.py` (append-only
  per-day audit log of every intent received and broker result), `archive.py`
  (`python -m faro.archive`: tails the streams into a durable per-day archive and
  trims them), `heartbeat.py` (container healthcheck verdict), `__main__.py`
  (wires it together).

## Develop

```bash
uv sync                        # wire resolves from PyPI (dollarsmore-wire)
uv run pytest -q
uv run ruff check .
uv run mypy faro tests tools
uv run typos .
```

## Docker

```bash
docker build -t faro .         # installs dollarsmore-wire from PyPI
```

The image ships only `faro` + `wire` + `requests`/`redis` — no strategy code — so
it is safe to publish.
