fix-engine 0.0.24 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: c91eff8188ecd2d2a756b520272008fe02b84eda
4
- data.tar.gz: f8a6ca41d7b37da9f38811a869d03013a1f925f1
3
+ metadata.gz: 32ec2077ed212596bc692e4650be57c8a003a55c
4
+ data.tar.gz: e2ad9ef6e6824e8d06de895dd44747f01a10934f
5
5
  SHA512:
6
- metadata.gz: a773023d43105871d71e3300ce97e9b8156e7412535f0a2867b2ec43bc826a7bd139c9e5cdb0a99c65f786ea5efced658bd1bd6b1cf590ccf03f6e8bbe72e0bd
7
- data.tar.gz: bca29ee94ce994971a868db41c1fdb110b913923cd79d4e04b0a42633476d3c9d1b008921f669fee078d8e1edeb09b15e0adfd650313bbb22ebc5128e2ae73f1
6
+ metadata.gz: 3cced1625f0196a24a4006e1b9784fa5db83d9141167bca0286bab405fd0e6abefa5ebf0db81b6042ec66fd4824e48b5d5ad1d4e0dd0d561a2bd56e9e075b46c
7
+ data.tar.gz: 36a2e95f782491cd465c5bf81029a84956884d85180c2beb08dff94652f261a43490d2bdd86feff1ec4bc2c770c99b886fc133ded5fc68b126f59e09710e1642
data/README.md CHANGED
@@ -1,4 +1,154 @@
1
1
  FIX Engine [![Build Status](https://secure.travis-ci.org/Paymium/fix-engine.png?branch=master)](http://travis-ci.org/Paymium/fix-engine)
2
2
  =
3
3
 
4
- This library provides an event-machine based FIX server.
4
+ This library provides an event-machine based FIX server and client connection implementation.
5
+
6
+ # Usage as a FIX client
7
+
8
+ ## Implement a connection handler
9
+
10
+ Create an `EM::Connection` subclass and include the `FE::ClientConnection` module. You will then have to implement
11
+ the callbacks for the various message types you are interested in.
12
+
13
+ ````ruby
14
+ require 'fix/engine'
15
+
16
+ module Referee
17
+
18
+ class FixConnection < EM::Connection
19
+
20
+ include FE::ClientConnection
21
+
22
+ attr_accessor :exchange
23
+
24
+ #
25
+ # When a logon message is received we request a market snapshot
26
+ # and subscribe for continuous updates
27
+ #
28
+ def on_logon(msg)
29
+ mdr = FP::Messages::MarketDataRequest.new
30
+
31
+ mdr.md_req_id = 'foo'
32
+
33
+ mdr.subscription_request_type = :updates
34
+ mdr.market_depth = :full
35
+ mdr.md_update_type = :incremental
36
+
37
+ mdr.instruments.build do |i|
38
+ i.symbol = 'EUR/XBT'
39
+ end
40
+
41
+ [:bid, :ask, :trade, :open, :vwap, :close].each do |mdet|
42
+ mdr.md_entry_types.build do |m|
43
+ m.md_entry_type = mdet
44
+ end
45
+ end
46
+
47
+ send_msg(mdr)
48
+ end
49
+
50
+ # Called when a market data snapshot is received
51
+ def on_market_data_snapshot(msg)
52
+ update_book_with(msg)
53
+ end
54
+
55
+ # Called upon each subsequent update
56
+ def on_market_data_incremental_refresh(msg)
57
+ update_book_with(msg)
58
+ end
59
+
60
+ # Update the local order book copy with the new data
61
+ def update_book_with(msg)
62
+ msg.md_entries.each do |mde|
63
+ exchange.book[mde.md_entry_type].set_depth_at(BigDecimal(mde.md_entry_px), BigDecimal(mde.md_entry_size))
64
+ end
65
+ end
66
+
67
+ end
68
+ end
69
+ ````
70
+
71
+ ## Use it to connect to a FIX acceptor
72
+
73
+ Once your connection class has been created you can establish a connection in a running EventMachine reactor.
74
+ See the [referee gem](https://github.com/davout/referee) for the full code example.
75
+
76
+ ````ruby
77
+ require 'referee/exchange'
78
+ require 'referee/fix_connection'
79
+
80
+ module Referee
81
+ module Exchanges
82
+ class Paymium < Referee::Exchange
83
+
84
+ FIX_SERVER = 'fix.paymium.com'
85
+ FIX_PORT = 8359
86
+
87
+ def symbol
88
+ 'PAYM'
89
+ end
90
+
91
+ def currency
92
+ 'EUR'
93
+ end
94
+
95
+ def connect
96
+ FE::Logger.logger.level = Logger::WARN
97
+
98
+ EM.connect(FIX_SERVER, FIX_PORT, FixConnection) do |conn|
99
+ conn.target_comp_id = 'PAYMIUM'
100
+ conn.comp_id = 'BC-U458625'
101
+ conn.username = 'BC-U458625'
102
+ conn.exchange = self
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ ````
109
+
110
+
111
+ # Usage as a FIX acceptor
112
+
113
+ You can start a simple FIX acceptor that will maintain a session by running the `fix-engine` executable.
114
+ The basic FIX acceptor requires a `COMP_ID` environment variable to be set.
115
+
116
+ ````
117
+ $ COMP_ID=MY_COMP_ID fix-engine
118
+
119
+ > D, [2015-01-07T12:47:07.807867 #87486] DEBUG -- : Starting FIX engine v0.0.31, listening on <0.0.0.0:8359>, exit with <Ctrl-C>
120
+ > D, [2015-01-07T12:47:12.379787 #87486] DEBUG -- : Client connected <127.0.0.1:54204>, expecting logon message in the next 10s
121
+ > D, [2015-01-07T12:47:12.816626 #87486] DEBUG -- : Received a <Fix::Protocol::Messages::Logon> from <127.0.0.1:54204> with sequence number <1>
122
+ > D, [2015-01-07T12:47:12.820093 #87486] DEBUG -- : Peer authenticated as <JAVA_TESTS> with heartbeat interval of <30s> and message sequence number start <1>
123
+ > D, [2015-01-07T12:47:12.820259 #87486] DEBUG -- : Heartbeat interval for <127.0.0.1:54204> : <30s>
124
+ > D, [2015-01-07T12:47:12.820899 #87486] DEBUG -- : Sending <Fix::Protocol::Messages::Logon> to <127.0.0.1:54204> with sequence number <1>
125
+ ````
126
+
127
+ In order to handle business messages appropriately you need to implement a connection handler
128
+ that includes the `FE::ServerConnection` module and use that as a connection handler.
129
+
130
+ ````ruby
131
+ class MyHandler < EM::Connection
132
+
133
+ include FE::ServerConnection
134
+
135
+ def on_market_data_request
136
+ # Fetch market data and send the relevant response
137
+ # ...
138
+ end
139
+
140
+ end
141
+
142
+ server = FE::Server.new('127.0.0.1', 8095, MyHandler) do |conn|
143
+ conn.comp_id = 'MY_COMP_ID'
144
+ end
145
+
146
+ # This will also start an EventMachine reactor
147
+ server.run!
148
+
149
+ # This would be used inside an already-running reactor
150
+ EM.run do
151
+ server.start_server
152
+ end
153
+ ````
154
+
@@ -5,5 +5,11 @@ require 'fix/engine'
5
5
  ip = ENV['FE_IP'] || FE::DEFAULT_IP
6
6
  port = ENV['FE_PORT'] || FE::DEFAULT_PORT
7
7
 
8
- Fix::Engine.run!(ip, port)
8
+ if ENV['COMP_ID']
9
+ Fix::Engine.run!(ip, port) do |conn|
10
+ conn.comp_id = ENV['COMP_ID']
11
+ end
12
+ else
13
+ puts "The COMP_ID environment variable must be set to be able to start a basic acceptor."
14
+ end
9
15
 
@@ -1,5 +1,6 @@
1
1
  require 'fix/engine/logger'
2
2
  require 'fix/engine/server'
3
+ require 'fix/engine/client_connection'
3
4
 
4
5
  #
5
6
  # Main FIX namespace
@@ -20,8 +21,8 @@ module Fix
20
21
  #
21
22
  # Runs a FIX server engine
22
23
  #
23
- def self.run!(ip = DEFAULT_IP, port = DEFAULT_PORT, handler = FE::Connection)
24
- Server.new(ip, port, handler).run!
24
+ def self.run!(ip = DEFAULT_IP, port = DEFAULT_PORT, handler = FE::ServerConnection, &block)
25
+ Server.new(ip, port, handler, &block).run!
25
26
  end
26
27
 
27
28
  #
@@ -8,9 +8,9 @@ module Fix
8
8
  #
9
9
  class Client
10
10
 
11
- @@clients = {}
11
+ @clients = {}
12
12
 
13
- attr_accessor :ip, :port, :connection, :client_id
13
+ attr_accessor :ip, :port, :connection, :username
14
14
 
15
15
  include Logger
16
16
 
@@ -18,34 +18,65 @@ module Fix
18
18
  @ip = ip
19
19
  @port = port
20
20
  @connection = connection
21
+
22
+ self.class.instance_variable_get(:@clients)[key] = self
21
23
  end
22
24
 
23
25
  #
24
26
  # Returns a client instance from its connection IP
25
27
  #
26
28
  # @param ip [String] The connection IP
29
+ # @param port [Fixnum] The connection port
30
+ # @param connection [FE::Connection] Optionnally the connection which will used to create an instance if none exists
27
31
  # @return [Fix::Engine::Client] The client connected for this IP
28
32
  #
29
- def self.get(ip, port, connection)
30
- @@clients[key(ip, port)] ||= Client.new(ip, port, connection)
33
+ def self.get(ip, port, connection = nil)
34
+ @clients[key(ip, port)] || Client.new(ip, port, connection)
31
35
  end
32
36
 
37
+ #
38
+ # Returns the count of currently connected clients
39
+ #
40
+ # @return [Fixnum] The client count
41
+ #
33
42
  def self.count
34
- @@clients.count
43
+ @clients.count
35
44
  end
36
45
 
46
+ #
47
+ # Removes a client from the currently connected ones
48
+ #
49
+ # @param ip [String] The client's remote IP
50
+ # @param port [Fixnum] The client's port
51
+ #
37
52
  def self.delete(ip, port)
38
- @@clients.delete(key(ip, port))
39
- end
40
-
41
- def has_session?
42
- !!client_id
53
+ @clients.delete(key(ip, port))
43
54
  end
44
55
 
56
+ #
57
+ # Returns an identifier for the current client
58
+ #
59
+ # @return [String] An identifier
60
+ #
45
61
  def key
46
62
  self.class.key(ip, port)
47
63
  end
48
64
 
65
+ #
66
+ # Removes the current client from the array of connected ones
67
+ #
68
+ def delete
69
+ self.class.delete(ip, port)
70
+ end
71
+
72
+ #
73
+ # Returns an identifier for the given IP and port
74
+ #
75
+ # @param ip [String] The client's remote IP
76
+ # @param port [Fixnum] The client's port
77
+ #
78
+ # @return [String] An identifier
79
+ #
49
80
  def self.key(ip, port)
50
81
  "#{ip}:#{port}"
51
82
  end
@@ -0,0 +1,50 @@
1
+ require 'fix/engine/connection'
2
+
3
+ module Fix
4
+ module Engine
5
+
6
+ #
7
+ # The client connection wrapper, used in order to connect a remote FIX server
8
+ #
9
+ module ClientConnection
10
+
11
+ include Connection
12
+
13
+ attr_accessor :username
14
+
15
+ #
16
+ # Run after we've connected to the server
17
+ #
18
+ def post_init
19
+ super
20
+
21
+ log("Connecting to server sending a logon message with our COMP_ID <#{@comp_id}>")
22
+
23
+ @logged_in = false
24
+
25
+ EM.next_tick { send_logon }
26
+ end
27
+
28
+ #
29
+ # Sends a logon message to the server we're connected to
30
+ #
31
+ def send_logon
32
+ logon = FP::Messages::Logon.new
33
+ logon.username = @username
34
+ logon.target_comp_id = @peer_comp_id
35
+ logon.sender_comp_id = @comp_id
36
+ logon.reset_seq_num_flag = true
37
+ send_msg(logon)
38
+ end
39
+
40
+ #
41
+ # Consider ourselves logged-in if we receive on of these
42
+ #
43
+ def on_logon(msg)
44
+ @logged_in = true
45
+ end
46
+
47
+ end
48
+ end
49
+ end
50
+
@@ -1,7 +1,6 @@
1
1
  require 'eventmachine'
2
2
 
3
3
  require 'fix/engine/message_buffer'
4
- require 'fix/engine/client'
5
4
 
6
5
  module Fix
7
6
  module Engine
@@ -9,61 +8,52 @@ module Fix
9
8
  #
10
9
  # The client connection handling logic and method overrides
11
10
  #
12
- class Connection < EM::Connection
11
+ module Connection
13
12
 
14
13
  include Logger
15
14
 
16
- #
17
- # Timespan during which a client must send a logon message after connecting
18
- #
19
- LOGON_TIMEOUT = 10
20
-
21
- #
22
- # Timespan during which we will wait for a heartbeat response from the client
23
- #
24
- HRTBT_TIMEOUT = 10
25
-
26
15
  #
27
16
  # Grace time before we disconnect a client that doesn't reply to a test request
28
17
  #
29
18
  TEST_REQ_GRACE_TIME = 15
30
19
 
31
- attr_accessor :ip, :port, :client, :msg_buf, :hrtbt_int, :last_request_at
20
+ attr_accessor :ip, :port, :msg_buf, :hrtbt_int, :last_request_at, :comp_id, :target_comp_id
32
21
 
33
22
  #
34
- # Our own company ID
35
- #
36
- DEFAULT_COMP_ID = 'PYMBTC'
37
-
38
- #
39
- # Run after a client has connected
23
+ # Initialize the messages array, our comp_id, and the expected message sequence number
40
24
  #
41
25
  def post_init
42
- @port, @ip = Socket.unpack_sockaddr_in(get_peername)
43
- @client = Client.get(ip, port, self)
44
- @expected_clt_seq_num = 1
26
+ @expected_seq_num = 1
45
27
 
46
- log("Client connected from <#{@client.key}>, expecting logon message in the next #{LOGON_TIMEOUT}s")
47
-
48
- @comp_id ||= DEFAULT_COMP_ID
49
-
50
28
  # The sent messages
51
29
  @messages = []
30
+ end
52
31
 
53
- # TODO : How do we test this
54
- # TODO : Do we cancel the periodic timeout when leaving ?
55
- EM.add_timer(LOGON_TIMEOUT) { logon_timeout }
32
+ #
33
+ # The way we refer to our connection peer in various logs and messages
34
+ #
35
+ def peer
36
+ "server"
56
37
  end
57
38
 
58
- def logon_timeout
59
- unless client.has_session?
60
- log("Client <#{client.key}> failed to authenticate before timeout, closing connection")
61
- close_connection_after_writing
62
- Client.delete(ip, port)
63
- end
39
+ #
40
+ # Sets the heartbeat interval and schedules the keep alive call
41
+ #
42
+ # @param interval [Fixnum] The frequency in seconds at which a heartbeat should be emitted
43
+ #
44
+ def set_heartbeat_interval(interval)
45
+ @hrtbt_int && raise("Can't set heartbeat interval twice")
46
+ @hrtbt_int = interval
47
+
48
+ log("Heartbeat interval for #{peer} : <#{hrtbt_int}s>")
49
+ @keep_alive_timer = EM.add_periodic_timer(1) { keep_alive }
64
50
  end
65
51
 
66
- def manage_hrtbts
52
+ #
53
+ # Keeps the connection alive by sending regular heartbeats, and test request
54
+ # messages whenever the connection has been idl'ing for too long
55
+ #
56
+ def keep_alive
67
57
  @last_send_at ||= 0
68
58
  @last_request_at ||= 0
69
59
  @hrtbt_int ||= 0
@@ -75,25 +65,48 @@ module Fix
75
65
 
76
66
  # Trigger a test req message when we haven't received anything for a while
77
67
  if !@pending_test_req_id && (last_request_at < (Time.now.to_i - @hrtbt_int))
78
- tr = FP::Messages::TestRequest.new
79
- tr.test_req_id = SecureRandom.hex(6)
80
- send_msg(tr)
81
- @pending_test_req_id = tr.test_req_id
68
+ send_test_request
69
+ end
70
+ end
82
71
 
83
- EM.add_timer(TEST_REQ_GRACE_TIME) do
84
- @pending_test_req_id && kill!
85
- end
72
+ #
73
+ # Sends a test request and expects an answer before +TEST_REQ_GRACE_TIME+
74
+ #
75
+ def send_test_request
76
+ tr = FP::Messages::TestRequest.new
77
+ tr.test_req_id = SecureRandom.hex(6)
78
+ send_msg(tr)
79
+ @pending_test_req_id = tr.test_req_id
80
+
81
+ EM.add_timer(TEST_REQ_GRACE_TIME) do
82
+ @pending_test_req_id && kill!
86
83
  end
87
84
  end
88
85
 
86
+ #
87
+ # Sends a heartbeat message with an optional +test_req_id+ parameter
88
+ #
89
+ # @param test_req_id [String] Sets the test request ID if sent in response to a test request
90
+ #
91
+ def send_heartbeat(test_req_id = nil)
92
+ msg = FP::Messages::Heartbeat.new
93
+ test_req_id && msg.test_req_id = test_req_id
94
+ send_msg(msg)
95
+ end
96
+
97
+ #
98
+ # Sends a +Fix::Protocol::Message+ to the connected peer
99
+ #
100
+ # @param msg [Fix::Protocol::Message] The message to send
101
+ #
89
102
  def send_msg(msg)
90
103
  @send_seq_num ||= 1
91
104
 
92
105
  msg.msg_seq_num = @send_seq_num
93
106
  msg.sender_comp_id = @comp_id
94
- msg.target_comp_id ||= @client_comp_id
107
+ msg.target_comp_id = @target_comp_id
95
108
 
96
- log("Sending <#{msg.class}> to <#{ip}:#{port}> with sequence number <#{msg.msg_seq_num}>")
109
+ log("Sending <#{msg.class}> to #{peer} with sequence number <#{msg.msg_seq_num}>")
97
110
 
98
111
  if msg.valid?
99
112
  @messages[msg.msg_seq_num] = msg
@@ -102,85 +115,71 @@ module Fix
102
115
  @last_send_at = Time.now.to_i
103
116
  else
104
117
  log(msg.errors.join(', '))
105
- raise "Tried to send invalid message!"
118
+ raise "Tried to send invalid message! <#{msg.errors.join(', ')}>"
106
119
  end
107
120
  end
108
121
 
109
- def set_heartbeat_interval(interval)
110
- @hrtbt_int && raise("Can't set heartbeat interval twice")
111
- @hrtbt_int = interval
112
-
113
- log("Heartbeat interval for <#{ip}:#{port}> : <#{hrtbt_int}s>")
114
- @hrtbt_monitor = EM.add_periodic_timer(1) { manage_hrtbts }
115
- end
116
-
122
+ #
123
+ # Kills the connection after sending a logout message, if applicable
124
+ #
117
125
  def kill!
118
- if @client_comp_id
119
- log("Logging out client <#{ip}:#{port}>")
126
+ if @target_comp_id
127
+ log("Logging out #{peer}")
128
+
120
129
  logout = FP::Messages::Logout.new
121
130
  logout.text = 'Bye!'
131
+
122
132
  send_msg(logout)
123
133
  end
124
134
 
125
135
  close_connection_after_writing
126
136
  end
127
137
 
138
+ #
139
+ # Cleans up after we're done
140
+ #
128
141
  def unbind
129
- log("Terminating client <#{ip}:#{port}>")
130
- Client.delete(ip, port)
131
- @hrtbt_monitor && @hrtbt_monitor.cancel
142
+ log("Terminating connection to #{peer}")
143
+ @keep_alive_timer && @keep_alive_timer.cancel
132
144
  end
133
145
 
134
- def client_error(error_msg, msg_seq_num, opts = {})
135
- log("Client error: \"#{error_msg}\"")
136
- rjct = FP::Messages::Reject.new
137
- rjct.text = error_msg
138
- rjct.ref_seq_num = msg_seq_num
139
- rjct.target_comp_id = opts[:target_comp_id] if opts[:target_comp_id]
146
+ #
147
+ # Notifies the connected peer it fucked up somehow and kill the connection
148
+ #
149
+ # @param error_msg [String] The reason to embed in the reject message
150
+ # @param msg_seq_num [Fixnum] The message sequence number this error pertains to
151
+ #
152
+ def peer_error(error_msg, msg_seq_num)
153
+ log("Notifying #{peer} of error: <#{error_msg}> and terminating")
154
+
155
+ rjct = FP::Messages::Reject.new
156
+ rjct.text = error_msg
157
+ rjct.ref_seq_num = msg_seq_num
158
+
140
159
  send_msg(rjct)
141
160
  kill!
142
161
  end
143
162
 
144
- def handle_msg(msg)
163
+ #
164
+ # Maintains the message sequence consistency before handing off the message to +#handle_msg+
165
+ #
166
+ def process_msg(msg)
145
167
  @recv_seq_num = msg.msg_seq_num
146
-
147
- log("Received a <#{msg.class}> from <#{ip}:#{port}> with sequence number <#{msg.msg_seq_num}>")
148
168
 
149
- # If sequence number == expected, then process it normally
150
- if (@expected_clt_seq_num == @recv_seq_num)
169
+ log("Received a <#{msg.class}> from #{peer} with sequence number <#{msg.msg_seq_num}>")
151
170
 
171
+ # If sequence number == expected, then process it normally
172
+ if (@expected_seq_num == @recv_seq_num)
152
173
  if @comp_id && msg.target_comp_id != @comp_id
153
- @client_comp_id = msg.sender_comp_id
174
+ @target_comp_id = msg.sender_comp_id
154
175
 
176
+ # Whoops, incorrect COMP_ID received, kill it with fire
155
177
  if (msg.target_comp_id != @comp_id)
156
- client_error("Incorrect TARGET_COMP_ID in message, expected <#{@comp_id}>, got <#{msg.target_comp_id}>", msg.header.msg_seq_num)
178
+ peer_error("Incorrect TARGET_COMP_ID in message, expected <#{@comp_id}>, got <#{msg.target_comp_id}>", msg.header.msg_seq_num)
157
179
  end
158
180
 
159
181
  else
160
- if !@client_comp_id && msg.is_a?(FP::Messages::Logon)
161
- log("Client authenticated as <#{msg.username}> with heartbeat interval of <#{msg.heart_bt_int}s> and message sequence number start <#{msg.msg_seq_num}>")
162
- client.client_id = msg.username
163
- @client_comp_id = msg.sender_comp_id
164
- set_heartbeat_interval(msg.heart_bt_int)
165
-
166
- logon = FP::Messages::Logon.new
167
- logon.username = msg.username
168
- logon.target_comp_id = msg.sender_comp_id
169
- logon.sender_comp_id = msg.target_comp_id
170
- logon.reset_seq_num_flag = true
171
- send_msg(logon)
172
-
173
- elsif @client_comp_id && msg.is_a?(FP::Messages::Logon)
174
- log("Received second logon message, reset_seq_num_flag <#{msg.reset_seq_num_flag}>")
175
- if msg.reset_seq_num_flag = 'Y'
176
- @send_seq_num = 1
177
- @messages = []
178
- end
179
-
180
- elsif !@client_comp_id
181
- client_error("The session must be started with a logon message", msg.msg_seq_num, target_comp_id: msg.sender_comp_id)
182
-
183
- elsif msg.is_a?(FP::Messages::Heartbeat)
182
+ if msg.is_a?(FP::Messages::Heartbeat)
184
183
  # If we were expecting an answer to a test request we can sign it off and
185
184
  # cancel the scheduled connection termination
186
185
  if @pending_test_req_id && msg.test_req_id && (@pending_test_req_id == msg.test_req_id)
@@ -195,36 +194,40 @@ module Fix
195
194
 
196
195
  elsif msg.is_a?(FP::Messages::ResendRequest)
197
196
  # Re-send requested message range
198
- @messages[msg.begin_seq_no, msg.end_seq_no.zero? ? @messages.length : msg.end_seq_no].each do |m|
197
+ @messages[msg.begin_seq_no - 1, (msg.end_seq_no.zero? ? @messages.length : (msg.end_seq_no - msg.begin_seq_no + 1))].each do |m|
199
198
  log("Re-sending <#{m.class}> to <#{ip}:#{port}> with sequence number <#{m.msg_seq_num}>")
200
199
  send_data(m.dump)
201
200
  @last_send_at = Time.now.to_i
202
201
  end
203
202
 
204
203
  elsif msg.is_a?(FP::Message)
205
- on_message(msg)
204
+ run_message_handler(msg)
206
205
  end
207
206
  end
208
207
 
209
- @expected_clt_seq_num += 1
210
-
211
- elsif msg.is_a?(FP::Messages::Logon)
212
- client_error("Expected logon message to have msg_seq_num = <1>, received <#{msg.msg_seq_num}>", msg.msg_seq_num, target_comp_id: msg.sender_comp_id)
208
+ @expected_seq_num += 1
213
209
 
214
- elsif (@expected_clt_seq_num > @recv_seq_num)
215
- log("Ignoring message <#{msg}> with stale sequence number <#{msg.msg_seq_num}>, expecting <#{@expected_clt_seq_num}>")
210
+ elsif (@expected_seq_num > @recv_seq_num)
211
+ log("Ignoring message <#{msg}> with stale sequence number <#{msg.msg_seq_num}>, expecting <#{@expected_seq_num}>")
216
212
 
217
- elsif (@expected_clt_seq_num < @recv_seq_num) && @client_comp_id
213
+ elsif (@expected_seq_num < @recv_seq_num) && @target_comp_id
218
214
  # Request missing range when detect a gap
219
215
  rr = FP::Messages::ResendRequest.new
220
- rr.begin_seq_no = @expected_clt_seq_num
216
+ rr.begin_seq_no = @expected_seq_num
221
217
  send_msg(rr)
222
218
  end
223
219
 
224
220
  self.last_request_at = Time.now.to_i
225
221
  end
226
222
 
227
- def on_message(msg)
223
+ #
224
+ # Runs the defined message handler for the message's class
225
+ #
226
+ # @param msg [FP::Message] The message to handle
227
+ #
228
+ def run_message_handler(msg)
229
+ m = "on_#{msg.class.to_s.split('::').last.gsub(/(.)([A-Z])/, '\1_\2').downcase}".to_sym
230
+ send(m, msg) if respond_to?(m)
228
231
  end
229
232
 
230
233
  #
@@ -234,58 +237,27 @@ module Fix
234
237
  # @param data [String] The received data chunk
235
238
  #
236
239
  def receive_data(data)
237
- data_chunk = data.chomp
238
- msg_buf << data_chunk
240
+ @buf ||= MessageBuffer.new do |parsed|
241
+ if (parsed.class == FP::ParseFailure) || !parsed.errors.count.zero?
242
+ peer_error("#{parsed.message} -- #{parsed.errors.join(", ")}", @expected_seq_num)
243
+ log("Failed to parse message <#{parsed.message}>")
244
+ parsed.errors.each { |err| log(" >>> #{err}") }
245
+
246
+ else
247
+ process_msg(parsed)
248
+
249
+ end
250
+ end
239
251
 
240
252
  begin
241
- parse_messages_from_buffer
253
+ @buf.add_data(data)
242
254
  rescue
243
- log("Client <#{@client.key}> raised exception when parsing data <#{data.gsub(/\x01/, '|')}>, terminating.")
255
+ log("Raised exception by #{peer} when parsing data <#{@buf.msg_buf.gsub(/\x01/, '|')}>, terminating.")
244
256
  log($!.message + $!.backtrace.join("\n"))
245
257
  kill!
246
258
  end
247
259
  end
248
260
 
249
- #
250
- # Attempts to parse fields from the message buffer, if the fields that get parsed
251
- # complete the temporary message, it is handled
252
- #
253
- def parse_messages_from_buffer
254
- while idx = msg_buf.index("\x01")
255
- field = msg_buf.slice!(0, idx + 1).gsub(/\x01\Z/, '')
256
- msg.append(field)
257
-
258
- if msg.complete?
259
- parsed = msg.parse!
260
- if parsed.is_a?(FP::Message)
261
- handle_msg(parsed)
262
- elsif parsed.is_a?(FP::ParseFailure)
263
- client_error(parsed.errors.join(", "), @expected_clt_seq_num, target_comp_id: (@client_comp_id || 'UNKNOWN'))
264
- end
265
- end
266
- end
267
- end
268
-
269
- #
270
- # The data buffer string
271
- #
272
- def msg_buf
273
- @msg_buf ||= ''
274
- end
275
-
276
- #
277
- # The temporary message to which fields get appended
278
- #
279
- def msg
280
- @msg ||= MessageBuffer.new(@client)
281
- end
282
-
283
- def send_heartbeat(test_req_id = nil)
284
- msg = FP::Messages::Heartbeat.new
285
- test_req_id && msg.test_req_id = test_req_id
286
- send_msg(msg)
287
- end
288
-
289
261
  end
290
262
  end
291
263
  end
@@ -24,8 +24,14 @@ module Fix
24
24
  # running specs
25
25
  #
26
26
  def self.log(msg)
27
+ logger.debug(msg)
28
+ end
29
+
30
+ #
31
+ # Returns the current logger
32
+ #
33
+ def self.logger
27
34
  @logger ||= ::Logger.new(STDOUT)
28
- @logger.debug(msg)
29
35
  end
30
36
 
31
37
  end
@@ -13,9 +13,21 @@ module Fix
13
13
 
14
14
  attr_accessor :fields, :client
15
15
 
16
- def initialize(client)
16
+ def initialize(&block)
17
17
  @fields = []
18
- @client = client
18
+
19
+ raise "A block accepting a FP::Message as single parameter must be provided" unless (block && (block.arity == 1))
20
+ @msg_handler = block
21
+ end
22
+
23
+ #
24
+ # Adds received bytes to the message buffer and attempt to process them
25
+ #
26
+ # @param data [String] The received FIX message bits, as they come
27
+ #
28
+ def add_data(data)
29
+ msg_buf << data.chomp
30
+ parse_messages
19
31
  end
20
32
 
21
33
  #
@@ -41,42 +53,48 @@ module Fix
41
53
  end
42
54
 
43
55
  #
44
- # Parses the message into a FP::Message instance
56
+ # Attempts to parse fields from the message buffer, if the fields that get parsed
57
+ # complete the temporary message, it is processed
45
58
  #
46
- def parse
47
- msg = FP.parse(to_s)
48
- if (msg.class == FP::ParseFailure) || !msg.errors.count.zero?
49
- log("Failed to parse message <#{debug}>")
50
- log_errors(msg)
59
+ def parse_messages(&block)
60
+ while idx = msg_buf.index("\x01")
61
+ field = msg_buf.slice!(0, idx + 1).gsub(/\x01\Z/, '')
62
+ append(field)
63
+
64
+ if complete?
65
+ parsed = FP.parse(to_s)
66
+ @fields = []
67
+ @msg_handler.call(parsed)
68
+ end
51
69
  end
52
-
53
- msg
54
70
  end
55
71
 
56
72
  #
57
- # Parses the message and empties the fields array so a new message
58
- # can start to get buffered right away
73
+ # The data buffer string
59
74
  #
60
- def parse!
61
- parsed = parse
62
- @fields = []
63
- parsed
75
+ def msg_buf
76
+ @msg_buf ||= ''
64
77
  end
65
78
 
79
+ #
80
+ # Returns a human-friendly string of the currently handled data
81
+ #
82
+ # @return [String] The parsed fields and the temporary buffer
83
+ #
66
84
  def debug
67
- to_s('|')
85
+ "#{to_s('|')}#{@msg_buf}"
68
86
  end
69
87
 
88
+ #
89
+ # Returns the current fields as a string joined by a given separator
90
+ #
91
+ # @param sep [String] The separator
92
+ # @return [String] The fields joined by the separator
93
+ #
70
94
  def to_s(sep = "\x01")
71
95
  fields.map { |f| f.join('=') }.join(sep) + sep
72
96
  end
73
97
 
74
- def log_errors(msg)
75
- log("Invalid message received <#{debug}>")
76
- msg.errors.each { |e| log(" >>> #{e}") }
77
- end
78
-
79
-
80
98
  end
81
99
  end
82
100
  end
@@ -2,7 +2,7 @@ require 'eventmachine'
2
2
 
3
3
  require 'fix/protocol'
4
4
  require 'fix/engine/version'
5
- require 'fix/engine/connection'
5
+ require 'fix/engine/server_connection'
6
6
 
7
7
  module Fix
8
8
  module Engine
@@ -14,14 +14,18 @@ module Fix
14
14
 
15
15
  include Logger
16
16
 
17
+ #
18
+ # Periodicity in seconds of logged status reports
19
+ #
17
20
  REPORT_INTERVAL = 10
18
21
 
19
22
  attr_accessor :ip, :port
20
23
 
21
- def initialize(ip, port, handler)
24
+ def initialize(ip, port, handler, &block)
22
25
  @ip = ip
23
26
  @port = port
24
27
  @handler = handler
28
+ @block = block
25
29
  end
26
30
 
27
31
  #
@@ -38,10 +42,15 @@ module Fix
38
42
  #
39
43
  def start_server
40
44
  raise "EventMachine must be running to start a server" unless EM.reactor_running?
41
- EM.start_server(ip, port, @handler)
45
+
46
+ EM.start_server(ip, port, @handler) { |conn| @block && @block.call(conn) }
47
+
42
48
  REPORT_INTERVAL && EM.add_periodic_timer(REPORT_INTERVAL) { report_status }
43
49
  end
44
50
 
51
+ #
52
+ # Logs a short summary of the current server status
53
+ #
45
54
  def report_status
46
55
  log("#{Client.count} client(s) currently connected")
47
56
  end
@@ -0,0 +1,103 @@
1
+ require 'fix/engine/connection'
2
+ require 'fix/engine/client'
3
+
4
+ module Fix
5
+ module Engine
6
+
7
+ #
8
+ # The server connection wrapper, used when accepting a connection
9
+ #
10
+ module ServerConnection
11
+
12
+ include Connection
13
+
14
+ #
15
+ # Timespan during which a client must send a logon message after connecting
16
+ #
17
+ LOGON_TIMEOUT = 10
18
+
19
+ #
20
+ # Run after a client has connected
21
+ #
22
+ def post_init
23
+ super
24
+
25
+ @port, @ip = Socket.unpack_sockaddr_in(get_peername)
26
+ @client = Client.get(ip, port, self)
27
+
28
+ log("Client connected #{peer}, expecting logon message in the next #{LOGON_TIMEOUT}s")
29
+
30
+ EM.add_timer(LOGON_TIMEOUT) { logon_timeout }
31
+ end
32
+
33
+ #
34
+ # Logs the client out should he fail to authenticate before +LOGON_TIMEOUT+ seconds
35
+ #
36
+ def logon_timeout
37
+ unless @target_comp_id
38
+ log("Client #{peer} failed to authenticate before timeout, closing connection")
39
+ close_connection_after_writing
40
+ client.delete
41
+ end
42
+ end
43
+
44
+ #
45
+ # Returns the currently connected client
46
+ #
47
+ def client
48
+ Client.get(ip, port, self)
49
+ end
50
+
51
+ #
52
+ # The way we refer to our connection peer in various logs and messages
53
+ #
54
+ def peer
55
+ "<#{client.key}>"
56
+ end
57
+
58
+ #
59
+ # Deletes the +FE::Client+ instance after the connection is terminated
60
+ #
61
+ def unbind
62
+ super
63
+ client.delete
64
+ end
65
+
66
+ #
67
+ # We override +FE::Connection#run_message_handlers+ to add some session-related logic
68
+ #
69
+ def run_message_handler(msg)
70
+ if !@target_comp_id && msg.is_a?(FP::Messages::Logon)
71
+ log("Peer authenticated as <#{msg.username}> with heartbeat interval of <#{msg.heart_bt_int}s> and message sequence number start <#{msg.msg_seq_num}>")
72
+ client.username = msg.username
73
+ @target_comp_id = msg.sender_comp_id
74
+ set_heartbeat_interval(msg.heart_bt_int)
75
+
76
+ logon = FP::Messages::Logon.new
77
+ logon.username = msg.username
78
+ logon.target_comp_id = msg.sender_comp_id
79
+ logon.sender_comp_id = msg.target_comp_id
80
+ logon.reset_seq_num_flag = true
81
+
82
+ send_msg(logon)
83
+
84
+ elsif @target_comp_id && msg.is_a?(FP::Messages::Logon)
85
+ log("Received second logon message, reset_seq_num_flag <#{msg.reset_seq_num_flag}>")
86
+ if msg.reset_seq_num_flag = 'Y'
87
+ @send_seq_num = 1
88
+ @messages = []
89
+ end
90
+
91
+ elsif !@target_comp_id
92
+ peer_error("The session must be started with a logon message", msg.msg_seq_num, target_comp_id: msg.sender_comp_id)
93
+
94
+ else
95
+ super(msg)
96
+
97
+ end
98
+ end
99
+
100
+ end
101
+ end
102
+ end
103
+
@@ -4,7 +4,7 @@ module Fix
4
4
  #
5
5
  # The fix-engine gem version string
6
6
  #
7
- VERSION = '0.0.24'
7
+ VERSION = '1.0.0'
8
8
 
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,128 +1,120 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fix-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.24
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David François
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2015-01-05 00:00:00.000000000 Z
11
+ date: 2015-01-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - '>='
17
+ - - ~>
18
18
  - !ruby/object:Gem::Version
19
- version: '0'
19
+ version: '3.1'
20
20
  type: :development
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - '>='
24
+ - - ~>
25
25
  - !ruby/object:Gem::Version
26
- version: '0'
26
+ version: '3.1'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: rake
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - '>='
31
+ - - ~>
32
32
  - !ruby/object:Gem::Version
33
- version: '0'
33
+ version: '10.3'
34
34
  type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - '>='
38
+ - - ~>
39
39
  - !ruby/object:Gem::Version
40
- version: '0'
40
+ version: '10.3'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: yard
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - '>='
45
+ - - ~>
46
46
  - !ruby/object:Gem::Version
47
- version: '0'
47
+ version: '0.8'
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - '>='
52
+ - - ~>
53
53
  - !ruby/object:Gem::Version
54
- version: '0'
55
- - !ruby/object:Gem::Dependency
56
- name: pry
57
- requirement: !ruby/object:Gem::Requirement
58
- requirements:
59
- - - '>='
60
- - !ruby/object:Gem::Version
61
- version: '0'
62
- type: :development
63
- prerelease: false
64
- version_requirements: !ruby/object:Gem::Requirement
65
- requirements:
66
- - - '>='
67
- - !ruby/object:Gem::Version
68
- version: '0'
54
+ version: '0.8'
69
55
  - !ruby/object:Gem::Dependency
70
56
  name: redcarpet
71
57
  requirement: !ruby/object:Gem::Requirement
72
58
  requirements:
73
- - - '>='
59
+ - - ~>
74
60
  - !ruby/object:Gem::Version
75
- version: '0'
61
+ version: '3.1'
76
62
  type: :development
77
63
  prerelease: false
78
64
  version_requirements: !ruby/object:Gem::Requirement
79
65
  requirements:
80
- - - '>='
66
+ - - ~>
81
67
  - !ruby/object:Gem::Version
82
- version: '0'
68
+ version: '3.1'
83
69
  - !ruby/object:Gem::Dependency
84
70
  name: simplecov
85
71
  requirement: !ruby/object:Gem::Requirement
86
72
  requirements:
87
- - - '>='
73
+ - - ~>
88
74
  - !ruby/object:Gem::Version
89
- version: '0'
75
+ version: '0.9'
90
76
  type: :development
91
77
  prerelease: false
92
78
  version_requirements: !ruby/object:Gem::Requirement
93
79
  requirements:
94
- - - '>='
80
+ - - ~>
95
81
  - !ruby/object:Gem::Version
96
- version: '0'
82
+ version: '0.9'
97
83
  - !ruby/object:Gem::Dependency
98
84
  name: fix-protocol
99
85
  requirement: !ruby/object:Gem::Requirement
100
86
  requirements:
101
- - - '>='
87
+ - - ~>
102
88
  - !ruby/object:Gem::Version
103
- version: '0'
89
+ version: 0.0.64
104
90
  type: :runtime
105
91
  prerelease: false
106
92
  version_requirements: !ruby/object:Gem::Requirement
107
93
  requirements:
108
- - - '>='
94
+ - - ~>
109
95
  - !ruby/object:Gem::Version
110
- version: '0'
96
+ version: 0.0.64
111
97
  - !ruby/object:Gem::Dependency
112
98
  name: eventmachine
113
99
  requirement: !ruby/object:Gem::Requirement
114
100
  requirements:
115
- - - '>='
101
+ - - ~>
116
102
  - !ruby/object:Gem::Version
117
- version: '0'
103
+ version: '1.0'
118
104
  type: :runtime
119
105
  prerelease: false
120
106
  version_requirements: !ruby/object:Gem::Requirement
121
107
  requirements:
122
- - - '>='
108
+ - - ~>
123
109
  - !ruby/object:Gem::Version
124
- version: '0'
125
- description: FIX engine handling connections, sessions, and message callbacks
110
+ version: '1.0'
111
+ description: " The FIX engine library allows one to easily connect to a FIX acceptor
112
+ and establish\n a session, it will handle the administrative messages such as logons,
113
+ hearbeats, gap fills and\n allow custom handling of business level messages.\n\n
114
+ \ Likewise, an acceptor may be easily implemented by defining callbacks for business
115
+ level messages.\n\n FIX protocol message parsing capabilities are provided by the
116
+ fix-protocol gem, which\n currently supports the administrative subset (and a few
117
+ business level messages) of the FIX 4.4\n message specification. \n"
126
118
  email:
127
119
  - david.francois@paymium.com
128
120
  executables: []
@@ -134,13 +126,16 @@ files:
134
126
  - bin/fix-engine
135
127
  - lib/fix/engine.rb
136
128
  - lib/fix/engine/client.rb
129
+ - lib/fix/engine/client_connection.rb
137
130
  - lib/fix/engine/connection.rb
138
131
  - lib/fix/engine/logger.rb
139
132
  - lib/fix/engine/message_buffer.rb
140
133
  - lib/fix/engine/server.rb
134
+ - lib/fix/engine/server_connection.rb
141
135
  - lib/fix/engine/version.rb
142
136
  homepage: https://github.com/paymium/fix-engine
143
- licenses: []
137
+ licenses:
138
+ - MIT
144
139
  metadata: {}
145
140
  post_install_message:
146
141
  rdoc_options: []