reworked dividend
[smr.git] / gui / lib / smr / transaction.rb
blob514d508ce88ceb5cd5e90806abfbd4e791e36727
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   # Stock.
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       # - if given Position is a cash position, #buy and #sell will directly
44       #   operate on it. See Smr::ID_CASH_SECURITY.
45       # - charges specified by #set_charges will be deducted from the cash
46       #   booking
47       def initialize(position, user, options={:issue_time=>false, :order=>false, :triggered_by_order=>false})
48         @position = position
49         @user = user
50         @time = options[:issue_time] || Time.now
51         @order = prepare_order(options[:triggered_by_order], options[:order])
52         @cash_position = prepare_cash_position
53         @error = Array.new
54       end
56       ##
57       # Tell whether this Transaction is a cash booking.
58       def is_cash_booking?
59           @position.is_cash_position?
60       end
62       ##
63       # Human readable string describing this transaction
64       def to_s
65         if @order.is_pending?
66             'Transaction in progress'
67         elsif @order.is_executed?
68             'Transaction complete, %s %.4f shares and booked against cash.' \
69             % [ @order.type, @order.shares ]
70         end
71       end
73       ##
74       # Set charges to be booked with this order.
75       #
76       # All charges will be deducted from the cash booking.
77       def set_charges(provision, courtage, expense)
78         return false unless usage_constrains_ok
79         @order.provision = provision
80         @order.courtage = courtage
81         @order.expense = expense
82       end
84       ##
85       # Set accrued interest to be booked with this order.
86       #
87       # Accrued interest is handled as follows:
88       # - on a BUY order :amount is considered being paid
89       # - on a SELL order :amount is considered being received
90       def set_accrued_interest(amount)
91         return false unless usage_constrains_ok
92         @order.accrued_interest = amount.abs
93       end
95       ##
96       # Set a commentary text. It might be a verbose explanation.
97       def set_comment(text)
98         return false unless usage_constrains_ok
99         @order.comment = text
100       end
102       ##
103       # Make a PURCHASE on the Position.
104       def buy(shares, limit, exchange=nil)
105         return false unless usage_constrains_ok
106         @order.type = 'buy'
107         @order.shares = shares.abs
108         @order.limit = limit
109         @order.exchange = if is_cash_booking? then 'cash' else exchange end
110         @order.save
111       end
113       ##
114       # Make a SALE on the Position.
115       def sell(shares, limit, exchange=nil)
116         return false unless usage_constrains_ok
117         @order.type = 'sale'
118         @order.shares = shares.abs
119         @order.limit = limit
120         @order.exchange = if is_cash_booking? then 'cash' else exchange end
121         @order.save
122       end
124       ##
125       # Make a cash booking using :amount.
126       #
127       # The :amount can be positive or negative. Works only if given Position
128       # is a cash position.
129       #
130       # Specify optional :charges and :accrued_interest as absolute number. It
131       # will be added/deducted from :amount, depending its sign.
132       #
133       # There is no need to call #execute when #book is used.
134       def book(amount, options={:time=>false, :charges=>false, :accrued_interest=>false, :comment=>false})
135         return false unless usage_constrains_ok
136         if not is_cash_booking? then
137             @error << '#book works only if a cash position was given to ::new'
138             return false
139         end
141         time = options[:time] || Time.now
142         charges = options[:charges] || 0.0
143         accrued_interest = options[:accrued_interest] || 0.0
144         set_comment(options[:comment]) if options[:comment]
146         if amount < 0.0 then
147             sell(amount.abs + accrued_interest + charges, CASHPOSITION_EXEC_QUOTE)
148         else
149             buy(amount + accrued_interest - charges, CASHPOSITION_EXEC_QUOTE)
150         end
151         execute(CASHPOSITION_EXEC_QUOTE, time)
152       end
154       ##
155       # Apply Order to Position at given quote and time.
156       #
157       # This is final and can not be reversed as it will also book the
158       # transaction against cash.
159       def execute(quote, time=Time.now)
160         return false unless usage_constrains_ok
161         @order.quote = quote
162         @order.save!  # trigger INSERT to have :id
164         PositionRevision.new(
165             :id_order=>@order.id,
166             :id_position=>@position.id,
167             :date=>time.to_i
168         ).save
170         process_revisions
171         book_order_against_cash(time)
172       end
174       ##
175       # Cancel the Transaction. Can not be reversed.
176       def cancel(time=Time.now)
177         @order.is_canceled = 1
178         @order.save!
179       end
181       # returns String describing the error when some methode returned False
182       def error
183         @error.join(', ')
184       end
186     protected
188       ##
189       # usability is limited by conditions verified here
190       def usage_constrains_ok
191         if @order.is_canceled? then
192             @error << 'Order #%i has been canceled already' % @order.id
193         elsif @order.is_executed? then
194             @error << 'Order #%i has been executed already' % @order.id
195         elsif @position.closed? then
196             @error << 'Position #%i is closed. Orders can only be added to open positions.' % @position.id
197         elsif @cash_position.closed? then
198             @error << ('Cash position #%i has been settled. No ' \
199                     + 'further Transactions are possible in this period.')
200                     % @cash_position.id
201         end
202         @error.empty?
203       end
205       ##
206       # create or use an Order
207       def prepare_order(triggered_by_order=false, order=false)
208         if order and order.is_a?(Order) and order.Position.Portfolio.id_user == @user.id then
209             o = order
210         else
211             o = Order.new(
212                 :issued=>@time.to_i,
213                 :expire=>(@time+1.month).end_of_day.to_i,
214                 :id_position=>@position.id,
215                 :expense=>0, :courtage=>0, :provision=>0
216             )
217         end
218         o.triggered_by_order = triggered_by_order if triggered_by_order
219         return o
220        end
222       ###
223       # Create new or use existing cash Position, depends on time.
224       #
225       # Specification of cash type Position records:
226       # - a cash Position lives 1 year: Jan 1st to Dec 31st
227       # - when a cash position is closed, that year is settled! no more orders
228       #   in that account / that year are possible
229       # - cash positions are created silently, if not present for that year
230       # - cash positions are to be closed manually (so it is still possible to
231       #   enter order data of past years)
232       #
233       # - User.id is checked here to improve security
234       #
235       def prepare_cash_position
236         start_date=@time.beginning_of_year.to_i
237         end_date=@time.end_of_year.to_i
238         cp = false
240         cps = Position.where(:id_portfolio=>@position.id_portfolio, :id_stock=>Smr::ID_CASH_SECURITY)
241                      .joins(:Portfolio).where('portfolio.id_user=%i' % @user.id).all.to_a
242         if cps then
243             cps.keep_if{ |cp|
244                 cp.PositionRevision.count == 0 or cp.PositionRevision.first.time.year == @time.year
245             }
246             cp = cps.first   # should we ever have multiple ones open the same year? multi currency?
247         end
248         if not cp then
249            cp = Position.new(
250               :id_portfolio=>@position.id_portfolio,
251               :id_stock=>Smr::ID_CASH_SECURITY,
252               :comment=>'Jan - Dec %i' % @time.year)
253            cp.save!
254         end
255         return cp
256       end
258       ##
259       # Re-calculate all PositionRevision records related to this Position.
260       def process_revisions
261           @order.save
262           shares = invested = 0
263           PositionRevision.where(:id_position=>@position.id).order(date: :asc).each do |pr|
264               o = pr.Order
265               if o.is_sale? then
266                   shares   -= o.shares
267                   invested -= o.shares * o.quote
268               else
269                   shares   += o.shares;
270                   invested += o.shares * o.quote
271               end
273               pr.shares = shares
274               pr.invested = invested
275               pr.save!
276           end
277       end
279       ##
280       # Make cash booking on cash Position in same Portfolio.
281       #
282       # Returns
283       # - 1 if cash booking was successfull
284       # - 2 if this Transaction is a cash booking itself
285       # - 3 if the Order in this Transaction has been booked already
286       #
287       def book_order_against_cash(time)
288         if @order.quote==0.0 or @order.shares==0.0 then
289             raise 'order not sufficiently prepared'
290         end
292         # do nothing if this is a cash booking or the order has already triggered a cash booking
293         return 2 if is_cash_booking?
294         return 3 if Order.where(:triggered_by_order=>@order.id).count > 0
296         # add new order onto it, execute right away
297         ct = Transaction.new(@cash_position, @user, :triggered_by_order=>@order.id)
298         vol = @order.volume
299         vol *= -1 if @order.is_purchase?
300         ct.book(vol, :charges=>@order.charges, :accrued_interest=>@order.accrued_interest, :time=>time)
302         return 1
303       end
305   end
308 end # module