stockcruncher 1.0.1 → 1.2.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82cc9e3595d2f91bded92cbbf001fc8fb76ff3bd0d4604361c24f78c3ef6da01
4
- data.tar.gz: 510435224f9e7362f51f692c3b6634d7372adae0db724d9d9545f3d869b53bfa
3
+ metadata.gz: e4729948dfb9635323240f73d947c2257b1ca4b8a6c2a065a812a8726f502599
4
+ data.tar.gz: 3686ad0bbb61ef2a3801a2a2c1b3258b543da3015c49b2c959570c9d7b0e8a3a
5
5
  SHA512:
6
- metadata.gz: 4a4e42a9068eabbbd9b5da44212d39c98aebce85c2b36add25f5da51853bbb00117cd9d6783d5f3cb2b35789eba7c917fe9020198de54e9bd9c45eff49221a0c
7
- data.tar.gz: 191d02a244a4fe2b575a7252824c2c510140072b56f1ea9791ddd62b94da5fda784046f246a812c4feb141e66196c711bfdfa71c951f0c8cac33409c4dacea31
6
+ metadata.gz: 24120c82f3e7cd9d622bcf1636ee528ea69d170fe0f60951b33b34754870a1b92837d5f2cbcd3f59ab4be85264195e7c2058036a557e8b486c8fe55cf81e5c18
7
+ data.tar.gz: e849aea61ae75175742d97611cffdc889aa3c95394aec2cc8ccbe93ed35bafc6958984fb0fc92b08190a41003c183b753ad287a51676990eee855d32f43bfceb
data/CHANGELOG CHANGED
@@ -5,3 +5,26 @@ Changelog
5
5
  -----
6
6
 
7
7
  - Initial version
8
+
9
+ 1.1.0
10
+ -----
11
+
12
+ - Add InfluxDB as database.
13
+ - WIP for daily time serie.
14
+
15
+ 1.1.1
16
+ -----
17
+
18
+ - Rescue error on no data
19
+
20
+ 1.2.0
21
+ -----
22
+
23
+ - daily subcommand now recalculates missing data
24
+ - daily subcommand also writes data to database
25
+
26
+ 1.2.1
27
+ -----
28
+
29
+ - Refactor to preprocess data in source class
30
+ - Refactor to print pretty json instead of comma separated striing
data/README.md CHANGED
@@ -25,29 +25,38 @@ to write the data in a database for later calculation and use.
25
25
 
26
26
  $ gem install stockcruncher
27
27
  $ mkdir /etc/stockcruncher && cd /etc/stockcruncher
28
- $ echo $'AlphaVantage:\n apikey: CHANGEME' > stockcruncher.yml
28
+ $ echo 'AlphaVantage:' > stockcruncher.yml
29
+ $ echo ' apikey: CHANGEME' >> stockcruncher.yml
30
+ $ echo 'InfluxDB:' >> stockcruncher.yml
31
+ $ echo ' scheme: http' >> stockcruncher.yml
32
+ $ echo ' host: localhost' >> stockcruncher.yml
33
+ $ echo ' port: 8086' >> stockcruncher.yml
34
+ $ echo ' user: CHANGEMEAGAIN' >> stockcruncher.yml
35
+ $ echo ' password: CHANGEMETOO' >> stockcruncher.yml
36
+ $ echo ' dbname: stock' >> stockcruncher.yml
29
37
 
30
38
  ## Usage
31
39
 
32
40
  An interactive help is available with:
33
41
 
34
- $ stockcruncher help
42
+ $ stockcruncher help [subcommand]
35
43
 
36
44
  ## Examples
37
45
 
38
46
  To get daily time serie data of a symbol:
39
47
 
40
- $ stockcruncher crunch AAPL daily
48
+ $ stockcruncher daily AAPL
41
49
 
42
50
  To get last day endpoint data of a symbol:
43
51
 
44
- $ stockcruncher crunch AAPL quote_endpoint
52
+ $ stockcruncher quote AAPL
45
53
 
46
54
  ## Limitations
