diff --git a/.png b/.png new file mode 100644 index 0000000..603f14f Binary files /dev/null and b/.png differ diff --git a/README.md b/README.md index 08320cc..a4cea5f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ pip install aiomql - Helper classes for Bot Building. Easy to use and extend. - Compatible with pandas-ta. - Sample Pre-Built strategies +- Visualization of charts using matplotlib and mplfinance - Manage Trading periods using Sessions - Risk Management - Run multiple bots concurrently with different accounts from the same broker or different brokers diff --git a/docs/TOC.md b/docs/TOC.md index 8f50f4d..2d7503b 100644 --- a/docs/TOC.md +++ b/docs/TOC.md @@ -3,6 +3,7 @@ - [Config](https://github.com/Ichinga-Samuel/aiomql/blob/master/docs/core/config.md) - [Base](https://github.com/Ichinga-Samuel/aiomql/blob/master/docs/core/base.md) - [Constants](https://github.com/Ichinga-Samuel/aiomql/blob/master/docs/core/constants.md) +- [TaskQueue](https://github.com/Ichinga-Samuel/aiomql/blob/master/docs/core/task_queue.md) - [Models](https://github.com/Ichinga-Samuel/aiomql/blob/master/docs/core/models.md) - [Bot_Builder](https://github.com/Ichinga-Samuel/aiomql/blob/master/docs/bot_builder.md) - [Account](https://github.com/Ichinga-Samuel/aiomql/blob/master/docs/account.md) @@ -14,6 +15,7 @@ - [Positions](https://github.com/Ichinga-Samuel/aiomql/blob/master/docs/postions.md) - [RAM](https://github.com/Ichinga-Samuel/aiomql/blob/master/docs/ram.md) - [Records](https://github.com/Ichinga-Samuel/aiomql/blob/master/docs/records.md) +- [TradeRecords](https://github.com/Ichinga-Samuel/aiomql/blob/master/docs/trade_records.md) - [Result](https://github.com/Ichinga-Samuel/aiomql/blob/master/docs/result.md) - [Session](https://github.com/Ichinga-Samuel/aiomql/blob/master/docs/sessions.md) - [Sessions](https://github.com/Ichinga-Samuel/aiomql/blob/master/docs/sessions.md) diff --git a/docs/bot_builder.md b/docs/bot_builder.md index 75e0161..e7bdfed 100644 --- a/docs/bot_builder.md +++ b/docs/bot_builder.md @@ -146,16 +146,17 @@ Removes it from the list of symbols if it was not successfully initialized or no ```python @classmethod -def run_bots(cls, bots: dict[Callable: dict] = None, num_workers: int = None): +def run_bots(cls, funcs: dict[Callable: dict] = None, num_workers: int = None): ``` -Run multiple bots at the same time. They will run in parallel. Using multiple bots is useful when you want to run -different strategies on different accounts. The callable should be a function that runs a bot instance and defines its -own Config instance within the function scope. The dictionary should contain the callable as the key and the dictionary -of keyword arguments to pass to the callable as the value. Use the path attribute of the config instance to specify the -terminal path of each account. The num_workers parameter specifies the number of workers to use. If not specified, the -number of workers will be the number of bots. +Run multiple functions (scripts, bots) at the same time in parallel with different accounts. +Running multiple functions is useful when you want to run different strategies on different accounts. +The callable can for example be a bot instance that defines its own Config instance within the function scope. +The dictionary should contain the callable as the key and the dictionary of keyword arguments to pass to the callable as +the value. Use the path attribute of the config instance to specify the terminal path of each account. +The num_workers parameter specifies the number of workers to use. If not specified, the number of workers will be the +number of bots. #### Parameters | Name | Type | Description | |---------------|------------------------|---------------------------------------------------------------------------------| -| `bots` | `dict[Callable: dict]` | A dictionary of callables and their keyword arguments to run as bots | +| `funcs` | `dict[Callable: dict]` | A dictionary of callables and their keyword arguments to run as bots | | `num_workers` | `int` | The number of workers to use. If not specified, the number of bots will be used | diff --git a/docs/candle.md b/docs/candle.md index c36a635..ec2fb88 100644 --- a/docs/candle.md +++ b/docs/candle.md @@ -84,6 +84,23 @@ A simple check to see if the candle is bearish. |------|---------------| | bool | True or False | + +### dict +```python +def dict(self, exclude: set = None, include: set = None) -> Dict[str, Any] +``` +Return a dictionary representation of the Candle object. +#### Parameters: +| Name | Type | Description | +|-----------|------------|-----------------------------------------------------------------------------| +| `exclude` | `set[str]` | A set of attributes to exclude from the dictionary. | +| `include` | `set[str]` | A set of attributes to include in the dictionary. | + +#### Returns: +| Type | Description | +|---------------|--------------------------------------------------| +| `Dict[str, Any]` | A dictionary representation of the Candle object.| + ### Candles ```python @@ -186,3 +203,40 @@ Rename columns of the data object. | Type | Description | |-----------|---------------------------------------------------------------------------| | `Candles` | A new instance of the class with the renamed columns if inplace is False. | + + + +### visualize +```python +async def visualize(self, *, count: int = 50, type='candle', savefig: str | dict = None, addplot: dict = None, + style: str = 'charles', ylabel: str = 'Price', title: str = 'Chart', **kwargs) +``` +Visualize the candles using the mplfinance library. +#### Parameters: +| Name | Type | Description | Default | +|----------|------------------|-----------------------------------------------------------------------------------------------|---------| +| `count` | `int` | The number of candles to visualize. | 50 | +| `type` | `str` | The type of chart to plot. | 'candle'| +| `savefig`| `str` or `dict` | The path to save the figure or a dictionary of keyword arguments to pass to the savefig method.| None | +| `addplot`| `dict` | A dictionary of keyword arguments to pass to the addplot method. | None | +| `style` | `str` | The style of the chart. | 'charles'| +| `ylabel` | `str` | The label of the y-axis. | 'Price' | +| `title` | `str` | The title of the chart. | 'Chart' | +| `kwargs` | `Any` | Additional keyword arguments to pass to the plot method. | | + + +```python +def make_addplot(self, *, count: int = 50, columns: list = None, **kwargs) -> dict +``` +Make subplots for adding to the main plot. +#### Parameters: +| Name | Type | Description | Default | +|-----------|--------|-----------------------------------------------------------------------------------------------|---------| +| `count` | `int` | The number of candles to visualize. | 50 | +| `columns` | `list` | The columns to plot. | None | +| `kwargs` | `Any` | Additional keyword arguments to pass to the addplot method. | | + +#### Returns: +| Type | Description | +|------|-----------------| +| dict | A makeplot dict | diff --git a/docs/core/task_queue.md b/docs/core/task_queue.md new file mode 100644 index 0000000..91e8323 --- /dev/null +++ b/docs/core/task_queue.md @@ -0,0 +1,91 @@ +# TaskQueue and QueueItem + +## Table of Contents +- [QueueItem](#queue_item) + - [run](#run) + +- [TaskQueue](#task_queue) + - [TaskQueue.add](#task_queue.add) + - [TaskQueue.add_task](#task_queue.add_task) + - [TaskQueue.worker](#task_queue.worker) + - [TaskQueue.start](#task_queue.start) + + + +### QueueItem +```python +class QueueItem: + def __init__(self, task: Callable | Awaitable, *args, **kwargs): +``` +A task to be executed by the `TaskQueue`. The task can be a callable or an awaitable. The task is wrapped as a +`QueueItem` object, which is then added to the `TaskQueue` for execution. The arguments and keyword arguments are +passed to the task when it is executed. All parameters are created as attributes of the `QueueItem` object. + +#### Parameters: +| Name | Type | Description | +|----------|---------------------------|-------------------------------------------------------------------| +| `task` | `Callable` \| `Awaitable` | A callable or awaitable task to be executed by the `TaskQueue` | +| `args` | `Any` | Positional arguments to be passed to the task when it is executed | +| `kwargs` | `Any` | Keyword arguments to be passed to the task when it is executed | + + +### run +```python +def run(self) -> Any +``` +Run the task. If the task is a coroutine, it is awaited. If the task is a callable, it is called. + + +### TaskQueue +```python +class TaskQueue: + def __init__(self): +``` +#### Attributes: +| Name | Type | Description | +|---------------|-----------------|---------------------------------------------------------------------------------| +| `queue` | `asyncio.Queue` | An asyncio.Queue queue of `QueueItem` objects to be executed by the `TaskQueue` | + + + +### add +```python +def add(self, item: QueueItem, *args, **kwargs) -> None +``` +Add a `QueueItem` to the `TaskQueue` queue. + +#### Parameters: +| Name | Type | Description | +|--------|-------------|----------------------------------------| +| `item` | `QueueItem` | A `QueueItem` to be added to the queue | + + + +### add_task +```python +def add_task(self, task: Callable | Awaitable, *args, **kwargs) -> None +``` +Create a QueueItem from the task and add it to the `TaskQueue` queue. The task can be a callable or an awaitable. +The arguments and keyword arguments are passed to the QueueItem. + +#### Parameters: +| Name | Type | Description | +|----------|---------------------------|-------------------------------------------------------------------| +| `task` | `Callable` \| `Awaitable` | A callable or awaitable task to be executed by the `TaskQueue` | +| `args` | `Any` | Positional arguments to be passed to the task when it is executed | +| `kwargs` | `Any` | Keyword arguments to be passed to the task when it is executed | + + +### worker +```python +async def worker(self) -> None +``` +A worker that processes the `QueueItem` objects in the `TaskQueue` queue. The worker runs indefinitely, processing +`QueueItem` objects as they are added to the queue. + + +### start +```python +def start(self) -> None +``` +Start the worker that processes the `QueueItem` objects in the `TaskQueue` queue. diff --git a/docs/history.md b/docs/history.md index 358515e..d554be3 100644 --- a/docs/history.md +++ b/docs/history.md @@ -5,8 +5,12 @@ - [\_\_init\_\_](#__init__) - [init](#init) - [get_deals](#get_deals) +- [get_deals_ticket](#get_deals_ticket) +- [get_deals_position](#get_deals_position) - [deals_total](#deals_total) - [get_orders](#get_orders) +- [get_orders_position](#get_orders_position) +- [get_order_ticket](#get_order_ticket) - [orders_total](#orders_total) @@ -68,13 +72,54 @@ Get history deals and orders ### get_deals ```python -async def get_deals() -> list[TradeDeal] +async def get_deals(self, *, date_from: datetime | int = None, date_to: datetime | int = None, group: str = '', + retries=3) -> list[TradeDeal] ``` -Get deals from trading history using the parameters set in the constructor. -#### Returns -| Name | Type | Description | Default | -|---------|-------------------|-----------------------|---------| -| `deals` | `list[TradeDeal]` | A list of trade deals | [] | +#### Parameters: +| Name | Type | Description | Default | +|-------------|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------| +| `date_from` | `datetime\|float` | Date the deals are requested from. Set by the 'datetime' object or as a number of seconds elapsed since 1970.01.01. Defaults to twenty-four hours from the current time in 'utc' | None | +| `date_to` | `datetime\|float` | Date up to which the deals are requested. Set by the 'datetime' object or as a number of seconds elapsed since 1970.01.01. Defaults to the current time in "utc" | None | +| `group` | `str` | Filter for selecting history by symbols. | "" | +| `retries` | `int` | Number of retries if the request fails. | 3 | + +#### Returns: +| Name | Type | Description | Default | +|---------|--------------------|-----------------------|---------| +| `deals` | `tuple[TradeDeal]` | A list of trade deals | [] | + + +### get_deals_ticket +```python +async def get_deals_ticket(self, *, ticket: int) -> tuple[TradeDeal] +``` +Get deals by ticket number +#### Parameters: +| Name | Type | Description | Default | +|----------|------|-----------------------|---------| +| `ticket` | `int`| Ticket number to get | 0 | + +#### Returns: +| Name | Type | Description | Default | +|---------|--------------------|-----------------------|---------| +| `deals` | `tuple[TradeDeal]` | A list of trade deals | [] | + + +### get_deals_position +```python +async def get_deals_position(self, *, position: int) -> list[TradeDeal] +``` +Get deals by position +#### Parameters: +| Name | Type | Description | Default | +|------------|------|-----------------------|---------| +| `position` | `int`| Position number to get | 0 | + +#### Returns: +| Name | Type | Description | Default | +|---------|--------------------|-----------------------|---------| +| `deals` | `tuple[TradeDeal]` | A list of trade deals | [] | + ### deals_total @@ -90,13 +135,54 @@ Get total number of deals within the specified period in the constructor. ### get_orders ```python -async def get_orders() -> list[TradeOrder] +async def get_orders(self, *, date_from: datetime | int = None, date_to: datetime | int = None, group: str = '', + retries=3) -> tuple[TradeOrder] ``` Get orders from trading history using the parameters set in the constructor. + +#### Parameters +| Name | Type | Description | Default | +|-------------|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------| +| `date_from` | `datetime\|float` | Date the deals are requested from. Set by the 'datetime' object or as a number of seconds elapsed since 1970.01.01. Defaults to twenty-four hours from the current time in 'utc' | None | +| `date_to` | `datetime\|float` | Date up to which the deals are requested. Set by the 'datetime' object or as a number of seconds elapsed since 1970.01.01. Defaults to the current time in "utc" | None | +| `group` | `str` | Filter for selecting history by symbols. | "" | + #### Returns -| Name | Type | Description | Default | -|----------|--------------------|------------------------|---------| -| `orders` | `list[TradeOrder]` | A list of trade orders | [] | +| Name | Type | Description | Default | +|----------|---------------------|------------------------|---------| +| `orders` | `tuple[TradeOrder]` | A list of trade orders | [] | + + +### get_orders_position +```python +async def get_orders_position(self, *, position: int) -> tuple[TradeOrder] +``` +Get orders by position. +#### Parameters +| Name | Type | Description | Default | +|------------|------|-----------------------|---------| +| `position` | `int`| Position number to get | 0 | + +#### Returns +| Name | Type | Description | Default | +|----------|---------------------|------------------------|---------| +| `orders` | `tuple[TradeOrder]` | A list of trade orders | [] | + + +### get_order_ticket +```python +async def get_order_ticket(self, ticket: int | None = None) -> TradeOrder +``` +Get a single order by ticket number. +#### Parameters +| Name | Type | Description | Default | +|----------|-------|----------------------|---------| +| `ticket` | `int` | Ticket number to get | 0 | + +#### Returns +| Name | Type | Description | Default | +|----------|---------------------|------------------------|---------| +| `order` | `TradeOrder` | A single trade order | None | ### orders_total diff --git a/docs/order.md b/docs/order.md index 4fde41f..da8e4fe 100644 --- a/docs/order.md +++ b/docs/order.md @@ -4,6 +4,7 @@ - [Order](#Order) - [\_\_init\_\_](#__init__) - [orders_total](#orders_total) +- [get_order](#get_order) - [get_orders](#get_orders) - [check](#check) - [send](#send) @@ -42,6 +43,13 @@ Get the total number of active orders. |-------|-------------------------------| | `int` | total number of active orders | + +### get_order +```python +async def get_order(self, ticket: int) -> TradeOrder +``` +Get an active trade order by ticket. + ### get_orders ```python diff --git a/docs/positions.md b/docs/positions.md index aab16b3..b98b8b9 100644 --- a/docs/positions.md +++ b/docs/positions.md @@ -5,9 +5,11 @@ - [Attributes](#attributes) - [\_\_init\_\_](#__init__) - [positions_total](#positions_total) +- [position_get](#position_get) - [positions_get](#positions_get) - [close](#close) - [close_by](#close_by) +- [close_position](#close_position) - [close_all](#close_all) @@ -67,6 +69,21 @@ Get open positions with the ability to filter by symbol or ticket. |-----------------------|--------------------------------| | `list[TradePosition]` | A list of open trade positions | + +### position_get +```python +async def position_get(self, *, ticket: int) -> TradePosition +``` +Get a position by ticket number. +#### Arguments +| Name | Type | Description | +|----------|-------|-----------------| +| `ticket` | `int` | Position ticket | + +#### Returns +| Type | Description | +|-----------------|----------------| +| `TradePosition` | Trade position | ### close @@ -88,12 +105,24 @@ Close a position by ticket number. ```python async def close_by(self, pos: TradePosition): ``` + Close a position by position object. #### Arguments | Name | Type | Description | |-------|-----------------|-----------------| | `pos` | `TradePosition` | Position object | + +### close_position +```python +async def close_position(self, *, position: TradePosition): +``` +Close a position by position object. +#### Arguments +| Name | Type | Description | +|------------|-----------------|-----------------| +| `position` | `TradePosition` | Position object | + ### close_all ```python diff --git a/docs/ram.md b/docs/ram.md index 5cbd98c..c32e0ca 100644 --- a/docs/ram.md +++ b/docs/ram.md @@ -5,7 +5,7 @@ - [\_\_init\_\_](#__init__) - [get\_amount](#get_amount) - [check_losing_positions](#check_losing_positions) -- [check_balance_level](#check_balance_level) +- [check_risk_level](#check_balance_level) ### RAM @@ -14,16 +14,17 @@ class RAM ``` Risk Assessment and Management. You can customize this class based on how you want to manage risk. #### Attributes -| Name | Type | Description | Default | -|------------------|---------|------------------------------------------------------|---------| -| `risk_to_reward` | `float` | Risk to reward ratio | 1 | -| `risk` | `float` | Percentage of account balance to risk per trade | | -| `points` | `float` | A fixed number of points per trade can be fixed here | | -| `pips` | `float` | A fixed number of pips per trade can be fixed here | | -| `min_amount` | `float` | Minimum amount to risk per trade | | -| `max_amount` | `float` | Maximum amount to risk per trade | | -| `balance_level` | `float` | Ratio of margin to available balance as a percentage | 10 | -| `loss_limit` | `int` | Number of open losing trades to allow at any time | 3 | +| Name | Type | Description | Default | +|------------------|---------|--------------------------------------------------------|---------| +| `risk_to_reward` | `float` | Risk to reward ratio | 1 | +| `risk` | `float` | Percentage of account balance to risk per trade | | +| `points` | `float` | A fixed number of points per trade can be fixed here | | +| `pips` | `float` | A fixed number of pips per trade can be fixed here | | +| `min_amount` | `float` | Minimum amount to risk per trade | | +| `max_amount` | `float` | Maximum amount to risk per trade | | +| `risk_level` | `float` | Ratio of free margin to current equity as a percentage | 50 | +| `loss_limit` | `int` | Number of open losing trades to allow at any time | 3 | + ### \_\_init\_\_ diff --git a/docs/records.md b/docs/records.md index 6161d20..75a10cb 100644 --- a/docs/records.md +++ b/docs/records.md @@ -17,7 +17,7 @@ class Records() This utility class read trade records from csv files, and update them based on their closing positions. To use this default implementation the csv files should at least have the following columns `['order', 'symbol', 'actual_profit', 'win', 'closed']` Once a trade have been closed, the actual profit and win status will be updated in the csv file. -#### Headers +#### Default Headers | column | type | description | |---------------|-------|-------------------------------------------------------| | order | int | Order id of the trade | @@ -36,8 +36,8 @@ Once a trade have been closed, the actual profit and win status will be updated ```python def __init__(records_dir: Path | str = '') ``` -Initialize the Records class. -#### Arguments +Initialize an instance of the class +#### Parameters | name | type | description | |--------------|------|----------------------------------------------------------------| | records_dir | Path | Absolute path to directory containing record of placed trades. | @@ -59,7 +59,7 @@ Get trade records from records_dir folder async def read_update(file: Path) ``` Read and update trade records -#### Arguments +#### Parameters | name | type | description | |------|------|-------------------| | file | Path | Trade record file | @@ -70,7 +70,7 @@ Read and update trade records async def update_rows(rows: list[dict]) -> list[dict] ``` Update the rows of entered trades in the csv file with the actual profit. -#### Arguments +#### Parameters | name | type | description | |------|------------|---------------------------------------------------------------------------| | rows | list[dict] | A list of dictionaries from the dictionary writer object of the csv file. | diff --git a/docs/result.md b/docs/result.md index 0e07d6e..fec7685 100644 --- a/docs/result.md +++ b/docs/result.md @@ -5,6 +5,7 @@ - [__init__](#__init__) - [get_data](#get_data) - [to_csv](#to_csv) +- [to_json](#to_json) ```python @@ -33,6 +34,7 @@ Prepare result data for record keeping and analysis. | `name` | `str` | The name of the result object | +### get\_data ```python def get_data(self) -> dict: ``` @@ -48,3 +50,11 @@ Get the result data as a dictionary async def to_csv() ``` Record trade results and associated parameters as a csv file + + +### to\_json +```python +async def to_json() +``` +Record trade results and associated parameters as a json file +``` diff --git a/docs/trade_records.md b/docs/trade_records.md new file mode 100644 index 0000000..31680d8 --- /dev/null +++ b/docs/trade_records.md @@ -0,0 +1,127 @@ +# Trade Records + +## Table of contents +- [Trade Records](#trade_records) +- [\_\_init\_\_](#__init__) +- [get_csv_records](#get_csv_records) +- [get_json_records](#get_json_records) +- [read_update_csv](#read_update_csv) +- [read_update_json](#read_update_json) +- [update_rows](#update_rows) +- [update_row](#update_row) +- [update_csv_records](#update_csv_records) +- [update_json_records](#update_json_records) +- [update_csv_record](#update_csv_record) +- [update_json_record](#update_json_record) + + +### Trade Records +```python +class TradeRecords() +``` +This utility class read trade records from csv and json files, and update them based on their closing positions. +To use this default implementation the csv or json file should be able to provide the following data. +`['order', 'symbol', 'actual_profit', 'win', 'closed']` +Once a trade have been closed, the actual profit and win status will be updated in the csv file. + +#### Default Headers +| column | type | description | +|---------------|-------|-------------------------------------------------------| +| order | int | Order id of the trade | +| symbol | str | the name of the Symbol | +| actual_profit | float | The actual profit of the trade, this zero by default | +| win | bool | The win status of the trade, this is False by default | +| closed | bool | The status of the trade, this is False by default | + +#### Attributes +| name | type | description | +|-------------|--------|--------------------------------------------------------------| +| records_dir | Path | Absolut path to directory containing record of placed trades | +| config | Config | Config object | + + +### \_\_init\_\_ +```python +def __init__(records_dir: Path | str = '') +``` +Initialize an instance of the class. +#### Parameters +| name | type | description | +|--------------|------|----------------------------------------------------------------| +| records_dir | Path | Absolute path to directory containing record of placed trades. | + + +### get_csv_records +```python +async def get_csv_records() +``` +Get trade records from records_dir folder. +#### Yields +| type | description | +|------|--------------------| +| Path | Trade record files | + + +### get_json_records +```python +async def get_json_records() +``` +Get trade records from records_dir folder. +#### Yields +| type | description | +|------|--------------------| +| Path | Trade record files | + + +### read_update_csv +```python +async def read_update_csv(file: Path) +``` +Read and update trade records from a csv file. +#### Parameters +| name | type | description | +|------|------|-------------------| +| file | Path | Trade record file | + + +### read_update_json +```python +async def read_update_json(file: Path) +``` +Read and update trade records from a json file. +#### Parameters +| name | type | description | +|------|------|-------------------| +| file | Path | Trade record file | + + +### update_rows +```python +async def update_rows(rows: list[dict]) -> list[dict] +``` +Update the rows of entered trades with the actual profit. +#### Parameters +| name | type | description | +|------|------------|---------------------------------------------------------------------------| +| rows | list[dict] | A list of dictionaries from the dictionary writer object of the csv file. | + +#### Returns +| type | description | +|------------|---------------------------------------------------------------| +| list[dict] | A list of dictionaries with the actual profit and win status. | + + +### update_row +```python +async def update_row(row: dict) -> dict +``` +Update the row of an entered trade with the actual profit. +#### Parameters +| name | type | description | +|------|------|-------------------------------------------| +| row | dict | A dictionary from the csv file row object | + +#### Returns +| type | description | +|------|------------------------------------| +| dict | A dictionary with the actual profit | diff --git a/docs/trader.md b/docs/trader.md index 88c714f..4b7f5d8 100644 --- a/docs/trader.md +++ b/docs/trader.md @@ -93,13 +93,16 @@ Checks the status of the order before placing the trade. ### record_trade ```python -async def record_trade(result: OrderSendResult) +async def record_trade(result: OrderSendResult, parameters: dict = None, name: str = '', exclude: set = None) ``` -Records the trade and the order details if `Config.record_trades` is true. +Records the trade and the order details if `Config.record_trades` is true. Trades are recorded as either json or csv. #### Parameters -| Name | Type | Description | Default | -|----------|-------------------|--------------------------------|---------| -| `result` | `OrderSendResult` | The result of the placed order | None | +| Name | Type | Description | Default | +|--------------|-------------------|--------------------------------------------------------------|---------| +| `result` | `OrderSendResult` | The result of the placed order | None | +| `parameters` | `dict` | parameters to saved instead of the ones in `self.parameters` | None | +| `name` | `str` | Name for the csv or json file | '' | +| `exclude` | `set` | Set of keys to exclude from the saved parameters | None | ### place\_trade diff --git a/examples/bot.py b/examples/bot.py deleted file mode 100644 index 2424b99..0000000 --- a/examples/bot.py +++ /dev/null @@ -1,40 +0,0 @@ -from datetime import time -import logging - -from aiomql import Bot, ForexSymbol, FingerTrap, Session, Sessions, RAM, SimpleTrader, TimeFrame - -logging.basicConfig(level=logging.INFO) - - -def build_bot(): - bot = Bot() - - # create sessions for the strategies - london = Session(name='London', start=8, end=time(hour=15, minute=30), on_end='close_all') - new_york = Session(name='New York', start=13, end=time(hour=20, minute=30)) - tokyo = Session(name='Tokyo', start=23, end=time(hour=6, minute=30)) - - # configure the parameters and the trader for a strategy - params = {'trend_candles_count': 500, 'fast_period': 8, 'slow_period': 34, 'entry_timeframe': TimeFrame.M5} - gbpusd = ForexSymbol(name='GBPUSD') - st1 = FingerTrap(symbol=gbpusd, params=params, - trader=SimpleTrader(symbol=gbpusd, ram=RAM(risk=0.05, risk_to_reward=2)), - sessions=Sessions(london, new_york)) - - # use the default for the other strategies - st2 = FingerTrap(symbol=ForexSymbol(name='AUDUSD'), sessions=Sessions(tokyo, new_york)) - st3 = FingerTrap(symbol=ForexSymbol(name='USDCAD'), sessions=Sessions(new_york)) - st4 = FingerTrap(symbol=ForexSymbol(name='USDJPY'), sessions=Sessions(tokyo)) - st5 = FingerTrap(symbol=ForexSymbol(name='EURGBP'), sessions=Sessions(london)) - - # sessions are not required - st6 = FingerTrap(symbol=ForexSymbol(name='EURUSD')) - - # add strategies to the bot - bot.add_strategies([st1, st2, st3, st4, st5, st6]) - - bot.execute() - - -# run the bot -build_bot() diff --git a/examples/candles.py b/examples/candles.py deleted file mode 100644 index 89c3065..0000000 --- a/examples/candles.py +++ /dev/null @@ -1,52 +0,0 @@ -import asyncio -from aiomql import Symbol, TimeFrame, Account, Candle, Candles - - -async def main(): - """Example of using the Candle and Candles classes. - The candle class is a single price bar. Holding the OHLCV data for a single price bar. - The Candles class is a container of Candle objects. It is an Iterable of Candle objects. - It can be sliced and indexed. It can also be accessed with keywords. - It is a wrapper around a pandas DataFrame. Which is what it uses to store the data. - """ - async with Account(): - sym = Symbol(name="EURUSD") - - # Get EURUSD price bars for the past 48 hours - candles: Candles = await sym.copy_rates_from_pos(timeframe=TimeFrame.H1, count=48, start_position=0) - - # get size of candles - print(len(candles)) # 48 - - # get the latest candle by accessing the last one. - last: Candle = candles[-1] # A Candle object - print(type(last)) - print(last.Index) - - # slicing returns a Candles object - half = candles[24:] - print(type(half)) - print(len(half)) - - close = candles['close'] # close price of all the candles as a pandas series - print(type(close)) - print(close) - - # compute ema using pandas ta - candles.ta.ema(length=34, append=True, fillna=0) - # rename the column to ema - candles.rename(EMA_34='ema') - - # use talib to compute crossover. This returns a series object that is not part of the candles object. - closeXema = candles.ta_lib.cross(candles.close, candles.ema) - - # add to the candles - candles['closeXema'] = closeXema - print(candles) - - # iterate over the first 5 candles - for candle in candles[:5]: - print(candle.open, candle.Index) - - -asyncio.run(main()) diff --git a/examples/order.py b/examples/order.py deleted file mode 100644 index eb3afb9..0000000 --- a/examples/order.py +++ /dev/null @@ -1,37 +0,0 @@ -import asyncio - -from aiomql import Account, OrderType, TradeAction, Order, ForexSymbol - - -async def main(): - async with Account(): - - # create a symbol - sym = ForexSymbol(name="EURUSD-T") - - # Confirm the symbol is available for this account and initialize with default values. - res = await sym.init() - - # I want to place a market buy order, risk only 2usd, and target 10 pips in this trade. - # The ForexSymbol object has a compute_volume method that can be used to compute the volume - # given a target pips and amount. - volume = await sym.compute_volume(amount=2, points=100) - - # a risk to reward ratio of 1:2 - # get the price tick of the symbol - tick = await sym.info_tick() - sl = tick.ask - (10 * sym.pip) - tp = tick.ask + (20 * sym.pip) - # create order - order = Order(symbol=sym.name, type=OrderType.BUY, volume=volume, action=TradeAction.DEAL, - price=tick.ask, sl=sl, tp=tp) - # check order. returns an OrderCheckResult object - chk = await order.check() - print(chk) - - # send order returns an OrderSendResult object - res = await order.send() - print(res) - - -asyncio.run(main()) diff --git a/examples/positions_history.py b/examples/positions_history.py deleted file mode 100644 index cfc53eb..0000000 --- a/examples/positions_history.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging - -import asyncio -from datetime import datetime -from aiomql import ForexSymbol, Account, Positions, History, SimpleTrader as Trader, OrderType, RAM - -logging.basicConfig(level=logging.INFO) - - -async def main(): - # Account details are in the aiomql.json file - async with Account(): - - # get start time - start = datetime.now() - - # create two symbols and initialize them - sym1 = ForexSymbol(name="EURUSD-T") - sym2 = ForexSymbol(name="GBPUSD-T") - await sym1.init() - await sym2.init() - - # Risk Assets Management instance - # fix the amount to be risked at 2 USD. USD is the account currency. - ram = RAM(amount=2, points=100) - - # Create two traders instance - trd = Trader(symbol=sym1, ram=ram) - trd2 = Trader(symbol=sym2, ram=ram) - - # Place Trades - await trd.place_trade(order_type=OrderType.SELL) - await trd2.place_trade(order_type=OrderType.BUY) - - # Create a Positions object - pos = Positions(group='*USD*') - - # get the number of open positions - total = await pos.positions_total() - print(f'{total} Open positions') # 2 - - # close all open positions - await pos.close_all() - end = datetime.now() - - # get the number of open positions - total = await pos.positions_total() - print(f'{total} Open positions') - - # get historical trades - start = datetime(day=start.day-1, month=start.month, year=start.year, hour=start.hour, minute=0, second=0) - his = History(date_from=start.timestamp(), date_to=end.timestamp()) - - # get the number of order - orders = await his.orders_total() - print(f'{orders} orders') - - # get the number of deals - # total_deals = await his.deals_total() - # print(f'{total_deals} Deals') - - -asyncio.run(main()) diff --git a/examples/records/FingerTrap.csv b/examples/records/FingerTrap.csv deleted file mode 100644 index b5397e5..0000000 --- a/examples/records/FingerTrap.csv +++ /dev/null @@ -1,7 +0,0 @@ -actual_profit,ask,bid,closed,date,deal,ecc,entry_ema,etf,expected_profit,fast_ema,name,order,price,slow_ema,symbol,tcc,time,ttf,volume,win -0,9213.42,9213.19,False,2024-02-11,1950149753,3360,5,M5,1.97,8,FingerTrap,5052174005,9213.42,20,Volatility 10 (1s) Index,672,22:15:53.721625,H1,0.56,False -0,9209.47,9209.24,False,2024-02-11,1950153282,3360,5,M5,1.46,8,FingerTrap,5052177651,9209.47,20,Volatility 10 (1s) Index,672,22:27:18.718854,H1,0.77,False -0,250524.34,250470.34,False,2024-02-11,1950153281,3360,5,M5,1.16,8,FingerTrap,5052177650,250524.34,20,Volatility 75 Index,672,22:27:18.424899,H1,0.001,False -0,2013.326,2013.201,False,2024-02-11,1950156825,3360,5,M5,1.45,8,FingerTrap,5052181298,2013.201,20,Volatility 25 Index,672,22:38:15.979751,H1,0.86,False -0,8620.27,8618.53,False,2024-02-11,1950156826,3360,5,M5,1.46,8,FingerTrap,5052181299,8618.53,20,Volatility 75 (1s) Index,672,22:38:16.244947,H1,0.109,False -0,2018.152,2018.027,False,2024-02-12,1950193973,3360,5,M5,1.44,8,FingerTrap,5052218853,2018.152,20,Volatility 25 Index,672,01:00:00.730605,H1,1.48,False diff --git a/examples/symbol.py b/examples/symbol.py deleted file mode 100644 index 94d10b5..0000000 --- a/examples/symbol.py +++ /dev/null @@ -1,40 +0,0 @@ -import asyncio -from datetime import datetime -from aiomql import ForexSymbol, TimeFrame, Account, Config - - -config = Config() - - -async def main(): - async with Account(): - sym = ForexSymbol(name="EURUSD-T") - res = await sym.init() - if not res: - print('Symbol not available') - return - - # get the last 1000 rates. - # data is returned as a Candles object - candles = await sym.copy_rates_from_pos(timeframe=TimeFrame.H1, count=1000, start_position=0) - print(len(candles)) # 1000 - - # get candles of the last 24 hours - today = datetime.now() - yesterday = today.replace(day=today.day - 1) - rates = await sym.copy_rates_range(timeframe=TimeFrame.H1, date_from=yesterday, date_to=today) - print(len(rates)) # 24 - - # get price ticks for the last 24 hours - # data is returned as a Ticks object - ticks = await sym.copy_ticks_range(date_from=yesterday, date_to=today) - print(len(ticks)) # ?? - - # get the current price tick - tick = await sym.info_tick() - # ask and bid price - ask, bid = tick.ask, tick.bid - print(ask, bid) - - -asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 225edb9..a8a3220 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "aiomql" -version = "3.20" +version = "3.21" readme = "README.md" requires-python = ">=3.11" classifiers = [ @@ -16,9 +16,9 @@ classifiers = [ "Operating System :: OS Independent", ] keywords = ['MetaTrader5', 'Asynchronous', 'Algorithmic Trading', 'Trading Bot'] -dependencies = ["MetaTrader5>=5.0.37", "pandas>=1.5.0", "pandas-ta>=0.3.14b0"] +dependencies = ["MetaTrader5>=5.0.37", "pandas>=1.5.0", "pandas-ta>=0.3.14b0", "matplotlib>=3.8.4", "mplfinance>=0.12.10b0"] authors = [{name = "Ichinga Samuel", email = "ichingasamuel@gmail.com"}] -description = "Asynchronous MetaTrader5 library and Bot Building Framework" +description = "Asynchronous MetaTrader5 library and Algorithmic Trading Framework" [project.urls] "Homepage" = "https://github.com/Ichinga-Samuel/aiomql" diff --git a/requirements.txt b/requirements.txt index 5156769..8fa1169 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,154 @@ -MetaTrader5~=5.0.45 -pandas~=2.1.1 -setuptools~=65.5.1 +anyio==4.3.0 +argon2-cffi==23.1.0 +argon2-cffi-bindings==21.2.0 +arrow==1.3.0 +asttokens==2.4.1 +async-lru==2.0.4 +attrs==23.2.0 +Babel==2.14.0 +beautifulsoup4==4.12.3 +black==23.9.1 +bleach==6.1.0 +build==1.0.3 +certifi==2023.7.22 +cffi==1.16.0 +charset-normalizer==3.3.0 +click==8.1.7 +colorama==0.4.6 +comm==0.2.2 +contourpy==1.2.1 +cycler==0.12.1 +databind.core==4.4.1 +databind.json==4.4.1 +debugpy==1.8.1 +decorator==5.1.1 +defusedxml==0.7.1 +Deprecated==1.2.14 +docspec==2.2.1 +docspec-python==2.2.1 +docstring-parser==0.11 +docutils==0.20.1 +executing==2.0.1 +fastjsonschema==2.19.1 +fonttools==4.51.0 +fqdn==1.5.1 +h11==0.14.0 +httpcore==1.0.5 +httpx==0.27.0 +idna==3.4 +importlib-metadata==6.8.0 +iniconfig==2.0.0 +ipykernel==6.29.4 +ipython==8.23.0 +ipywidgets==8.1.2 +isoduration==20.11.0 +jaraco.classes==3.3.0 +jedi==0.19.1 +Jinja2==3.1.2 +json5==0.9.24 +jsonpointer==2.4 +jsonschema==4.21.1 +jsonschema-specifications==2023.12.1 +jupyter==1.0.0 +jupyter-console==6.6.3 +jupyter-events==0.10.0 +jupyter-lsp==2.2.5 +jupyter_client==8.6.1 +jupyter_core==5.7.2 +jupyter_server==2.13.0 +jupyter_server_terminals==0.5.3 +jupyterlab==4.1.6 +jupyterlab_pygments==0.3.0 +jupyterlab_server==2.26.0 +jupyterlab_widgets==3.0.10 +keyring==24.2.0 +kiwisolver==1.4.5 +markdown-it-py==3.0.0 +MarkupSafe==2.1.3 +matplotlib==3.8.4 +matplotlib-inline==0.1.6 +mdurl==0.1.2 +MetaTrader5==5.0.45 +mistune==3.0.2 +more-itertools==10.1.0 +mplfinance==0.12.10b0 +mypy-extensions==1.0.0 +nbclient==0.10.0 +nbconvert==7.16.3 +nbformat==5.10.4 +nest-asyncio==1.6.0 +nh3==0.2.14 +notebook==7.1.2 +notebook_shim==0.2.4 +nr-date==2.1.0 +nr-stream==1.1.5 +nr.util==0.8.12 +numpy==1.26.0 +overrides==7.7.0 +packaging==23.2 +pandas==2.1.1 +pandas-ta==0.3.14b0 +pandocfilters==1.5.1 +parso==0.8.4 +pathspec==0.11.2 +pillow==10.3.0 +pkginfo==1.9.6 +platformdirs==3.11.0 +pluggy==1.3.0 +prometheus_client==0.20.0 +prompt-toolkit==3.0.43 +psutil==5.9.8 +pure-eval==0.2.2 +pycparser==2.22 +pydoc-markdown==4.8.2 +Pygments==2.16.1 +pyparsing==3.1.2 +pyproject_hooks==1.0.0 +pytest==7.4.4 +python-dateutil==2.8.2 +python-json-logger==2.0.7 +python-telegram-bot==21.0.1 +pytz==2023.3.post1 +pywin32==306 +pywin32-ctypes==0.2.2 +pywinpty==2.0.13 +PyYAML==6.0.1 +pyzmq==25.1.2 +qtconsole==5.5.1 +QtPy==2.4.1 +readme-renderer==42.0 +referencing==0.34.0 +requests==2.31.0 +requests-toolbelt==1.0.0 +rfc3339-validator==0.1.4 +rfc3986==2.0.0 +rfc3986-validator==0.1.1 +rich==13.6.0 +rpds-py==0.18.0 +Send2Trash==1.8.3 +six==1.16.0 +sniffio==1.3.1 +soupsieve==2.5 +stack-data==0.6.3 +terminado==0.18.1 +tinycss2==1.2.1 +tomli==2.0.1 +tomli_w==1.0.0 +tornado==6.4 +traitlets==5.14.2 +twine==4.0.2 +typeapi==2.1.1 +types-python-dateutil==2.9.0.20240316 +typing_extensions==4.6.3 +tzdata==2023.3 +uri-template==1.3.0 +urllib3==2.0.6 +watchdog==3.0.0 +wcwidth==0.2.13 +webcolors==1.13 +webencodings==0.5.1 +websocket-client==1.7.0 +widgetsnbextension==4.0.10 +wrapt==1.15.0 +yapf==0.40.2 +zipp==3.17.0 diff --git a/src/aiomql/__init__.py b/src/aiomql/__init__.py index 996569f..fb6a018 100644 --- a/src/aiomql/__init__.py +++ b/src/aiomql/__init__.py @@ -6,6 +6,7 @@ from .strategy import Strategy from .bot_builder import Bot from .result import Result from .records import Records +from .trade_records import TradeRecords from .candle import Candle, Candles from .positions import Positions from .executor import Executor diff --git a/src/aiomql/account.py b/src/aiomql/account.py index 9cfa938..fdcf22a 100644 --- a/src/aiomql/account.py +++ b/src/aiomql/account.py @@ -83,7 +83,7 @@ class Account(AccountInfo): if ini and res: return True else: - await asyncio.sleep(tries) + await asyncio.sleep(5+tries) return await self._login(acc=acc, tries=tries-1) def has_symbol(self, symbol: str | SymbolInfo): diff --git a/src/aiomql/bot_builder.py b/src/aiomql/bot_builder.py index 35707e4..5a2912f 100644 --- a/src/aiomql/bot_builder.py +++ b/src/aiomql/bot_builder.py @@ -37,11 +37,16 @@ class Bot: self.executor = Executor() @classmethod - def run_bots(cls, bots: dict[Callable: dict] = None, num_workers: int = None): - """Run multiple bots at the same time.""" - num_workers = num_workers or len(bots) * 2 + def run_bots(cls, funcs: dict[Callable: dict] = None, num_workers: int = None): + """Run multiple scripts or bots in parallel with different accounts. + + Args: + funcs (dict): A dictionary of functions to run with their respective keyword arguments as a dictionary + num_workers (int): Number of workers to run the functions + """ + num_workers = num_workers or len(funcs) * 2 with ProcessPoolExecutor(max_workers=num_workers) as executor: - for bot, kwargs in bots.items(): + for bot, kwargs in funcs.items(): executor.submit(bot, **kwargs) async def initialize(self): diff --git a/src/aiomql/candle.py b/src/aiomql/candle.py index 5c86c4c..21cc18d 100644 --- a/src/aiomql/candle.py +++ b/src/aiomql/candle.py @@ -4,7 +4,9 @@ from typing import Type, TypeVar, Generic, Iterable from logging import getLogger from pandas import DataFrame, Series +import pandas as pd import pandas_ta as ta +import mplfinance as mplt from .core.constants import TimeFrame @@ -12,7 +14,7 @@ logger = getLogger(__name__) class Candle: - """A class representing bars from the MetaTrader 5 terminal as a customized class analogous to Japanese + """A customized class representing rates from the MetaTrader 5 terminal analogous to Japanese Candlesticks. You can subclass this class for added customization. Attributes: @@ -25,7 +27,6 @@ class Candle: real_volume (float): Trade volume spread (float): Spread Index (int): Custom attribute representing the position of the candle in a sequence. - mid (float): The median of the high and low price. """ time: float open: float @@ -36,7 +37,6 @@ class Candle: spread: float tick_volume: float Index: int - mid: float def __init__(self, **kwargs): """Create a Candle object from keyword arguments. This class must always be instantiated with open, high, low @@ -49,17 +49,15 @@ class Candle: raise ValueError("Candle must be instantiated with open, high, low and close prices") self.time = kwargs.pop('time', 0) self.Index = kwargs.pop('Index', 0) - self.mid = kwargs.pop('mid', (kwargs['high'] + kwargs['low']) / 2) self.set_attributes(**kwargs) def __repr__(self): - return ("%(class)s(Index=%(Index)s, time=%(time)s, open=%(open)s, high=%(high)s, low=%(low)s, close=%(close)s," - " mid=%(mid)s)") % {"class": self.__class__.__name__, "open": self.open, "high": self.high, - "low": self.low, "close": self.close, "time": self.time, "mid": self.mid, - 'Index': self.Index} + return ("%(class)s(Index=%(Index)s, time=%(time)s, open=%(open)s, high=%(high)s, low=%(low)s, close=%(close)s)" + % {"class": self.__class__.__name__, "open": self.open, "high": self.high, + "low": self.low, "close": self.close, "time": self.time, 'Index': self.Index}) def __str__(self): - return self.dict() + return str(self.dict()) def __eq__(self, other: "Candle"): return self.time == other.time @@ -152,7 +150,6 @@ class Candles(Generic[_Candle]): tick_volume: Series real_volume: Series spread: Series - mid: Series Candle: Type[Candle] timeframe: TimeFrame _data: DataFrame @@ -177,9 +174,6 @@ class Candles(Generic[_Candle]): raise ValueError(f"Cannot create DataFrame from object of {type(data)}") self._data = data.loc[::-1].reset_index(drop=True) if flip else data - if 'mid' not in self._data.columns.values: - mid = (self._data['high'] + self._data['low']) / 2 - self._data.insert(0, 'mid', mid) self.Candle = candle_class or Candle def __repr__(self): @@ -268,3 +262,38 @@ class Candles(Generic[_Candle]): """ res = self._data.rename(columns=kwargs, inplace=inplace) return self if inplace else self.__class__(data=res) + + def make_addplot(self, *, count: int = 50, columns: list = None, **kwargs) -> dict: + """ + Make subplots for adding to the main plot + + Args: + count (int): The numbers of candles to make the addplot for. Defaults to 50. + columns (list[str]): The columns to make the plot from. Defaults to None. + **kwargs: Valid arguments for the mplfinance make_addplot function + """ + columns = columns or [] + data = self._data[-count:] + data.index = pd.to_datetime(data['time'], unit='s') + return mplt.make_addplot(data[columns], **kwargs) + + def visualize(self, *, count: int = 50, type='candle', savefig: str | dict = None, addplot: dict = None, + style: str = 'charles', ylabel: str = 'Price', title: str = 'Chart', **kwargs): + """Visualize the candles using the mplfinance library. + Args: + count (int): The number of candles to visualize, counting from behind, i.e the most recent candles. + Defaults to 50. + type: Type of chart, defaults to candle + savefig (str|dict): The path to save the figure or a dictionary of parameters to pass to the savefig method. + addplot: Additional plots to add to the chart. Defaults to None. They should match the dimension of the + original data which is specified via the count parameter. + style (str): The style of the chart. Defaults to 'charles'. + ylabel (str): The label of the y-axis. Defaults to 'Price'. + title (str): The title of the chart. Defaults to 'Chart'. + kwargs: valid kwargs for the plot function. + """ + kwargs |= {key: arg for key, arg in (('savefig', savefig), ('addplot', addplot), ('style', style), + ('ylabel', ylabel), ('title', title), ('type', type)) if arg} + data = self._data[-count:] + data.index = pd.to_datetime(data['time'], unit='s') + mplt.plot(data, **kwargs) diff --git a/src/aiomql/core/config.py b/src/aiomql/core/config.py index 2bca79a..8fa6c2c 100644 --- a/src/aiomql/core/config.py +++ b/src/aiomql/core/config.py @@ -1,12 +1,13 @@ import os from pathlib import Path -from typing import Iterator +from typing import Iterator, Literal, TypeVar import json from logging import getLogger from .task_queue import TaskQueue logger = getLogger(__name__) +Bot = TypeVar("Bot") class Config: @@ -14,6 +15,7 @@ class Config: Attributes: record_trades (bool): Whether to keep record of trades or not. + trade_record_mode: How to save trade, json or csv. Defaults to json filename (str): Name of the config file records_dir (str): Path to the directory where trade records are saved login (int): Trading account number @@ -31,20 +33,21 @@ class Config: By passing reload=True to the load_config method, you can reload and search again for the config file. """ login: int = 0 + trade_record_mode: Literal['csv', 'json'] = 'csv' password: str = "" server: str = "" path: str | Path = "" timeout: int = 60000 record_trades: bool = True filename: str = "aiomql.json" - win_percentage: float = 0.85 - records_dir: str | Path = 'records' - config_dir: str = '' _initialize = True state: dict = {} - root_dir: Path = Path('.').absolute().resolve() + root: Path + root_dir: Path + records_dir: Path + config_dir: str = '' task_queue: TaskQueue = TaskQueue() - bot: 'Bot' = None + bot: Bot = None _instance: 'Config' def __new__(cls, *args, **kwargs): @@ -54,19 +57,16 @@ class Config: def __init__(self, **kwargs): reload = kwargs.pop('reload', False) - root_dir = kwargs.pop('root_dir', None) - setattr(self, 'root_dir', root_dir) if root_dir else ... - [setattr(self, key, value) for key, value in kwargs.items()] - self.load_config(reload=reload) + self.load_config(reload=reload, **kwargs) + + def set_root(self, *, root: str | Path): + root = Path(root) if str else root + self.root = root.absolute().resolve() + self.root_dir = self.root def __setattr__(self, key, value): - if key == 'root_dir': - value = Path(value).absolute().resolve() - if key == 'records_dir': - self.create_records_dir(records_dir=value) - return if key == 'path': - value = self.root_dir / Path(value) if not Path(value).exists() else value + value = str(self.root_dir / Path(value).absolute().resolve()) super().__setattr__(key, value) @staticmethod @@ -96,40 +96,53 @@ class Config: return def create_records_dir(self, *, records_dir: str | Path = 'records'): - """Create records directory if it does not exist. Relative to the root directory of the project. + """Create records directory if it does not exist. By default, it is relative to the root directory of the + project unless an absolute path is provided. + Keyword Args: - records_dir (str|Path): The name of the directory to create + records_dir (str|Path): The directory to save trade records. Default is 'records' """ try: - records_dir = Path(records_dir) if isinstance(records_dir, str) else records_dir - records_dir = self.root_dir / records_dir + if isinstance(records_dir, str): + records_dir = self.root_dir / records_dir + elif isinstance(records_dir, Path): + records_dir = records_dir.absolute().resolve() records_dir.mkdir(parents=True, exist_ok=True) - super().__setattr__('records_dir', records_dir) - return records_dir + self.records_dir = records_dir except Exception as err: logger.warning(f"{err}: Unable to create records directory") - def load_config(self, *, file: str = None, reload: bool = True, filename: str = None, config_dir: str = ''): + def load_config(self, *, file: str = None, reload: bool = True, filename: str = None, + config_dir: str = '', **kwargs): """Load configuration settings from a file. Keyword Args: file (str): The path to the file to load. If not provided, the file is searched for reload (bool): Whether to reload the config object. Default is True filename (str): The name of the file to load. If not provided, the default filename is used config_dir (str): The name of the directory to search for the file. Default is the root directory + root_dir (str): The root directory of the project + kwargs: Additional keyword arguments """ if not (self._initialize or reload): return - self._initialize = False data = {} self.filename = filename or self.filename self.config_dir = config_dir or self.config_dir + root_dir = kwargs.pop('root_dir', None) + records_dir = kwargs.pop('records_dir', 'records') + if self._initialize or (root_dir is not None): + self.set_root(root=(root_dir or '.')) + self.create_records_dir(records_dir=records_dir) + if (file := (file or self.find_config())) is None: logger.warning("No Config File Found") else: fh = open(file, mode="r") data = json.load(fh) fh.close() + data |= kwargs [setattr(self, key, value) for key, value in data.items()] + self._initialize = False def account_info(self) -> dict[str, int | str]: """Returns Account login details as found in the config object if available diff --git a/src/aiomql/core/meta_trader.py b/src/aiomql/core/meta_trader.py index 0dc0087..b42f315 100644 --- a/src/aiomql/core/meta_trader.py +++ b/src/aiomql/core/meta_trader.py @@ -310,7 +310,7 @@ class MetaTrader(metaclass=BaseMeta): async def positions_total(self) -> int: return await asyncio.to_thread(self._positions_total) - async def positions_get(self, group: str = "", ticket: int = 0, symbol: str = "") -> tuple[TradePosition] | None: + async def positions_get(self, group: str = "", ticket: int = None, symbol: str = "") -> tuple[TradePosition] | None: kwargs = {key: value for key, value in (('group', group), ('ticket', ticket), ('symbol', symbol)) if value} res = await asyncio.to_thread(self._positions_get, **kwargs) if res is None: @@ -324,11 +324,10 @@ class MetaTrader(metaclass=BaseMeta): return await asyncio.to_thread(self._history_orders_total, date_from, date_to) async def history_orders_get(self, date_from: datetime | float = None, date_to: datetime | float = None, - group: str = '', - ticket: int = 0, position: int = 0) -> tuple[TradeOrder] | None: - kwargs = {key: value for key, value in (('date_from', date_from), ('date_to', date_to), ('group', group), - ('ticket', ticket), ('position', position)) if value} - res = await asyncio.to_thread(self._history_orders_get, **kwargs) + group: str = '', ticket: int = None, position: int = None) -> tuple[TradeOrder] | None: + kwargs = {key: value for key, value in (('group', group), ('ticket', ticket), ('position', position)) if value} + args = tuple(arg for arg in (date_from, date_to) if arg) + res = await asyncio.to_thread(self._history_orders_get, *args, **kwargs) if res is None: err = await self.last_error() self.error = Error(*err) @@ -340,10 +339,10 @@ class MetaTrader(metaclass=BaseMeta): return await asyncio.to_thread(self._history_deals_total, date_from, date_to) async def history_deals_get(self, date_from: datetime | float = None, date_to: datetime | float = None, - group: str = '', ticket: int = 0, position: int = 0) -> tuple[TradeDeal] | None: - kwargs = {key: value for key, value in (('date_from', date_from), ('date_to', date_to), ('group', group), - ('ticket', ticket), ('position', position)) if value} - res = await asyncio.to_thread(self._history_deals_get, **kwargs) + group: str = '', ticket: int = None, position: int = None) -> tuple[TradeDeal] | None: + kwargs = {key: value for key, value in (('group', group), ('ticket', ticket), ('position', position)) if value} + args = tuple(arg for arg in (date_from, date_to) if arg) + res = await asyncio.to_thread(self._history_deals_get, *args, **kwargs) if res is None: err = await self.last_error() self.error = Error(*err) diff --git a/src/aiomql/core/task_queue.py b/src/aiomql/core/task_queue.py index d687b47..06043d8 100644 --- a/src/aiomql/core/task_queue.py +++ b/src/aiomql/core/task_queue.py @@ -18,7 +18,8 @@ class QueueItem: else: return self.task(*self.args, **self.kwargs) except Exception as err: - logger.error(f'Error in running {self.task.__name__} with {str(self.args)}, {self.kwargs}: {err}') + logger.error(f"Error in running {getattr(self.task, '__name__', str(self.task))}" + f" with {str(self.args)}, {self.kwargs}: {err}") class TaskQueue: diff --git a/src/aiomql/history.py b/src/aiomql/history.py index 27846d3..a0a633a 100644 --- a/src/aiomql/history.py +++ b/src/aiomql/history.py @@ -2,8 +2,11 @@ import asyncio from datetime import datetime from logging import getLogger +from pandas import DataFrame +import pandas as pd + from .core.config import Config -from .core.meta_trader import MetaTrader +from .core.meta_trader import MetaTrader, CopyTicks, OrderType from .core.models import TradeDeal, TradeOrder logger = getLogger(__name__) @@ -27,8 +30,8 @@ class History: mt5: MetaTrader config: Config - def __init__(self, *, date_from: datetime | float = None, date_to: datetime | float = None, - group: str = "", ticket: int = 0, position: int = 0): + def __init__(self, *, date_from: datetime | int = None, date_to: datetime | int = None, + group: str = "", ticket: int = None, position: int = None): """ Args: date_from (datetime, float): Date the orders are requested from. Set by the 'datetime' object or as a @@ -71,63 +74,165 @@ class History: self.initialized = all(res) return self.initialized - async def get_deals(self, retries=3) -> list[TradeDeal]: + async def get_deals(self, *, date_from: datetime | int = None, date_to: datetime | int = None, group: str = '', + retries: int = 3) -> tuple[TradeDeal, ...]: """Get deals from trading history using the parameters set in the constructor. Returns: - list[TradeDeal]: A list of trade deals + tuple[TradeDeal]: A list of trade deals """ if retries < 1: logger.warning(f'Failed to get deals: {self.mt5.error}') - return [] - deals = await self.mt5.history_deals_get(date_from=self.date_from, date_to=self.date_to, position=self.position, - group=self.group, ticket=self.ticket) + return tuple() + + date_from, date_to, group = date_from or self.date_from, date_to or self.date_to, group or self.group + deals = await self.mt5.history_deals_get(date_from=date_from, date_to=date_to, group=group) + if deals is not None: - self.deals = [TradeDeal(**deal._asdict()) for deal in deals] + self.deals = tuple(TradeDeal(**deal._asdict()) for deal in deals) self.total_deals = len(self.deals) return self.deals + if self.mt5.error.is_connection_error(): await asyncio.sleep(retries) - return await self.get_deals(retries=retries - 1) + return await self.get_deals(date_from=date_from, date_to=date_to, group=group, retries=retries-1) logger.warning(f'Failed to get deals: {self.mt5.error}') - return [] + return tuple() - async def deals_total(self) -> int: + async def get_deals_ticket(self, *, ticket: int = None) -> tuple[TradeDeal, ...]: + """Call specifying the order ticket. Return all deals having the specified order ticket in the DEAL_ORDER + property. + + Args: + ticket (int): The order ticket + + Returns: + tuple[TradeDeal]: A tuple of all deals with the order ticket + """ + ticket = ticket or self.ticket + assert ticket is not None, 'ticket not provided' + deals = await self.mt5.history_deals_get(ticket=ticket) + return tuple(sorted([TradeDeal(**deal._asdict()) for deal in deals], key=lambda x: x.time_msc)) + + async def get_deals_position(self, *, position: int = None) -> tuple[TradeDeal, ...]: + """ + Get all deals with the specified position ticket in the DEAL_POSITION_ID property + Args: + position (int): The position ticket + + Returns: + tuple[TradeDeal]: A tuple of all deals with the position ticket + """ + position = position or self.position + assert position is not None, 'position not provided' + deals = await self.mt5.history_deals_get(position=position) + return tuple(sorted([TradeDeal(**deal._asdict()) for deal in deals], key=lambda x: x.time_msc)) + + async def deals_total(self, *, date_from: int | datetime = None, date_to: int | datetime = None) -> int: """Get total number of deals within the specified period in the constructor. - + Args: + date_from (int|datetime): Date the orders are requested from. Set by the 'datetime' object or as a number of + seconds elapsed since 1970.01.01. + date_to (int|datetime): Date up to which the orders are requested. Set by the 'datetime' object or as a + number of seconds elapsed since 1970.01.01. Returns: int: Total number of Deals """ - self.total_deals = await self.mt5.history_deals_total(self.date_from, self.date_to) - return self.total_deals + date_from, date_to = date_from or self.date_from, date_to or self.date_to + assert date_from is not None and date_to is not None, 'date_from and/or date_to not provided' + total_deals = await self.mt5.history_deals_total(date_from, date_to) + return total_deals - async def get_orders(self, retries=3) -> list[TradeOrder]: - """Get orders from trading history using the parameters set in the constructor. + async def get_orders(self, *, date_from: datetime | int = None, date_to: datetime | int = None, group: str = '', + retries: int = 3) -> tuple[TradeOrder, ...]: + """Get orders from trading history using the parameters set in the constructor or the method arguments. Returns: list[TradeOrder]: A list of trade orders """ if retries < 1: logger.warning(f'Failed to get orders: {self.mt5.error}') - return [] - orders = await self.mt5.history_orders_get(date_from=self.date_from, date_to=self.date_to, group=self.group, - position=self.position, ticket=self.ticket) + return tuple() + + date_from, date_to, group = date_from or self.date_from, date_to or self.date_to, group or self.group + orders = await self.mt5.history_orders_get(date_from=date_from, date_to=date_to, group=group) if orders is not None: - self.orders = [TradeOrder(**order._asdict()) for order in orders] - self.total_orders = len(self.orders) - return self.orders + return tuple(TradeOrder(**order._asdict()) for order in orders) + if self.mt5.error.is_connection_error(): await asyncio.sleep(retries) - return await self.get_orders(retries=retries - 1) - logger.warning(f'Failed to get orders: {self.mt5.error}') - return [] + return await self.get_orders(date_from=date_from, date_to=date_to, group=group, retries=retries - 1) - async def orders_total(self) -> int: + logger.warning(f'Failed to get orders: {self.mt5.error}') + return tuple() + + async def get_order_ticket(self, ticket: int | None = None) -> TradeOrder: + ticket = ticket or self.ticket + assert isinstance(ticket, int), 'ticket not provided' + orders = await self.mt5.history_orders_get(ticket=ticket) + order = orders[0] + assert order.ticket == ticket + return TradeOrder(**order._asdict()) + + async def get_orders_position(self, position: int = None) -> tuple[TradeOrder, ...]: + """ + Call specifying the position ticket. Return all orders with a position ticket specified in the + ORDER_POSITION_ID property + + Args: + position: The position ticket + + Returns: + tuple[TradeOrder]: A tuple of all orders with the position ticket + """ + position = position or self.position + assert isinstance(position, int), 'position not provided' + orders = await self.mt5.history_orders_get(position=position) + return tuple(sorted([TradeOrder(**order._asdict()) for order in orders], key=lambda x: x.time_done_msc)) + + async def orders_total(self, date_from: int | datetime = None, date_to: int | datetime = None) -> int: """Get total number of orders within the specified period in the constructor. Returns: int: Total number of orders """ - self.total_orders = await self.mt5.history_orders_total(self.date_from, self.date_to) - return self.total_orders + date_from, date_to = date_from or self.date_from, date_to or self.date_to + assert date_from is not None and date_to is not None, 'date_from and/or date_to not provided' + total_orders = await self.mt5.history_orders_total(date_from, date_to) + return total_orders + + async def track_order(self, *, position: int = None, end_time: datetime = None) -> DataFrame: + """ + Track an order from the time it was opened to the time it was closed or any given time. + The tracking is done by getting the ticks + for the order symbol from the time the order was opened to the time it was closed. The profit for each tick is + calculated using the order type, symbol, initial volume, open price and the bid or ask price of the tick + depending on the order type. + Args: + end_time (datetime): The time to stop tracking the order. If not provided, the tracking will continue until + the order is closed. + position (int): The position ticket + end_time (int): The time to stop tracking the order in seconds. If not provided, the tracking will continue + until the order is closed. + Returns: + DataFrame: A pandas DataFrame of the ticks and profit for the order. + """ + orders = await self.get_orders_position(position=position) + deals = await self.get_deals_position(position=position) + open_order = orders[0] + open_deal = deals[0] + close_deal = deals[-1] + time_done = datetime.timestamp(end_time) if end_time is not None else close_deal.time + time_done_msc = int(time_done * 1000) + open_order.set_attributes(time_done_msc=time_done_msc, time_done=time_done, price_open=open_deal.price) + ticks = await self.mt5.copy_ticks_range(open_order.symbol, open_order.time_setup, open_order.time_done, + CopyTicks.ALL) + data = pd.DataFrame(ticks) + profit = lambda x: self.mt5._order_calc_profit(open_order.type, open_order.symbol, open_order.volume_initial, + open_order.price_open, + x.ask if open_order.type == OrderType.BUY else x.bid) + data['profits'] = data.apply(profit, axis=1) + data['time'] = pd.to_datetime(data['time'], unit='s') + data.set_index('time', inplace=True) + return data diff --git a/src/aiomql/lib/strategies/finger_trap.py b/src/aiomql/lib/strategies/finger_trap.py index f76d8a8..0445628 100644 --- a/src/aiomql/lib/strategies/finger_trap.py +++ b/src/aiomql/lib/strategies/finger_trap.py @@ -69,15 +69,13 @@ class FingerTrap(Strategy): self.tracker.update(new=True, entry_time=current) candles.ta.ema(length=self.entry_ema, append=True) candles.rename(**{f"EMA_{self.entry_ema}": "ema"}) - cae = candles.ta_lib.cross(candles.close, candles.ema) - cbe = candles.ta_lib.cross(candles.close, candles.ema, above=False) - trend = self.ttf.time // self.etf.time - bull_trend = cae.iloc[-trend:] - bear_trend = cbe.iloc[-trend:] - if self.tracker.bullish and any(bull_trend): + candles['cae'] = candles.ta_lib.cross(candles.close, candles.ema) + candles['cbe'] = candles.ta_lib.cross(candles.close, candles.ema, above=False) + current = candles[-1] + if self.tracker.bullish and current.cae: sl = find_bullish_fractal(candles).low self.tracker.update(snooze=self.ttf.time, order_type=OrderType.BUY, sl=sl) - elif self.tracker.bearish and any(bear_trend): + elif self.tracker.bearish and current.cbe: sl = find_bearish_fractal(candles).high self.tracker.update(snooze=self.ttf.time, order_type=OrderType.SELL, sl=sl) else: diff --git a/src/aiomql/order.py b/src/aiomql/order.py index e383bd4..2e3580a 100644 --- a/src/aiomql/order.py +++ b/src/aiomql/order.py @@ -38,6 +38,26 @@ class Order(TradeRequest): """ return await self.mt5.orders_total() + async def get_order(self, *, ticket: int, retries: int = 3) -> TradeOrder: + """ + Get the order by ticket number. + Args: + ticket (int): Order ticket number + retries (int): Number of retries + Returns: + """ + if retries < 1: + raise OrderError(f'Failed to get orders for {self.symbol}: {self.mt5.error}') + orders = await self.mt5.orders_get(ticket=ticket) + if orders is not None: + order = TradeOrder(**orders[0]._asdict()) + assert order.ticket == ticket, f'Order ticket mismatch {order.ticket} != {ticket}' + return order + if self.mt5.error.is_connection_error(): + await asyncio.sleep(retries) + return await self.get_order(ticket=ticket, retries=retries-1) + raise OrderError(f'Failed to get orders for {self.symbol}: {self.mt5.error}') + async def get_orders(self, *, ticket: int = 0, symbol: str = '', group: str = '', retries=3)\ -> tuple[TradeOrder, ...]: """Get the list of active orders for the current symbol. diff --git a/src/aiomql/positions.py b/src/aiomql/positions.py index 23205a7..5317a89 100644 --- a/src/aiomql/positions.py +++ b/src/aiomql/positions.py @@ -68,9 +68,30 @@ class Positions: logger.warning(f'Failed to get positions for {symbol or self.symbol}. {self.mt5.error}') return [] - async def close(self, *, ticket: int, symbol: str, price: float, volume: float, order_type: OrderType): - """Close an open position for the trading account.""" + async def position_get(self, *, ticket: int) -> TradePosition: + """Get an open position by ticket. + Args: + ticket (int): Position ticket. + Returns: + TradePosition: Return an open position + """ + positions = await self.positions_get(ticket=ticket) + position = positions[0] if positions else None + if position is None: + raise ValueError(f'Position with ticket {ticket} not found') + assert position.ticket == ticket, f'Position with ticket {ticket} not found' + return position + + async def close(self, *, ticket: int, symbol: str, price: float, volume: float, order_type: OrderType): + """Close an open position for the trading account using the ticket and other parameters. + Args: + ticket (int): Position ticket. + symbol (str): Financial instrument name. + price (float): Closing price. + volume (float): Volume to close. + order_type (OrderType): Order type. + """ order = Order(action=TradeAction.DEAL, price=price, position=ticket, symbol=symbol, volume=volume, type=order_type.opposite) return await order.send() @@ -81,6 +102,12 @@ class Positions: price=pos.price_current) return await order.send() + async def close_position(self, *, position: TradePosition): + """Close an open position for the trading account. Using a position object.""" + order = Order(position=position.ticket, symbol=position.symbol, volume=position.volume, + type=position.type.opposite, price=position.price_current) + return await order.send() + async def close_all(self, symbol: str = '', group: str = '') -> int: """Close all open positions for the trading account. Specify a symbol or group to filter positions. @@ -94,6 +121,6 @@ class Positions: symbol = symbol or self.symbol group = group or self.group positions = [pos for pos in await self.positions_get(symbol=symbol, group=group)] - orders = [self.close_by(pos) for pos in positions] + orders = [self.close_position(position=pos) for pos in positions] results = await asyncio.gather(*[order for order in orders], return_exceptions=True) return len([res for res in results if (res and res.retcode) == 10009]) diff --git a/src/aiomql/ram.py b/src/aiomql/ram.py index f75b99e..354a496 100644 --- a/src/aiomql/ram.py +++ b/src/aiomql/ram.py @@ -9,10 +9,11 @@ class RAM: risk: float points: float pips: float - min_amount: float - max_amount: float - balance_level: float = 10 + min_amount: float = 0 + max_amount: float = 0 + risk_level: float = 50 loss_limit: int = 3 + open_limit: int = 6 def __init__(self, *, risk_to_reward: float = 1, risk: float = 0.01, **kwargs): """Initialize Risk Assessment and Management with the provided keyword arguments. @@ -28,23 +29,38 @@ class RAM: [setattr(self, key, value) for key, value in kwargs.items()] async def get_amount(self) -> float: - """Calculate the amount to risk per trade as a percentage of balance. + """Calculate the amount to risk per trade as a percentage of equity. Returns: float: Amount to risk per trade """ await self.account.refresh() - return self.account.balance * self.risk + amount = self.account.margin_free * self.risk + if self.min_amount and self.max_amount: + return max(self.min_amount, min(self.max_amount, amount)) + return amount - async def check_losing_positions(self) -> bool: - """Check if the number of losing positions is greater than or equal the loss limit.""" - positions = await Positions().positions_get() - positions.sort(key=lambda pos: pos.time_msc) + async def check_losing_positions(self, *, symbol: str = '') -> bool: + """Check if the number of losing positions is greater than or equal the loss limit. + + Args: + symbol (str): Symbol to check. Defaults to ''. + """ + positions = await Positions().positions_get(symbol=symbol) loosing = [trade for trade in positions if trade.profit <= 0] return len(loosing) >= self.loss_limit - async def check_balance_level(self) -> bool: - """Check if the balance level is greater than or equal to the balance level.""" + async def check_open_positions(self, *, symbol: str = '') -> bool: + """Check if the number of open positions is greater than or equal the loss limit. + + Args: + symbol (str): Symbol to check. Defaults to ''. + """ + positions = await Positions().positions_get(symbol=symbol) + return len(positions) >= self.open_limit + + async def check_risk_level(self) -> bool: + """Check the risk level.""" await self.account.refresh() - balance_level = (self.account.margin / self.account.balance) * 100 - return balance_level >= self.balance_level + risk_level = (1 - (self.account.margin_free / self.account.equity)) * 100 + return risk_level >= self.risk_level diff --git a/src/aiomql/records.py b/src/aiomql/records.py index 84cec0d..c9ec5a1 100644 --- a/src/aiomql/records.py +++ b/src/aiomql/records.py @@ -4,6 +4,7 @@ import asyncio from pathlib import Path import csv import logging +from typing import Iterable from .core import Config, MetaTrader @@ -50,7 +51,7 @@ class Records: """ try: fr = open(file, mode='r', newline='') - reader = csv.DictReader(fr) + reader: Iterable[dict] | csv.DictReader = csv.DictReader(fr) rows = [row for row in reader] rows = await self.update_rows(rows) fr.close() diff --git a/src/aiomql/result.py b/src/aiomql/result.py index 202ca10..148ab5b 100644 --- a/src/aiomql/result.py +++ b/src/aiomql/result.py @@ -1,7 +1,7 @@ import csv +import json from logging import getLogger -from threading import RLock -from pathlib import Path +from typing import Iterable, Literal from .core import Config from .core.models import OrderSendResult @@ -31,26 +31,64 @@ class Result: self.parameters = parameters or {} self.result = result self.name = name or parameters.get('name', 'Trades') - if not Path(self.config.records_dir).exists(): - Path(self.config.records_dir).mkdir(parents=True, exist_ok=True) def get_data(self) -> dict: res = self.result.get_dict(exclude={'retcode', 'comment', 'retcode_external', 'request_id', 'request'}) return self.parameters | res | {'actual_profit': 0, 'closed': False, 'win': False} + async def save(self, *, trade_record_mode: Literal['csv', 'json'] = None): + """Record trade results as a csv or json file + Args: + trade_record_mode (Literal['csv'|'json']): Mode of saving trade records + """ + trade_record_mode = trade_record_mode or self.config.trade_record_mode + if trade_record_mode == 'csv': + await self.to_csv() + else: + await self.to_json() + async def to_csv(self): """Record trade results and associated parameters as a csv file """ try: data = self.get_data() file = self.config.records_dir / f"{self.name}.csv" - exists = file.exists() - with RLock(): - with open(file, 'a', newline='') as fh: - f_names = sorted(list(data.keys())) - writer = csv.DictWriter(fh, fieldnames=f_names, extrasaction='ignore', restval=None) - if not exists: - writer.writeheader() - writer.writerow(data) + file.touch(exist_ok=True) if not file.exists() else ... + reader: Iterable[dict] = csv.DictReader(file.open('r', newline='')) + rows: list[dict] = [] + headers = set() + [(rows.append(row), headers.update(row.keys())) for row in reader] + rows.append(data) + headers.update(data.keys()) + writer = csv.DictWriter(file.open('w', newline=''), fieldnames=headers, restval=None, + extrasaction='ignore') + writer.writeheader() + writer.writerows(rows) except Exception as err: - logger.error(f'Error: {err}. Unable to save trade results') + logger.error(f'Unable to save to csv: {err}') + + @staticmethod + def serialize(value) -> str: + """Serialize the trade records and strategy parameters + """ + try: + return str(value) + except (ValueError, TypeError) as _: + return "" + + async def to_json(self): + """Save trades and strategy parameters in a json file + """ + try: + file = self.config.records_dir / f"{self.name}.json" + data = self.get_data() + exists = file.touch(exist_ok=True) if not file.exists() else True + if not exists: + json.dump([], file.open('w')) + with file.open('r') as fh: + rows = json.load(fh) + rows.append(data) + with file.open('w') as fh: + json.dump(rows, fh, indent=2, skipkeys=True, default=self.serialize) + except Exception as err: + logger.error(f"Unable to save as json file: {err}") diff --git a/src/aiomql/ticks.py b/src/aiomql/ticks.py index 1d3d8fb..fe62229 100644 --- a/src/aiomql/ticks.py +++ b/src/aiomql/ticks.py @@ -4,6 +4,8 @@ from typing import TypeVar, Iterable from pandas import DataFrame, Series import pandas_ta as ta +import mplfinance as mplt +import pandas as pd from .core.constants import TickFlag @@ -47,6 +49,21 @@ class Tick: % {"class": self.__class__.__name__, "time": self.time, "bid": self.bid, "ask": self.ask, "last": self.last, "volume": self.volume, 'Index': self.Index}) + def dict(self, exclude: set = None, include: set = None) -> dict: + """ + Returns a dictionary of the instance attributes. + + Args: + exclude: A set of attributes to exclude from the dictionary. Defaults to None. + include: A set of attributes to include in the dictionary. Defaults to None. + + Returns: dict + """ + exclude = exclude or set() + include = include or set() + keys = include or set(self.__dict__.keys()).difference(exclude) + return {k: v for k, v in self.__dict__.items() if k in keys} + def set_attributes(self, **kwargs): """Set attributes from keyword arguments""" for key, value in kwargs.items(): @@ -158,3 +175,38 @@ class Ticks: """ res = self._data.rename(columns=kwargs, inplace=inplace) return res if inplace else self.__class__(data=res) + + def make_addplot(self, *, count: int = 50, columns: list = None, **kwargs) -> dict: + """ + Make subplots for adding to the main plot + + Args: + count (int): The numbers of candles to make the addplot for. Defaults to 50. + columns (list[str]): The columns to make the plot from. Defaults to None. + **kwargs: Valid arguments for the mplfinance make_addplot function + """ + columns = columns or [] + data = self._data[-count:] + data.index = pd.to_datetime(data['time'], unit='s') + return mplt.make_addplot(data[columns], **kwargs) + + def visualize(self, *, count: int = 50, type='candle', savefig: str | dict = None, addplot: dict = None, + style: str = 'charles', ylabel: str = 'Price', title: str = 'Chart', **kwargs): + """Visualize the candles using the mplfinance library. + Args: + count (int): The number of candles to visualize, counting from behind, i.e the most recent candles. + Defaults to 50. + type: Type of chart, defaults to candle + savefig (str|dict): The path to save the figure or a dictionary of parameters to pass to the savefig method. + addplot: Additional plots to add to the chart. Defaults to None. They should match the dimension of the + original data which is specified via the count parameter. + style (str): The style of the chart. Defaults to 'charles'. + ylabel (str): The label of the y-axis. Defaults to 'Price'. + title (str): The title of the chart. Defaults to 'Chart'. + kwargs: valid kwargs for the plot function. + """ + kwargs |= {key: arg for key, arg in (('savefig', savefig), ('addplot', addplot), ('style', style), + ('ylabel', ylabel), ('title', title), ('type', type)) if arg} + data = self._data[-count:] + data.index = pd.to_datetime(data['time'], unit='s') + mplt.plot(data, **kwargs) diff --git a/src/aiomql/trade_records.py b/src/aiomql/trade_records.py new file mode 100644 index 0000000..55fd395 --- /dev/null +++ b/src/aiomql/trade_records.py @@ -0,0 +1,154 @@ +"""This module contains the Records class, which is used to read and update trade records from csv files.""" + +import asyncio +import json +from pathlib import Path +import csv +import logging +from typing import Iterable + +from .core import Config, MetaTrader + +logger = logging.getLogger(__name__) + + +class TradeRecords: + """This utility class read trade records from csv files, and update them based on their closing positions. + + Attributes: + config: Config object + records_dir(Path): Absolute path to directory containing record of placed trades, If not given takes the default + from the config + """ + config: Config + mt5: MetaTrader + + def __init__(self, *, records_dir: Path | str = ''): + """Initialize the Records class. The main method of this class is update_records which you should call to update + all the records specified in the records_dir. + + Keyword Args: + records_dir (Path): Absolute path to directory containing record of placed trades. + """ + self.config = Config() + self.mt5 = MetaTrader() + self.records_dir = records_dir or self.config.records_dir + + async def get_csv_records(self): + """Get trade records saved as csv from records_dir folder + + Yields: + files: Trade record files + """ + for file in self.records_dir.iterdir(): + if file.is_file() and file.name.endswith('.csv'): + yield file + + async def get_json_records(self): + """Get trade records from records_dir folder + + Yields: + files: Trade record files + """ + for file in self.records_dir.iterdir(): + if file.is_file() and file.name.endswith('.json'): + yield file + + async def read_update_csv(self, *, file: Path): + """Read and update csv trade records + + Args: + file: Trade record file in csv format + """ + try: + fr = open(file, mode='r', newline='') + reader: Iterable[dict] | csv.DictReader = csv.DictReader(fr) + rows = [row for row in reader] + rows = await self.update_rows(rows=rows) + fr.close() + fw = open(file, mode='w', newline='') + writer = csv.DictWriter(fw, fieldnames=reader.fieldnames, extrasaction='ignore', restval=None) + writer.writeheader() + writer.writerows(rows) + fw.close() + except Exception as err: + logger.error(f'Error: {err}. Unable to read and update csv trade records') + + async def read_update_json(self, *, file: Path): + """Read and update json trade records + Args: + file: Trade record file in csv format + """ + try: + fh = open(file, mode='r') + data = json.load(fh) + rows = [row for row in data] + rows = await self.update_rows(rows=rows) + fh.close() + fh = open(file, mode='w') + json.dump(rows, fh, indent=2) + fh.close() + except Exception as err: + logger.error(f'Error: {err}. Unable to read and update json trade records') + + async def update_row(self, *, row: dict) -> dict: + """Update a single row of entered trade in the csv or json file with the actual profit. + + Args: + row: A dictionary from the dictionary writer object of the csv file. + + Returns: + dict: A dictionary with the actual profit and win status. + """ + try: + order = int(row['order']) + deals = await self.mt5.history_deals_get(position=order) + if not deals or len(deals) <= 1: + return row + deals = [deal for deal in deals if (deal.order != deal.position_id and deal.position_id == order + and deal.entry == 1)] + deals.sort(key=lambda x: x.time_msc) + deal = deals[-1] + row.update(actual_profit=deal.profit, win=deal.profit > 0, closed=True) + return row + except Exception as err: + logging.error(f'Error: {err}. Unable to update trade record') + return row + + async def update_rows(self, *, rows: list[dict]) -> list[dict]: + """Update the rows of entered trades in the csv or json file with the actual profit. + + Args: + rows: A list of dictionaries. + + Returns: + list[dict]: A list of dictionaries with the actual profit and win status. + """ + closed, unclosed = [], [] + for row in rows: + closed_ = row.get('closed', False) + closed_ = closed_.title() == 'True' if isinstance(closed_, str) else closed_ + if closed_: + closed.append(row) + else: + unclosed.append(row) + unclosed = await asyncio.gather(*[self.update_row(row=row) for row in unclosed]) + return closed + list(unclosed) + + async def update_csv_records(self): + """Update csv trade records in the records_dir folder.""" + records = [self.read_update_csv(file=record) async for record in self.get_csv_records()] + await asyncio.gather(*records) + + async def update_json_records(self): + """Update json trade records in the records_dir folder.""" + records = [self.read_update_json(file=record) async for record in self.get_json_records()] + await asyncio.gather(*records) + + async def update_csv_record(self, *, file: Path | str): + """Update a single trade record csv file.""" + await self.read_update_csv(file=file) + + async def update_json_record(self, *, file: Path | str): + """Update a single json trade record file""" + await self.read_update_json(file=file) diff --git a/src/aiomql/trader.py b/src/aiomql/trader.py index aabeb0e..829351e 100644 --- a/src/aiomql/trader.py +++ b/src/aiomql/trader.py @@ -107,17 +107,18 @@ class Trader(ABC): await self.record_trade(result, parameters=self.parameters.copy()) return result - async def record_trade(self, result: OrderSendResult, parameters: dict = None, name: str = ''): - """Record the trade in a csv file. - + async def record_trade(self, result: OrderSendResult, parameters: dict = None, name: str = '', exclude: set = None): + """Record the trade in csv or json. Args: result (OrderSendResult): Result of the order send parameters: parameters of the trading strategy used to place the trade name: Name of the trading strategy + exclude: Exclude these fields from the recorded trade """ if result.retcode != 10009 or not self.config.record_trades: return params = parameters or self.parameters.copy() + params = {k: v for k, v in params.items() if k not in (exclude or set())} profit = await self.order.calc_profit() params["expected_profit"] = profit date = datetime.utcnow() @@ -125,7 +126,7 @@ class Trader(ABC): params["date"] = str(date.date()) params["time"] = str(date.time()) res = Result(result=result, parameters=params, name=name) - self.config.task_queue.add_task(res.to_csv) + self.config.task_queue.add_task(res.save) @abstractmethod async def place_trade(self, *args, **kwargs):