This repository has been archived by the owner on Sep 23, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 126
/
run.py
551 lines (393 loc) · 21.8 KB
/
run.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
#!/usr/bin/env python3
import asyncio
import logging
import math
import os
try:
from typing import Literal
except ImportError:
from typing_extensions import Literal
from metaapi_cloud_sdk import MetaApi
from prettytable import PrettyTable
from telegram import ParseMode, Update
from telegram.ext import CommandHandler, Filters, MessageHandler, Updater, ConversationHandler, CallbackContext
# MetaAPI Credentials
API_KEY = os.environ.get("API_KEY")
ACCOUNT_ID = os.environ.get("ACCOUNT_ID")
# Telegram Credentials
TOKEN = os.environ.get("TOKEN")
TELEGRAM_USER = os.environ.get("TELEGRAM_USER")
# Heroku Credentials
APP_URL = os.environ.get("APP_URL")
# Port number for Telegram bot web hook
PORT = int(os.environ.get('PORT', '8443'))
# Enables logging
logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)
# possibles states for conversation handler
CALCULATE, TRADE, DECISION = range(3)
# allowed FX symbols
SYMBOLS = ['AUDCAD', 'AUDCHF', 'AUDJPY', 'AUDNZD', 'AUDUSD', 'CADCHF', 'CADJPY', 'CHFJPY', 'EURAUD', 'EURCAD', 'EURCHF', 'EURGBP', 'EURJPY', 'EURNZD', 'EURUSD', 'GBPAUD', 'GBPCAD', 'GBPCHF', 'GBPJPY', 'GBPNZD', 'GBPUSD', 'NOW', 'NZDCAD', 'NZDCHF', 'NZDJPY', 'NZDUSD', 'USDCAD', 'USDCHF', 'USDJPY', 'XAGUSD', 'XAUUSD']
# RISK FACTOR
RISK_FACTOR = float(os.environ.get("RISK_FACTOR"))
# Helper Functions
def ParseSignal(signal: str) -> dict:
"""Starts process of parsing signal and entering trade on MetaTrader account.
Arguments:
signal: trading signal
Returns:
a dictionary that contains trade signal information
"""
# converts message to list of strings for parsing
signal = signal.splitlines()
signal = [line.rstrip() for line in signal]
trade = {}
# determines the order type of the trade
if('Buy Limit'.lower() in signal[0].lower()):
trade['OrderType'] = 'Buy Limit'
elif('Sell Limit'.lower() in signal[0].lower()):
trade['OrderType'] = 'Sell Limit'
elif('Buy Stop'.lower() in signal[0].lower()):
trade['OrderType'] = 'Buy Stop'
elif('Sell Stop'.lower() in signal[0].lower()):
trade['OrderType'] = 'Sell Stop'
elif('Buy'.lower() in signal[0].lower()):
trade['OrderType'] = 'Buy'
elif('Sell'.lower() in signal[0].lower()):
trade['OrderType'] = 'Sell'
# returns an empty dictionary if an invalid order type was given
else:
return {}
# extracts symbol from trade signal
trade['Symbol'] = (signal[0].split())[-1].upper()
# checks if the symbol is valid, if not, returns an empty dictionary
if(trade['Symbol'] not in SYMBOLS):
return {}
# checks wheter or not to convert entry to float because of market exectution option ("NOW")
if(trade['OrderType'] == 'Buy' or trade['OrderType'] == 'Sell'):
trade['Entry'] = (signal[1].split())[-1]
else:
trade['Entry'] = float((signal[1].split())[-1])
trade['StopLoss'] = float((signal[2].split())[-1])
trade['TP'] = [float((signal[3].split())[-1])]
# checks if there's a fourth line and parses it for TP2
if(len(signal) > 4):
trade['TP'].append(float(signal[4].split()[-1]))
# adds risk factor to trade
trade['RiskFactor'] = RISK_FACTOR
return trade
def GetTradeInformation(update: Update, trade: dict, balance: float) -> None:
"""Calculates information from given trade including stop loss and take profit in pips, posiition size, and potential loss/profit.
Arguments:
update: update from Telegram
trade: dictionary that stores trade information
balance: current balance of the MetaTrader account
"""
# calculates the stop loss in pips
if(trade['Symbol'] == 'XAUUSD'):
multiplier = 0.1
elif(trade['Symbol'] == 'XAGUSD'):
multiplier = 0.001
elif(str(trade['Entry']).index('.') >= 2):
multiplier = 0.01
else:
multiplier = 0.0001
# calculates the stop loss in pips
stopLossPips = abs(round((trade['StopLoss'] - trade['Entry']) / multiplier))
# calculates the position size using stop loss and RISK FACTOR
trade['PositionSize'] = math.floor(((balance * trade['RiskFactor']) / stopLossPips) / 10 * 100) / 100
# calculates the take profit(s) in pips
takeProfitPips = []
for takeProfit in trade['TP']:
takeProfitPips.append(abs(round((takeProfit - trade['Entry']) / multiplier)))
# creates table with trade information
table = CreateTable(trade, balance, stopLossPips, takeProfitPips)
# sends user trade information and calcualted risk
update.effective_message.reply_text(f'<pre>{table}</pre>', parse_mode=ParseMode.HTML)
return
def CreateTable(trade: dict, balance: float, stopLossPips: int, takeProfitPips: int) -> PrettyTable:
"""Creates PrettyTable object to display trade information to user.
Arguments:
trade: dictionary that stores trade information
balance: current balance of the MetaTrader account
stopLossPips: the difference in pips from stop loss price to entry price
Returns:
a Pretty Table object that contains trade information
"""
# creates prettytable object
table = PrettyTable()
table.title = "Trade Information"
table.field_names = ["Key", "Value"]
table.align["Key"] = "l"
table.align["Value"] = "l"
table.add_row([trade["OrderType"] , trade["Symbol"]])
table.add_row(['Entry\n', trade['Entry']])
table.add_row(['Stop Loss', '{} pips'.format(stopLossPips)])
for count, takeProfit in enumerate(takeProfitPips):
table.add_row([f'TP {count + 1}', f'{takeProfit} pips'])
table.add_row(['\nRisk Factor', '\n{:,.0f} %'.format(trade['RiskFactor'] * 100)])
table.add_row(['Position Size', trade['PositionSize']])
table.add_row(['\nCurrent Balance', '\n$ {:,.2f}'.format(balance)])
table.add_row(['Potential Loss', '$ {:,.2f}'.format(round((trade['PositionSize'] * 10) * stopLossPips, 2))])
# total potential profit from trade
totalProfit = 0
for count, takeProfit in enumerate(takeProfitPips):
profit = round((trade['PositionSize'] * 10 * (1 / len(takeProfitPips))) * takeProfit, 2)
table.add_row([f'TP {count + 1} Profit', '$ {:,.2f}'.format(profit)])
# sums potential profit from each take profit target
totalProfit += profit
table.add_row(['\nTotal Profit', '\n$ {:,.2f}'.format(totalProfit)])
return table
async def ConnectMetaTrader(update: Update, trade: dict, enterTrade: bool):
"""Attempts connection to MetaAPI and MetaTrader to place trade.
Arguments:
update: update from Telegram
trade: dictionary that stores trade information
Returns:
A coroutine that confirms that the connection to MetaAPI/MetaTrader and trade placement were successful
"""
# creates connection to MetaAPI
api = MetaApi(API_KEY)
try:
account = await api.metatrader_account_api.get_account(ACCOUNT_ID)
initial_state = account.state
deployed_states = ['DEPLOYING', 'DEPLOYED']
if initial_state not in deployed_states:
# wait until account is deployed and connected to broker
logger.info('Deploying account')
await account.deploy()
logger.info('Waiting for API server to connect to broker ...')
await account.wait_connected()
# connect to MetaApi API
connection = account.get_rpc_connection()
await connection.connect()
# wait until terminal state synchronized to the local state
logger.info('Waiting for SDK to synchronize to terminal state ...')
await connection.wait_synchronized()
# obtains account information from MetaTrader server
account_information = await connection.get_account_information()
update.effective_message.reply_text("Successfully connected to MetaTrader!\nCalculating trade risk ... 🤔")
# checks if the order is a market execution to get the current price of symbol
if(trade['Entry'] == 'NOW'):
price = await connection.get_symbol_price(symbol=trade['Symbol'])
# uses bid price if the order type is a buy
if(trade['OrderType'] == 'Buy'):
trade['Entry'] = float(price['bid'])
# uses ask price if the order type is a sell
if(trade['OrderType'] == 'Sell'):
trade['Entry'] = float(price['ask'])
# produces a table with trade information
GetTradeInformation(update, trade, account_information['balance'])
# checks if the user has indicated to enter trade
if(enterTrade == True):
# enters trade on to MetaTrader account
update.effective_message.reply_text("Entering trade on MetaTrader Account ... 👨🏾💻")
try:
# executes buy market execution order
if(trade['OrderType'] == 'Buy'):
for takeProfit in trade['TP']:
result = await connection.create_market_buy_order(trade['Symbol'], trade['PositionSize'] / len(trade['TP']), trade['StopLoss'], takeProfit)
# executes buy limit order
elif(trade['OrderType'] == 'Buy Limit'):
for takeProfit in trade['TP']:
result = await connection.create_limit_buy_order(trade['Symbol'], trade['PositionSize'] / len(trade['TP']), trade['Entry'], trade['StopLoss'], takeProfit)
# executes buy stop order
elif(trade['OrderType'] == 'Buy Stop'):
for takeProfit in trade['TP']:
result = await connection.create_stop_buy_order(trade['Symbol'], trade['PositionSize'] / len(trade['TP']), trade['Entry'], trade['StopLoss'], takeProfit)
# executes sell market execution order
elif(trade['OrderType'] == 'Sell'):
for takeProfit in trade['TP']:
result = await connection.create_market_sell_order(trade['Symbol'], trade['PositionSize'] / len(trade['TP']), trade['StopLoss'], takeProfit)
# executes sell limit order
elif(trade['OrderType'] == 'Sell Limit'):
for takeProfit in trade['TP']:
result = await connection.create_limit_sell_order(trade['Symbol'], trade['PositionSize'] / len(trade['TP']), trade['Entry'], trade['StopLoss'], takeProfit)
# executes sell stop order
elif(trade['OrderType'] == 'Sell Stop'):
for takeProfit in trade['TP']:
result = await connection.create_stop_sell_order(trade['Symbol'], trade['PositionSize'] / len(trade['TP']), trade['Entry'], trade['StopLoss'], takeProfit)
# sends success message to user
update.effective_message.reply_text("Trade entered successfully! 💰")
# prints success message to console
logger.info('\nTrade entered successfully!')
logger.info('Result Code: {}\n'.format(result['stringCode']))
except Exception as error:
logger.info(f"\nTrade failed with error: {error}\n")
update.effective_message.reply_text(f"There was an issue 😕\n\nError Message:\n{error}")
except Exception as error:
logger.error(f'Error: {error}')
update.effective_message.reply_text(f"There was an issue with the connection 😕\n\nError Message:\n{error}")
return
# Handler Functions
def PlaceTrade(update: Update, context: CallbackContext) -> int:
"""Parses trade and places on MetaTrader account.
Arguments:
update: update from Telegram
context: CallbackContext object that stores commonly used objects in handler callbacks
"""
# checks if the trade has already been parsed or not
if(context.user_data['trade'] == None):
try:
# parses signal from Telegram message
trade = ParseSignal(update.effective_message.text)
# checks if there was an issue with parsing the trade
if(not(trade)):
raise Exception('Invalid Trade')
# sets the user context trade equal to the parsed trade
context.user_data['trade'] = trade
update.effective_message.reply_text("Trade Successfully Parsed! 🥳\nConnecting to MetaTrader ... \n(May take a while) ⏰")
except Exception as error:
logger.error(f'Error: {error}')
errorMessage = f"There was an error parsing this trade 😕\n\nError: {error}\n\nPlease re-enter trade with this format:\n\nBUY/SELL SYMBOL\nEntry \nSL \nTP \n\nOr use the /cancel to command to cancel this action."
update.effective_message.reply_text(errorMessage)
# returns to TRADE state to reattempt trade parsing
return TRADE
# attempts connection to MetaTrader and places trade
asyncio.run(ConnectMetaTrader(update, context.user_data['trade'], True))
# removes trade from user context data
context.user_data['trade'] = None
return ConversationHandler.END
def CalculateTrade(update: Update, context: CallbackContext) -> int:
"""Parses trade and places on MetaTrader account.
Arguments:
update: update from Telegram
context: CallbackContext object that stores commonly used objects in handler callbacks
"""
# checks if the trade has already been parsed or not
if(context.user_data['trade'] == None):
try:
# parses signal from Telegram message
trade = ParseSignal(update.effective_message.text)
# checks if there was an issue with parsing the trade
if(not(trade)):
raise Exception('Invalid Trade')
# sets the user context trade equal to the parsed trade
context.user_data['trade'] = trade
update.effective_message.reply_text("Trade Successfully Parsed! 🥳\nConnecting to MetaTrader ... (May take a while) ⏰")
except Exception as error:
logger.error(f'Error: {error}')
errorMessage = f"There was an error parsing this trade 😕\n\nError: {error}\n\nPlease re-enter trade with this format:\n\nBUY/SELL SYMBOL\nEntry \nSL \nTP \n\nOr use the /cancel to command to cancel this action."
update.effective_message.reply_text(errorMessage)
# returns to CALCULATE to reattempt trade parsing
return CALCULATE
# attempts connection to MetaTrader and calculates trade information
asyncio.run(ConnectMetaTrader(update, context.user_data['trade'], False))
# asks if user if they would like to enter or decline trade
update.effective_message.reply_text("Would you like to enter this trade?\nTo enter, select: /yes\nTo decline, select: /no")
return DECISION
def unknown_command(update: Update, context: CallbackContext) -> None:
"""Checks if the user is authorized to use this bot or shares to use /help command for instructions.
Arguments:
update: update from Telegram
context: CallbackContext object that stores commonly used objects in handler callbacks
"""
if(not(update.effective_message.chat.username == TELEGRAM_USER)):
update.effective_message.reply_text("You are not authorized to use this bot! 🙅🏽♂️")
return
update.effective_message.reply_text("Unknown command. Use /trade to place a trade or /calculate to find information for a trade. You can also use the /help command to view instructions for this bot.")
return
# Command Handlers
def welcome(update: Update, context: CallbackContext) -> None:
"""Sends welcome message to user.
Arguments:
update: update from Telegram
context: CallbackContext object that stores commonly used objects in handler callbacks
"""
welcome_message = "Welcome to the FX Signal Copier Telegram Bot! 💻💸\n\nYou can use this bot to enter trades directly from Telegram and get a detailed look at your risk to reward ratio with profit, loss, and calculated lot size. You are able to change specific settings such as allowed symbols, risk factor, and more from your personalized Python script and environment variables.\n\nUse the /help command to view instructions and example trades."
# sends messages to user
update.effective_message.reply_text(welcome_message)
return
def help(update: Update, context: CallbackContext) -> None:
"""Sends a help message when the command /help is issued
Arguments:
update: update from Telegram
context: CallbackContext object that stores commonly used objects in handler callbacks
"""
help_message = "This bot is used to automatically enter trades onto your MetaTrader account directly from Telegram. To begin, ensure that you are authorized to use this bot by adjusting your Python script or environment variables.\n\nThis bot supports all trade order types (Market Execution, Limit, and Stop)\n\nAfter an extended period away from the bot, please be sure to re-enter the start command to restart the connection to your MetaTrader account."
commands = "List of commands:\n/start : displays welcome message\n/help : displays list of commands and example trades\n/trade : takes in user inputted trade for parsing and placement\n/calculate : calculates trade information for a user inputted trade"
trade_example = "Example Trades 💴:\n\n"
market_execution_example = "Market Execution:\nBUY GBPUSD\nEntry NOW\nSL 1.14336\nTP 1.28930\nTP 1.29845\n\n"
limit_example = "Limit Execution:\nBUY LIMIT GBPUSD\nEntry 1.14480\nSL 1.14336\nTP 1.28930\n\n"
note = "You are able to enter up to two take profits. If two are entered, both trades will use half of the position size, and one will use TP1 while the other uses TP2.\n\nNote: Use 'NOW' as the entry to enter a market execution trade."
# sends messages to user
update.effective_message.reply_text(help_message)
update.effective_message.reply_text(commands)
update.effective_message.reply_text(trade_example + market_execution_example + limit_example + note)
return
def cancel(update: Update, context: CallbackContext) -> int:
"""Cancels and ends the conversation.
Arguments:
update: update from Telegram
context: CallbackContext object that stores commonly used objects in handler callbacks
"""
update.effective_message.reply_text("Command has been canceled.")
# removes trade from user context data
context.user_data['trade'] = None
return ConversationHandler.END
def error(update: Update, context: CallbackContext) -> None:
"""Logs Errors caused by updates.
Arguments:
update: update from Telegram
context: CallbackContext object that stores commonly used objects in handler callbacks
"""
logger.warning('Update "%s" caused error "%s"', update, context.error)
return
def Trade_Command(update: Update, context: CallbackContext) -> int:
"""Asks user to enter the trade they would like to place.
Arguments:
update: update from Telegram
context: CallbackContext object that stores commonly used objects in handler callbacks
"""
if(not(update.effective_message.chat.username == TELEGRAM_USER)):
update.effective_message.reply_text("You are not authorized to use this bot! 🙅🏽♂️")
return ConversationHandler.END
# initializes the user's trade as empty prior to input and parsing
context.user_data['trade'] = None
# asks user to enter the trade
update.effective_message.reply_text("Please enter the trade that you would like to place.")
return TRADE
def Calculation_Command(update: Update, context: CallbackContext) -> int:
"""Asks user to enter the trade they would like to calculate trade information for.
Arguments:
update: update from Telegram
context: CallbackContext object that stores commonly used objects in handler callbacks
"""
if(not(update.effective_message.chat.username == TELEGRAM_USER)):
update.effective_message.reply_text("You are not authorized to use this bot! 🙅🏽♂️")
return ConversationHandler.END
# initializes the user's trade as empty prior to input and parsing
context.user_data['trade'] = None
# asks user to enter the trade
update.effective_message.reply_text("Please enter the trade that you would like to calculate.")
return CALCULATE
def main() -> None:
"""Runs the Telegram bot."""
updater = Updater(TOKEN, use_context=True)
# get the dispatcher to register handlers
dp = updater.dispatcher
# message handler
dp.add_handler(CommandHandler("start", welcome))
# help command handler
dp.add_handler(CommandHandler("help", help))
conv_handler = ConversationHandler(
entry_points=[CommandHandler("trade", Trade_Command), CommandHandler("calculate", Calculation_Command)],
states={
TRADE: [MessageHandler(Filters.text & ~Filters.command, PlaceTrade)],
CALCULATE: [MessageHandler(Filters.text & ~Filters.command, CalculateTrade)],
DECISION: [CommandHandler("yes", PlaceTrade), CommandHandler("no", cancel)]
},
fallbacks=[CommandHandler("cancel", cancel)],
)
# conversation handler for entering trade or calculating trade information
dp.add_handler(conv_handler)
# message handler for all messages that are not included in conversation handler
dp.add_handler(MessageHandler(Filters.text, unknown_command))
# log all errors
dp.add_error_handler(error)
# listens for incoming updates from Telegram
updater.start_webhook(listen="0.0.0.0", port=PORT, url_path=TOKEN, webhook_url=APP_URL + TOKEN)
updater.idle()
return
if __name__ == '__main__':
main()