ability to settle cashpositions
[smr.git] / gui / lib / smr / transaction.rb
blob59ae751a177c8f6564144b87f3e64a8a5258de5d
2 # This file is part of SMR.
4 # SMR is free software: you can redistribute it and/or modify it under the
5 # terms of the GNU General Public License as published by the Free Software
6 # Foundation, either version 3 of the License, or (at your option) any later
7 # version.
9 # SMR is distributed in the hope that it will be useful, but WITHOUT ANY
10 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
11 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License along with
14 # SMR.  If not, see <http://www.gnu.org/licenses/>.
17 module Smr  #:nodoc:
18   ##
19   # Businesslogic to buy/sell securities in SMR.
20   #
21   # Works mostly on top of the Order model but interacts with Position and
22   # Security. Essentially a Transaction creates new PositionRevision(s).
23   #
24   # Methods return False in case #usage_constrains_ok is not happy. #error
25   # has a String desribing the error in that case.
26   class Transaction
28       # Quote.quote to use for executing cash bookings
29       # - defined as constant, though I do not see why this should ever change
30       CASHPOSITION_EXEC_QUOTE = 1.0
32       # cash Position used for booking this Transaction
33       attr_reader :cash_position
35       # Order used or issued by this Transaction
36       attr_reader :order
38       ##
39       # Perform a transaction on given Position (or Smr::AssetPosition).
40       # - User is asked for the sake of security, it should be authenticated!
41       # - :order will use existing Order as basis for the transaction. The
42       #   Order must belong to this Position and should be in pending state.
43       # - :order_is_redemption says the given :order was not initiated by the
44       #   SMR User but rather by the issuer of the Security to redeem it. The
45       #   resulting cash booking is categorized accordingly then.
46       # - if given Position is a cash position, #buy and #sell will directly
47       #   operate on it. See Smr::ID_CASH_SECURITY.
48       # - charges specified by #set_charges will be deducted from the cash
49       #   booking
50       # - if given, :type is one of the types supported by PositionRevision, :shares is default
51       def initialize(position, user,
52             options={
53                 :type=>:shares, :issue_time=>false,
54                 :order=>false, :order_is_redemption=>false,
55                 :triggered_by_order=>false
56             }
57           )
58         @position = position
59         @user = user
60         @time = options[:issue_time] || Time.now
61         @order = prepare_order(options[:triggered_by_order], options[:order])
62         @order_is_redemption = options[:order_is_redemption] || false
63         @cash_position = prepare_cash_position
64         @error = Array.new
65         @type =  options[:type] || :shares
66       end
68       ##
69       # Tell whether this Transaction is a cash booking.
70       def is_cash_booking?
71           @position.is_cash_position?
72       end
74       ##
75       # Human readable string describing this transaction
76       def to_s
77         if not @error.empty?
78             'Transaction has errors: ' + @error.join(', ')
79         elsif @order.is_pending?
80             'Transaction in progress'
81         elsif @order.is_executed?
82             'Transaction complete, %s %.4f shares and booked against cash.' \
83             % [ @order.type, @order.shares ]
84         end
85       end
87       ##
88       # Set charges to be booked with this order.
89       #
90       # All charges will be deducted from the cash booking.
91       def set_charges(provision, courtage, expense)
92         return false unless usage_constrains_ok
93         @order.provision = provision
94         @order.courtage = courtage
95         @order.expense = expense
96       end
98       ##
99       # Set accrued interest to be booked with this order.
100       #
101       # Accrued interest is handled as follows:
102       # - on a BUY order :amount is considered being paid
103       # - on a SELL order :amount is considered being received
104       def set_accrued_interest(amount)
105         return false unless usage_constrains_ok
106         @order.accrued_interest = amount.abs
107       end
109       ##
110       # Set a commentary text. It might be a verbose explanation.
111       def set_comment(text)
112         return false unless usage_constrains_ok
113         @order.comment = text
114       end
116       ##
117       # Make a PURCHASE on the Position.
118       def buy(shares, limit, exchange=nil)
119         return false unless usage_constrains_ok
120         @order.type = 'buy'
121         @order.shares = shares.abs
122         @order.limit = limit
123         @order.exchange = if is_cash_booking? then 'cash' else exchange end
124         @order.save
125       end
127       ##
128       # Make a SALE on the Position.
129       def sell(shares, limit, exchange=nil)
130         return false unless usage_constrains_ok
131         @order.type = 'sale'
132         @order.shares = shares.abs
133         @order.limit = limit
134         @order.exchange = if is_cash_booking? then 'cash' else exchange end
135         @order.save
136       end
138       ##
139       # Make a cash booking using :amount.
140       #
141       # The :amount can be positive or negative. Works only if given Position
142       # is a cash position.
143       #
144       # Specify optional :charges and :accrued_interest as absolute number. It
145       # will be added/deducted from :amount, depending its sign.
146       #
147       # There is no need to call #execute when #book is used.
148       def book(amount, options={:time=>false, :charges=>false, :accrued_interest=>false, :comment=>false})
149         return false unless usage_constrains_ok
150         if not is_cash_booking? then
151             @error << '#book works only if a cash position was given to ::new'
152             return false
153         end
155         # #book is always to handle cash of some sort, so its default
156         # is the "cash equivalent" of :shares.
157         # Otherwise fall through an use options[:type] as was given to #new.
158         @type = :order_booking if @type == :shares
160         time = options[:time] || Time.now
161         charges = options[:charges] || 0.0
162         accrued_interest = options[:accrued_interest] || 0.0
163         set_comment(options[:comment]) if options[:comment]
165         if amount < 0.0 then
166             sell(amount.abs + accrued_interest + charges, CASHPOSITION_EXEC_QUOTE)
167         else
168             buy(amount + accrued_interest - charges, CASHPOSITION_EXEC_QUOTE)
169         end
170         execute(CASHPOSITION_EXEC_QUOTE, time)
171       end
173       ##
174       # Apply Order to Position at given quote and time.
175       #
176       # This is final and can not be reversed as it will also book the
177       # transaction against cash.
178       def execute(quote, time=Time.now)
179         return false unless usage_constrains_ok
180         @order.quote = quote
181         @order.save! unless @order.id # trigger :id being created
183         PositionRevision.create!(
184             :type=>@type,
185             :id_order=>@order.id,
186             :id_position=>@position.id,
187             :date=>time.to_i,
188         )
190         process_revisions
191         book_order_against_cash(time)
192       end
194       ##
195       # Cancel the Transaction. Can not be reversed.
196       def cancel(time=Time.now)
197         @order.is_canceled = 1
198         @order.save!
199       end
201       # returns String describing the error when some methode returned False
202       def error
203         @error.join(', ')
204       end
206       ###
207       # Create new or use existing cashposition in Portfolio, depending on
208       # :time and presence of such a Position.
209       #
210       # Implemented as class method so it can be used externally to find
211       # cashpositions.
212       #
213       # Specification of cash type Position records:
214       # - a cash Position lives 1 year: Jan 1st to Dec 31st
215       #   (the :time parameter is of Time and used to determine the year)
216       # - when a cash position is closed, that year is settled! no more orders
217       #   in that account / that year are possible
218       # - cash positions are created silently, if not present for that year
219       # - cash positions are to be closed manually (so it is still possible to
220       #   enter order data of past years)
221       #
222       # - User.id is checked here to improve security
223       #
224       def Transaction::find_cashposition(id_portfolio, id_user, time=Time.now)
225         start_date=time.beginning_of_year.to_i
226         end_date=time.end_of_year.to_i
227         cp = false
229         cps = Position.where(:id_portfolio=>id_portfolio, :id_security=>Smr::ID_CASH_SECURITY)
230                      .joins(:Portfolio).where('portfolio.id_user=%i' % id_user).all.to_a
231         if cps then
232             cps.keep_if{ |cp|
233                 cp.PositionRevision.count == 0 or cp.PositionRevision.first.time.year == time.year
234             }
235             cp = cps.first   # should we ever have multiple ones open the same year? multi currency?
236         end
237         if not cp
238            cp = Position.new(
239               :id_portfolio=>id_portfolio,
240               :id_security=>Smr::ID_CASH_SECURITY,
241               :comment=>'Jan - Dec %i' % time.year)
242            cp.save!
243         end
244         return cp
245       end
247     protected
249       ##
250       # usability is limited by conditions verified here
251       def usage_constrains_ok
252         if @order.is_canceled? then
253             @error << 'Order #%i has been canceled already' % @order.id
254         elsif @order.is_executed? then
255             @error << 'Order #%i has been executed already' % @order.id
256         elsif @position.closed? then
257             @error << ('Position #%i is closed. Orders can only be added to '
258                     + 'open positions.' % @position.id)
259         elsif @cash_position.closed? then
260             @error << ('Cash position has been settled. No further ' \
261                     + 'transactions are possible for the period of %s.' % @cash_position.comment)
262         end
263         @error.empty?
264       end
266       ##
267       # create or use an Order
268       def prepare_order(triggered_by_order=false, order=false)
269         if order and order.is_a?(Order) then
270             raise 'possible security violation: given order(:id=>%i) does not belong to position in a portfolio owned by user(:id=>%i)' % [order.id, @user.id] if order.Position.Portfolio.id_user != @user.id
271             o = order
272         else
273             o = Order.new(
274                 :issued=>@time.to_i,
275                 :expire=>(@time+1.month).end_of_day.to_i,
276                 :id_position=>@position.id,
277                 :expense=>0, :courtage=>0, :provision=>0
278             )
279         end
280         o.triggered_by_order = triggered_by_order if triggered_by_order
281         return o
282        end
284       ###
285       # utilize #find_cashposition in context of a instance
286       def prepare_cash_position
287         return Transaction::find_cashposition(@position.id_portfolio, @user.id, @time)
288       end
290       ##
291       # Re-calculate all PositionRevision records related to this Position.
292       def process_revisions
293           @order.save
294           shares = invested = 0
295           PositionRevision.where(:id_position=>@position.id).order(date: :asc).each do |pr|
296               o = pr.Order
297               if o.is_sale? then
298                   shares   -= o.shares
299                   invested -= o.shares * o.quote
300               else
301                   shares   += o.shares;
302                   invested += o.shares * o.quote
303               end
305               pr.shares = shares
306               pr.invested = invested
307               pr.save!
308           end
309       end
311       ##
312       # Make cash booking on cash Position in same Portfolio.
313       #
314       # Returns
315       # - 1 if cash booking was successfull
316       # - 2 if this Transaction is a cash booking itself
317       # - 3 if the Order in this Transaction has been booked already
318       #
319       def book_order_against_cash(time)
320         if @order.quote==0.0 or @order.shares==0.0 then
321             raise 'order not sufficiently prepared'
322         end
324         # do nothing if this is a cash booking or the order has already triggered a cash booking
325         return 2 if is_cash_booking?
326         return 3 if Order.where(:triggered_by_order=>@order.id).count > 0
328         # add new order onto it, execute right away
329         ct = Transaction.new(
330             @cash_position, @user, :triggered_by_order=>@order.id,
331             :type=>(@order_is_redemption ? :redemption_booking : :shares )
332         )
333         vol = @order.volume
334         vol *= -1 if @order.is_purchase?
335         ct.book(vol, :charges=>@order.charges, :accrued_interest=>@order.accrued_interest, :time=>time)
337         return 1
338       end
340   end
343 end # module