├── .gitignore ├── pylintrc ├── README.md ├── strategy.py ├── pubnub_light.py ├── websocket.py ├── goxtool.py └── goxapi.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.ini 3 | *.pyc 4 | *.swp 5 | .ropeproject 6 | _* 7 | .project 8 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [REPORTS] 2 | output-format=parseable 3 | include-ids=yes 4 | reports=no 5 | 6 | [MESSAGES CONTROL] 7 | disable=I0011 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #goxtool.py 2 | 3 | Goxtool is a trading client for the MtGox Bitcon currency exchange. It is 4 | designed to work in the Linux console (it has a curses user interface). It 5 | can display live streaming market data and you can buy and sell with 6 | keyboard commands. 7 | 8 | Goxtool also has a simple interface to plug in your own automated trading 9 | strategies, your own code can be (re)loded at runtime, it will receive 10 | events from the API and can act upon them. 11 | 12 | The user manual is here: 13 | [http://prof7bit.github.com/goxtool/](http://prof7bit.github.com/goxtool/) 14 | 15 | -------------------------------------------------------------------------------- /strategy.py: -------------------------------------------------------------------------------- 1 | """ 2 | trading robot breadboard 3 | """ 4 | 5 | import goxapi 6 | 7 | class Strategy(goxapi.BaseObject): 8 | # pylint: disable=C0111,W0613,R0201 9 | 10 | def __init__(self, gox): 11 | goxapi.BaseObject.__init__(self) 12 | self.signal_debug.connect(gox.signal_debug) 13 | gox.signal_keypress.connect(self.slot_keypress) 14 | gox.signal_strategy_unload.connect(self.slot_before_unload) 15 | gox.signal_ticker.connect(self.slot_tick) 16 | gox.signal_depth.connect(self.slot_depth) 17 | gox.signal_trade.connect(self.slot_trade) 18 | gox.signal_userorder.connect(self.slot_userorder) 19 | gox.orderbook.signal_owns_changed.connect(self.slot_owns_changed) 20 | gox.history.signal_changed.connect(self.slot_history_changed) 21 | gox.signal_wallet.connect(self.slot_wallet_changed) 22 | self.gox = gox 23 | self.name = "%s.%s" % \ 24 | (self.__class__.__module__, self.__class__.__name__) 25 | self.debug("%s loaded" % self.name) 26 | 27 | def __del__(self): 28 | """the strategy object will be garbage collected now, this mainly 29 | only exists to produce the log message, so you can make sure it 30 | really garbage collects and won't stay in memory on reload. If you 31 | don't see this log mesage on reload then you have circular references""" 32 | self.debug("%s unloaded" % self.name) 33 | 34 | def slot_before_unload(self, _sender, _data): 35 | """the strategy is about to be unloaded. Use this signal to persist 36 | any state and also use it to forcefully destroy any circular references 37 | to allow it to be properly garbage collected (you might need to do 38 | this if you instantiated linked lists or similar structures, the 39 | symptom would be that you don't see the 'unloaded' message above.""" 40 | pass 41 | 42 | def slot_keypress(self, gox, (key)): 43 | """a key in has been pressed (only a..z without "q" and "l") 44 | The argument key contains the ascii code. To react to a certain 45 | key use something like if key == ord('a') 46 | """ 47 | pass 48 | 49 | def slot_tick(self, gox, (bid, ask)): 50 | """a tick message has been received from the streaming API""" 51 | pass 52 | 53 | def slot_depth(self, gox, (typ, price, volume, total_volume)): 54 | """a depth message has been received. Use this only if you want to 55 | keep track of the depth and orderbook updates yourself or if you 56 | for example want to log all depth messages to a database. This 57 | signal comes directly from the streaming API and the gox.orderbook 58 | might not yet be updated at this time.""" 59 | pass 60 | 61 | def slot_trade(self, gox, (date, price, volume, typ, own)): 62 | """a trade message has been received. Note that this signal comes 63 | directly from the streaming API, it might come before orderbook.owns 64 | list has been updated, don't rely on the own orders and wallet already 65 | having been updated when this is fired.""" 66 | pass 67 | 68 | def slot_userorder(self, gox, (price, volume, typ, oid, status)): 69 | """this comes directly from the API and owns list might not yet be 70 | updated, if you need the new owns list then use slot_owns_changed""" 71 | pass 72 | 73 | def slot_owns_changed(self, orderbook, _dummy): 74 | """this comes *after* userorder and orderbook.owns is updated already. 75 | Also note that this signal is sent by the orderbook object, not by gox, 76 | so the sender argument is orderbook and not gox. This signal might be 77 | useful if you want to detect whether an order has been filled, you 78 | count open orders, count pending orders and compare with last count""" 79 | pass 80 | 81 | def slot_wallet_changed(self, gox, _dummy): 82 | """this comes after the wallet has been updated. Access the new balances 83 | like so: gox.wallet[gox.curr_base] or gox.wallet[gox.curr_quote] and use 84 | gox.base2float() or gox.quote2float() if you need float values. You can 85 | also access balances from other currenies like gox.wallet["JPY"] but it 86 | is not guaranteed that they exist if you never had a balance in that 87 | particular currency. Always test for their existence first. Note that 88 | there will be multiple wallet signals after every trade. You can look 89 | into gox.msg to inspect the original server message that triggered this 90 | signal to filter the flood a little bit.""" 91 | pass 92 | 93 | def slot_history_changed(self, history, _dummy): 94 | """this is fired whenever a new trade is inserted into the history, 95 | you can also use this to query the close price of the most recent 96 | candle which is effectvely the price of the last trade message. 97 | Contrary to the slot_trade this also fires when streaming API 98 | reconnects and re-downloads the trade history, you can use this 99 | to implement a stoploss or you could also use it for example to detect 100 | when a new candle is opened""" 101 | pass 102 | -------------------------------------------------------------------------------- /pubnub_light.py: -------------------------------------------------------------------------------- 1 | """pubnub light API (only subscribe, not publish)""" 2 | 3 | # Copyright (c) 2013 Bernd Kreuss 4 | # 5 | # This program is free software; you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation; either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program; if not, write to the Free Software 17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 18 | # MA 02110-1301, USA. 19 | 20 | import base64 21 | from Crypto.Cipher import AES 22 | import gzip 23 | import hashlib 24 | import io 25 | import json 26 | import socket 27 | import ssl 28 | import uuid 29 | 30 | class SocketClosedException(Exception): 31 | """raised when socket read fails. This normally happens when the 32 | hup() method is invoked, your thread that loops over read() should 33 | catch this exception and then decide whether to retry or terminate""" 34 | pass 35 | 36 | 37 | class PubNub(): #pylint: disable=R0902 38 | """implements a simple pubnub client that tries to stay connected 39 | and is interruptible immediately (using socket instead of urllib2). 40 | This client supports multiplexing, SSL and gzip compression.""" 41 | def __init__(self): 42 | self.sock = None 43 | self.uuid = uuid.uuid4() 44 | self.timestamp = 0 45 | self.connected = False 46 | self.sub = "" 47 | self.chan = "" 48 | self.auth = "" 49 | self.cipher = "" 50 | self.use_ssl = False 51 | 52 | #pylint: disable=R0913 53 | def subscribe(self, sub, chan, auth="", cipher="", use_ssl=False): 54 | """set the subscription parameters. This is needed after __init__(), 55 | chan is a string containing a channel name or a comma separated list of 56 | multiple cannels, it will replace all previously set subscriptions.""" 57 | self.sub = sub 58 | self.chan = chan 59 | self.auth = auth 60 | self.cipher = cipher 61 | self.use_ssl = use_ssl 62 | 63 | # force disconnect of currently active longpoll. 64 | self.hup() 65 | 66 | def read(self): 67 | """read (blocking) and return list of messages. Each message in the 68 | list a tuple of (channel, msg) where channel is the name of the channel 69 | the message came from and msg is the payload. Right after subscribe() 70 | you should enter a loop over this blocking read() call to read messages 71 | from the subscribed channels. It will raise an exception if interrupted 72 | (for example by hup() or by subscribe() or if something goes wrong), 73 | so you should catch exceptions and then decide whether to re-enter your 74 | loop because you merely called subscribe() again or whether you want 75 | to terminate because your application ends. 76 | """ 77 | try: 78 | if not self.connected: 79 | self._connect() 80 | 81 | (length, encoding, chunked) = self._send_request() 82 | 83 | if chunked: 84 | data = self._read_chunked() 85 | else: 86 | data = self._read_num_bytes(length) 87 | 88 | if encoding == "gzip": 89 | data = self._unzip(data) 90 | 91 | data = json.loads(data) 92 | self.timestamp = int(data[1]) 93 | if len(data[0]): 94 | if self.cipher: 95 | msg_list = [self._decrypt(m) for m in data[0]] 96 | else: 97 | msg_list = data[0] 98 | 99 | if len(data) > 2: 100 | chan_list = data[2].split(",") 101 | else: 102 | chan_list = [self.chan for m in msg_list] 103 | 104 | return zip(chan_list, msg_list) 105 | else: 106 | return [] 107 | 108 | except: 109 | self.connected = False 110 | self.sock.close() 111 | raise 112 | 113 | def hup(self): 114 | """close socket and force the blocking read() to exit with an Exception. 115 | Usually the thread in your app that does the read() will then have 116 | the opportunity to decide whether to re-enter the read() because you 117 | only set new subscription parameters or to terminate because you want 118 | to shut down the client completely.""" 119 | if self.sock: 120 | self.connected = False 121 | self.sock.shutdown(2) 122 | self.sock.close() 123 | 124 | def _connect(self): 125 | """connect and set self.connected flag, raise exception if error. 126 | This method is used internally, you don't explicitly call it yourself, 127 | the read() method will invoke it automatically if necessary.""" 128 | self.sock = socket.socket() 129 | host = "pubsub.pubnub.com" 130 | port = 80 131 | if self.use_ssl: 132 | self.sock = ssl.wrap_socket(self.sock) 133 | port = 443 134 | self.sock.connect((host, port)) 135 | self.connected = True 136 | 137 | def _send_request(self): 138 | """send http request, read response header and return 139 | response header info tuple (see: _read_response_header).""" 140 | headers = [ 141 | "GET /subscribe/%s/%s/0/%i?uuid=%s&auth=%s HTTP/1.1" \ 142 | % (self.sub, self.chan, self.timestamp, self.uuid, self.auth), 143 | "Accept-Encoding: gzip", 144 | "Host: pubsub.pubnub.com", 145 | "Connection: keep-alive"] 146 | str_headers = "%s\r\n\r\n" % "\r\n".join(headers) 147 | self.sock.send(str_headers) 148 | return self._read_response_header() 149 | 150 | def _read_response_header(self): 151 | """read the http response header and return a tuple containing 152 | the values (length, encoding, chunked) which will be needed to 153 | correctly read and interpret the rest of the response.""" 154 | length = None 155 | encoding = "identity" 156 | chunked = False 157 | 158 | hdr = [] 159 | while True: 160 | line = self._read_line() 161 | if not line: 162 | break 163 | hdr.append(line) 164 | 165 | for line in hdr: 166 | if "Content-Length" in line: 167 | length = int(line[15:]) 168 | if "Content-Encoding" in line: 169 | encoding = line[17:].strip() 170 | if "Transfer-Encoding: chunked" in line: 171 | chunked = True 172 | 173 | return (length, encoding, chunked) 174 | 175 | def _read_line(self): 176 | """read one line from socket until and including CRLF, return stripped 177 | line or raise SocketClosedException if socket was closed""" 178 | line = "" 179 | while not line[-2:] == "\r\n": 180 | char = self.sock.recv(1) 181 | if not char: 182 | raise SocketClosedException 183 | line += char 184 | return line.strip() 185 | 186 | def _read_num_bytes(self, num): 187 | """read (blocking) exactly num bytes from socket, 188 | raise SocketClosedException if the socket is closed.""" 189 | buf = "" 190 | while len(buf) < num: 191 | chunk = self.sock.recv(num - len(buf)) 192 | if not chunk: 193 | raise SocketClosedException 194 | buf += chunk 195 | return buf 196 | 197 | def _read_chunked(self): 198 | """read chunked transfer encoding""" 199 | buf = "" 200 | size = 1 201 | while size: 202 | size = int(self._read_line(), 16) 203 | buf += self._read_num_bytes(size) 204 | self._read_num_bytes(2) # CRLF 205 | return buf 206 | 207 | #pylint: disable=R0201 208 | def _unzip(self, data): 209 | """unzip the gzip content encoding""" 210 | with io.BytesIO(data) as buf: 211 | with gzip.GzipFile(fileobj=buf) as unzipped: 212 | return unzipped.read() 213 | 214 | def _decrypt(self, msg): 215 | """decrypt a single pubnub message""" 216 | # they must be real crypto experts at pubnub.com 217 | # two lines of code and two capital mistakes :-( 218 | # pylint: disable=E1101 219 | key = hashlib.sha256(self.cipher).hexdigest()[0:32] 220 | aes = AES.new(key, AES.MODE_CBC, "0123456789012345") 221 | decrypted = aes.decrypt(base64.decodestring(msg)) 222 | return json.loads(decrypted[0:-ord(decrypted[-1])]) 223 | -------------------------------------------------------------------------------- /websocket.py: -------------------------------------------------------------------------------- 1 | """ 2 | websocket - WebSocket client library for Python 3 | 4 | Copyright (C) 2010 Hiroki Ohtani(liris) 5 | 6 | This library is free software; you can redistribute it and/or 7 | modify it under the terms of the GNU Lesser General Public 8 | License as published by the Free Software Foundation; either 9 | version 2.1 of the License, or (at your option) any later version. 10 | 11 | This library is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 14 | Lesser General Public License for more details. 15 | 16 | You should have received a copy of the GNU Lesser General Public 17 | License along with this library; if not, write to the Free Software 18 | Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 19 | 20 | """ 21 | 22 | 23 | import socket 24 | from urlparse import urlparse 25 | import os 26 | import array 27 | import struct 28 | import uuid 29 | import hashlib 30 | import base64 31 | import logging 32 | 33 | """ 34 | websocket python client. 35 | ========================= 36 | 37 | This version support only hybi-13. 38 | Please see http://tools.ietf.org/html/rfc6455 for protocol. 39 | """ 40 | 41 | 42 | # websocket supported version. 43 | VERSION = 13 44 | 45 | # closing frame status codes. 46 | STATUS_NORMAL = 1000 47 | STATUS_GOING_AWAY = 1001 48 | STATUS_PROTOCOL_ERROR = 1002 49 | STATUS_UNSUPPORTED_DATA_TYPE = 1003 50 | STATUS_STATUS_NOT_AVAILABLE = 1005 51 | STATUS_ABNORMAL_CLOSED = 1006 52 | STATUS_INVALID_PAYLOAD = 1007 53 | STATUS_POLICY_VIOLATION = 1008 54 | STATUS_MESSAGE_TOO_BIG = 1009 55 | STATUS_INVALID_EXTENSION = 1010 56 | STATUS_UNEXPECTED_CONDITION = 1011 57 | STATUS_TLS_HANDSHAKE_ERROR = 1015 58 | 59 | logger = logging.getLogger() 60 | 61 | 62 | class WebSocketException(Exception): 63 | """ 64 | websocket exeception class. 65 | """ 66 | pass 67 | 68 | 69 | class WebSocketConnectionClosedException(WebSocketException): 70 | """ 71 | If remote host closed the connection or some network error happened, 72 | this exception will be raised. 73 | """ 74 | pass 75 | 76 | default_timeout = None 77 | traceEnabled = False 78 | 79 | 80 | def enableTrace(tracable): 81 | """ 82 | turn on/off the tracability. 83 | 84 | tracable: boolean value. if set True, tracability is enabled. 85 | """ 86 | global traceEnabled 87 | traceEnabled = tracable 88 | if tracable: 89 | if not logger.handlers: 90 | logger.addHandler(logging.StreamHandler()) 91 | logger.setLevel(logging.DEBUG) 92 | 93 | 94 | def setdefaulttimeout(timeout): 95 | """ 96 | Set the global timeout setting to connect. 97 | 98 | timeout: default socket timeout time. This value is second. 99 | """ 100 | global default_timeout 101 | default_timeout = timeout 102 | 103 | 104 | def getdefaulttimeout(): 105 | """ 106 | Return the global timeout setting(second) to connect. 107 | """ 108 | return default_timeout 109 | 110 | 111 | def _parse_url(url): 112 | """ 113 | parse url and the result is tuple of 114 | (hostname, port, resource path and the flag of secure mode) 115 | 116 | url: url string. 117 | """ 118 | if ":" not in url: 119 | raise ValueError("url is invalid") 120 | 121 | scheme, url = url.split(":", 1) 122 | 123 | parsed = urlparse(url, scheme="http") 124 | if parsed.hostname: 125 | hostname = parsed.hostname 126 | else: 127 | raise ValueError("hostname is invalid") 128 | port = 0 129 | if parsed.port: 130 | port = parsed.port 131 | 132 | is_secure = False 133 | if scheme == "ws": 134 | if not port: 135 | port = 80 136 | elif scheme == "wss": 137 | is_secure = True 138 | if not port: 139 | port = 443 140 | else: 141 | raise ValueError("scheme %s is invalid" % scheme) 142 | 143 | if parsed.path: 144 | resource = parsed.path 145 | else: 146 | resource = "/" 147 | 148 | if parsed.query: 149 | resource += "?" + parsed.query 150 | 151 | return (hostname, port, resource, is_secure) 152 | 153 | 154 | def create_connection(url, timeout=None, **options): 155 | """ 156 | connect to url and return websocket object. 157 | 158 | Connect to url and return the WebSocket object. 159 | Passing optional timeout parameter will set the timeout on the socket. 160 | If no timeout is supplied, the global default timeout setting returned by getdefauttimeout() is used. 161 | You can customize using 'options'. 162 | If you set "header" dict object, you can set your own custom header. 163 | 164 | >>> conn = create_connection("ws://echo.websocket.org/", 165 | ... header={"User-Agent: MyProgram", 166 | ... "x-custom: header"}) 167 | 168 | 169 | timeout: socket timeout time. This value is integer. 170 | if you set None for this value, it means "use default_timeout value" 171 | 172 | options: current support option is only "header". 173 | if you set header as dict value, the custom HTTP headers are added. 174 | """ 175 | websock = WebSocket() 176 | websock.settimeout(timeout != None and timeout or default_timeout) 177 | websock.connect(url, **options) 178 | return websock 179 | 180 | _MAX_INTEGER = (1 << 32) -1 181 | _AVAILABLE_KEY_CHARS = range(0x21, 0x2f + 1) + range(0x3a, 0x7e + 1) 182 | _MAX_CHAR_BYTE = (1<<8) -1 183 | 184 | # ref. Websocket gets an update, and it breaks stuff. 185 | # http://axod.blogspot.com/2010/06/websocket-gets-update-and-it-breaks.html 186 | 187 | 188 | def _create_sec_websocket_key(): 189 | uid = uuid.uuid4() 190 | return base64.encodestring(uid.bytes).strip() 191 | 192 | _HEADERS_TO_CHECK = { 193 | "upgrade": "websocket", 194 | "connection": "upgrade", 195 | } 196 | 197 | 198 | class _SSLSocketWrapper(object): 199 | def __init__(self, sock): 200 | self.ssl = socket.ssl(sock) 201 | 202 | def recv(self, bufsize): 203 | return self.ssl.read(bufsize) 204 | 205 | def send(self, payload): 206 | return self.ssl.write(payload) 207 | 208 | _BOOL_VALUES = (0, 1) 209 | 210 | 211 | def _is_bool(*values): 212 | for v in values: 213 | if v not in _BOOL_VALUES: 214 | return False 215 | 216 | return True 217 | 218 | 219 | class ABNF(object): 220 | """ 221 | ABNF frame class. 222 | see http://tools.ietf.org/html/rfc5234 223 | and http://tools.ietf.org/html/rfc6455#section-5.2 224 | """ 225 | 226 | # operation code values. 227 | OPCODE_TEXT = 0x1 228 | OPCODE_BINARY = 0x2 229 | OPCODE_CLOSE = 0x8 230 | OPCODE_PING = 0x9 231 | OPCODE_PONG = 0xa 232 | 233 | # available operation code value tuple 234 | OPCODES = (OPCODE_TEXT, OPCODE_BINARY, OPCODE_CLOSE, 235 | OPCODE_PING, OPCODE_PONG) 236 | 237 | # opcode human readable string 238 | OPCODE_MAP = { 239 | OPCODE_TEXT: "text", 240 | OPCODE_BINARY: "binary", 241 | OPCODE_CLOSE: "close", 242 | OPCODE_PING: "ping", 243 | OPCODE_PONG: "pong" 244 | } 245 | 246 | # data length threashold. 247 | LENGTH_7 = 0x7d 248 | LENGTH_16 = 1 << 16 249 | LENGTH_63 = 1 << 63 250 | 251 | def __init__(self, fin = 0, rsv1 = 0, rsv2 = 0, rsv3 = 0, 252 | opcode = OPCODE_TEXT, mask = 1, data = ""): 253 | """ 254 | Constructor for ABNF. 255 | please check RFC for arguments. 256 | """ 257 | self.fin = fin 258 | self.rsv1 = rsv1 259 | self.rsv2 = rsv2 260 | self.rsv3 = rsv3 261 | self.opcode = opcode 262 | self.mask = mask 263 | self.data = data 264 | self.get_mask_key = os.urandom 265 | 266 | @staticmethod 267 | def create_frame(data, opcode): 268 | """ 269 | create frame to send text, binary and other data. 270 | 271 | data: data to send. This is string value(byte array). 272 | if opcode is OPCODE_TEXT and this value is uniocde, 273 | data value is conveted into unicode string, automatically. 274 | 275 | opcode: operation code. please see OPCODE_XXX. 276 | """ 277 | if opcode == ABNF.OPCODE_TEXT and isinstance(data, unicode): 278 | data = data.encode("utf-8") 279 | # mask must be set if send data from client 280 | return ABNF(1, 0, 0, 0, opcode, 1, data) 281 | 282 | def format(self): 283 | """ 284 | format this object to string(byte array) to send data to server. 285 | """ 286 | if not _is_bool(self.fin, self.rsv1, self.rsv2, self.rsv3): 287 | raise ValueError("not 0 or 1") 288 | if self.opcode not in ABNF.OPCODES: 289 | raise ValueError("Invalid OPCODE") 290 | length = len(self.data) 291 | if length >= ABNF.LENGTH_63: 292 | raise ValueError("data is too long") 293 | 294 | frame_header = chr(self.fin << 7 295 | | self.rsv1 << 6 | self.rsv2 << 5 | self.rsv3 << 4 296 | | self.opcode) 297 | if length < ABNF.LENGTH_7: 298 | frame_header += chr(self.mask << 7 | length) 299 | elif length < ABNF.LENGTH_16: 300 | frame_header += chr(self.mask << 7 | 0x7e) 301 | frame_header += struct.pack("!H", length) 302 | else: 303 | frame_header += chr(self.mask << 7 | 0x7f) 304 | frame_header += struct.pack("!Q", length) 305 | 306 | if not self.mask: 307 | return frame_header + self.data 308 | else: 309 | mask_key = self.get_mask_key(4) 310 | return frame_header + self._get_masked(mask_key) 311 | 312 | def _get_masked(self, mask_key): 313 | s = ABNF.mask(mask_key, self.data) 314 | return mask_key + "".join(s) 315 | 316 | @staticmethod 317 | def mask(mask_key, data): 318 | """ 319 | mask or unmask data. Just do xor for each byte 320 | 321 | mask_key: 4 byte string(byte). 322 | 323 | data: data to mask/unmask. 324 | """ 325 | _m = array.array("B", mask_key) 326 | _d = array.array("B", data) 327 | for i in xrange(len(_d)): 328 | _d[i] ^= _m[i % 4] 329 | return _d.tostring() 330 | 331 | 332 | class WebSocket(object): 333 | """ 334 | Low level WebSocket interface. 335 | This class is based on 336 | The WebSocket protocol draft-hixie-thewebsocketprotocol-76 337 | http://tools.ietf.org/html/draft-hixie-thewebsocketprotocol-76 338 | 339 | We can connect to the websocket server and send/recieve data. 340 | The following example is a echo client. 341 | 342 | >>> import websocket 343 | >>> ws = websocket.WebSocket() 344 | >>> ws.connect("ws://echo.websocket.org") 345 | >>> ws.send("Hello, Server") 346 | >>> ws.recv() 347 | 'Hello, Server' 348 | >>> ws.close() 349 | 350 | get_mask_key: a callable to produce new mask keys, see the set_mask_key 351 | function's docstring for more details 352 | """ 353 | 354 | def __init__(self, get_mask_key = None): 355 | """ 356 | Initalize WebSocket object. 357 | """ 358 | self.connected = False 359 | self.io_sock = self.sock = socket.socket() 360 | self.get_mask_key = get_mask_key 361 | 362 | def set_mask_key(self, func): 363 | """ 364 | set function to create musk key. You can custumize mask key generator. 365 | Mainly, this is for testing purpose. 366 | 367 | func: callable object. the fuct must 1 argument as integer. 368 | The argument means length of mask key. 369 | This func must be return string(byte array), 370 | which length is argument specified. 371 | """ 372 | self.get_mask_key = func 373 | 374 | def settimeout(self, timeout): 375 | """ 376 | Set the timeout to the websocket. 377 | 378 | timeout: timeout time(second). 379 | """ 380 | self.sock.settimeout(timeout) 381 | 382 | def gettimeout(self): 383 | """ 384 | Get the websocket timeout(second). 385 | """ 386 | return self.sock.gettimeout() 387 | 388 | def connect(self, url, **options): 389 | """ 390 | Connect to url. url is websocket url scheme. ie. ws://host:port/resource 391 | You can customize using 'options'. 392 | If you set "header" dict object, you can set your own custom header. 393 | 394 | >>> ws = WebSocket() 395 | >>> ws.connect("ws://echo.websocket.org/", 396 | ... header={"User-Agent: MyProgram", 397 | ... "x-custom: header"}) 398 | 399 | timeout: socket timeout time. This value is integer. 400 | if you set None for this value, 401 | it means "use default_timeout value" 402 | 403 | options: current support option is only "header". 404 | if you set header as dict value, 405 | the custom HTTP headers are added. 406 | 407 | """ 408 | hostname, port, resource, is_secure = _parse_url(url) 409 | # TODO: we need to support proxy 410 | self.sock.connect((hostname, port)) 411 | if is_secure: 412 | self.io_sock = _SSLSocketWrapper(self.sock) 413 | self._handshake(hostname, port, resource, **options) 414 | 415 | def _handshake(self, host, port, resource, **options): 416 | sock = self.io_sock 417 | headers = [] 418 | headers.append("GET %s HTTP/1.1" % resource) 419 | headers.append("Upgrade: websocket") 420 | headers.append("Connection: Upgrade") 421 | if port == 80: 422 | hostport = host 423 | else: 424 | hostport = "%s:%d" % (host, port) 425 | headers.append("Host: %s" % hostport) 426 | 427 | if "origin" in options: 428 | headers.append("Origin: %s" % options["origin"]) 429 | else: 430 | headers.append("Origin: %s" % hostport) 431 | 432 | key = _create_sec_websocket_key() 433 | headers.append("Sec-WebSocket-Key: %s" % key) 434 | headers.append("Sec-WebSocket-Version: %s" % VERSION) 435 | if "header" in options: 436 | headers.extend(options["header"]) 437 | 438 | headers.append("") 439 | headers.append("") 440 | 441 | header_str = "\r\n".join(headers) 442 | sock.send(header_str) 443 | if traceEnabled: 444 | logger.debug("--- request header ---") 445 | logger.debug(header_str) 446 | logger.debug("-----------------------") 447 | 448 | status, resp_headers = self._read_headers() 449 | if status != 101: 450 | self.close() 451 | raise WebSocketException("Handshake Status %d" % status) 452 | 453 | success = self._validate_header(resp_headers, key) 454 | if not success: 455 | self.close() 456 | raise WebSocketException("Invalid WebSocket Header") 457 | 458 | self.connected = True 459 | 460 | def _validate_header(self, headers, key): 461 | for k, v in _HEADERS_TO_CHECK.iteritems(): 462 | r = headers.get(k, None) 463 | if not r: 464 | return False 465 | r = r.lower() 466 | if v != r: 467 | return False 468 | 469 | result = headers.get("sec-websocket-accept", None) 470 | if not result: 471 | return False 472 | result = result.lower() 473 | 474 | value = key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" 475 | hashed = base64.encodestring(hashlib.sha1(value).digest()).strip().lower() 476 | return hashed == result 477 | 478 | def _read_headers(self): 479 | status = None 480 | headers = {} 481 | if traceEnabled: 482 | logger.debug("--- response header ---") 483 | 484 | while True: 485 | line = self._recv_line() 486 | if line == "\r\n": 487 | break 488 | line = line.strip() 489 | if traceEnabled: 490 | logger.debug(line) 491 | if not status: 492 | status_info = line.split(" ", 2) 493 | status = int(status_info[1]) 494 | else: 495 | kv = line.split(":", 1) 496 | if len(kv) == 2: 497 | key, value = kv 498 | headers[key.lower()] = value.strip().lower() 499 | else: 500 | raise WebSocketException("Invalid header") 501 | 502 | if traceEnabled: 503 | logger.debug("-----------------------") 504 | 505 | return status, headers 506 | 507 | def send(self, payload, opcode = ABNF.OPCODE_TEXT): 508 | """ 509 | Send the data as string. 510 | 511 | payload: Payload must be utf-8 string or unicoce, 512 | if the opcode is OPCODE_TEXT. 513 | Otherwise, it must be string(byte array) 514 | 515 | opcode: operation code to send. Please see OPCODE_XXX. 516 | """ 517 | frame = ABNF.create_frame(payload, opcode) 518 | if self.get_mask_key: 519 | frame.get_mask_key = self.get_mask_key 520 | data = frame.format() 521 | self.io_sock.send(data) 522 | if traceEnabled: 523 | logger.debug("send: " + repr(data)) 524 | 525 | def ping(self, payload = ""): 526 | """ 527 | send ping data. 528 | 529 | payload: data payload to send server. 530 | """ 531 | self.send(payload, ABNF.OPCODE_PING) 532 | 533 | def pong(self, payload): 534 | """ 535 | send pong data. 536 | 537 | payload: data payload to send server. 538 | """ 539 | self.send(payload, ABNF.OPCODE_PONG) 540 | 541 | def recv(self): 542 | """ 543 | Receive string data(byte array) from the server. 544 | 545 | return value: string(byte array) value. 546 | """ 547 | opcode, data = self.recv_data() 548 | return data 549 | 550 | def recv_data(self): 551 | """ 552 | Recieve data with operation code. 553 | 554 | return value: tuple of operation code and string(byte array) value. 555 | """ 556 | while True: 557 | frame = self.recv_frame() 558 | if not frame: 559 | # handle error: 560 | # 'NoneType' object has no attribute 'opcode' 561 | raise WebSocketException("Not a valid frame %s" % frame) 562 | elif frame.opcode in (ABNF.OPCODE_TEXT, ABNF.OPCODE_BINARY): 563 | return (frame.opcode, frame.data) 564 | elif frame.opcode == ABNF.OPCODE_CLOSE: 565 | self.send_close() 566 | return (frame.opcode, None) 567 | elif frame.opcode == ABNF.OPCODE_PING: 568 | self.pong(frame.data) 569 | 570 | def recv_frame(self): 571 | """ 572 | recieve data as frame from server. 573 | 574 | return value: ABNF frame object. 575 | """ 576 | header_bytes = self._recv_strict(2) 577 | if not header_bytes: 578 | return None 579 | b1 = ord(header_bytes[0]) 580 | fin = b1 >> 7 & 1 581 | rsv1 = b1 >> 6 & 1 582 | rsv2 = b1 >> 5 & 1 583 | rsv3 = b1 >> 4 & 1 584 | opcode = b1 & 0xf 585 | b2 = ord(header_bytes[1]) 586 | mask = b2 >> 7 & 1 587 | length = b2 & 0x7f 588 | 589 | length_data = "" 590 | if length == 0x7e: 591 | length_data = self._recv_strict(2) 592 | length = struct.unpack("!H", length_data)[0] 593 | elif length == 0x7f: 594 | length_data = self._recv_strict(8) 595 | length = struct.unpack("!Q", length_data)[0] 596 | 597 | mask_key = "" 598 | if mask: 599 | mask_key = self._recv_strict(4) 600 | data = self._recv_strict(length) 601 | if traceEnabled: 602 | recieved = header_bytes + length_data + mask_key + data 603 | logger.debug("recv: " + repr(recieved)) 604 | 605 | if mask: 606 | data = ABNF.mask(mask_key, data) 607 | 608 | frame = ABNF(fin, rsv1, rsv2, rsv3, opcode, mask, data) 609 | return frame 610 | 611 | def send_close(self, status = STATUS_NORMAL, reason = ""): 612 | """ 613 | send close data to the server. 614 | 615 | status: status code to send. see STATUS_XXX. 616 | 617 | reason: the reason to close. This must be string. 618 | """ 619 | if status < 0 or status >= ABNF.LENGTH_16: 620 | raise ValueError("code is invalid range") 621 | self.send(struct.pack('!H', status) + reason, ABNF.OPCODE_CLOSE) 622 | 623 | def close(self, status = STATUS_NORMAL, reason = ""): 624 | """ 625 | Close Websocket object 626 | 627 | status: status code to send. see STATUS_XXX. 628 | 629 | reason: the reason to close. This must be string. 630 | """ 631 | if self.connected: 632 | if status < 0 or status >= ABNF.LENGTH_16: 633 | raise ValueError("code is invalid range") 634 | 635 | try: 636 | self.send(struct.pack('!H', status) + reason, ABNF.OPCODE_CLOSE) 637 | timeout = self.sock.gettimeout() 638 | self.sock.settimeout(3) 639 | try: 640 | frame = self.recv_frame() 641 | if logger.isEnabledFor(logging.DEBUG): 642 | logger.error("close status: " + repr(frame.data)) 643 | except: 644 | pass 645 | self.sock.settimeout(timeout) 646 | self.sock.shutdown(socket.SHUT_RDWR) 647 | except: 648 | pass 649 | self._closeInternal() 650 | 651 | def _closeInternal(self): 652 | self.connected = False 653 | self.sock.close() 654 | self.io_sock = self.sock 655 | 656 | def _recv(self, bufsize): 657 | bytes = self.io_sock.recv(bufsize) 658 | if not bytes: 659 | raise WebSocketConnectionClosedException() 660 | return bytes 661 | 662 | def _recv_strict(self, bufsize): 663 | remaining = bufsize 664 | bytes = "" 665 | while remaining: 666 | bytes += self._recv(remaining) 667 | remaining = bufsize - len(bytes) 668 | 669 | return bytes 670 | 671 | def _recv_line(self): 672 | line = [] 673 | while True: 674 | c = self._recv(1) 675 | line.append(c) 676 | if c == "\n": 677 | break 678 | return "".join(line) 679 | 680 | 681 | class WebSocketApp(object): 682 | """ 683 | Higher level of APIs are provided. 684 | The interface is like JavaScript WebSocket object. 685 | """ 686 | def __init__(self, url, 687 | on_open = None, on_message = None, on_error = None, 688 | on_close = None, keep_running = True, get_mask_key = None): 689 | """ 690 | url: websocket url. 691 | on_open: callable object which is called at opening websocket. 692 | this function has one argument. The arugment is this class object. 693 | on_message: callbale object which is called when recieved data. 694 | on_message has 2 arguments. 695 | The 1st arugment is this class object. 696 | The passing 2nd arugment is utf-8 string which we get from the server. 697 | on_error: callable object which is called when we get error. 698 | on_error has 2 arguments. 699 | The 1st arugment is this class object. 700 | The passing 2nd arugment is exception object. 701 | on_close: callable object which is called when closed the connection. 702 | this function has one argument. The arugment is this class object. 703 | keep_running: a boolean flag indicating whether the app's main loop should 704 | keep running, defaults to True 705 | get_mask_key: a callable to produce new mask keys, see the WebSocket.set_mask_key's 706 | docstring for more information 707 | """ 708 | self.url = url 709 | self.on_open = on_open 710 | self.on_message = on_message 711 | self.on_error = on_error 712 | self.on_close = on_close 713 | self.keep_running = keep_running 714 | self.get_mask_key = get_mask_key 715 | self.sock = None 716 | 717 | def send(self, data, opcode = ABNF.OPCODE_TEXT): 718 | """ 719 | send message. 720 | data: message to send. If you set opcode to OPCODE_TEXT, data must be utf-8 string or unicode. 721 | opcode: operation code of data. default is OPCODE_TEXT. 722 | """ 723 | if self.sock.send(data, opcode) == 0: 724 | raise WebSocketConnectionClosedException() 725 | 726 | def close(self): 727 | """ 728 | close websocket connection. 729 | """ 730 | self.keep_running = False 731 | self.sock.close() 732 | 733 | def run_forever(self): 734 | """ 735 | run event loop for WebSocket framework. 736 | This loop is infinite loop and is alive during websocket is available. 737 | """ 738 | if self.sock: 739 | raise WebSocketException("socket is already opened") 740 | try: 741 | self.sock = WebSocket(self.get_mask_key) 742 | self.sock.connect(self.url) 743 | self._run_with_no_err(self.on_open) 744 | while self.keep_running: 745 | data = self.sock.recv() 746 | if data is None: 747 | break 748 | self._run_with_no_err(self.on_message, data) 749 | except Exception, e: 750 | self._run_with_no_err(self.on_error, e) 751 | finally: 752 | self.sock.close() 753 | self._run_with_no_err(self.on_close) 754 | self.sock = None 755 | 756 | def _run_with_no_err(self, callback, *args): 757 | if callback: 758 | try: 759 | callback(self, *args) 760 | except Exception, e: 761 | if logger.isEnabledFor(logging.DEBUG): 762 | logger.error(e) 763 | 764 | 765 | if __name__ == "__main__": 766 | enableTrace(True) 767 | ws = create_connection("ws://echo.websocket.org/") 768 | print "Sending 'Hello, World'..." 769 | ws.send("Hello, World") 770 | print "Sent" 771 | print "Receiving..." 772 | result = ws.recv() 773 | print "Received '%s'" % result 774 | ws.close() 775 | -------------------------------------------------------------------------------- /goxtool.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | 3 | """ 4 | Tool to display live MtGox market info and 5 | framework for experimenting with trading bots 6 | """ 7 | # Copyright (c) 2013 Bernd Kreuss 8 | # 9 | # This program is free software; you can redistribute it and/or modify 10 | # it under the terms of the GNU General Public License as published by 11 | # the Free Software Foundation; either version 3 of the License, or 12 | # (at your option) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program; if not, write to the Free Software 21 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 22 | # MA 02110-1301, USA. 23 | 24 | # pylint: disable=C0301,C0302,R0902,R0903,R0912,R0913,R0914,R0915,R0922,W0703 25 | 26 | import argparse 27 | import curses 28 | import curses.panel 29 | import curses.textpad 30 | import goxapi 31 | import logging 32 | import locale 33 | import math 34 | import os 35 | import sys 36 | import time 37 | import traceback 38 | import threading 39 | 40 | sys_out = sys.stdout #pylint: disable=C0103 41 | 42 | # 43 | # 44 | # curses user interface 45 | # 46 | 47 | HEIGHT_STATUS = 2 48 | HEIGHT_CON = 7 49 | WIDTH_ORDERBOOK = 45 50 | 51 | COLORS = [["con_text", curses.COLOR_BLUE, curses.COLOR_CYAN] 52 | ,["con_text_buy", curses.COLOR_BLUE, curses.COLOR_GREEN] 53 | ,["con_text_sell", curses.COLOR_BLUE, curses.COLOR_RED] 54 | ,["status_text", curses.COLOR_BLUE, curses.COLOR_CYAN] 55 | 56 | ,["book_text", curses.COLOR_BLACK, curses.COLOR_CYAN] 57 | ,["book_bid", curses.COLOR_BLACK, curses.COLOR_GREEN] 58 | ,["book_ask", curses.COLOR_BLACK, curses.COLOR_RED] 59 | ,["book_own", curses.COLOR_BLACK, curses.COLOR_YELLOW] 60 | ,["book_vol", curses.COLOR_BLACK, curses.COLOR_CYAN] 61 | 62 | ,["chart_text", curses.COLOR_BLACK, curses.COLOR_WHITE] 63 | ,["chart_up", curses.COLOR_BLACK, curses.COLOR_GREEN] 64 | ,["chart_down", curses.COLOR_BLACK, curses.COLOR_RED] 65 | ,["order_pending", curses.COLOR_BLACK, curses.COLOR_RED] 66 | 67 | ,["dialog_text", curses.COLOR_BLUE, curses.COLOR_CYAN] 68 | ,["dialog_sel", curses.COLOR_CYAN, curses.COLOR_BLUE] 69 | ,["dialog_sel_text", curses.COLOR_BLUE, curses.COLOR_YELLOW] 70 | ,["dialog_sel_sel", curses.COLOR_YELLOW, curses.COLOR_BLUE] 71 | ,["dialog_bid_text", curses.COLOR_GREEN, curses.COLOR_BLACK] 72 | ,["dialog_ask_text", curses.COLOR_RED, curses.COLOR_WHITE] 73 | ] 74 | 75 | INI_DEFAULTS = [["goxtool", "set_xterm_title", "True"] 76 | ,["goxtool", "dont_truncate_logfile", "False"] 77 | ,["goxtool", "show_orderbook_stats", "True"] 78 | ,["goxtool", "highlight_changes", "True"] 79 | ,["goxtool", "orderbook_group", "0"] 80 | ,["goxtool", "orderbook_sum_total", "False"] 81 | ,["goxtool", "display_right", "history_chart"] 82 | ,["goxtool", "depth_chart_group", "1"] 83 | ,["goxtool", "depth_chart_sum_total", "True"] 84 | ,["goxtool", "show_ticker", "True"] 85 | ,["goxtool", "show_depth", "True"] 86 | ,["goxtool", "show_trade", "True"] 87 | ,["goxtool", "show_trade_own", "True"] 88 | ] 89 | 90 | COLOR_PAIR = {} 91 | 92 | def init_colors(): 93 | """initialize curses color pairs and give them names. The color pair 94 | can then later quickly be retrieved from the COLOR_PAIR[] dict""" 95 | index = 1 96 | for (name, back, fore) in COLORS: 97 | if curses.has_colors(): 98 | curses.init_pair(index, fore, back) 99 | COLOR_PAIR[name] = curses.color_pair(index) 100 | else: 101 | COLOR_PAIR[name] = 0 102 | index += 1 103 | 104 | def dump_all_stacks(): 105 | """dump a stack trace for all running threads for debugging purpose""" 106 | 107 | def get_name(thread_id): 108 | """return the human readable name that was assigned to a thread""" 109 | for thread in threading.enumerate(): 110 | if thread.ident == thread_id: 111 | return thread.name 112 | 113 | ret = "\n# Full stack trace of all running threads:\n" 114 | #pylint: disable=W0212 115 | for thread_id, stack in sys._current_frames().items(): 116 | ret += "\n# %s (%s)\n" % (get_name(thread_id), thread_id) 117 | for filename, lineno, name, line in traceback.extract_stack(stack): 118 | ret += 'File: "%s", line %d, in %s\n' % (filename, lineno, name) 119 | if line: 120 | ret += " %s\n" % (line.strip()) 121 | return ret 122 | 123 | def try_get_lock_or_break_open(): 124 | """this is an ugly hack to workaround possible deadlock problems. 125 | It is used during shutdown to make sure we can properly exit even when 126 | some slot is stuck (due to a programming error) and won't release the lock. 127 | If we can't acquire it within 2 seconds we just break it open forcefully.""" 128 | #pylint: disable=W0212 129 | time_end = time.time() + 2 130 | while time.time() < time_end: 131 | if goxapi.Signal._lock.acquire(False): 132 | return 133 | time.sleep(0.001) 134 | 135 | # something keeps holding the lock, apparently some slot is stuck 136 | # in an infinite loop. In order to be able to shut down anyways 137 | # we just throw away that lock and replace it with a new one 138 | lock = threading.RLock() 139 | lock.acquire() 140 | goxapi.Signal._lock = lock 141 | print "### could not acquire signal lock, frozen slot somewhere?" 142 | print "### please see the stacktrace log to determine the cause." 143 | 144 | class Win: 145 | """represents a curses window""" 146 | # pylint: disable=R0902 147 | 148 | def __init__(self, stdscr): 149 | """create and initialize the window. This will also subsequently 150 | call the paint() method.""" 151 | self.stdscr = stdscr 152 | self.posx = 0 153 | self.posy = 0 154 | self.width = 10 155 | self.height = 10 156 | self.termwidth = 10 157 | self.termheight = 10 158 | self.win = None 159 | self.panel = None 160 | self.__create_win() 161 | 162 | def __del__(self): 163 | del self.panel 164 | del self.win 165 | curses.panel.update_panels() 166 | curses.doupdate() 167 | 168 | def calc_size(self): 169 | """override this method to change posx, posy, width, height. 170 | It will be called before window creation and on resize.""" 171 | pass 172 | 173 | def do_paint(self): 174 | """call this if you want the window to repaint itself""" 175 | curses.curs_set(0) 176 | if self.win: 177 | self.paint() 178 | self.done_paint() 179 | 180 | # method could be a function - pylint: disable=R0201 181 | def done_paint(self): 182 | """update the sreen after paint operations, this will invoke all 183 | necessary stuff to refresh all (possibly overlapping) windows in 184 | the right order and then push it to the screen""" 185 | curses.panel.update_panels() 186 | curses.doupdate() 187 | 188 | def paint(self): 189 | """paint the window. Override this with your own implementation. 190 | This method must paint the entire window contents from scratch. 191 | It is automatically called after the window has been initially 192 | created and also after every resize. Call it explicitly when 193 | your data has changed and must be displayed""" 194 | pass 195 | 196 | def resize(self): 197 | """You must call this method from your main loop when the 198 | terminal has been resized. It will subsequently make it 199 | recalculate its own new size and then call its paint() method""" 200 | del self.win 201 | self.__create_win() 202 | 203 | def addstr(self, *args): 204 | """drop-in replacement for addstr that will never raie exceptions 205 | and that will cut off at end of line instead of wrapping""" 206 | if len(args) > 0: 207 | line, col = self.win.getyx() 208 | string = args[0] 209 | attr = 0 210 | if len(args) > 1: 211 | attr = args[1] 212 | if len(args) > 2: 213 | line, col, string = args[:3] 214 | attr = 0 215 | if len(args) > 3: 216 | attr = args[3] 217 | if line >= self.height: 218 | return 219 | space_left = self.width - col - 1 #always omit last column, avoids problems. 220 | if space_left <= 0: 221 | return 222 | self.win.addstr(line, col, string[:space_left], attr) 223 | 224 | def addch(self, posy, posx, character, color_pair): 225 | """place a character but don't throw error in lower right corner""" 226 | if posy < 0 or posy > self.height - 1: 227 | return 228 | if posx < 0 or posx > self.width - 1: 229 | return 230 | if posx == self.width - 1 and posy == self.height - 1: 231 | return 232 | self.win.addch(posy, posx, character, color_pair) 233 | 234 | def __create_win(self): 235 | """create the window. This will also be called on every resize, 236 | windows won't be moved, they will be deleted and recreated.""" 237 | self.__calc_size() 238 | try: 239 | self.win = curses.newwin(self.height, self.width, self.posy, self.posx) 240 | self.panel = curses.panel.new_panel(self.win) 241 | self.win.scrollok(True) 242 | self.win.keypad(1) 243 | self.do_paint() 244 | except Exception: 245 | self.win = None 246 | self.panel = None 247 | 248 | def __calc_size(self): 249 | """calculate the default values for positionand size. By default 250 | this will result in a window covering the entire terminal. 251 | Implement the calc_size() method (which will be called afterwards) 252 | to change (some of) these values according to your needs.""" 253 | maxyx = self.stdscr.getmaxyx() 254 | self.termwidth = maxyx[1] 255 | self.termheight = maxyx[0] 256 | self.posx = 0 257 | self.posy = 0 258 | self.width = self.termwidth 259 | self.height = self.termheight 260 | self.calc_size() 261 | 262 | 263 | class WinConsole(Win): 264 | """The console window at the bottom""" 265 | def __init__(self, stdscr, gox): 266 | """create the console window and connect it to the Gox debug 267 | callback function""" 268 | self.gox = gox 269 | gox.signal_debug.connect(self.slot_debug) 270 | Win.__init__(self, stdscr) 271 | 272 | def paint(self): 273 | """just empty the window after resize (I am lazy)""" 274 | self.win.bkgd(" ", COLOR_PAIR["con_text"]) 275 | 276 | def resize(self): 277 | """resize and print a log message. Old messages will have been 278 | lost after resize because of my dumb paint() implementation, so 279 | at least print a message indicating that fact into the 280 | otherwise now empty console window""" 281 | Win.resize(self) 282 | self.write("### console has been resized") 283 | 284 | def calc_size(self): 285 | """put it at the bottom of the screen""" 286 | self.height = HEIGHT_CON 287 | self.posy = self.termheight - self.height 288 | 289 | def slot_debug(self, dummy_gox, (txt)): 290 | """this slot will be connected to all debug signals.""" 291 | self.write(txt) 292 | 293 | def write(self, txt): 294 | """write a line of text, scroll if needed""" 295 | if not self.win: 296 | return 297 | 298 | # This code would break if the format of 299 | # the log messages would ever change! 300 | if " tick:" in txt: 301 | if not self.gox.config.get_bool("goxtool", "show_ticker"): 302 | return 303 | if "depth:" in txt: 304 | if not self.gox.config.get_bool("goxtool", "show_depth"): 305 | return 306 | if "trade:" in txt: 307 | if "own order" in txt: 308 | if not self.gox.config.get_bool("goxtool", "show_trade_own"): 309 | return 310 | else: 311 | if not self.gox.config.get_bool("goxtool", "show_trade"): 312 | return 313 | 314 | col = COLOR_PAIR["con_text"] 315 | if "trade: bid:" in txt: 316 | col = COLOR_PAIR["con_text_buy"] + curses.A_BOLD 317 | if "trade: ask:" in txt: 318 | col = COLOR_PAIR["con_text_sell"] + curses.A_BOLD 319 | self.win.addstr("\n" + txt, col) 320 | self.done_paint() 321 | 322 | 323 | class WinOrderBook(Win): 324 | """the orderbook window""" 325 | 326 | def __init__(self, stdscr, gox): 327 | """create the orderbook window and connect it to the 328 | onChanged callback of the gox.orderbook instance""" 329 | self.gox = gox 330 | gox.orderbook.signal_changed.connect(self.slot_changed) 331 | Win.__init__(self, stdscr) 332 | 333 | def calc_size(self): 334 | """put it into the middle left side""" 335 | self.height = self.termheight - HEIGHT_CON - HEIGHT_STATUS 336 | self.posy = HEIGHT_STATUS 337 | self.width = WIDTH_ORDERBOOK 338 | 339 | def paint(self): 340 | """paint the visible portion of the orderbook""" 341 | 342 | def paint_row(pos, price, vol, ownvol, color, changevol): 343 | """paint a row in the orderbook (bid or ask)""" 344 | if changevol > 0: 345 | col2 = col_bid + curses.A_BOLD 346 | elif changevol < 0: 347 | col2 = col_ask + curses.A_BOLD 348 | else: 349 | col2 = col_vol 350 | self.addstr(pos, 0, book.gox.quote2str(price), color) 351 | self.addstr(pos, 12, book.gox.base2str(vol), col2) 352 | if ownvol: 353 | self.addstr(pos, 28, book.gox.base2str(ownvol), col_own) 354 | 355 | self.win.bkgd(" ", COLOR_PAIR["book_text"]) 356 | self.win.erase() 357 | 358 | gox = self.gox 359 | book = gox.orderbook 360 | 361 | mid = self.height / 2 362 | col_bid = COLOR_PAIR["book_bid"] 363 | col_ask = COLOR_PAIR["book_ask"] 364 | col_vol = COLOR_PAIR["book_vol"] 365 | col_own = COLOR_PAIR["book_own"] 366 | 367 | sum_total = gox.config.get_bool("goxtool", "orderbook_sum_total") 368 | group = gox.config.get_float("goxtool", "orderbook_group") 369 | group = gox.quote2int(group) 370 | if group == 0: 371 | group = 1 372 | 373 | # 374 | # 375 | # paint the asks (first we put them into bins[] then we paint them) 376 | # 377 | if len(book.asks): 378 | i = 0 379 | bins = [] 380 | pos = mid - 1 381 | vol = 0 382 | prev_vol = 0 383 | 384 | # no grouping, bins can be created in one simple and fast loop 385 | if group == 1: 386 | cnt = len(book.asks) 387 | while pos >= 0 and i < cnt: 388 | level = book.asks[i] 389 | price = level.price 390 | if sum_total: 391 | vol += level.volume 392 | else: 393 | vol = level.volume 394 | ownvol = level.own_volume 395 | bins.append([pos, price, vol, ownvol, 0]) 396 | pos -= 1 397 | i += 1 398 | 399 | # with gouping its a bit more complicated 400 | else: 401 | # first bin is exact lowest ask price 402 | price = book.asks[0].price 403 | vol = book.asks[0].volume 404 | bins.append([pos, price, vol, 0, 0]) 405 | prev_vol = vol 406 | pos -= 1 407 | 408 | # now all following bins 409 | bin_price = int(math.ceil(float(price) / group) * group) 410 | if bin_price == price: 411 | # first level was exact bin price already, skip to next bin 412 | bin_price += group 413 | while pos >= 0 and bin_price < book.asks[-1].price + group: 414 | vol, _vol_quote = book.get_total_up_to(bin_price, True) ## 01 freeze 415 | if vol > prev_vol: 416 | # append only non-empty bins 417 | if sum_total: 418 | bins.append([pos, bin_price, vol, 0, 0]) 419 | else: 420 | bins.append([pos, bin_price, vol - prev_vol, 0, 0]) 421 | prev_vol = vol 422 | pos -= 1 423 | bin_price += group 424 | 425 | # now add the own volumes to their bins 426 | for order in book.owns: 427 | if order.typ == "ask" and order.price > 0: 428 | order_bin_price = int(math.ceil(float(order.price) / group) * group) 429 | for abin in bins: 430 | if abin[1] == order.price: 431 | abin[3] += order.volume 432 | break 433 | if abin[1] == order_bin_price: 434 | abin[3] += order.volume 435 | break 436 | 437 | # mark the level where change took place (optional) 438 | if gox.config.get_bool("goxtool", "highlight_changes"): 439 | if book.last_change_type == "ask": 440 | change_bin_price = int(math.ceil(float(book.last_change_price) / group) * group) 441 | for abin in bins: 442 | if abin[1] == book.last_change_price: 443 | abin[4] = book.last_change_volume 444 | break 445 | if abin[1] == change_bin_price: 446 | abin[4] = book.last_change_volume 447 | break 448 | 449 | # now finally paint the asks 450 | for pos, price, vol, ownvol, changevol in bins: 451 | paint_row(pos, price, vol, ownvol, col_ask, changevol) 452 | 453 | # 454 | # 455 | # paint the bids (first we put them into bins[] then we paint them) 456 | # 457 | if len(book.bids): 458 | i = 0 459 | bins = [] 460 | pos = mid + 1 461 | vol = 0 462 | prev_vol = 0 463 | 464 | # no grouping, bins can be created in one simple and fast loop 465 | if group == 1: 466 | cnt = len(book.bids) 467 | while pos < self.height and i < cnt: 468 | level = book.bids[i] 469 | price = level.price 470 | if sum_total: 471 | vol += level.volume 472 | else: 473 | vol = level.volume 474 | ownvol = level.own_volume 475 | bins.append([pos, price, vol, ownvol, 0]) 476 | prev_vol = vol 477 | pos += 1 478 | i += 1 479 | 480 | # with gouping its a bit more complicated 481 | else: 482 | # first bin is exact lowest ask price 483 | price = book.bids[0].price 484 | vol = book.bids[0].volume 485 | bins.append([pos, price, vol, 0, 0]) 486 | prev_vol = vol 487 | pos += 1 488 | 489 | # now all following bins 490 | bin_price = int(math.floor(float(price) / group) * group) 491 | if bin_price == price: 492 | # first level was exact bin price already, skip to next bin 493 | bin_price -= group 494 | while pos < self.height and bin_price >= 0: 495 | vol, _vol_quote = book.get_total_up_to(bin_price, False) 496 | if vol > prev_vol: 497 | # append only non-empty bins 498 | if sum_total: 499 | bins.append([pos, bin_price, vol, 0, 0]) 500 | else: 501 | bins.append([pos, bin_price, vol - prev_vol, 0, 0]) 502 | prev_vol = vol 503 | pos += 1 504 | bin_price -= group 505 | 506 | # now add the own volumes to their bins 507 | for order in book.owns: 508 | if order.typ == "bid" and order.price > 0: 509 | order_bin_price = int(math.floor(float(order.price) / group) * group) 510 | for abin in bins: 511 | if abin[1] == order.price: 512 | abin[3] += order.volume 513 | break 514 | if abin[1] == order_bin_price: 515 | abin[3] += order.volume 516 | break 517 | 518 | # mark the level where change took place (optional) 519 | if gox.config.get_bool("goxtool", "highlight_changes"): 520 | if book.last_change_type == "bid": 521 | change_bin_price = int(math.floor(float(book.last_change_price) / group) * group) 522 | for abin in bins: 523 | if abin[1] == book.last_change_price: 524 | abin[4] = book.last_change_volume 525 | break 526 | if abin[1] == change_bin_price: 527 | abin[4] = book.last_change_volume 528 | break 529 | 530 | # now finally paint the bids 531 | for pos, price, vol, ownvol, changevol in bins: 532 | paint_row(pos, price, vol, ownvol, col_bid, changevol) 533 | 534 | # update the xterm title bar 535 | if self.gox.config.get_bool("goxtool", "set_xterm_title"): 536 | last_candle = self.gox.history.last_candle() 537 | if last_candle: 538 | title = self.gox.quote2str(last_candle.cls).strip() 539 | title += " - goxtool -" 540 | title += " bid:" + self.gox.quote2str(book.bid).strip() 541 | title += " ask:" + self.gox.quote2str(book.ask).strip() 542 | 543 | term = os.environ["TERM"] 544 | # the following is incomplete but better safe than sorry 545 | # if you know more terminals then please provide a patch 546 | if "xterm" in term or "rxvt" in term: 547 | sys_out.write("\x1b]0;%s\x07" % title) 548 | sys_out.flush() 549 | 550 | def slot_changed(self, _book, _dummy): 551 | """Slot for orderbook.signal_changed""" 552 | self.do_paint() 553 | 554 | 555 | TYPE_HISTORY = 1 556 | TYPE_ORDERBOOK = 2 557 | 558 | class WinChart(Win): 559 | """the chart window""" 560 | 561 | def __init__(self, stdscr, gox): 562 | self.gox = gox 563 | self.pmin = 0 564 | self.pmax = 0 565 | self.change_type = None 566 | gox.history.signal_changed.connect(self.slot_history_changed) 567 | gox.orderbook.signal_changed.connect(self.slot_orderbook_changed) 568 | 569 | # some terminals do not support reverse video 570 | # so we cannot use reverse space for candle bodies 571 | if curses.A_REVERSE & curses.termattrs(): 572 | self.body_char = " " 573 | self.body_attr = curses.A_REVERSE 574 | else: 575 | self.body_char = curses.ACS_CKBOARD # pylint: disable=E1101 576 | self.body_attr = 0 577 | 578 | Win.__init__(self, stdscr) 579 | 580 | def calc_size(self): 581 | """position in the middle, right to the orderbook""" 582 | self.posx = WIDTH_ORDERBOOK 583 | self.posy = HEIGHT_STATUS 584 | self.width = self.termwidth - WIDTH_ORDERBOOK 585 | self.height = self.termheight - HEIGHT_CON - HEIGHT_STATUS 586 | 587 | def is_in_range(self, price): 588 | """is this price in the currently visible range?""" 589 | return price <= self.pmax and price >= self.pmin 590 | 591 | def get_optimal_step(self, num_min): 592 | """return optimal step size for painting y-axis labels so that the 593 | range will be divided into at least num_min steps""" 594 | if self.pmax <= self.pmin: 595 | return None 596 | stepex = float(self.pmax - self.pmin) / num_min 597 | step1 = math.pow(10, math.floor(math.log(stepex, 10))) 598 | step2 = step1 * 2 599 | step5 = step1 * 5 600 | if step5 <= stepex: 601 | return step5 602 | if step2 <= stepex: 603 | return step2 604 | return step1 605 | 606 | def price_to_screen(self, price): 607 | """convert price into screen coordinates (y=0 is at the top!)""" 608 | relative_from_bottom = \ 609 | float(price - self.pmin) / float(self.pmax - self.pmin) 610 | screen_from_bottom = relative_from_bottom * self.height 611 | return int(self.height - screen_from_bottom) 612 | 613 | def paint_y_label(self, posy, posx, price): 614 | """paint the y label of the history chart, formats the number 615 | so that it needs not more room than necessary but it also uses 616 | pmax to determine how many digits are needed so that all numbers 617 | will be nicely aligned at the decimal point""" 618 | 619 | fprice = self.gox.quote2float(price) 620 | labelstr = ("%f" % fprice).rstrip("0").rstrip(".") 621 | 622 | # look at pmax to determine the max number of digits before the decimal 623 | # and then pad all smaller prices with spaces to make them align nicely. 624 | need_digits = int(math.log10(self.gox.quote2float(self.pmax))) + 1 625 | have_digits = len(str(int(fprice))) 626 | if have_digits < need_digits: 627 | padding = " " * (need_digits - have_digits) 628 | labelstr = padding + labelstr 629 | 630 | self.addstr( 631 | posy, posx, 632 | labelstr, 633 | COLOR_PAIR["chart_text"] 634 | ) 635 | 636 | def paint_candle(self, posx, candle): 637 | """paint a single candle""" 638 | 639 | sopen = self.price_to_screen(candle.opn) 640 | shigh = self.price_to_screen(candle.hig) 641 | slow = self.price_to_screen(candle.low) 642 | sclose = self.price_to_screen(candle.cls) 643 | 644 | for posy in range(self.height): 645 | if posy >= shigh and posy < sopen and posy < sclose: 646 | # upper wick 647 | # pylint: disable=E1101 648 | self.addch(posy, posx, curses.ACS_VLINE, COLOR_PAIR["chart_text"]) 649 | if posy >= sopen and posy < sclose: 650 | # red body 651 | self.addch(posy, posx, self.body_char, self.body_attr + COLOR_PAIR["chart_down"]) 652 | if posy >= sclose and posy < sopen: 653 | # green body 654 | self.addch(posy, posx, self.body_char, self.body_attr + COLOR_PAIR["chart_up"]) 655 | if posy >= sopen and posy >= sclose and posy < slow: 656 | # lower wick 657 | # pylint: disable=E1101 658 | self.addch(posy, posx, curses.ACS_VLINE, COLOR_PAIR["chart_text"]) 659 | 660 | def paint(self): 661 | typ = self.gox.config.get_string("goxtool", "display_right") 662 | if typ == "history_chart": 663 | self.paint_history_chart() 664 | elif typ == "depth_chart": 665 | self.paint_depth_chart() 666 | else: 667 | self.paint_history_chart() 668 | 669 | def paint_depth_chart(self): 670 | """paint a depth chart""" 671 | 672 | # pylint: disable=C0103 673 | if self.gox.curr_quote in "JPY SEK": 674 | BAR_LEFT_EDGE = 7 675 | FORMAT_STRING = "%6.0f" 676 | else: 677 | BAR_LEFT_EDGE = 8 678 | FORMAT_STRING = "%7.2f" 679 | 680 | def paint_depth(pos, price, vol, own, col_price, change): 681 | """paint one row of the depth chart""" 682 | if change > 0: 683 | col = col_bid + curses.A_BOLD 684 | elif change < 0: 685 | col = col_ask + curses.A_BOLD 686 | else: 687 | col = col_bar 688 | pricestr = FORMAT_STRING % self.gox.quote2float(price) 689 | self.addstr(pos, 0, pricestr, col_price) 690 | length = int(vol * mult_x) 691 | # pylint: disable=E1101 692 | self.win.hline(pos, BAR_LEFT_EDGE, curses.ACS_CKBOARD, length, col) 693 | if own: 694 | self.addstr(pos, length + BAR_LEFT_EDGE, "o", col_own) 695 | 696 | self.win.bkgd(" ", COLOR_PAIR["chart_text"]) 697 | self.win.erase() 698 | 699 | book = self.gox.orderbook 700 | if not (book.bid and book.ask and len(book.bids) and len(book.asks)): 701 | # orderbook is not initialized yet, paint nothing 702 | return 703 | 704 | col_bar = COLOR_PAIR["book_vol"] 705 | col_bid = COLOR_PAIR["book_bid"] 706 | col_ask = COLOR_PAIR["book_ask"] 707 | col_own = COLOR_PAIR["book_own"] 708 | 709 | group = self.gox.config.get_float("goxtool", "depth_chart_group") 710 | if group == 0: 711 | group = 1 712 | group = self.gox.quote2int(group) 713 | 714 | max_vol_ask = 0 715 | max_vol_bid = 0 716 | bin_asks = [] 717 | bin_bids = [] 718 | mid = self.height / 2 719 | sum_total = self.gox.config.get_bool("goxtool", "depth_chart_sum_total") 720 | 721 | # 722 | # 723 | # bin the asks 724 | # 725 | pos = mid - 1 726 | prev_vol = 0 727 | bin_price = int(math.ceil(float(book.asks[0].price) / group) * group) 728 | while pos >= 0 and bin_price < book.asks[-1].price + group: 729 | bin_vol, _bin_vol_quote = book.get_total_up_to(bin_price, True) 730 | if bin_vol > prev_vol: 731 | # add only non-empty bins 732 | if sum_total: 733 | bin_asks.append([pos, bin_price, bin_vol, 0, 0]) 734 | max_vol_ask = max(bin_vol, max_vol_ask) 735 | else: 736 | bin_asks.append([pos, bin_price, bin_vol - prev_vol, 0, 0]) 737 | max_vol_ask = max(bin_vol - prev_vol, max_vol_ask) 738 | prev_vol = bin_vol 739 | pos -= 1 740 | bin_price += group 741 | 742 | # 743 | # 744 | # bin the bids 745 | # 746 | pos = mid + 1 747 | prev_vol = 0 748 | bin_price = int(math.floor(float(book.bids[0].price) / group) * group) 749 | while pos < self.height and bin_price >= 0: 750 | _bin_vol_base, bin_vol_quote = book.get_total_up_to(bin_price, False) 751 | bin_vol = self.gox.base2int(bin_vol_quote / book.bid) 752 | if bin_vol > prev_vol: 753 | # add only non-empty bins 754 | if sum_total: 755 | bin_bids.append([pos, bin_price, bin_vol, 0, 0]) 756 | max_vol_bid = max(bin_vol, max_vol_bid) 757 | else: 758 | bin_bids.append([pos, bin_price, bin_vol - prev_vol, 0, 0]) 759 | max_vol_bid = max(bin_vol - prev_vol, max_vol_bid) 760 | prev_vol = bin_vol 761 | pos += 1 762 | bin_price -= group 763 | 764 | max_vol_tot = max(max_vol_ask, max_vol_bid) 765 | if not max_vol_tot: 766 | return 767 | mult_x = float(self.width - BAR_LEFT_EDGE - 2) / max_vol_tot 768 | 769 | # add the own volume to the bins 770 | for order in book.owns: 771 | if order.price > 0: 772 | if order.typ == "ask": 773 | bin_price = int(math.ceil(float(order.price) / group) * group) 774 | for abin in bin_asks: 775 | if abin[1] == bin_price: 776 | abin[3] += order.volume 777 | break 778 | else: 779 | bin_price = int(math.floor(float(order.price) / group) * group) 780 | for abin in bin_bids: 781 | if abin[1] == bin_price: 782 | abin[3] += order.volume 783 | break 784 | 785 | # highlight the relative change (optional) 786 | if self.gox.config.get_bool("goxtool", "highlight_changes"): 787 | price = book.last_change_price 788 | if book.last_change_type == "ask": 789 | bin_price = int(math.ceil(float(price) / group) * group) 790 | for abin in bin_asks: 791 | if abin[1] == bin_price: 792 | abin[4] = book.last_change_volume 793 | break 794 | if book.last_change_type == "bid": 795 | bin_price = int(math.floor(float(price) / group) * group) 796 | for abin in bin_bids: 797 | if abin[1] == bin_price: 798 | abin[4] = book.last_change_volume 799 | break 800 | 801 | # paint the asks 802 | for pos, price, vol, own, change in bin_asks: 803 | paint_depth(pos, price, vol, own, col_ask, change) 804 | 805 | # paint the bids 806 | for pos, price, vol, own, change in bin_bids: 807 | paint_depth(pos, price, vol, own, col_bid, change) 808 | 809 | def paint_history_chart(self): 810 | """paint a history candlestick chart""" 811 | 812 | if self.change_type == TYPE_ORDERBOOK: 813 | # erase only the rightmost column to redraw bid/ask and orders 814 | # beause we won't redraw the chart, its only an orderbook change 815 | self.win.vline(0, self.width - 1, " ", self.height, COLOR_PAIR["chart_text"]) 816 | else: 817 | self.win.bkgd(" ", COLOR_PAIR["chart_text"]) 818 | self.win.erase() 819 | 820 | hist = self.gox.history 821 | book = self.gox.orderbook 822 | 823 | self.pmax = 0 824 | self.pmin = 9999999999 825 | 826 | # determine y range 827 | posx = self.width - 2 828 | index = 0 829 | while index < hist.length() and posx >= 0: 830 | candle = hist.candles[index] 831 | if self.pmax < candle.hig: 832 | self.pmax = candle.hig 833 | if self.pmin > candle.low: 834 | self.pmin = candle.low 835 | index += 1 836 | posx -= 1 837 | 838 | if self.pmax == self.pmin: 839 | return 840 | 841 | # paint the candlestick chart. 842 | # We won't paint it if it was triggered from an orderbook change 843 | # signal because that would be redundant and only waste CPU. 844 | # In that case we only repaint the bid/ask markers (see below) 845 | if self.change_type != TYPE_ORDERBOOK: 846 | # paint the candles 847 | posx = self.width - 2 848 | index = 0 849 | while index < hist.length() and posx >= 0: 850 | candle = hist.candles[index] 851 | self.paint_candle(posx, candle) 852 | index += 1 853 | posx -= 1 854 | 855 | # paint the y-axis labels 856 | posx = 0 857 | step = self.get_optimal_step(4) 858 | if step: 859 | labelprice = int(self.pmin / step) * step 860 | while not labelprice > self.pmax: 861 | posy = self.price_to_screen(labelprice) 862 | if posy < self.height - 1: 863 | self.paint_y_label(posy, posx, labelprice) 864 | labelprice += step 865 | 866 | # paint bid, ask, own orders 867 | posx = self.width - 1 868 | for order in book.owns: 869 | if self.is_in_range(order.price): 870 | posy = self.price_to_screen(order.price) 871 | if order.status == "pending": 872 | self.addch(posy, posx, 873 | ord("p"), COLOR_PAIR["order_pending"]) 874 | else: 875 | self.addch(posy, posx, 876 | ord("o"), COLOR_PAIR["book_own"]) 877 | 878 | if self.is_in_range(book.bid): 879 | posy = self.price_to_screen(book.bid) 880 | # pylint: disable=E1101 881 | self.addch(posy, posx, 882 | curses.ACS_HLINE, COLOR_PAIR["chart_up"]) 883 | 884 | if self.is_in_range(book.ask): 885 | posy = self.price_to_screen(book.ask) 886 | # pylint: disable=E1101 887 | self.addch(posy, posx, 888 | curses.ACS_HLINE, COLOR_PAIR["chart_down"]) 889 | 890 | 891 | def slot_history_changed(self, _sender, _data): 892 | """Slot for history changed""" 893 | self.change_type = TYPE_HISTORY 894 | self.do_paint() 895 | self.change_type = None 896 | 897 | def slot_orderbook_changed(self, _sender, _data): 898 | """Slot for orderbook changed""" 899 | self.change_type = TYPE_ORDERBOOK 900 | self.do_paint() 901 | self.change_type = None 902 | 903 | 904 | class WinStatus(Win): 905 | """the status window at the top""" 906 | 907 | def __init__(self, stdscr, gox): 908 | """create the status window and connect the needed callbacks""" 909 | self.gox = gox 910 | self.order_lag = 0 911 | self.order_lag_txt = "" 912 | self.sorted_currency_list = [] 913 | gox.signal_orderlag.connect(self.slot_orderlag) 914 | gox.signal_wallet.connect(self.slot_changed) 915 | gox.orderbook.signal_changed.connect(self.slot_changed) 916 | Win.__init__(self, stdscr) 917 | 918 | def calc_size(self): 919 | """place it at the top of the terminal""" 920 | self.height = HEIGHT_STATUS 921 | 922 | def sort_currency_list_if_changed(self): 923 | """sort the currency list in the wallet for better display, 924 | sort it only if it has changed, otherwise leave it as it is""" 925 | currency_list = self.gox.wallet.keys() 926 | if len(currency_list) == len(self.sorted_currency_list): 927 | return 928 | 929 | # now we will bring base and quote currency to the front and sort the 930 | # the rest of the list of names by acount balance in descending order 931 | if self.gox.curr_base in currency_list: 932 | currency_list.remove(self.gox.curr_base) 933 | if self.gox.curr_quote in currency_list: 934 | currency_list.remove(self.gox.curr_quote) 935 | currency_list.sort(key=lambda name: -self.gox.wallet[name]) 936 | currency_list.insert(0, self.gox.curr_quote) 937 | currency_list.insert(0, self.gox.curr_base) 938 | self.sorted_currency_list = currency_list 939 | 940 | def paint(self): 941 | """paint the complete status""" 942 | cbase = self.gox.curr_base 943 | cquote = self.gox.curr_quote 944 | self.sort_currency_list_if_changed() 945 | self.win.bkgd(" ", COLOR_PAIR["status_text"]) 946 | self.win.erase() 947 | 948 | # 949 | # first line 950 | # 951 | line1 = "Market: %s%s | " % (cbase, cquote) 952 | line1 += "Account: " 953 | if len(self.sorted_currency_list): 954 | for currency in self.sorted_currency_list: 955 | if currency in self.gox.wallet: 956 | line1 += currency + " " \ 957 | + goxapi.int2str(self.gox.wallet[currency], currency).strip() \ 958 | + " + " 959 | line1 = line1.strip(" +") 960 | else: 961 | line1 += "No info (yet)" 962 | 963 | # 964 | # second line 965 | # 966 | line2 = "" 967 | if self.gox.config.get_bool("goxtool", "show_orderbook_stats"): 968 | str_btc = locale.format('%d', self.gox.orderbook.total_ask, 1) 969 | str_fiat = locale.format('%d', self.gox.orderbook.total_bid, 1) 970 | if self.gox.orderbook.total_ask: 971 | str_ratio = locale.format('%1.2f', 972 | self.gox.orderbook.total_bid / self.gox.orderbook.total_ask, 1) 973 | else: 974 | str_ratio = "-" 975 | 976 | line2 += "sum_bid: %s %s | " % (str_fiat, cquote) 977 | line2 += "sum_ask: %s %s | " % (str_btc, cbase) 978 | line2 += "ratio: %s %s/%s | " % (str_ratio, cquote, cbase) 979 | 980 | line2 += "o_lag: %s | " % self.order_lag_txt 981 | line2 += "s_lag: %.3f s" % (self.gox.socket_lag / 1e6) 982 | self.addstr(0, 0, line1, COLOR_PAIR["status_text"]) 983 | self.addstr(1, 0, line2, COLOR_PAIR["status_text"]) 984 | 985 | 986 | def slot_changed(self, dummy_sender, dummy_data): 987 | """the callback funtion called by the Gox() instance""" 988 | self.do_paint() 989 | 990 | def slot_orderlag(self, dummy_sender, (usec, text)): 991 | """slot for order_lag mesages""" 992 | self.order_lag = usec 993 | self.order_lag_txt = text 994 | self.do_paint() 995 | 996 | 997 | class DlgListItems(Win): 998 | """dialog with a scrollable list of items""" 999 | def __init__(self, stdscr, width, title, hlp, keys): 1000 | self.items = [] 1001 | self.selected = [] 1002 | self.item_top = 0 1003 | self.item_sel = 0 1004 | self.dlg_width = width 1005 | self.dlg_title = title 1006 | self.dlg_hlp = hlp 1007 | self.dlg_keys = keys 1008 | self.reserved_lines = 5 # how many lines NOT used for order list 1009 | self.init_items() 1010 | Win.__init__(self, stdscr) 1011 | 1012 | def init_items(self): 1013 | """initialize the items list, must override and implement this""" 1014 | raise NotImplementedError() 1015 | 1016 | def calc_size(self): 1017 | maxh = self.termheight - 4 1018 | self.height = len(self.items) + self.reserved_lines 1019 | if self.height > maxh: 1020 | self.height = maxh 1021 | self.posy = (self.termheight - self.height) / 2 1022 | 1023 | self.width = self.dlg_width 1024 | self.posx = (self.termwidth - self.width) / 2 1025 | 1026 | def paint_item(self, posx, index): 1027 | """paint the item. Must override and implement this""" 1028 | raise NotImplementedError() 1029 | 1030 | def paint(self): 1031 | self.win.bkgd(" ", COLOR_PAIR["dialog_text"]) 1032 | self.win.erase() 1033 | self.win.border() 1034 | self.addstr(0, 1, " %s " % self.dlg_title, COLOR_PAIR["dialog_text"]) 1035 | index = self.item_top 1036 | posy = 2 1037 | while posy < self.height - 3 and index < len(self.items): 1038 | self.paint_item(posy, index) 1039 | index += 1 1040 | posy += 1 1041 | 1042 | self.win.move(self.height - 2, 2) 1043 | for key, desc in self.dlg_hlp: 1044 | self.addstr(key + " ", COLOR_PAIR["dialog_sel"]) 1045 | self.addstr(desc + " ", COLOR_PAIR["dialog_text"]) 1046 | 1047 | def down(self, num): 1048 | """move the cursor down (or up)""" 1049 | if not len(self.items): 1050 | return 1051 | self.item_sel += num 1052 | if self.item_sel < 0: 1053 | self.item_sel = 0 1054 | if self.item_sel > len(self.items) - 1: 1055 | self.item_sel = len(self.items) - 1 1056 | 1057 | last_line = self.height - 1 - self.reserved_lines 1058 | if self.item_sel < self.item_top: 1059 | self.item_top = self.item_sel 1060 | if self.item_sel - self.item_top > last_line: 1061 | self.item_top = self.item_sel - last_line 1062 | 1063 | self.do_paint() 1064 | 1065 | def toggle_select(self): 1066 | """toggle selection under cursor""" 1067 | if not len(self.items): 1068 | return 1069 | item = self.items[self.item_sel] 1070 | if item in self.selected: 1071 | self.selected.remove(item) 1072 | else: 1073 | self.selected.append(item) 1074 | self.do_paint() 1075 | 1076 | def modal(self): 1077 | """run the modal getch-loop for this dialog""" 1078 | if self.win: 1079 | done = False 1080 | while not done: 1081 | key_pressed = self.win.getch() 1082 | if key_pressed in [27, ord("q"), curses.KEY_F10]: 1083 | done = True 1084 | if key_pressed == curses.KEY_DOWN: 1085 | self.down(1) 1086 | if key_pressed == curses.KEY_UP: 1087 | self.down(-1) 1088 | if key_pressed == curses.KEY_IC: 1089 | self.toggle_select() 1090 | self.down(1) 1091 | 1092 | for key, func in self.dlg_keys: 1093 | if key == key_pressed: 1094 | func() 1095 | done = True 1096 | 1097 | # help the garbage collector clean up circular references 1098 | # to make sure __del__() will be called to close the dialog 1099 | del self.dlg_keys 1100 | 1101 | 1102 | class DlgCancelOrders(DlgListItems): 1103 | """modal dialog to cancel orders""" 1104 | def __init__(self, stdscr, gox): 1105 | self.gox = gox 1106 | hlp = [("INS", "select"), ("F8", "cancel selected"), ("F10", "exit")] 1107 | keys = [(curses.KEY_F8, self._do_cancel)] 1108 | DlgListItems.__init__(self, stdscr, 45, "Cancel order(s)", hlp, keys) 1109 | 1110 | def init_items(self): 1111 | for order in self.gox.orderbook.owns: 1112 | self.items.append(order) 1113 | self.items.sort(key = lambda o: -o.price) 1114 | 1115 | def paint_item(self, posy, index): 1116 | """paint one single order""" 1117 | order = self.items[index] 1118 | if order in self.selected: 1119 | marker = "*" 1120 | if index == self.item_sel: 1121 | attr = COLOR_PAIR["dialog_sel_sel"] 1122 | else: 1123 | attr = COLOR_PAIR["dialog_sel_text"] + curses.A_BOLD 1124 | else: 1125 | marker = "" 1126 | if index == self.item_sel: 1127 | attr = COLOR_PAIR["dialog_sel"] 1128 | else: 1129 | attr = COLOR_PAIR["dialog_text"] 1130 | 1131 | self.addstr(posy, 2, marker, attr) 1132 | self.addstr(posy, 5, order.typ, attr) 1133 | self.addstr(posy, 9, self.gox.quote2str(order.price), attr) 1134 | self.addstr(posy, 22, self.gox.base2str(order.volume), attr) 1135 | 1136 | def _do_cancel(self): 1137 | """cancel all selected orders (or the order under cursor if empty)""" 1138 | 1139 | def do_cancel(order): 1140 | """cancel a single order""" 1141 | self.gox.cancel(order.oid) 1142 | 1143 | if not len(self.items): 1144 | return 1145 | if not len(self.selected): 1146 | order = self.items[self.item_sel] 1147 | do_cancel(order) 1148 | else: 1149 | for order in self.selected: 1150 | do_cancel(order) 1151 | 1152 | 1153 | class TextBox(): 1154 | """wrapper for curses.textpad.Textbox""" 1155 | 1156 | def __init__(self, dlg, posy, posx, length): 1157 | self.dlg = dlg 1158 | self.win = dlg.win.derwin(1, length, posy, posx) 1159 | self.win.keypad(1) 1160 | self.box = curses.textpad.Textbox(self.win, insert_mode=True) 1161 | self.value = "" 1162 | self.result = None 1163 | self.editing = False 1164 | 1165 | def __del__(self): 1166 | self.box = None 1167 | self.win = None 1168 | 1169 | def modal(self): 1170 | """enter te edit box modal loop""" 1171 | self.win.move(0, 0) 1172 | self.editing = True 1173 | goxapi.start_thread(self.cursor_placement_thread, "TextBox cursor placement") 1174 | self.value = self.box.edit(self.validator) 1175 | self.editing = False 1176 | return self.result 1177 | 1178 | def validator(self, char): 1179 | """here we tweak the behavior slightly, especially we want to 1180 | end modal editing mode immediately on arrow up/down and on enter 1181 | and we also want to catch ESC and F10, to abort the entire dialog""" 1182 | if curses.ascii.isprint(char): 1183 | return char 1184 | if char == curses.ascii.TAB: 1185 | char = curses.KEY_DOWN 1186 | if char in [curses.KEY_DOWN, curses.KEY_UP]: 1187 | self.result = char 1188 | return curses.ascii.BEL 1189 | if char in [10, 13, curses.KEY_ENTER, curses.ascii.BEL]: 1190 | self.result = 10 1191 | return curses.ascii.BEL 1192 | if char in [27, curses.KEY_F10]: 1193 | self.result = -1 1194 | return curses.ascii.BEL 1195 | return char 1196 | 1197 | def cursor_placement_thread(self): 1198 | """this is the most ugly hack of the entire program. During the 1199 | signals hat are fired while we are editing there will be many repaints 1200 | of other other panels below this dialog and when curses is done 1201 | repainting everything the blinking cursor is not in the correct 1202 | position. This is only a cosmetic problem but very annnoying. Try to 1203 | force it into the edit field by repainting it very often.""" 1204 | while self.editing: 1205 | # pylint: disable=W0212 1206 | with goxapi.Signal._lock: 1207 | curses.curs_set(2) 1208 | self.win.touchwin() 1209 | self.win.refresh() 1210 | time.sleep(0.1) 1211 | curses.curs_set(0) 1212 | 1213 | 1214 | class NumberBox(TextBox): 1215 | """TextBox that only accepts numbers""" 1216 | def __init__(self, dlg, posy, posx, length): 1217 | TextBox.__init__(self, dlg, posy, posx, length) 1218 | 1219 | def validator(self, char): 1220 | """allow only numbers to be entered""" 1221 | if char == ord("q"): 1222 | char = curses.KEY_F10 1223 | if curses.ascii.isprint(char): 1224 | if chr(char) not in "0123456789.": 1225 | char = 0 1226 | return TextBox.validator(self, char) 1227 | 1228 | 1229 | class DlgNewOrder(Win): 1230 | """abtract base class for entering new orders""" 1231 | def __init__(self, stdscr, gox, color, title): 1232 | self.gox = gox 1233 | self.color = color 1234 | self.title = title 1235 | self.edit_price = None 1236 | self.edit_volume = None 1237 | Win.__init__(self, stdscr) 1238 | 1239 | def calc_size(self): 1240 | Win.calc_size(self) 1241 | self.width = 35 1242 | self.height = 8 1243 | self.posx = (self.termwidth - self.width) / 2 1244 | self.posy = (self.termheight - self.height) / 2 1245 | 1246 | def paint(self): 1247 | self.win.bkgd(" ", self.color) 1248 | self.win.border() 1249 | self.addstr(0, 1, " %s " % self.title, self.color) 1250 | self.addstr(2, 2, " price", self.color) 1251 | self.addstr(2, 30, self.gox.curr_quote) 1252 | self.addstr(4, 2, "volume", self.color) 1253 | self.addstr(4, 30, self.gox.curr_base) 1254 | self.addstr(6, 2, "F10 ", self.color + curses.A_REVERSE) 1255 | self.addstr("cancel ", self.color) 1256 | self.addstr("Enter ", self.color + curses.A_REVERSE) 1257 | self.addstr("submit ", self.color) 1258 | self.edit_price = NumberBox(self, 2, 10, 20) 1259 | self.edit_volume = NumberBox(self, 4, 10, 20) 1260 | 1261 | def do_submit(self, price_float, volume_float): 1262 | """sumit the order. implementating class will do eiter buy or sell""" 1263 | raise NotImplementedError() 1264 | 1265 | def modal(self): 1266 | """enter the modal getch() loop of this dialog""" 1267 | if self.win: 1268 | focus = 1 1269 | # next time I am going to use some higher level 1270 | # wrapper on top of curses, i promise... 1271 | while True: 1272 | if focus == 1: 1273 | res = self.edit_price.modal() 1274 | if res == -1: 1275 | break # cancel entire dialog 1276 | if res in [10, curses.KEY_DOWN, curses.KEY_UP]: 1277 | try: 1278 | price_float = float(self.edit_price.value) 1279 | focus = 2 1280 | except ValueError: 1281 | pass # can't move down until this is a valid number 1282 | 1283 | if focus == 2: 1284 | res = self.edit_volume.modal() 1285 | if res == -1: 1286 | break # cancel entire dialog 1287 | if res in [curses.KEY_UP, curses.KEY_DOWN]: 1288 | focus = 1 1289 | if res == 10: 1290 | try: 1291 | volume_float = float(self.edit_volume.value) 1292 | break # have both values now, can submit order 1293 | except ValueError: 1294 | pass # no float number, stay in this edit field 1295 | 1296 | if res == -1: 1297 | #user has hit f10. just end here, do nothing 1298 | pass 1299 | if res == 10: 1300 | self.do_submit(price_float, volume_float) 1301 | 1302 | # make sure all cyclic references are garbage collected or 1303 | # otherwise the curses window won't disappear 1304 | self.edit_price = None 1305 | self.edit_volume = None 1306 | 1307 | 1308 | class DlgNewOrderBid(DlgNewOrder): 1309 | """Modal dialog for new buy order""" 1310 | def __init__(self, stdscr, gox): 1311 | DlgNewOrder.__init__(self, stdscr, gox, 1312 | COLOR_PAIR["dialog_bid_text"], 1313 | "New buy order") 1314 | 1315 | def do_submit(self, price, volume): 1316 | price = self.gox.quote2int(price) 1317 | volume = self.gox.base2int(volume) 1318 | self.gox.buy(price, volume) 1319 | 1320 | 1321 | class DlgNewOrderAsk(DlgNewOrder): 1322 | """Modal dialog for new sell order""" 1323 | def __init__(self, stdscr, gox): 1324 | DlgNewOrder.__init__(self, stdscr, gox, 1325 | COLOR_PAIR["dialog_ask_text"], 1326 | "New sell order") 1327 | 1328 | def do_submit(self, price, volume): 1329 | price = self.gox.quote2int(price) 1330 | volume = self.gox.base2int(volume) 1331 | self.gox.sell(price, volume) 1332 | 1333 | 1334 | 1335 | # 1336 | # 1337 | # logging, printing, etc... 1338 | # 1339 | 1340 | class LogWriter(): 1341 | """connects to gox.signal_debug and logs it all to the logfile""" 1342 | def __init__(self, gox): 1343 | self.gox = gox 1344 | if self.gox.config.get_bool("goxtool", "dont_truncate_logfile"): 1345 | logfilemode = 'a' 1346 | else: 1347 | logfilemode = 'w' 1348 | 1349 | logging.basicConfig(filename='goxtool.log' 1350 | ,filemode=logfilemode 1351 | ,format='%(asctime)s:%(levelname)s:%(message)s' 1352 | ,level=logging.DEBUG 1353 | ) 1354 | self.gox.signal_debug.connect(self.slot_debug) 1355 | 1356 | def close(self): 1357 | """stop logging""" 1358 | #not needed 1359 | pass 1360 | 1361 | # pylint: disable=R0201 1362 | def slot_debug(self, sender, (msg)): 1363 | """handler for signal_debug signals""" 1364 | name = "%s.%s" % (sender.__class__.__module__, sender.__class__.__name__) 1365 | logging.debug("%s:%s", name, msg) 1366 | 1367 | 1368 | class PrintHook(): 1369 | """intercept stdout/stderr and send it all to gox.signal_debug instead""" 1370 | def __init__(self, gox): 1371 | self.gox = gox 1372 | self.stdout = sys.stdout 1373 | self.stderr = sys.stderr 1374 | sys.stdout = self 1375 | sys.stderr = self 1376 | 1377 | def close(self): 1378 | """restore normal stdio""" 1379 | sys.stdout = self.stdout 1380 | sys.stderr = self.stderr 1381 | 1382 | def write(self, string): 1383 | """called when someone uses print(), send it to gox""" 1384 | string = string.strip() 1385 | if string != "": 1386 | self.gox.signal_debug(self, string) 1387 | 1388 | 1389 | 1390 | # 1391 | # 1392 | # dynamically (re)loadable strategy module 1393 | # 1394 | 1395 | class StrategyManager(): 1396 | """load the strategy module""" 1397 | 1398 | def __init__(self, gox, strategy_name_list): 1399 | self.strategy_object_list = [] 1400 | self.strategy_name_list = strategy_name_list 1401 | self.gox = gox 1402 | self.reload() 1403 | 1404 | def unload(self): 1405 | """unload the strategy, will trigger its the __del__ method""" 1406 | self.gox.signal_strategy_unload(self, None) 1407 | self.strategy_object_list = [] 1408 | 1409 | def reload(self): 1410 | """reload and re-initialize the strategy module""" 1411 | self.unload() 1412 | for name in self.strategy_name_list: 1413 | name = name.replace(".py", "").strip() 1414 | 1415 | try: 1416 | strategy_module = __import__(name) 1417 | try: 1418 | reload(strategy_module) 1419 | strategy_object = strategy_module.Strategy(self.gox) 1420 | self.strategy_object_list.append(strategy_object) 1421 | if hasattr(strategy_object, "name"): 1422 | self.gox.strategies[strategy_object.name] = strategy_object 1423 | 1424 | except Exception: 1425 | self.gox.debug("### error while loading strategy %s.py, traceback follows:" % name) 1426 | self.gox.debug(traceback.format_exc()) 1427 | 1428 | except ImportError: 1429 | self.gox.debug("### could not import %s.py, traceback follows:" % name) 1430 | self.gox.debug(traceback.format_exc()) 1431 | 1432 | 1433 | def toggle_setting(gox, alternatives, option_name, direction): 1434 | """toggle a setting in the ini file""" 1435 | # pylint: disable=W0212 1436 | with goxapi.Signal._lock: 1437 | setting = gox.config.get_string("goxtool", option_name) 1438 | try: 1439 | newindex = (alternatives.index(setting) + direction) % len(alternatives) 1440 | except ValueError: 1441 | newindex = 0 1442 | gox.config.set("goxtool", option_name, alternatives[newindex]) 1443 | gox.config.save() 1444 | 1445 | def toggle_depth_group(gox, direction): 1446 | """toggle the step width of the depth chart""" 1447 | if gox.curr_quote in "JPY SEK": 1448 | alt = ["5", "10", "25", "50", "100", "200", "500", "1000", "2000", "5000", "10000"] 1449 | else: 1450 | alt = ["0.05", "0.1", "0.25", "0.5", "1", "2", "5", "10", "20", "50", "100"] 1451 | toggle_setting(gox, alt, "depth_chart_group", direction) 1452 | gox.orderbook.signal_changed(gox.orderbook, None) 1453 | 1454 | def toggle_orderbook_group(gox, direction): 1455 | """toggle the group width of the orderbook""" 1456 | if gox.curr_quote in "JPY SEK": 1457 | alt = ["0", "5", "10", "25", "50", "100", "200", "500", "1000", "2000", "5000", "10000"] 1458 | else: 1459 | alt = ["0", "0.05", "0.1", "0.25", "0.5", "1", "2", "5", "10", "20", "50", "100"] 1460 | toggle_setting(gox, alt, "orderbook_group", direction) 1461 | gox.orderbook.signal_changed(gox.orderbook, None) 1462 | 1463 | def toggle_orderbook_sum(gox): 1464 | """toggle the summing in the orderbook on and off""" 1465 | alt = ["False", "True"] 1466 | toggle_setting(gox, alt, "orderbook_sum_total", 1) 1467 | gox.orderbook.signal_changed(gox.orderbook, None) 1468 | 1469 | def toggle_depth_sum(gox): 1470 | """toggle the summing in the depth chart on and off""" 1471 | alt = ["False", "True"] 1472 | toggle_setting(gox, alt, "depth_chart_sum_total", 1) 1473 | gox.orderbook.signal_changed(gox.orderbook, None) 1474 | 1475 | def set_ini(gox, setting, value, signal, signal_sender, signal_params): 1476 | """set the ini value and then send a signal""" 1477 | # pylint: disable=W0212 1478 | with goxapi.Signal._lock: 1479 | gox.config.set("goxtool", setting, value) 1480 | gox.config.save() 1481 | signal(signal_sender, signal_params) 1482 | 1483 | 1484 | 1485 | # 1486 | # 1487 | # main program 1488 | # 1489 | 1490 | def main(): 1491 | """main funtion, called at the start of the program""" 1492 | 1493 | debug_tb = [] 1494 | def curses_loop(stdscr): 1495 | """Only the code inside this function runs within the curses wrapper""" 1496 | 1497 | # this function may under no circumstancs raise an exception, so I'm 1498 | # wrapping everything into try/except (should actually never happen 1499 | # anyways but when it happens during coding or debugging it would 1500 | # leave the terminal in an unusable state and this must be avoded). 1501 | # We have a list debug_tb[] where we can append tracebacks and 1502 | # after curses uninitialized properly and the terminal is restored 1503 | # we can print them. 1504 | try: 1505 | init_colors() 1506 | gox = goxapi.Gox(secret, config) 1507 | 1508 | logwriter = LogWriter(gox) 1509 | printhook = PrintHook(gox) 1510 | 1511 | conwin = WinConsole(stdscr, gox) 1512 | bookwin = WinOrderBook(stdscr, gox) 1513 | statuswin = WinStatus(stdscr, gox) 1514 | chartwin = WinChart(stdscr, gox) 1515 | 1516 | strategy_manager = StrategyManager(gox, strat_mod_list) 1517 | 1518 | gox.start() 1519 | while True: 1520 | key = stdscr.getch() 1521 | if key == ord("q"): 1522 | break 1523 | elif key == curses.KEY_F4: 1524 | DlgNewOrderBid(stdscr, gox).modal() 1525 | elif key == curses.KEY_F5: 1526 | DlgNewOrderAsk(stdscr, gox).modal() 1527 | elif key == curses.KEY_F6: 1528 | DlgCancelOrders(stdscr, gox).modal() 1529 | elif key == curses.KEY_RESIZE: 1530 | # pylint: disable=W0212 1531 | with goxapi.Signal._lock: 1532 | stdscr.erase() 1533 | stdscr.refresh() 1534 | conwin.resize() 1535 | bookwin.resize() 1536 | chartwin.resize() 1537 | statuswin.resize() 1538 | elif key == ord("l"): 1539 | strategy_manager.reload() 1540 | 1541 | # which chart to show on the right side 1542 | elif key == ord("H"): 1543 | set_ini(gox, "display_right", "history_chart", 1544 | gox.history.signal_changed, gox.history, None) 1545 | elif key == ord("D"): 1546 | set_ini(gox, "display_right", "depth_chart", 1547 | gox.orderbook.signal_changed, gox.orderbook, None) 1548 | 1549 | # depth chart step 1550 | elif key == ord(","): # zoom out 1551 | toggle_depth_group(gox, +1) 1552 | elif key == ord("."): # zoom in 1553 | toggle_depth_group(gox, -1) 1554 | 1555 | # orderbook grouping step 1556 | elif key == ord("-"): # zoom out (larger step) 1557 | toggle_orderbook_group(gox, +1) 1558 | elif key == ord("+"): # zoom in (smaller step) 1559 | toggle_orderbook_group(gox, -1) 1560 | 1561 | elif key == ord("S"): 1562 | toggle_orderbook_sum(gox) 1563 | 1564 | elif key == ord("T"): 1565 | toggle_depth_sum(gox) 1566 | 1567 | # lowercase keys go to the strategy module 1568 | elif key >= ord("a") and key <= ord("z"): 1569 | gox.signal_keypress(gox, (key)) 1570 | else: 1571 | gox.debug("key pressed: key=%i" % key) 1572 | 1573 | except KeyboardInterrupt: 1574 | # Ctrl+C has been pressed 1575 | pass 1576 | 1577 | except Exception: 1578 | debug_tb.append(traceback.format_exc()) 1579 | 1580 | # we are here because shutdown was requested. 1581 | # 1582 | # Before we do anything we dump stacktraces of all currently running 1583 | # threads to a separate logfile because this helps debugging freezes 1584 | # and deadlocks that might occur if things went totally wrong. 1585 | with open("goxtool.stacktrace.log", "w") as stacklog: 1586 | stacklog.write(dump_all_stacks()) 1587 | 1588 | # we need the signal lock to be able to shut down. And we cannot 1589 | # wait for any frozen slot to return, so try really hard to get 1590 | # the lock and if that fails then unlock it forcefully. 1591 | try_get_lock_or_break_open() 1592 | 1593 | # Now trying to shutdown everything in an orderly manner.it in the 1594 | # Since we are still inside curses but we don't know whether 1595 | # the printhook or the logwriter was initialized properly already 1596 | # or whether it crashed earlier we cannot print here and we also 1597 | # cannot log, so we put all tracebacks into the debug_tb list to 1598 | # print them later once the terminal is properly restored again. 1599 | try: 1600 | strategy_manager.unload() 1601 | except Exception: 1602 | debug_tb.append(traceback.format_exc()) 1603 | 1604 | try: 1605 | gox.stop() 1606 | except Exception: 1607 | debug_tb.append(traceback.format_exc()) 1608 | 1609 | try: 1610 | printhook.close() 1611 | except Exception: 1612 | debug_tb.append(traceback.format_exc()) 1613 | 1614 | try: 1615 | logwriter.close() 1616 | except Exception: 1617 | debug_tb.append(traceback.format_exc()) 1618 | 1619 | # curses_loop() ends here, we must reach this point under all circumstances. 1620 | # Now curses will restore the terminal back to cooked (normal) mode. 1621 | 1622 | 1623 | # Here it begins. The very first thing is to always set US or GB locale 1624 | # to have always the same well defined behavior for number formatting. 1625 | for loc in ["en_US.UTF8", "en_GB.UTF8", "en_EN", "en_GB", "C"]: 1626 | try: 1627 | locale.setlocale(locale.LC_NUMERIC, loc) 1628 | break 1629 | except locale.Error: 1630 | continue 1631 | 1632 | # before we can finally start the curses UI we might need to do some user 1633 | # interaction on the command line, regarding the encrypted secret 1634 | argp = argparse.ArgumentParser(description='MtGox live market data monitor' 1635 | + ' and trading bot experimentation framework') 1636 | argp.add_argument('--add-secret', action="store_true", 1637 | help="prompt for API secret, encrypt it and then exit") 1638 | argp.add_argument('--strategy', action="store", default="strategy.py", 1639 | help="name of strategy module files, comma separated list, default=strategy.py") 1640 | argp.add_argument('--protocol', action="store", default="", 1641 | help="force protocol (socketio or websocket), ignore setting in .ini") 1642 | argp.add_argument('--no-fulldepth', action="store_true", default=False, 1643 | help="do not download full depth (useful for debugging)") 1644 | argp.add_argument('--no-depth', action="store_true", default=False, 1645 | help="do not request depth messages (implies no-fulldeph), useful for low traffic") 1646 | argp.add_argument('--no-lag', action="store_true", default=False, 1647 | help="do not request order-lag updates, useful for low traffic") 1648 | argp.add_argument('--no-history', action="store_true", default=False, 1649 | help="do not download full history (useful for debugging)") 1650 | argp.add_argument('--use-http', action="store_true", default=False, 1651 | help="use http api for trading (more reliable, recommended") 1652 | argp.add_argument('--no-http', action="store_true", default=False, 1653 | help="use streaming api for trading (problematic when streaming api disconnects often)") 1654 | argp.add_argument('--password', action="store", default=None, 1655 | help="password for decryption of stored key. This is a dangerous option " 1656 | +"because the password might end up being stored in the history file " 1657 | +"of your shell, for example in ~/.bash_history. Use this only when " 1658 | +"starting it from within a script and then of course you need to " 1659 | +"keep this start script in a secure place!") 1660 | args = argp.parse_args() 1661 | 1662 | config = goxapi.GoxConfig("goxtool.ini") 1663 | config.init_defaults(INI_DEFAULTS) 1664 | secret = goxapi.Secret(config) 1665 | secret.password_from_commandline_option = args.password 1666 | if args.add_secret: 1667 | # prompt for secret, encrypt, write to .ini and then exit the program 1668 | secret.prompt_encrypt() 1669 | else: 1670 | strat_mod_list = args.strategy.split(",") 1671 | goxapi.FORCE_PROTOCOL = args.protocol 1672 | goxapi.FORCE_NO_FULLDEPTH = args.no_fulldepth 1673 | goxapi.FORCE_NO_DEPTH = args.no_depth 1674 | goxapi.FORCE_NO_LAG = args.no_lag 1675 | goxapi.FORCE_NO_HISTORY = args.no_history 1676 | goxapi.FORCE_HTTP_API = args.use_http 1677 | goxapi.FORCE_NO_HTTP_API = args.no_http 1678 | if goxapi.FORCE_NO_DEPTH: 1679 | goxapi.FORCE_NO_FULLDEPTH = True 1680 | 1681 | # if its ok then we can finally enter the curses main loop 1682 | if secret.prompt_decrypt() != secret.S_FAIL_FATAL: 1683 | 1684 | ### 1685 | # 1686 | # now going to enter cbreak mode and start the curses loop... 1687 | curses.wrapper(curses_loop) 1688 | # curses ended, terminal is back in normal (cooked) mode 1689 | # 1690 | ### 1691 | 1692 | if len(debug_tb): 1693 | print "\n\n*** error(s) in curses_loop() that caused unclean shutdown:\n" 1694 | for trb in debug_tb: 1695 | print trb 1696 | else: 1697 | print 1698 | print "*******************************************************" 1699 | print "* Please donate: 1C8aDabADaYvTKvCAG1htqYcEgpAhkeYoW *" 1700 | print "*******************************************************" 1701 | 1702 | if __name__ == "__main__": 1703 | main() 1704 | 1705 | -------------------------------------------------------------------------------- /goxapi.py: -------------------------------------------------------------------------------- 1 | """Mt.Gox API.""" 2 | 3 | # Copyright (c) 2013 Bernd Kreuss 4 | # 5 | # This program is free software; you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation; either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program; if not, write to the Free Software 17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 18 | # MA 02110-1301, USA. 19 | 20 | # pylint: disable=C0302,C0301,R0902,R0903,R0912,R0913,R0914,R0915,W0703,W0105 21 | 22 | import sys 23 | PY_VERSION = sys.version_info 24 | 25 | if PY_VERSION < (2, 7): 26 | print("Sorry, minimal Python version is 2.7, you have: %d.%d" 27 | % (PY_VERSION.major, PY_VERSION.minor)) 28 | sys.exit(1) 29 | 30 | from ConfigParser import SafeConfigParser 31 | import base64 32 | import bisect 33 | import binascii 34 | import contextlib 35 | from Crypto.Cipher import AES 36 | import getpass 37 | import gzip 38 | import hashlib 39 | import hmac 40 | import inspect 41 | import io 42 | import json 43 | import logging 44 | import pubnub_light 45 | import Queue 46 | import time 47 | import traceback 48 | import threading 49 | from urllib2 import Request as URLRequest 50 | from urllib2 import urlopen, HTTPError 51 | from urllib import urlencode 52 | import weakref 53 | import websocket 54 | 55 | input = raw_input # pylint: disable=W0622,C0103 56 | 57 | FORCE_PROTOCOL = "" 58 | FORCE_NO_FULLDEPTH = False 59 | FORCE_NO_DEPTH = False 60 | FORCE_NO_LAG = False 61 | FORCE_NO_HISTORY = False 62 | FORCE_HTTP_API = False 63 | FORCE_NO_HTTP_API = False 64 | 65 | SOCKETIO_HOST = "socketio.mtgox.com" 66 | WEBSOCKET_HOST = "websocket.mtgox.com" 67 | HTTP_HOST = "data.mtgox.com" 68 | 69 | USER_AGENT = "goxtool.py" 70 | 71 | # available channels as per https://mtgox.com/api/2/stream/list_public?pretty 72 | # queried on 2013-12-14 - this must be updated when they add new currencies, 73 | # I'm too lazy now to do that dynamically, it doesn't change often (if ever) 74 | CHANNELS = { 75 | "ticker.LTCGBP": "0102a446-e4d4-4082-8e83-cc02822f9172", 76 | "ticker.LTCCNY": "0290378c-e3d7-4836-8cb1-2bfae20cc492", 77 | "depth.BTCHKD": "049f65dc-3af3-4ffd-85a5-aac102b2a579", 78 | "depth.BTCEUR": "057bdc6b-9f9c-44e4-bc1a-363e4443ce87", 79 | "ticker.NMCAUD": "08c65460-cbd9-492e-8473-8507dfa66ae6", 80 | "ticker.BTCEUR": "0bb6da8b-f6c6-4ecf-8f0d-a544ad948c15", 81 | "depth.BTCKRW": "0c84bda7-e613-4b19-ae2a-6d26412c9f70", 82 | "depth.BTCCNY": "0d1ecad8-e20f-459e-8bed-0bdcf927820f", 83 | "ticker.BTCCAD": "10720792-084d-45ba-92e3-cf44d9477775", 84 | "depth.BTCCHF": "113fec5f-294d-4929-86eb-8ca4c3fd1bed", 85 | "ticker.LTCNOK": "13616ae8-9268-4a43-bdf7-6b8d1ac814a2", 86 | "ticker.LTCUSD": "1366a9f3-92eb-4c6c-9ccc-492a959eca94", 87 | "ticker.BTCBTC": "13edff67-cfa0-4d99-aa76-52bd15d6a058", 88 | "ticker.LTCCAD": "18b55737-3f5c-4583-af63-6eb3951ead72", 89 | "ticker.NMCCNY": "249fdefd-c6eb-4802-9f54-064bc83908aa", 90 | "depth.BTCUSD": "24e67e0d-1cad-4cc0-9e7a-f8523ef460fe", 91 | "ticker.BTCCHF": "2644c164-3db7-4475-8b45-c7042efe3413", 92 | "depth.BTCAUD": "296ee352-dd5d-46f3-9bea-5e39dede2005", 93 | "ticker.BTCCZK": "2a968b7f-6638-40ba-95e7-7284b3196d52", 94 | "ticker.BTCSGD": "2cb73ed1-07f4-45e0-8918-bcbfda658912", 95 | "ticker.NMCJPY": "314e2b7a-a9fa-4249-bc46-b7f662ecbc3a", 96 | "ticker.BTCNMC": "36189b8c-cffa-40d2-b205-fb71420387ae", 97 | "depth.BTCINR": "414fdb18-8f70-471c-a9df-b3c2740727ea", 98 | "depth.BTCSGD": "41e5c243-3d44-4fad-b690-f39e1dbb86a8", 99 | "ticker.BTCLTC": "48b6886f-49c0-4614-b647-ba5369b449a9", 100 | "ticker.LTCEUR": "491bc9bb-7cd8-4719-a9e8-16dad802ffac", 101 | "ticker.BTCINR": "55e5feb8-fea5-416b-88fa-40211541deca", 102 | "ticker.LTCJPY": "5ad8e40f-6df3-489f-9cf1-af28426a50cf", 103 | "depth.BTCCAD": "5b234cc3-a7c1-47ce-854f-27aee4cdbda5", 104 | "ticker.BTCNZD": "5ddd27ca-2466-4d1a-8961-615dedb68bf1", 105 | "depth.BTCGBP": "60c3af1b-5d40-4d0e-b9fc-ccab433d2e9c", 106 | "depth.BTCNOK": "66da7fb4-6b0c-4a10-9cb7-e2944e046eb5", 107 | "depth.BTCTHB": "67879668-532f-41f9-8eb0-55e7593a5ab8", 108 | "ticker.BTCSEK": "6caf1244-655b-460f-beaf-5c56d1f4bea7", 109 | "ticker.BTCNOK": "7532e866-3a03-4514-a4b1-6f86e3a8dc11", 110 | "ticker.BTCGBP": "7b842b7d-d1f9-46fa-a49c-c12f1ad5a533", 111 | "trade.lag": "85174711-be64-4de1-b783-0628995d7914", 112 | "depth.BTCSEK": "8f1fefaa-7c55-4420-ada0-4de15c1c38f3", 113 | "depth.BTCDKK": "9219abb0-b50c-4007-b4d2-51d1711ab19c", 114 | "depth.BTCJPY": "94483e07-d797-4dd4-bc72-dc98f1fd39e3", 115 | "ticker.NMCUSD": "9aaefd15-d101-49f3-a2fd-6b63b85b6bed", 116 | "ticker.LTCAUD": "a046600a-a06c-4ebf-9ffb-bdc8157227e8", 117 | "ticker.BTCJPY": "a39ae532-6a3c-4835-af8c-dda54cb4874e", 118 | "depth.BTCCZK": "a7a970cf-4f6c-4d85-a74e-ac0979049b87", 119 | "ticker.LTCDKK": "b10a706e-e8c7-4ea8-9148-669f86930b36", 120 | "ticker.BTCPLN": "b4a02cb3-2e2d-4a88-aeea-3c66cb604d01", 121 | "ticker.BTCRUB": "bd04f720-3c70-4dce-ae71-2422ab862c65", 122 | "ticker.NMCGBP": "bf5126ba-5187-456f-8ae6-963678d0607f", 123 | "ticker.BTCKRW": "bf85048d-4db9-4dbe-9ca3-5b83a1a4186e", 124 | "ticker.BTCCNY": "c251ec35-56f9-40ab-a4f6-13325c349de4", 125 | "depth.BTCNZD": "cedf8730-bce6-4278-b6fe-9bee42930e95", 126 | "ticker.BTCHKD": "d3ae78dd-01dd-4074-88a7-b8aa03cd28dd", 127 | "ticker.BTCTHB": "d58e3b69-9560-4b9e-8c58-b5c0f3fda5e1", 128 | "ticker.BTCUSD": "d5f06780-30a8-4a48-a2f8-7ed181b4a13f", 129 | "depth.BTCRUB": "d6412ca0-b686-464c-891a-d1ba3943f3c6", 130 | "ticker.NMCEUR": "d8512d04-f262-4a14-82f2-8e5c96c15e68", 131 | "trade.BTC": "dbf1dee9-4f2e-4a08-8cb7-748919a71b21", 132 | "ticker.NMCCAD": "dc28033e-7506-484c-905d-1c811a613323", 133 | "depth.BTCPLN": "e4ff055a-f8bf-407e-af76-676cad319a21", 134 | "ticker.BTCDKK": "e5ce0604-574a-4059-9493-80af46c776b3", 135 | "ticker.BTCAUD": "eb6aaa11-99d0-4f64-9e8c-1140872a423d" 136 | } 137 | 138 | 139 | # deprecated, use gox.quote2str() and gox.base2str() instead 140 | def int2str(value_int, currency): 141 | """return currency integer formatted as a string""" 142 | if currency in "BTC LTC NMC": 143 | return ("%16.8f" % (value_int / 100000000.0)) 144 | elif currency in "JPY SEK": 145 | return ("%12.3f" % (value_int / 1000.0)) 146 | else: 147 | return ("%12.5f" % (value_int / 100000.0)) 148 | 149 | 150 | # deprecated, use gox.quote2float() and gox.base2float() instead 151 | def int2float(value_int, currency): 152 | """convert integer to float, determine the factor by currency name""" 153 | if currency in "BTC LTC NMC": 154 | return value_int / 100000000.0 155 | elif currency in "JPY SEK": 156 | return value_int / 1000.0 157 | else: 158 | return value_int / 100000.0 159 | 160 | 161 | # deprecated, use gox.quote2int() and gox.base2int() instead 162 | def float2int(value_float, currency): 163 | """convert float value to integer, determine the factor by currency name""" 164 | if currency in "BTC LTC NMC": 165 | return int(round(value_float * 100000000)) 166 | elif currency in "JPY SEK": 167 | return int(round(value_float * 1000)) 168 | else: 169 | return int(round(value_float * 100000)) 170 | 171 | 172 | def http_request(url, post=None, headers=None): 173 | """request data from the HTTP API, returns the response a string. If a 174 | http error occurs it will *not* raise an exception, instead it will 175 | return the content of the error document. This is because MtGox will 176 | send 5xx http status codes even if application level errors occur 177 | (such as canceling the same order twice or things like that) and the 178 | real error message will be in the json that is returned, so the return 179 | document is always much more interesting than the http status code.""" 180 | 181 | def read_gzipped(response): 182 | """read data from the response object, 183 | unzip if necessary, return text string""" 184 | if response.info().get('Content-Encoding') == 'gzip': 185 | with io.BytesIO(response.read()) as buf: 186 | with gzip.GzipFile(fileobj=buf) as unzipped: 187 | data = unzipped.read() 188 | else: 189 | data = response.read() 190 | return data 191 | 192 | if not headers: 193 | headers = {} 194 | request = URLRequest(url, post, headers) 195 | request.add_header('Accept-encoding', 'gzip') 196 | request.add_header('User-Agent', USER_AGENT) 197 | data = "" 198 | try: 199 | with contextlib.closing(urlopen(request, post)) as res: 200 | data = read_gzipped(res) 201 | except HTTPError as err: 202 | data = read_gzipped(err) 203 | 204 | return data 205 | 206 | def start_thread(thread_func, name=None): 207 | """start a new thread to execute the supplied function""" 208 | thread = threading.Thread(None, thread_func) 209 | thread.daemon = True 210 | thread.start() 211 | if name: 212 | thread.name = name 213 | return thread 214 | 215 | def pretty_format(something): 216 | """pretty-format a nested dict or list for debugging purposes. 217 | If it happens to be a valid json string then it will be parsed first""" 218 | try: 219 | return pretty_format(json.loads(something)) 220 | except Exception: 221 | try: 222 | return json.dumps(something, indent=5) 223 | except Exception: 224 | return str(something) 225 | 226 | 227 | # pylint: disable=R0904 228 | class GoxConfig(SafeConfigParser): 229 | """return a config parser object with default values. If you need to run 230 | more Gox() objects at the same time you will also need to give each of them 231 | them a separate GoxConfig() object. For this reason it takes a filename 232 | in its constructor for the ini file, you can have separate configurations 233 | for separate Gox() instances""" 234 | 235 | _DEFAULTS = [["gox", "base_currency", "BTC"] 236 | ,["gox", "quote_currency", "USD"] 237 | ,["gox", "use_ssl", "True"] 238 | ,["gox", "use_plain_old_websocket", "True"] 239 | ,["gox", "use_http_api", "True"] 240 | ,["gox", "use_tonce", "True"] 241 | ,["gox", "load_fulldepth", "True"] 242 | ,["gox", "load_history", "True"] 243 | ,["gox", "history_timeframe", "15"] 244 | ,["gox", "secret_key", ""] 245 | ,["gox", "secret_secret", ""] 246 | ,["pubnub", "stream_sorter_time_window", "0.5"] 247 | ] 248 | 249 | def __init__(self, filename): 250 | self.filename = filename 251 | SafeConfigParser.__init__(self) 252 | self.load() 253 | self.init_defaults(self._DEFAULTS) 254 | # upgrade from deprecated "currency" to "quote_currency" 255 | # todo: remove this piece of code again in a few months 256 | if self.has_option("gox", "currency"): 257 | self.set("gox", "quote_currency", self.get_string("gox", "currency")) 258 | self.remove_option("gox", "currency") 259 | self.save() 260 | 261 | def init_defaults(self, defaults): 262 | """add the missing default values, default is a list of defaults""" 263 | for (sect, opt, default) in defaults: 264 | self._default(sect, opt, default) 265 | 266 | def save(self): 267 | """save the config to the .ini file""" 268 | with open(self.filename, 'wb') as configfile: 269 | self.write(configfile) 270 | 271 | def load(self): 272 | """(re)load the onfig from the .ini file""" 273 | self.read(self.filename) 274 | 275 | def get_safe(self, sect, opt): 276 | """get value without throwing exception.""" 277 | try: 278 | return self.get(sect, opt) 279 | 280 | except: # pylint: disable=W0702 281 | for (dsect, dopt, default) in self._DEFAULTS: 282 | if dsect == sect and dopt == opt: 283 | self._default(sect, opt, default) 284 | return default 285 | return "" 286 | 287 | def get_bool(self, sect, opt): 288 | """get boolean value from config""" 289 | return self.get_safe(sect, opt) == "True" 290 | 291 | def get_string(self, sect, opt): 292 | """get string value from config""" 293 | return self.get_safe(sect, opt) 294 | 295 | def get_int(self, sect, opt): 296 | """get int value from config""" 297 | vstr = self.get_safe(sect, opt) 298 | try: 299 | return int(vstr) 300 | except ValueError: 301 | return 0 302 | 303 | def get_float(self, sect, opt): 304 | """get int value from config""" 305 | vstr = self.get_safe(sect, opt) 306 | try: 307 | return float(vstr) 308 | except ValueError: 309 | return 0.0 310 | 311 | def _default(self, section, option, default): 312 | """create a default option if it does not yet exist""" 313 | if not self.has_section(section): 314 | self.add_section(section) 315 | if not self.has_option(section, option): 316 | self.set(section, option, default) 317 | self.save() 318 | 319 | class Signal(): 320 | """callback functions (so called slots) can be connected to a signal and 321 | will be called when the signal is called (Signal implements __call__). 322 | The slots receive two arguments: the sender of the signal and a custom 323 | data object. Two different threads won't be allowed to send signals at the 324 | same time application-wide, concurrent threads will have to wait until 325 | the lock is releaesed again. The lock allows recursive reentry of the same 326 | thread to avoid deadlocks when a slot wants to send a signal itself.""" 327 | 328 | _lock = threading.RLock() 329 | signal_error = None 330 | 331 | def __init__(self): 332 | self._functions = weakref.WeakSet() 333 | self._methods = weakref.WeakKeyDictionary() 334 | 335 | # the Signal class itself has a static member signal_error where it 336 | # will send tracebacks of exceptions that might happen. Here we 337 | # initialize it if it does not exist already 338 | if not Signal.signal_error: 339 | Signal.signal_error = 1 340 | Signal.signal_error = Signal() 341 | 342 | def connect(self, slot): 343 | """connect a slot to this signal. The parameter slot can be a funtion 344 | that takes exactly 2 arguments or a method that takes self plus 2 more 345 | arguments, or it can even be even another signal. the first argument 346 | is a reference to the sender of the signal and the second argument is 347 | the payload. The payload can be anything, it totally depends on the 348 | sender and type of the signal.""" 349 | if inspect.ismethod(slot): 350 | instance = slot.__self__ 351 | function = slot.__func__ 352 | if instance not in self._methods: 353 | self._methods[instance] = set() 354 | if function not in self._methods[instance]: 355 | self._methods[instance].add(function) 356 | else: 357 | if slot not in self._functions: 358 | self._functions.add(slot) 359 | 360 | def __call__(self, sender, data, error_signal_on_error=True): 361 | """dispatch signal to all connected slots. This is a synchronuos 362 | operation, It will not return before all slots have been called. 363 | Also only exactly one thread is allowed to emit signals at any time, 364 | all other threads that try to emit *any* signal anywhere in the 365 | application at the same time will be blocked until the lock is released 366 | again. The lock will allow recursive reentry of the seme thread, this 367 | means a slot can itself emit other signals before it returns (or 368 | signals can be directly connected to other signals) without problems. 369 | If a slot raises an exception a traceback will be sent to the static 370 | Signal.signal_error() or to logging.critical()""" 371 | with self._lock: 372 | sent = False 373 | errors = [] 374 | for func in self._functions: 375 | try: 376 | func(sender, data) 377 | sent = True 378 | 379 | except: # pylint: disable=W0702 380 | errors.append(traceback.format_exc()) 381 | 382 | for instance, functions in self._methods.items(): 383 | for func in functions: 384 | try: 385 | func(instance, sender, data) 386 | sent = True 387 | 388 | except: # pylint: disable=W0702 389 | errors.append(traceback.format_exc()) 390 | 391 | for error in errors: 392 | if error_signal_on_error: 393 | Signal.signal_error(self, (error), False) 394 | else: 395 | logging.critical(error) 396 | 397 | return sent 398 | 399 | 400 | class BaseObject(): 401 | """This base class only exists because of the debug() method that is used 402 | in many of the goxtool objects to send debug output to the signal_debug.""" 403 | 404 | def __init__(self): 405 | self.signal_debug = Signal() 406 | 407 | def debug(self, *args): 408 | """send a string composed of all *args to all slots who 409 | are connected to signal_debug or send it to the logger if 410 | nobody is connected""" 411 | msg = " ".join([str(x) for x in args]) 412 | if not self.signal_debug(self, (msg)): 413 | logging.debug(msg) 414 | 415 | 416 | class Timer(Signal): 417 | """a simple timer (used for stuff like keepalive).""" 418 | 419 | def __init__(self, interval, one_shot=False): 420 | """create a new timer, interval is in seconds""" 421 | Signal.__init__(self) 422 | self._one_shot = one_shot 423 | self._canceled = False 424 | self._interval = interval 425 | self._timer = None 426 | self._start() 427 | 428 | def _fire(self): 429 | """fire the signal and restart it""" 430 | if not self._canceled: 431 | self.__call__(self, None) 432 | if not (self._canceled or self._one_shot): 433 | self._start() 434 | 435 | def _start(self): 436 | """start the timer""" 437 | self._timer = threading.Timer(self._interval, self._fire) 438 | self._timer.daemon = True 439 | self._timer.start() 440 | 441 | def cancel(self): 442 | """cancel the timer""" 443 | self._canceled = True 444 | self._timer.cancel() 445 | self._timer = None 446 | 447 | 448 | class Secret: 449 | """Manage the MtGox API secret. This class has methods to decrypt the 450 | entries in the ini file and it also provides a method to create these 451 | entries. The methods encrypt() and decrypt() will block and ask 452 | questions on the command line, they are called outside the curses 453 | environment (yes, its a quick and dirty hack but it works for now).""" 454 | 455 | S_OK = 0 456 | S_FAIL = 1 457 | S_NO_SECRET = 2 458 | S_FAIL_FATAL = 3 459 | 460 | def __init__(self, config): 461 | """initialize the instance""" 462 | self.config = config 463 | self.key = "" 464 | self.secret = "" 465 | 466 | # pylint: disable=C0103 467 | self.password_from_commandline_option = None 468 | 469 | def decrypt(self, password): 470 | """decrypt "secret_secret" from the ini file with the given password. 471 | This will return false if decryption did not seem to be successful. 472 | After this menthod succeeded the application can access the secret""" 473 | 474 | key = self.config.get_string("gox", "secret_key") 475 | sec = self.config.get_string("gox", "secret_secret") 476 | if sec == "" or key == "": 477 | return self.S_NO_SECRET 478 | 479 | # pylint: disable=E1101 480 | hashed_pass = hashlib.sha512(password.encode("utf-8")).digest() 481 | crypt_key = hashed_pass[:32] 482 | crypt_ini = hashed_pass[-16:] 483 | aes = AES.new(crypt_key, AES.MODE_OFB, crypt_ini) 484 | try: 485 | encrypted_secret = base64.b64decode(sec.strip().encode("ascii")) 486 | self.secret = aes.decrypt(encrypted_secret).strip() 487 | self.key = key.strip() 488 | except ValueError: 489 | return self.S_FAIL 490 | 491 | # now test if we now have something plausible 492 | try: 493 | print("testing secret...") 494 | # is it plain ascii? (if not this will raise exception) 495 | dummy = self.secret.decode("ascii") 496 | # can it be decoded? correct size afterwards? 497 | if len(base64.b64decode(self.secret)) != 64: 498 | raise Exception("decrypted secret has wrong size") 499 | 500 | print("testing key...") 501 | # key must be only hex digits and have the right size 502 | hex_key = self.key.replace("-", "").encode("ascii") 503 | if len(binascii.unhexlify(hex_key)) != 16: 504 | raise Exception("key has wrong size") 505 | 506 | print("ok :-)") 507 | return self.S_OK 508 | 509 | except Exception as exc: 510 | # this key and secret do not work :-( 511 | self.secret = "" 512 | self.key = "" 513 | print("### Error occurred while testing the decrypted secret:") 514 | print(" '%s'" % exc) 515 | print(" This does not seem to be a valid MtGox API secret") 516 | return self.S_FAIL 517 | 518 | def prompt_decrypt(self): 519 | """ask the user for password on the command line 520 | and then try to decrypt the secret.""" 521 | if self.know_secret(): 522 | return self.S_OK 523 | 524 | key = self.config.get_string("gox", "secret_key") 525 | sec = self.config.get_string("gox", "secret_secret") 526 | if sec == "" or key == "": 527 | return self.S_NO_SECRET 528 | 529 | if self.password_from_commandline_option: 530 | password = self.password_from_commandline_option 531 | else: 532 | password = getpass.getpass("enter passphrase for secret: ") 533 | 534 | result = self.decrypt(password) 535 | if result != self.S_OK: 536 | print("") 537 | print("secret could not be decrypted") 538 | answer = input("press any key to continue anyways " \ 539 | + "(trading disabled) or 'q' to quit: ") 540 | if answer == "q": 541 | result = self.S_FAIL_FATAL 542 | else: 543 | result = self.S_NO_SECRET 544 | return result 545 | 546 | # pylint: disable=R0201 547 | def prompt_encrypt(self): 548 | """ask for key, secret and password on the command line, 549 | then encrypt the secret and store it in the ini file.""" 550 | print("Please copy/paste key and secret from MtGox and") 551 | print("then provide a password to encrypt them.") 552 | print("") 553 | 554 | 555 | key = input(" key: ").strip() 556 | secret = input(" secret: ").strip() 557 | while True: 558 | password1 = getpass.getpass(" password: ").strip() 559 | if password1 == "": 560 | print("aborting") 561 | return 562 | password2 = getpass.getpass("password (again): ").strip() 563 | if password1 != password2: 564 | print("you had a typo in the password. try again...") 565 | else: 566 | break 567 | 568 | # pylint: disable=E1101 569 | hashed_pass = hashlib.sha512(password1.encode("utf-8")).digest() 570 | crypt_key = hashed_pass[:32] 571 | crypt_ini = hashed_pass[-16:] 572 | aes = AES.new(crypt_key, AES.MODE_OFB, crypt_ini) 573 | 574 | # since the secret is a base64 string we can just just pad it with 575 | # spaces which can easily be stripped again after decryping 576 | print(len(secret)) 577 | secret += " " * (16 - len(secret) % 16) 578 | print(len(secret)) 579 | secret = base64.b64encode(aes.encrypt(secret)).decode("ascii") 580 | 581 | self.config.set("gox", "secret_key", key) 582 | self.config.set("gox", "secret_secret", secret) 583 | self.config.save() 584 | 585 | print("encrypted secret has been saved in %s" % self.config.filename) 586 | 587 | def know_secret(self): 588 | """do we know the secret key? The application must be able to work 589 | without secret and then just don't do any account related stuff""" 590 | return(self.secret != "") and (self.key != "") 591 | 592 | 593 | class OHLCV(): 594 | """represents a chart candle. tim is POSIX timestamp of open time, 595 | prices and volume are integers like in the other parts of the gox API""" 596 | 597 | def __init__(self, tim, opn, hig, low, cls, vol): 598 | self.tim = tim 599 | self.opn = opn 600 | self.hig = hig 601 | self.low = low 602 | self.cls = cls 603 | self.vol = vol 604 | 605 | def update(self, price, volume): 606 | """update high, low and close values and add to volume""" 607 | if price > self.hig: 608 | self.hig = price 609 | if price < self.low: 610 | self.low = price 611 | self.cls = price 612 | self.vol += volume 613 | 614 | 615 | class History(BaseObject): 616 | """represents the trading history""" 617 | 618 | def __init__(self, gox, timeframe): 619 | BaseObject.__init__(self) 620 | 621 | self.signal_fullhistory_processed = Signal() 622 | self.signal_changed = Signal() 623 | 624 | self.gox = gox 625 | self.candles = [] 626 | self.timeframe = timeframe 627 | 628 | self.ready_history = False 629 | 630 | gox.signal_trade.connect(self.slot_trade) 631 | gox.signal_fullhistory.connect(self.slot_fullhistory) 632 | 633 | def add_candle(self, candle): 634 | """add a new candle to the history""" 635 | self._add_candle(candle) 636 | self.signal_changed(self, (self.length())) 637 | 638 | def slot_trade(self, dummy_sender, data): 639 | """slot for gox.signal_trade""" 640 | (date, price, volume, dummy_typ, own) = data 641 | if not own: 642 | time_round = int(date / self.timeframe) * self.timeframe 643 | candle = self.last_candle() 644 | if candle: 645 | if candle.tim == time_round: 646 | candle.update(price, volume) 647 | self.signal_changed(self, (1)) 648 | else: 649 | self.debug("### opening new candle") 650 | self.add_candle(OHLCV( 651 | time_round, price, price, price, price, volume)) 652 | else: 653 | self.add_candle(OHLCV( 654 | time_round, price, price, price, price, volume)) 655 | 656 | def _add_candle(self, candle): 657 | """add a new candle to the history but don't fire signal_changed""" 658 | self.candles.insert(0, candle) 659 | 660 | def slot_fullhistory(self, dummy_sender, data): 661 | """process the result of the fullhistory request""" 662 | (history) = data 663 | 664 | if not len(history): 665 | self.debug("### history download was empty") 666 | return 667 | 668 | def get_time_round(date): 669 | """round timestamp to current candle timeframe""" 670 | return int(date / self.timeframe) * self.timeframe 671 | 672 | #remove existing recent candle(s) if any, we will create them fresh 673 | date_begin = get_time_round(int(history[0]["date"])) 674 | while len(self.candles) and self.candles[0].tim >= date_begin: 675 | self.candles.pop(0) 676 | 677 | new_candle = OHLCV(0, 0, 0, 0, 0, 0) #this is a dummy, not actually inserted 678 | count_added = 0 679 | for trade in history: 680 | date = int(trade["date"]) 681 | price = int(trade["price_int"]) 682 | volume = int(trade["amount_int"]) 683 | time_round = get_time_round(date) 684 | if time_round > new_candle.tim: 685 | if new_candle.tim > 0: 686 | self._add_candle(new_candle) 687 | count_added += 1 688 | new_candle = OHLCV( 689 | time_round, price, price, price, price, volume) 690 | new_candle.update(price, volume) 691 | 692 | # insert current (incomplete) candle 693 | self._add_candle(new_candle) 694 | count_added += 1 695 | self.debug("### got %d updated candle(s)" % count_added) 696 | self.ready_history = True 697 | self.signal_fullhistory_processed(self, None) 698 | self.signal_changed(self, (self.length())) 699 | 700 | def last_candle(self): 701 | """return the last (current) candle or None if empty""" 702 | if self.length() > 0: 703 | return self.candles[0] 704 | else: 705 | return None 706 | 707 | def length(self): 708 | """return the number of candles in the history""" 709 | return len(self.candles) 710 | 711 | 712 | class BaseClient(BaseObject): 713 | """abstract base class for SocketIOClient and WebsocketClient""" 714 | 715 | _last_unique_microtime = 0 716 | _nonce_lock = threading.Lock() 717 | 718 | def __init__(self, curr_base, curr_quote, secret, config): 719 | BaseObject.__init__(self) 720 | 721 | self.signal_recv = Signal() 722 | self.signal_fulldepth = Signal() 723 | self.signal_fullhistory = Signal() 724 | self.signal_connected = Signal() 725 | self.signal_disconnected = Signal() 726 | 727 | self._timer = Timer(60) 728 | self._timer.connect(self.slot_timer) 729 | 730 | self._info_timer = None # used when delayed requesting private/info 731 | 732 | self.curr_base = curr_base 733 | self.curr_quote = curr_quote 734 | 735 | self.currency = curr_quote # deprecated, use curr_quote instead 736 | 737 | self.secret = secret 738 | self.config = config 739 | self.socket = None 740 | self.http_requests = Queue.Queue() 741 | 742 | self._recv_thread = None 743 | self._http_thread = None 744 | self._terminating = False 745 | self.connected = False 746 | self._time_last_received = 0 747 | self._time_last_subscribed = 0 748 | self.history_last_candle = None 749 | 750 | def start(self): 751 | """start the client""" 752 | self._recv_thread = start_thread(self._recv_thread_func, "socket receive thread") 753 | self._http_thread = start_thread(self._http_thread_func, "http thread") 754 | 755 | def stop(self): 756 | """stop the client""" 757 | self._terminating = True 758 | self._timer.cancel() 759 | if self.socket: 760 | self.debug("### closing socket") 761 | self.socket.sock.close() 762 | 763 | def force_reconnect(self): 764 | """force client to reconnect""" 765 | self.socket.close() 766 | 767 | def _try_send_raw(self, raw_data): 768 | """send raw data to the websocket or disconnect and close""" 769 | if self.connected: 770 | try: 771 | self.socket.send(raw_data) 772 | except Exception as exc: 773 | self.debug(exc) 774 | self.connected = False 775 | self.socket.close() 776 | 777 | def send(self, json_str): 778 | """there exist 2 subtly different ways to send a string over a 779 | websocket. Each client class will override this send method""" 780 | raise NotImplementedError() 781 | 782 | def get_unique_mirotime(self): 783 | """produce a unique nonce that is guaranteed to be ever increasing""" 784 | with self._nonce_lock: 785 | microtime = int(time.time() * 1E6) 786 | if microtime <= self._last_unique_microtime: 787 | microtime = self._last_unique_microtime + 1 788 | self._last_unique_microtime = microtime 789 | return microtime 790 | 791 | def use_http(self): 792 | """should we use http api? return true if yes""" 793 | use_http = self.config.get_bool("gox", "use_http_api") 794 | if FORCE_HTTP_API: 795 | use_http = True 796 | if FORCE_NO_HTTP_API: 797 | use_http = False 798 | return use_http 799 | 800 | def use_tonce(self): 801 | """should we use tonce instead on nonce? tonce is current microtime 802 | and also works when messages come out of order (which happens at 803 | the mtgox server in certain siuations). They still have to be unique 804 | because mtgox will remember all recently used tonce values. It will 805 | only be accepted when the local clock is +/- 10 seconds exact.""" 806 | return self.config.get_bool("gox", "use_tonce") 807 | 808 | def request_fulldepth(self): 809 | """start the fulldepth thread""" 810 | 811 | def fulldepth_thread(): 812 | """request the full market depth, initialize the order book 813 | and then terminate. This is called in a separate thread after 814 | the streaming API has been connected.""" 815 | self.debug("### requesting initial full depth") 816 | use_ssl = self.config.get_bool("gox", "use_ssl") 817 | proto = {True: "https", False: "http"}[use_ssl] 818 | fulldepth = http_request("%s://%s/api/2/%s%s/money/depth/full" % ( 819 | proto, 820 | HTTP_HOST, 821 | self.curr_base, 822 | self.curr_quote 823 | )) 824 | self.signal_fulldepth(self, (json.loads(fulldepth))) 825 | 826 | start_thread(fulldepth_thread, "http request full depth") 827 | 828 | def request_history(self): 829 | """request trading history""" 830 | 831 | # Gox() will have set this field to the timestamp of the last 832 | # known candle, so we only request data since this time 833 | since = self.history_last_candle 834 | 835 | def history_thread(): 836 | """request trading history""" 837 | 838 | # 1308503626, 218868 <-- last small transacion ID 839 | # 1309108565, 1309108565842636 <-- first big transaction ID 840 | 841 | if since: 842 | querystring = "?since=%i" % (since * 1000000) 843 | else: 844 | querystring = "" 845 | 846 | self.debug("### requesting history") 847 | use_ssl = self.config.get_bool("gox", "use_ssl") 848 | proto = {True: "https", False: "http"}[use_ssl] 849 | json_hist = http_request("%s://%s/api/2/%s%s/money/trades%s" % ( 850 | proto, 851 | HTTP_HOST, 852 | self.curr_base, 853 | self.curr_quote, 854 | querystring 855 | )) 856 | history = json.loads(json_hist) 857 | if history["result"] == "success": 858 | self.signal_fullhistory(self, history["data"]) 859 | 860 | start_thread(history_thread, "http request trade history") 861 | 862 | def _recv_thread_func(self): 863 | """this will be executed as the main receiving thread, each type of 864 | client (websocket or socketio) will implement its own""" 865 | raise NotImplementedError() 866 | 867 | def channel_subscribe(self, download_market_data=True): 868 | """subscribe to needed channnels and download initial data (orders, 869 | account info, depth, history, etc. Some of these might be redundant but 870 | at the time I wrote this code the socketio server seemed to have a bug, 871 | not being able to subscribe via the GET parameters, so I send all 872 | needed subscription requests here again, just to be on the safe side.""" 873 | 874 | symb = "%s%s" % (self.curr_base, self.curr_quote) 875 | if not FORCE_NO_DEPTH: 876 | self.send(json.dumps({"op":"mtgox.subscribe", "channel":"depth.%s" % symb})) 877 | self.send(json.dumps({"op":"mtgox.subscribe", "channel":"ticker.%s" % symb})) 878 | 879 | # trades and lag are the same channels for all currencies 880 | self.send(json.dumps({"op":"mtgox.subscribe", "type":"trades"})) 881 | if not FORCE_NO_LAG: 882 | self.send(json.dumps({"op":"mtgox.subscribe", "type":"lag"})) 883 | 884 | self.request_idkey() 885 | self.request_orders() 886 | self.request_info() 887 | 888 | if download_market_data: 889 | if self.config.get_bool("gox", "load_fulldepth"): 890 | if not FORCE_NO_FULLDEPTH: 891 | self.request_fulldepth() 892 | 893 | if self.config.get_bool("gox", "load_history"): 894 | if not FORCE_NO_HISTORY: 895 | self.request_history() 896 | 897 | self._time_last_subscribed = time.time() 898 | 899 | def _slot_timer_info_later(self, _sender, _data): 900 | """the slot for the request_info_later() timer signal""" 901 | self.request_info() 902 | self._info_timer = None 903 | 904 | def request_info_later(self, delay): 905 | """request the private/info in delay seconds from now""" 906 | if self._info_timer: 907 | self._info_timer.cancel() 908 | self._info_timer = Timer(delay, True) 909 | self._info_timer.connect(self._slot_timer_info_later) 910 | 911 | def request_info(self): 912 | """request the private/info object""" 913 | if self.use_http(): 914 | self.enqueue_http_request("money/info", {}, "info") 915 | else: 916 | self.send_signed_call("private/info", {}, "info") 917 | 918 | def request_idkey(self): 919 | """request the private/idkey object""" 920 | if self.use_http(): 921 | self.enqueue_http_request("money/idkey", {}, "idkey") 922 | else: 923 | self.send_signed_call("private/idkey", {}, "idkey") 924 | 925 | def request_orders(self): 926 | """request the private/orders object""" 927 | if self.use_http(): 928 | self.enqueue_http_request("money/orders", {}, "orders") 929 | else: 930 | self.send_signed_call("private/orders", {}, "orders") 931 | 932 | def _http_thread_func(self): 933 | """send queued http requests to the http API (only used when 934 | http api is forced, normally this is much slower)""" 935 | while not self._terminating: 936 | # pop queued request from the queue and process it 937 | (api_endpoint, params, reqid) = self.http_requests.get(True) 938 | translated = None 939 | try: 940 | answer = self.http_signed_call(api_endpoint, params) 941 | if answer["result"] == "success": 942 | # the following will reformat the answer in such a way 943 | # that we can pass it directly to signal_recv() 944 | # as if it had come directly from the websocket 945 | translated = { 946 | "op": "result", 947 | "result": answer["data"], 948 | "id": reqid 949 | } 950 | else: 951 | if "error" in answer: 952 | if answer["token"] == "unknown_error": 953 | # enqueue it again, it will eventually succeed. 954 | self.enqueue_http_request(api_endpoint, params, reqid) 955 | else: 956 | 957 | # these are errors like "Order amount is too low" 958 | # or "Order not found" and the like, we send them 959 | # to signal_recv() as if they had come from the 960 | # streaming API beause Gox() can handle these errors. 961 | translated = { 962 | "op": "remark", 963 | "success": False, 964 | "message": answer["error"], 965 | "token": answer["token"], 966 | "id": reqid 967 | } 968 | 969 | else: 970 | self.debug("### unexpected http result:", answer, reqid) 971 | 972 | except Exception as exc: 973 | # should this ever happen? HTTP 5xx wont trigger this, 974 | # something else must have gone wrong, a totally malformed 975 | # reply or something else. 976 | # 977 | # After some time of testing during times of heavy 978 | # volatility it appears that this happens mostly when 979 | # there is heavy load on their servers. Resubmitting 980 | # the API call will then eventally succeed. 981 | self.debug("### exception in _http_thread_func:", 982 | exc, api_endpoint, params, reqid) 983 | 984 | # enqueue it again, it will eventually succeed. 985 | self.enqueue_http_request(api_endpoint, params, reqid) 986 | 987 | if translated: 988 | self.signal_recv(self, (json.dumps(translated))) 989 | 990 | self.http_requests.task_done() 991 | 992 | def enqueue_http_request(self, api_endpoint, params, reqid): 993 | """enqueue a request for sending to the HTTP API, returns 994 | immediately, behaves exactly like sending it over the websocket.""" 995 | if self.secret and self.secret.know_secret(): 996 | self.http_requests.put((api_endpoint, params, reqid)) 997 | 998 | def http_signed_call(self, api_endpoint, params): 999 | """send a signed request to the HTTP API V2""" 1000 | if (not self.secret) or (not self.secret.know_secret()): 1001 | self.debug("### don't know secret, cannot call %s" % api_endpoint) 1002 | return 1003 | 1004 | key = self.secret.key 1005 | sec = self.secret.secret 1006 | 1007 | if self.use_tonce(): 1008 | params["tonce"] = self.get_unique_mirotime() 1009 | else: 1010 | params["nonce"] = self.get_unique_mirotime() 1011 | 1012 | post = urlencode(params) 1013 | prefix = api_endpoint + chr(0) 1014 | # pylint: disable=E1101 1015 | sign = hmac.new(base64.b64decode(sec), prefix + post, hashlib.sha512).digest() 1016 | 1017 | headers = { 1018 | 'Rest-Key': key, 1019 | 'Rest-Sign': base64.b64encode(sign) 1020 | } 1021 | 1022 | use_ssl = self.config.get_bool("gox", "use_ssl") 1023 | proto = {True: "https", False: "http"}[use_ssl] 1024 | url = "%s://%s/api/2/%s" % ( 1025 | proto, 1026 | HTTP_HOST, 1027 | api_endpoint 1028 | ) 1029 | self.debug("### (%s) calling %s" % (proto, url)) 1030 | return json.loads(http_request(url, post, headers)) 1031 | 1032 | def send_signed_call(self, api_endpoint, params, reqid): 1033 | """send a signed (authenticated) API call over the socket.io. 1034 | This method will only succeed if the secret key is available, 1035 | otherwise it will just log a warning and do nothing.""" 1036 | if (not self.secret) or (not self.secret.know_secret()): 1037 | self.debug("### don't know secret, cannot call %s" % api_endpoint) 1038 | return 1039 | 1040 | key = self.secret.key 1041 | sec = self.secret.secret 1042 | 1043 | call = { 1044 | "id" : reqid, 1045 | "call" : api_endpoint, 1046 | "params" : params, 1047 | "currency" : self.curr_quote, 1048 | "item" : self.curr_base 1049 | } 1050 | if self.use_tonce(): 1051 | call["tonce"] = self.get_unique_mirotime() 1052 | else: 1053 | call["nonce"] = self.get_unique_mirotime() 1054 | call = json.dumps(call) 1055 | 1056 | # pylint: disable=E1101 1057 | sign = hmac.new(base64.b64decode(sec), call, hashlib.sha512).digest() 1058 | signedcall = key.replace("-", "").decode("hex") + sign + call 1059 | 1060 | self.debug("### (socket) calling %s" % api_endpoint) 1061 | self.send(json.dumps({ 1062 | "op" : "call", 1063 | "call" : base64.b64encode(signedcall), 1064 | "id" : reqid, 1065 | "context" : "mtgox.com" 1066 | })) 1067 | 1068 | def send_order_add(self, typ, price, volume): 1069 | """send an order""" 1070 | reqid = "order_add:%s:%d:%d" % (typ, price, volume) 1071 | if price > 0: 1072 | params = {"type": typ, "price_int": price, "amount_int": volume} 1073 | else: 1074 | params = {"type": typ, "amount_int": volume} 1075 | 1076 | if self.use_http(): 1077 | api = "%s%s/money/order/add" % (self.curr_base , self.curr_quote) 1078 | self.enqueue_http_request(api, params, reqid) 1079 | else: 1080 | api = "order/add" 1081 | self.send_signed_call(api, params, reqid) 1082 | 1083 | def send_order_cancel(self, oid): 1084 | """cancel an order""" 1085 | params = {"oid": oid} 1086 | reqid = "order_cancel:%s" % oid 1087 | if self.use_http(): 1088 | api = "money/order/cancel" 1089 | self.enqueue_http_request(api, params, reqid) 1090 | else: 1091 | api = "order/cancel" 1092 | self.send_signed_call(api, params, reqid) 1093 | 1094 | def on_idkey_received(self, data): 1095 | """id key was received, subscribe to private channel""" 1096 | self.send(json.dumps({"op":"mtgox.subscribe", "key":data})) 1097 | 1098 | def slot_timer(self, _sender, _data): 1099 | """check timeout (last received, dead socket?)""" 1100 | if self.connected: 1101 | if time.time() - self._time_last_received > 60: 1102 | self.debug("### did not receive anything for a long time, disconnecting.") 1103 | self.force_reconnect() 1104 | self.connected = False 1105 | if time.time() - self._time_last_subscribed > 1800: 1106 | # sometimes after running for a few hours it 1107 | # will lose some of the subscriptons for no 1108 | # obvious reason. I've seen it losing the trades 1109 | # and the lag channel channel already, and maybe 1110 | # even others. Simply subscribing again completely 1111 | # fixes this condition. For this reason we renew 1112 | # all channel subscriptions once every hour. 1113 | self.debug("### refreshing channel subscriptions") 1114 | self.channel_subscribe(False) 1115 | 1116 | 1117 | class WebsocketClient(BaseClient): 1118 | """this implements a connection to MtGox through the websocket protocol.""" 1119 | def __init__(self, curr_base, curr_quote, secret, config): 1120 | BaseClient.__init__(self, curr_base, curr_quote, secret, config) 1121 | self.hostname = WEBSOCKET_HOST 1122 | 1123 | def _recv_thread_func(self): 1124 | """connect to the websocket and start receiving in an infinite loop. 1125 | Try to reconnect whenever connection is lost. Each received json 1126 | string will be dispatched with a signal_recv signal""" 1127 | reconnect_time = 1 1128 | use_ssl = self.config.get_bool("gox", "use_ssl") 1129 | wsp = {True: "wss://", False: "ws://"}[use_ssl] 1130 | port = {True: 443, False: 80}[use_ssl] 1131 | ws_origin = "%s:%d" % (self.hostname, port) 1132 | ws_headers = ["User-Agent: %s" % USER_AGENT] 1133 | while not self._terminating: #loop 0 (connect, reconnect) 1134 | try: 1135 | # channels separated by "/", wildcards allowed. Available 1136 | # channels see here: https://mtgox.com/api/2/stream/list_public 1137 | # example: ws://websocket.mtgox.com/?Channel=depth.LTCEUR/ticker.LTCEUR 1138 | # the trades and lag channel will be subscribed after connect 1139 | sym = "%s%s" % (self.curr_base, self.curr_quote) 1140 | if not FORCE_NO_DEPTH: 1141 | ws_url = "%s%s?Channel=depth.%s/ticker.%s" % \ 1142 | (wsp, self.hostname, sym, sym) 1143 | else: 1144 | ws_url = "%s%s?Channel=ticker.%s" % \ 1145 | (wsp, self.hostname, sym) 1146 | self.debug("### trying plain old Websocket: %s ... " % ws_url) 1147 | 1148 | self.socket = websocket.WebSocket() 1149 | # The server is somewhat picky when it comes to the exact 1150 | # host:port syntax of the origin header, so I am supplying 1151 | # my own origin header instead of the auto-generated one 1152 | self.socket.connect(ws_url, origin=ws_origin, header=ws_headers) 1153 | self._time_last_received = time.time() 1154 | self.connected = True 1155 | self.debug("### connected, subscribing needed channels") 1156 | self.channel_subscribe() 1157 | self.debug("### waiting for data...") 1158 | self.signal_connected(self, None) 1159 | while not self._terminating: #loop1 (read messages) 1160 | str_json = self.socket.recv() 1161 | self._time_last_received = time.time() 1162 | if str_json[0] == "{": 1163 | self.signal_recv(self, (str_json)) 1164 | 1165 | except Exception as exc: 1166 | self.connected = False 1167 | self.signal_disconnected(self, None) 1168 | if not self._terminating: 1169 | self.debug("### ", exc.__class__.__name__, exc, 1170 | "reconnecting in %i seconds..." % reconnect_time) 1171 | if self.socket: 1172 | self.socket.close() 1173 | time.sleep(reconnect_time) 1174 | 1175 | def send(self, json_str): 1176 | """send the json encoded string over the websocket""" 1177 | self._try_send_raw(json_str) 1178 | 1179 | 1180 | class SocketIO(websocket.WebSocket): 1181 | """This is the WebSocket() class with added Super Cow Powers. It has a 1182 | different connect method so that it can connect to socket.io. It will do 1183 | the initial HTTP request with keep-alive and then use that same socket 1184 | to upgrade to websocket""" 1185 | def __init__(self, get_mask_key = None): 1186 | websocket.WebSocket.__init__(self, get_mask_key) 1187 | 1188 | def connect(self, url, **options): 1189 | """connect to socketio and then upgrade to websocket transport. Example: 1190 | connect('wss://websocket.mtgox.com/socket.io/1', query='Currency=EUR')""" 1191 | 1192 | def read_block(sock): 1193 | """read from the socket until empty line, return list of lines""" 1194 | lines = [] 1195 | line = "" 1196 | while True: 1197 | res = sock.recv(1) 1198 | line += res 1199 | if res == "": 1200 | return None 1201 | if res == "\n": 1202 | line = line.strip() 1203 | if line == "": 1204 | return lines 1205 | lines.append(line) 1206 | line = "" 1207 | 1208 | # pylint: disable=W0212 1209 | hostname, port, resource, is_secure = websocket._parse_url(url) 1210 | self.sock.connect((hostname, port)) 1211 | if is_secure: 1212 | self.io_sock = websocket._SSLSocketWrapper(self.sock) 1213 | 1214 | path_a = resource 1215 | if "query" in options: 1216 | path_a += "?" + options["query"] 1217 | self.io_sock.send("GET %s HTTP/1.1\r\n" % path_a) 1218 | self.io_sock.send("Host: %s:%d\r\n" % (hostname, port)) 1219 | self.io_sock.send("User-Agent: %s\r\n" % USER_AGENT) 1220 | self.io_sock.send("Accept: text/plain\r\n") 1221 | self.io_sock.send("Connection: keep-alive\r\n") 1222 | self.io_sock.send("\r\n") 1223 | 1224 | headers = read_block(self.io_sock) 1225 | if not headers: 1226 | raise IOError("disconnected while reading headers") 1227 | if not "200" in headers[0]: 1228 | raise IOError("wrong answer: %s" % headers[0]) 1229 | result = read_block(self.io_sock) 1230 | if not result: 1231 | raise IOError("disconnected while reading socketio session ID") 1232 | if len(result) != 3: 1233 | raise IOError("invalid response from socket.io server") 1234 | 1235 | ws_id = result[1].split(":")[0] 1236 | resource = "%s/websocket/%s" % (resource, ws_id) 1237 | if "query" in options: 1238 | resource = "%s?%s" % (resource, options["query"]) 1239 | 1240 | # now continue with the normal websocket GET and upgrade request 1241 | self._handshake(hostname, port, resource, **options) 1242 | 1243 | 1244 | class PubnubClient(BaseClient): 1245 | """"This implements the pubnub client. This client cannot send trade 1246 | requests over the streamin API, therefore all interaction with MtGox has 1247 | to happen through http(s) api, this client will enforce this flag to be 1248 | set automatically.""" 1249 | def __init__(self, curr_base, curr_quote, secret, config): 1250 | global FORCE_HTTP_API #pylint: disable=W0603 1251 | BaseClient.__init__(self, curr_base, curr_quote, secret, config) 1252 | FORCE_HTTP_API = True 1253 | self._pubnub = None 1254 | self._pubnub_priv = None 1255 | self._private_thread_started = False 1256 | self.stream_sorter = PubnubStreamSorter( 1257 | self.config.get_float("pubnub", "stream_sorter_time_window")) 1258 | self.stream_sorter.signal_pop.connect(self.signal_recv) 1259 | self.stream_sorter.signal_debug.connect(self.signal_debug) 1260 | 1261 | def start(self): 1262 | BaseClient.start(self) 1263 | self.stream_sorter.start() 1264 | 1265 | def stop(self): 1266 | """stop the client""" 1267 | self._terminating = True 1268 | self.stream_sorter.stop() 1269 | self._timer.cancel() 1270 | self.force_reconnect() 1271 | 1272 | def force_reconnect(self): 1273 | self.connected = False 1274 | self.signal_disconnected(self, None) 1275 | # as long as the _terinating flag is not set 1276 | # a hup() will just make them reconnect, 1277 | # the same way a network failure would do. 1278 | if self._pubnub_priv: 1279 | self._pubnub_priv.hup() 1280 | if self._pubnub: 1281 | self._pubnub.hup() 1282 | 1283 | def send(self, _msg): 1284 | # can't send with this client, 1285 | self.debug("### invalid attempt to use send() with Pubnub client") 1286 | 1287 | def _recv_thread_func(self): 1288 | self._pubnub = pubnub_light.PubNub() 1289 | self._pubnub.subscribe( 1290 | 'sub-c-50d56e1e-2fd9-11e3-a041-02ee2ddab7fe', 1291 | ",".join([ 1292 | CHANNELS['depth.%s%s' % (self.curr_base, self.curr_quote)], 1293 | CHANNELS['ticker.%s%s' % (self.curr_base, self.curr_quote)], 1294 | CHANNELS['trade.%s' % self.curr_base], 1295 | CHANNELS['trade.lag'] 1296 | ]), 1297 | "", 1298 | "", 1299 | self.config.get_bool("gox", "use_ssl") 1300 | ) 1301 | 1302 | # the following doesn't actually subscribe to the public channels 1303 | # in this implementation, it only gets acct info and market data 1304 | # and enqueue a request for the pricate channel auth credentials 1305 | self.channel_subscribe(True) 1306 | 1307 | self.debug("### starting public channel pubnub client") 1308 | while not self._terminating: 1309 | try: 1310 | while not self._terminating: 1311 | messages = self._pubnub.read() 1312 | self._time_last_received = time.time() 1313 | if not self.connected: 1314 | self.connected = True 1315 | self.signal_connected(self, None) 1316 | for _channel, message in messages: 1317 | self.stream_sorter.put(message) 1318 | except Exception: 1319 | self.debug("### public channel interrupted") 1320 | #self.debug(traceback.format_exc()) 1321 | if not self._terminating: 1322 | time.sleep(1) 1323 | self.debug("### public channel restarting") 1324 | 1325 | self.debug("### public channel thread terminated") 1326 | 1327 | def _recv_private_thread_func(self): 1328 | """thread for receiving the private messages""" 1329 | self.debug("### starting private channel pubnub client") 1330 | while not self._terminating: 1331 | try: 1332 | while not self._terminating: 1333 | messages = self._pubnub_priv.read() 1334 | self._time_last_received = time.time() 1335 | for _channel, message in messages: 1336 | self.stream_sorter.put(message) 1337 | 1338 | except Exception: 1339 | self.debug("### private channel interrupted") 1340 | #self.debug(traceback.format_exc()) 1341 | if not self._terminating: 1342 | time.sleep(1) 1343 | self.debug("### private channel restarting") 1344 | 1345 | self.debug("### private channel thread terminated") 1346 | 1347 | def _pubnub_receive(self, msg): 1348 | """callback method called by pubnub when a message is received""" 1349 | self.signal_recv(self, msg) 1350 | self._time_last_received = time.time() 1351 | return not self._terminating 1352 | 1353 | def channel_subscribe(self, download_market_data=False): 1354 | # no channels to subscribe, this happened in PubNub.__init__ already 1355 | if self.secret and self.secret.know_secret(): 1356 | self.enqueue_http_request("stream/private_get", {}, "idkey") 1357 | 1358 | self.request_info() 1359 | self.request_orders() 1360 | 1361 | if download_market_data: 1362 | if self.config.get_bool("gox", "load_fulldepth"): 1363 | if not FORCE_NO_FULLDEPTH: 1364 | self.request_fulldepth() 1365 | if self.config.get_bool("gox", "load_history"): 1366 | if not FORCE_NO_HISTORY: 1367 | self.request_history() 1368 | 1369 | self._time_last_subscribed = time.time() 1370 | 1371 | def on_idkey_received(self, data): 1372 | if not self._pubnub_priv: 1373 | self.debug("### init private pubnub") 1374 | self._pubnub_priv = pubnub_light.PubNub() 1375 | 1376 | self._pubnub_priv.subscribe( 1377 | data["sub"], 1378 | data["channel"], 1379 | data["auth"], 1380 | data["cipher"], 1381 | self.config.get_bool("gox", "use_ssl") 1382 | ) 1383 | 1384 | if not self._private_thread_started: 1385 | start_thread(self._recv_private_thread_func, "private channel thread") 1386 | self._private_thread_started = True 1387 | 1388 | 1389 | class PubnubStreamSorter(BaseObject): 1390 | """sort the incoming messages by "stamp" field. This will introduce 1391 | a delay but its the only way to get these messages into proper order.""" 1392 | def __init__(self, delay): 1393 | BaseObject.__init__(self) 1394 | self.delay = delay 1395 | self.queue = [] 1396 | self.terminating = False 1397 | self.stat_last = 0 1398 | self.stat_bad = 0 1399 | self.stat_good = 0 1400 | self.signal_pop = Signal() 1401 | self.lock = threading.Lock() 1402 | 1403 | def start(self): 1404 | """start the extraction thread""" 1405 | start_thread(self._extract_thread_func, "message sorter thread") 1406 | self.debug("### initialized stream sorter with %g s time window" 1407 | % (self.delay)) 1408 | 1409 | def put(self, message): 1410 | """put a message into the queue""" 1411 | stamp = int(message["stamp"]) / 1000000.0 1412 | 1413 | # sort it into the existing waiting messages 1414 | self.lock.acquire() 1415 | bisect.insort(self.queue, (stamp, time.time(), message)) 1416 | self.lock.release() 1417 | 1418 | def stop(self): 1419 | """terminate the sorter thread""" 1420 | self.terminating = True 1421 | 1422 | def _extract_thread_func(self): 1423 | """this thread will permanently pop oldest messages 1424 | from the queue after they have stayed delay time in 1425 | it and fire signal_pop for each message.""" 1426 | while not self.terminating: 1427 | self.lock.acquire() 1428 | while self.queue \ 1429 | and self.queue[0][1] + self.delay < time.time(): 1430 | (stamp, _received, msg) = self.queue.pop(0) 1431 | self._update_statistics(stamp, msg) 1432 | self.signal_pop(self, (msg)) 1433 | self.lock.release() 1434 | time.sleep(50E-3) 1435 | 1436 | def _update_statistics(self, stamp, _msg): 1437 | """collect some statistics and print to log occasionally""" 1438 | if stamp < self.stat_last: 1439 | self.stat_bad += 1 1440 | self.debug("### message late:", self.stat_last - stamp) 1441 | else: 1442 | self.stat_good += 1 1443 | self.stat_last = stamp 1444 | if self.stat_good % 2000 == 0: 1445 | if self.stat_good + self.stat_bad > 0: 1446 | self.debug("### stream sorter: good:%i bad:%i (%g%%)" % \ 1447 | (self.stat_good, self.stat_bad, \ 1448 | 100.0 * self.stat_bad / (self.stat_bad + self.stat_good))) 1449 | 1450 | 1451 | class SocketIOClient(BaseClient): 1452 | """this implements a connection to MtGox using the socketIO protocol.""" 1453 | 1454 | def __init__(self, curr_base, curr_quote, secret, config): 1455 | BaseClient.__init__(self, curr_base, curr_quote, secret, config) 1456 | self.hostname = SOCKETIO_HOST 1457 | self._timer.connect(self.slot_keepalive_timer) 1458 | 1459 | def _recv_thread_func(self): 1460 | """this is the main thread that is running all the time. It will 1461 | connect and then read (blocking) on the socket in an infinite 1462 | loop. SocketIO messages ('2::', etc.) are handled here immediately 1463 | and all received json strings are dispathed with signal_recv.""" 1464 | use_ssl = self.config.get_bool("gox", "use_ssl") 1465 | wsp = {True: "wss://", False: "ws://"}[use_ssl] 1466 | while not self._terminating: #loop 0 (connect, reconnect) 1467 | try: 1468 | url = "%s%s/socket.io/1" % (wsp, self.hostname) 1469 | 1470 | # subscribing depth and ticker through the querystring, 1471 | # the trade and lag will be subscribed later after connect 1472 | sym = "%s%s" % (self.curr_base, self.curr_quote) 1473 | if not FORCE_NO_DEPTH: 1474 | querystring = "Channel=depth.%s/ticker.%s" % (sym, sym) 1475 | else: 1476 | querystring = "Channel=ticker.%s" % (sym) 1477 | self.debug("### trying Socket.IO: %s?%s ..." % (url, querystring)) 1478 | self.socket = SocketIO() 1479 | self.socket.connect(url, query=querystring) 1480 | 1481 | self._time_last_received = time.time() 1482 | self.connected = True 1483 | self.debug("### connected") 1484 | self.socket.send("1::/mtgox") 1485 | 1486 | self.debug(self.socket.recv()) 1487 | self.debug(self.socket.recv()) 1488 | 1489 | self.debug("### subscribing to channels") 1490 | self.channel_subscribe() 1491 | 1492 | self.debug("### waiting for data...") 1493 | self.signal_connected(self, None) 1494 | while not self._terminating: #loop1 (read messages) 1495 | msg = self.socket.recv() 1496 | self._time_last_received = time.time() 1497 | if msg == "2::": 1498 | #self.debug("### ping -> pong") 1499 | self.socket.send("2::") 1500 | continue 1501 | prefix = msg[:10] 1502 | if prefix == "4::/mtgox:": 1503 | str_json = msg[10:] 1504 | if str_json[0] == "{": 1505 | self.signal_recv(self, (str_json)) 1506 | 1507 | except Exception as exc: 1508 | self.connected = False 1509 | self.signal_disconnected(self, None) 1510 | if not self._terminating: 1511 | self.debug("### ", exc.__class__.__name__, exc, \ 1512 | "reconnecting in 1 seconds...") 1513 | self.socket.close() 1514 | time.sleep(1) 1515 | 1516 | def send(self, json_str): 1517 | """send a string to the websocket. This method will prepend it 1518 | with the 1::/mtgox: that is needed for the socket.io protocol 1519 | (as opposed to plain websockts) and the underlying websocket 1520 | will then do the needed framing on top of that.""" 1521 | self._try_send_raw("4::/mtgox:" + json_str) 1522 | 1523 | def slot_keepalive_timer(self, _sender, _data): 1524 | """send a keepalive, just to make sure our socket is not dead""" 1525 | if self.connected: 1526 | #self.debug("### sending keepalive") 1527 | self._try_send_raw("2::") 1528 | 1529 | 1530 | # pylint: disable=R0902 1531 | class Gox(BaseObject): 1532 | """represents the API of the MtGox exchange. An Instance of this 1533 | class will connect to the streaming socket.io API, receive live 1534 | events, it will emit signals you can hook into for all events, 1535 | it has methods to buy and sell""" 1536 | 1537 | def __init__(self, secret, config): 1538 | """initialize the gox API but do not yet connect to it.""" 1539 | BaseObject.__init__(self) 1540 | 1541 | self.signal_depth = Signal() 1542 | self.signal_trade = Signal() 1543 | self.signal_ticker = Signal() 1544 | self.signal_fulldepth = Signal() 1545 | self.signal_fullhistory = Signal() 1546 | self.signal_wallet = Signal() 1547 | self.signal_userorder = Signal() 1548 | self.signal_orderlag = Signal() 1549 | self.signal_disconnected = Signal() # socket connection lost 1550 | self.signal_ready = Signal() # connected and fully initialized 1551 | 1552 | self.signal_order_too_fast = Signal() # don't use that 1553 | 1554 | self.strategies = weakref.WeakValueDictionary() 1555 | 1556 | # the following are not fired by gox itself but by the 1557 | # application controlling it to pass some of its events 1558 | self.signal_keypress = Signal() 1559 | self.signal_strategy_unload = Signal() 1560 | 1561 | self._idkey = "" 1562 | self.wallet = {} 1563 | self.trade_fee = 0 # percent (float, for example 0.6 means 0.6%) 1564 | self.monthly_volume = 0 # BTC (satoshi int) 1565 | self.order_lag = 0 # microseconds 1566 | self.socket_lag = 0 # microseconds 1567 | self.last_tid = 0 1568 | self.count_submitted = 0 # number of submitted orders not yet acked 1569 | self.msg = {} # the incoming message that is currently processed 1570 | 1571 | # the following will be set to true once the information 1572 | # has been received after connect, once all thes flags are 1573 | # true it will emit the signal_connected. 1574 | self.ready_idkey = False 1575 | self.ready_info = False 1576 | self._was_disconnected = True 1577 | 1578 | self.config = config 1579 | self.curr_base = config.get_string("gox", "base_currency") 1580 | self.curr_quote = config.get_string("gox", "quote_currency") 1581 | 1582 | self.currency = self.curr_quote # deprecated, use curr_quote instead 1583 | 1584 | # these are needed for conversion from/to intereger, float, string 1585 | if self.curr_quote in "JPY SEK": 1586 | self.mult_quote = 1e3 1587 | self.format_quote = "%12.3f" 1588 | else: 1589 | self.mult_quote = 1e5 1590 | self.format_quote = "%12.5f" 1591 | self.mult_base = 1e8 1592 | self.format_base = "%16.8f" 1593 | 1594 | Signal.signal_error.connect(self.signal_debug) 1595 | 1596 | timeframe = 60 * config.get_int("gox", "history_timeframe") 1597 | if not timeframe: 1598 | timeframe = 60 * 15 1599 | self.history = History(self, timeframe) 1600 | self.history.signal_debug.connect(self.signal_debug) 1601 | 1602 | self.orderbook = OrderBook(self) 1603 | self.orderbook.signal_debug.connect(self.signal_debug) 1604 | 1605 | use_websocket = self.config.get_bool("gox", "use_plain_old_websocket") 1606 | use_pubnub = False 1607 | 1608 | if "socketio" in FORCE_PROTOCOL: 1609 | use_websocket = False 1610 | if "websocket" in FORCE_PROTOCOL: 1611 | use_websocket = True 1612 | if "pubnub" in FORCE_PROTOCOL: 1613 | use_websocket = False 1614 | use_pubnub = True 1615 | 1616 | if use_websocket: 1617 | self.client = WebsocketClient(self.curr_base, self.curr_quote, secret, config) 1618 | else: 1619 | if use_pubnub: 1620 | self.client = PubnubClient(self.curr_base, self.curr_quote, secret, config) 1621 | else: 1622 | self.client = SocketIOClient(self.curr_base, self.curr_quote, secret, config) 1623 | 1624 | self.client.signal_debug.connect(self.signal_debug) 1625 | self.client.signal_disconnected.connect(self.slot_disconnected) 1626 | self.client.signal_connected.connect(self.slot_client_connected) 1627 | self.client.signal_recv.connect(self.slot_recv) 1628 | self.client.signal_fulldepth.connect(self.signal_fulldepth) 1629 | self.client.signal_fullhistory.connect(self.signal_fullhistory) 1630 | 1631 | self.timer_poll = Timer(120) 1632 | self.timer_poll.connect(self.slot_poll) 1633 | 1634 | self.history.signal_changed.connect(self.slot_history_changed) 1635 | self.history.signal_fullhistory_processed.connect(self.slot_fullhistory_processed) 1636 | self.orderbook.signal_fulldepth_processed.connect(self.slot_fulldepth_processed) 1637 | self.orderbook.signal_owns_initialized.connect(self.slot_owns_initialized) 1638 | 1639 | def start(self): 1640 | """connect to MtGox and start receiving events.""" 1641 | self.debug("### starting gox streaming API, trading %s%s" % 1642 | (self.curr_base, self.curr_quote)) 1643 | self.client.start() 1644 | 1645 | def stop(self): 1646 | """shutdown the client""" 1647 | self.debug("### shutdown...") 1648 | self.client.stop() 1649 | 1650 | def order(self, typ, price, volume): 1651 | """place pending order. If price=0 then it will be filled at market""" 1652 | self.count_submitted += 1 1653 | self.client.send_order_add(typ, price, volume) 1654 | 1655 | def buy(self, price, volume): 1656 | """new buy order, if price=0 then buy at market""" 1657 | self.order("bid", price, volume) 1658 | 1659 | def sell(self, price, volume): 1660 | """new sell order, if price=0 then sell at market""" 1661 | self.order("ask", price, volume) 1662 | 1663 | def cancel(self, oid): 1664 | """cancel order""" 1665 | self.client.send_order_cancel(oid) 1666 | 1667 | def cancel_by_price(self, price): 1668 | """cancel all orders at price""" 1669 | for i in reversed(range(len(self.orderbook.owns))): 1670 | order = self.orderbook.owns[i] 1671 | if order.price == price: 1672 | if order.oid != "": 1673 | self.cancel(order.oid) 1674 | 1675 | def cancel_by_type(self, typ=None): 1676 | """cancel all orders of type (or all orders if typ=None)""" 1677 | for i in reversed(range(len(self.orderbook.owns))): 1678 | order = self.orderbook.owns[i] 1679 | if typ == None or typ == order.typ: 1680 | if order.oid != "": 1681 | self.cancel(order.oid) 1682 | 1683 | def base2float(self, int_number): 1684 | """convert base currency values from mtgox integer to float. Base 1685 | currency are the coins you are trading (BTC, LTC, etc). Use this method 1686 | to convert order volumes (amount of coins) from int to float.""" 1687 | return float(int_number) / self.mult_base 1688 | 1689 | def base2str(self, int_number): 1690 | """convert base currency values from mtgox integer to formatted string""" 1691 | return self.format_base % (float(int_number) / self.mult_base) 1692 | 1693 | def base2int(self, float_number): 1694 | """convert base currency values from float to mtgox integer""" 1695 | return int(round(float_number * self.mult_base)) 1696 | 1697 | def quote2float(self, int_number): 1698 | """convert quote currency values from mtgox integer to float. Quote 1699 | currency is the currency used to quote prices (USD, EUR, etc), use this 1700 | method to convert the prices of orders, bid or ask from int to float.""" 1701 | return float(int_number) / self.mult_quote 1702 | 1703 | def quote2str(self, int_number): 1704 | """convert quote currency values from mtgox integer to formatted string""" 1705 | return self.format_quote % (float(int_number) / self.mult_quote) 1706 | 1707 | def quote2int(self, float_number): 1708 | """convert quote currency values from float to mtgox integer""" 1709 | return int(round(float_number * self.mult_quote)) 1710 | 1711 | def check_connect_ready(self): 1712 | """check if everything that is needed has been downloaded 1713 | and emit the connect signal if everything is ready""" 1714 | need_no_account = not self.client.secret.know_secret() 1715 | need_no_depth = not self.config.get_bool("gox", "load_fulldepth") 1716 | need_no_history = not self.config.get_bool("gox", "load_history") 1717 | need_no_depth = need_no_depth or FORCE_NO_FULLDEPTH 1718 | need_no_history = need_no_history or FORCE_NO_HISTORY 1719 | ready_account = \ 1720 | self.ready_idkey and self.ready_info and self.orderbook.ready_owns 1721 | if ready_account or need_no_account: 1722 | if self.orderbook.ready_depth or need_no_depth: 1723 | if self.history.ready_history or need_no_history: 1724 | if self._was_disconnected: 1725 | self.signal_ready(self, None) 1726 | self._was_disconnected = False 1727 | 1728 | def slot_client_connected(self, _sender, _data): 1729 | """connected to the client""" 1730 | self.check_connect_ready() 1731 | 1732 | def slot_fulldepth_processed(self, _sender, _data): 1733 | """connected to the orderbook""" 1734 | self.check_connect_ready() 1735 | 1736 | def slot_fullhistory_processed(self, _sender, _data): 1737 | """connected to the history""" 1738 | self.check_connect_ready() 1739 | 1740 | def slot_owns_initialized(self, _sender, _data): 1741 | """connected to the orderbook""" 1742 | self.check_connect_ready() 1743 | 1744 | def slot_disconnected(self, _sender, _data): 1745 | """this slot is connected to the client object, all it currently 1746 | does is to emit a disconnected signal itself""" 1747 | self.ready_idkey = False 1748 | self.ready_info = False 1749 | self.orderbook.ready_owns = False 1750 | self.orderbook.ready_depth = False 1751 | self.history.ready_history = False 1752 | self._was_disconnected = True 1753 | self.signal_disconnected(self, None) 1754 | 1755 | def slot_recv(self, dummy_sender, data): 1756 | """Slot for signal_recv, handle new incoming JSON message. Decode the 1757 | JSON string into a Python object and dispatch it to the method that 1758 | can handle it.""" 1759 | (str_json) = data 1760 | handler = None 1761 | if type(str_json) == dict: 1762 | msg = str_json # was already a dict 1763 | else: 1764 | msg = json.loads(str_json) 1765 | self.msg = msg 1766 | 1767 | if "stamp" in msg: 1768 | delay = time.time() * 1e6 - int(msg["stamp"]) 1769 | self.socket_lag = (self.socket_lag * 29 + delay) / 30 1770 | 1771 | if "op" in msg: 1772 | try: 1773 | msg_op = msg["op"] 1774 | handler = getattr(self, "_on_op_" + msg_op) 1775 | 1776 | except AttributeError: 1777 | self.debug("slot_recv() ignoring: op=%s" % msg_op) 1778 | else: 1779 | self.debug("slot_recv() ignoring:", msg) 1780 | 1781 | if handler: 1782 | handler(msg) 1783 | 1784 | def slot_poll(self, _sender, _data): 1785 | """poll stuff from http in regular intervals, not yet implemented""" 1786 | if self.client.secret and self.client.secret.know_secret(): 1787 | # poll recent own trades 1788 | # fixme: how do i do this, whats the api for this? 1789 | pass 1790 | 1791 | def slot_history_changed(self, _sender, _data): 1792 | """this is a small optimzation, if we tell the client the time 1793 | of the last known candle then it won't fetch full history next time""" 1794 | last_candle = self.history.last_candle() 1795 | if last_candle: 1796 | self.client.history_last_candle = last_candle.tim 1797 | 1798 | def _on_op_error(self, msg): 1799 | """handle error mesages (op:error)""" 1800 | self.debug("### _on_op_error()", msg) 1801 | 1802 | def _on_op_subscribe(self, msg): 1803 | """handle subscribe messages (op:subscribe)""" 1804 | self.debug("### subscribed channel", msg["channel"]) 1805 | 1806 | def _on_op_result(self, msg): 1807 | """handle result of authenticated API call (op:result, id:xxxxxx)""" 1808 | result = msg["result"] 1809 | reqid = msg["id"] 1810 | 1811 | if reqid == "idkey": 1812 | self.debug("### got key, subscribing to account messages") 1813 | self._idkey = result 1814 | self.client.on_idkey_received(result) 1815 | self.ready_idkey = True 1816 | self.check_connect_ready() 1817 | 1818 | elif reqid == "orders": 1819 | self.debug("### got own order list") 1820 | self.count_submitted = 0 1821 | self.orderbook.init_own(result) 1822 | self.debug("### have %d own orders for %s/%s" % 1823 | (len(self.orderbook.owns), self.curr_base, self.curr_quote)) 1824 | 1825 | elif reqid == "info": 1826 | self.debug("### got account info") 1827 | gox_wallet = result["Wallets"] 1828 | self.wallet = {} 1829 | self.monthly_volume = int(result["Monthly_Volume"]["value_int"]) 1830 | self.trade_fee = float(result["Trade_Fee"]) 1831 | for currency in gox_wallet: 1832 | self.wallet[currency] = int( 1833 | gox_wallet[currency]["Balance"]["value_int"]) 1834 | 1835 | self.signal_wallet(self, None) 1836 | self.ready_info = True 1837 | self.check_connect_ready() 1838 | 1839 | elif reqid == "order_lag": 1840 | lag_usec = result["lag"] 1841 | lag_text = result["lag_text"] 1842 | self.debug("### got order lag: %s" % lag_text) 1843 | self.order_lag = lag_usec 1844 | self.signal_orderlag(self, (lag_usec, lag_text)) 1845 | 1846 | elif "order_add:" in reqid: 1847 | # order/add has been acked and we got an oid, now we can already 1848 | # insert a pending order into the owns list (it will be pending 1849 | # for a while when the server is busy but the most important thing 1850 | # is that we have the order-id already). 1851 | parts = reqid.split(":") 1852 | typ = parts[1] 1853 | price = int(parts[2]) 1854 | volume = int(parts[3]) 1855 | oid = result 1856 | self.debug("### got ack for order/add:", typ, price, volume, oid) 1857 | self.count_submitted -= 1 1858 | self.orderbook.add_own(Order(price, volume, typ, oid, "pending")) 1859 | 1860 | elif "order_cancel:" in reqid: 1861 | # cancel request has been acked but we won't remove it from our 1862 | # own list now because it is still active on the server. 1863 | # do nothing now, let things happen in the user_order message 1864 | parts = reqid.split(":") 1865 | oid = parts[1] 1866 | self.debug("### got ack for order/cancel:", oid) 1867 | 1868 | else: 1869 | self.debug("### _on_op_result() ignoring:", msg) 1870 | 1871 | def _on_op_private(self, msg): 1872 | """handle op=private messages, these are the messages of the channels 1873 | we subscribed (trade, depth, ticker) and also the per-account messages 1874 | (user_order, wallet, own trades, etc)""" 1875 | private = msg["private"] 1876 | handler = None 1877 | try: 1878 | handler = getattr(self, "_on_op_private_" + private) 1879 | except AttributeError: 1880 | self.debug("### _on_op_private() ignoring: private=%s" % private) 1881 | self.debug(pretty_format(msg)) 1882 | 1883 | if handler: 1884 | handler(msg) 1885 | 1886 | def _on_op_private_ticker(self, msg): 1887 | """handle incoming ticker message (op=private, private=ticker)""" 1888 | msg = msg["ticker"] 1889 | if msg["sell"]["currency"] != self.curr_quote: 1890 | return 1891 | if msg["item"] != self.curr_base: 1892 | return 1893 | bid = int(msg["buy"]["value_int"]) 1894 | ask = int(msg["sell"]["value_int"]) 1895 | 1896 | self.debug(" tick: %s %s" % ( 1897 | self.quote2str(bid), 1898 | self.quote2str(ask) 1899 | )) 1900 | self.signal_ticker(self, (bid, ask)) 1901 | 1902 | def _on_op_private_depth(self, msg): 1903 | """handle incoming depth message (op=private, private=depth)""" 1904 | msg = msg["depth"] 1905 | if msg["currency"] != self.curr_quote: 1906 | return 1907 | if msg["item"] != self.curr_base: 1908 | return 1909 | typ = msg["type_str"] 1910 | price = int(msg["price_int"]) 1911 | volume = int(msg["volume_int"]) 1912 | timestamp = int(msg["now"]) 1913 | total_volume = int(msg["total_volume_int"]) 1914 | 1915 | delay = time.time() * 1e6 - timestamp 1916 | 1917 | self.debug("depth: %s: %s @ %s total vol: %s (age: %0.2f s)" % ( 1918 | typ, 1919 | self.base2str(volume), 1920 | self.quote2str(price), 1921 | self.base2str(total_volume), 1922 | delay / 1e6 1923 | )) 1924 | self.signal_depth(self, (typ, price, volume, total_volume)) 1925 | 1926 | def _on_op_private_trade(self, msg): 1927 | """handle incoming trade mesage (op=private, private=trade)""" 1928 | if msg["trade"]["price_currency"] != self.curr_quote: 1929 | return 1930 | if msg["trade"]["item"] != self.curr_base: 1931 | return 1932 | if msg["channel"] == CHANNELS["trade.%s" % self.curr_base]: 1933 | own = False 1934 | else: 1935 | own = True 1936 | date = int(msg["trade"]["date"]) 1937 | price = int(msg["trade"]["price_int"]) 1938 | volume = int(msg["trade"]["amount_int"]) 1939 | typ = msg["trade"]["trade_type"] 1940 | 1941 | if own: 1942 | self.debug("trade: %s: %s @ %s (own order filled)" % ( 1943 | typ, 1944 | self.base2str(volume), 1945 | self.quote2str(price) 1946 | )) 1947 | # send another private/info request because the fee might have 1948 | # changed. We request it a minute later because the server 1949 | # seems to need some time until the new values are available. 1950 | self.client.request_info_later(60) 1951 | else: 1952 | self.debug("trade: %s: %s @ %s" % ( 1953 | typ, 1954 | self.base2str(volume), 1955 | self.quote2str(price) 1956 | )) 1957 | 1958 | self.signal_trade(self, (date, price, volume, typ, own)) 1959 | 1960 | def _on_op_private_user_order(self, msg): 1961 | """handle incoming user_order message (op=private, private=user_order)""" 1962 | order = msg["user_order"] 1963 | oid = order["oid"] 1964 | 1965 | # there exist 3 fundamentally different types of user_order messages, 1966 | # they differ in the presence or absence of certain parts of the message 1967 | 1968 | if "status" in order: 1969 | # these are limit orders or market orders (new or updated). 1970 | # 1971 | # we also need to check whether they belong to our own gox instance, 1972 | # since they contain currency this is easy, we compare the currency 1973 | # and simply ignore mesages for all unrelated currencies. 1974 | if order["currency"] == self.curr_quote and order["item"] == self.curr_base: 1975 | volume = int(order["amount"]["value_int"]) 1976 | typ = order["type"] 1977 | status = order["status"] 1978 | if "price" in order: 1979 | # these are limit orders (new or updated) 1980 | price = int(order["price"]["value_int"]) 1981 | else: 1982 | # these are market orders (new or updated) 1983 | price = 0 1984 | self.signal_userorder(self, (price, volume, typ, oid, status)) 1985 | 1986 | else: 1987 | # these are remove messages (cancel or fill) 1988 | # here it is a bit more expensive to check whether they belong to 1989 | # this gox instance, they don't carry any other useful data besides 1990 | # the order id and the remove reason but since a remove message can 1991 | # only affect us if the oid is in the owns list already we just 1992 | # ask the orderbook instance whether it knows about this order 1993 | # and ignore all the ones that have unknown oid 1994 | if self.orderbook.have_own_oid(oid): 1995 | # they don't contain a status field either, so we make up 1996 | # our own status string to make it more useful. It will 1997 | # be "removed:" followed by the reason. Possible reasons are: 1998 | # "requested", "completed_passive", "completed_active" 1999 | # so for example a cancel would be "removed:requested" 2000 | # and a limit order fill would be "removed:completed_passive". 2001 | status = "removed:" + order["reason"] 2002 | self.signal_userorder(self, (0, 0, "", oid, status)) 2003 | 2004 | def _on_op_private_wallet(self, msg): 2005 | """handle incoming wallet message (op=private, private=wallet)""" 2006 | balance = msg["wallet"]["balance"] 2007 | currency = balance["currency"] 2008 | total = int(balance["value_int"]) 2009 | self.wallet[currency] = total 2010 | self.signal_wallet(self, None) 2011 | 2012 | def _on_op_private_lag(self, msg): 2013 | """handle the lag message""" 2014 | self.order_lag = int(msg["lag"]["age"]) 2015 | if self.order_lag < 60000000: 2016 | text = "%0.3f s" % (int(self.order_lag / 1000) / 1000.0) 2017 | else: 2018 | text = "%d s" % (int(self.order_lag / 1000000)) 2019 | self.signal_orderlag(self, (self.order_lag, text)) 2020 | 2021 | def _on_op_remark(self, msg): 2022 | """handler for op=remark messages""" 2023 | 2024 | if "success" in msg and not msg["success"]: 2025 | if msg["message"] == "Invalid call": 2026 | self._on_invalid_call(msg) 2027 | elif msg["message"] == "Order not found": 2028 | self._on_order_not_found(msg) 2029 | elif msg["message"] == "Order amount is too low": 2030 | self._on_order_amount_too_low(msg) 2031 | elif "Too many orders placed" in msg["message"]: 2032 | self._on_too_many_orders(msg) 2033 | else: 2034 | # we should log this, helps with debugging 2035 | self.debug(msg) 2036 | 2037 | def _on_invalid_call(self, msg): 2038 | """this comes as an op=remark message and is a strange mystery""" 2039 | # Workaround: Maybe a bug in their server software, 2040 | # I don't know what's missing. Its all poorly documented :-( 2041 | # Sometimes some API calls fail the first time for no reason, 2042 | # if this happens just send them again. This happens only 2043 | # somtimes (10%) and sending them again will eventually succeed. 2044 | 2045 | if msg["id"] == "idkey": 2046 | self.debug("### resending private/idkey") 2047 | self.client.send_signed_call( 2048 | "private/idkey", {}, "idkey") 2049 | 2050 | elif msg["id"] == "info": 2051 | self.debug("### resending private/info") 2052 | self.client.send_signed_call( 2053 | "private/info", {}, "info") 2054 | 2055 | elif msg["id"] == "orders": 2056 | self.debug("### resending private/orders") 2057 | self.client.send_signed_call( 2058 | "private/orders", {}, "orders") 2059 | 2060 | elif "order_add:" in msg["id"]: 2061 | parts = msg["id"].split(":") 2062 | typ = parts[1] 2063 | price = int(parts[2]) 2064 | volume = int(parts[3]) 2065 | self.debug("### resending failed", msg["id"]) 2066 | self.client.send_order_add(typ, price, volume) 2067 | 2068 | elif "order_cancel:" in msg["id"]: 2069 | parts = msg["id"].split(":") 2070 | oid = parts[1] 2071 | self.debug("### resending failed", msg["id"]) 2072 | self.client.send_order_cancel(oid) 2073 | 2074 | else: 2075 | self.debug("### _on_invalid_call() ignoring:", msg) 2076 | 2077 | def _on_order_not_found(self, msg): 2078 | """this means we have sent order/cancel with non-existing oid""" 2079 | parts = msg["id"].split(":") 2080 | oid = parts[1] 2081 | self.debug("### got 'Order not found' for", oid) 2082 | # we are now going to fake a user_order message (the one we 2083 | # obviously missed earlier) that will have the effect of 2084 | # removing the order cleanly. 2085 | fakemsg = {"user_order": {"oid": oid, "reason": "requested"}} 2086 | self._on_op_private_user_order(fakemsg) 2087 | 2088 | def _on_order_amount_too_low(self, _msg): 2089 | """we received an order_amount too low message.""" 2090 | self.debug("### Server said: 'Order amount is too low'") 2091 | self.count_submitted -= 1 2092 | 2093 | def _on_too_many_orders(self, msg): 2094 | """server complains too many orders were placd too fast""" 2095 | self.debug("### Server said: '%s" % msg["message"]) 2096 | self.count_submitted -= 1 2097 | self.signal_order_too_fast(self, msg) 2098 | 2099 | 2100 | class Level: 2101 | """represents a level in the orderbook""" 2102 | def __init__(self, price, volume): 2103 | self.price = price 2104 | self.volume = volume 2105 | self.own_volume = 0 2106 | 2107 | # these fields are only used to store temporary cache values 2108 | # in some (not all!) levels and is calculated by the OrderBook 2109 | # on demand, do not access this, use get_total_up_to() instead! 2110 | self._cache_total_vol = 0 2111 | self._cache_total_vol_quote = 0 2112 | 2113 | class Order: 2114 | """represents an order""" 2115 | def __init__(self, price, volume, typ, oid="", status=""): 2116 | """initialize a new order object""" 2117 | self.price = price 2118 | self.volume = volume 2119 | self.typ = typ 2120 | self.oid = oid 2121 | self.status = status 2122 | 2123 | class OrderBook(BaseObject): 2124 | """represents the orderbook. Each Gox instance has one 2125 | instance of OrderBook to maintain the open orders. This also 2126 | maintains a list of own orders belonging to this account""" 2127 | 2128 | def __init__(self, gox): 2129 | """create a new empty orderbook and associate it with its 2130 | Gox instance, initialize it and connect its slots to gox""" 2131 | BaseObject.__init__(self) 2132 | self.gox = gox 2133 | 2134 | self.signal_changed = Signal() 2135 | """orderbook state has changed 2136 | param: None 2137 | an update to the state of the orderbook happened, this is emitted very 2138 | often, it happens after every depth message, after every trade and 2139 | also after every user_order message. This signal is for example used 2140 | in goxtool.py to repaint the user interface of the orderbook window.""" 2141 | 2142 | self.signal_fulldepth_processed = Signal() 2143 | """fulldepth download is complete 2144 | param: None 2145 | The orderbook (fulldepth) has been downloaded from the server. 2146 | This happens soon after connect.""" 2147 | 2148 | self.signal_owns_initialized = Signal() 2149 | """own order list has been initialized 2150 | param: None 2151 | The owns list has been initialized. This happens soon after connect 2152 | after it has downloaded the authoritative list of pending and open 2153 | orders. This will also happen if it reinitialized after lost connection.""" 2154 | 2155 | self.signal_owns_changed = Signal() 2156 | """owns list has changed 2157 | param: None 2158 | an update to the owns list has happened, this can be order added, 2159 | removed or filled, status or volume of an order changed. For specific 2160 | changes to individual orders see the signal_own_* signals below.""" 2161 | 2162 | self.signal_own_added = Signal() 2163 | """order was added 2164 | param: (order) 2165 | order is a reference to the Order() instance 2166 | This signal will be emitted whenever a new order is added to 2167 | the owns list. Orders will initially have status "pending" and 2168 | some time later there will be signal_own_opened when the status 2169 | changed to open.""" 2170 | 2171 | self.signal_own_removed = Signal() 2172 | """order has been removed 2173 | param: (order, reason) 2174 | order is a reference to the Order() instance 2175 | reason is a string that can have the following values: 2176 | "requested" order was canceled 2177 | "completed_passive" limit order was filled completely 2178 | "completed_active" market order was filled completely 2179 | Bots will probably be interested in this signal because this is a 2180 | reliable way to determine that a trade has fully completed because the 2181 | trade signal alone won't tell you whether its partial or complete""" 2182 | 2183 | self.signal_own_opened = Signal() 2184 | """order status went to "open" 2185 | param: (order) 2186 | order is a reference to the Order() instance 2187 | when the order changes from 'post-pending' to 'open' then this 2188 | signal will be emitted. It won't be emitted for market orders because 2189 | market orders can't have an "open" status, they never move beyond 2190 | "executing", they just execute and emit volume and removed signals.""" 2191 | 2192 | self.signal_own_volume = Signal() 2193 | """order volume changed (partial fill) 2194 | param: (order, voldiff) 2195 | order is a reference to the Order() instance 2196 | voldiff is the differenc in volume, so for a partial or a complete fill 2197 | it would contain a negative value (integer number of satoshi) of the 2198 | difference between now and the previous volume. This signal is always 2199 | emitted when an order is filled or partially filled, it can be emitted 2200 | multiple times just like the trade messages. It will be emitted for 2201 | all types of orders. The last volume signal that finally brouhgt the 2202 | remaining order volume down to zero will be immediately followed by 2203 | a removed signal.""" 2204 | 2205 | self.bids = [] # list of Level(), lowest ask first 2206 | self.asks = [] # list of Level(), highest bid first 2207 | self.owns = [] # list of Order(), unordered list 2208 | 2209 | self.bid = 0 2210 | self.ask = 0 2211 | self.total_bid = 0 2212 | self.total_ask = 0 2213 | 2214 | self.ready_depth = False 2215 | self.ready_owns = False 2216 | 2217 | self.last_change_type = None # ("bid", "ask", None) this can be used 2218 | self.last_change_price = 0 # for highlighting relative changes 2219 | self.last_change_volume = 0 # of orderbook levels in goxtool.py 2220 | 2221 | self._valid_bid_cache = -1 # index of bid with valid _cache_total_vol 2222 | self._valid_ask_cache = -1 # index of ask with valid _cache_total_vol 2223 | 2224 | gox.signal_ticker.connect(self.slot_ticker) 2225 | gox.signal_depth.connect(self.slot_depth) 2226 | gox.signal_trade.connect(self.slot_trade) 2227 | gox.signal_userorder.connect(self.slot_user_order) 2228 | gox.signal_fulldepth.connect(self.slot_fulldepth) 2229 | 2230 | def slot_ticker(self, dummy_sender, data): 2231 | """Slot for signal_ticker, incoming ticker message""" 2232 | (bid, ask) = data 2233 | self.bid = bid 2234 | self.ask = ask 2235 | self.last_change_type = None 2236 | self.last_change_price = 0 2237 | self.last_change_volume = 0 2238 | self._repair_crossed_asks(ask) 2239 | self._repair_crossed_bids(bid) 2240 | self.signal_changed(self, None) 2241 | 2242 | def slot_depth(self, dummy_sender, data): 2243 | """Slot for signal_depth, process incoming depth message""" 2244 | (typ, price, _voldiff, total_vol) = data 2245 | if self._update_book(typ, price, total_vol): 2246 | self.signal_changed(self, None) 2247 | 2248 | def slot_trade(self, dummy_sender, data): 2249 | """Slot for signal_trade event, process incoming trade messages. 2250 | For trades that also affect own orders this will be called twice: 2251 | once during the normal public trade message, affecting the public 2252 | bids and asks and then another time with own=True to update our 2253 | own orders list""" 2254 | (dummy_date, price, volume, typ, own) = data 2255 | if own: 2256 | # nothing special to do here (yet), there will also be 2257 | # separate user_order messages to update my owns list 2258 | # and a copy of this trade message in the public channel 2259 | pass 2260 | else: 2261 | # we update the orderbook. We could also wait for the depth 2262 | # message but we update the orderbook immediately. 2263 | voldiff = -volume 2264 | if typ == "bid": # tryde_type=bid means an ask order was filled 2265 | self._repair_crossed_asks(price) 2266 | if len(self.asks): 2267 | if self.asks[0].price == price: 2268 | self.asks[0].volume -= volume 2269 | if self.asks[0].volume <= 0: 2270 | voldiff -= self.asks[0].volume 2271 | self.asks.pop(0) 2272 | self.last_change_type = "ask" #the asks have changed 2273 | self.last_change_price = price 2274 | self.last_change_volume = voldiff 2275 | self._update_total_ask(voldiff) 2276 | self._valid_ask_cache = -1 2277 | if len(self.asks): 2278 | self.ask = self.asks[0].price 2279 | 2280 | if typ == "ask": # trade_type=ask means a bid order was filled 2281 | self._repair_crossed_bids(price) 2282 | if len(self.bids): 2283 | if self.bids[0].price == price: 2284 | self.bids[0].volume -= volume 2285 | if self.bids[0].volume <= 0: 2286 | voldiff -= self.bids[0].volume 2287 | self.bids.pop(0) 2288 | self.last_change_type = "bid" #the bids have changed 2289 | self.last_change_price = price 2290 | self.last_change_volume = voldiff 2291 | self._update_total_bid(voldiff, price) 2292 | self._valid_bid_cache = -1 2293 | if len(self.bids): 2294 | self.bid = self.bids[0].price 2295 | 2296 | self.signal_changed(self, None) 2297 | 2298 | def slot_user_order(self, dummy_sender, data): 2299 | """Slot for signal_userorder, process incoming user_order mesage""" 2300 | (price, volume, typ, oid, status) = data 2301 | found = False 2302 | removed = False # was the order removed? 2303 | opened = False # did the order change from 'post-pending' to 'open'"? 2304 | voldiff = 0 # did the order volume change (full or partial fill) 2305 | if "executing" in status: 2306 | # don't need this status at all 2307 | return 2308 | if "post-pending" in status: 2309 | # don't need this status at all 2310 | return 2311 | if "removed" in status: 2312 | for i in range(len(self.owns)): 2313 | if self.owns[i].oid == oid: 2314 | order = self.owns[i] 2315 | 2316 | # work around MtGox strangeness: 2317 | # for some reason it will send a "completed_passive" 2318 | # immediately followed by a "completed_active" when a 2319 | # market order is filled and removed. Since "completed_passive" 2320 | # is meant for limit orders only we will just completely 2321 | # IGNORE all "completed_passive" if it affects a market order, 2322 | # there WILL follow a "completed_active" immediately after. 2323 | if order.price == 0: 2324 | if "passive" in status: 2325 | # ignore it, the correct one with 2326 | # "active" will follow soon 2327 | return 2328 | 2329 | self.debug( 2330 | "### removing order %s " % oid, 2331 | "price:", self.gox.quote2str(order.price), 2332 | "type:", order.typ) 2333 | 2334 | # remove it from owns... 2335 | self.owns.pop(i) 2336 | 2337 | # ...and update own volume cache in the bids or asks 2338 | self._update_level_own_volume( 2339 | order.typ, 2340 | order.price, 2341 | self.get_own_volume_at(order.price, order.typ) 2342 | ) 2343 | removed = True 2344 | break 2345 | else: 2346 | for order in self.owns: 2347 | if order.oid == oid: 2348 | found = True 2349 | self.debug( 2350 | "### updating order %s " % oid, 2351 | "volume:", self.gox.base2str(volume), 2352 | "status:", status) 2353 | voldiff = volume - order.volume 2354 | opened = (order.status != "open" and status == "open") 2355 | order.volume = volume 2356 | order.status = status 2357 | break 2358 | 2359 | if not found: 2360 | # This can happen if we added the order with a different 2361 | # application or the gox server sent the user_order message 2362 | # before the reply to "order/add" (this can happen because 2363 | # actually there is no guarantee which one arrives first). 2364 | # We will treat this like a reply to "order/add" 2365 | self.add_own(Order(price, volume, typ, oid, status)) 2366 | 2367 | # The add_own() method has handled everything that was needed 2368 | # for new orders and also emitted all signals already, we 2369 | # can immediately return here because the job is done. 2370 | return 2371 | 2372 | # update level own volume cache 2373 | self._update_level_own_volume( 2374 | typ, price, self.get_own_volume_at(price, typ)) 2375 | 2376 | # We try to help the strategy with tracking the orders as good 2377 | # as we can by sending different signals for different events. 2378 | if removed: 2379 | reason = self.gox.msg["user_order"]["reason"] 2380 | self.signal_own_removed(self, (order, reason)) 2381 | if opened: 2382 | self.signal_own_opened(self, (order)) 2383 | if voldiff: 2384 | self.signal_own_volume(self, (order, voldiff)) 2385 | self.signal_changed(self, None) 2386 | self.signal_owns_changed(self, None) 2387 | 2388 | def slot_fulldepth(self, dummy_sender, data): 2389 | """Slot for signal_fulldepth, process received fulldepth data. 2390 | This will clear the book and then re-initialize it from scratch.""" 2391 | (depth) = data 2392 | self.debug("### got full depth, updating orderbook...") 2393 | self.bids = [] 2394 | self.asks = [] 2395 | self.total_ask = 0 2396 | self.total_bid = 0 2397 | if "error" in depth: 2398 | self.debug("### ", depth["error"]) 2399 | return 2400 | for order in depth["data"]["asks"]: 2401 | price = int(order["price_int"]) 2402 | volume = int(order["amount_int"]) 2403 | self._update_total_ask(volume) 2404 | self.asks.append(Level(price, volume)) 2405 | for order in depth["data"]["bids"]: 2406 | price = int(order["price_int"]) 2407 | volume = int(order["amount_int"]) 2408 | self._update_total_bid(volume, price) 2409 | self.bids.insert(0, Level(price, volume)) 2410 | 2411 | # update own volume cache 2412 | for order in self.owns: 2413 | self._update_level_own_volume( 2414 | order.typ, order.price, self.get_own_volume_at(order.price, order.typ)) 2415 | 2416 | if len(self.bids): 2417 | self.bid = self.bids[0].price 2418 | if len(self.asks): 2419 | self.ask = self.asks[0].price 2420 | 2421 | self._valid_ask_cache = -1 2422 | self._valid_bid_cache = -1 2423 | self.ready_depth = True 2424 | self.signal_fulldepth_processed(self, None) 2425 | self.signal_changed(self, None) 2426 | 2427 | def _repair_crossed_bids(self, bid): 2428 | """remove all bids that are higher that official current bid value, 2429 | this should actually never be necessary if their feed would not 2430 | eat depth- and trade-messages occaionally :-(""" 2431 | while len(self.bids) and self.bids[0].price > bid: 2432 | price = self.bids[0].price 2433 | volume = self.bids[0].volume 2434 | self._update_total_bid(-volume, price) 2435 | self.bids.pop(0) 2436 | self._valid_bid_cache = -1 2437 | #self.debug("### repaired bid") 2438 | 2439 | def _repair_crossed_asks(self, ask): 2440 | """remove all asks that are lower that official current ask value, 2441 | this should actually never be necessary if their feed would not 2442 | eat depth- and trade-messages occaionally :-(""" 2443 | while len(self.asks) and self.asks[0].price < ask: 2444 | volume = self.asks[0].volume 2445 | self._update_total_ask(-volume) 2446 | self.asks.pop(0) 2447 | self._valid_ask_cache = -1 2448 | #self.debug("### repaired ask") 2449 | 2450 | def _update_book(self, typ, price, total_vol): 2451 | """update the bids or asks list, insert or remove level and 2452 | also update all other stuff that needs to be tracked such as 2453 | total volumes and invalidate the total volume cache index. 2454 | Return True if book has changed, return False otherwise""" 2455 | (lst, index, level) = self._find_level(typ, price) 2456 | if total_vol == 0: 2457 | if level == None: 2458 | return False 2459 | else: 2460 | voldiff = -level.volume 2461 | lst.pop(index) 2462 | else: 2463 | if level == None: 2464 | voldiff = total_vol 2465 | level = Level(price, total_vol) 2466 | lst.insert(index, level) 2467 | else: 2468 | voldiff = total_vol - level.volume 2469 | if voldiff == 0: 2470 | return False 2471 | level.volume = total_vol 2472 | 2473 | # now keep all the other stuff in sync with it 2474 | self.last_change_type = typ 2475 | self.last_change_price = price 2476 | self.last_change_volume = voldiff 2477 | if typ == "ask": 2478 | self._update_total_ask(voldiff) 2479 | if len(self.asks): 2480 | self.ask = self.asks[0].price 2481 | self._valid_ask_cache = min(self._valid_ask_cache, index - 1) 2482 | else: 2483 | self._update_total_bid(voldiff, price) 2484 | if len(self.bids): 2485 | self.bid = self.bids[0].price 2486 | self._valid_bid_cache = min(self._valid_bid_cache, index - 1) 2487 | 2488 | return True 2489 | 2490 | def _update_total_ask(self, volume): 2491 | """update total volume of base currency on the ask side""" 2492 | self.total_ask += self.gox.base2float(volume) 2493 | 2494 | def _update_total_bid(self, volume, price): 2495 | """update total volume of quote currency on the bid side""" 2496 | self.total_bid += \ 2497 | self.gox.base2float(volume) * self.gox.quote2float(price) 2498 | 2499 | def _update_level_own_volume(self, typ, price, own_volume): 2500 | """update the own_volume cache in the Level object at price""" 2501 | 2502 | if price == 0: 2503 | # market orders have price == 0, we don't add them 2504 | # to the orderbook, own_volume is meant for limit orders. 2505 | # Also a price level of 0 makes no sense anyways, this 2506 | # would only insert empty rows at price=0 into the book 2507 | return 2508 | 2509 | (index, level) = self._find_level_or_insert_new(typ, price) 2510 | if level.volume == 0 and own_volume == 0: 2511 | if typ == "ask": 2512 | self.asks.pop(index) 2513 | else: 2514 | self.bids.pop(index) 2515 | else: 2516 | level.own_volume = own_volume 2517 | 2518 | def _find_level(self, typ, price): 2519 | """find the level in the orderbook and return a triple 2520 | (list, index, level) where list is a reference to the list, 2521 | index is the index if its an exact match or the index of the next 2522 | element if it was not found (can be used for inserting) and level 2523 | is either a reference to the found level or None if not found.""" 2524 | lst = {"ask": self.asks, "bid": self.bids}[typ] 2525 | comp = {"ask": lambda x, y: x < y, "bid": lambda x, y: x > y}[typ] 2526 | low = 0 2527 | high = len(lst) 2528 | 2529 | # binary search 2530 | while low < high: 2531 | mid = (low + high) // 2 2532 | midval = lst[mid].price 2533 | if comp(midval, price): 2534 | low = mid + 1 2535 | elif comp(price, midval): 2536 | high = mid 2537 | else: 2538 | return (lst, mid, lst[mid]) 2539 | 2540 | # not found, return insertion point (index of next higher level) 2541 | return (lst, high, None) 2542 | 2543 | def _find_level_or_insert_new(self, typ, price): 2544 | """find the Level() object in bids or asks or insert a new 2545 | Level() at the correct position. Returns tuple (index, level)""" 2546 | (lst, index, level) = self._find_level(typ, price) 2547 | if level: 2548 | return (index, level) 2549 | 2550 | # no exact match found, create new Level() and insert 2551 | level = Level(price, 0) 2552 | lst.insert(index, level) 2553 | 2554 | # invalidate the total volume cache at and beyond this level 2555 | if typ == "ask": 2556 | self._valid_ask_cache = min(self._valid_ask_cache, index - 1) 2557 | else: 2558 | self._valid_bid_cache = min(self._valid_bid_cache, index - 1) 2559 | 2560 | return (index, level) 2561 | 2562 | def get_own_volume_at(self, price, typ=None): 2563 | """returns the sum of the volume of own orders at a given price. This 2564 | method will not look up the cache in the bids or asks lists, it will 2565 | use the authoritative data from the owns list bacause this method is 2566 | also used to calculate these cached values in the first place.""" 2567 | volume = 0 2568 | for order in self.owns: 2569 | if order.price == price and (not typ or typ == order.typ): 2570 | volume += order.volume 2571 | return volume 2572 | 2573 | def have_own_oid(self, oid): 2574 | """do we have an own order with this oid in our list already?""" 2575 | for order in self.owns: 2576 | if order.oid == oid: 2577 | return True 2578 | return False 2579 | 2580 | # pylint: disable=W0212 2581 | def get_total_up_to(self, price, is_ask): 2582 | """return a tuple of the total volume in coins and in fiat between top 2583 | and this price. This will calculate the total on demand, it has a cache 2584 | to not repeat the same calculations more often than absolutely needed""" 2585 | if is_ask: 2586 | lst = self.asks 2587 | known_level = self._valid_ask_cache 2588 | comp = lambda x, y: x < y 2589 | else: 2590 | lst = self.bids 2591 | known_level = self._valid_bid_cache 2592 | comp = lambda x, y: x > y 2593 | 2594 | # now first we need the list index of the level we are looking for or 2595 | # if it doesn't match exactly the index of the level right before that 2596 | # price, for this we do a quick binary search for the price 2597 | low = 0 2598 | high = len(lst) 2599 | while low < high: 2600 | mid = (low + high) // 2 2601 | midval = lst[mid].price 2602 | if comp(midval, price): 2603 | low = mid + 1 2604 | elif comp(price, midval): 2605 | high = mid 2606 | else: 2607 | break 2608 | if comp(price, midval): 2609 | needed_level = mid - 1 2610 | else: 2611 | needed_level = mid 2612 | 2613 | # if the total volume at this level has been calculated 2614 | # already earlier then we don't need to do anything further, 2615 | # we can immediately return the cached value from that level. 2616 | if needed_level <= known_level: 2617 | lvl = lst[needed_level] 2618 | return (lvl._cache_total_vol, lvl._cache_total_vol_quote) 2619 | 2620 | # we are still here, this means we must calculate and update 2621 | # all totals in all levels between last_known and needed_level 2622 | # after that is done we can return the total at needed_level. 2623 | if known_level == -1: 2624 | total = 0 2625 | total_quote = 0 2626 | else: 2627 | total = lst[known_level]._cache_total_vol 2628 | total_quote = lst[known_level]._cache_total_vol_quote 2629 | 2630 | mult_base = self.gox.mult_base 2631 | for i in range(known_level, needed_level): 2632 | that = lst[i+1] 2633 | total += that.volume 2634 | total_quote += that.volume * that.price / mult_base 2635 | that._cache_total_vol = total 2636 | that._cache_total_vol_quote = total_quote 2637 | 2638 | if is_ask: 2639 | self._valid_ask_cache = needed_level 2640 | else: 2641 | self._valid_bid_cache = needed_level 2642 | 2643 | return (total, total_quote) 2644 | 2645 | def init_own(self, own_orders): 2646 | """called by gox when the initial order list is downloaded, 2647 | this will happen after connect or reconnect""" 2648 | self.owns = [] 2649 | 2650 | # also reset the own volume cache in bids and ass list 2651 | for level in self.bids + self.asks: 2652 | level.own_volume = 0 2653 | 2654 | if own_orders: 2655 | for order in own_orders: 2656 | if order["currency"] == self.gox.curr_quote \ 2657 | and order["item"] == self.gox.curr_base: 2658 | self._add_own(Order( 2659 | int(order["price"]["value_int"]), 2660 | int(order["amount"]["value_int"]), 2661 | order["type"], 2662 | order["oid"], 2663 | order["status"] 2664 | )) 2665 | 2666 | self.ready_owns = True 2667 | self.signal_changed(self, None) 2668 | self.signal_owns_initialized(self, None) 2669 | self.signal_owns_changed(self, None) 2670 | 2671 | def add_own(self, order): 2672 | """called by gox when a new order has been acked after it has been 2673 | submitted or after a receiving a user_order message for a new order. 2674 | This is a separate method from _add_own because we additionally need 2675 | to fire the a bunch of signals when this happens""" 2676 | if not self.have_own_oid(order.oid): 2677 | self.debug("### adding order:", 2678 | order.typ, order.price, order.volume, order.oid) 2679 | self._add_own(order) 2680 | self.signal_own_added(self, (order)) 2681 | self.signal_changed(self, None) 2682 | self.signal_owns_changed(self, None) 2683 | 2684 | def _add_own(self, order): 2685 | """add order to the list of own orders. This method is used during 2686 | initial download of complete order list.""" 2687 | if not self.have_own_oid(order.oid): 2688 | self.owns.append(order) 2689 | 2690 | # update own volume in that level: 2691 | self._update_level_own_volume( 2692 | order.typ, 2693 | order.price, 2694 | self.get_own_volume_at(order.price, order.typ) 2695 | ) 2696 | --------------------------------------------------------------------------------