Metadata-Version: 2.4
Name: dollarsmore-faro
Version: 0.10.0
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.3.0
Requires-Dist: redis<9,>=8.0.1
Requires-Dist: requests<3,>=2.34.2
Requires-Dist: sentry-sdk<3,>=2.66.0
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`) |

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.

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. And an owed flatten blocks arming, so the two surfaces cannot disagree.

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.

## 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), `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 .
```

## 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.
