TradingView is where most retail traders build strategies. MetaTrader 5 is where most brokers execute trades. The gap between them is a webhook -- and filling that gap reliably at low latency on a $7/month budget turned out to be a surprisingly deep engineering problem.
I'm the solo founder of SignalForge AI, a TradingView-to-MT5 bridge SaaS. This post covers the architecture decisions, the bugs that cost real money, and the latency optimization that took execution from 12 seconds to 172ms.
The architecture in 30 seconds
TradingView Alert (webhook)
|
v
Flask on Railway (US East)
- Authenticate token
- Parse signal (3 formats)
- Route to broker account(s)
- Store in PostgreSQL | v MT5 Expert Advisor (on client PC)
- Polls /ea/poll/ every 500ms
- Picks up pending signal
- Executes on broker
- Reports back to /ea/ack No WebSockets. No message queue. Just HTTP polling. I'll explain why. Why polling beats WebSockets here The MT5 Expert Advisor (EA) runs inside MetaTrader, which is a Win32 application from 2010. MQL5 (the EA programming language) supports WebRequest() for HTTP but has no native WebSocket client. You can hack one with DLLs, but:
DLLs require the user to enable "Allow DLL imports" (security risk, scares users)
DLL WebSocket libraries in MQL5 are fragile and poorly maintained
If the WebSocket drops, reconnection logic in MQL5 is painful
HTTP polling at 500ms intervals gives ~250ms average latency (half the polling interval) which is perfectly acceptable for trade execution. The total end-to-end from TradingView alert to MT5 order is 375-875ms, well under the 1-second threshold traders care about.
The simplicity also means the EA is a few hundred lines of MQL5 with zero external dependencies.
The multi-format parser
TradingView alerts can send any text as a webhook payload. Different traders use different formats, and I wanted to support all of them without forcing a specific syntax. The parser accepts three formats:
- Key-value (our native format): buy XAUUSD lot=0.01 sl=1500 tp=4000
- JSON: json{"action": "buy", "symbol": "XAUUSD", "lot": 0.01, "sl": 1500, "tp": 4000}
- PineConnector-compatible compact: short US30.cash lot=0.5 sl=1000 tp=1300 The parser tries JSON first (cheapest check), then falls back to key-value splitting. Action aliases are normalized: long becomes buy, short becomes sell, closelong/closeshort/closeall become close. pythonFIELD_ALIASES = { 'ticker': 'symbol', 'volume': 'lot', 'signal': 'action', 'stop_loss': 'sl', 'take_profit': 'tp', 'takeprofit': 'tp', 'stoploss': 'sl', }
ACTION_ALIASES = {
'long': 'buy',
'short': 'sell',
'closelong': 'close',
'closeshort': 'close',
'closeall': 'close',
}
One deliberate decision: quantity is not aliased to lot. In futures, quantity means contracts. In forex, lot means lot size. Conflating them silently would cause position sizing errors that cost real money. If someone sends quantity=5, the parser rejects it with a clear error rather than opening 5 lots of gold.
The bug that broke a prop firm challenge
An early user was running PineConnector-style alerts on FTMO with US30.cash as the symbol. His trades were failing with "symbol not found."
The bug: I had .upper() on the symbol field for normalization. Turns out FTMO's MT5 instance uses US30.cash (mixed case), not US30.CASH. Other brokers use XAUUSD.m, EURUSD.raw, etc. Case matters.
The fix was one line: remove .upper() and preserve the symbol exactly as the trader sends it. But it was a reminder that in trading infrastructure, every normalization is a potential break. The symbol the trader types must be the exact symbol their broker recognizes.
From 12 seconds to 172ms: the latency fix
After launch, some signals were taking 12+ seconds to execute. For a scalping strategy, that's catastrophic -- the entry price has moved significantly.
The culprit: expire_old_signals(). This function cleans up signals older than 60 seconds so they don't execute stale. I was calling it on every single request -- every poll, every webhook, every ping.
On a PostgreSQL database hosted on Railway, each call triggered a DELETE FROM signals WHERE created_at < NOW() - interval '60 seconds'. With WAL checkpoints and Railway's shared disk I/O, this added 2-8 seconds of latency randomly.
The fix: call expire_old_signals() at most once every 60 seconds using a simple timestamp check:
python_last_expire = 0
def maybe_expire_signals():
global _last_expire
now = time.time()
if now - _last_expire > 60:
_last_expire = now
expire_old_signals()
Median latency dropped from 12 seconds to 172ms. The commit hash is burned into my memory.
Multi-broker routing
Most bridge tools support one broker account. Traders running multiple accounts (e.g., a personal account plus a prop firm challenge) need one alert to execute on all of them.
The routing logic:
pythondef _get_target_brokers(user, signal_data):
active = BrokerAccount.query.filter_by(
user_id=user.id, status='active'
).all()
# If signal specifies an account, route to that one only
target_account = signal_data.get('account')
if target_account:
return [b for b in active if b.mt5_login == target_account]
# Otherwise broadcast to all active accounts
return active
Plan limits are enforced at registration time (Starter: 1 account, Trader: 3, Pro: 10) so the broadcast is always bounded.
Auto-registration of MT5 accounts
Users kept getting stuck at the "connect your MT5" step. They'd install the EA, paste their token, but the backend had no record of their MT5 login number.
The solution: the EA pings /ea/ping/ every 30 seconds with the MT5 login in the headers. If the backend sees an unknown login, it auto-registers it:
python@app.route('/ea/ping/')
def ea_ping(token):
user = User.query.filter_by(webhook_token=token).first()
if not user:
return jsonify({'status': 'invalid_token'}), 401
mt5_login = request.headers.get('X-MT5-Login')
if mt5_login:
existing = BrokerAccount.query.filter_by(
user_id=user.id, mt5_login=mt5_login
).first()
if not existing:
register_mt5_account(user, mt5_login)
return jsonify({'status': 'ok'})
This eliminated the most common support ticket overnight.
Railway deployment specifics
The entire backend runs on Railway's Hobby plan (~$7/month including PostgreSQL). Some things I learned:
Region matters. I started on Singapore (default). Latency to TradingView's webhook servers (US-based) was 200ms+ just for the network hop. Moving to US East cut that to ~20ms.
Auto-deploy is the workflow. Push to main on GitHub, Railway builds and deploys in ~45 seconds. No CI/CD config, no Docker (Railway detects the Python buildpack automatically). For a solo founder, this is the ideal balance of speed and reliability.
PostgreSQL cold starts. Railway's managed Postgres occasionally takes 1-2 seconds on the first query after idle periods. For a trading app where every millisecond matters during market hours, this is noticeable. The fix: the EA's 30-second ping acts as a keep-alive that prevents the database from going cold during trading sessions.
Gunicorn timeout. Set to 120 seconds. The AI filter on the Pro plan calls an external LLM API that can take 3-8 seconds. Default 30-second timeout was killing those requests.
What I'd do differently
Use a proper job queue. Right now, signal processing is synchronous in the request handler. If I were starting over, I'd use Redis + Celery (or even just a simple threading.Thread) to return 200 immediately and process the signal async. The current approach works at my scale but won't scale to millions of signals.
Separate read/write databases. The polling endpoint is 95% of all traffic and only reads. The webhook endpoint writes. At scale, a read replica would eliminate all contention.
WebSocket with fallback. If MQL5 ever gets native WebSocket support (or if I build a Windows service as a sidecar), the polling overhead disappears entirely. Average latency would drop from 250ms to <50ms.
The numbers so far
Median latency: 172ms server-side, 375-875ms end-to-end
Monthly cost: ~$7 (Railway Hobby + PostgreSQL)
Codebase: a few thousand lines of Python (Flask) + a few hundred lines of MQL5 (EA)
Deploy time: ~45 seconds from git push to live
Try it
If you want to automate TradingView strategies on MetaTrader 5, SignalForge AI starts at $4.99/month with a 14-day free trial. The Trader plan ($14.99/mo) adds prop firm drawdown protection and a news filter.
If you're building something similar and have questions about the architecture, drop a comment or find me on YouTube.
Benjamin builds trading infrastructure from Alicante, Spain. He is the solo founder of SignalForge AI.
Top comments (0)