minor features, layout improvements, many fixes
[smr.git] / gui / lib / smr / asset_position.rb
blobc1f502d4cb8713b1ccd2d102596af4d23cd9fc3e
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/>.
16 require 'smr/dividend_income'
17 require 'smr/link'
19 module Smr  #:nodoc:
20   
21   #
22   # A position at some point in time.
23   #
24   # It provides all information from a positions point of view. Like the Security
25   # held, Order (s) that were issued and the PositionRevision (s) it created.
26   #
27   # A AssetPosition is in one of three logical states of valuation, depending on
28   # the date given to #new:
29   #
30   # <b>open</b>:: 
31   #  does valuation with market value at date
32   # <b>closed, date before closure</b>:: 
33   #  does valuation with market value at date with securities held at date
34   # <b>closed, date after closure</b>:: 
35   #  does final gain calculation
36   #
37   # Use #is_closed? and #is_viewed_before_closure? to check for the state as a
38   # number of methods will just return false when called in the wrong state.
39   #
40   class AssetPosition
41     include Comparable
43     # accessors to underlying Position for compatibility
44     attr_reader :id
45     attr_reader :id_portfolio
46     attr_reader :id_security
47     attr_reader :closed
48     attr_reader :comment
50     # Time at which we are looking at the Position.
51     attr_reader :date
53     # Security held by this position.
54     attr_reader :security
56     # Portfolio this position is in.
57     attr_reader :portfolio
59     # User holding this position.
60     attr_reader :user
62     # Quote found for Security, its the most recent one before given :date
63     attr_reader :last_quote
65     # Collection of PositionRevision with respect to the point in time we are
66     # at.
67     attr_reader :revisions
69     # Collection of Order (s) in pending state, ie those not executed yet.
70     # FIXME: test expiry and canceled state, see #has_pending_orders?
71     attr_reader :pending_orders
73     ##
74     # Charges paid on all orders
75     attr_reader :charges
77     ##
78     # Accrued interest balance booked on executed orders
79     # - a BUY order is considered having paid interest
80     # - a SELL order is considered having received interest
81     attr_reader :accrued_interest
83     ##
84     # initialize with Position id and User id
85     #
86     # initilization will fail if +id_position+ is not part of a Portfolio owned
87     # by the User specified by +id_user+.
88     def initialize(id_position, id_user, date=Time.now)
90         @user = User.where(:id=>id_user).first
91         @position = Position.where(:id=>id_position).joins(:Portfolio).where('portfolio.id_user=%i'% id_user).first
92         raise 'possible security violation: id_position=%i does not belong to id_user=%i.' % [id_position, id_user] if not @position.is_a?(Position)
94         @id = id_position
95         @id_portfolio = @position.id_portfolio
96         @id_security = @position.id_security
97         @closed = @position.closed
98         @comment = @position.comment
99         @date = date
100         @orders = Array.new
101         @pending_orders = Array.new
102         @dividend = nil
103         @sum_buy_orders = 0.0
104         @sum_sell_orders = 0.0
105         @accrued_interest = 0.0
106         @charges = 0.0
107   
108         @viewed_before_closure = false
109   
110         # adjust date in case we are closed in the future
111         # - subsequent gain calculations produce bogus results otherwise
112         if @position.closed > 0 and @date < @position.time_closed then
113             @viewed_before_closure = true
114         end
115   
116         # preload things to SELECT them only once
117         # - even ActiveRecords CACHE takes time and we can be more intelligent
118         @security = @position.Security
119         @portfolio = Portfolio.where(:id=>@position.id_portfolio, :id_user=>id_user).first
120         @last_quote = @security.last_quote(@date)
121         @revisions = @position.PositionRevision.order(:date).where('date <= %i' % @date).to_a
122         @revisions.each do |r|
123             if r.id_order != 0 then
124                 o = r.Order
125                 @orders << o
126                 @charges += o.charges
127                 if o.is_purchase? then
128                     @sum_buy_orders += o.volume
129                     @accrued_interest -= o.accrued_interest
130                 else
131                     @sum_sell_orders += o.volume
132                     @accrued_interest += o.accrued_interest
133                 end
134             end
135         end
136         @pending_orders=Order.where(:id_position=>@id).pending.order(issued: :desc)
137     end
138   
139     public
141     ##
142     # Descriptive String of the position and its contents, human readable.
143     def to_s(options={:with_details=>false})
144         if options[:with_details] then
145             '%s (%i shares held at %s)' % [@security, shares, @portfolio]
146         else @security.to_s end
147     end
149     ##
150     # Currency code this position is held in.
151     # - note: currencies are not implemented yet, so this returns the default
152     def currency
153         Smr::DEFAULT_CURRENCY
154     end
156     ##
157     # Time when this position was closed, if #closed?
158     def time_closed
159         @position.time_closed
160     end
161   
162     ##
163     # Tell whether there are orders waiting for execute, expiry or cancelation.
164     def has_pending_orders?
165         not @pending_orders.empty?
166     end
167   
168     ##
169     # Smr::Dividend object providing Dividend information as of #date.
170     def dividend
171         if not @dividend then @dividend = Smr::DividendIncome.new(self) end
172         @dividend
173     end
175     ##
176     # Tell if Position is closed, regardless when that happened.
177     def closed?
178         is_closed?
179     end
180     def is_closed?
181         @position.closed > 0
182     end
184     ##
185     # Tell whether this is a cash deposit, also see Transaction.
186     def is_cash_position?
187         @position.is_cash_position?
188     end
190     ##
191     # Close the Position.
192     #
193     # This will close the position right away in case it is empty. +True+ is
194     # returned in that case.
195     #
196     # In case it is not empty a new Order is returned, that will sell it off
197     # and close it if executed. That Order may be presented for editing first,
198     # ie to add charges, execution price, etc...
199     #
200     # +False+ is returned in case the position has been closed already, even if
201     # that happened 'in the future' (given date > smr_browse_date or Time.now).
202     #
203     # Closing a Smr::AssetPosition is a one-time-only operation. New orders are
204     # not possible once this has been done. Closing is only possible when the
205     # number of shares held is zero.
206     def close(date=@date)
207         return false if is_closed?
208         
209         if shares.between? -0.01, 0.01
210             # at 0 or when a very tiny amount remains, we close.
211             # NOTE:
212             # - see :precision and :scale on float type columns, it rounds to a scale of 4 digits
213             # - http://stackoverflow.com/questions/23120584/why-does-mysql-round-floats-way-more-than-expected
214             @position.closed = date.to_i
215             @position.save!
216             return true
217         elsif not is_new? and date > @date
218             # need to look ahead in time
219             p = Smr::AssetPosition.new(@position.id, @user.id, date)
220             return true if p.close(date).is_a?(TrueClass)
221         else
222             return Order.new(
223                 :id_position=>@id, :type=>'sale', :issued=>date.to_i,
224                 :shares=>shares, :limit=>(last_quote.quote or 0),
225                 :comment=>'Selling off to close position.'
226             )
227         end
228     end
230     ##
231     # Settle position in case its a cash position.
232     #
233     # Settlements happens at the end of year of given :date. Usually
234     # smr_browse_date.
235     def settle
236         raise 'only cashpositions can be settled' unless is_cash_position?
237         this_year = @revisions.first.time
238         next_year = this_year + 1.year
240         # re-read self at end of year (the year it was opened)
241         position_eoy = Smr::AssetPosition.new(@position.id, @user.id, this_year.end_of_year)
243         # put current balance into cashposition of next year
244         forward_position = Smr::Transaction::find_cashposition(@portfolio.id, @user.id, next_year)
245         forward_transaction = Smr::Transaction.new(forward_position, @user, :type=>:cash_booking)
246         forward_transaction.book(
247             position_eoy.shares,
248             :time=>next_year.beginning_of_year + 1.second,
249             :comment=>'balance carried over from %s' % this_year.year
250         )
252         # make settlement booking to zero balance
253         # - settling time is right at midnight, end of year to have the last balance shown
254         #   on december 31st, while it disappears on january 1st
255         settle_transaction = Smr::Transaction.new(@position, @user, :type=>:cash_booking)
256         settle_transaction.book(
257             position_eoy.shares * -1,
258             :time=>this_year.end_of_year + 1.second,
259             :comment=>'settlement of %s, balance has been carried over into %s' % [this_year.year, next_year.year]
260         )
262         # re-read self at end of year and close
263         position_eoy = Smr::AssetPosition.new(@position.id, @user.id, this_year.end_of_year + 1.second)
264         raise 'cashposition not empty after #settle transaction' unless position_eoy.close.is_a?(TrueClass)
265     end
267     ##
268     # A Position without Order is considered new.
269     def is_new?
270         @revisions.empty? and not is_closed? or (@revisions.count == 1 and @revisions.first.id_order == 0)
271     end
273     ##
274     # Tell if this is a short position.
275     def is_short?
276         shares < 0
277     end
279     ##
280     # Tell whether this Position has been closed in the future.
281     def is_viewed_before_closure?
282         @viewed_before_closure
283     end
284   
285     ##
286     # Total money invested here at date or false if settled at given date.
287     def invested
288         return false if is_closed? and not is_viewed_before_closure?
289         return 0 if revisions.count==0
290         revisions.last.invested
291     end
292   
293     ##
294     # Total number of shares held at this point in time.
295     def shares
296         shares=0
297         revisions.each do |pr|
298             if not pr.Order.nil? then
299                 if pr.Order.is_purchase?
300                     shares += pr.Order.shares
301                 else
302                     shares -= pr.Order.shares
303                 end
304             end
305         end
306         return shares
307     end
309     ##
310     # charges per 1k of purchase volume
311     def charges_per_kilo
312         charges / (purchase_volume / 1000)
313     end
315     ##
316     # Price this position has cost per share.
317     def cost_price
318         return 0.0 if shares.zero? or is_closed?
319         invested / shares
320     end
321   
322     ##
323     # Position status as human readable string.
324     def status
325         if is_closed? then 'closed'
326         elsif is_new? then 'new'
327         else 'open' end
328     end
329   
330     ##
331     # Time of last Quote.
332     def quote_time
333         if not @last_quote then return @last_quote.time else false end
334     end
336     ##
337     # Market value of position based on last price or false if there is
338     # no price.
339     def market_value
340         if last_quote.quote != 0 then shares * last_quote.quote else false end
341     end
342   
343     ##
344     # Volume spend to purchase this position.
345     def purchase_volume
346         @sum_buy_orders
347     end
348   
349     ##
350     # Amount settled or false if position is open
351     def settled_volume
352         if is_closed? and not is_viewed_before_closure? then @sum_sell_orders else false end
353     end
354   
355     ##
356     # Profit/loss based on total cost of position and market value or
357     # settlement or false if it cant be calculated.
358     def profit_loss
359         if is_closed? and not is_viewed_before_closure? then
360             @sum_sell_orders - @sum_buy_orders 
361         else
362             return false unless market_value
363             market_value - invested
364         end
365     end
366   
367     ##
368     # Gain based on market value adjusted by dividend received and charges
369     # payed or false if it cant be calculated
370     def gain
371         return false unless profit_loss
372         profit_loss + dividend.received - charges + accrued_interest
373     end
374   
375     ##
376     # Dirty value composed of market value, dividend received and charges paid
377     # or false if position is closed or it cant be calculated.
378     #
379     # Its what one made on the position in total. Its dirty since we do not
380     # know whether dividend was re-invested in some other position. So do
381     # _not_ use this to calculate asset totals.
382     def dirty_value
383         return false if is_closed? and not is_viewed_before_closure?
384         return false unless market_value
385         market_value + dividend.received - charges + accrued_interest
386     end
388     ##
389     # Absolute value this position provides as collateral. Its composed usint
390     # the #collateral_coverage_ratio of Security.
391     def collateral_coverage_value
392         return 0.0 if not market_value  or is_cash_position?
393         ( market_value * @security.collateral_coverage_ratio).to_f
394     end
396     ##
397     # Smr::Link to this position
398     def link
399         if is_cash_position?
400             Smr::Link.new :cashposition, id
401         else Smr::Link.new :position, id end
402     end
403   
404     ##
405     # compares by profit_loss and invested, where losses have priority
406     def <=>(other)
407         return 0  unless profit_loss
408         r=0
409         have_loss = false
410         both_have_loss = false
412         # a value weights more than false (which means we could not calculate
413         # profit_loss)
414         return 1 if profit_loss!=false and other.profit_loss==false
415         return -1 if profit_loss==false and other.profit_loss!=false
417         # compare losses first...
418         if profit_loss < 0
419             have_loss = true
420             if profit_loss < other.profit_loss
421                 r=1
422             else profit_loss > other.profit_loss
423                 r=-1
424             end
425         end
427         if other.profit_loss < 0
428             if have_loss then both_have_loss = true end
429             have_loss = true
431             if profit_loss > other.profit_loss
432                 r=-1
433             else profit_loss < other.profit_loss
434                 r=1
435             end
436         end
438         # ... then investment, but only both have a loss or there is none at all
439         # - compare by absolute values so short positions are inline
440         if both_have_loss or not have_loss
441             if invested.abs > other.invested.abs
442                 r=1
443             elsif invested.abs < other.invested.abs
444                 r=-1
445             end
446         end
448         r
449     end
451     ##
452     # Upcoming / expected cashflows. Collection, ordered by date.
453     #
454     # The :type option can filter cashflows of a specific type. See
455     # PositionRevision#types to learn what types of cashflow SMR knows. Note,
456     # however, that a Security typemodel is not required to implement all of
457     # them. If a filter is unknown to the typemodel it will not return any
458     # cashflow.
459     #
460     # Its empty if the Security typemodel does not support cashflow predictions
461     # or just has no cashflow coming up.
462     def cashflow(options={:end_date=>false, :type=>:none})
463         return Array.new unless @security.has_type_model?
464         case @security.type
465             when :bond  then amount = shares * 100
466             else amount = shares
467         end
468         @security.get_type.cashflow(
469             :amount=>amount,
470             :start_date=>@date, :end_date=>options[:end_date],
471             :type=>options[:type], :item_link=>Smr::Link.new(:position, @position.id)
472         )
473     end
475   end
476   
477   ##
478   # Collection of AssetPosition objects as found by an SQL statement at some
479   # point in time.
480   #
481   # ATTENTION::
482   #  the statement should only select position.id, this class takes care of
483   #  everything else
484   class AssetPositions
485     ##
486     # Provide Time date and SQL statement as string.
487     def initialize(date, sql)
488         @date = date
489         @smrpositions = Array.new
490         @positions = Position.find_by_sql(sql)
491     end
492   
493     ##
494     # Collection of AssetPosition objects.
495     def get_all
496         @positions
497     end
498   end
499   
500 end # module