A Telegram-controlled conditional order bot for Pocket Option binary options.
You tell it a price. It keeps that instrument's live tick stream open, and the moment the market touches your price it opens the trade for you and reports back: entry, win, loss, and the account balance on every update.
Because these are binary options, there is no stop loss and no take profit anywhere in the system. The only exit is the expiry the trade was opened with.
Per order you specify:
| Field | Meaning |
|---|---|
| Symbol | e.g. GBPAUD_otc, EURUSD |
| Direction | buy (call) or sell (put) |
| Trigger price | the price the market has to reach |
| Entry mode | touch: enter on the very tick that hits the pricenext_candle: the touch only arms the order; enter on the first tick of the next candle |
| Expiry mode | fixed: stay in for exactly N secondsfloating: ride to the close of the current candle (or the N-th candle) |
| Chart | timeframe (5s … 4h) and type (candle / heikin-ashi / line) |
| Amount | trade size in USD |
| Account | demo or real, these are physically different broker clusters |
| Validity | optional deadline after which an untriggered order is abandoned |
Triggering is a crossing, not proximity. When the order is created the bot samples the market and records which side of the trigger it sat on. An order placed above the market only fires when the price rises into it; one placed below only fires when the price falls into it. A tick that gaps straight over the trigger still counts.
One live session per watched instrument. Each (account, symbol) pair that has an active order
gets its own authenticated socket to the broker, kept alive with the broker's own ping cadence and
re-subscribed automatically across reconnects. Sessions are reference-counted by the orders that need
them and torn down shortly after the last one settles.
Everything survives a restart. Orders live in SQLite and every state transition is written through. On boot the engine re-attaches each pending, armed and open order. The one exception is an order that was mid-flight when the process died: rather than risk a duplicate trade, it is flagged for you to check on the broker.
bun install
cp .env.example .env # then fill in the two required values
bun startMinimum .env:
TELEGRAM_BOT_TOKEN=123456:AA...
TELEGRAM_ADMIN_IDS= # leave empty; the first /start claims the botThen in Telegram: /start, and give the bot a broker session with /session demo <SSID>.
The bot authenticates over socket.io exactly as the web app does (it uses the socket.io-client
library against wss://demo-api-eu.po.market/socket.io/?EIO=4&transport=websocket), so it needs the
auth frame your browser sends.
- Log into the account you want in a browser and open its trading screen:
- demo:
https://p.finance/fa/cabinet/demo-quick-high-low/ - real:
https://p.finance/fa/cabinet/quick-high-low/
- demo:
- Open DevTools (F12) → Network → filter WS → reload the page.
- Click the connection whose URL looks like
demo-api-eu.po.market/socket.io/?EIO=4&transport=websocket(for a real account:api-eu.po.market). - Open the Messages tab. After the server's
40{"sid":"…"}handshake you will see an outgoing frame starting with42["auth",. - Send that whole frame to the bot:
/session demo 42["auth",{"sessionToken":"…","uid":"…","lang":"fa","currentUrl":"cabinet/demo-quick-high-low","isChart":1}]
Both auth dialects are supported, and the bot recognises either success reply:
| Front-end | Auth frame key | Success reply |
|---|---|---|
| p.finance (current) | sessionToken |
42["auth/success"] |
| older / pocketoption.com | session |
successauth |
The bot deletes the message carrying the SSID immediately, then tests the connection so you get a straight yes/no rather than a silent failure.
⏳ Sessions are short-lived. Capture the frame from a tab that is logged in right now; a token copied days earlier is almost always dead. The two failure modes are distinguished for you: a
sessionframe the broker rejects is dropped within milliseconds withNotAuthorized, while a stalesessionTokenframe is silently ignored and is reported after the auth timeout. In both cases the bot stops reconnecting, tells you, and leaves your pending orders intact until you send a fresh SSID.
The SSID can also be pre-loaded from .env (PO_DEMO_SSID / PO_REAL_SSID); values set from
Telegram take precedence and persist in the database.
| Command | Purpose |
|---|---|
/new |
Interactive order builder, every field on one inline keyboard |
/order … |
One-line order (see below) |
/list |
Active orders, with live price and a cancel button |
/cancel <id> |
Cancel a pending or armed order (an open trade cannot be cancelled) |
/history |
Recent orders |
/stats |
Wins / losses / net P&L over the last 24h |
/balance [demo|real] |
Account balance |
/price <symbol> |
Live price |
/symbols [query] |
Broker symbol list, or a search through it |
/status |
Session health, endpoints, broker clock offset |
/mode demo|real |
Default account |
/settings, /set <key> <value> |
Defaults for new orders |
/session demo|real <SSID> |
Store and test broker credentials |
/start, /help |
The guide, with pages on charts, time formats and entry rules |
/id |
Your chat id |
/order <symbol> <buy|sell> <price> [key=value …]
/order GBPAUD_otc buy 1.95320 tf=1m dur=60 amount=1 acc=demo
/order EURUSD sell 1.08540 tf=1m exp=float candles=1 entry=next
| Key | Values | Default |
|---|---|---|
tf |
5s, 1m, 5m, 1h, … |
/set tf |
chart |
candle, ha, line |
/set chart |
entry |
touch, next |
/set entry |
exp |
fixed, float |
/set expiry |
dur |
60, 1m, 31s (implies exp=fixed) |
/set dur |
candles |
1, 2, … (implies exp=float) |
/set candles |
amount |
USD | /set amount |
acc |
demo, real |
/mode |
valid |
30m, abandon if never triggered |
none |
Every value is read leniently. Durations accept any unit spelling in either language and any
case: 90, 1m, 1M, 2 minutes, ۳۰ دقیقه, 1h 30m, 2 ساعت و ۱۵ دقیقه, 3 days, 1 ماه
(a bare number means seconds). Direction, account, chart and entry words accept their Persian
equivalents too (خرید / فروش, واقعی, هایکن, بعدی), and Persian digits work everywhere.
fixed maps to the broker's relative time field: N seconds from the fill.
floating needs the trade to land exactly on a candle close, so it uses the broker's absolute
closeAt field. That field is expressed in the broker's timezone, so the client starts from
PO_SERVER_TIME_OFFSET (default 7200) and re-learns the true offset from the first order
acknowledgement, which keeps it correct across DST changes. If the broker still rejects the absolute
expiry, the engine retries the same trade with the equivalent relative duration.
If a floating order would expire within MIN_DURATION_SECONDS of the fill, it rolls forward to the
next candle instead of opening a trade that is over before it starts.
src/
index.ts wiring + graceful shutdown
config.ts env parsing and validation (zod)
types.ts domain vocabulary
pocket/
servers.ts demo/real endpoint lists with failover
protocol.ts socket.io event names and defensive frame parsers
client.ts one authenticated connection: auth, keepalive, orders
reconnect.ts backoff and endpoint failover policy
pending.ts requests waiting for a broker acknowledgement
candles.ts tick -> candle aggregation (candle / heikin-ashi / line)
symbols.ts symbol spelling and matching against the asset list
engine/
trigger.ts pure crossing detection
expiry.ts when a trade closes: fixed seconds or a candle boundary
session.ts one broker connection, its ticks and its candle series
session-manager.ts one session per (account, symbol), reference counted
market.ts balance, asset list, live price
engine.ts the order state machine
storage/
db.ts SQLite schema (bun:sqlite, WAL)
orders.ts order repository
settings.ts runtime settings, overriding env
telegram/
bot.ts assembly: access control, message routing, wiring
runtime.ts the deps and operations every command shares
router.ts inline-button dispatch by callback prefix
reply.ts HTML defaults, "working…" notices, best-effort API calls
texts.ts the /start guide and its topic pages
notify.ts engine events -> chat messages
limits.ts order guard rails
submit.ts the one path an order takes to reach the engine
symbol-check.ts the broker's verdict on a symbol, in Persian
parse.ts one-line /order syntax
format.ts all Persian user-facing copy
commands/
help.ts /start, /help, /id and the guide pages
orders.ts /new, /order, /list, /cancel, /history, /stats
market.ts /balance, /price, /symbols, /status
settings.ts /mode, /settings, /set, /session
wizard/
index.ts the /new panel: state, taps, prompts, retiring dead panels
panel.ts panel text and keyboards
draft.ts the draft order and the answers typed into it
prompts.ts what we ask when a value has to be typed
util/
time.ts duration parsing/formatting and candle boundaries
async.ts waiting for the first value a subscription delivers
values.ts coercions for data that arrives from outside the process
ssid.ts SSID payload parsing
errors.ts one readable message out of anything thrown
emitter.ts tiny typed event emitter
Order lifecycle:
pending ──price touches trigger──┬─ touch mode ──────────────► placing ─► open ─► won / lost / draw
└─ next_candle ─► armed ─► (candle opens) ─► placing ─► open ─► …
pending also ends in cancelled (by you) or expired (validity elapsed); any step can end in
failed if the broker refuses the trade.
The image is Bun on Alpine, runs as a non-root user, and keeps a read only root
filesystem. Only ./data is writable, which is where the SQLite database lives.
cp .env.example .env # then fill in the token and the SSID
mkdir -p data && sudo chown -R 1000:1000 data # 1000:1000 is the image's own bun user
docker compose up -d
docker compose logs -fTo keep the database owned by your own user instead, point the container at it:
sed -i "s/^DOCKER_UID=.*/DOCKER_UID=$(id -u)/; s/^DOCKER_GID=.*/DOCKER_GID=$(id -g)/" .env
sudo chown -R "$(id -u):$(id -g)" data
docker compose up -d --force-recreateThe database is a bind mount, not a Docker volume: ./data on the host is
mounted at /app/data in the container, so data/bot.sqlite stays in the
project directory where you can read, copy and back it up normally. Point
DATA_DIR in .env somewhere else if you want it in another path.
DOCKER_UID / DOCKER_GID must match the owner of ./data on the host,
otherwise the container cannot write the database, and deploying as root is the
usual way to get this wrong: a freshly cloned ./data is owned by root, while
the container runs as uid 1000. The entrypoint checks the directory before
startup and prints the exact chown to run instead of failing later on a write.
The mount is declared with create_host_path: false, so a missing ./data
stops compose with a clear message rather than creating a root owned directory.
The container writes a heartbeat file every 30 seconds and the healthcheck marks
it unhealthy once that file is older than two minutes, so a process that is
technically alive but no longer working shows up in docker ps as unhealthy.
With restart: unless-stopped the bot comes back after a crash or a reboot, and
pending orders are re-attached from SQLite on startup.
Useful commands:
docker compose ps # health status
docker compose restart bot # after changing .env
docker compose up -d --build # after changing the code
cp data/bot.sqlite backup-$(date +%F).sqlite # back up orders and settingsStopping is graceful: docker compose stop sends SIGTERM, the bot stops polling,
closes the broker sockets and the database, and exits within the 20 second grace
period. Open trades keep running at the broker and are reconciled on the next
start.
Everything is optional except TELEGRAM_BOT_TOKEN. See .env.example for the full list; the
notable ones:
| Variable | Default | Purpose |
|---|---|---|
TELEGRAM_ADMIN_IDS |
(empty) | Allowed chat ids. Empty means the first /start claims the bot |
PO_DEMO_SSID / PO_REAL_SSID |
(empty) | Seed credentials; /session overrides and persists |
PO_DEMO_SERVERS / PO_REAL_SERVERS |
built-in list | url or `url |
PO_SERVER_TIME_OFFSET |
7200 |
Starting guess for the broker clock offset |
MIN_DURATION_SECONDS |
5 |
Broker floor for a binary option |
SESSION_IDLE_TTL_SECONDS |
60 |
How long a session lingers after its last order settles |
DISPLAY_TIMEZONE |
Asia/Tehran |
Timezone for every timestamp shown in Telegram |
bun run dev # watch mode
bun run check # typecheck, then the full test suite
bun test # tests only, no network access required
bun run typecheck # tsc --noEmitThe engine takes its SessionManager by injection, so the whole order state machine
(triggering, arming, expiry arithmetic, settlement) is tested against a fake broker with no
sockets involved. The Telegram layer is tested by feeding synthetic updates through grammY and
capturing the outgoing API calls; tests/harness.ts builds that fake bot, tests/fakes.ts the
fake broker and the order fixtures.
tsconfig.json fails the build on unused locals and parameters, so dead code cannot accumulate
quietly.
- Pocket Option publishes no API contract. Every frame parser here is defensive, and the client
treats account-scoped data (a balance push) as proof of authentication so a renamed success event
cannot strand it. A broker-side change can still break things;
/statusand the connection notifications exist to make that obvious quickly. - The bot never cancels an open binary option, because the broker does not allow it.
- Trade at your own risk. Real-money mode does exactly what you tell it to.