init push - laying out the project
This commit is contained in:
15
portal/app/models/account.rb
Normal file
15
portal/app/models/account.rb
Normal file
@ -0,0 +1,15 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: accounts
|
||||
#
|
||||
# id :uuid not null, primary key
|
||||
# name :string
|
||||
# contact_id :uuid not null
|
||||
# subdomain :string not null
|
||||
# style :text
|
||||
# size :bigint default(10000)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class Account < ApplicationRecord
|
||||
end
|
3
portal/app/models/application_record.rb
Normal file
3
portal/app/models/application_record.rb
Normal file
@ -0,0 +1,3 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
self.abstract_class = true
|
||||
end
|
82
portal/app/models/bet.rb
Normal file
82
portal/app/models/bet.rb
Normal file
@ -0,0 +1,82 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: bets
|
||||
#
|
||||
# id :uuid not null, primary key
|
||||
# tip_provider_id :string not null
|
||||
# tip_provider_bet_id :string not null
|
||||
# tip_provider_percent :float not null
|
||||
# exchange_id :string not null
|
||||
# exchange_event_name :string not null
|
||||
# exchange_event_id :string
|
||||
# tip_provider_odds :string
|
||||
# expected_value :float
|
||||
# exchange_bet_id :string
|
||||
# stake :float default(0.0), not null
|
||||
# outcome :string default("processing"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# exchange_account_id :string
|
||||
# tip_provider_event_id :string
|
||||
# exchange_odds :string
|
||||
# team1 :string not null
|
||||
# team2 :string
|
||||
# tip_provider_market_details :jsonb
|
||||
# exchange_market_details :jsonb
|
||||
# log :jsonb
|
||||
# original_json :jsonb
|
||||
# executed_odds :string
|
||||
# placement_attempts :integer default(0)
|
||||
# period :string
|
||||
#
|
||||
class Bet < ApplicationRecord
|
||||
enum outcome: { processing: 'processing', won: 'won', lost: 'lost', open: 'open', expired: 'expired', skipped: 'skipped', ignored: 'ignored', errored: 'errored', voided: 'voided', cancelled: 'cancelled'}
|
||||
scope :placed_bets, -> { where('outcome in (?)', %w[won lost open voided cancelled]) }
|
||||
scope :retryable_bets, -> { where("outcome in ('expired', 'skipped')") }
|
||||
|
||||
default_scope { where(exchange_account_id: ENV['EXCHANGE_ACCOUNT']) }
|
||||
|
||||
belongs_to :exchange_account
|
||||
include Loggable
|
||||
def json_payload
|
||||
{
|
||||
id: id,
|
||||
exchange: exchange_id,
|
||||
exchange_event_name: exchange_event_name,
|
||||
created_at: updated_at,
|
||||
exchange_odds: exchange_odds,
|
||||
tip_provider_odds: tip_provider_odds,
|
||||
expected_value: expected_value&.round(1),
|
||||
stake: stake,
|
||||
outcome: outcome,
|
||||
market_identifier: tip_provider_market_details.to_s,
|
||||
exchange_market_details: exchange_market_details.to_s,
|
||||
bet_placement_type: bet_placement_type,
|
||||
executed_odds: executed_odds,
|
||||
ev_percent: tip_provider_percent.round(2),
|
||||
event_scheduled_time: original_json['started_at'] ? Time.at(original_json['started_at']) : "",
|
||||
log: show_log
|
||||
}
|
||||
end
|
||||
|
||||
def self.calculate_expected_value(stake, odds)
|
||||
(stake * odds) - stake
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def placed_bet?
|
||||
open? || lost? || won? || voided?
|
||||
end
|
||||
|
||||
def bet_placement_type
|
||||
if placed_bet?
|
||||
return 'Simulated' unless exchange_bet_id
|
||||
|
||||
return "Placed [#{exchange_bet_id}]"
|
||||
end
|
||||
''
|
||||
end
|
||||
|
||||
|
||||
end
|
5
portal/app/models/betfair_event.rb
Normal file
5
portal/app/models/betfair_event.rb
Normal file
@ -0,0 +1,5 @@
|
||||
class BetfairEvent < ApplicationRecord
|
||||
has_many :betfair_event_runners, dependent: :delete_all, primary_key: :event_id
|
||||
|
||||
enum status: { open: 'open', closed: 'closed' }
|
||||
end
|
6
portal/app/models/betfair_event_runner.rb
Normal file
6
portal/app/models/betfair_event_runner.rb
Normal file
@ -0,0 +1,6 @@
|
||||
class BetfairEventRunner < ApplicationRecord
|
||||
belongs_to :betfair_event, foreign_key: :event_id
|
||||
has_many :betfair_runner_odds, dependent: :delete_all
|
||||
|
||||
scope :runners_for_open_events, -> { joins("INNER JOIN betfair_events ON betfair_event_runners.event_id = betfair_events.event_id").where(betfair_events: { status: 'open' }) }
|
||||
end
|
4
portal/app/models/betfair_runner_odd.rb
Normal file
4
portal/app/models/betfair_runner_odd.rb
Normal file
@ -0,0 +1,4 @@
|
||||
class BetfairRunnerOdd < ApplicationRecord
|
||||
belongs_to :betfair_event_runner
|
||||
enum bet_type: {back: 'back', lay: 'lay'}
|
||||
end
|
6
portal/app/models/concerns/latest.rb
Normal file
6
portal/app/models/concerns/latest.rb
Normal file
@ -0,0 +1,6 @@
|
||||
module Latest
|
||||
extend ActiveSupport::Concern
|
||||
included do
|
||||
scope :latest, -> { order(created_at: :desc).first }
|
||||
end
|
||||
end
|
218
portal/app/models/exchange_account.rb
Normal file
218
portal/app/models/exchange_account.rb
Normal file
@ -0,0 +1,218 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: exchange_accounts
|
||||
#
|
||||
# id :string not null, primary key
|
||||
# exchange_id :string not null
|
||||
# exchange_name :string not null
|
||||
# login_uid :string not null
|
||||
# login_pass :string not null
|
||||
# apikey :string not null
|
||||
# contact_name :string not null
|
||||
# contact_email :string not null
|
||||
# account_balance :float default(10000.0)
|
||||
# account_balance_last_checked :datetime
|
||||
# ssl_pem :text
|
||||
# allow_multiple_bets_per_event :boolean default(FALSE)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# log :jsonb
|
||||
# betting_enabled :boolean default(FALSE)
|
||||
# last_session_token :string
|
||||
# last_session_token_saved_at :datetime
|
||||
# status :string default("inactive"), not null
|
||||
# stake_strategy :jsonb
|
||||
# last_log_time :datetime
|
||||
#
|
||||
class ExchangeAccount < ApplicationRecord
|
||||
has_many :bets
|
||||
has_many :source_subscriptions, dependent: :delete_all
|
||||
has_many :tip_sources, through: :source_subscriptions
|
||||
has_many :subscription_runs, through: :source_subscriptions
|
||||
enum status: { active: 'active', inactive: 'inactive'}
|
||||
|
||||
DEFAULT_STRATEGY =
|
||||
{
|
||||
max_bankroll_per_bet: 0.2,
|
||||
stake_sizing: 'MINIMUM', #[MINIMUM,FIXED, HYBRID, PROPORTIONAL]
|
||||
min_odds_to_bet: 1.1,
|
||||
max_odds_to_bet: 3.0,
|
||||
min_ev: 2,
|
||||
max_ev: 7,
|
||||
fixed_stake_size: 3.0,
|
||||
odds_margin: 0.1,
|
||||
kelly_multiplier: 0.3,
|
||||
}.freeze
|
||||
|
||||
DEFAULT_HYBRID_STRATEGY =
|
||||
{
|
||||
max_bets: 10,
|
||||
action_on_max_losses: 'REBASE', #[REBASE]
|
||||
pause_in_minutes: 0,
|
||||
rebase_status: :complete,
|
||||
current_stake: 0.0,
|
||||
last_account_balance: nil,
|
||||
account_balance_checked_at: nil,
|
||||
max_losses: 0.1
|
||||
}.freeze
|
||||
|
||||
def json_payload
|
||||
{
|
||||
id: id,
|
||||
exchange_name: exchange_name,
|
||||
exchange_id: exchange_id,
|
||||
contact_name: contact_name,
|
||||
contact_email: contact_email,
|
||||
account_balance: account_balance,
|
||||
is_active: active?,
|
||||
can_bet: can_bet?,
|
||||
stake_strategy_name: stake_strategy_description,
|
||||
log: subscription_runs.empty? ? nil : last_run.log,
|
||||
last_log_time: subscription_runs.empty? ? updated_at : last_run.created_at,
|
||||
stake_strategy_config: current_stake_strategy,
|
||||
source_types: tip_sources.empty? ? 'None' : tip_sources.pluck(:source_type).join(' , ')
|
||||
}
|
||||
end
|
||||
|
||||
def last_run
|
||||
subscription_runs&.latest
|
||||
end
|
||||
|
||||
def self.mounted_account
|
||||
find_by_id(ENV['EXCHANGE_ACCOUNT'])
|
||||
end
|
||||
|
||||
def self.mounted_account_payload
|
||||
return {} unless mounted_account
|
||||
|
||||
mounted_account.json_payload
|
||||
end
|
||||
|
||||
def optimal_stake(bet, exchange_minimum, stake_available)
|
||||
return exchange_minimum if minimum_stake?
|
||||
|
||||
x = [stake_by_strategy(bet).round(1), stake_available].min
|
||||
|
||||
[x, exchange_minimum].max
|
||||
end
|
||||
|
||||
# # account_balance will ultimately come from accounts model
|
||||
def actual_account_balance
|
||||
return account_balance if betting_enabled
|
||||
|
||||
simulated_account_balance
|
||||
end
|
||||
|
||||
def simulated_account_balance
|
||||
account_balance + total_won - (total_lost + total_open)
|
||||
end
|
||||
|
||||
def total_won(subset = my_bets)
|
||||
subset.won.sum(:expected_value).round(1)
|
||||
end
|
||||
|
||||
def total_lost(subset = my_bets)
|
||||
subset.lost.sum(:stake).round(1)
|
||||
end
|
||||
|
||||
def total_open(subset = my_bets)
|
||||
subset.open.sum(:stake).round(1)
|
||||
end
|
||||
|
||||
def can_bet?
|
||||
return false unless betting_enabled
|
||||
|
||||
actual_account_balance > total_open
|
||||
end
|
||||
|
||||
def my_bets
|
||||
Bet.unscoped.where(exchange_account_id: id)
|
||||
end
|
||||
|
||||
def current_stake_strategy
|
||||
if stake_strategy.blank?
|
||||
update(stake_strategy: DEFAULT_STRATEGY)
|
||||
end
|
||||
@current_stake_strategy ||= stake_strategy.with_indifferent_access
|
||||
end
|
||||
|
||||
def minimum_stake?
|
||||
stake_strategy_description == 'MINIMUM'
|
||||
end
|
||||
|
||||
def stake_strategy_description
|
||||
return current_stake_strategy[:stake_sizing] unless current_stake_strategy[:stake_sizing].blank?
|
||||
|
||||
'MINIMUM'
|
||||
end
|
||||
|
||||
def stake_by_strategy(bet)
|
||||
return classic_kelly_stake_sizing(bet) if current_stake_strategy[:stake_sizing] == 'KELLY'
|
||||
return proportional_stake if current_stake_strategy[:stake_sizing] == 'PROPORTIONAL'
|
||||
return current_stake_strategy[:fixed_stake_size] if current_stake_strategy[:stake_sizing] == 'FIXED'
|
||||
return hybrid_stake_sizing if current_stake_strategy[:stake_sizing] == 'HYBRID'
|
||||
|
||||
0
|
||||
end
|
||||
|
||||
# def kelly_stake_sizing(bet)
|
||||
# proportional = proportional_stake
|
||||
# multiplier = current_stake_strategy[:kelly_multiplier] || 0.3
|
||||
# kelly_factor = bet.tip_provider_percent * multiplier
|
||||
# stake = kelly_factor * proportional
|
||||
#
|
||||
# return 0 if stake.negative?
|
||||
#
|
||||
# [stake, proportional].min
|
||||
# end
|
||||
|
||||
def classic_kelly_stake_sizing(bet)
|
||||
multiplier = current_stake_strategy[:kelly_multiplier] || 0.3
|
||||
max_per_bet = proportional_stake
|
||||
kelly(odds: bet.executed_odds.to_f, probs: bet.tip_provider_percent, balance: actual_account_balance, multiplier: multiplier, per_bet_max: max_per_bet)
|
||||
end
|
||||
|
||||
def kelly(odds:, probs:, balance: , multiplier:, per_bet_max:)
|
||||
kelly_stake_percent = (((odds * probs) -1) / (odds - 1 ) * multiplier)/100
|
||||
stake = kelly_stake_percent * balance
|
||||
|
||||
return 0 if stake.negative?
|
||||
|
||||
[per_bet_max, stake].min
|
||||
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def hybrid_stake_sizing
|
||||
hybrid_strategy = current_stake_strategy[:hybrid] || DEFAULT_HYBRID_STRATEGY.clone
|
||||
last_based_account_balance = hybrid_strategy[:last_account_balance]
|
||||
last_based_account_balance ||= rebase_hybrid_strategy
|
||||
|
||||
max_losses = hybrid_strategy[:max_losses]
|
||||
max_allowable_losses = last_based_account_balance * max_losses
|
||||
|
||||
difference = actual_account_balance - last_based_account_balance
|
||||
if difference.abs >= max_allowable_losses
|
||||
rebase_hybrid_strategy
|
||||
end
|
||||
current_stake_strategy[:hybrid][:current_stake]
|
||||
# raise "[Bet sizing] Betting on '#{id}' paused due to stake management rules"
|
||||
end
|
||||
|
||||
def rebase_hybrid_strategy
|
||||
puts 'Rebasing hybrid stake because account balances have gained/loss at least the max'
|
||||
hybrid = current_stake_strategy[:hybrid] || DEFAULT_HYBRID_STRATEGY.clone(freeze: false)
|
||||
hybrid[:last_account_balance] = actual_account_balance
|
||||
hybrid[:current_stake] = ((actual_account_balance * hybrid[:max_losses]) / hybrid[:max_bets]).round(2)
|
||||
hybrid[:account_balance_checked_at] = Time.now
|
||||
current_stake_strategy[:hybrid] = hybrid
|
||||
update(stake_strategy: current_stake_strategy)
|
||||
hybrid[:current_stake]
|
||||
end
|
||||
|
||||
def proportional_stake
|
||||
(current_stake_strategy[:max_bankroll_per_bet] * actual_account_balance)
|
||||
end
|
||||
|
||||
end
|
20
portal/app/models/loggable.rb
Normal file
20
portal/app/models/loggable.rb
Normal file
@ -0,0 +1,20 @@
|
||||
module Loggable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def set_log(s)
|
||||
log_this(s, false)
|
||||
end
|
||||
|
||||
def log_this(s, append = true)
|
||||
x = append ? log || [] : []
|
||||
x << "[#{Time.now}]: #{s}"
|
||||
update(log: x)
|
||||
end
|
||||
|
||||
def show_log
|
||||
x = log || []
|
||||
return 'Nothing logged' if x.length.zero?
|
||||
|
||||
x.join(',')
|
||||
end
|
||||
end
|
17
portal/app/models/source_subscription.rb
Normal file
17
portal/app/models/source_subscription.rb
Normal file
@ -0,0 +1,17 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: source_subscriptions
|
||||
#
|
||||
# id :uuid not null, primary key
|
||||
# exchange_account_id :string
|
||||
# tip_source_id :uuid
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class SourceSubscription < ApplicationRecord
|
||||
belongs_to :tip_source, dependent: :delete
|
||||
belongs_to :exchange_account
|
||||
|
||||
has_many :subscription_runs
|
||||
has_many :tip_source_data, through: :tip_source
|
||||
end
|
17
portal/app/models/subscription_run.rb
Normal file
17
portal/app/models/subscription_run.rb
Normal file
@ -0,0 +1,17 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: subscription_runs
|
||||
#
|
||||
# id :uuid not null, primary key
|
||||
# source_subscription_id :uuid
|
||||
# tip_source_data_id :uuid
|
||||
# log :jsonb
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class SubscriptionRun < ApplicationRecord
|
||||
include Loggable
|
||||
include Latest
|
||||
belongs_to :tip_source_data
|
||||
belongs_to :source_subscription
|
||||
end
|
22
portal/app/models/tip_source.rb
Normal file
22
portal/app/models/tip_source.rb
Normal file
@ -0,0 +1,22 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: tip_sources
|
||||
#
|
||||
# id :uuid not null, primary key
|
||||
# tipster_account_id :string
|
||||
# description :string
|
||||
# source_type :string
|
||||
# active :boolean default(FALSE)
|
||||
# filters :jsonb
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class TipSource < ApplicationRecord
|
||||
belongs_to :tipster_account, dependent: :delete
|
||||
has_many :source_subscriptions, dependent: :delete_all
|
||||
has_many :tip_source_data, class_name: 'TipSourceData'
|
||||
has_many :subscription_runs, through: :source_subscriptions
|
||||
|
||||
scope :active_sources, -> { where(active: true)}
|
||||
|
||||
end
|
15
portal/app/models/tip_source_data.rb
Normal file
15
portal/app/models/tip_source_data.rb
Normal file
@ -0,0 +1,15 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: tip_source_data
|
||||
#
|
||||
# id :uuid not null, primary key
|
||||
# tip_source_id :uuid
|
||||
# data :jsonb
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class TipSourceData < ApplicationRecord
|
||||
include Latest
|
||||
belongs_to :tip_source, dependent: :delete
|
||||
has_many :source_subscriptions
|
||||
end
|
18
portal/app/models/tipster_account.rb
Normal file
18
portal/app/models/tipster_account.rb
Normal file
@ -0,0 +1,18 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: tipster_accounts
|
||||
#
|
||||
# id :string not null, primary key
|
||||
# tipster_name :string not null
|
||||
# apikey :string not null
|
||||
# filter_ids :string not null
|
||||
# contact_name :string not null
|
||||
# contact_email :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class TipsterAccount < ApplicationRecord
|
||||
has_many :tip_sources
|
||||
has_many :tip_source_data, through: :tip_sources, class_name: 'TipSourceData'
|
||||
has_many :tip_source_data_processing_runs, through: :tip_source_data
|
||||
end
|
82
portal/app/models/user.rb
Normal file
82
portal/app/models/user.rb
Normal file
@ -0,0 +1,82 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: users
|
||||
#
|
||||
# id :uuid not null, primary key
|
||||
# first_name :string
|
||||
# last_name :string
|
||||
# email :string default(""), not null
|
||||
# encrypted_password :string default(""), not null
|
||||
# reset_password_token :string
|
||||
# reset_password_sent_at :datetime
|
||||
# remember_created_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# uid :string
|
||||
# provider :string
|
||||
#
|
||||
class User < ApplicationRecord
|
||||
# Include default devise modules. Others available are:
|
||||
# :confirmable, :lockable, :timeoutable, :trackable and
|
||||
devise :database_authenticatable, :registerable, :omniauthable
|
||||
|
||||
|
||||
def name
|
||||
"#{first_name} #{last_name}"
|
||||
end
|
||||
|
||||
def self.find_or_create_from_auth_hash(auth)
|
||||
user = where(email: auth.info.email).first
|
||||
|
||||
unless user.present?
|
||||
user = where(provider: auth.provider, uid: auth.uid).first_or_initialize
|
||||
user.email = auth.info.email
|
||||
user.password = Devise.friendly_token.first(8)
|
||||
end
|
||||
|
||||
unless user.provider.present?
|
||||
user.provider = auth.provider
|
||||
user.uid = auth.uid
|
||||
end
|
||||
|
||||
user.first_name = auth.info.first_name
|
||||
user.last_name = auth.info.last_name
|
||||
|
||||
user.save!
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def fullname
|
||||
"#{first_name} #{last_name}"
|
||||
end
|
||||
|
||||
def confirmation_token
|
||||
if read_attribute(:confirmation_token).nil?
|
||||
self.confirmation_token = generate_confirmation_token
|
||||
save!
|
||||
end
|
||||
read_attribute(:confirmation_token)
|
||||
end
|
||||
|
||||
def json_payload
|
||||
{
|
||||
id: id,
|
||||
email: email,
|
||||
first_name: first_name,
|
||||
last_name: last_name,
|
||||
}
|
||||
end
|
||||
|
||||
def admin?
|
||||
%w[mike@wizewerx.com].include?(email)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_confirmation_token
|
||||
token = Devise.friendly_token(10)
|
||||
token = Devise.friendly_token(10) while User.where(confirmation_token: token).count.positive?
|
||||
self.confirmation_token = token
|
||||
end
|
||||
end
|
Reference in New Issue
Block a user