├── ctfix ├── __init__.py ├── client │ ├── __init__.py │ ├── asyncio.py │ └── asyncore.py ├── math.py ├── session.py ├── field.py ├── message.py └── symbol.py ├── requirements.txt ├── .gitignore ├── setup.py ├── tests ├── test_math.py ├── test_message.py └── test_session.py ├── example └── client.py ├── tests.py └── README.md /ctfix/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ctfix/client/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pkg-resources==0.0.0 2 | uvloop==0.9.1 3 | pytest==3.5.1 4 | pytest-mock==1.10.0 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | venv 3 | 4 | build 5 | setup.cfg 6 | ctfix.egg-info 7 | 8 | *.log 9 | main.py 10 | 11 | .pytest_cache -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='ctfix', 5 | packages=find_packages(), # this must be the same as the name above 6 | version='0.22', 7 | description='Ctrader FIX API', 8 | author='Dmitry Shabanov', 9 | author_email='dm.skpd@gmail.com', 10 | url='https://github.com/Skpd/ctrader-fix-api', # use the URL to the github repo 11 | download_url='https://github.com/Skpd/ctrader-fix-api/archive/v0.21.tar.gz', # I'll explain this in a second 12 | keywords=['ctrader', 'fix', 'ctfix'], # arbitrary keywords 13 | classifiers=[], 14 | ) 15 | -------------------------------------------------------------------------------- /tests/test_math.py: -------------------------------------------------------------------------------- 1 | from ctfix.math import * 2 | 3 | 4 | def test_spread(): 5 | assert calculate_spread('113', '113.015', 2) == 15 6 | assert calculate_spread('1.09553', '1.09553', 4) == 0 7 | assert calculate_spread('9.59', '10', 1) == 41 8 | assert calculate_spread('113.1', '113.2', 2) == 100 9 | 10 | 11 | def test_pip_value(): 12 | assert calculate_pip_value('19.00570', 100000, 4) == '0.52616' 13 | assert calculate_pip_value('1.3348', 100000, 4) == '7.49176' 14 | assert calculate_pip_value('112.585', 10000, 2) == '0.88822' 15 | 16 | 17 | def test_commission(): 18 | assert calculate_commission(10000, 1, 0.000030) == 0.6 19 | -------------------------------------------------------------------------------- /ctfix/math.py: -------------------------------------------------------------------------------- 1 | __all__ = ['calculate_commission', 'calculate_pip_value', 'calculate_spread'] 2 | 3 | 4 | def calculate_spread(bid: str, ask: str, pip_position: int) -> int: 5 | spread = float(ask) - float(bid) 6 | spread = '{:.{}f}'.format(spread, pip_position + 1) 7 | return int(spread.replace('.', '')) 8 | 9 | 10 | def calculate_pip_value(price: str, size: int, pip_position: int) -> str: 11 | pip = (pow(1 / 10, pip_position) * size) / float(price) 12 | pip = '{:.5f}'.format(pip) 13 | return pip 14 | 15 | 16 | def calculate_commission(size=10000, rate=1, commission=0.000030): 17 | # can't handle different size/rate for now 18 | return (size * commission) * rate * 2 19 | -------------------------------------------------------------------------------- /example/client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from ctfix.client.asyncore import Client as AsyncoreClient 4 | from ctfix.message import Message 5 | from ctfix.session import Session 6 | 7 | 8 | def main(): 9 | global client 10 | session = Session('sender.id', 'CSERVER', 'QUOTE') 11 | Message.default_session = session 12 | client = AsyncoreClient(('ip.ad.dr.ess', 5201), 'login', 'password', session) 13 | client.add_handler(Message.TYPES.Logon, subscribe) 14 | client.add_handler(Message.TYPES.Heartbeat, funky_print) 15 | AsyncoreClient.run() 16 | 17 | 18 | def subscribe(): 19 | global client 20 | client.symbol_subscribe(1) 21 | 22 | 23 | def funky_print(): 24 | print("Do you hear my heart beat?") 25 | 26 | 27 | if __name__ == "__main__": 28 | 29 | if len(sys.argv) > 1 and sys.argv[1] == '--debug': 30 | AsyncoreClient.logging_level = logging.DEBUG 31 | 32 | main() 33 | -------------------------------------------------------------------------------- /tests/test_message.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from ctfix.session import Session 3 | from ctfix.message import * 4 | 5 | 6 | def test_message_init(): 7 | with pytest.raises(RuntimeError): 8 | Message() 9 | 10 | session1 = Session('sender', 'target') 11 | session2 = Session('sender', 'target') 12 | 13 | assert session1 != session2 14 | 15 | Message.default_session = session1 16 | msg = Message() 17 | assert msg.current_session == msg.default_session == session1 18 | 19 | msg = Message(session=session2) 20 | assert msg.current_session == session2 21 | assert msg.default_session != session2 22 | 23 | msg = Message([(1, 2)]) 24 | assert msg.fields[0] == (1, 2) 25 | 26 | 27 | def test_add_field(): 28 | msg = Message() 29 | msg.add_field((1, 2)) 30 | assert msg.fields[0] == (1, 2) 31 | 32 | msg.add_field(3, 4) 33 | assert msg.fields[1] == (3, 4) 34 | 35 | msg.length = 123 36 | msg.add_field(5, 6) 37 | assert msg.length is None 38 | assert msg.string is None 39 | 40 | assert msg.add_field(7, 8) == msg 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import FIX44 2 | import unittest 3 | from FIX44 import SOH 4 | from Message import make_pair 5 | 6 | 7 | class TestCalculations(unittest.TestCase): 8 | def testSpread(self): 9 | self.assertEqual(FIX44.calculate_spread('113', '113.015', 2), 15) 10 | self.assertEqual(FIX44.calculate_spread('1.09553', '1.09553', 4), 0) 11 | self.assertEqual(FIX44.calculate_spread('9.59', '10', 1), 41) 12 | self.assertEqual(FIX44.calculate_spread('113.1', '113.2', 2), 100) 13 | 14 | def testPipValue(self): 15 | self.assertEqual(FIX44.calculate_pip_value('19.00570', 100000, 4), '0.52616') 16 | self.assertEqual(FIX44.calculate_pip_value('1.3348', 100000, 4), '7.49176') 17 | self.assertEqual(FIX44.calculate_pip_value('112.585', 10000, 2), '0.88822') 18 | 19 | def test_commission(self): 20 | self.assertEqual(FIX44.calculate_commission(10000, 1, 0.000030), 0.6) 21 | 22 | def test_make_valid_tuple(self): 23 | self.assertEqual( 24 | make_pair(('first', 'second')), 25 | 'first=second{}'.format(SOH) 26 | ) 27 | 28 | 29 | if __name__ == '__main__': 30 | unittest.main() 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ctrader-fix-api 2 | 3 | See example/client.py for v0.1 version 4 | 5 | # Setup 6 | 7 | * virtualenv -p python3 venv 8 | * ./venv/bin/pip install -r requirements.txt 9 | 10 | # Usage 11 | ``` 12 | usage: client.py [-h] [--version] -b BROKER -u USERNAME -p PASSWORD -s SERVER 13 | [-v] [-t MAX_THREADS] 14 | 15 | CTrader FIX async client. 16 | 17 | optional arguments: 18 | -h, --help show this help message and exit 19 | --version show program's version number and exit 20 | -b BROKER, --broker BROKER 21 | Broker name, usually first part of sender id. 22 | -u USERNAME, --username USERNAME 23 | Account number. 24 | -p PASSWORD, --password PASSWORD 25 | Account password. 26 | -s SERVER, --server SERVER 27 | Host, ex hXX.p.ctrader.com. 28 | -v, --verbose Increase verbosity level. -v to something somewhat 29 | useful, -vv to full debug 30 | -t MAX_THREADS, --max-threads MAX_THREADS 31 | Thread limit in thread pool. Default to symbol table 32 | length. 33 | 34 | ``` 35 | -------------------------------------------------------------------------------- /ctfix/session.py: -------------------------------------------------------------------------------- 1 | __all__ = ['Session'] 2 | 3 | from ctfix.symbol import SETTINGS 4 | 5 | 6 | class Session: 7 | __sequence_number = 0 8 | symbol_table = None 9 | 10 | def __init__(self, sender_id: str, target_id: str, **kwargs): 11 | self.sender_id = sender_id 12 | self.target_id = target_id 13 | 14 | self.password = kwargs.get('password', None) 15 | self.username = kwargs.get('username', None) 16 | self.target_sub = kwargs.get('target_sub', None) 17 | self.sender_sub = kwargs.get('sender_sub', None) 18 | 19 | self.set_symbol_table(kwargs.get('symbol_table_ref', None)) 20 | self.reset_sequence() 21 | 22 | def set_symbol_table(self, symbol_table_ref=None): 23 | if not symbol_table_ref and self.sender_id and self.sender_id.split('.')[0] in SETTINGS: 24 | self.symbol_table = SETTINGS.get(self.sender_id.split('.')[0]) 25 | elif symbol_table_ref and symbol_table_ref in SETTINGS: 26 | self.symbol_table = SETTINGS.get(symbol_table_ref) 27 | else: 28 | self.symbol_table = SETTINGS.get('default') 29 | 30 | def next_sequence_number(self): 31 | self.__sequence_number += 1 32 | return self.__sequence_number 33 | 34 | def reset_sequence(self): 35 | self.__sequence_number = 0 36 | -------------------------------------------------------------------------------- /ctfix/field.py: -------------------------------------------------------------------------------- 1 | SEPARATOR = chr(1) 2 | BeginString = 8 3 | BodyLength = 9 4 | MsgType = 35 5 | SenderCompID = 49 6 | TargetCompID = 56 7 | TargetSubID = 57 8 | SenderSubID = 50 9 | MsgSeqNum = 34 10 | SendingTime = 52 11 | CheckSum = 10 12 | TestReqID = 112 13 | EncryptMethod = 98 14 | HeartBtInt = 108 15 | ResetSeqNum = 141 16 | Username = 553 17 | Password = 554 18 | Text = 58 19 | BeginSeqNo = 7 20 | EndSeqNo = 16 21 | GapFillFlag = 123 22 | NewSeqNo = 36 23 | MDReqID = 262 24 | SubscriptionRequestType = 263 25 | MarketDepth = 264 26 | MDUpdateType = 265 27 | NoMDEntryTypes = 267 28 | NoMDEntries = 268 29 | MDEntryType = 269 30 | NoRelatedSym = 146 31 | Symbol = 55 32 | MDEntryPx = 270 33 | MDUpdateAction = 279 34 | MDEntryID = 278 35 | MDEntrySize = 271 36 | ClOrdID = 11 37 | Side = 54 38 | TransactTime = 60 39 | OrderQty = 38 40 | OrdType = 40 41 | Price = 44 42 | StopPx = 99 43 | TimeInForce = 59 44 | ExpireTime = 126 45 | PosMaintRptID = 721 46 | OrderID = 37 47 | ExecType = 150 48 | OrdStatus = 39 49 | AvgPx = 6 50 | LeavesQty = 151 51 | CumQty = 14 52 | OrdRejReason = 103 53 | BuySide = 1 54 | SellSide = 2 55 | Account = 1 56 | 57 | 58 | class Groups: 59 | MDEntry_Snapshot = (MDEntryType, MDEntryPx) 60 | MDEntry_Refresh = ( 61 | MDUpdateAction, 62 | MDEntryType, 63 | MDEntryID, 64 | Symbol, 65 | MDEntryPx, 66 | MDEntrySize 67 | ) 68 | -------------------------------------------------------------------------------- /tests/test_session.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest import mock 3 | from ctfix.session import Session 4 | 5 | 6 | def test_session_requires_sender_and_target_id(): 7 | with pytest.raises(TypeError): 8 | # noinspection PyArgumentList 9 | Session() 10 | 11 | 12 | def test_session_accept_optional_args(): 13 | session = Session( 14 | 'sender', 'target', 15 | username='username', password='password', sender_sub='sender_sub', target_sub='target_sub' 16 | ) 17 | 18 | assert session.username == 'username' 19 | assert session.password == 'password' 20 | assert session.sender_sub == 'sender_sub' 21 | assert session.target_sub == 'target_sub' 22 | 23 | 24 | @mock.patch.dict('ctfix.session.SETTINGS', {'default': {1: 2}, 'something': {2: 3}}) 25 | def test_session_set_symbol_table(): 26 | session = Session('something.sender', 'target') 27 | assert 2 in session.symbol_table 28 | 29 | session = Session('sender', 'target') 30 | assert 1 in session.symbol_table 31 | 32 | session = Session('sender', 'target', symbol_table_ref='something') 33 | assert 2 in session.symbol_table 34 | 35 | 36 | def test_session_sequence(): 37 | session = Session('sender', 'target') 38 | # noinspection PyProtectedMember,PyUnresolvedReferences 39 | assert 0 == session._Session__sequence_number 40 | assert 1 == session.next_sequence_number() 41 | session.reset_sequence() 42 | # noinspection PyProtectedMember,PyUnresolvedReferences 43 | assert 0 == session._Session__sequence_number 44 | 45 | -------------------------------------------------------------------------------- /ctfix/client/asyncio.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from concurrent.futures import ThreadPoolExecutor 3 | import logging 4 | from ctfix.session import Session 5 | from ctfix.message import * 6 | from ctfix.math import * 7 | from ctfix.field import * 8 | 9 | 10 | class Client: 11 | TYPE_QUOTE = 'QUOTE' 12 | TYPE_TRADE = 'TRADE' 13 | session = None 14 | writer = None 15 | reader = None 16 | logger = None 17 | loop = None 18 | executor = None 19 | buffer = b'' 20 | handlers = {} 21 | client_type = None 22 | 23 | def __init__(self, loop, session: Session, max_threads=None, client_type=None): 24 | self.client_type = self.TYPE_TRADE if client_type == self.TYPE_TRADE else self.TYPE_QUOTE 25 | self.session = session 26 | self.loop = loop 27 | self.executor = ThreadPoolExecutor( 28 | max_workers=max_threads if max_threads else len(self.session.symbol_table) 29 | ) 30 | logging.basicConfig( 31 | format='%(asctime)s %(threadName)s %(name)s %(levelname)s: %(message)s' 32 | ) 33 | self.logger = logging.getLogger('{} {}'.format(self.session.sender_id, self.client_type)) 34 | self.handlers = { 35 | Message.TYPES.Logon: [self.on_logon], 36 | Message.TYPES.Heartbeat: [self.on_heartbeat], 37 | Message.TYPES.MarketDataSnapshot: [self.on_market_data], 38 | Message.TYPES.TestRequest: [self.on_test] 39 | } 40 | 41 | async def connect(self, host=None, port=None): 42 | self.logger.info('Connecting ') 43 | self.writer = None 44 | self.reader = None 45 | self.buffer = b'' 46 | self.session.reset_sequence() 47 | (self.reader, self.writer) = await asyncio.open_connection(host, port, loop=self.loop) 48 | self.on_connect() 49 | 50 | def on_connect(self): 51 | self.logger.info('Connected') 52 | self.write(LogonMessage(self.session.username, self.session.password, 30, self.session)) 53 | 54 | def on_logon(self): 55 | self.logger.critical('Signed in as {}'.format(self.session.sender_id)) 56 | 57 | def on_test(self, msg: Message): 58 | self.write(TestResponseMessage(msg.get_field(TestReqID), self.session)) 59 | 60 | def on_heartbeat(self): 61 | self.write(HeartbeatMessage(self.session)) 62 | 63 | def on_market_data(self, msg: Message): 64 | prices = msg.get_group(Groups.MDEntry_Snapshot) 65 | 66 | if len(prices) < 2 or MDEntryPx not in prices[0] or MDEntryPx not in prices[1]: 67 | self.logger.error("No ask or bid in price update.") 68 | return 69 | 70 | ask_idx = 1 if prices[0][MDEntryType] == '0' else 0 71 | bid_idx = (ask_idx + 1) % 2 72 | spread = calculate_spread( 73 | prices[bid_idx][MDEntryPx], 74 | prices[ask_idx][MDEntryPx], 75 | self.session.symbol_table[int(msg.get_field(Symbol))]['pip_position'] 76 | ) 77 | name = self.session.symbol_table[int(msg.get_field(Symbol))]['name'] 78 | 79 | self.logger.info("\t{0: <10}\tSPREAD: {1}\tBID: {2: <10}\tASK: {3: <10}".format( 80 | name, spread, prices[bid_idx][MDEntryPx], prices[ask_idx][MDEntryPx], 81 | )) 82 | 83 | def process(self, buffer): 84 | self.logger.debug('<<< IN {}'.format(buffer.decode().split(SEPARATOR))) 85 | 86 | msg = Message.from_string(buffer.decode(), self.session) 87 | 88 | if msg.get_type() in self.handlers: 89 | if type(self.handlers[msg.get_type()]) is not list: 90 | self.handlers[msg.get_type()] = [self.handlers[msg.get_type()]] 91 | 92 | for handler in self.handlers[msg.get_type()]: 93 | self.logger.debug('Executing {} for message type {}'.format(handler.__name__, msg.get_type())) 94 | handler(msg) 95 | self.logger.debug('{} done'.format(handler.__name__)) 96 | else: 97 | self.logger.warning('No handler for message type "{}"'.format(msg.get_type())) 98 | 99 | def feed(self, data): 100 | self.buffer += data 101 | 102 | header, value = data.split(b'=') 103 | if header == b'10': 104 | self.logger.debug('Submitting task to execute') 105 | self.loop.call_soon_threadsafe(self.executor.submit, self.process, self.buffer) 106 | self.logger.debug('Submitted') 107 | self.buffer = b'' 108 | 109 | def write(self, msg: Message): 110 | self.logger.debug('>>> OUT {}'.format(bytes(msg).replace(b'\x01', b'|'))) 111 | self.loop.call_soon_threadsafe(self.writer.write, bytes(msg)) 112 | 113 | async def run(self, host, port): 114 | await self.connect(host, port) 115 | 116 | max_attempts = 2 117 | attempts = max_attempts 118 | 119 | while attempts and self.loop.is_running(): 120 | try: 121 | data = await self.reader.readuntil(bytes(SEPARATOR, 'ASCII')) 122 | self.feed(data) 123 | attempts = max_attempts 124 | except asyncio.streams.IncompleteReadError: 125 | self.logger.critical('!Disconnected!') 126 | self.logger.info('Trying to reconnect') 127 | attempts -= 1 128 | await self.connect(host, port) 129 | 130 | self.logger.critical('Giving up after {} attempts'.format(max_attempts)) 131 | self.loop.stop() 132 | -------------------------------------------------------------------------------- /ctfix/message.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | 'Message', 'LogonMessage', 'HeartbeatMessage', 'TestResponseMessage', 3 | 'MarketDataRequestMessage', 'CreateOrder', 'CreateLimitOrder' 4 | ] 5 | 6 | import datetime 7 | import ctfix.field 8 | 9 | 10 | class Message: 11 | PROTOCOL = 'FIX.4.4' 12 | 13 | class TYPES: 14 | Logon = 'A' 15 | Heartbeat = '0' 16 | TestRequest = '1' 17 | Logout = '5' 18 | ResendRequest = '2' 19 | Reject = '3' 20 | SequenceReset = '4' 21 | MarketDataRequest = 'V' 22 | MarketDataSnapshot = 'W' 23 | MarketDataRefresh = 'X' 24 | NewOrder = 'D' 25 | OrderStatus = 'H' 26 | ExecutionReport = '8' 27 | MessageReject = 'j' 28 | PositionRequest = 'AN' 29 | PositionReport = 'AP' 30 | 31 | default_session = None 32 | current_session = None 33 | 34 | def __init__(self, fields=None, session=None): 35 | self.msg_type = None 36 | self.fields = [] 37 | 38 | if fields is None: 39 | fields = [] 40 | 41 | for pair in fields: 42 | self.add_field(pair) 43 | 44 | self.length = None 45 | self.string = None 46 | 47 | if not session and not self.default_session: 48 | raise RuntimeError('Session must be provided if default session is not set') 49 | 50 | if not session: 51 | self.current_session = self.default_session 52 | else: 53 | self.current_session = session 54 | 55 | def __getitem__(self, item): 56 | return self.get_field(item) 57 | 58 | def __setitem__(self, key, value): 59 | return self.add_field((key, value)) 60 | 61 | def get_type(self): 62 | return self.msg_type 63 | 64 | def get_field(self, field_type): 65 | for pair in self.fields: 66 | if str(pair[0]) == str(field_type): 67 | return pair[1] 68 | 69 | def add_field(self, pair_or_first, second=None): 70 | if not second and len(pair_or_first) != 2: 71 | raise TypeError('set_field accepts either valid pair or two arguments') 72 | elif not second: 73 | first = pair_or_first[0] 74 | second = pair_or_first[1] 75 | else: 76 | first = pair_or_first 77 | 78 | # add field to the field list 79 | self.fields.append((first, second)) 80 | 81 | # set message type if field is a message type 82 | if str(first) == str(ctfix.field.MsgType): 83 | self.msg_type = second 84 | 85 | # drop compiled message cache 86 | self.length = None 87 | self.string = None 88 | 89 | return self 90 | 91 | def get_all_by(self, field_type): 92 | result = [] 93 | for pair in self.fields: 94 | if str(pair[0]) == str(field_type): 95 | result.append(pair[1]) 96 | return result 97 | 98 | def get_group(self, group): 99 | result = [] 100 | for field_id in group: 101 | values = self.get_all_by(field_id) 102 | for i, value in enumerate(values): 103 | if len(result) - 1 < i: 104 | result.append({}) 105 | result[i][field_id] = value 106 | return result 107 | 108 | def __len__(self): 109 | if self.length is None: 110 | self.build_message() 111 | 112 | return self.length 113 | 114 | def __str__(self): 115 | if self.string is None: 116 | self.build_message() 117 | 118 | return self.string 119 | 120 | def __bytes__(self): 121 | if self.string is None: 122 | self.build_message() 123 | 124 | return bytes(self.string, 'ASCII') 125 | 126 | def build_header(self): 127 | header = [ 128 | (ctfix.field.MsgSeqNum, self.current_session.next_sequence_number()), 129 | (ctfix.field.SenderCompID, self.current_session.sender_id), 130 | (ctfix.field.SendingTime, self.get_time()), 131 | (ctfix.field.TargetCompID, self.current_session.target_id), 132 | ] 133 | 134 | if self.current_session.target_sub is not None: 135 | header.append((ctfix.field.TargetSubID, self.current_session.target_sub)) 136 | if self.current_session.sender_sub is not None: 137 | header.append((ctfix.field.SenderSubID, self.current_session.sender_sub)) 138 | 139 | return header 140 | 141 | def build_message(self): 142 | header = self.build_header() 143 | self.length = len(header) 144 | 145 | body = '' 146 | for pair in sorted(header): 147 | body += self.make_pair(pair) 148 | for pair in self.fields: 149 | body += Message.make_pair(pair) 150 | 151 | self.length += len(body) + 1 152 | 153 | msg_str = self.make_pair((ctfix.field.BeginString, Message.PROTOCOL)) 154 | msg_str += self.make_pair((ctfix.field.BodyLength, self.length)) 155 | msg_str += self.make_pair((ctfix.field.MsgType, self.msg_type)) 156 | msg_str += body 157 | msg_str += self.build_checksum(msg_str) 158 | 159 | self.string = msg_str 160 | 161 | @staticmethod 162 | def get_time(add_seconds=None): 163 | if add_seconds: 164 | return (datetime.datetime.utcnow() + datetime.timedelta(0, add_seconds)).strftime("%Y%m%d-%H:%M:%S.%f")[:-3] 165 | else: 166 | return datetime.datetime.utcnow().strftime("%Y%m%d-%H:%M:%S.%f")[:-3] 167 | 168 | @staticmethod 169 | def make_pair(pair: tuple): 170 | return str(pair[0]) + "=" + str(pair[1]) + ctfix.field.SEPARATOR 171 | 172 | @classmethod 173 | def build_checksum(cls, message: str): 174 | checksum = sum([ord(i) for i in list(message)]) % 256 175 | return cls.make_pair((10, str(checksum).zfill(3))) 176 | 177 | @classmethod 178 | def from_string(cls, string, session=None): 179 | result = cls([], session) 180 | for pair in string.split(ctfix.field.SEPARATOR): 181 | if len(pair): 182 | values = pair.split('=') 183 | if len(values) == 2: 184 | result.add_field((values[0], values[1])) 185 | result.string = string 186 | return result 187 | 188 | 189 | class LogonMessage(Message): 190 | def __init__(self, username, password, heartbeat=30, session=None): 191 | super().__init__([ 192 | (ctfix.field.Username, username), 193 | (ctfix.field.Password, password), 194 | (ctfix.field.EncryptMethod, 0), 195 | (ctfix.field.HeartBtInt, heartbeat), 196 | (ctfix.field.ResetSeqNum, 'Y'), 197 | ], session) 198 | self.msg_type = Message.TYPES.Logon 199 | 200 | 201 | class HeartbeatMessage(Message): 202 | def __init__(self, session=None): 203 | super().__init__([], session) 204 | self.msg_type = Message.TYPES.Heartbeat 205 | 206 | 207 | class TestResponseMessage(Message): 208 | def __init__(self, text, session=None): 209 | super().__init__([(ctfix.field.TestReqID, text)], session) 210 | self.msg_type = Message.TYPES.Heartbeat 211 | 212 | 213 | class MarketDataRequestMessage(Message): 214 | def __init__(self, request_id, symbol, unsubscribe=False, refresh=False, session=None): 215 | super().__init__([ 216 | (ctfix.field.MDReqID, request_id), 217 | (ctfix.field.SubscriptionRequestType, 2 if unsubscribe else 1), 218 | (ctfix.field.MarketDepth, 0 if refresh else 1), 219 | (ctfix.field.MDUpdateType, 0), 220 | (ctfix.field.NoRelatedSym, 1), 221 | (ctfix.field.Symbol, symbol), 222 | (ctfix.field.NoMDEntryTypes, 2), 223 | (ctfix.field.MDEntryType, 0), 224 | (ctfix.field.MDEntryType, 1), 225 | ], session) 226 | self.msg_type = Message.TYPES.MarketDataRequest 227 | 228 | 229 | class CreateOrder(Message): 230 | def __init__(self, order_id, symbol, side, size, price, session=None): 231 | super().__init__([ 232 | (ctfix.field.ClOrdID, order_id), 233 | (ctfix.field.Symbol, symbol), 234 | (ctfix.field.Side, side), 235 | (ctfix.field.Price, price), 236 | (ctfix.field.TransactTime, self.get_time()), 237 | (ctfix.field.OrderQty, size), 238 | (ctfix.field.OrdType, 1), 239 | (ctfix.field.TimeInForce, 3), 240 | ], session) 241 | self.msg_type = Message.TYPES.NewOrder 242 | 243 | 244 | class CreateLimitOrder(Message): 245 | def __init__(self, order_id, symbol, side, size, price, expiry, session=None): 246 | super().__init__([ 247 | (ctfix.field.ClOrdID, order_id), 248 | (ctfix.field.Symbol, symbol), 249 | (ctfix.field.Side, side), 250 | (ctfix.field.TransactTime, self.get_time()), 251 | (ctfix.field.OrderQty, size), 252 | (ctfix.field.OrdType, 2), 253 | (ctfix.field.Price, price), 254 | (ctfix.field.TimeInForce, 6), 255 | (ctfix.field.ExpireTime, expiry) 256 | ], session) 257 | self.msg_type = Message.TYPES.NewOrder 258 | -------------------------------------------------------------------------------- /ctfix/client/asyncore.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import socket 3 | import asyncore 4 | import logging 5 | import logging.handlers 6 | from ctfix.message import Message, LogonMessage, HeartbeatMessage, TestResponseMessage, MarketDataRequestMessage 7 | from ctfix.math import calculate_spread 8 | from ctfix.field import * 9 | 10 | 11 | class Client(asyncore.dispatcher): 12 | logging_level = logging.INFO 13 | authorized = False 14 | commission = 0.000030 15 | buffer = '' 16 | address = None 17 | 18 | def __init__(self, address: tuple, user, password, session, log_file=None): 19 | asyncore.dispatcher.__init__(self) 20 | self.session = session 21 | self.user = user 22 | self.password = password 23 | self.address = address 24 | 25 | self.symbol_requests = [] 26 | self.market_last_request = 1 27 | 28 | if log_file is None: 29 | log_file = 'messages_' + self.session.sender_id + '.log' 30 | 31 | logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=self.logging_level) 32 | self.logger = logging.getLogger('fix-client.' + self.session.sender_id) 33 | 34 | log_handler = logging.handlers.TimedRotatingFileHandler(log_file, 'h', 1) 35 | log_handler.setFormatter(logging.Formatter('%(asctime)s: %(message)s')) 36 | self.message_logger = logging.getLogger('fix-messages.' + self.session.sender_id) 37 | self.message_logger.addHandler(log_handler) 38 | self.message_logger.info("\nNEW SESSION\n") 39 | 40 | self.handlers = { 41 | Message.TYPES.Logon: [self.logon_handler], 42 | Message.TYPES.Logout: [self.logout_handler], 43 | Message.TYPES.Heartbeat: [self.heartbeat_handler], 44 | Message.TYPES.TestRequest: [self.test_request_handled], 45 | Message.TYPES.Reject: [self.reject_handler], 46 | Message.TYPES.MessageReject: [self.reject_handler], 47 | Message.TYPES.MarketDataSnapshot: [self.market_data_snapshot_handler], 48 | Message.TYPES.MarketDataRefresh: [self.market_data_refresh_handler], 49 | Message.TYPES.ExecutionReport: [self.execution_report_handler], 50 | } 51 | 52 | self.do_connect() 53 | 54 | def do_connect(self): 55 | self.session.reset_sequence() 56 | self.authorized = False 57 | self.buffer = '' 58 | self.create_socket(socket.AF_INET, socket.SOCK_STREAM) 59 | self.connect(self.address) 60 | 61 | def add_handler(self, h_type, h_callback): 62 | if h_type not in self.handlers: 63 | self.handlers[h_type] = [] 64 | self.handlers[h_type].insert(0, h_callback) 65 | 66 | def set_handler(self, h_type, h_callback): 67 | self.handlers[h_type] = [h_callback] 68 | 69 | def send(self, data): 70 | if data is None: 71 | return 72 | 73 | if not isinstance(data, Message): 74 | data = bytes(data, 'ASCII') 75 | 76 | sent = 0 77 | while sent < len(data): 78 | sent_m = asyncore.dispatcher.send(self, bytes(data)[sent:]) 79 | if sent_m is None: 80 | return 81 | else: 82 | sent += sent_m 83 | 84 | self.message_logger.debug('OUT >>> ' + str(data).replace('\x01', '|')) 85 | return sent 86 | 87 | def handle_connect(self): 88 | if not self.authorized: 89 | self.send(LogonMessage(self.user, self.password, 3, self.session)) 90 | 91 | def handle_close(self): 92 | self.close() 93 | 94 | def handle_read(self): 95 | self.buffer += self.recv(2048).decode('ASCII') 96 | 97 | if len(self.buffer) == 0: 98 | self.logger.info(self.session.sender_id + ' disconnected.') 99 | self.close() 100 | sys.exit(1) 101 | 102 | while True: 103 | checksum_point = self.buffer.find(SEPARATOR + '10=') 104 | 105 | if len(self.buffer) == 0 or checksum_point == -1 or self.buffer[checksum_point + 7:][:1] != SEPARATOR: 106 | break 107 | 108 | message = Message.from_string(self.buffer[:checksum_point + 8], self.session) 109 | self.handle_message(message) 110 | self.buffer = self.buffer[checksum_point + 8:] 111 | 112 | def handle_message(self, message): 113 | handlers = self.get_message_handler(message) 114 | 115 | if message.get_field(CheckSum) is None: 116 | self.logger.critical("Incomplete message: {0}".format(message)) 117 | return 118 | 119 | if handlers is not None: 120 | for h in handlers: 121 | h(message) 122 | 123 | def get_message_handler(self, message: Message): 124 | if message.get_type() is None: 125 | self.logger.warning("Can't handle message: can't find message type") 126 | return None 127 | elif message.get_type() in self.handlers: 128 | return self.handlers[message.get_type()] 129 | else: 130 | self.logger.warning("Can't handle message: handler {0} not registered".format(message.get_type())) 131 | return None 132 | 133 | def writable(self): 134 | return self.connecting 135 | 136 | def logon_handler(self, message: Message): 137 | self.authorized = True 138 | self.logger.info("Logged in at {0} as {1}".format(message.get_field(SendingTime), self.session.sender_id)) 139 | 140 | def heartbeat_handler(self, message: Message): 141 | self.logger.debug( 142 | "Received heartbeat, sending it back. Server time: {0}.".format(message.get_field(SendingTime)) 143 | ) 144 | self.send(HeartbeatMessage(self.session)) 145 | 146 | def test_request_handled(self, message: Message): 147 | self.send(TestResponseMessage(message.get_field(TestReqID), self.session)) 148 | 149 | def reject_handler(self, message: Message): 150 | self.logger.warning("MESSAGE REJECTED: {0}".format(message.get_field(Text))) 151 | 152 | def market_data_snapshot_handler(self, message: Message): 153 | prices = message.get_group(Groups.MDEntry_Snapshot) 154 | 155 | if len(prices) < 2 or MDEntryPx not in prices[0] or MDEntryPx not in prices[1]: 156 | self.logger.warning("No ask or bid in price update.") 157 | return 158 | 159 | ask_idx = 1 if prices[0][MDEntryType] == '0' else 0 160 | bid_idx = (ask_idx + 1) % 2 161 | spread = calculate_spread( 162 | prices[bid_idx][MDEntryPx], 163 | prices[ask_idx][MDEntryPx], 164 | self.session.symbol_table[int(message.get_field(Symbol))]['pip_position'] 165 | ) 166 | name = self.session.symbol_table[int(message.get_field(Symbol))]['name'] 167 | self.logger.info( 168 | "Symbol: {0: <7}\tBID: {1: <10}\tASK: {2: <10}\tSPREAD: {3}\t\tBID_VOL: {4}\tASK_VOL: {5}".format( 169 | name, 170 | prices[bid_idx][MDEntryPx], 171 | prices[ask_idx][MDEntryPx], 172 | spread, 173 | int(self.session.symbol_table[int(message.get_field(Symbol))]['bid_volume'] / 1000000), 174 | int(self.session.symbol_table[int(message.get_field(Symbol))]['ask_volume'] / 1000000), 175 | ) 176 | ) 177 | 178 | def market_data_refresh_handler(self, message: Message): 179 | results = message.get_group(Groups.MDEntry_Refresh) 180 | actions = {'0': 'New', '2': 'Delete'} 181 | types = {'0': 'BID', '1': 'ASK'} 182 | 183 | message = "Price Update:" 184 | for r in results: 185 | if actions[r[MDUpdateAction]] == 'New': 186 | if MDEntryPx in r: 187 | name = self.session.symbol_table[int(r[Symbol])]['name'] 188 | message += "\n\t\t\tSymbol: {0: <7}, Type: {1}, ID: {2}, Price: {3: <10}, Size: {4}, Action: {5}"\ 189 | .format( 190 | name, types[r[MDEntryType]], r[MDEntryID], r[MDEntryPx], 191 | r[MDEntrySize], actions[r[MDUpdateAction]] 192 | ) 193 | else: 194 | message += "\n\t\t\tSymbol: {0: <7}, ID: {1}, Action: {2}".format( 195 | 'none', r[MDEntryID], actions[r[MDUpdateAction]] 196 | ) 197 | 198 | self.logger.info(message) 199 | 200 | def execution_report_handler(self, message: Message): 201 | statuses = {'0': 'New', '1': 'Partial', '2': 'Filled', '4': 'Cancelled', '8': 'Rejected', 'C': 'Expired'} 202 | self.logger.warning("ORDER {0} {1} status: {2}, Time: {3}. {4}".format( 203 | message.get_field(OrderID), message.get_field(ClOrdID), 204 | statuses[message.get_field(OrdStatus)], message.get_field(TransactTime), 205 | message.get_field(Text) 206 | )) 207 | 208 | def logout_handler(self, message: Message): 209 | self.logger.critical('Logout reason: {0}'.format(message.get_field(Text))) 210 | self.close() 211 | 212 | def symbol_subscribe(self, symbol_id, refresh=False): 213 | self.market_last_request += 1 214 | self.symbol_requests.append({'symbol': symbol_id, 'request_id': self.market_last_request}) 215 | self.send(MarketDataRequestMessage(self.market_last_request, symbol_id, False, refresh, self.session)) 216 | 217 | def symbol_unsubscribe(self, symbol_id): 218 | try: 219 | symbol_idx = next(i for (i, d) in enumerate(self.symbol_requests) if d['symbol'] == symbol_id) 220 | except StopIteration: 221 | self.logger.warning("Can't find subscription for symbol {0}".format(symbol_id)) 222 | return 223 | 224 | self.send(MarketDataRequestMessage( 225 | self.symbol_requests[symbol_idx]['request_id'], 226 | symbol_id, 227 | True, 228 | False, 229 | self.session 230 | )) 231 | 232 | @staticmethod 233 | def run(): 234 | asyncore.loop(3) 235 | -------------------------------------------------------------------------------- /ctfix/symbol.py: -------------------------------------------------------------------------------- 1 | SETTINGS = { 2 | 'default': { 3 | 1: {'id': 1, 'pip_position': 4, 'name': 'EURUSD.spa', 'bid_volume': 0, 'ask_volume': 0}, 4 | 2: {'id': 2, 'pip_position': 4, 'name': 'GBPUSD.spa', 'bid_volume': 0, 'ask_volume': 0}, 5 | 3: {'id': 3, 'pip_position': 2, 'name': 'EURJPY.spa', 'bid_volume': 0, 'ask_volume': 0}, 6 | 4: {'id': 4, 'pip_position': 2, 'name': 'USDJPY.spa', 'bid_volume': 0, 'ask_volume': 0}, 7 | 5: {'id': 5, 'pip_position': 4, 'name': 'AUDUSD.spa', 'bid_volume': 0, 'ask_volume': 0}, 8 | 6: {'id': 6, 'pip_position': 4, 'name': 'USDCHF.spa', 'bid_volume': 0, 'ask_volume': 0}, 9 | 7: {'id': 7, 'pip_position': 2, 'name': 'GBPJPY.spa', 'bid_volume': 0, 'ask_volume': 0}, 10 | 8: {'id': 8, 'pip_position': 4, 'name': 'USDCAD.spa', 'bid_volume': 0, 'ask_volume': 0}, 11 | 9: {'id': 9, 'pip_position': 4, 'name': 'EURGBP.spa', 'bid_volume': 0, 'ask_volume': 0}, 12 | 10: {'id': 10, 'pip_position': 4, 'name': 'EURCHF.spa', 'bid_volume': 0, 'ask_volume': 0}, 13 | 11: {'id': 11, 'pip_position': 2, 'name': 'AUDJPY.spa', 'bid_volume': 0, 'ask_volume': 0}, 14 | 12: {'id': 12, 'pip_position': 4, 'name': 'NZDUSD.spa', 'bid_volume': 0, 'ask_volume': 0}, 15 | 13: {'id': 13, 'pip_position': 2, 'name': 'CHFJPY.spa', 'bid_volume': 0, 'ask_volume': 0}, 16 | 14: {'id': 14, 'pip_position': 4, 'name': 'EURAUD.spa', 'bid_volume': 0, 'ask_volume': 0}, 17 | 15: {'id': 15, 'pip_position': 2, 'name': 'CADJPY.spa', 'bid_volume': 0, 'ask_volume': 0}, 18 | 16: {'id': 16, 'pip_position': 4, 'name': 'GBPAUD.spa', 'bid_volume': 0, 'ask_volume': 0}, 19 | 17: {'id': 17, 'pip_position': 4, 'name': 'EURCAD.spa', 'bid_volume': 0, 'ask_volume': 0}, 20 | 18: {'id': 18, 'pip_position': 4, 'name': 'AUDCAD.spa', 'bid_volume': 0, 'ask_volume': 0}, 21 | 19: {'id': 19, 'pip_position': 4, 'name': 'GBPCAD.spa', 'bid_volume': 0, 'ask_volume': 0}, 22 | 20: {'id': 20, 'pip_position': 4, 'name': 'AUDNZD.spa', 'bid_volume': 0, 'ask_volume': 0}, 23 | 21: {'id': 21, 'pip_position': 2, 'name': 'NZDJPY.spa', 'bid_volume': 0, 'ask_volume': 0}, 24 | 22: {'id': 22, 'pip_position': 4, 'name': 'USDNOK.spa', 'bid_volume': 0, 'ask_volume': 0}, 25 | 23: {'id': 23, 'pip_position': 4, 'name': 'AUDCHF.spa', 'bid_volume': 0, 'ask_volume': 0}, 26 | 24: {'id': 24, 'pip_position': 4, 'name': 'USDMXN.spa', 'bid_volume': 0, 'ask_volume': 0}, 27 | 25: {'id': 25, 'pip_position': 4, 'name': 'GBPNZD.spa', 'bid_volume': 0, 'ask_volume': 0}, 28 | 26: {'id': 26, 'pip_position': 4, 'name': 'EURNZD.spa', 'bid_volume': 0, 'ask_volume': 0}, 29 | 27: {'id': 27, 'pip_position': 4, 'name': 'CADCHF.spa', 'bid_volume': 0, 'ask_volume': 0}, 30 | 28: {'id': 28, 'pip_position': 4, 'name': 'USDSGD.spa', 'bid_volume': 0, 'ask_volume': 0}, 31 | 29: {'id': 29, 'pip_position': 4, 'name': 'USDSEK.spa', 'bid_volume': 0, 'ask_volume': 0}, 32 | 30: {'id': 30, 'pip_position': 4, 'name': 'NZDCAD.spa', 'bid_volume': 0, 'ask_volume': 0}, 33 | 31: {'id': 31, 'pip_position': 4, 'name': 'EURSEK.spa', 'bid_volume': 0, 'ask_volume': 0}, 34 | 32: {'id': 32, 'pip_position': 4, 'name': 'GBPSGD.spa', 'bid_volume': 0, 'ask_volume': 0}, 35 | 33: {'id': 33, 'pip_position': 4, 'name': 'EURNOK.spa', 'bid_volume': 0, 'ask_volume': 0}, 36 | 34: {'id': 34, 'pip_position': 4, 'name': 'EURHUF.spa', 'bid_volume': 0, 'ask_volume': 0}, 37 | 35: {'id': 35, 'pip_position': 4, 'name': 'USDPLN.spa', 'bid_volume': 0, 'ask_volume': 0}, 38 | 36: {'id': 36, 'pip_position': 4, 'name': 'USDDKK.spa', 'bid_volume': 0, 'ask_volume': 0}, 39 | 37: {'id': 37, 'pip_position': 4, 'name': 'GBPNOK.spa', 'bid_volume': 0, 'ask_volume': 0}, 40 | 39: {'id': 39, 'pip_position': 4, 'name': 'NZDCHF.spa', 'bid_volume': 0, 'ask_volume': 0}, 41 | 40: {'id': 40, 'pip_position': 4, 'name': 'GBPCHF.spa', 'bid_volume': 0, 'ask_volume': 0}, 42 | 43: {'id': 43, 'pip_position': 4, 'name': 'USDTRY.spa', 'bid_volume': 0, 'ask_volume': 0}, 43 | 44: {'id': 44, 'pip_position': 4, 'name': 'EURTRY.spa', 'bid_volume': 0, 'ask_volume': 0}, 44 | 46: {'id': 46, 'pip_position': 4, 'name': 'EURZAR.spa', 'bid_volume': 0, 'ask_volume': 0}, 45 | 47: {'id': 47, 'pip_position': 2, 'name': 'SGDJPY.spa', 'bid_volume': 0, 'ask_volume': 0}, 46 | 48: {'id': 48, 'pip_position': 4, 'name': 'USDHKD.spa', 'bid_volume': 0, 'ask_volume': 0}, 47 | 49: {'id': 49, 'pip_position': 4, 'name': 'USDZAR.spa', 'bid_volume': 0, 'ask_volume': 0}, 48 | 50: {'id': 50, 'pip_position': 4, 'name': 'EURMXN.spa', 'bid_volume': 0, 'ask_volume': 0}, 49 | 51: {'id': 51, 'pip_position': 4, 'name': 'EURPLN.spa', 'bid_volume': 0, 'ask_volume': 0}, 50 | 53: {'id': 53, 'pip_position': 4, 'name': 'NZDSGD.spa', 'bid_volume': 0, 'ask_volume': 0}, 51 | 54: {'id': 54, 'pip_position': 4, 'name': 'USDHUF.spa', 'bid_volume': 0, 'ask_volume': 0}, 52 | 55: {'id': 55, 'pip_position': 4, 'name': 'EURCZK.spa', 'bid_volume': 0, 'ask_volume': 0}, 53 | 56: {'id': 56, 'pip_position': 4, 'name': 'USDCZK.spa', 'bid_volume': 0, 'ask_volume': 0}, 54 | 57: {'id': 57, 'pip_position': 4, 'name': 'EURDKK.spa', 'bid_volume': 0, 'ask_volume': 0}, 55 | 60: {'id': 60, 'pip_position': 4, 'name': 'USDCNH.spa', 'bid_volume': 0, 'ask_volume': 0}, 56 | 61: {'id': 61, 'pip_position': 4, 'name': 'GBPSEK.spa', 'bid_volume': 0, 'ask_volume': 0}, 57 | }, 58 | 'mpa': { 59 | 99: {'id': 99, 'pip_position': 4, 'name': '99', 'bid_volume': 0, 'ask_volume': 0}, 60 | 100: {'id': 100, 'pip_position': 4, 'name': '100', 'bid_volume': 0, 'ask_volume': 0}, 61 | 101: {'id': 101, 'pip_position': 2, 'name': '101', 'bid_volume': 0, 'ask_volume': 0}, 62 | 102: {'id': 102, 'pip_position': 4, 'name': '102', 'bid_volume': 0, 'ask_volume': 0}, 63 | 103: {'id': 103, 'pip_position': 4, 'name': '103', 'bid_volume': 0, 'ask_volume': 0}, 64 | 104: {'id': 104, 'pip_position': 4, 'name': '104', 'bid_volume': 0, 'ask_volume': 0}, 65 | 105: {'id': 105, 'pip_position': 2, 'name': '105', 'bid_volume': 0, 'ask_volume': 0}, 66 | 106: {'id': 106, 'pip_position': 2, 'name': '106', 'bid_volume': 0, 'ask_volume': 0}, 67 | 107: {'id': 107, 'pip_position': 4, 'name': '107', 'bid_volume': 0, 'ask_volume': 0}, 68 | 108: {'id': 108, 'pip_position': 4, 'name': '108', 'bid_volume': 0, 'ask_volume': 0}, 69 | 109: {'id': 109, 'pip_position': 4, 'name': '109', 'bid_volume': 0, 'ask_volume': 0}, 70 | 110: {'id': 110, 'pip_position': 4, 'name': '110', 'bid_volume': 0, 'ask_volume': 0}, 71 | 111: {'id': 111, 'pip_position': 4, 'name': '111', 'bid_volume': 0, 'ask_volume': 0}, 72 | 112: {'id': 112, 'pip_position': 4, 'name': '112', 'bid_volume': 0, 'ask_volume': 0}, 73 | 113: {'id': 113, 'pip_position': 2, 'name': '113', 'bid_volume': 0, 'ask_volume': 0}, 74 | 114: {'id': 114, 'pip_position': 4, 'name': '114', 'bid_volume': 0, 'ask_volume': 0}, 75 | 115: {'id': 115, 'pip_position': 4, 'name': '115', 'bid_volume': 0, 'ask_volume': 0}, 76 | 116: {'id': 116, 'pip_position': 4, 'name': '116', 'bid_volume': 0, 'ask_volume': 0}, 77 | 117: {'id': 117, 'pip_position': 4, 'name': '117', 'bid_volume': 0, 'ask_volume': 0}, 78 | 118: {'id': 118, 'pip_position': 4, 'name': '118', 'bid_volume': 0, 'ask_volume': 0}, 79 | 119: {'id': 119, 'pip_position': 4, 'name': '119', 'bid_volume': 0, 'ask_volume': 0}, 80 | 120: {'id': 120, 'pip_position': 4, 'name': '120', 'bid_volume': 0, 'ask_volume': 0}, 81 | 121: {'id': 121, 'pip_position': 4, 'name': '121', 'bid_volume': 0, 'ask_volume': 0}, 82 | 122: {'id': 122, 'pip_position': 4, 'name': '122', 'bid_volume': 0, 'ask_volume': 0}, 83 | 123: {'id': 123, 'pip_position': 2, 'name': '123', 'bid_volume': 0, 'ask_volume': 0}, 84 | 124: {'id': 124, 'pip_position': 4, 'name': '124', 'bid_volume': 0, 'ask_volume': 0}, 85 | 125: {'id': 125, 'pip_position': 4, 'name': '125', 'bid_volume': 0, 'ask_volume': 0}, 86 | 126: {'id': 126, 'pip_position': 4, 'name': '126', 'bid_volume': 0, 'ask_volume': 0}, 87 | 127: {'id': 127, 'pip_position': 2, 'name': '127', 'bid_volume': 0, 'ask_volume': 0}, 88 | 128: {'id': 128, 'pip_position': 2, 'name': '128', 'bid_volume': 0, 'ask_volume': 0}, 89 | 129: {'id': 129, 'pip_position': 4, 'name': '129', 'bid_volume': 0, 'ask_volume': 0}, 90 | 130: {'id': 130, 'pip_position': 4, 'name': '130', 'bid_volume': 0, 'ask_volume': 0}, 91 | 131: {'id': 131, 'pip_position': 4, 'name': '131', 'bid_volume': 0, 'ask_volume': 0}, 92 | 132: {'id': 132, 'pip_position': 2, 'name': '132', 'bid_volume': 0, 'ask_volume': 0}, 93 | 133: {'id': 133, 'pip_position': 4, 'name': '133', 'bid_volume': 0, 'ask_volume': 0}, 94 | 134: {'id': 134, 'pip_position': 4, 'name': '134', 'bid_volume': 0, 'ask_volume': 0}, 95 | 135: {'id': 135, 'pip_position': 2, 'name': '135', 'bid_volume': 0, 'ask_volume': 0}, 96 | 136: {'id': 136, 'pip_position': 4, 'name': '136', 'bid_volume': 0, 'ask_volume': 0}, 97 | 137: {'id': 137, 'pip_position': 4, 'name': '137', 'bid_volume': 0, 'ask_volume': 0}, 98 | 138: {'id': 138, 'pip_position': 4, 'name': '138', 'bid_volume': 0, 'ask_volume': 0}, 99 | 139: {'id': 139, 'pip_position': 4, 'name': '139', 'bid_volume': 0, 'ask_volume': 0}, 100 | 140: {'id': 140, 'pip_position': 4, 'name': '140', 'bid_volume': 0, 'ask_volume': 0}, 101 | 141: {'id': 141, 'pip_position': 2, 'name': '141', 'bid_volume': 0, 'ask_volume': 0}, 102 | 142: {'id': 142, 'pip_position': 4, 'name': '142', 'bid_volume': 0, 'ask_volume': 0}, 103 | 143: {'id': 143, 'pip_position': 4, 'name': '143', 'bid_volume': 0, 'ask_volume': 0}, 104 | 144: {'id': 144, 'pip_position': 4, 'name': '144', 'bid_volume': 0, 'ask_volume': 0}, 105 | 145: {'id': 145, 'pip_position': 4, 'name': '145', 'bid_volume': 0, 'ask_volume': 0}, 106 | 146: {'id': 146, 'pip_position': 4, 'name': '146', 'bid_volume': 0, 'ask_volume': 0}, 107 | 147: {'id': 147, 'pip_position': 4, 'name': '147', 'bid_volume': 0, 'ask_volume': 0}, 108 | 148: {'id': 148, 'pip_position': 4, 'name': '148', 'bid_volume': 0, 'ask_volume': 0}, 109 | 206: {'id': 206, 'pip_position': 4, 'name': '206', 'bid_volume': 0, 'ask_volume': 0}, 110 | } 111 | } 112 | --------------------------------------------------------------------------------