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
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/>.
19 # Businesslogic to buy/sell securities in SMR.
21 # Works mostly on top of the Order model but interacts with Position and
22 # Security. Essentially a Transaction creates new PositionRevision(s).
24 # Methods return False in case #usage_constrains_ok is not happy. #error
25 # has a String desribing the error in that case.
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
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
50 # - if given, :type is one of the types supported by PositionRevision, :shares is default
51 def initialize(position, user,
53 :type=>:shares, :issue_time=>false,
54 :order=>false, :order_is_redemption=>false,
55 :triggered_by_order=>false
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
65 @type = options[:type] || :shares
69 # Tell whether this Transaction is a cash booking.
71 @position.is_cash_position?
75 # Human readable string describing this transaction
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 ]
88 # Set charges to be booked with this order.
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
99 # Set accrued interest to be booked with this order.
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
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
117 # Make a PURCHASE on the Position.
118 def buy(shares, limit, exchange=nil)
119 return false unless usage_constrains_ok
121 @order.shares = shares.abs
123 @order.exchange = if is_cash_booking? then 'cash' else exchange end
128 # Make a SALE on the Position.
129 def sell(shares, limit, exchange=nil)
130 return false unless usage_constrains_ok
132 @order.shares = shares.abs
134 @order.exchange = if is_cash_booking? then 'cash' else exchange end
139 # Make a cash booking using :amount.
141 # The :amount can be positive or negative. Works only if given Position
142 # is a cash position.
144 # Specify optional :charges and :accrued_interest as absolute number. It
145 # will be added/deducted from :amount, depending its sign.
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'
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]
166 sell(amount.abs + accrued_interest + charges, CASHPOSITION_EXEC_QUOTE)
168 buy(amount + accrued_interest - charges, CASHPOSITION_EXEC_QUOTE)
170 execute(CASHPOSITION_EXEC_QUOTE, time)
174 # Apply Order to Position at given quote and time.
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
181 @order.save! unless @order.id # trigger :id being created
183 PositionRevision.create!(
185 :id_order=>@order.id,
186 :id_position=>@position.id,
191 book_order_against_cash(time)
195 # Cancel the Transaction. Can not be reversed.
196 def cancel(time=Time.now)
197 @order.is_canceled = 1
201 # returns String describing the error when some methode returned False
207 # Create new or use existing cashposition in Portfolio, depending on
208 # :time and presence of such a Position.
210 # Implemented as class method so it can be used externally to find
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)
222 # - User.id is checked here to improve security
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
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
233 cp.PositionRevision.count == 0 or cp.PositionRevision.first.time.year == time.year
235 cp = cps.first # should we ever have multiple ones open the same year? multi currency?
239 :id_portfolio=>id_portfolio,
240 :id_security=>Smr::ID_CASH_SECURITY,
241 :comment=>'Jan - Dec %i' % time.year)
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)
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
275 :expire=>(@time+1.month).end_of_day.to_i,
276 :id_position=>@position.id,
277 :expense=>0, :courtage=>0, :provision=>0
280 o.triggered_by_order = triggered_by_order if triggered_by_order
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)
291 # Re-calculate all PositionRevision records related to this Position.
292 def process_revisions
294 shares = invested = 0
295 PositionRevision.where(:id_position=>@position.id).order(date: :asc).each do |pr|
299 invested -= o.shares * o.quote
302 invested += o.shares * o.quote
306 pr.invested = invested
312 # Make cash booking on cash Position in same Portfolio.
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
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'
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 )
334 vol *= -1 if @order.is_purchase?
335 ct.book(vol, :charges=>@order.charges, :accrued_interest=>@order.accrued_interest, :time=>time)