47
55
 
48
56
  Data are currently scraped from AlphaVantage API.
49
57
  More source could be great especially because AlphaVantage doesn't provide EU
50
58
  intraday data.
59
+ InfluxDB is used as database to keep time series data.
51
60
 
52
61
  ## Development
53
62
 
@@ -10,9 +10,9 @@ module StockCruncher
10
10
 
11
11
  def start(args = ARGV)
12
12
  StockCruncher::CLI.start(args)
13
- rescue NotImplementedError => e
13
+ rescue StandardError => e
14
14
  warn e.message
15
- exit(3)
15
+ exit 1
16
16
  end
17
17
  end
18
18
  end
@@ -7,35 +7,98 @@ module StockCruncher
7
7
  # This is an data cruncher class for AlphaVantage API.
8
8
  class AlphaVantage < Cruncher
9
9
  API_URL = 'https://www.alphavantage.co/query?'
10
- SERIE = {
11
- 'daily' => 'TIME_SERIES_DAILY',
12
- 'quote_endpoint' => 'GLOBAL_QUOTE'
13
- }.freeze
10
+
11
+ # Method to calculate missing data (previousClose, change, changePercent)
12
+ def calculate_missing_data(hash)
13
+ keys = hash.keys
14
+ hash.each_with_index do |(date, v), index|
15
+ prevday = keys[index + 1]
16
+ next if prevday.nil?
17
+
18
+ prevclose = hash[prevday]['close']
19
+ hash[date] = v.merge(generate_missing_data(v['close'], prevclose))
20
+ end
21
+ hash
22
+ end
23
+
24
+ # Method to calculate change difference
25
+ def change(value, base)
26
+ (value - base).round(4).to_s
27
+ end
28
+
29
+ # Method to calculate percentage of change
30
+ def change_percent(value, base)
31
+ ((value / base - 1) * 100).round(4).to_s
32
+ end
33
+
34
+ # Method to create a new hash from two arrays of keys and values
35
+ def create_hash(descriptions, values)
36
+ descriptions.split(',').zip(values.split(',')).to_h
37
+ end
14
38
 
15
39
  # Main method to crunch data.
16
- def crunch(symbol, serie, opts)
17
- err_msg = "#{serie} does not exist."
18
- raise NotImplementedError, err_msg unless SERIE.key?(serie)
40
+ def crunch_daily(symbol, fullsize)
41
+ url = API_URL + parameters(symbol, 'TIME_SERIES_DAILY')
42
+ url += '&datatype=csv&outputsize=' + (fullsize ? 'full' : 'compact')
43
+ res = request(url)
44
+ transform_daily(res.body)
45
+ end
19
46
 
20
- url = API_URL + parameters(symbol, serie) + options(serie, opts)
47
+ # Main method to crunch data.
48
+ def crunch_quote(symbol)
49
+ url = API_URL + parameters(symbol, 'GLOBAL_QUOTE')
50
+ url += '&datatype=csv'
21
51
  res = request(url)
22
- puts res.body
52
+ transform_quote(res.body)
23
53
  end
24
54
 
25
- def options(serie, opts)
26
- o = '&datatype=' + (opts['json'] ? 'json' : 'csv')
27
- if serie == 'daily'
28
- o += '&outputsize='
29
- o += opts['full'] ? 'full' : 'compact'
30
- end
31
- o
55
+ # Method to generate missing data
56
+ def generate_missing_data(current, previous)
57
+ {
58
+ 'previousClose' => previous,
59
+ 'change' => change(current.to_f, previous.to_f),
60
+ 'changePercent' => change_percent(current.to_f, previous.to_f)
61
+ }
32
62
  end
33
63
 
64
+ # Set parameters of api call
34
65
  def parameters(symbol, serie)
35
- p = 'function=' + SERIE[serie]
66
+ p = 'function=' + serie
36
67
  p += '&symbol=' + symbol
37
68
  p += '&apikey=' + @config[self.class.name.split('::').last]['apikey']
38
69
  p
39
70
  end
