Disclaimer
These examples are provided for educational purposes only. They are not financial advice and do not guarantee profit.
The current bot examples were generated with AI-assisted development and tested before publication. They demonstrate how VOOI Ultra users can quickly prototype trading bots and agentic trading workflows using VOOI Perps API access available through VOOI Ultra.
Testing does not make the bots risk-free. Trading bots can place real orders and interact with real funds. Review the code, configuration, strategy, and risk controls before running any bot with live capital.
Automated crypto-futures trading bot that ingests trading signals from Telegram channels, parses them with an LLM, and routes orders to perpetual exchanges (Hyperliquid, Lighter, Aster) via the VOOI Ultra API.
⚠️ This bot trades with real money. It will place leveraged perpetual orders against connected exchanges as soon as it parses a valid signal. Read the Safety and Risk management sections before pointing it at a funded account. The authors accept no liability for trading losses. See LICENSE.
- What it does
- Architecture
- Signal funnel
- Quickstart
- Configuration
- CLI reference
- Database schema
- Risk management
- Operations
- Development
- Known limitations
- License
- Listens to a configured set of Telegram channels via Telethon (real-time + 60s polling fallback).
- Parses each message with an OpenAI-compatible LLM into a structured signal
{symbol, side, entry_prices, take_profits, stop_loss, leverage}or rejects it as noise. - Routes valid signals to one of three connected exchanges (Hyperliquid / Lighter / Aster) using live quotes from
/exchange/quotesto pick the venue with the best execution. - Places a reduce-only-aware limit entry, then — once filled — places SL and TP triggers via VOOI Ultra API.
- Monitors open positions: moves SL to breakeven on configurable PnL%, runs a reconciler against
/exchange/positions+/exchange/open-orders, and an SL-safety watchdog that firesERROR_NAKED_POSITIONif a position is observed without a stop-loss. - Persists everything (messages → signals → orders → positions → trades) in PostgreSQL so the system survives restarts and can audit historic runs.
- ✅ Spot perpetual futures (linear, USD-quoted).
- ✅ Three venues via VOOI: Hyperliquid, Lighter, Aster.
- ❌ Spot trading (no spot endpoints used).
- ❌ Options.
- ❌ Mean-reversion / market-making strategies — entries are driven by external signals only.
┌──────────────┐ NewMessage ┌──────────┐ parse ┌────────┐
│ Telegram ├─────────────────▶│ ingester ├─────────────▶│ LLM │
│ channels │ └─────┬────┘ │ (GPT) │
└──────────────┘ │ └────┬───┘
▼ valid signal │
┌────────────┐ ◀──────────────┘
│ router │
└─────┬──────┘
│ quote shop, sizing,
▼ rate-limit, dedup
┌────────────┐ POST /exchange/orders
│ orders.py ├──────────────────────────▶ VOOI API ─▶ exchange
└─────┬──────┘
│ entry filled (SSE/reconciler)
▼
┌──────────────────┐ POST trigger × 2
│ post_fill_placer ├──────────────────▶ VOOI API
└─────┬────────────┘ (SL + TP)
│
▼
┌───────────────────────────────┐
│ breakeven (SSE-driven) │ ── price ticks fire evaluator
│ breakeven_supervisor (10s) │ ── heartbeat + reconcile + rescue
│ reconciler (60s sync) │ ── REST positions/orders
│ sl_safety_check (30s) │ ── NAKED + NO_TP alerts
│ tp_safety_watchdog (30s) │ ── retries missing TPs
│ lighter_sl_watchdog (30s) │ ── re-places dropped SLs
└───────────────────────────────┘
| Task | File | Purpose |
|---|---|---|
ingester |
bot/ingester.py |
Telethon real-time + polling, persists messages |
sse_listener |
bot/sse_listener.py |
Subscribes to /exchange/updates for live prices + order events |
reconciler |
bot/reconciler.py |
Every 60s reconciles DB ↔ exchange (positions, orders, expired limits) |
breakeven_supervisor |
bot/breakeven_watcher.py |
10 s loop: heartbeat, reconcile in-memory trigger_index with DB, REST-rescue positions whose SSE feed went stale. Trigger detection itself is event-driven from SSE — see evaluate_breakeven_trigger. |
sl_safety_check |
bot/sl_safety.py |
Detects positions without active SL → ERROR_NAKED_POSITION. Also flags missing TP → ERROR_NO_TP (only while SL has not been moved to breakeven). |
tp_safety_watchdog |
bot/sl_safety.py |
Re-places missing TP orders for open positions. Rate-limit: 6 attempts/hour per position. |
lighter_sl_watchdog |
bot/sl_safety.py |
Re-places SL on lighter (where the venue occasionally drops triggers). Rate-limit: 3 attempts/hour per position. |
startup_cleanup (bot/startup_cleanup.py) runs once at boot to cancel dangling orphan orders and mark phantom positions.
The "yield" from a raw message to an actual fill is heavily filtered. Understanding this is essential for tuning.
Telegram messages
│
▼ LLM classifier
parsed signal? ──── no ──▶ stored as noise (signals.is_signal = false)
│ yes
▼ router checks
skip_reason set? ──── yes ─▶ stored, no order (symbol_not_found, rate_limit_*, already_in_position, ...)
│ no
▼ POST /exchange/orders
entry order placed ──── rejected by exchange ─▶ status='rejected'
│ filled
▼ post_fill_placer
SL + TP placed (status='open') ── partial fail ─▶ ERROR_NAKED_POSITION
│
▼ outcomes
closed_tp / closed_sl / closed_manual / closed_liquidation
Skip reasons populated by the router:
signals.skip_reason |
When |
|---|---|
symbol_not_found |
Symbol absent from /exchange/markets on all enabled venues |
rate_limit_global |
MAX_PLACEMENTS_PER_HOUR_GLOBAL exceeded |
rate_limit_per_channel |
MAX_PLACEMENTS_PER_HOUR_PER_CHANNEL exceeded |
already_in_position |
A position for the same symbol is already open on the chosen venue |
symbol_active_on_all_available_exchanges |
All venues already trading this symbol — no free slot |
Three supported paths: Docker (fastest, batteries included), native local (macOS / Linux dev box with system Postgres), and VPS (Ubuntu/Debian, systemd). All three need the same credentials.
- A Telegram account; app credentials from https://my.telegram.org/apps.
- A VOOI Ultra API key from https://ultra.vooi.io/api-tokens.
- An OpenAI-compatible API key (defaults to
gpt-4o-mini). - Python 3.11+ and PostgreSQL 15+ (Docker bundles both).
git clone <repo-url> vooi-signal-bot
cd vooi-signal-bot
cp .env.example .env
cp channels.txt.example channels.txtEdit .env and fill in:
| Field | Where to get it |
|---|---|
TELEGRAM_API_ID, TELEGRAM_API_HASH |
https://my.telegram.org/apps |
VOOI_API_KEY |
https://ultra.vooi.io/api-tokens |
LLM_API_KEY |
OpenAI / Anthropic / Azure-OpenAI key |
POSTGRES_PASSWORD and matching DATABASE_URL |
Choose your own |
ALERT_TELEGRAM_BOT_TOKEN / _CHAT_ID (optional) |
Create via @BotFather, get chat id from @userinfobot |
Sensible defaults are already set for trading parameters (DEFAULT_SL_PCT,
MIN_PROFIT_PCT_OF_COLLATERAL, BREAKEVEN_TRIGGER_PCT, …). See
Configuration before changing them.
docker compose up -d db # start Postgres
docker compose run --rm bot python -m bot tg-login # one-time interactive Telegram auth
docker compose up -d bot # start the bot
docker compose logs -f bot # follow outputThe tg-login step prompts for the SMS code that Telegram sends to your phone.
It writes signal_bot.session (SQLite) in the project root — keep it safe; it
is the equivalent of a logged-in Telegram session.
Stop everything: docker compose down. The DB volume persists across restarts.
Useful when you want to iterate on code without rebuilding containers.
# macOS — Postgres via Homebrew
brew install postgresql@15
brew services start postgresql@15
createdb signalbot
psql signalbot -c "CREATE USER botuser WITH PASSWORD 'change_me_before_production';"
psql signalbot -c "GRANT ALL PRIVILEGES ON DATABASE signalbot TO botuser;"
psql signalbot -c "GRANT ALL ON SCHEMA public TO botuser;"
# Linux — Postgres via apt
sudo apt install postgresql-15
sudo systemctl enable --now postgresql
sudo -u postgres createdb signalbot
sudo -u postgres psql -c "CREATE USER botuser WITH PASSWORD 'change_me_before_production';"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE signalbot TO botuser;"
# Python project (both platforms)
python3.11 -m venv .venv && source .venv/bin/activate
pip install -e .
# Apply migrations
alembic upgrade head
# Interactive Telegram login (one-time per session file)
python -m bot tg-login
# Add channels and start
python -m bot channels add @some_signals_group
python -m bot channels list
python -m bot runCommon gotcha: if you ever see lock file "postmaster.pid" already exists from
Postgres after a hard shutdown, delete the stale postmaster.pid in the data
directory (/opt/homebrew/var/postgresql@15/ on macOS,
/var/lib/postgresql/15/main/ on Linux) and restart the service. Never delete
the file while Postgres is actually running.
Production-ish setup for a small VPS (1 vCPU, 1 GB RAM is enough; reserve 5+ GB disk for logs and DB).
# 1) System prep
sudo apt update && sudo apt install -y python3.11 python3.11-venv \
postgresql-15 git curl
sudo systemctl enable --now postgresql
# 2) Dedicated user
sudo useradd -m -s /bin/bash signalbot
sudo -iu signalbot
# 3) Clone + configure
git clone <repo-url> ~/signal_bot
cd ~/signal_bot
cp .env.example .env
nano .env # fill in credentials
# 4) Database
sudo -u postgres createdb signalbot
sudo -u postgres psql -c "CREATE USER botuser WITH PASSWORD 'pick-a-strong-one';"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE signalbot TO botuser;"
# match DATABASE_URL in .env to the user/password you just set
# 5) Python deps + migrations
python3.11 -m venv .venv
source .venv/bin/activate
pip install -e .
alembic upgrade head
# 6) Telegram login (interactive — needs SMS code)
python -m bot tg-login
# 7) Add channels
python -m bot channels add @some_signals_group
# 8) Install the systemd unit
exit # back to your sudo-able user
sudo cp /home/signalbot/signal_bot/signal-bot.service /etc/systemd/system/
sudo nano /etc/systemd/system/signal-bot.service # check WorkingDirectory + User
sudo systemctl daemon-reload
sudo systemctl enable --now signal-bot
sudo systemctl status signal-bot
journalctl -u signal-bot -fVPS-specific recommendations:
- Firewall: do not expose Postgres to the public internet. Bind it to
127.0.0.1(listen_addressesinpostgresql.conf). - Log rotation: the bot writes to
./logs/bot.logand./logs/vooi-raw.log. Add a logrotate config or rely onjournalctl --vacuum-time=14d. - Backups:
pg_dump signalbot > backup-$(date +%F).sqldaily — the DB is the source of truth for PnL accounting. - Restart on reboot:
systemctl enable signal-bothandles this. The Telethon session is persistent insignal_bot.session; you do not need to log in again. - Network reliability: if Telegram or the VOOI API gets briefly cut off, the
bot's
ingestertask crashes and the supervisor stops all six tasks (exit 0). The unit file hasRestart=on-failure— adjust toRestart=alwaysif you want it to recover from any exit without ops intervention.
python -m bot status # connectivity + counts
python -m bot vooi-check # VOOI API auth check
python -m bot signals --since 1h # show recently parsed signalsAll configuration is via environment variables (12-factor). See .env.example for the full list with inline comments. The key knobs:
| Variable | Default | Effect |
|---|---|---|
DEFAULT_LEVERAGE |
5 |
Initial leverage if signal doesn't specify one |
MAX_LEVERAGE |
10 |
Hard cap regardless of signal |
DEFAULT_MARGIN_MODE |
cross |
cross or isolated |
DEFAULT_POSITION_SIZE_PCT |
5 |
% of free margin per trade |
MAX_POSITION_SIZE_USD |
1000 |
Absolute notional cap |
DEFAULT_SL_PCT |
5 |
Max % of MARGIN to lose on a single trade. Price distance = DEFAULT_SL_PCT / leverage. |
USE_SIGNAL_SL |
false |
true: honor signal's explicit stop_loss when present. false: always derive SL from DEFAULT_SL_PCT (hard-cap margin loss regardless of what the channel said). |
All "target" percentages in the trading section are denominated as a fraction of margin (collateral), not price. This keeps numbers comparable across leverages: "I want to risk 5% of margin per trade" is the same intent whether you're trading 3x or 10x. The bot converts margin-% to price-% internally by dividing by leverage.
price_distance_pct = margin_pct / leverage
| At lev=3 | At lev=5 | At lev=10 |
|---|---|---|
| 5% margin = 1.667% price | 5% margin = 1.0% price | 5% margin = 0.5% price |
The TP price target is calculated dynamically:
TP_distance_price = (MIN_PROFIT_PCT_OF_COLLATERAL / 100 / leverage)
+ max(actual_overhead_pct, TP_OVERHEAD_FLOOR_PCT / 100 / leverage)
where actual_overhead_pct = (quote_fees_bps * 2
+ exit_slippage_bps
+ FUNDING_COST_BUFFER_BPS) / 10000
# VOOI's quote.feesBps already includes any server-side broker / builder
# fee, so we do not add it separately. The factor of 2 covers both the
# entry and exit legs.
Both MIN_PROFIT_PCT_OF_COLLATERAL and TP_OVERHEAD_FLOOR_PCT are in % of
margin. The floor protects against quotes returning unrealistically small
overhead (e.g. some venues report taker=0); in that case the floor widens TP
so real fees and slippage cannot eat the profit.
| Variable | Default | Effect |
|---|---|---|
MIN_PROFIT_PCT_OF_COLLATERAL |
3 |
Required net profit on margin (%) after fees. |
FUNDING_COST_BUFFER_BPS |
5 |
Estimated funding cost padding. |
TP_OVERHEAD_FLOOR_PCT |
2 |
Floor on cost-overhead component, in % of margin. 0 = disabled. |
BREAKEVEN_TRIGGER_PCT |
0.5 |
Move SL to BE when unrealised profit reaches this % of margin. |
Worked example — BTC long at entry 77200, lev=5, lighter:
MIN_PROFIT=3of margin = 0.6% of price (3 / 100 / 5).TP_OVERHEAD_FLOOR_PCT=2of margin = 0.4% of price.- Raw overhead on lighter (taker=0, builder=1.5bps × 2 + slippage 5bps + funding 5bps) = 13 bps = 0.13% of price.
- 0.13% < 0.4% floor → cost overhead = 0.4%.
- Total = 0.6% + 0.4% = 1.0% of price.
- TP = 77200 × 1.01 = 77972. Gross +5% margin; net ≈ +4.35% margin after real costs.
Worked example — same trade, signal_sl=75000:
- With
USE_SIGNAL_SL=true: SL placed at 75000 → −14% of margin if hit. - With
USE_SIGNAL_SL=false(default): SL placed at 77200 × (1 − 5%/5/100) = 76428 → −5% of margin if hit.
- Entry placed as a limit order. No SL / TP yet.
- Entry fills.
post_fill_placerimmediately submits two reduce-only trigger orders:- SL: either signal-provided (if
USE_SIGNAL_SL=true) or computedentry ± DEFAULT_SL_PCT / leverage. - TP: computed per the formula above.
- SL: either signal-provided (if
- Breakeven move. Event-driven from SSE: every
marketPriceframe callsevaluate_breakeven_trigger, which checks the in-memorytrigger_indexand, ifBREAKEVEN_TRIGGER_PCT(% of margin) is crossed in our favor, schedulesmove_sl_to_breakevenas a separate asyncio task. The move cancels the existing SL and places a new one atentry × (1 ± buffer)wherebuffer ≈ 8 bpscovers exit fees. From this point the position cannot close in the red — worst case is exit at ~0. A 10 s supervisor loop (tp_breakeven_supervisor_task) maintains the heartbeat, reconciles the trigger index with the DB, and REST-rescues positions whose SSE feed has gone stale. - SL safety watchdog (every 30s) verifies every
openposition has an active SL on the exchange. Missing →ERROR_NAKED_POSITIONalert. A separatelighter_sl_watchdogre-places SL on lighter specifically, where the exchange occasionally drops trigger orders for opaque reasons. - TP safety watchdog (every 30s) verifies every
openposition has an active TP — including after BE-SL has fired (BE-SL caps the loss at ~0 but does NOT take profit; without a TP the position runs unbounded until manual close). Missing → recompute the target via the same formula used at post-fill and re-place via the verified-trigger path. Rate-limited at 6 attempts/hour/position; final failure surfacesERROR_NO_TP. This recovers from the common pattern "VOOI returned 503 during the post-fill TP POST and the bot gave up". - Reconciler fill recovery (every
RECONCILER_INTERVAL_SEC) is the ground-truth backstop when both SSE and/exchange/positionsmiss a fill. It works from three independent angles so a single upstream gap cannot leave a position untracked (and therefore unprotected):adopt_orphan_exchange_positionswalks live/exchange/positionsand adopts any non-zero position with no DB row by matching it to a same-side, same-size (±5%) entry order — then runs the normal post-fill SL/TP placement. It also revives entries previously written off asexpired/cancelled_reconcilerwhen a matching live position proves they actually filled.detect_entry_fills_from_historyworks from our own entry orders the other way round: for each entry with avooi_order_idbut no position, it checks/exchange/trades(the one endpoint that reflects a fill immediately, even when/positionslags). A fill that already closed is reconstructed into a closed position + trade for PnL completeness; a fill still open gets SL/TP placed. Throttled to one/tradeslookup per order per 10 min.- A live position that matches no entry order at all is escalated as
ERROR_NAKED_POSITION(de-duped to once per hour), never left as a silent log line. cancel_expired_limit_ordersonly marks an entryexpiredwhen the cancel is confirmed (2xx) or the order is already gone (404), and never when a live position for that symbol exists. A failed cancel (e.g. a transient401) leaves the order live so it is retried — preventing the desync where a "cancelled" order later fills into an untracked position.
Caveat at high leverage: at
lev ≥ 7, the fixed 8-bps BE buffer (in % of price) can exceedBREAKEVEN_TRIGGER_PCT / leverage. The breakeven move would place SL above the current price → instant trigger, near-zero close. IncreaseBREAKEVEN_TRIGGER_PCTwhen raising leverage above 6.
| Variable | Default | Effect |
|---|---|---|
MAX_PLACEMENTS_PER_HOUR_GLOBAL |
10 |
Total entries across all channels |
MAX_PLACEMENTS_PER_HOUR_PER_CHANNEL |
3 |
Per-channel rate limit |
DAILY_DD_PCT_PAUSE |
10 |
Pause new entries if realized+unrealized DD ≥ this % |
LIMIT_ORDER_TTL_HOURS |
24 |
Auto-cancel unfilled entry limits after N hours |
MIN_NOTIONAL_USD_* |
10 |
Hard minimum order notional, per exchange |
| Variable | Default | Effect |
|---|---|---|
RECONCILER_INTERVAL_SEC |
60 |
DB↔exchange sync period |
TELEGRAM_POLL_INTERVAL_SEC |
60 |
Backup polling for missed messages |
SSE_PRICE_STALENESS_THRESHOLD_SEC |
30 |
Fall back to REST quote if SSE cache is older |
LOG_LEVEL |
INFO |
DEBUG / INFO / WARNING |
ASTER_DEBUG_LOG_PATH |
./logs/aster-debug.log |
Verbose per-request NDJSON for aster calls (see below) |
Every VOOI request/response is already persisted to the vooi_api_calls / vooi_errors tables and a summary line to logs/vooi-raw.log. For diagnosing intermittent aster auth failures (401 Invalid credentials) there is an extra, verbose stream at ASTER_DEBUG_LOG_PATH: one NDJSON line per phase (request_sent before the call, response_received, error) carrying the full method/URL, redacted request headers, request body, response headers (incl. the upstream fly-request-id to hand to support), response body, and duration — all keyed by the same correlation_id as the DB rows. The pre-flight request_sent line means an action is recorded even if the response never arrives (timeout / crash). An aster 401 additionally emits a loud aster_auth_failed warning to the live log with the request id surfaced.
These must be set per deployment. .env is git-ignored.
TELEGRAM_API_ID,TELEGRAM_API_HASHVOOI_API_KEYLLM_API_KEYPOSTGRES_PASSWORD/ fullDATABASE_URLALERT_TELEGRAM_BOT_TOKEN,ALERT_TELEGRAM_CHAT_ID
All commands are invoked as python -m bot <command> (or bot <command> after pip install -e .).
| Command | Purpose |
|---|---|
run |
Start all 7 concurrent tasks. Foreground process; supervise externally (systemd / docker). |
status |
Health probe: DB connectivity, counts, last activity. |
vooi-check |
Validate VOOI_API_KEY against /exchange/positions. |
tg-login |
Interactive Telethon login; creates *.session file. Run once before run. |
healthcheck |
Used by Dockerfile HEALTHCHECK. Non-zero exit on failure. |
| Command | Purpose |
|---|---|
channels add @username (or numeric id) |
Resolve via Telethon, insert into channels table. |
channels list |
Show all channels with is_active flag. |
channels remove <id> |
Remove from the DB. To disable without deleting: UPDATE channels SET is_active = false WHERE id = .... |
| Command | Purpose |
|---|---|
signals --since 24h |
List parsed signals in the window. |
messages --since 24h |
List raw messages (incl. noise). |
signal-dryrun <id> |
Re-parse a stored message without placing orders. |
simulate-breakeven <position_id> |
Trigger a breakeven check without waiting for the loop. |
vooi-errors list |
Read recent VOOI API errors from vooi_errors table. |
vooi-errors export <out.json> |
Bundle for sharing with the VOOI team. |
vooi-errors mark-reported <ids> |
Mark errors as reported (won't appear in default list). |
vooi-errors prune --before <date> |
Delete old rows. |
PostgreSQL 15+. All tables in the default public schema. Migrations managed by Alembic (alembic upgrade head runs automatically on bot run).
| Table | Purpose |
|---|---|
channels |
Telegram channels being monitored. |
messages |
Every raw message ingested (telegram_message_id unique per channel). |
signals |
LLM-parsed messages. is_signal=false rows are noise; skip_reason non-null = filtered before routing. |
orders |
Every order we sent to VOOI (entry, stopLoss, takeProfit). Tracks status lifecycle. |
positions |
Filled positions linking entry/SL/TP orders; carries realized PnL on close. |
trades |
Fills derived from /exchange/trades (used by _try_close_from_history for phantom recovery). |
events |
Structured event log (NAKED, breakeven_triggered, etc.) for downstream alerting. |
vooi_api_calls |
Raw audit of every VOOI HTTP call — method, path, status, duration, correlation_id. |
vooi_errors |
Subset of failed calls, grouped for triage / export. |
runtime_state |
KV store for cross-task state (rate-limit counters, daily DD baseline). |
Key invariants:
positions.entry_order_idis NOT NULL — a position exists iff its entry filled.positions.sl_order_idMAY be NULL momentarily (between fill detection and post-fill placement).sl_safety_checkfiresERROR_NAKED_POSITIONif it stays NULL for >30s afterstatus='open'.orders.vooi_order_idis the exchange-side ID; recovered via clientOrderId lookup or attribute match when VOOI's POST returns{status:"ok"}without an ID.
The bot has multiple safety layers; understand each before running with real funds.
- Reduce-only triggers: SL and TP are placed with
reduceOnly=true, so they can never accidentally open a new position. - Min notional check: orders below
MIN_NOTIONAL_USD_*are skipped client-side withorder_skip_below_min_notionalto avoid exchange rejection storms. - Limit TTL: unfilled entry limits older than
LIMIT_ORDER_TTL_HOURSare auto-cancelled by the reconciler.
- Mandatory SL: every filled position triggers
post_fill_placerwhich places SL + TP. If the SL POST cannot be verified on the exchange (lighter, in particular, returnsclientOrderId=nullwhich breaks the standard lookup), the order is markedrejectedandERROR_NAKED_POSITIONis fired. The bot does not silently leave positions without a stop. - Breakeven move: once price moves
BREAKEVEN_TRIGGER_PCTin our favor, the existing SL is cancelled and a new SL is placed at entry. 3 retry attempts; if all fail, firesbreakeven_sl_exhausted_ERROR_NAKED_POSITIONand alerts. - SL safety watchdog: every 30s, scans positions with
status='open'and verifies an active SL exists on the exchange. Missing → NAKED alert. - Phantom detection: positions missing from
/exchange/positionsfor ≥3 reconciler cycles (≥180s) are reconciled against trade history. If no closing fill is found, they're markedclosed_manualwithclose_reason='phantom_no_exchange_position'— a loud signal that something diverged.
- Rate limits:
MAX_PLACEMENTS_PER_HOUR_*cap how aggressively a single channel can drive the bot. - Daily drawdown pause: when realized+unrealized PnL ≥
DAILY_DD_PCT_PAUSEof starting equity, new entries are blocked until the next UTC day. - Per-trade notional cap:
MAX_POSITION_SIZE_USDoverridesDEFAULT_POSITION_SIZE_PCTif the % calc would produce a larger trade.
- Alerts: any
ERROR_*event (alerts.py) is pushed to a configured Telegram chat ifALERT_TELEGRAM_BOT_TOKENis set. The bot will not stop on its own — alerts are advisory. - Audit log: every VOOI API call is written to
logs/vooi-raw.log(JSONL) with correlation_id. Errors additionally land invooi_errorsfor export.
This is not financial advice and not a turnkey money printer. It is a faithful executor of whatever Telegram channels you point it at. If those channels publish bad signals, the bot will trade bad signals.
Before running with real funds:
- Run on a paper / small-balance account first. Hardcode
MAX_POSITION_SIZE_USD=10andDEFAULT_POSITION_SIZE_PCT=1for at least a week. Measure the win-rate and slippage on your specific channels. - Read every closed position in the first week. Verify the bot's PnL math against your exchange statements.
- Configure alerts. Set
ALERT_TELEGRAM_BOT_TOKENso you get notified of NAKED positions and daily DD pauses. - Have a kill switch.
docker compose downorsystemctl stop signal-botwill stop new orders within seconds. Open positions will remain — you must close them manually on the exchange. - Channel selection matters more than parameters. Bad signals tuned aggressively still lose money.
See Path C — VPS above for the systemd setup. Once installed, day-to-day commands:
sudo systemctl status signal-bot # current state
sudo systemctl restart signal-bot # apply config / .env changes
sudo systemctl stop signal-bot # halt new orders (keeps Postgres up)
journalctl -u signal-bot -f # tail logs
journalctl -u signal-bot --since "1 hour ago" | grep ERRORThe Telethon session is a SQLite file; concurrent access by two bot processes leads to database is locked. Always verify there is at most one process before starting:
pgrep -fa "python -m bot"
lsof signal_bot.session 2>/dev/null # Should show no processThen start cleanly. If the lock file (*-journal) exists from a previous crash, SQLite auto-recovers on the next open.
python -m bot status returns non-zero on any of:
- DB unreachable
- VOOI API auth failure
- No messages in the last N minutes (configurable)
The Dockerfile HEALTHCHECK invokes python -m bot healthcheck every 30s.
pg_dump signalbot > backup.sql regularly. The DB is the source of truth for PnL accounting — losing it loses your audit trail.
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"pytest tests/unit/ # fast, no external deps
pytest tests/integration/ # requires local Postgres
pytest tests/e2e/ # requires live VOOI + Telegrambot/
├── cli.py # Typer entry point — every command
├── config.py # Pydantic Settings — env var schema
├── db.py # SQLAlchemy 2.x async engine / session_scope
├── models.py # ORM models matching the schema above
├── ingester.py # Telethon listener + polling
├── parser.py # LLM signal parsing
├── llm_client.py # OpenAI wrapper
├── router.py # Venue selection, dedup, rate limits
├── resolver.py # Symbol normalization + decimal caches
├── orders.py # place_entry_order, lookups
├── post_fill_placer.py # On fill: place SL + TP (with verification)
├── breakeven_watcher.py # SSE-driven BE evaluator + 10 s supervisor
├── sl_safety.py # NAKED detector
├── sse_listener.py # /exchange/updates subscriber
├── reconciler.py # 60s DB↔exchange sync
├── startup_cleanup.py # Boot-time orphan cleanup
├── vooi_client.py # httpx wrapper, retries, redaction
├── vooi_audit.py # vooi_api_calls / vooi_errors writer
├── streamer.py # Internal event emitter (DB + alerts)
├── alerts.py # Telegram outbound alerts
├── reports.py # CLI reports (signals, messages)
├── tp_calculator.py # TP/SL pricing math
└── __main__.py # `python -m bot` -> cli.app
- All I/O is async. No blocking calls in the main event loop.
- Decimals use
decimal.Decimal, never floats, on the trade path. - Every DB transaction goes through
session_scope()(no manualsession.commit()). - Logs are structured (
structlog). Event names use snake_case verbs (entry_order_placed,breakeven_trigger_detected). - Every VOOI API error is logged with
correlation_id; that ID flows through tovooi_errors.
- Stale SSE rescue is REST-based. Breakeven detection is normally driven by
marketPriceSSE frames. If VOOI's SSE goes down for >SSE_PRICE_STALENESS_THRESHOLD_SEC, the 10 s supervisor batch-quotes/exchange/quotesfor stale positions only — far gentler than the old 2 s per-position poll, but still adds REST load when the SSE link is broken. - Lighter returns
clientOrderId: nullin/exchange/open-orders, breaking the standard lookup path.post_fill_placer._verify_trigger_on_exchangeworks around this by matching(asset, side, triggerPrice, size, type). If you seepost_fill_trigger_unverified_NAKEDin logs, this is the recovery path firing. - Aster does not support bracket orders. TP/SL must be placed as separate orders after the entry fills — never inline with the entry POST. This is handled automatically.
- Hyperliquid requires
0x-prefixed 128-bit hex clientOrderIds. Seebot/orders.py:make_client_order_id. The breakeven watcher uses the same helper. - VOOI Ultra API may return
503with the actual error embedded in the body rather than the appropriate HTTP code. The client unwraps known wrapper strings and re-classifies. - Symbol normalization is heuristic.
BTC/USDT,BTCUSDT,BTC-PERPall resolve toBTCviaresolver.py. If your signals use exotic notation, extend_canonical(). - No spot, no options, no cross-margin transfers. Out of scope.
Pull requests welcome. Please:
- Open an issue first for non-trivial changes.
- Keep PRs focused — one logical change per PR.
- Add tests for new behavior, especially around order placement and reconciler logic.
- Don't commit
.env, session files, or live API responses.
MIT. The Software is provided "as is", without warranty of any kind. Use at your own risk.