fix-engine 0.0.24 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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: []