71
+
72
+ # Method to transform raw data to constructed hash
73
+ def prepare_daily_timeserie(data)
74
+ lines = data.split("\r\n")
75
+ desc = lines.shift.split(',').drop(1)
76
+ hash = {}
77
+ lines.each do |line|
78
+ values = line.split(',')
79
+ date = values.shift
80
+ hash[date] = desc.zip(values).to_h
81
+ end
82
+ hash
83
+ end
84
+
85
+ # Method to transform daily result to nested hash
86
+ def transform_daily(rawdata)
87
+ raise StandardError, 'No data' if rawdata.match?(/Error Message/)
88
+
89
+ values = prepare_daily_timeserie(rawdata)
90
+ values = calculate_missing_data(values)
91
+ values
92
+ end
93
+
94
+ # Method to transform quote result to hash
95
+ def transform_quote(rawdata)
96
+ raise StandardError, 'No data' if rawdata.match?(/{}/)
97
+
98
+ values = create_hash(*rawdata.split("\r\n"))
99
+ values['close'] = values.delete('price')
100
+ values['changePercent'] = values['changePercent'].delete('%')
101
+ values
102
+ end
40
103
  end
41
104
  end
@@ -1,20 +1,14 @@
1
1
  #!/usr/bin/ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ require 'json'
4
5
  require 'thor'
6
+ require 'yaml'
5
7
 
6
8
  module StockCruncher
7
9
  # Simple CLI for StockCruncher
8
10
  class CLI < Thor
