From d47d32e891b057b812c41560ced609e9ca14a38d Mon Sep 17 00:00:00 2001 From: pqauvsum Date: Sun, 26 Oct 2014 11:32:58 -0400 Subject: [PATCH] organized code into Smr module - put stylesheets into proper locations - usability and layout improvements - moved from paginate to just_paginate gem --- gui/Gemfile | 4 +- gui/app/assets/stylesheets/application.css | 25 + gui/app/assets/stylesheets/assets.css | 18 + gui/app/assets/stylesheets/blog.css | 9 + gui/app/assets/stylesheets/cashflow.css | 7 + gui/app/assets/stylesheets/figures.css | 16 + gui/app/assets/stylesheets/positions.css | 11 + gui/app/assets/stylesheets/quoterecords.css | 23 + gui/app/controllers/application_controller.rb | 21 + gui/app/controllers/assets_controller.rb | 4 +- gui/app/controllers/blog_controller.rb | 6 +- gui/app/controllers/cashflow_controller.rb | 9 +- gui/app/controllers/documents_controller.rb | 14 +- gui/app/controllers/figures_controller.rb | 22 +- .../controllers/objects/figurevar_controller.rb | 7 +- .../controllers/objects/portfolio_controller.rb | 7 +- gui/app/controllers/objects/stock_controller.rb | 8 +- gui/app/controllers/positions_controller.rb | 12 +- gui/app/controllers/quoterecords_controller.rb | 51 +- gui/app/helpers/application_helper.rb | 16 +- gui/app/models/order.rb | 5 +- gui/app/models/quoterecord.rb | 6 +- gui/app/views/assets/index.html.erb | 4 +- gui/app/views/documents/index.html.erb | 3 +- gui/app/views/figures/index.html.erb | 11 +- gui/app/views/layouts/application.html.erb | 18 +- gui/app/views/objects/figurevar/index.html.erb | 2 +- gui/app/views/objects/portfolio/index.html.erb | 2 +- gui/app/views/objects/stock/index.html.erb | 2 +- gui/app/views/positions/show.html.erb | 14 +- gui/app/views/quoterecords/_form.html.erb | 22 + gui/app/views/quoterecords/index.html.erb | 34 +- gui/lib/smr/asset.rb | 134 +++++ gui/lib/smr/asset_position.rb | 325 +++++++++++ gui/lib/smr/asset_position_dividend.rb | 84 +++ gui/lib/smr/blog.rb | 108 ++++ gui/lib/smr/cashflowlog.rb | 188 +++++++ gui/lib/smr/daemon_client.rb | 131 +++++ gui/lib/smr/figures.rb | 560 +++++++++++++++++++ gui/lib/smr/quoterecords.rb | 129 +++++ gui/lib/smr/uploaded_file.rb | 199 +++++++ gui/test/functional/smr_asset_position_test.rb | 107 ++++ gui/test/functional/smr_asset_test.rb | 37 +- gui/test/functional/smr_cashflowlog_test.rb | 138 ++--- gui/test/functional/smr_dividend_test.rb | 106 ++-- gui/test/integration/admin_session_test.rb | 74 +++ gui/test/integration/demo1_user_session_test.rb | 618 +++++++++++---------- 47 files changed, 2801 insertions(+), 550 deletions(-) create mode 100644 gui/app/assets/stylesheets/application.css create mode 100644 gui/app/assets/stylesheets/assets.css create mode 100644 gui/app/assets/stylesheets/blog.css create mode 100644 gui/app/assets/stylesheets/cashflow.css create mode 100644 gui/app/assets/stylesheets/figures.css create mode 100644 gui/app/assets/stylesheets/positions.css create mode 100644 gui/app/assets/stylesheets/quoterecords.css create mode 100644 gui/app/views/quoterecords/_form.html.erb create mode 100644 gui/lib/smr/asset.rb create mode 100644 gui/lib/smr/asset_position.rb create mode 100644 gui/lib/smr/asset_position_dividend.rb create mode 100644 gui/lib/smr/blog.rb create mode 100644 gui/lib/smr/cashflowlog.rb create mode 100644 gui/lib/smr/daemon_client.rb create mode 100644 gui/lib/smr/figures.rb create mode 100644 gui/lib/smr/quoterecords.rb create mode 100644 gui/lib/smr/uploaded_file.rb create mode 100644 gui/test/functional/smr_asset_position_test.rb rewrite gui/test/functional/smr_cashflowlog_test.rb (70%) rewrite gui/test/functional/smr_dividend_test.rb (68%) create mode 100644 gui/test/integration/admin_session_test.rb rewrite gui/test/integration/demo1_user_session_test.rb (99%) diff --git a/gui/Gemfile b/gui/Gemfile index a16ae80..786a1a1 100644 --- a/gui/Gemfile +++ b/gui/Gemfile @@ -11,5 +11,5 @@ gem 'thin' # better than the webrick HTTP server! gem 'bcrypt-ruby', '~> 3.1.2' # staff used in helpers, mainly for formatting -gem 'percentage', '~> 1.0.0' # https://github.com/timcraft/percentage -gem 'paginate', '~> 3.0' # http://rubygems.org/gems/paginate +gem 'percentage', '~> 1.0.0' # https://github.com/timcraft/percentage +gem 'just_paginate', '~> 0.2.2' # https://gitorious.org/gitorious/just_paginate diff --git a/gui/app/assets/stylesheets/application.css b/gui/app/assets/stylesheets/application.css new file mode 100644 index 0000000..0926d44 --- /dev/null +++ b/gui/app/assets/stylesheets/application.css @@ -0,0 +1,25 @@ +/* + * Generic throught the Application + * + *= require_tree . + *= require_self + */ + +/* Navigation */ +ul#smr_menu { float: right; font-size: smaller; } + +div#date_nav { margin-bottom: 3em; } +div#date_nav button, +div#date_nav select { float: left; height: 2em; } + +fieldset { border: 1px solid; } + +table { margin-left:auto; margin-right:auto; } +table thead th { padding: 0 1em 0 1em; } + +h2 > em { font-size: smaller; display: block; margin: 0 0 1em 0.5em; font-weight: normal; } + +/* Pagination */ +div.pagination { overflow: hidden; text-align: center; float: right; } +div.pagination li { float: left; padding: 0 0 0.5em 0.5em; list-style-type: none; } + diff --git a/gui/app/assets/stylesheets/assets.css b/gui/app/assets/stylesheets/assets.css new file mode 100644 index 0000000..56228ec --- /dev/null +++ b/gui/app/assets/stylesheets/assets.css @@ -0,0 +1,18 @@ + +/* + * Assets + */ +ul#assets_invested { display: inline; margin: 0 30% 0 25%; } +ul#assets_invested li { display: inline; padding-left: 15px; } +ul#assets_invested li em { font-size: 2em; font-weight: bold; } + +table#assets { } +table#assets > tbody td { border-bottom: dashed 1px; padding: 0.5em 0 0.25em 0; } +table#assets > tbody td > span { display: block; font-size: smaller; } +table#assets > tbody td:nth-child(2), +table#assets > tbody td:nth-child(3), +table#assets > tbody td:nth-child(4), +table#assets > tbody td:nth-child(5), +table#assets > tbody td:nth-child(6), +table#assets > tbody td:nth-child(7) { text-align: right; } + diff --git a/gui/app/assets/stylesheets/blog.css b/gui/app/assets/stylesheets/blog.css new file mode 100644 index 0000000..f50e2fe --- /dev/null +++ b/gui/app/assets/stylesheets/blog.css @@ -0,0 +1,9 @@ + +/* + * Blog + */ + +div.smr_blogitem { padding: 10px; margin: 5px; float: left; width: 22em; outline: 1px outset; } +div.smr_blogitem > h3 { margin: 5px 1px 0 5px; } +div.smr_blogitem > p { white-space:pre-wrap; text-align: justify; background-color: #f4eded; margin: 5px; } + diff --git a/gui/app/assets/stylesheets/cashflow.css b/gui/app/assets/stylesheets/cashflow.css new file mode 100644 index 0000000..c8b78e9 --- /dev/null +++ b/gui/app/assets/stylesheets/cashflow.css @@ -0,0 +1,7 @@ + +/* + * Cashflow + */ +table#cashflow {} +table#cashflow > tbody td:nth-child(3) { text-align: right; } + diff --git a/gui/app/assets/stylesheets/figures.css b/gui/app/assets/stylesheets/figures.css new file mode 100644 index 0000000..f0e2e58 --- /dev/null +++ b/gui/app/assets/stylesheets/figures.css @@ -0,0 +1,16 @@ + +/* + * Figures + */ +table#figures {} +table#figures > tbody > tr:nth-child(1) > td { padding: 0 1em 0 1em; + font-weight: bold; +} +table#figures > tbody td:nth-child(3), +table#figures > tbody td:nth-child(4), +table#figures > tbody td:nth-child(5), +table#figures > tbody td:nth-child(6), +table#figures > tbody td:nth-child(7), +table#figures > tbody td:nth-child(8) { text-align: right; } +table#figures > tbody td:nth-child(3) { background-color: lightgray } + diff --git a/gui/app/assets/stylesheets/positions.css b/gui/app/assets/stylesheets/positions.css new file mode 100644 index 0000000..c0b6c18 --- /dev/null +++ b/gui/app/assets/stylesheets/positions.css @@ -0,0 +1,11 @@ + +/* + * Position + */ +table#executed_orders {} +table#executed_orders > tbody td > span { display: block; font-size: smaller; } +table#executed_orders > tbody td:nth-child(4), +table#executed_orders > tbody td:nth-child(5), +table#executed_orders > tbody td:nth-child(6), +table#executed_orders > tbody td:nth-child(7) { text-align: right; } + diff --git a/gui/app/assets/stylesheets/quoterecords.css b/gui/app/assets/stylesheets/quoterecords.css new file mode 100644 index 0000000..9583510 --- /dev/null +++ b/gui/app/assets/stylesheets/quoterecords.css @@ -0,0 +1,23 @@ + +/* + * Quoterecords + */ +table#quoterecords { } +table#quoterecords > tbody td:first-child { text-align: right; width:30%; + font-size: 1.5em; background-color: lightgray; +} +table#quoterecords > tbody td:first-child > span { text-align: justify; } +table#quoterecords > tbody td, +table#quoterecords > thead > tr:nth-child(2) { text-align: center; font-size: 2em; } +table#quoterecords > tbody td > span { display: block; font-size: 0.5em; } +table#quoterecords > tbody td:nth-child(2) { background-color: #A4CCAB; } +table#quoterecords > tbody td:nth-child(3) { background-color: #92CCA0; } +table#quoterecords > tbody td:nth-child(4) { background-color: #85B991; } +table#quoterecords > tbody td:nth-child(5) { background-color: #D59C95; } +table#quoterecords > tbody td:nth-child(6) { background-color: #D5ACA4; } +table#quoterecords > tbody td:nth-child(7) { background-color: #D5B8A4; } + +table#quoterecord_observations > tbody td { border-bottom: dashed 1px; padding: 0.5em 0 0.25em 0; } +table#quoterecord_observations > tbody td:nth-child(1) { background-color: lightgray; } +table#quoterecord_observations > tbody td:nth-child(2) span { background-color: #f4eded; padding: 5px;} + diff --git a/gui/app/controllers/application_controller.rb b/gui/app/controllers/application_controller.rb index 9d83233..9c9b3c4 100644 --- a/gui/app/controllers/application_controller.rb +++ b/gui/app/controllers/application_controller.rb @@ -86,6 +86,13 @@ class ApplicationController < ActionController::Base session[:smr_browse_date] = Time.new(d['year'],d['month'],d['day']).end_of_day.to_i end + if params[:smr_step_date] then + case params[:smr_step_date] + when 'day_back' then session[:smr_browse_date] -= 1.days + when 'day_forward' then session[:smr_browse_date] += 1.days + end + end + redirect_to :back end @@ -131,5 +138,19 @@ class ApplicationController < ActionController::Base end securities end + + ## + # Returns sanitized page number as set by +params[:page]+. + def smr_page + JustPaginate.page_value(params[:page]) + end + + ## + # Paginate a collection if things. + def smr_paginate(current_page, collection, items_per_page=20) + JustPaginate.paginate(current_page, items_per_page, collection.count) do |range| + collection.slice(range) + end + end end diff --git a/gui/app/controllers/assets_controller.rb b/gui/app/controllers/assets_controller.rb index 324bd42..d858715 100644 --- a/gui/app/controllers/assets_controller.rb +++ b/gui/app/controllers/assets_controller.rb @@ -13,7 +13,7 @@ # You should have received a copy of the GNU General Public License along with # SMR. If not, see . # -require 'smr_asset' +require 'asset' ## # Shows total Assets. @@ -33,7 +33,7 @@ class AssetsController < ApplicationController '+ position'=>:new_position, '+ quote'=>:new_asset, }) - @assets = SmrAsset.new(current_user.id, smr_browse_date) + @assets = Smr::Asset.new(current_user.id, smr_browse_date) @open_positions = @assets.open_positions # exports for use by other controllers diff --git a/gui/app/controllers/blog_controller.rb b/gui/app/controllers/blog_controller.rb index 5415fe2..e39a414 100644 --- a/gui/app/controllers/blog_controller.rb +++ b/gui/app/controllers/blog_controller.rb @@ -13,7 +13,7 @@ # You should have received a copy of the GNU General Public License along with # SMR. If not, see . # -require 'smr_blog.rb' +require 'blog' ## # The blog is a roll of comments a User made on various items. @@ -21,14 +21,14 @@ require 'smr_blog.rb' # That is on Order, Quoterecord, uploaded Document and other records. In # addition to that a User may write own articles as Comment. # -# All of that is shown as a list of SmrBlogItem objects collected by SmrBlog. +# All of that is shown as a list of Smr::BlogItem objects collected by Smr::Blog. class BlogController < ApplicationController ## # show the blogroll def index smr_menu_addsubitem('blog', {'+ article'=>:new_blog}) - @blogroll = SmrBlog.new(current_user.id, smr_browse_date) + @blogroll = Smr::Blog.new(current_user.id, smr_browse_date) end ## diff --git a/gui/app/controllers/cashflow_controller.rb b/gui/app/controllers/cashflow_controller.rb index 0fc2da1..4f16478 100644 --- a/gui/app/controllers/cashflow_controller.rb +++ b/gui/app/controllers/cashflow_controller.rb @@ -13,11 +13,10 @@ # You should have received a copy of the GNU General Public License along with # SMR. If not, see . # -require 'smr_cashflowlog' - +require 'cashflowlog' ## -# see SmrCashflowLog +# see Smr::CashflowLog class CashflowController < ApplicationController ## @@ -41,7 +40,7 @@ class CashflowController < ApplicationController @securities = smr_securities_list # make the log - @log = SmrCashflowLog.new(Time.at(@timeframe), smr_browse_date, current_user.id) + @log = Smr::CashflowLog.new(Time.at(@timeframe), smr_browse_date, current_user.id) @log.set_filter(@filter) end @@ -84,7 +83,7 @@ class CashflowController < ApplicationController ## # internal helper defining parameters acceptable for filter specification def filter_params - if SmrCashflowLog.new(Time.now,Time.now,1).filters.values.include?(params[:filter].parameterize.to_sym) then + if Smr::CashflowLog.new(Time.now,Time.now,1).filters.values.include?(params[:filter].parameterize.to_sym) then params[:filter].parameterize.to_sym else raise 'symbol "%s" is not accepted as filter by SmrCashflowLog' % params[:filter] diff --git a/gui/app/controllers/documents_controller.rb b/gui/app/controllers/documents_controller.rb index 71d895d..b53ae33 100644 --- a/gui/app/controllers/documents_controller.rb +++ b/gui/app/controllers/documents_controller.rb @@ -13,10 +13,10 @@ # You should have received a copy of the GNU General Public License along with # SMR. If not, see . # -require 'smr_document' +require 'uploaded_file' ## -# Controller for all sorts of SmrDocument things. +# Controller for all sorts of Smr::UploadedFile things. # # This shows all documents available for the user, no matter where they've been # attached to, when or if. @@ -24,9 +24,11 @@ class DocumentsController < ApplicationController public def index - @documents = SmrDocuments.new(current_user.id) @portfolios = Portfolio.where(:id_user=>current_user.id).order(order: :asc) @document = Document.new + + @page = smr_page + @documents, @total_pages = smr_paginate(@page, Smr::UploadedFiles.new(current_user.id)) end ## @@ -35,13 +37,13 @@ class DocumentsController < ApplicationController uploaded_file = params[:document][:content] if uploaded_file then - d = store_new_document(current_user.id, uploaded_file, params[:document][:comment]) + d = Smr::UploadedFile.store(current_user.id, uploaded_file, params[:document][:comment]) n = 'saved file %s' % uploaded_file.original_filename else n = 'please select a file first' end - if not params[:id_portfolio].empty? and d.is_a?(SmrDocument) then + if not params[:id_portfolio].empty? and d.is_a?(Smr::UploadedFile) then da = DocumentAssign.where(:id_document=>d.id).first || DocumentAssign.new(:id_document=>d.id) da.id_portfolio = params[:id_portfolio].to_i da.is_assigned = true @@ -55,7 +57,7 @@ class DocumentsController < ApplicationController # serve DocumentData file content with correct mimetype to the browser def download if params[:id] then - d = SmrDocument.new(params[:id], current_user.id) + d = Smr::UploadedFile.new(params[:id], current_user.id) if not d.empty? then send_data(d.data, :type=>d.mimetype, :filename=>d.filename, :disposition=>'attachment', :length=>d.size) return diff --git a/gui/app/controllers/figures_controller.rb b/gui/app/controllers/figures_controller.rb index 76355fd..42338a1 100644 --- a/gui/app/controllers/figures_controller.rb +++ b/gui/app/controllers/figures_controller.rb @@ -13,24 +13,25 @@ # You should have received a copy of the GNU General Public License along with # SMR. If not, see . # -require 'smr_figures.rb' +require 'figures' ## -# Figures are to record and follow fundamental information across fiscal +# Smr::Figures are to record and follow fundamental information across fiscal # quarters and years. class FiguresController < ApplicationController def index smr_menu_addsubitem('figures', {'+ data'=>:new_figure}) - begin daemon = SmrDaemonClient.new + begin daemon = Smr::DaemonClient.new rescue daemon = false flash[:notice] = 'Autofigures unavailable: %s' % $! end - @datatable = SmrFiguresDataTable.new + @datatable = Smr::FiguresDataTable.new @have_data = false @keep_form_open = session[:figures_keep_form_open] + @scale_value = session[:scale_value] @securities = smr_securities_list if params[:id_stock] and params[:id_stock].to_i>1 then @@ -50,7 +51,7 @@ class FiguresController < ApplicationController autovars = FigureVar.where(:id_user=>[0,current_user.id]).where.not(:expression=>'') parsed_exprs = daemon.parse_math_expressions( autovars.collect{|v| v.expression} ) for i in 0..autovars.length-1 - autofigures << SmrAutofigure.new(autovars[i], parsed_exprs[i]) + autofigures << Smr::Autofigure.new(autovars[i], parsed_exprs[i]) end end @@ -96,7 +97,7 @@ class FiguresController < ApplicationController # pre-populate new object with some fields from the one last added/edited last=FigureData.where(:id_stock=>@selected_security.id).last @figuredata = FigureData.new( - id_stock=>@selected_security.id, + :id_stock=>@selected_security.id, :date=>last.time, :id_figure_var=>last.id_figure_var, :period=>last.period, @@ -123,16 +124,21 @@ class FiguresController < ApplicationController render :index end + ## # handles creates and updates def create - session[:figures_keep_form_open]= if params[:keep_form_open] then true else false end + session[:figures_keep_form_open] = if params[:keep_form_open] then true else false end + session[:scale_value] = if params[:scale_value].to_i > 1 then params[:scale_value].to_i end if params[:figure_data][:id].to_i > 0 then fd = FigureData.where(:id=>params[:figure_data][:id]) .joins(:FigureVar).where('figure_var.id_user IN (%i,%i)' % [0,current_user.id]) .first - else fd = FigureData.new(figuredata_params) end + else + fd = FigureData.new(figuredata_params) + fd.value *= session[:scale_value] if session[:scale_value] + end fd.date = Time.parse(params[:figure_data][:time]).to_i diff --git a/gui/app/controllers/objects/figurevar_controller.rb b/gui/app/controllers/objects/figurevar_controller.rb index b5a6410..f899251 100644 --- a/gui/app/controllers/objects/figurevar_controller.rb +++ b/gui/app/controllers/objects/figurevar_controller.rb @@ -19,9 +19,10 @@ class Objects::FigurevarController < ObjectsController def index super - @figurevars = FigureVar.where(:id_user=>[0,current_user.id]) - .order(:name) - .paginate(:page => params[:page]) + @figurevars, @total_pages = smr_paginate( + @page = smr_page, + FigureVar.where(:id_user=>[0,current_user.id]).order(:name).to_a + ) end ## diff --git a/gui/app/controllers/objects/portfolio_controller.rb b/gui/app/controllers/objects/portfolio_controller.rb index b975288..9142b17 100644 --- a/gui/app/controllers/objects/portfolio_controller.rb +++ b/gui/app/controllers/objects/portfolio_controller.rb @@ -19,9 +19,10 @@ class Objects::PortfolioController < ObjectsController def index super - @portfolios = Portfolio.where(:id_user=>current_user.id) - .order(:name) - .paginate(:page => params[:page]) + @portfolios, @total_pages = smr_paginate( + @page = smr_page, + Portfolio.where(:id_user=>current_user.id).order(:name).to_a + ) end ## diff --git a/gui/app/controllers/objects/stock_controller.rb b/gui/app/controllers/objects/stock_controller.rb index 6a5d144..aa8478f 100644 --- a/gui/app/controllers/objects/stock_controller.rb +++ b/gui/app/controllers/objects/stock_controller.rb @@ -13,7 +13,7 @@ # You should have received a copy of the GNU General Public License along with # SMR. If not, see . # -require 'smr_daemon_client.rb' +require 'daemon_client' ## # handles CRUD on Stock records @@ -27,7 +27,7 @@ require 'smr_daemon_client.rb' class Objects::StockController < ObjectsController def index super - @stocks = Stock.order(:name).paginate(:page => params[:page]) + @stocks, @total_pages = smr_paginate(@page=smr_page, Stock.order(:name).to_a) @symbolextensions = StockSymbolextension.all @quotesources = StockQuotesource.all end @@ -45,11 +45,11 @@ class Objects::StockController < ObjectsController def edit self.index - daemon = begin SmrDaemonClient.new rescue false end + daemon = begin Smr::DaemonClient.new rescue false end @stock = Stock.find(params[:id]) if @stock.fetch_quote and daemon then - daemon = SmrDaemonClient.new + daemon = Smr::DaemonClient.new @quote_retrieval_status = daemon.status_stock(@stock.id) end render :index diff --git a/gui/app/controllers/positions_controller.rb b/gui/app/controllers/positions_controller.rb index 5ab2dc2..4de4a94 100644 --- a/gui/app/controllers/positions_controller.rb +++ b/gui/app/controllers/positions_controller.rb @@ -13,19 +13,19 @@ # You should have received a copy of the GNU General Public License along with # SMR. If not, see . # -require 'smr_document' +require 'uploaded_file' ## -# Controller for SmrPosition +# Controller for AssetPosition # -# This also cares about orders and other things since an SmrPosition +# This also cares about orders and other things since an AssetPosition # is more than just the Position model. class PositionsController < ApplicationController ## # Show details about a SmrPosition. def show - @position = SmrPosition.new(params[:id], current_user.id, smr_browse_date) + @position = Smr::AssetPosition.new(params[:id], current_user.id, smr_browse_date) if @position.is_new? then smr_menu_addsubitem('assets', { @@ -65,7 +65,7 @@ class PositionsController < ApplicationController if params[:id_position] then # 1) - @position = SmrPosition.new(params[:id_position], current_user.id, smr_browse_date) + @position = Smr::AssetPosition.new(params[:id_position], current_user.id, smr_browse_date) elsif params[:id_portfolio] then # 2) @portfolio = Portfolio.find_by_id(params[:id_portfolio]) @@ -164,7 +164,7 @@ class PositionsController < ApplicationController ## # close this position at smr_browse_date def close - p = SmrPosition.new(params[:id].to_i, current_user.id, smr_browse_date) + p = Smr::AssetPosition.new(params[:id].to_i, current_user.id, smr_browse_date) stat = p.close(smr_browse_date) if stat.is_a?(TrueClass) then n='Closed position #%i as of %s.' % [p.id, p.time_closed] diff --git a/gui/app/controllers/quoterecords_controller.rb b/gui/app/controllers/quoterecords_controller.rb index 0eaef93..3f513cb 100644 --- a/gui/app/controllers/quoterecords_controller.rb +++ b/gui/app/controllers/quoterecords_controller.rb @@ -13,7 +13,7 @@ # You should have received a copy of the GNU General Public License along with # SMR. If not, see . # -require 'smr_quoterecords.rb' +require 'quoterecords' ## # Quote Records are a way of ordering market quotes into trends and putting @@ -27,41 +27,52 @@ class QuoterecordsController < ApplicationController @securities = smr_securities_list if params[:id_stock] and params[:id_stock].to_i>1 then - i = @securities.index{|s|s.id==params[:id_stock].to_i} + session[:quoterecords_security_index] = @securities.index{|s|s.id==params[:id_stock].to_i} else i = 0 end case params[:show] - when 'previous' then i -= 1 - when 'next' then i += 1 + when 'previous' then session[:quoterecords_security_index] -= 1 + when 'next' then session[:quoterecords_security_index] += 1 end - i = 0 if not (0..@securities.count-1) === i - @selected_security = @securities[i] + session[:quoterecords_security_index] = 0 if not (0..@securities.count-1) === session[:quoterecords_security_index] + @selected_security = @securities[ session[:quoterecords_security_index] ] @intraday_quotes = Quote.where(:id_stock=>@selected_security.id, :date=>smr_browse_date.beginning_of_day.to_i..smr_browse_date.end_of_day.to_i).order(date: :desc) - @quoterecords = SmrQuoterecords.new(current_user.id, @selected_security, smr_browse_date) + @quoterecords, @total_pages = smr_paginate( + @page = smr_page, + Smr::Quoterecords.new(current_user.id, @selected_security, smr_browse_date) + ) @sensitivity = QuoterecordThreshold.where(:id_user=>current_user.id, :id_stock=>@selected_security.id).first || (QuoterecordThreshold.new(:id_stock=>@selected_security.id)) @possible_columns = Hash.new - @quoterecords.get_columns.collect{|c| @possible_columns[@quoterecords.translate_column(c)] = c } + Quoterecord.get_columns.collect do |c| + @possible_columns[Quoterecord.translate_column(c)] = c + end end ## - # New Quoterecord from params. + # handles creates and updates def create - qr = Quoterecord.new(quoterecord_params) - qr.id_user = current_user.id - qr.created = Time.now.to_i - qr.save! - redirect_to :back + if params[:quoterecord][:id].to_i > 0 then + qr = Quoterecord.where(:id=>params[:quoterecord][:id], :id_user=>current_user.id).first + qr.update(quoterecord_params) + else + qr = Quoterecord.new(quoterecord_params) + qr.id_user = current_user.id + qr.created = Time.now.to_i + qr.save! + end + + index + render :index end ## - # Update existing Quoterecord. - def update - qr = Quoterecord.where(:id=>params[:quoterecord][:id], :id_user=>current_user.id).limit(1) || QuoterecordThreshold.new(:id_user=>current_user.id) - qr.update(quoterecord_params) - qr.save! - redirect_to :back + # present exiting Quoterecord for editing. + def show + index + @quoterecord = Quoterecord.where(:id=>params[:id], :id_user=>current_user.id).first + render :index end ## diff --git a/gui/app/helpers/application_helper.rb b/gui/app/helpers/application_helper.rb index 87ebb0c..f188a0f 100644 --- a/gui/app/helpers/application_helper.rb +++ b/gui/app/helpers/application_helper.rb @@ -13,6 +13,7 @@ # You should have received a copy of the GNU General Public License along with # SMR. If not, see . # + require 'percentage' ## @@ -111,7 +112,7 @@ module ApplicationHelper end ## - # Provide link back to original object from SmrBlogItem. Returns nil if + # Provide link back to original object from Smr::BlogItem. Returns nil if # there is no reference on that item. # # NOTE: @@ -119,7 +120,7 @@ module ApplicationHelper # older than 7 days (the later is intentional as we want to store THE # TRUTH and not alternate it afterwards) def smr_link_back(smr_blogitem, name) - raise 'smr_blogitem must be SmrBlogItem' unless smr_blogitem.is_a?(SmrBlogItem) + raise 'smr_blogitem must be Smr::BlogItem' unless smr_blogitem.is_a?(Smr::BlogItem) if smr_blogitem.type == 'Comment' and smr_blogitem.refid and smr_blogitem.date>7.days.ago then link_to name, :controller=>:blog, :action=>:new, :id=>smr_blogitem.refid @@ -127,4 +128,15 @@ module ApplicationHelper nil end end + + ## + # Create HTML container with page links. + # + # It will be positioned at +current_page+ with a maximum of +num_pages+ + # and each link will direct to +basepath/?page=N+. + def smr_paginate(current_page, num_pages, basepath) + JustPaginate.page_navigation(current_page, num_pages) do |p| + '%s/?page=%i' % [basepath, p] + end.html_safe + end end diff --git a/gui/app/models/order.rb b/gui/app/models/order.rb index 92ad9b8..e5bc28d 100644 --- a/gui/app/models/order.rb +++ b/gui/app/models/order.rb @@ -70,10 +70,11 @@ class Order < ActiveRecord::Base # tells humans what this Order is about def to_s addon = if self.addon!='none' then ' ' + self.addon else nil end - limit = if self.limit > 0 then 'limit %.2f'%self.limit else 'at market' end + limit = if self.limit > 0 then 'limit %.4f'%self.limit else 'at market' end + quote = if self.quote > 0 then 'at quote %.4f'%self.quote else false end '%s %.2f shares %s%s' % [ - self.type, self.shares, limit, addon + self.type, self.shares, (quote||limit), addon ] end diff --git a/gui/app/models/quoterecord.rb b/gui/app/models/quoterecord.rb index 0c63759..275be88 100644 --- a/gui/app/models/quoterecord.rb +++ b/gui/app/models/quoterecord.rb @@ -46,13 +46,13 @@ class Quoterecord < ActiveRecord::Base ## # returns array of possible values for :column field - def get_columns + def self.get_columns POSSIBLE_COLUMNS.keys end ## # returns human readable +String+ for given column name, see get_columns() - def translate_column(column=self.column) + def self.translate_column(column=self.column) POSSIBLE_COLUMNS[column] end @@ -70,7 +70,7 @@ class Quoterecord < ActiveRecord::Base msg << 'DOWN hit' if self.is_downhit msg << 'classified quote' if msg.empty? - msg << 'into %s column' % self.translate_column(self.column) + msg << 'into %s column' % self.class.translate_column(self.column) msg << 'which is a pivotal point' if self.is_pivotal_point msg << 'and triggered SIGNAL' if self.is_signal diff --git a/gui/app/views/assets/index.html.erb b/gui/app/views/assets/index.html.erb index 54d6ead..15c4cb1 100644 --- a/gui/app/views/assets/index.html.erb +++ b/gui/app/views/assets/index.html.erb @@ -20,10 +20,10 @@ <% end %> <% end %> -
    +
    • Invested: <%= smr_humanize(@assets.invested) %>
    • Market Value: <%= smr_humanize(@assets.market_value) %>
    • -
    • Profit/Loss: <%= smr_humanize(@assets.profit_loss) %> (<%= percentage_change(@assets.invested, @assets.market_value) %>)
    • +
    • <%= if @assets.profit_loss>=0 then 'Profit' else 'Loss' end %>: <%= smr_humanize(@assets.profit_loss) %> (<%= percentage_change(@assets.invested, @assets.market_value) %>)

    Open Positions

    diff --git a/gui/app/views/documents/index.html.erb b/gui/app/views/documents/index.html.erb index a7edfae..03fe956 100644 --- a/gui/app/views/documents/index.html.erb +++ b/gui/app/views/documents/index.html.erb @@ -12,10 +12,11 @@ <% end %> + - + diff --git a/gui/app/views/figures/index.html.erb b/gui/app/views/figures/index.html.erb index 0c7600a..02cd67e 100644 --- a/gui/app/views/figures/index.html.erb +++ b/gui/app/views/figures/index.html.erb @@ -18,14 +18,17 @@
    <%= f.select :id_figure_var, @figurevariables.collect{|v| [v.name, v.id] }, :prompt=>'-- Select Variable --' %>
    <%= f.select :period, @figuredata.get_periods.collect{|p| [@figuredata.translate_period(p), p] }, :prompt=>'-- Select Period --' %>
    - <%= f.date_field :time, :placeholder=>'date recorded' %>
    - <%= f.text_field :analyst, :placeholder=>'who analysed?' %>
    - <%= f.number_field :value, :step=>0.01, :placeholder=>'the value' %>
    + <%= f.date_field :time, :placeholder=>'Date Recorded.' %>
    + <%= f.text_field :analyst, :placeholder=>'Who analysed?' %>
    + <%= f.number_field :value, :step=>0.01, :placeholder=>'The value.' %>

    +
    Value options:
    <%= f.check_box :is_expected %><%= label :new_figuredata, :is_expected %>
    <%= f.check_box :is_audited %><%= label :new_figuredata, :is_audited %>

    - <%= check_box_tag(:keep_form_open, true, @keep_form_open) %><%= label_tag :keep_form_open, 'keep form open to add more data' %>
    +
    Data entry options:
    + <%= check_box_tag(:keep_form_open, true, @keep_form_open) %><%= label_tag :keep_form_open, 'Keep form open to add more data.' %>
    + <%= number_field_tag(:scale_value, @scale_value, :placeholder=>'Scale the value.') %><%= label_tag :scale_value, 'Useful if report is in thousands, millions or billions.' %>

    <%= f.submit 'Save Figure Data' %>
    diff --git a/gui/app/views/layouts/application.html.erb b/gui/app/views/layouts/application.html.erb index ef73d6f..4285521 100644 --- a/gui/app/views/layouts/application.html.erb +++ b/gui/app/views/layouts/application.html.erb @@ -2,7 +2,7 @@ smr @ <%= smr_browse_date.strftime('%B %%s, %Y') % smr_browse_date.strftime('%d').to_i.ordinalize %> - <%= stylesheet_link_tag :all %> + <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %> <%= csrf_meta_tag %> @@ -15,15 +15,19 @@ <% if current_user %>
    +
    + + +
    - <%= select_date(smr_browse_date, order: [:day, :month, :year], prefix: 'smr_browse_date') %> - + <%= select_date(smr_browse_date, order: [:day, :month, :year], prefix: 'smr_browse_date') %> +
    - <%= hidden_field_tag 'smr_browse_date[year]', Time.now.year %> - <%= hidden_field_tag 'smr_browse_date[month]', Time.now.month %> - <%= hidden_field_tag 'smr_browse_date[day]', Time.now.day %> - + <%= hidden_field_tag 'smr_browse_date[year]', Time.now.year %> + <%= hidden_field_tag 'smr_browse_date[month]', Time.now.month %> + <%= hidden_field_tag 'smr_browse_date[day]', Time.now.day %> +
    diff --git a/gui/app/views/objects/figurevar/index.html.erb b/gui/app/views/objects/figurevar/index.html.erb index e8d8ec4..aa3449a 100644 --- a/gui/app/views/objects/figurevar/index.html.erb +++ b/gui/app/views/objects/figurevar/index.html.erb @@ -26,7 +26,7 @@
    <%= paginate @documents %><%= smr_paginate(@page, @total_pages, documents_path) %> Uploaded Filename Comment / Relations
    - + diff --git a/gui/app/views/objects/portfolio/index.html.erb b/gui/app/views/objects/portfolio/index.html.erb index 5fac097..d396f06 100644 --- a/gui/app/views/objects/portfolio/index.html.erb +++ b/gui/app/views/objects/portfolio/index.html.erb @@ -28,7 +28,7 @@
    <%= paginate @figurevars %><%= smr_paginate @page, @total_pages, objects_figurevar_index_path %> Unit Expression
    - + diff --git a/gui/app/views/objects/stock/index.html.erb b/gui/app/views/objects/stock/index.html.erb index 4e03cec..f7cace5 100644 --- a/gui/app/views/objects/stock/index.html.erb +++ b/gui/app/views/objects/stock/index.html.erb @@ -42,7 +42,7 @@
    <%= paginate @portfolios %><%= smr_paginate @page, @total_pages, objects_portfolio_index_path %> Created Tax Allowance
    - + diff --git a/gui/app/views/positions/show.html.erb b/gui/app/views/positions/show.html.erb index 23e3f2b..f730443 100644 --- a/gui/app/views/positions/show.html.erb +++ b/gui/app/views/positions/show.html.erb @@ -24,10 +24,10 @@
    <%= paginate @stocks %><%= smr_paginate @page, @total_pages, objects_stock_index_path %> Symbol Quote Source Fetch Quote
    - - + + - +
    Purchased Volume<%= smr_humanize(@position.purchase_volume) %>
    Settled Volume<%= smr_humanize(@position.settled_volume) %>
    = Profit/Loss<%= smr_humanize(@position.profit_loss) %> <%= percentage_change(@position.purchase_volume, @position.settled_volume)%>
    Dividend<%= smr_humanize(@position.dividend.received) %>
    = Profit/Loss<%= smr_humanize(@position.profit_loss) %> <%= percentage_change(@position.purchase_volume, @position.settled_volume) %>
    Dividend<%= smr_humanize(@position.dividend.received) %> <%= percentage_of(@position.purchase_volume, @position.dividend.received) %>b
    Charges<%= smr_humanize(@position.charges) %>
    = Gain<%= smr_humanize(@position.gain) %> <%= percentage_of(@position.purchase_volume, @position.gain) %>
    = Gain<%= smr_humanize(@position.gain) %> <%= percentage_of(@position.purchase_volume, @position.gain) %>
    <% elsif @position.is_new? %>

    This position is new. You may order something or just close it.

    @@ -44,10 +44,10 @@ - - - - + + + +
    Invested Money<%= smr_humanize(@position.invested) %>
    Market Value<%= smr_humanize(@position.market_value) %>
    = Profit/Loss<%= smr_humanize(@position.profit_loss) %> <%= percentage_change(@position.invested, @position.market_value)%>
    Dividend<%= smr_humanize(@position.dividend.received) %>
    Charges<%= smr_humanize(@position.charges) %>
    = Gain<%= smr_humanize(@position.gain) %> <%= percentage_change(@position.invested, @position.dirty_value) %>
    = Profit/Loss<%= smr_humanize(@position.profit_loss) %> <%= percentage_change(@position.invested, @position.market_value)%>
    Dividend<%= smr_humanize(@position.dividend.received) %> <%= percentage_of(@position.invested, @position.dividend.received)%>
    Charges<%= smr_humanize(@position.charges) %> <%= percentage_of(@position.invested, @position.charges)%>
    = Gain<%= smr_humanize(@position.gain) %> <%= percentage_change(@position.invested, @position.dirty_value) %>
    <% end %> diff --git a/gui/app/views/quoterecords/_form.html.erb b/gui/app/views/quoterecords/_form.html.erb new file mode 100644 index 0000000..2a32dc7 --- /dev/null +++ b/gui/app/views/quoterecords/_form.html.erb @@ -0,0 +1,22 @@ + +<% skip_fieldset = false if not skip_fieldset %> + +<%= form_for quoterecord, :url=>{action: "create"}, :method=>:POST do |f| %> + <% if not skip_fieldset %> +
    + <% if f.object.id %>Edit <% else %>Add new<% end %>Quote Record<%= if f.object.id then ' #%i'%f.object.id end %> + <% end %> + <%= f.hidden_field :id %> + <%= f.hidden_field :id_stock %> + <%= f.hidden_field :id_quote %> + <%= f.select :column, Quoterecord.get_columns.collect{|c| [Quoterecord.translate_column(c), c] }, :prompt=>'-- Select Column --' %> + <%= f.text_field :comment, placeholder: 'Comment' %> + <%= f.check_box :is_pivotal_point %><%= f.label :is_pivotal_point, 'Pivotal Point' %> + <%= f.check_box :is_uphit %><%= f.label :is_uphit, 'UP hit' %> + <%= f.check_box :is_downhit %><%= f.label :is_downhit, 'DOWN hit' %> + <%= f.check_box :is_signal %><%= f.label :is_signal, 'Signal' %> + <%= f.submit (if f.object.id then 'save' else 'record this one' end) %> + <% if not skip_fieldset %> +
    + <% end %> +<% end %> diff --git a/gui/app/views/quoterecords/index.html.erb b/gui/app/views/quoterecords/index.html.erb index cac2c51..7ea514c 100644 --- a/gui/app/views/quoterecords/index.html.erb +++ b/gui/app/views/quoterecords/index.html.erb @@ -12,7 +12,7 @@ <% if not @intraday_quotes.empty? %>

    Observations intraday quotes in contrast to previous records

    - +
    <% @intraday_quotes.each do |q| %> <% end %> @@ -44,17 +38,21 @@ <% if not @quoterecords.empty? %>

    Previous Records

    +<% if @quoterecord %> + <%= render :partial=>'form', locals: { :quoterecord=>@quoterecord } %> +<% end %> +
    @@ -20,21 +20,15 @@ <%if q.volume%>, <%=q.volume%> shares traded<%end%> - <% msg, newqr = @quoterecords.inspect_quote(q) %> + <% # build new Quoterecord as suggestion + msg, newqr = @quoterecords.inspect_quote(q) + newqr.id_quote = q.id + newqr.column = @quoterecords.get_last_recorded_column + %> <% if not msg.empty? %> <%= msg %> - <% end %> - <%= form_for newqr, url: {action: "create"} do |f| %> - <%= f.hidden_field :id_stock %> - <%= f.hidden_field :id_quote, :value=>q.id %> - <%= f.select :column, options_for_select(@possible_columns, @quoterecords.get_last_recorded_column), :prompt=>'-- Select Column --' %> - <%= f.text_field :comment, placeholder: 'Comment' %> - <%= f.check_box :is_pivotal_point %><%= f.label :is_pivotal_point, 'Pivotal Point' %> - <%= f.check_box :is_uphit %><%= f.label :is_uphit, 'UP hit' %> - <%= f.check_box :is_downhit %><%= f.label :is_downhit, 'DOWN hit' %> - <%= f.check_box :is_signal %><%= f.label :is_signal, 'Signal' %> - <%= f.submit 'record this one' %> <% end %> + <%= render :partial=>'form', locals: { :quoterecord=>newqr, :skip_fieldset=>true } %>
    - - <% @quoterecords.get_columns.each do |c| %> - + + <% Smr::Quoterecords.get_columns.each do |c| %> + <% end %> - <% @quoterecords.get_columns.each do |c| %> + <% Smr::Quoterecords.get_columns.each do |c| %> <% end %> @@ -63,7 +61,7 @@ <% @quoterecords.each do |qr| %> - <% @quoterecords.get_columns.each do |c| %> + <% Smr::Quoterecords.get_columns.each do |c| %>
    <%= paginate @quoterecords %><%= @quoterecords.translate_column(c) %><%= smr_paginate @page, @total_pages, quoterecords_path %><%= Smr::Quoterecords.translate_column(c) %>
    <%= @quoterecords.get_pivot_point(c) %>
    <%=link_to '+', qr %> <%=smr_humanize(qr.Quote.time)%> <%=qr.comment%> <%if qr.is_column?(c) %> <%= qr.Quote.quote %> diff --git a/gui/lib/smr/asset.rb b/gui/lib/smr/asset.rb new file mode 100644 index 0000000..a66ca57 --- /dev/null +++ b/gui/lib/smr/asset.rb @@ -0,0 +1,134 @@ +# +# This file is part of SMR. +# +# SMR is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# SMR is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# SMR. If not, see . +# +require 'asset_position' + +module Smr #:nodoc: + ## + # Total assets of current user at some point in time. + # + # Use #open_positions to access SmrPosition objects of all positions held. Then + # each knows more details about itself. + # + class Asset + ## + # age of +Quote+ data before its considered old + QUOTE_OUTDATED_DAYS = 4 + + ## + # date defines the point of Time this object sits at + def initialize(id_user, date) + raise 'date must be of Time' unless date.is_a?(Time) + @id_user = id_user + @date = date + + @invested = 0.0 + @market_value = 0.0 + @profit_loss = 0.0 + + @_have_old_quotes = false + @_have_missing_quotes = false + + @open_positions = Array.new + + # ATTENTION: not portable! + # - thats not the preferred way to do a query. + # - this one is just too complex for ActiveRecord at the moment + open_positions = Smr::AssetPositions.new(@date, ' + SELECT + p.id + /* p.id_stock, + p.id_portfolio, # if those values could be used + pr.id_order, # things would be much faster since + pr.id_position, # subsequent SmrPosition objects query + pr.date, # these again... too many queries... + pr.shares, + pr.invested */ + FROM + position p, + position_revision pr, + stock s, + portfolio po, + (SELECT + MAX(pr1.date) AS LASTDATE, + pr1.id_position AS LASTPOSITION + FROM + position_revision pr1 + WHERE + pr1.date<=%i + GROUP BY pr1.id_position + ) AS blah + WHERE + p.id=pr.id_position + AND p.id_portfolio=po.id + AND s.id=p.id_stock + AND po.id_user=%i + AND (p.closed=0 OR p.closed>%i) + AND pr.date=LASTDATE + AND pr.id_position=LASTPOSITION + AND po.name NOT LIKE "watchlist:%%" /* HACK for speed on old DBs */ + ORDER BY s.name, pr.date' % [@date, @id_user, @date]).get_all + + # turn Position objects into SmrPosition + # - for the sake of performance we calculate totals right here + open_positions.each do |p| + sp = Smr::AssetPosition.new(p.id, id_user, @date) + @open_positions << sp + + @invested += sp.invested + @market_value += sp.market_value if sp.market_value + @profit_loss += sp.profit_loss if sp.profit_loss + + if not sp.market_value then @_have_missing_quotes = true end + if sp.quote_time and sp.quote_time < QUOTE_OUTDATED_DAYS.days.ago then + @_have_old_quotes = true + end + end + end + + public + + def open_positions + @open_positions.sort_by!{ |p| p.invested }.reverse! + @open_positions + end + + # return currently invested amount of money + def invested + @invested + end + + # return market value + def market_value + @market_value + end + + # return profit/loss + def profit_loss + @profit_loss + end + + # tell whether this Asset report is based on outdated quotes + def have_old_quotes? + @_have_old_quotes + end + + # tell whether this Asset report is lacking quote data + def have_missing_quotes? + @_have_missing_quotes + end + end + +end # module diff --git a/gui/lib/smr/asset_position.rb b/gui/lib/smr/asset_position.rb new file mode 100644 index 0000000..7964ed9 --- /dev/null +++ b/gui/lib/smr/asset_position.rb @@ -0,0 +1,325 @@ +# +# This file is part of SMR. +# +# SMR is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# SMR is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# SMR. If not, see . +# + +module Smr #:nodoc: + require 'dividend' + + # + # A position at some point in time. + # + # It provides all information from a positions point of view. Like the Stock + # held, Order (s) that were issued and the PositionRevision (s) it created. + # + # A AssetPosition is in one of three logical states of valuation, depending on + # the date given to #new: + # + # open:: + # does valuation with market value at date + # closed, date before closure:: + # does valuation with market value at date with securities held at date + # closed, date after closure:: + # does final gain calculation + # + # Use #closed? and #is_viewed_before_closure? to check for the state as a + # number of methods will just return false when called in the wrong state. + # + class AssetPosition + ## + # initialize with Position id and User id + # + # initilization will fail if +id_position+ is not part of a Portfolio owned + # by the User specified by +id_user+. + def initialize(id_position, id_user, date=Time.now) + + @position = Position.where(:id=>id_position).joins(:Portfolio).where('portfolio.id_user=%i'% id_user).first + raise 'possible security violation: id_position=%i does not belong to id_user=%i, aborting SmrPosition.initialize()' % [id_position, id_user] if not @position.is_a?(Position) + + @id = id_position + @date = date + @orders = Array.new + @orders_pending = Array.new + @quotes = Array.new + @dividend = nil + @sum_buy_orders = 0.0 + @sum_sell_orders = 0.0 + + @viewed_before_closure = false + + # adjust date in case we are closed in the future + # - subsequent gain calculations produce bogus results otherwise + if @position.closed > 0 and @date < @position.time_closed then + @viewed_before_closure = true + end + + # preload things to SELECT them only once + # - even ActiveRecords CACHE takes time and we can be more intelligent + @stock = @position.Stock + @portfolio = Portfolio.where(:id=>@position.id_portfolio, :id_user=>id_user).first + @last_quote = Quote.order(date: :desc).where(id_stock: stock.id).where('date <= %i' % @date).limit(1).first + # @revisions = @position.PositionRevision.order(:date).where('date <= %i' % @date).where.not(:id_order=>0).to_a + @revisions = @position.PositionRevision.order(:date).where('date <= %i' % @date).to_a + @revisions.each do |r| + if r.id_order != 0 then + @orders << r.Order + if r.Order.type.eql?('buy') then + @sum_buy_orders += r.Order.shares * r.Order.quote + else @sum_sell_orders += r.Order.shares * r.Order.quote + end + end + end + @orders_pending=Order.where(:id_position=>@id).where(:quote=>0).where(:is_canceled=>nil).order(issued: :desc) + end + + public + + def id + @id + end + + def date + @date + end + + def comment + @position.comment + end + + def time_closed + @position.time_closed + end + + ## + # return Stock object held by this position + def stock + @stock + end + + ## + # return Portfolio object this position is in + def portfolio + @portfolio + end + + ## + # return array of PositionRevision objects with respect to the point in time we are at + def revisions + @revisions + end + + ## + # return array of Order (s) in pending state, ie those not executed + # FIXME: test expiry and canceled state, see query above + def pending_orders + @orders_pending + end + + ## + # tell whether there are orders waiting for execute or expiry + def has_pending_orders? + not @orders_pending.empty? + end + + ## + # return AssetPositionDividend object providing dividend information as of #date + def dividend + if not @dividend then @dividend = AssetPositionDividend.new(self) end + @dividend + end + + def closed? + @position.closed > 0 + end + + ## + # Close the Position. + # + # This will close the position right away in case it is empty. +True+ is + # returned in that case. + # + # In case it is not empty a new Order is returned, that will sell it off + # and close it if executed. That Order may be presented for editing first, + # ie to add charges, execution price, etc... + # + # +False+ is returned in case the position has been closed already, even if + # that happened 'in the future' (given date > smr_browse_date or Time.now). + # + # Closing a SmrPosition is a one-time-only operation. New orders are not + # possible once this has been done. Closing is only possible when the + # number of shares held is zero. + def close(date=@date) + return false if closed? + + if shares == 0.0 + @position.closed = date.to_i + @position.save! + return true + else + return Order.new( + :id_position=>@id, :type=>'sale', :issued=>date.to_i, + :shares=>shares, :limit=>(last_quote or 0), + :comment=>'Selling off to close position.' + ) + end + end + + def is_new? + @revisions.first.id_order == 0 and @revisions.count == 1 and not closed? + end + + def is_viewed_before_closure? + @viewed_before_closure + end + + ## + # total money invested here at date or false if settled at given date + def invested + if closed? and not is_viewed_before_closure? then return false end + revisions.last.invested + end + + ## + # total number of shares held at this point in time + def shares + shares=0 + revisions.each do |pr| + if not pr.Order.nil? then + if pr.Order.type == 'buy' + shares += pr.Order.shares + else + shares -= pr.Order.shares + end + end + end + return shares + end + + ## + # price this position has cost per share + def cost_price + if shares.zero? then return 0.0 end + invested / shares + end + + ## + # returns position status as human readable string + def status + if closed? + return 'closed' + elsif is_new? + return 'new' + else + return 'open' + end + end + + ## + # returns last Quote of the Stock held, false if there is no quote data + def last_quote + if not @last_quote.nil? then return @last_quote.quote else false end + end + + ## + # returns time of last Quote + def quote_time + if not @last_quote.nil? then return @last_quote.time else false end + end + + ## + # returns market value of position based on last price or false if there is + # no price + def market_value + if last_quote then shares * last_quote else false end + end + + ## + # returns volume purchased or false if position is in open state + def purchase_volume + if closed? and not is_viewed_before_closure? then @sum_buy_orders else false end + end + + ## + # returns amount settled of false if position is in open state + def settled_volume + if closed? and not is_viewed_before_closure? then @sum_sell_orders else false end + end + + ## + # returns profit/loss based on cost of position and market value or + # settlement or false if it cant be calculated + def profit_loss + if closed? and not is_viewed_before_closure? then + @sum_sell_orders - @sum_buy_orders + else + if not market_value then return false + else market_value - invested end + end + end + + ## + # returns gain based on market value adjusted by dividend received and + # charges payed + def gain + profit_loss + dividend.received + charges + end + + ## + # returns dirty_value composed of market value, dividend received and + # charges paid or false if position is closed. + # + # Its what one made on the position in total. Its dirty since we do not + # know whether dividend was re-invested in some other position. So do + # _not_ use this to calculate asset totals. + def dirty_value + if closed? and not is_viewed_before_closure? then return false end + market_value + dividend.received + charges + end + + ## + # returns total charges paid on this position + # Note: this is always a negative number + def charges + charges = 0.0 + @orders.each do |o| + charges += o.provision + o.courtage + o.expense + end + charges * -1 + end + end + + ## + # Collection of AssetPosition objects as found by an SQL statement at some + # point in time + # + # ATTENTION:: + # the statement should only select position.id, this class takes care of + # everything else + class AssetPositions + ## + # provide +Time+ date and SQL statement as string. + def initialize(date, sql) + @date = date + @smrpositions = Array.new + @positions = Position.find_by_sql(sql) + end + + ## + # returns array of AssetPosition objects + def get_all + @positions + end + end + +end # module diff --git a/gui/lib/smr/asset_position_dividend.rb b/gui/lib/smr/asset_position_dividend.rb new file mode 100644 index 0000000..304eb02 --- /dev/null +++ b/gui/lib/smr/asset_position_dividend.rb @@ -0,0 +1,84 @@ +# +# This file is part of SMR. +# +# SMR is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# SMR is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# SMR. If not, see . +# + +module Smr #:nodoc: + ## + # Dividend receivend on a AssetPosition at some point in time. + class AssetPositionDividend + ## + # initialize with Smr::Position + def initialize (position) + raise 'position must be a SmrPosition object' unless position.is_a?(Smr::AssetPosition) + @position = position + + @payments = [] + @received = 0.0 + @_queried_payments = false + end + + public + + ## + # returns array of payments received, uses Dividend model to figure it + # + # hash keys for each payment are: :time, :received, :shares and :total + def payments + query_payments + @payments + end + + ## + # returns total dividend received + def received + query_payments + @received + end + + def empty? + @payments.empty? + end + + protected + + ## + # queries all payments + # - result will be cached right here so ActiveRecord is invoked only once + def query_payments + if @_queried_payments then return end + + dp = Dividend.where('id_stock = %i' % @position.stock.id).where('date <= %i' % @position.date) + if not dp.empty? then + dp.each do |d| + @position.revisions.each do |r| + if r.date <= d.date then + @payments[d.id] = { + :time => d.time, + :received => d.received, + :shares => r.shares, + :total => r.shares * d.received + } + end + end + end + @payments.compact! + @payments.each do |p| @received += p[:total] end + end + + @_queried_payments = true + end + end + +end # module diff --git a/gui/lib/smr/blog.rb b/gui/lib/smr/blog.rb new file mode 100644 index 0000000..903fddb --- /dev/null +++ b/gui/lib/smr/blog.rb @@ -0,0 +1,108 @@ +# +# This file is part of SMR. +# +# SMR is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# SMR is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# SMR. If not, see . +# + +module Smr #:nodoc: + ## + # Collection of most recent SmrBlogItem objects of given User.id. + # + # As of now all items from previous 30 days are collected. + class Blog + def initialize(id_user, end_date=Time.now) + @collection = Array.new + + start_date = (end_date - 30.days).to_i + end_date = end_date.to_i + + # load personal commentary + Comment.where(:id_user=>id_user) + .where({ :date=>(start_date..end_date) }).each do |c| + @collection << BlogItem.new(c.time, c.class, c.title, c.comment, false, c.id) + end + + # load order commentary + Order.where.not(:comment=>'') + .where({ :issued=>(start_date..end_date) }) + .joins('LEFT JOIN position p ON p.id = order.id_position') + .joins('LEFT JOIN portfolio po ON po.id = p.id_portfolio') + .where('po.id_user=%i' % id_user).each do |o| + @collection << BlogItem.new(o.time_issued, o.class, o.to_s ,o.comment) + end + + # load quoterecord commentary + Quoterecord.where(:id_user=>id_user) + .where({ :created=>(start_date..end_date) }) + .where.not(:comment=>'').each do |qr| + @collection << BlogItem.new(qr.time_created, qr.class, qr.to_s, qr.comment) + end + + @collection.sort_by!{|i| i.date}.reverse! + true + end + + ## + # loops over SmrBlogItem collection + def each(&block) + @collection.each(&block) + end + + ## + # tell whether there is anything in the collection + def empty? + @collection.empty? + end + end + + + ## + # Represents a single SmrBlog item for a template to render. + # + # - +url+ is to link to somewhere in the WWW + # - +refid+ is some :id of the object that created this SmrBlogItem, its used + # to provide a smr_link_back() to that record. + class BlogItem + def initialize(date, type, title, body, url=false, refid=false) + raise 'date must be of type Time' unless date.is_a?(Time) + + @date=date; @type=type.to_s; @title=title; @body=body; @url=url; + @refid=refid; + end + + def date + @date + end + + def type + @type + end + + def title + @title + end + + def body + @body + end + + def url + @url + end + + def refid + @refid + end + end + +end # module diff --git a/gui/lib/smr/cashflowlog.rb b/gui/lib/smr/cashflowlog.rb new file mode 100644 index 0000000..32fa74e --- /dev/null +++ b/gui/lib/smr/cashflowlog.rb @@ -0,0 +1,188 @@ +# +# This file is part of SMR. +# +# SMR is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# SMR is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# SMR. If not, see . +# + +module Smr #:nodoc: + ## + # Log of Cashflows happened up to :date + # + # Cashflow is generated from Order executions and Dividend received. + class CashflowLog + def initialize(start_date, end_date, id_user) + raise ':start_date must be of Time' unless start_date.is_a?(Time) + raise ':end_date must be of Time' unless end_date.is_a?(Time) + + @start_date=start_date.to_i; @end_date=end_date.to_i; @id_user=id_user; + + @filter = :none + @made_log = false + @log = Array.new + end + + public + + ## + # loops over CashflowLogItems we have up to date + def each(&block) + generate_log if not @made_log + @log.each(&block) + end + + ## + # tell whether there is something + def empty? + generate_log if not @made_log + @log.empty? + end + + ## + # list of filters that may be used on us. Also see set_filter(). + def filters + { + 'No Filter' => :none, + 'Dividend' => :dividend, + 'All Cost Charged' => :charges, + 'Provisions Charged' => :provision, + 'Courtage Charged' => :courtage, + 'Expenses Charged' => :expense, + 'Ordered Transactions' => :orders, + } + end + + ## + # apply a filter when creating the log, see filters() + def set_filter(filter) + raise 'filter must be one value from those filters() returns' unless filters.has_value?(filter) + @filter=filter + end + + ## + # cumulated cashflow from all CashflowLogItems + def total + generate_log if not @made_log + total = 0.0 + @log.collect{|i| total += i.total } + total + end + + protected + + ## + # compose a log of cashflows from orders and dividend received + def generate_log + @made_log = true + @log.clear + + log_orders = Array.new + log_dividends = Array.new + + if [:none, :orders, :charges, :provision, :courtage, :expense].include?(@filter) then + # cashflow from transactions + orders = Order.select('`order`.*', 'pr.date AS executed') + .where(:issued=>(@start_date..@end_date)) + .where('quote > 0') + .joins('LEFT JOIN position_revision pr ON pr.id_order = order.id') + .joins('LEFT JOIN position p ON p.id = order.id_position') + .joins('LEFT JOIN stock s ON s.id = p.id_stock') + .order(issued: :desc) + + orders.each do |o| + case @filter + when :charges + tmp = Array.new + tmp << '%.2f Provisions' % o.provision if o.provision > 0 + tmp << '%.2f Courtage' % o.courtage if o.courtage > 0 + tmp << '%.2f Expenses' % o.expense if o.expense > 0 + + what = 'payed %s on Order #%i (%s)' % [tmp.join(', '), o.id, o.to_s] + total = o.provision + o.courtage + o.expense + + when :provision + what = 'payed %.2f Provisions on Order #%i (%s)' % [o.provision, o.id, o.to_s] + total = o.provision + + when :courtage + what = 'payed %.2f Courtage on Order #%i (%s)' % [o.courtage, o.id, o.to_s] + total = o.courtage + + when :expense + what = 'payed %.2f Expenses on Order #%i (%s)' % [o.expense, o.id, o.to_s] + total = o.expense + + else # :orders and :none + what = '%s in Order #%i' % [o.to_s, o.id] + total = o.shares * o.quote + total *= -1 if o.type == 'buy' + end + + next if total == 0.0 # skip everything that did not create any cashflow + log_orders << CashflowLogItem.new(Time.at(o.executed), what, total, o.comment) + end + end + + if [:none, :dividend].include?(@filter) then + # cashflow from dividend received + Position.select(:id) + .joins(:Portfolio) + .where('portfolio.id_user'=>@id_user) + .where('(position.closed=0 OR position.closed>=%i) OR (position.closed BETWEEN %i AND %i)' % [@end_date, @start_date, @end_date]).each do |p| + + ap = AssetPosition.new(p.id, @id_user, Time.at(@end_date)) + ap.dividend.payments.each do |dp| + next if dp[:time].to_i < @start_date + next if dp[:total] == 0.0 # skips positions sold right before ex-Div date + log_dividends << CashflowLogItem.new(dp[:time], + 'received dividend of %.2f for %i shares from %s' % [dp[:received], dp[:shares], ap.stock.name], + dp[:total], + 'via %s' % ap.portfolio.name + ) + end + end + end + + # merge cashflows by date + @log = log_orders + log_dividends + @log.sort_by!{ |i| i.date}.reverse! + end + end + + ## + # Represents a single item of cashflow. + # + # Cashflow may be generated by an order, a dividend payment or something else. + # This class is made handy for presenting data in templates. + class CashflowLogItem + def initialize(date, what, total, comment='') + @date=date; @what=what; @total=total; @comment = comment; + end + + def date + @date + end + + def what + @what + end + + def total + @total + end + + def comment + @comment + end + end + +end # module diff --git a/gui/lib/smr/daemon_client.rb b/gui/lib/smr/daemon_client.rb new file mode 100644 index 0000000..81c81e7 --- /dev/null +++ b/gui/lib/smr/daemon_client.rb @@ -0,0 +1,131 @@ +# +# This file is part of SMR. +# +# SMR is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# SMR is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# SMR. If not, see . +# +# +module Smr #:nodoc: + require 'xmlrpc/client' + + ## + # Client to operate smrd functionality via XML-RPC. + # + # Since smrd is not existing yet, this class talks to the old and ancient + # stockmaniacd. The plan is to change that. + class DaemonClient + + # hostname to connect to, an IP address should do as well + SMRD_HOST = 'localhost' + + # port to connect to + SMRD_PORT = 13000 + + ## + # Establish new connection to the daemon. + # Note: constructor is costly as it tests to connect to SMRD_PORT. + def initialize + begin TCPSocket.new(SMRD_HOST, SMRD_PORT) + rescue + raise 'unable to connect to daemon at %s:%s: %s' % [SMRD_HOST, SMRD_PORT, $!] + end + @server = XMLRPC::Client.new(SMRD_HOST, '/', SMRD_PORT); + end + + public + + ## + # Returns +Hash+ with quote retrieval status information for given Stock#id. + def status_stock(id_stock) + ok, response = @server.call2('stockmaniac.get_workitem', 'stock', id_stock) + + if ok then + return response['raw'].sort + else + raise 'xml-rpc error: %s - %s' % [response.faultCode, response.faultString] + end + end + + ## + # Request the daemon to parse math :expressions. + # + # Returns a list of variables contained in each expression. The returned + # list will have the same indices as :expressions, but the order of elements + # is _not_ consistent and not guaranteed to reflect the order used in the + # expression. For example: + # + # expressions = [ 'EBIT / Shares * 0.5', 'asset - debt' ] + # + # may return + # + # [["EBIT", "Shares"], ["asset", "debt"]] + # + # or + # + # [["Shares", "EBIT"], ["debt", "asset"]] + # + # the index of expressions, however, is kept. For example: + # + # expressions = [ 'asset - debt', 'EBIT / Shares * 0.5' ] + # + # will return with order as queried: + # + # [["asset", "debt"], ["EBIT", "Shares"]] + # + def parse_math_expressions(expressions) + raise 'expressions must be a Array' unless expressions.is_a?(Array) + + ok, response = @server.call2('stockmaniac.parse_math_expressions', expressions) + + if not ok + raise 'xml-rpc error: %s - %s' % [response.faultCode, response.faultString] + end + + # sort by key, numerically!, convert to array + parsed = response.sort_by{|k,v| k.to_i} + + # alway return two-level array + if expressions.length==1 then [parsed] else parsed end + end + + ## + # Request the daemon to solve math :expressions using data :values. + # + # The indices of :expressions and :solving_data collections are expected to + # match. For example: expressions[0] will be solved using variable names + # and values from values[0]. Suppose + # + # expressions[0] = 'a * b' + # + # values[0] is expected to have indices for +a+ and +b+ like: + # + # values[0] = [ 'a'=>3, 'b'=>4 ] + # + # The returned result array will be indexed like :expressions and :values. + def solve_math_expressions(expressions, values) + raise ':expressions must be a Array' unless expressions.is_a?(Array) + raise ':values must be a Array' unless values.is_a?(Array) + raise ':expressions and :values must have same length' unless expressions.length==values.length + + ok, response = @server.call2('stockmaniac.solve_math_expressions', expressions, values) + + if not ok + raise 'xml-rpc error: %s - %s' % [response.faultCode, response.faultString] + end + + # sort by key, numerically!, convert to array of result values + response.sort_by{|k,v| k.to_i}.collect{|v| v.second} + end + + end + +end # module diff --git a/gui/lib/smr/figures.rb b/gui/lib/smr/figures.rb new file mode 100644 index 0000000..4d86b50 --- /dev/null +++ b/gui/lib/smr/figures.rb @@ -0,0 +1,560 @@ +# +# This file is part of SMR. +# +# SMR is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# SMR is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# SMR. If not, see . +# + +module Smr #:nodoc: + ## + # Implements a mapping mechanism to keep track of the FigureData records of + # most recent relevance. Its most useful when inherited from the actual + # organization class. + class FiguresDataMap + + def initialize + @map = Hash.new{ |h,k| h[k] = Hash.new(&h.default_proc) } + @last_figure_var = FigureVar.new + @last_figure_data = FigureData.new + end + + ## + # Add a FigureData to the map, return code indicates whether this data is + # already known (1), known and more recent (2) or not known at all (false). + # + # Map items are categorized into (:name, :year, :id, :period). This is a + # multi-dimensional Hash, see + # http://stackoverflow.com/questions/10253105/dynamically-creating-a-multi-dimensional-hash-in-ruby + # + # Unknown items will be map_added to the map. If a FigureData with the same + # categorization is passed, the map uses :time to decide whether to update + # or ignore it. + # + def map_add(figure_data) + unless (figure_data.is_a?(FigureData) or figure_data.is_a?(Smr::AutofigureData)) + raise ':figure_data must be of FigureData or SmrAutofigureData' + end + @last_figure_data = figure_data + @last_figure_var = @last_figure_data.FigureVar + + # define locals to shorten the code below + name = @last_figure_var.name + id = @last_figure_var.id.to_i + year = @last_figure_data.time.year.to_i + period = @last_figure_data.period + + if not (@map.has_key?(name) and @map[name].has_key?(year) and @map[name][year].has_key?(id) and @map[name][year][id].has_key?(period)) then + # we do not know this figure yet, map_add to map + @map[name][year][id][period] = @last_figure_data.time + return false + else + if (@map.has_key?(name) and @map[name].has_key?(year) and @map[name][year].has_key?(id) and @map[name][year][id].has_key?(period)) and @map[name][year][id][period] > figure_data.time then + # known but older + return 1 + else + # known but more recent => update map + @map[name][year][id][period] = figure_data.time + return 2 + end + end + end + + ## + # return name last FigureVar processed by map_add() + def get_name + @last_figure_var.name + end + + ## + # return FigureData id of the last item processed by map_add() + def get_figure_data_id + @last_figure_data.id + end + + ## + # return period of the last item processed by map_add() + def get_period + @last_figure_data.period + end + + ## + # return date as +Time+ of the last item processed by map_add() + def get_date + @last_figure_data.time + end + end + + ## + # Builds FigureVar and FigureData objects into a form that is easy to process + # in views. + # + # Use add() to push FigureData records into the table. When done, tell the object + # to process all data by calling render(). Thereafter all data is available for + # display through the +get_*+ methods. + # + # Note that methods return false or raise an exception when used their proper + # mode. + class FiguresDataTable < FiguresDataMap + def initialize + super + + ## + # mode of operation (input or display) + # + # The add() mgqethod is only active in input mode while all the +get_*+ + # methods are only available in display mode. You must call render() + # before you can get any data out. A new object is in input mode. + @input_mode = true + + # see add() and add_record() + @most_recent_figures = Array.new + + # see add_record()a and update_data() + @less_recent_figures = Hash.new{ |h,k| h[k] = Hash.new(&h.default_proc) } + + # the final table created by render() + @table = Hash.new{ |h,k| h[k] = Hash.new(&h.default_proc) } + + # see get_class() + @class = Array.new + + ## + # CSS class string used for rows, altered by cycle_css_row() + @cycle_css_row = 'row1' + end + + ## + # Process a FigureData while in input mode. + def add(figure_data) + raise 'add() only works in input mode, it stops working when render() was called' if not @input_mode + + case map_add(figure_data) + when false then add_record(figure_data) + when 1 then add_record(figure_data, {:less_recent=>true}) + when 2 then update_data(figure_data) + end + + true + end + + ## + # End input mode and render all data ready for displaying + # + # NOTE: @table is a multi-dimensional array but we use only two dimensions, + # see http://stackoverflow.com/questions/10253105/dynamically-creating-a-multi-dimensional-hash-in-ruby + # + # Final table will look like when viewed by calling cell() on each + # +row+,+col+ coordinate: + # + # ______ ________________ ____________ ______________ _______________ _____ + # | | | - Year - | - Quarter 1 -| - Quarter 2 - | ... | + # | year | FigureVar.name | FigureData | FigureData | FigureData | ... | + # |------|----------------|------------|--------------|---------------|-----| + # ... + # + # The text_table() method is very useful for debugging. + # + def render + raise 'no data to render(), use add() first' if @most_recent_figures.empty? + @input_mode = false + @table.clear if @table.count > 0 + + # create table head + periods = FigureData.new.get_periods + @table[0][0] = '' + @table[0][1] = '' + periods.each_index {|i| @table[0][i+2]=FigureData.new.translate_period(periods[i])} + + # add most recent FigureVar + # - first: sort by FigureVar.time + # - second: add by period index + offset (because of the table head) + # ATTENTION: sorting by fd.FigureVar.name probably expensive, consider + # fs.id_figure_var as performant alternative + @most_recent_figures.sort_by! { |fd| [fd.time.year, fd.FigureVar.name] } + + row=1 + @most_recent_figures.each do |fd| + @table[row][0] = fd.time.year + @table[row][1] = fd.FigureVar.name + + col = periods.index(fd.period) + 2 + @table[row][col] = fd + + row += 1 + end + + # consolidate rows by years and figure name (first two columns) + prev_row = @table[1] + prev_key = 1 + @table.each do |k, row| + next if k == 0 # skip table head + + if row[0]==prev_row[0] and row[1]==prev_row[1] then + # merge into prev_row since row is more recent, see sort_by above + @table[k] = prev_row.merge(row) + @table.delete(prev_key) if k > prev_key + end + prev_row = @table[k] + prev_key = k + end + + # re-index @table since rows(), columns() and cell() rely on + # consecutive numbering + tmp_table = Hash.new{ |h,k| h[k] = Hash.new(&h.default_proc) } + i=0 + @table.each_key do |k| + tmp_table[i] = @table[k] + i += 1 + end + @table = tmp_table + end + + ## + # print table in text format (for debugging only!) + def text_table + render + format = Array.new(columns, '%12s').join('|') + + puts '----------- @table ------------' + for r in 0...rows + row = Array.new + for c in 0...columns + row[c] = cell(r, c) + end + puts(format % row) + end + puts '-------------------------------' + end + + ## + # Returns number of rows after render(). + def rows + raise 'rows() only works after render() was called' if @input_mode + @table.count + end + + ## + # Returns number of columns after render(). + # NOTE: the first row sets the number of columns of the table + def columns + raise 'columns() only works after render() was called' if @input_mode + @table[0].count + end + + ## + # Returns content for a cell identified by row and col, nil if empty. + def cell(row, col) + raise 'cell() only works after render() was called' if @input_mode + + if c = raw_cell_content(row, col) then + if c.is_a?(FigureData) or c.is_a?(Smr::AutofigureData) + c.value + else + # leftmost column is always a descriptive string, not to be + # rounded, scaled, whatever ... + if col==0 then c.to_s else c end + end + else + nil + end + end + + ## + # Returns unit of value in cell or nil if there is nothing. + def cell_unit(row, col) + raise 'cell_unit() only works after render() was called' if @input_mode + + if c = raw_cell_content(row, col) then + if c.is_a?(FigureData) or c.is_a?(Smr::AutofigureData) + c.FigureVar.unit + end + end + end + + ## + # Return rowspanning information for given cell (row, col) or false if this + # cell should not span at all + # + # rowspanning information for @datatable is stored in @rowspan and looks + # this way: + # - two-dimensional array: #row, #col + # - a number indicates how many lines a cell should span down + # - a dash ('-') indicates that a cell is covered by another cell that + # spans above it + # - #row,#col coordinates are not set means the cell is not affected by + # rowspanning at all + def rowspan(row, col) + raise 'rowspan() only works after render() was called' if @input_mode + false + end + + ## + # Return class information for given row or cell + # + # False is returned if there is no such information (the row or cell should + # not have any class attribute set). + # + # The +col+ paramenter is optional, if given you ask for a specific cell, + # if ommited (default) you ask for the entire row. + # + # CSS class information for @datatable is stored in @class and looks like + # this: + # - one-or-two-dimensional array: #row, #col + # - contains a string of CSS class names that should be set on a given row + # and/or cell + # - false means that there shouldn't be any class attribute set + def class(row, col=false) + raise 'class() only works after render() was called' if @input_mode + false + end + + ## + # Return link to edit FigureVar of given field + # + # False is returned if that field is empty. + def link(row, col) + raise 'link() only works after render() was called' if @input_mode + + if c = raw_cell_content(row, col) then + if c.is_a?(FigureData) then return('/figures/%i/edit' % c.id) end + end + + false + end + + protected + + ## + # add FigureData to internal @most_recent_figures or @less_recent_figures + # + # @less_recent_figures contains all those where a more recent counterpart + # is available in @most_recent_figures. See get_summary() and + # have_summary(). + # + # @most_recent_figures is a regular +Array+ + # + # @less_recent_figures is a multi-dimensional +Hash+ structured as + # + # @less_recent_figures[:year][:period][:id_figure_data] = Array.new + # + def add_record(figure_data, options={ :less_recent=>false }) + y = figure_data.time.year + p = figure_data.period + id = figure_data.id + + if options[:less_recent] then + if @less_recent_figures.has_key?(y) and @less_recent_figures[y].has_key?(p) and @less_recent_figures[y][p].has_key?(id) then + @less_recent_figures[y][p][id] << figure_data + else + @less_recent_figures[y][p][id] = [figure_data] + end + else + @most_recent_figures << figure_data + end + + true + end + + ## + # update FigureData item in @most_recent_figures, make sure the old data is + # kept in @less_recent_figures. + # FIXME: to be implemented! + def update_data(figure_data) + #@most_recent_figures.find(:id_figure_var=>figure_data.id_figure_var, ...) + true + end + + ## + # Returns content for a cell identified by row and col. + # + # The content can be +nil+ if that cell is empty or whatever is there, ie. + # a FigureData, a +String+, etc.... + def raw_cell_content(row, col) + if @table.has_key?(row) and @table[row].has_key?(col) then + @table[row][col] + else nil end + end + end + + ## + # creates many (!) AutofigureData objects from a FigureVar record + # + # A Autofigure is based on a FigureVar record with the :expression field + # containing a math expression. That is each FigureVar becomes a Autofigure + # by setting the :expression field. It will return to being a ordinary + # FigureVar by emptying the the :expression field. + # + # Each Autofigure will create as many AutofigureData objects as possible, + # depending on the information available to solve the given :expression. It + # might be hundreds if there is input data for, say, 100 fiscal quarters. + # + class Autofigure + + ## + # Init with FigureVar and a list of variables contained in its :expression. + # See SmrDaemonClient#parse_math_expressions. + def initialize(figure_var, variables) + raise 'figure_var must be a FigureVar object' unless figure_var.is_a?(FigureVar) + raise 'figure_var must have :expression field set' if figure_var.expression.empty? + @autofigure = figure_var + @expression_vars = Hash.new + + # internally we work with FigureVar ids, not with their names + FigureVar.select(:id, :name).where(:name=>variables).each do |v| + @expression_vars[v.id]=v.name + end + + # collects FigureData by year and period, to know what we have (=> + # #add()) and still need (=> #get_missing_variables) + @datamatrix = Hash.new + + # collects Time if most recent FigureData per row in @datamatrix, + # necessary to know how to time the SmrAutofigureData objects + @timematrix = Hash.new + + @id_stock = nil + end + + ## + # Add FigureData as input for solving the expression. + # + # Returns +true+ if it was added or +false+ if it did not help to fill this + # expression. + # + # Note: data is overwritten. A FigureData of same +:year+ and +:period+ + # will overwrite the previously passed one. So mind the order. + def add(figure_data) + raise 'figure_data must be a FigureData object' unless figure_data.is_a?(FigureData) + + if @id_stock and @id_stock != figure_data.id_stock + raise 'add()ing FigureData of another Stock should not happen, should it?' + else + @id_stock = figure_data.id_stock + end + + i = '%s_%s' % [figure_data.time.year, figure_data.period ] + if @expression_vars.keys.include?(figure_data.id_figure_var) + newdata = { figure_data.id_figure_var=>figure_data.value } + @datamatrix[i] = if @datamatrix[i] then @datamatrix[i].merge(newdata) else newdata end + + if not @timematrix[i] or @timematrix[i] < figure_data.time then + @timematrix[i] = figure_data.time + end + + return true + end + + return false + end + + ## + # Tells whats missing to solve all expressions. + def get_missing_variables + missing = Array.new + @datamatrix.each do |i,d| + diff = @expression_vars.keys - d.keys + if not diff.empty? + missing << i.to_s + '_' + @expression_vars.select{|k,v| diff.include?(k) }.values.join('-') + end + end + missing + end + + ## + # Returns collection of AutofigureData objects with solvable + # expressions. Objects with non-solvable expressions (missing data) are + # skipped silently. + def get + @datamatrix.collect do |i,d| + diff = @expression_vars.keys - d.keys + if diff.empty? + dn = Hash.new + d.each{ |k,v| dn[@expression_vars[k]]=v } + AutofigureData.new(@autofigure, dn, @id_stock, i.split('_').second, @timematrix[i]) + end + end.compact + end + + end + + ## + # like a FigureData object without related database record + # + # A AutofigureData object is ment to behave exactly like a FigureData + # object. Except that it can`t be saved or updated or trigger any other + # database operation. Its an entirely artificial record so to say. + class AutofigureData + # compatibility with FigureData, always +nil+ + attr_reader :id + + # compatibility with FigureData, empty + attr_reader :analyst, :is_expected, :is_audited, :comment + + # where this autofigure was derived from + attr_reader :id_figure_var, :id_stock, :period + + # unix timestamp when this autofigure was made, also see time() + attr_reader :date + + # the Hash of data variables and values necessary for solving + attr_reader :expression, :solving_data + + # result of the expression or +nil+ if not yet solved + # Note: this must be calculated/set externaly + attr_accessor :value + + ## + # Give FigureVar out of which this SmrAutofigureData is derived and a Hash + # of var=>value for solving the expression. +id_stock+ is to know what this + # is for. + def initialize(autofigure, values, id_stock, period, time) + raise 'autofigure must be a FigureVar' unless autofigure.is_a?(FigureVar) + raise 'values must be a Hash' unless values.is_a?(Hash) + raise 'time must be of Time' unless time.is_a?(Time) + @autofigure = autofigure + @expression = autofigure.expression + @solving_data = values + + # carry on meaningful fields + @id_figure_var = autofigure.id + @id_stock = id_stock + @date = time.to_i + @period = period + @analyst = self.class + end + + ## + # Time when this autofigure was made + def time + Time.at(@date) + end + + ## + # wrapper for compatibility with FigureData + def get_periods + FigureData.new.get_periods + end + + ## + # wrapper for compatibility with FigureData + def translate_period(period=period) + FigureData.new.translate_period(period) + end + + ## + # wrapper for compatibility with FigureData + def FigureVar + @autofigure + end + end + +end # module diff --git a/gui/lib/smr/quoterecords.rb b/gui/lib/smr/quoterecords.rb new file mode 100644 index 0000000..5398094 --- /dev/null +++ b/gui/lib/smr/quoterecords.rb @@ -0,0 +1,129 @@ +# +# This file is part of SMR. +# +# SMR is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# SMR is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# SMR. If not, see . +# + +module Smr #:nodoc: + ## + # Collection of Quoterecord objects owned by a given user, related to given + # security at given date. + # + # == Security + # The +id_user+ parameter should not come from user input, but rather + # result from a successful authentication process. + class Quoterecords < Array + ## + # provide Stock and +Time+ date + def initialize(id_user, stock, date=Time.now.end_of_day) + raise ':stock must be a Stock' unless stock.is_a?(Stock) + @@id_user = id_user + @@date = date + @@stock = stock + + Quoterecord.joins(:Quote) + .where(:id_user=>@@id_user, :id_stock=>@@stock.id) + .where('quote.date <= %i' % date) + .order('quote.date DESC').each do |qr| + push(qr) + end + end + + ## + # return column names + def self.get_columns + Quoterecord.get_columns + end + + ## + # translate column name into human readable +String+ + def self.translate_column(column) + Quoterecord.translate_column(column) + end + + ## + # return quote of last pivotal point for +column+ + def get_pivot_point(column) + qr = Quoterecord.select(:id_quote).where( + :id_user=>@@id_user, :id_stock=>@@stock.id, :column=>column, + :is_pivotal_point=>true, :created=>0..@@date.to_i + ).order(created: :desc).limit(1).first + qr.Quote.quote if qr.is_a?(Quoterecord) + end + + ## + # return column name of last recorded record + def get_last_recorded_column + self.first.column if not empty? + end + + ## + # view given Quote in context of existing Quoterecord data + # + # returns a message string with its findings and a new Quoterecord object, + # contained in a two field array or false on failure. + # + # This new object is linked to Quote and has fields set according to the + # findings. + # + def inspect_quote(quote) + raise ':quote must be of Quote' unless quote.is_a?(Quote) + + message = [] + threshold = QuoterecordThreshold.where(:id_user=>@@id_user, :id_stock=>@@stock.id).first || QuoterecordThreshold.new(:quote_sensitivity=>0, :hit_sensitivity=>0) + new_quoterecord = Quoterecord.new(:id_user=>@@id_user, :id_stock=>@@stock.id) + + # check sensitivity hit against most recent quote record (=mrqr) + if empty? + message << 'no previous quoterecords, initiate recording with this one' + else + mrqr = self.first + if mrqr.Quote.quote > 0.0 + if quote.quote > mrqr.Quote.quote + threshold.quote_sensitivity then + message << 'UP hit' + new_quoterecord.is_uphit = true + elsif quote.quote < mrqr.Quote.quote - threshold.quote_sensitivity then + message << 'DOWN hit' + new_quoterecord.is_downhit = true + end + end + end + + # check quote against previous pivotal points + self.class.get_columns.each do |col| + next if not pivot_point = get_pivot_point(col) + t_col = self.class.translate_column(col) + + case col + when 'upward', 'natural_rally', 'secondary_rally' + if quote.quote > pivot_point + threshold.hit_sensitivity then + message << 'pivotal point hit in %s column' % t_col + elsif (mrqr.Quote.quote-threshold.hit_sensitivity..mrqr.Quote.quote+threshold.hit_sensitivity) === quote.quote + message << 'close to pivotal point in %s column' % t_col + end + + when 'downward', 'natural_reaction', 'secondary_reaction' + if quote.quote < pivot_point - threshold.hit_sensitivity then + message << 'pivotal point hit in %s column' % t_col + elsif (mrqr.Quote.quote-threshold.hit_sensitivity..mrqr.Quote.quote+threshold.hit_sensitivity) === quote.quote + message << 'close to pivotal point in %s column' % t_col + end + end + end + + return message.join(', '), new_quoterecord + end + + end + +end # module diff --git a/gui/lib/smr/uploaded_file.rb b/gui/lib/smr/uploaded_file.rb new file mode 100644 index 0000000..c21d2fd --- /dev/null +++ b/gui/lib/smr/uploaded_file.rb @@ -0,0 +1,199 @@ + # + # This file is part of SMR. + # + # SMR is free software: you can redistribute it and/or modify it under the + # terms of the GNU General Public License as published by the Free Software + # Foundation, either version 3 of the License, or (at your option) any later + # version. + # + # SMR is distributed in the hope that it will be useful, but WITHOUT ANY + # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + # A PARTICULAR PURPOSE. See the GNU General Public License for more details. + # + # You should have received a copy of the GNU General Public License along with + # SMR. If not, see . + # + +module Smr #:nodoc: + ## + # A UploadedFile with relations to Portfolio, Position, Order and/or Stock. + # + # == Security + # For the sake of security its required to supply id of User that is used to + # make sure the requested document does belong to that user. The +id_user+ + # parameter should not come from user input, but rather result from a + # successful authentication process. + # + + # == Attention + # - right now this is only for viewing perposes + # - to store new data records use the model classes directly. These are + # Document, DocumentData and DocumentAssign. + # + class UploadedFile + ## + # initialize with Document id and User id + def initialize(id_document, id_user) + @id = id_document + @id_user = id_user + + @document = Document.where(:id=>@id, :id_user=>@id_user).limit(1).first + end + + public + + def id + @document.id + end + + ## + # returns Document.time_upload + def time_upload + @document.time_upload + end + + def comment + @document.comment + end + + def filename + @document.filename + end + + def size + @document.size + end + + def mimetype + @document.mimetype + end + + def has_relations? + if DocumentAssign.where(:id_document=>@id, :is_assigned=>1).count > 0 then return true + else return false end + end + + ## + # tell whether a Document record was found + def empty? + not @document.is_a?(Document) + end + + ## + # return human readable string of relations of this document + # + # TODO: make link to each relation + def relations_text + rel = DocumentAssign.where(:id_document=>@id, :is_assigned=>1).first + t = Array.new + + return nil unless rel + + t << 'security %s' % rel.Stock.name if rel.id_stock + t << 'order #%i' % rel.id_order if rel.id_order + t << 'portfolio %s' % rel.Portfolio.name if rel.id_portfolio + t << 'position %s held at %s' % [rel.Position.Stock.name, rel.Position.Portfolio.name] if rel.id_position + + return 'related to ' + t.join(', ') + end + + ## + # return Portfolio object(s) if any were assigned + # FIXME: not yet implemented + def portfolio + true + end + + ## + # return Position object(s) if any were assigned + # FIXME: not yet implemented + def position + true + end + + ## + # return Order object(s) if any were assigned + # FIXME: not yet implemented + def order + true + end + + ## + # return Stock object(s) if any were assigned + # FIXME: not yet implemented + def stock + true + end + + ## + # return BLOB (binary large object) data of that document + def data + DocumentData.where(:id_document=>@id).first.content + end + + ## + # assigns this document to an Order, a Position, a Stock or a Portfolio. + # + # Give a symbol and the id that should be assigned. Like + # +assign(:order=>5)+ or more than on with +assign(:order=>6, + # :position=>3)+. + # + # Unassign by specifying zero, ie +assign(:order=>0)+. + def assign(assignments = {}) + da = DocumentAssign.find_by_id_document(@id) || DocumentAssign.new(:id_document=>@id, :is_assigned=>1) + + return false if assignments.empty? + + da.id_order = assignments[:order] if assignments[:order]; da.id_order=nil if assignments[:order] == 0 + da.id_position = assignments[:position] if assignments[:position]; da.id_position=nil if assignments[:position] == 0 + da.id_portfolio = assignments[:portfolio] if assignments[:portfolio]; da.id_portfolio=nil if assignments[:portfolio] == 0 + da.id_stock = assignments[:stock] if assignments[:stock]; da.id_stock=nil if assignments[:stock] == 0 + + da.save! + end + + ## + # Make new Smr::UploadedFile from POSTed data. + # + # This creates the records which can later be viewed as Smr::UploadedFile. On + # success it will return a new Smr::UploadedFile based on the new records. + # - +upload_time+ should be of class Time + # - +file+ is expected to be of class ActionDispatch::Http::UploadedFile. + # + # NOTE: this is a class method, not an instance method! + def self.store(id_user, file, comment='', upload_time=Time.now) + raise 'file must be of ActionDispatch::Http::UploadedFile' unless file.is_a?(ActionDispatch::Http::UploadedFile) + d = Document.new( + :id_user=>id_user, + :date_upload=>upload_time.to_i, + :size=>file.size, + :mimetype=>file.content_type, + :filename=>file.original_filename, + :comment=>comment + ) + d.save! + DocumentData.new(:id_document=>d.id, :content=>file.read).save! + return UploadedFile.new(d.id, id_user) + end + end + + ## + # Collection of UploadedFile objects owned by a given user. + # + # == Security + # The +id_user+ parameter should not come from user input, but rather + # result from a successful authentication process. + class UploadedFiles < Array + ## + # provide +Time+ date and SQL statement as string. + def initialize(id_user) + @id_user = id_user + @document_ids = Document.select(:id).where(:id_user=>@id_user).order(date_upload: :desc).to_a + @document_ids.each do |id| + push(UploadedFile.new(id, @id_user)) + end + compact! + end + end + +end # module diff --git a/gui/test/functional/smr_asset_position_test.rb b/gui/test/functional/smr_asset_position_test.rb new file mode 100644 index 0000000..b8cc9e9 --- /dev/null +++ b/gui/test/functional/smr_asset_position_test.rb @@ -0,0 +1,107 @@ +# +# This file is part of SMR. +# +# SMR is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# SMR is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# SMR. If not, see . +# +require 'test_helper' + +module Smr #:nodoc: + class AssetPositionTest < ActionController::TestCase + + test "correct number of shares" do + to_test = [ + # Cisco Systems Inc. + {:id=>1, :at=>Time.new(2003,5,23), :shares=>15.0}, + {:id=>1, :at=>Time.new(2005,5,23), :shares=>-5.0}, + + # IBM Inc. + {:id=>10, :at=>Time.new(2003,5,23), :shares=>0.0}, + {:id=>10, :at=>Time.new(2005,5,23), :shares=>25.0}, + + # McDonalds Inc. + {:id=>20, :at=>Time.new(2010,5,23), :shares=>0.0}, + + # DWS Vermoegensbildungsfonds A + {:id=>6, :at=>Time.new(2004,5,23), :shares=> 15.0}, + {:id=>6, :at=>Time.new(2004,8,23), :shares=> 112.3456}, + + # Altana AG + {:id=>11, :at=>Time.new(2014,6,20), :shares=>70.0}, + ] + + to_test.each do |tp| + p = AssetPosition.new(tp[:id], 1, tp[:at]) + assert p, 'no Position for id_position=%i, id_user=1?' % tp[:id] + assert_in_delta tp[:shares], p.shares, 0.0001, 'wrong number of shares in position #%i' % tp[:id] + end + end + + ## + # tests amount of money invested + # - that covers less code than the gain test but helps debugging when the + # later fails + test "correct invested amount" do + to_test = [ + # Altana AG + {:id=>11, :at=>Time.new(2014,6,20), :invested=>2458.8}, + ] + + to_test.each do |t| + p = AssetPosition.new(t[:id], 1, t[:at]) + assert p, 'no Position for id_position=%i, id_user=1?' % t[:id] + assert_in_delta t[:invested], p.invested, 0.0001, 'wrong invested amount of money in position #%i' % t[:id] + end + end + + ## + # make sure positions are closed properly + test "positions closed" do + to_test = [ + # DaimlerChrysler AG + {:id=>2, :at=>Time.new(2005,5,23)}, + + # Allianz AG + {:id=>7, :at=>Time.new(2005,5,23)} + ] + + to_test.each do |tp| + p = AssetPosition.new(tp[:id], 1, tp[:at]) + assert p, 'no Position for id_position=%i, id_user=1?' % tp[:id] + if not p.closed? then + assert false, 'position #%i is not closed at %s' % [ tp[:id], tp[:at] ] + end + end + end + + ## + # tests gain and all the code necessary to calculate this correctly + test "correct gain" do + to_test = [ + # Altana (open) + {:id=>18, :at=>Time.new(2005,10,12).end_of_day, :gain=>27.0, :id_user=>3}, + + # Allianz AG (closed, viewed before closure) + {:id=>7, :at=>Time.new(2005,5,23).end_of_day, :gain=>462.30}, + + # Allianz AG (closed, viewed after closure, settled) + {:id=>7, :at=>Time.new(2008,5,23).end_of_day, :gain=>3201.40}, + ] + + to_test.each do |tp| + p = AssetPosition.new(tp[:id], tp[:id_user]||1, tp[:at]) + assert p, 'no Position for id_position=%i, id_user=1?' % tp[:id] + assert_in_delta tp[:gain], p.gain, 0.0001, 'position #%i has incorrect gain at %s' % [ tp[:id], tp[:at] ] + end + end + end +end # module diff --git a/gui/test/functional/smr_asset_test.rb b/gui/test/functional/smr_asset_test.rb index e740eb9..4625cbe 100644 --- a/gui/test/functional/smr_asset_test.rb +++ b/gui/test/functional/smr_asset_test.rb @@ -15,23 +15,26 @@ # require 'test_helper' -class SmrAssetTest < ActionController::TestCase +module Smr #:nodoc: - ## - # test time machine by verifying investment sums at certain dates - # - # this is using the admin user with id_user=>1 - test 'invested' do - to_test = [ - {:at=>Time.new(2003,5,23), :invested=>225.0}, - {:at=>Time.new(2004,5,23), :invested=>12245.849999999999}, - {:at=>Time.new(2014,5,23), :invested=>10570.05}, - {:at=>Time.new(2014,6,20), :invested=>12068.85}, - ] - - to_test.each do |ta| - a = SmrAsset.new(1, ta[:at]) - assert_equal ta[:invested], a.invested, 'wrong investment total at %s' % ta[:at] + class AssetTest < ActionController::TestCase + ## + # test time machine by verifying investment sums at certain dates + # + # this is using the admin user with id_user=>1 + test 'invested' do + to_test = [ + {:at=>Time.new(2003,5,23), :invested=>225.0}, + {:at=>Time.new(2004,5,23), :invested=>12245.85}, + {:at=>Time.new(2014,5,23), :invested=>10570.05}, + {:at=>Time.new(2014,6,20), :invested=>12068.85}, + ] + + to_test.each do |ta| + a = Smr::Asset.new(1, ta[:at]) + assert_in_delta(ta[:invested], a.invested, 0.0001, 'wrong investment total on %s ' % ta[:at]) + end end end -end + +end # module diff --git a/gui/test/functional/smr_cashflowlog_test.rb b/gui/test/functional/smr_cashflowlog_test.rb dissimilarity index 70% index e2aac0e..58934a4 100644 --- a/gui/test/functional/smr_cashflowlog_test.rb +++ b/gui/test/functional/smr_cashflowlog_test.rb @@ -1,67 +1,71 @@ -# -# This file is part of SMR. -# -# SMR is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# SMR is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR -# A PARTICULAR PURPOSE. See the GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along with -# SMR. If not, see . -# -require 'test_helper' -require 'smr_cashflowlog' - -class SmrCashflowLogTest < ActionController::TestCase - - test 'cashflow at specific date' do - to_test = [ - {:at=>Time.new(2001,6,7), :total=>false}, - {:at=>Time.new(2003,5,23), :total=>-225.0}, - {:at=>Time.new(2004,9,23), :total=>-15432.496}, - {:at=>Time.new(2008,1,1), :total=>-35591.296}, - ] - - to_test.each do |t| - # startdate at January 1st 2001 means we ask for cashflows from "All Time" - cf = SmrCashflowLog.new(Time.new(2001,1,1), t[:at], 1) - - if t[:total] == false then - assert_nil cf.each.first, 'cashflow that should not be here %s' % t[:at] - else - assert_equal t[:total], cf.total, 'wrong cashflow total on %s ' % t[:at] - end - end - end - - test 'cashflow filterd at specific date' do - to_test = [ - {:at=>Time.new(2003,5,23), :total=>false, :filter=>:dividend}, - {:at=>Time.new(2004,9,23), :total=>-15634.045999999998, :filter=>:orders}, - {:at=>Time.new(2007,5,23), :total=>880.55, :filter=>:dividend}, - {:at=>Time.new(2008,1,1), :total=>109.42000000000002, :filter=>:provision}, - ] - - to_test.each do |t| - cf = SmrCashflowLog.new(Time.new(2001,1,1), t[:at], 1) - cf.set_filter(t[:filter]) - - if t[:total] == false then - assert_nil cf.each.first, 'cashflow that should not be here on %s' % t[:at] - else - assert_equal t[:total], cf.total, 'wrong cashflow total on %s' % t[:at] - end - end - end - - test 'unknown filter' do - cf = SmrCashflowLog.new(Time.new(2001,1,1), Time.now, 1) - assert_raise RuntimeError do - cf.set_filter(:this_filter_does_not_exist_and_never_will) - end - end -end +# +# This file is part of SMR. +# +# SMR is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# SMR is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# SMR. If not, see . +# +require 'test_helper' + +module Smr #:nodoc: + require 'cashflowlog' + + class CashflowLogTest < ActionController::TestCase + + test 'cashflow at specific date' do + to_test = [ + {:at=>Time.new(2001,6,7), :total=>false}, + {:at=>Time.new(2003,5,23), :total=>-225.0}, + {:at=>Time.new(2004,9,23), :total=>-15432.496}, + {:at=>Time.new(2008,1,1), :total=>-35591.296}, + ] + + to_test.each do |t| + # startdate at January 1st 2001 means we ask for cashflows from "All Time" + cf = CashflowLog.new(Time.new(2001,1,1), t[:at], 1) + + if t[:total] == false then + assert_nil cf.each.first, 'cashflow that should not be here %s' % t[:at] + else + assert_in_delta t[:total], cf.total, 0.0001, 'wrong cashflow total on %s ' % t[:at] + end + end + end + + test 'cashflow filterd at specific date' do + to_test = [ + {:at=>Time.new(2003,5,23), :total=>false, :filter=>:dividend}, + {:at=>Time.new(2004,9,23), :total=>-15634.046, :filter=>:orders}, + {:at=>Time.new(2007,5,23), :total=>880.55, :filter=>:dividend}, + {:at=>Time.new(2008,1,1), :total=>109.42, :filter=>:provision}, + ] + + to_test.each do |t| + cf = CashflowLog.new(Time.new(2001,1,1), t[:at], 1) + cf.set_filter(t[:filter]) + + if t[:total] == false then + assert_nil cf.each.first, 'cashflow that should not be here on %s' % t[:at] + else + assert_in_delta t[:total], cf.total, 0.0001, 'wrong cashflow total on %s ' % t[:at] + end + end + end + + test 'unknown filter' do + cf = CashflowLog.new(Time.new(2001,1,1), Time.now, 1) + assert_raise RuntimeError do + cf.set_filter(:this_filter_does_not_exist_and_never_will) + end + end + end + +end # module diff --git a/gui/test/functional/smr_dividend_test.rb b/gui/test/functional/smr_dividend_test.rb dissimilarity index 68% index 70dc955..d74950b 100644 --- a/gui/test/functional/smr_dividend_test.rb +++ b/gui/test/functional/smr_dividend_test.rb @@ -1,52 +1,54 @@ -# -# This file is part of SMR. -# -# SMR is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# SMR is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR -# A PARTICULAR PURPOSE. See the GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along with -# SMR. If not, see . -# -require 'test_helper' - -class SmrDividendTest < ActionController::TestCase - - test "dividend received on correct number of shares" do - to_test = [ - # Allianz AG - {:id=>7, :at=>Time.new(2004,2,7).end_of_day, :shares=>10, :received=>1.5, :total=>15}, - {:id=>7, :at=>Time.new(2004,2,10).end_of_day, :shares=>35, :received=>2, :total=>70}, - {:id=>7, :at=>Time.new(2004,2,11).end_of_day, :shares=>35, :received=>1.83, :total=>64.05}, - {:id=>7, :at=>Time.new(2004,2,12).end_of_day, :shares=>35, :received=>1.5, :total=>52.5}, - ] - - to_test.each do |td| - d = SmrDividend.new( SmrPosition.new(td[:id], 1, td[:at]) ) - p = d.payments.last - - assert p, 'no dividend payments, should have some' - assert_equal p[:shares], td[:shares], 'wrong number of shares for dividend payment in position #%i at %s' % [td[:id], td[:at]] - assert_equal p[:received], td[:received], 'received payment incorrect for position #%i at %s' % [td[:id], td[:at]] - assert_equal p[:total], td[:total], 'total payment incorrect for position #%i at %s' % [td[:id], td[:at]] - end - end - - test "no dividend received" do - to_test = [ - # Altana - {:id=>11, :at=>Time.new(2005,5,5).end_of_day}, - ] - - to_test.each do |td| - d = SmrDividend.new( SmrPosition.new(td[:id], 1, td[:at]) ) - p = d.payments.last - assert_not p, 'found dividend payments which should not be here' - end - end -end +# +# This file is part of SMR. +# +# SMR is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# SMR is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# SMR. If not, see . +# +require 'test_helper' + +module Smr #:nodoc: + class AssetPositionDividendTest < ActionController::TestCase + + test "dividend received on correct number of shares" do + to_test = [ + # Allianz AG + {:id=>7, :at=>Time.new(2004,2,7).end_of_day, :shares=>10, :received=>1.5, :total=>15}, + {:id=>7, :at=>Time.new(2004,2,10).end_of_day, :shares=>35, :received=>2, :total=>70}, + {:id=>7, :at=>Time.new(2004,2,11).end_of_day, :shares=>35, :received=>1.83, :total=>64.05}, + {:id=>7, :at=>Time.new(2004,2,12).end_of_day, :shares=>35, :received=>1.5, :total=>52.5}, + ] + + to_test.each do |td| + d = AssetPositionDividend.new( AssetPosition.new(td[:id], 1, td[:at]) ) + p = d.payments.last + + assert p, 'no dividend payments, should have some' + assert_equal p[:shares], td[:shares], 'wrong number of shares for dividend payment in position #%i at %s' % [td[:id], td[:at]] + assert_equal p[:received], td[:received], 'received payment incorrect for position #%i at %s' % [td[:id], td[:at]] + assert_equal p[:total], td[:total], 'total payment incorrect for position #%i at %s' % [td[:id], td[:at]] + end + end + + test "no dividend received" do + to_test = [ + # Altana + {:id=>11, :at=>Time.new(2005,5,5).end_of_day}, + ] + + to_test.each do |td| + d = AssetPositionDividend.new( AssetPosition.new(td[:id], 1, td[:at]) ) + p = d.payments.last + assert_not p, 'found dividend payments which should not be here' + end + end + end +end # module diff --git a/gui/test/integration/admin_session_test.rb b/gui/test/integration/admin_session_test.rb new file mode 100644 index 0000000..b25f1c9 --- /dev/null +++ b/gui/test/integration/admin_session_test.rb @@ -0,0 +1,74 @@ + # + # This file is part of SMR. + # + # SMR is free software: you can redistribute it and/or modify it under the + # terms of the GNU General Public License as published by the Free Software + # Foundation, either version 3 of the License, or (at your option) any later + # version. + # + # SMR is distributed in the hope that it will be useful, but WITHOUT ANY + # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + # A PARTICULAR PURPOSE. See the GNU General Public License for more details. + # + # You should have received a copy of the GNU General Public License along with + # SMR. If not, see . + # + require 'test_helper' + require 'digest/sha1' + +module Smr #:nodoc: + ## + # Typical admin privileged session to test administrating things. + # + class AdminSessionTest < ActionDispatch::IntegrationTest + LOGIN = 'admin' + PASSWORD = 'admin' + + ## + # try to login as privileged admin user + test "login as admin" do + # see if it takes us to the login page + get root_path + assert_response :redirect, 'accessing / should redirect to /login' + follow_redirect! + assert_response :success + assert_equal login_path, path + + smr_login(LOGIN, PASSWORD) + end + + ## + # manage (add, edit, delete) securities + test "manage securities" do + smr_login(LOGIN, PASSWORD) + + get objects_stock_index_path + assert_response :success + assert_not_nil assigns(:stocks) + # FIXME: tbd + end + + ## + # manage (add, edit, delete) portfolios + test "manage portfolios" do + smr_login(LOGIN, PASSWORD) + + get objects_portfolio_index_path + assert_response :success + assert_not_nil assigns(:portfolios) + # FIXME: tbd + end + + ## + # manage (add, edit, delete) FigureVar records + test "manage figures" do + smr_login(LOGIN, PASSWORD) + + get objects_figurevar_index_path + assert_response :success + assert_not_nil assigns(:figurevars) + # FIXME: tbd + end + end + +end # module diff --git a/gui/test/integration/demo1_user_session_test.rb b/gui/test/integration/demo1_user_session_test.rb dissimilarity index 99% index adf2b5d..5f33cea 100644 --- a/gui/test/integration/demo1_user_session_test.rb +++ b/gui/test/integration/demo1_user_session_test.rb @@ -1,303 +1,315 @@ -# -# This file is part of SMR. -# -# SMR is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# SMR is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR -# A PARTICULAR PURPOSE. See the GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along with -# SMR. If not, see . -# -require 'test_helper' -require 'digest/sha1' - -## -# typical user session involving interaction with multiple controllers by doing -# 'every day work'. -# -# HINT: regarding path methods, see -# http://stackoverflow.com/questions/5345757/in-rails-how-to-see-all-the-path-and-url-methods-added-by-railss-routing#5346350 -class Demo1UserSessionTest < ActionDispatch::IntegrationTest - LOGIN = 'demo1' - PASSWORD = 'demo' - - ## - # try to login as demo1 user - test "login as demo1" do - # see if it takes us to the login page - get root_path - assert_response :redirect, 'accessing / should redirect to /login' - follow_redirect! - assert_response :success - assert_equal login_path, path - - smr_login(LOGIN, PASSWORD) - end - - ## - # try to list documents, upload one, download it again, delete it - test "work with documents" do - smr_login(LOGIN, PASSWORD) - - get documents_path - assert_response :success - assert_not_nil assigns(:portfolios) - assert_not_nil assigns(:document) - assert_not_nil docs = assigns(:documents) - assert docs.is_a?(SmrDocuments) - assert docs.empty? - - upload_file = fixture_file_upload('quote.yml', 'text/plain') - upload_sha1 = Digest::SHA1.hexdigest(upload_file.read) - - # upload - post documents_path, - {:action=>'create', :document=>{:comment=>'IntegrationTest', :content=>upload_file}, :id_portfolio=>'' }, - {:html=>{:multipart=>true}, :referer=>documents_path } - assert_response :redirect - follow_redirect! - assert_not_nil docs = assigns(:documents), 'no document shown after upload' - assert docs.is_a?(SmrDocuments) - assert_not docs.empty? - assert d = docs.each.first - assert d.is_a?(SmrDocument) - - # download nothing - get download_path, {}, {'HTTP_REFERER'=>documents_path} - assert_redirected_to documents_path - - # download what we uploaded - get download_path, {:id=>d.id}, {'HTTP_REFERER'=>documents_path} - assert_equal @response.content_type, 'text/plain' - assert_equal Digest::SHA1.hexdigest(@response.body), upload_sha1 - - # delete what we uploaded - get delete_document_path, {:id=>d.id}, {'HTTP_REFERER'=>documents_path} - assert_redirected_to documents_path - end - - ## - # view a open position - test "view open position" do - smr_login(LOGIN, PASSWORD) - - # take second position from asset listing - assert_not_nil asset_position_entry = assigns(:open_positions).second - assert asset_position_entry.is_a?(SmrPosition) - get '%s/%i' % [positions_path, asset_position_entry.id] - assert_response :success - assert_equal position_path, path - assert_not_nil p = assigns(:position) - assert p.is_a?(SmrPosition) - - # position should be open since we took it from open_positions - assert_not p.closed? - assert_not p.is_new? - assert p.invested.is_a?(Float) - assert (p.market_value.is_a?(Float) and p.market_value>0.0) - assert p.profit_loss.is_a?(Float) - assert p.dividend.received.is_a?(Float) - assert (p.charges.is_a?(Float) and p.charges <=0.0) - assert p.gain.is_a?(Float) - - # it must have at least one revision since its open but not new - assert_not_nil first_revision = p.revisions.first - assert first_revision.is_a?(PositionRevision) - assert first_revision.Order.is_a?(Order) - assert_not_equal first_revision.shares, 0.0 - end - - ## - # issue and cancel orders on existing position - # - test "issue orders on existing position" do - smr_login(LOGIN, PASSWORD) - - get new_position_path - assert :success - - # the form should offer option 3: new order in portfolio OR new order - # on existing position - assert_not assigns(:position) - assert_not assigns(:portfolio) - assert open_positions = assigns(:open_positions) - assert stocks = assigns(:stocks) - assert portfolios = assigns(:portfolios) - - # issue new order on existing open position - marker_order1=Digest::SHA1.hexdigest(Time.now.to_s) - post positions_path, - { :action=>'create', :id_position=>open_positions.first.id, - :order_issued=>Time.now, :order_expire=>Time.now+2.days, - :order=>{ :shares=>100, :limit=>6.66, :provision=>1.11, - :expense=>2.22, :courtage=>0.0, - :comment=>marker_order1} - }, - {:html=>{:multipart=>true}, :referer=>positions_path } - assert :success - - get '%s/%i' % [positions_path, open_positions.first.id] - assert :success - assert p = assigns(:position) - assigns p.is_a?(SmrPosition) - assert p.has_pending_orders? - neworder1 = p.pending_orders.where(:comment=>marker_order1).first - assert neworder1.is_a?(Order), 'issued order not found in pending state' - - # issue and execute new order in one step - marker_order2=Digest::SHA1.hexdigest(Time.now.to_s) - post positions_path, - { :action=>'create', :id_position=>open_positions.first.id, - :execute_now=>1, :execute_quote=>7.76, - :order_issued=>Time.now, :order_expire=>Time.now+2.days, - :order=>{ :shares=>200, :limit=>7.77, :provision=>0.00, - :expense=>0.001, :courtage=>3.33, - :comment=>marker_order2} - }, - {:html=>{:multipart=>true}, :referer=>positions_path } - assert :success - - get '%s/%i' % [positions_path, open_positions.first.id] - assert :success - assert p_after_execute = assigns(:position) - assigns p_after_execute.is_a?(SmrPosition) - - assert p_after_execute.revisions.any? {|r| r.Order.comment == marker_order2}, - 'executed new order did not create new revision' - - # check whether executing changed number of shares, invested, etc... in - # position ... and.... well... it should be correctly calculated - assert_equal p.shares + 200, p_after_execute.shares, - 'wrong number of shares in position after order execute' - assert_equal p.invested + 200*7.76, p_after_execute.invested, - 'wrong invested amount in position after order execute' - assert_equal p.charges - 0.001 - 3.33, p_after_execute.charges, - 'wrong charges in position after order execute' - - # cancel issued neworder1 - post cancel_order_path, - { :action=>'cancel_order', :id_order=>neworder1.id}, - {:html=>{:multipart=>true}, :referer=>'%s/%i' % [positions_path, p.id] } - assert_redirected_to '%s/%i' % [positions_path, p.id] - follow_redirect! - assert p_after_cancel = assigns(:position) - assigns p_after_cancel.is_a?(SmrPosition) - assert_nil p_after_cancel.pending_orders.where(:comment=>marker_order1).first, - 'failed to cancel pending order' - end - - ## - # view cashflows from dividends and orders - test "view cashflow" do - smr_login(LOGIN, PASSWORD) - - get cashflow_index_path - assert_response :success - assert_equal cashflow_index_path, path - assert_not_nil cflog = assigns(:log) - assert cflog.is_a?(SmrCashflowLog) - - cflog.each do |i| - assert i.is_a?(SmrCashflowLogItem) - end - end - - ## - # view and add FigureData - test "work with figure data" do - smr_login(LOGIN, PASSWORD) - - get figures_path - assert_response :success - assert_equal figures_path, path - assert assigns(:securities).is_a?(Array) - assert assigns(:selected_security).is_a?(Stock) - assert assigns(:have_data).is_a?(FalseClass) - - # select a security that has figure data - get figures_path, {:id_stock=>25, :show=>'this'} - assert_response :success - assert assigns(:have_data).is_a?(TrueClass) - assert assigns(:datatable).render # Raises if no data - - # add new FigureData - # - no fixtures on BASF AG, therefore nothing should be shown at first - # and then only FigureData we added from here - figure_data = { - :id=>0, # new - :id_stock=>15, # BASF AG - :id_figure_var=>4, # Demo1Var1 - :period=>'year', :time=>1.days.ago.strftime('%Y-%m-%d'), - :analyst=>'integration test', :value=>1.23, :is_expected=>1, - :is_audited=>1, :comment=>'made by demo1 integration test' - } - - get new_figure_path, :id_stock=>figure_data[:id_stock], :show=>'this' - assert_response :success - assert assigns(:figuredata).is_a?(FigureData) - assert assigns(:have_data).is_a?(FalseClass) - post(figures_path, - {:action=>'create', :figure_data=>figure_data}, - {:referer=>figures_path} - ) - follow_redirect! - assert_equal figures_path, path - assert assigns(:selected_security).is_a?(Stock) - assert_equal assigns(:selected_security).id, figure_data[:id_stock] - assert assigns(:have_data).is_a?(TrueClass) - - # try again, but store FigureData on a FigureVar that belongs to - # another user - # note: thats something the Gui does not allow to do - figure_data[:id_figure_var] = 1 # FirstVar, belongs :id_user=1 (admin) - figure_data[:id_stock] = 6 # CocaCola Inc. - get new_figure_path, :id_stock=>figure_data[:id_stock], :show=>'this' - assert_response :success - assert assigns(:have_data).is_a?(FalseClass) - post(figures_path, - {:action=>'create', :figure_data=>figure_data}, - {:referer=>figures_path} - ) - follow_redirect! - assert assigns(:have_data).is_a?(FalseClass) - end - - ## - # use the personal blog - test "read and blog articles" do - smr_login(LOGIN, PASSWORD) - - get blog_path - assert_response :success - assert_equal blog_path, path - assert_not_nil blog = assigns(:blogroll) - assert blog.is_a?(SmrBlog) - assert blog.empty? - - # blog new article - get new_blog_path - assert_response :success - assert_equal new_blog_path, path - assert_not_nil article = assigns(:article) - assert article.is_a?(Comment) - post blog_path, - {:action=>'create', :comment=>{:title=>'IntegrationTest Title', :comment=>'IntegrationTest Text'} }, - {:referer=>blog_path } - follow_redirect! - assert_equal blog_path, path - assert_not_nil blog = assigns(:blogroll) - assert blog.is_a?(SmrBlog) - assert_not blog.empty? - item = blog.each.first - assert item.is_a?(SmrBlogItem) - assert_equal 'Comment', item.type - assert_equal 'IntegrationTest Title', item.title - assert_equal 'IntegrationTest Text', item.body - end -end - + # + # This file is part of SMR. + # + # SMR is free software: you can redistribute it and/or modify it under the + # terms of the GNU General Public License as published by the Free Software + # Foundation, either version 3 of the License, or (at your option) any later + # version. + # + # SMR is distributed in the hope that it will be useful, but WITHOUT ANY + # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + # A PARTICULAR PURPOSE. See the GNU General Public License for more details. + # + # You should have received a copy of the GNU General Public License along with + # SMR. If not, see . + # + require 'test_helper' + require 'digest/sha1' + + module Smr #:nodoc: + ## + # typical user session involving interaction with multiple controllers by doing + # 'every day work'. + # + # HINT: regarding path methods, see + # http://stackoverflow.com/questions/5345757/in-rails-how-to-see-all-the-path-and-url-methods-added-by-railss-routing#5346350 + class Demo1UserSessionTest < ActionDispatch::IntegrationTest + LOGIN = 'demo1' + PASSWORD = 'demo' + + ## + # try to login as demo1 user + test "login as demo1" do + # see if it takes us to the login page + get root_path + assert_response :redirect, 'accessing / should redirect to /login' + follow_redirect! + assert_response :success + assert_equal login_path, path + + smr_login(LOGIN, PASSWORD) + end + + ## + # try to list documents, upload one, download it again, delete it + test "work with documents" do + smr_login(LOGIN, PASSWORD) + + get documents_path + assert_response :success + assert_not_nil assigns(:portfolios) + assert_not_nil assigns(:document) + assert_not_nil docs = assigns(:documents) + assert docs.is_a?(UploadedFiles) + assert docs.empty? + + upload_file = fixture_file_upload('quote.yml', 'text/plain') + upload_sha1 = Digest::SHA1.hexdigest(upload_file.read) + + # upload + post documents_path, + {:action=>'create', :document=>{:comment=>'IntegrationTest', :content=>upload_file}, :id_portfolio=>'' }, + {:html=>{:multipart=>true}, :referer=>documents_path } + assert_response :redirect + follow_redirect! + assert_not_nil docs = assigns(:documents), 'no document shown after upload' + assert docs.is_a?(UploadedFiles) + assert_not docs.empty? + assert d = docs.each.first + assert d.is_a?(UploadedFile) + + # download nothing + get download_path, {}, {'HTTP_REFERER'=>documents_path} + assert_redirected_to documents_path + + # download what we uploaded + get download_path, {:id=>d.id}, {'HTTP_REFERER'=>documents_path} + assert_equal @response.content_type, 'text/plain' + assert_equal Digest::SHA1.hexdigest(@response.body), upload_sha1 + + # delete what we uploaded + get delete_document_path, {:id=>d.id}, {'HTTP_REFERER'=>documents_path} + assert_redirected_to documents_path + end + + ## + # view a open position + test "view open position" do + smr_login(LOGIN, PASSWORD) + + # take second position from asset listing + assert_not_nil asset_position_entry = assigns(:open_positions).second + assert asset_position_entry.is_a?(AssetPosition) + get '%s/%i' % [positions_path, asset_position_entry.id] + assert_response :success + assert_equal position_path, path + assert_not_nil p = assigns(:position) + assert p.is_a?(AssetPosition) + + # position should be open since we took it from open_positions + assert_not p.closed? + assert_not p.is_new? + assert p.invested.is_a?(Float) + assert (p.market_value.is_a?(Float) and p.market_value>0.0) + assert p.profit_loss.is_a?(Float) + assert p.dividend.received.is_a?(Float) + assert (p.charges.is_a?(Float) and p.charges <=0.0) + assert p.gain.is_a?(Float) + + # it must have at least one revision since its open but not new + assert_not_nil first_revision = p.revisions.first + assert first_revision.is_a?(PositionRevision) + assert first_revision.Order.is_a?(Order) + assert_not_equal first_revision.shares, 0.0 + end + + ## + # issue and cancel orders on existing position + # + test "issue orders on existing position" do + smr_login(LOGIN, PASSWORD) + + get new_position_path + assert :success + + # the form should offer option 3: new order in portfolio OR new order + # on existing position + assert_not assigns(:position) + assert_not assigns(:portfolio) + assert open_positions = assigns(:open_positions) + assert stocks = assigns(:stocks) + assert portfolios = assigns(:portfolios) + + # issue new order on existing open position + marker_order1=Digest::SHA1.hexdigest(Time.now.to_s) + post positions_path, + { :action=>'create', :id_position=>open_positions.first.id, + :order_issued=>Time.now, :order_expire=>Time.now+2.days, + :order=>{ :shares=>100, :limit=>6.66, :provision=>1.11, + :expense=>2.22, :courtage=>0.0, + :comment=>marker_order1} + }, + {:html=>{:multipart=>true}, :referer=>positions_path } + assert :success + + get '%s/%i' % [positions_path, open_positions.first.id] + assert :success + assert p = assigns(:position) + assigns p.is_a?(AssetPosition) + assert p.has_pending_orders? + neworder1 = p.pending_orders.where(:comment=>marker_order1).first + assert neworder1.is_a?(Order), 'issued order not found in pending state' + + # issue and execute new order in one step + marker_order2=Digest::SHA1.hexdigest(Time.now.to_s) + post positions_path, + { :action=>'create', :id_position=>open_positions.first.id, + :execute_now=>1, :execute_quote=>7.76, + :order_issued=>Time.now, :order_expire=>Time.now+2.days, + :order=>{ :shares=>200, :limit=>7.77, :provision=>0.00, + :expense=>0.001, :courtage=>3.33, + :comment=>marker_order2} + }, + {:html=>{:multipart=>true}, :referer=>positions_path } + assert :success + + get '%s/%i' % [positions_path, open_positions.first.id] + assert :success + assert p_after_execute = assigns(:position) + assigns p_after_execute.is_a?(AssetPosition) + + assert p_after_execute.revisions.any? {|r| r.Order.comment == marker_order2}, + 'executed new order did not create new revision' + + # check whether executing changed number of shares, invested, etc... in + # position ... and.... well... it should be correctly calculated + assert_equal p.shares + 200, p_after_execute.shares, + 'wrong number of shares in position after order execute' + assert_equal p.invested + 200*7.76, p_after_execute.invested, + 'wrong invested amount in position after order execute' + assert_equal p.charges - 0.001 - 3.33, p_after_execute.charges, + 'wrong charges in position after order execute' + + # cancel issued neworder1 + post cancel_order_path, + { :action=>'cancel_order', :id_order=>neworder1.id}, + {:html=>{:multipart=>true}, :referer=>'%s/%i' % [positions_path, p.id] } + assert_redirected_to '%s/%i' % [positions_path, p.id] + follow_redirect! + assert p_after_cancel = assigns(:position) + assigns p_after_cancel.is_a?(AssetPosition) + assert_nil p_after_cancel.pending_orders.where(:comment=>marker_order1).first, + 'failed to cancel pending order' + end + + ## + # view cashflows from dividends and orders + test "view cashflow" do + smr_login(LOGIN, PASSWORD) + + get cashflow_index_path + assert_response :success + assert_equal cashflow_index_path, path + assert_not_nil cflog = assigns(:log) + assert cflog.is_a?(CashflowLog) + + cflog.each do |i| + assert i.is_a?(CashflowLogItem) + end + end + + ## + # view and add FigureData + test "work with figure data" do + smr_login(LOGIN, PASSWORD) + + get figures_path + assert_response :success + assert_equal figures_path, path + assert assigns(:securities).is_a?(Array) + assert assigns(:selected_security).is_a?(Stock) + assert assigns(:have_data).is_a?(FalseClass) + + # select a security that has figure data + get figures_path, {:id_stock=>25, :show=>'this'} + assert_response :success + assert assigns(:have_data).is_a?(TrueClass) + assert assigns(:datatable).render # Raises if no data + + # add new FigureData + # - no fixtures on BASF AG, therefore nothing should be shown at first + # and then only FigureData we added from here + figure_data = { + :id=>0, # new + :id_stock=>15, # BASF AG + :id_figure_var=>4, # Demo1Var1 + :period=>'year', :time=>1.days.ago.strftime('%Y-%m-%d'), + :analyst=>'integration test', :value=>1.23, :is_expected=>1, + :is_audited=>1, :comment=>'made by demo1 integration test' + } + + get new_figure_path, :id_stock=>figure_data[:id_stock], :show=>'this' + assert_response :success + assert assigns(:figuredata).is_a?(FigureData) + assert assigns(:have_data).is_a?(FalseClass) + post(figures_path, + {:action=>'create', :figure_data=>figure_data}, + {:referer=>figures_path} + ) + follow_redirect! + assert_equal figures_path, path + assert assigns(:selected_security).is_a?(Stock) + assert_equal assigns(:selected_security).id, figure_data[:id_stock] + assert assigns(:have_data).is_a?(TrueClass) + + # try again, but store FigureData on a FigureVar that belongs to + # another user + # note: thats something the Gui does not allow to do + figure_data[:id_figure_var] = 1 # FirstVar, belongs :id_user=1 (admin) + figure_data[:id_stock] = 6 # CocaCola Inc. + get new_figure_path, :id_stock=>figure_data[:id_stock], :show=>'this' + assert_response :success + assert assigns(:have_data).is_a?(FalseClass) + post(figures_path, + {:action=>'create', :figure_data=>figure_data}, + {:referer=>figures_path} + ) + follow_redirect! + assert assigns(:have_data).is_a?(FalseClass) + end + + ## + # use the personal blog + test "read and blog articles" do + smr_login(LOGIN, PASSWORD) + + get blog_path + assert_response :success + assert_equal blog_path, path + assert_not_nil blog = assigns(:blogroll) + assert blog.is_a?(Blog) + assert blog.empty? + + # blog new article + get new_blog_path + assert_response :success + assert_equal new_blog_path, path + assert_not_nil article = assigns(:article) + assert article.is_a?(Comment) + post blog_path, + {:action=>'create', :comment=>{:title=>'IntegrationTest Title', :comment=>'IntegrationTest Text'} }, + {:referer=>blog_path } + follow_redirect! + assert_equal blog_path, path + assert_not_nil blog = assigns(:blogroll) + assert blog.is_a?(Blog) + assert_not blog.empty? + item = blog.each.first + assert item.is_a?(BlogItem) + assert_equal 'Comment', item.type + assert_equal 'IntegrationTest Title', item.title + assert_equal 'IntegrationTest Text', item.body + end + + ## + # use quote records + test "work with quoterecords" do + smr_login(LOGIN, PASSWORD) + + get quoterecords_path + assert_response :success + # FIXME: tbd + end + end + +end # module -- 2.11.4.GIT