9
- desc 'version', 'Print stockcruncher current version'
10
- def version
11
- puts "StockCruncher version #{StockCruncher::VERSION}"
12
- end
13
-
14
- desc('crunch SYMBOL TIMESERIES [options]',
15
- 'Crunch SYMBOL stock market data for time series.' \
16
- 'Possible timeseries: daily, quote_endpoint.')
17
- option(
11
+ class_option(
18
12
  :config,
19
13
  aliases: ['-c'],
20
14
  type: :string,
@@ -22,13 +16,28 @@ module StockCruncher
22
16
  desc: 'Yaml formatted config file to load ' \
23
17
  '(default to /etc/stockcruncher/stockcruncher.yml).'
24
18
  )
25
- option(
26
- :json,
27
- aliases: ['-j'],
19
+ class_option(
20
+ :insecure,
21
+ aliases: ['-k'],
22
+ type: :boolean,
23
+ default: false,
24
+ desc: 'Ignore SSL certificate (default to false).'
25
+ )
26
+ class_option(
27
+ :quiet,
28
+ aliases: ['-q'],
28
29
  type: :boolean,
29
30
  default: false,
30
- desc: 'Json format data (default to csv).'
31
+ desc: 'Run silently (default to false).'
31
32
  )
33
+
34
+ desc 'version', 'Print stockcruncher current version'
35
+ def version
36
+ puts "StockCruncher version #{StockCruncher::VERSION}"
37
+ end
38
+
39
+ desc('daily SYMBOL [options]',
40
+ 'Crunch SYMBOL stock market data for daily time series.')
32
41
  option(
33
42
  :full,
34
43
  aliases: ['-f'],
@@ -36,10 +45,24 @@ module StockCruncher
36
45
  default: false,
37
46
  desc: 'Full data size.'
38
47
  )
39
- def crunch(symbol, timeserie)
48
+ def daily(symbol)
49
+ opts = options.dup
50
+ config = YAML.load_file(opts['config'])
51
+ cruncher = StockCruncher::AlphaVantage.new(config, opts['insecure'])
52
+ data = cruncher.crunch_daily(symbol, opts['full'])
53
+ StockCruncher::InfluxDB.new(config).export_history(symbol, data)
54
+ puts JSON.pretty_generate(data) unless opts['quiet']
55
+ end
56
+
57
+ desc('quote SYMBOL [options]',
58
+ 'Crunch SYMBOL stock market data for last day quote.')
59
+ def quote(symbol)
40
60
  opts = options.dup
41
- cruncher = StockCruncher::AlphaVantage.new(opts['config'])
42
- cruncher.crunch(symbol, timeserie, opts)
61
+ config = YAML.load_file(opts['config'])
62
+ cruncher = StockCruncher::AlphaVantage.new(config, opts['insecure'])
63
+ data = cruncher.crunch_quote(symbol)
64
+ StockCruncher::InfluxDB.new(config).export_last_day(data)
65
+ puts JSON.pretty_generate(data) unless opts['quiet']
43
66
  end
44
67
  end
45
68
  end
@@ -2,27 +2,22 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'net/http'
5
- require 'yaml'
6
5
 
7
6
  module StockCruncher
8
7
  # This is an data cruncher abstract class.
9
8
  class Cruncher
10
- # Class abstract constructor method
11
- def initialize(file)
12
- @config = load_conf(file)
13
- end
14
-
15
- # Method to load configurations described in config_file.
16
- def load_conf(file)
17
- YAML.load_file(file)
9
+ # Class constructor method
10
+ def initialize(config, insecure = false)
11
+ @config = config
12
+ @insecure = insecure
18
13
  end
19
14
 
20
15
  # Method to send http get request
21
- def request(url, insecure = false)
16
+ def request(url)
22
17
  uri = URI.parse(url)
23
18
  http = Net::HTTP.new(uri.host, uri.port)
24
19
  http.use_ssl = uri.scheme.eql?('https')
25
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE if insecure
20
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @insecure
26
21
  req = Net::HTTP::Get.new(uri.request_uri)
27
22
  http.request(req)
28
23
  end
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'date'
5
+ require 'net/http'
6
+
7
+ module StockCruncher
8
+ # this is a class to write time series to database
9
+ class InfluxDB
10
+ # Class constructor method
11
+ def initialize(config, insecure = false)
12
+ @cfg = config[self.class.name.split('::').last]
13
+ @insecure = insecure
14
+ end
15
+
16
+ # Method to export latest data to database
17
+ def export_last_day(values)
18
+ tags = { 'symbol' => values.delete('symbol') }
19
+ date = values.delete('latestDay')
20
+ write('daily', tags, values, date)
21
+ end
22
+
23
+ # Method to export historical data to database
24
+ def export_history(symbol, timeseries)
25
+ tags = { 'symbol' => symbol }
26
+ timeseries.each_pair do |date, values|
27
+ write('daily', tags, values, date)
28
+ end
29
+ end
30
+
31
+ # Method to format and array of values into comma separated string
32
+ def format_values(values)
33
+ string = ''
34
+ values.each_pair do |k, v|
35
+ string += "#{k}=#{v}"
36
+ string += ',' unless k == values.keys.last
37
+ end
38
+ string
39
+ end
40
+
41
+ # Method to send http post request
42
+ def request(url, body)
43
+ uri = URI.parse(url)
44
+ http = Net::HTTP.new(uri.host, uri.port)
45
+ http.use_ssl = uri.scheme.eql?('https')
46
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @insecure
47
+ req = Net::HTTP::Post.new(uri.request_uri)
48
+ req.basic_auth(@cfg['user'], @cfg['password'])
49
+ req.body = body
50
+ http.request(req)
51
+ end
52
+
53
+ # Method to write data in bucket
54
+ def write(name, tags, values, date)
55
+ url = "#{@cfg['scheme']}://#{@cfg['host']}:#{@cfg['port']}/write?" \
56
+ "db=#{@cfg['dbname']}"
57
+ timestamp = DateTime.parse(date + 'T18:00:00').strftime('%s%N')
58
+ body = "#{name},#{format_values(tags)} #{format_values(values)} " \
59
+ "#{timestamp}"
60
+ request(url, body)
61
+ end
62
+ end
63
+ end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module StockCruncher
5
- VERSION = '1.0.1'
5
+ VERSION = '1.2.1'
6
6
  end
@@ -0,0 +1,49 @@
1
+ {
2
+ "2020-08-03": {
3
+ "open": "23.8500",
4
+ "high": "24.6500",
5
+ "low": "23.7400",
6
+ "close": "24.5500",
7
+ "volume": "3112972",
8
+ "previousClose": "23.8100",
9
+ "change": "0.74",
10
+ "changePercent": "3.1079"
11
+ },
12
+ "2020-07-31": {
13
+ "open": "24.0100",
14
+ "high": "24.5100",
15
+ "low": "23.5900",
16
+ "close": "23.8100",
17
+ "volume": "5482485",
18
+ "previousClose": "23.6600",
19
+ "change": "0.15",
20
+ "changePercent": "0.634"
21
+ },
22
+ "2020-07-30": {
23
+ "open": "24.4700",
24
+ "high": "24.6600",
25
+ "low": "23.5200",
26
+ "close": "23.6600",
27
+ "volume": "6466738",
28
+ "previousClose": "24.5600",
29
+ "change": "-0.9",
30
+ "changePercent": "-3.6645"
31
+ },
32
+ "2020-07-29": {
33
+ "open": "24.7500",
34
+ "high": "25.0300",
35
+ "low": "24.0400",
36
+ "close": "24.5600",
37
+ "volume": "4605804",
38
+ "previousClose": "24.7500",
39
+ "change": "-0.19",
40
+ "changePercent": "-0.7677"
41
+ },
42
+ "2020-07-28": {
43
+ "open": "25.9900",
44
+ "high": "26.0700",
45
+ "low": "24.5100",
46
+ "close": "24.7500",
47
+ "volume": "6904261"
48
+ }
49
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "open": "100.0000",
3
+ "high": "100.1000",
4
+ "low": "99.9000",
5
+ "volume": "4",
6
+ "previousClose": "100.0000",
7
+ "change": "0.0000",
8
+ "changePercent": "0.0000",
9
+ "close": "100.0000"
10
+ }
@@ -1,2 +1,9 @@
1
1
  AlphaVantage:
2
2
  apikey: demo
3
+ InfluxDB:
4
+ scheme: http
5
+ host: localhost
6
+ port: 8086
7
+ user: testuser
8
+ password: testpassword
9
+ dbname: test
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
3
4
  require 'spec_helper'
4
5
 
5
- describe StockCruncher::CLI do
6
+ daily = File.read('spec/files/SYM.daily')
7
+ quote = File.read('spec/files/SYM.quote')
8
+
9
+ describe StockCruncher::CLI do # rubocop:disable Metrics/BlockLength
6
10
  context 'version' do
7
11
  it 'prints the version.' do
8
12
  out = "StockCruncher version #{StockCruncher::VERSION}\n"
@@ -10,9 +14,27 @@ describe StockCruncher::CLI do
10
14
  end
11
15
  end
12
16
 
13
- context 'crunch SYM quote_endpoint -c spec/files/stockcruncher.yml' do
17
+ context 'daily NODATA -c spec/files/stockcruncher.yml' do
18
+ it 'Should not get any data and should fail.' do
19
+ expect { start(self) }.to raise_error(SystemExit)
20
+ end
21
+ end
22
+
23
+ context 'daily SYM -c spec/files/stockcruncher.yml' do
24
+ it 'Get the daily time serie for SYM.' do
25
+ expect { start(self) }.to output(daily).to_stdout
26
+ end
27
+ end
28
+
29
+ context 'quote NODATA -c spec/files/stockcruncher.yml' do
30
+ it 'Should not get any data and should fail.' do
31
+ expect { start(self) }.to raise_error(SystemExit)
32
+ end
33
+ end
34
+
35
+ context 'quote SYM -c spec/files/stockcruncher.yml' do
14
36
  it 'Get the quote for SYM.' do
15
- expect { start(self) }.to output("{}\n").to_stdout
37
+ expect { start(self) }.to output(quote).to_stdout
16
38
  end
17
39
  end
18
40
 
@@ -2,15 +2,35 @@
2
2
 
3
3
  require 'spec_helper'
4
4
 
5
+ quote = 'symbol,open,high,low,price,volume,latestDay,previousClose,change,ch' \
6
+ "angePercent\r\nSYM,100.0000,100.1000,99.9000,100.0000,4,2020-07-30," \
7
+ "100.0000,0.0000,0.0000%\r\n"
8
+ q_err = '{}'
9
+ daily = "timestamp,open,high,low,close,volume\r\n2020-08-03,23.8500,24.6500," \
10
+ "23.7400,24.5500,3112972\r\n2020-07-31,24.0100,24.5100,23.5900,23.81" \
11
+ "00,5482485\r\n2020-07-30,24.4700,24.6600,23.5200,23.6600,6466738\r" \
12
+ "\n2020-07-29,24.7500,25.0300,24.0400,24.5600,4605804\r\n2020-07-28," \
13
+ "25.9900,26.0700,24.5100,24.7500,6904261\r\n"
14
+ d_err = "{\n \"Error Message\": \"Invalid API call.\"\n}"
15
+
5
16
  RSpec.configure do |config|
6
17
  config.before(:each) do
7
18
  # requests an API without extra arguments
19
+ stub_request(:get, 'https://www.alphavantage.co/query?' \
20
+ 'function=GLOBAL_QUOTE&symbol=NODATA&apikey=demo&datatype=csv')
21
+ .to_return('status' => 200, 'body' => q_err, 'headers' => {})
8
22
  stub_request(:get, 'https://www.alphavantage.co/query?' \
9
23
  'function=GLOBAL_QUOTE&symbol=SYM&apikey=demo&datatype=csv')
10
- .to_return('status' => 200, 'body' => '{}', 'headers' => {})
24
+ .to_return('status' => 200, 'body' => quote, 'headers' => {})
25
+ stub_request(:get, 'https://www.alphavantage.co/query?' \
26
+ 'function=TIME_SERIES_DAILY&symbol=NODATA&apikey=demo' \
27
+ '&datatype=csv&outputsize=compact')
28
+ .to_return('status' => 200, 'body' => d_err, 'headers' => {})
11
29
  stub_request(:get, 'https://www.alphavantage.co/query?' \
12
30
  'function=TIME_SERIES_DAILY&symbol=SYM&apikey=demo' \
13
31
  '&datatype=csv&outputsize=compact')
14
- .to_return('status' => 200, 'body' => '{}', 'headers' => {})
32
+ .to_return('status' => 200, 'body' => daily, 'headers' => {})
33
+ stub_request(:post, 'http://localhost:8086/write?db=test')
34
+ .to_return('status' => 204, 'body' => '', 'headers' => {})
15
35
  end
16
36
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stockcruncher
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Richard Delaplace
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-07-27 00:00:00.000000000 Z
11
+ date: 2020-08-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -130,10 +130,12 @@ files:
130
130
  - lib/stockcruncher/alphavantage.rb
131
131
  - lib/stockcruncher/cli.rb
132
132
  - lib/stockcruncher/cruncher.rb
133
+ - lib/stockcruncher/influxdb.rb
133
134
  - lib/stockcruncher/version.rb
135
+ - spec/files/SYM.daily
136
+ - spec/files/SYM.quote
134
137
  - spec/files/stockcruncher.yml
135
138
  - spec/spec_helper.rb
136
- - spec/stockcruncher/alphavantage_spec.rb
137
139
  - spec/stockcruncher/cli_spec.rb
138
140
  - spec/stockcruncher/stubs/servers_stubs.rb
139
141
  - spec/stockcruncher_spec.rb
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
-
5
- describe StockCruncher::AlphaVantage do
6
- context 'crunch SYM daily -c spec/files/stockcruncher.yml' do
7
- it 'requests a source and get the result.' do
8
- expect { start(self) }.to output("{}\n").to_stdout
9
- end
10
- end
11
- context 'crunch SYM weekly -c spec/files/stockcruncher.yml' do
12
- it 'requests a not implemented time serie.' do
13
- expect { start(self) }.to raise_error(SystemExit)
14
- end
15
- end
16
- end