jumbotron 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/adapters/jumbotron/adapters/espn/nfl.rb +15 -0
- data/app/adapters/jumbotron/adapters/espn/nfl_helpers/scoreboard.rb +14 -2
- data/app/adapters/jumbotron/adapters/espn/nfl_helpers/teams.rb +2 -1
- data/app/adapters/jumbotron/adapters/sandbox/nfl.rb +135 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/acquisition_scope.rb +74 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/competition_odds.rb +82 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/current_observation_state.rb +53 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/line_acquisition_scope.rb +30 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/monotonic_observation.rb +113 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/observation_values.rb +53 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/outcome_candidates.rb +38 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/projection.rb +87 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/scoreboard.rb +115 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/synthetic_observation.rb +32 -0
- data/app/adapters/jumbotron/adapters/sandbox/nfl_helpers/timeline_observation.rb +119 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/competitor.rb +14 -1
- data/app/deserializers/jumbotron/deserializers/clients/espn/competitor_record.rb +27 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/venue.rb +10 -1
- data/app/deserializers/jumbotron/deserializers/clients/espn/venue_address.rb +27 -0
- data/app/deserializers/jumbotron/deserializers/public/register_sandbox_projection.rb +42 -0
- data/app/deserializers/jumbotron/deserializers/public/reset_sandbox_projection.rb +51 -0
- data/app/errors/jumbotron/error.rb +7 -0
- data/app/errors/jumbotron/errors/sandbox.rb +67 -0
- data/app/errors/jumbotron/sandbox_reset_rejected_error.rb +6 -0
- data/app/models/jumbotron/canonical.rb +20 -5
- data/app/models/jumbotron/game.rb +11 -0
- data/app/models/jumbotron/public/translate_outcome.rb +13 -7
- data/app/models/jumbotron/public.rb +31 -6
- data/app/models/jumbotron/sandbox_game_mapping.rb +24 -0
- data/app/models/jumbotron/sandbox_game_plan.rb +23 -0
- data/app/models/jumbotron/sandbox_projection.rb +30 -0
- data/app/models/jumbotron/sandbox_projection_data.rb +32 -0
- data/app/models/jumbotron/season.rb +10 -0
- data/app/providers/jumbotron/providers/sandbox/resources/competition_odds.rb +24 -0
- data/app/providers/jumbotron/providers/sandbox/resources/projection.rb +23 -0
- data/app/providers/jumbotron/providers/sandbox/resources/scoreboard.rb +24 -0
- data/app/services/jumbotron/services/adapters/execute_line_acquisition_scope.rb +2 -1
- data/app/services/jumbotron/services/adapters/resolve_game_update_policy.rb +1 -0
- data/app/services/jumbotron/services/adapters/select_eligible_games.rb +1 -1
- data/app/services/jumbotron/services/canonical/upsert_game_graph.rb +126 -0
- data/app/services/jumbotron/services/canonical/upsert_game_participant.rb +114 -6
- data/app/services/jumbotron/services/canonical/upsert_team.rb +24 -0
- data/app/services/jumbotron/services/canonical/upsert_venue.rb +33 -1
- data/app/services/jumbotron/services/public/assemble_public_game.rb +20 -2
- data/app/services/jumbotron/services/public/assemble_sandbox_projection.rb +30 -0
- data/app/services/jumbotron/services/public/assemble_sandbox_projection_reset.rb +49 -0
- data/app/services/jumbotron/services/sandbox/build_source_snapshot.rb +224 -0
- data/app/services/jumbotron/services/sandbox/project_game.rb +73 -0
- data/app/services/jumbotron/services/sandbox/register_projection.rb +76 -0
- data/app/services/jumbotron/services/sandbox/remove_empty_projection_geometry.rb +46 -0
- data/app/services/jumbotron/services/sandbox/remove_mapped_game.rb +29 -0
- data/app/services/jumbotron/services/sandbox/resolve_game_observation.rb +106 -0
- data/app/services/jumbotron/services/sandbox/seed_projected_game.rb +95 -0
- data/app/services/jumbotron/services/sandbox/select_line_basis.rb +69 -0
- data/app/services/jumbotron/services/sandbox/validate_projected_season.rb +77 -0
- data/app/workflows/jumbotron/workflows/public_read.rb +2 -1
- data/app/workflows/jumbotron/workflows/sandbox/register_projection_workflow.rb +33 -0
- data/app/workflows/jumbotron/workflows/sandbox/reset_projection_workflow.rb +158 -0
- data/app/workflows/jumbotron/workflows/synchronize_adapter_workflow.rb +3 -51
- data/db/migrate/20260828220001_add_game_presentation_contract_fields.rb +19 -0
- data/db/migrate/20260908000001_create_jumbotron_sandbox_game_plans.rb +16 -0
- data/db/migrate/20260908000002_create_jumbotron_sandbox_projections.rb +39 -0
- data/docs/upgrades/0.2.0.md +74 -0
- data/lib/jumbotron/client.rb +14 -0
- data/lib/jumbotron/version.rb +1 -1
- data/spec/factories/jumbotron/sandbox_game_mapping.rb +24 -0
- data/spec/factories/jumbotron/sandbox_game_plan.rb +10 -0
- data/spec/factories/jumbotron/sandbox_projection.rb +18 -0
- metadata +62 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 727d9ee2cecff54934b335484762a677ebfa7ee2bfe4a7f7dc408e04b0f7f6b0
|
|
4
|
+
data.tar.gz: 3c16c45a725c58a7fc654172819752a9c4e69fd06f554353f51c8e127feb7eca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c7334f554705bccc4937c73975a77defce7bf08f0aeb23d984652eaa05cc4e43ddcecb0d1f3414a25f0e970f0dc34573e0adbbfdbc489252aeee6156b2e69a78
|
|
7
|
+
data.tar.gz: a02bd5b23d138019d874124ac178652a8a8a936e238a1d1d9f2d54939903d03ea26accda1e72c90ba6899652f0fe41c73fd9593377b8b01ed0a8ef9889fb3654
|
|
@@ -66,6 +66,21 @@ module Jumbotron
|
|
|
66
66
|
INTERRUPTED_LIFECYCLES.include?(game.lifecycle)
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
# Bounded post-final scoreboard re-observation when post-game record is still
|
|
70
|
+
# missing (Strategy B). Stops after window or once post_game is persisted.
|
|
71
|
+
policy :post_final_record,
|
|
72
|
+
type: :post_final_game_update,
|
|
73
|
+
cadence: Cadence.new(every: 15, unit: :minute),
|
|
74
|
+
eligible: lambda { |game, now:|
|
|
75
|
+
return false unless game.lifecycle == "completed"
|
|
76
|
+
return false if game.changed_at.nil? || game.changed_at < now - 6.hours
|
|
77
|
+
|
|
78
|
+
participants = game.game_participants
|
|
79
|
+
return false if participants.empty?
|
|
80
|
+
|
|
81
|
+
participants.any? { |participant| participant.record_summary_post_game.blank? }
|
|
82
|
+
}
|
|
83
|
+
|
|
69
84
|
policy :far_future_lines,
|
|
70
85
|
type: :future_line_update,
|
|
71
86
|
cadence: Cadence.new(every: 1, unit: :week),
|
|
@@ -80,11 +80,14 @@ module Jumbotron
|
|
|
80
80
|
raise TransformError, "venue id required" if venue.id.blank?
|
|
81
81
|
raise TransformError, "venue name required" if venue.full_name.blank?
|
|
82
82
|
|
|
83
|
+
address = venue.respond_to?(:address) ? venue.address : nil
|
|
83
84
|
Canonical::VenueInput.new(
|
|
84
85
|
provider_identities: [
|
|
85
86
|
Canonical::ProviderIdentityRef.new(provider: "espn", namespace: "venue", id: venue.id.to_s)
|
|
86
87
|
],
|
|
87
|
-
name: venue.full_name.to_s
|
|
88
|
+
name: venue.full_name.to_s,
|
|
89
|
+
city: address&.city.presence,
|
|
90
|
+
region: address&.state.presence
|
|
88
91
|
)
|
|
89
92
|
end
|
|
90
93
|
private_class_method :transform_venue
|
|
@@ -104,13 +107,22 @@ module Jumbotron
|
|
|
104
107
|
],
|
|
105
108
|
team_name: name.to_s,
|
|
106
109
|
team_nickname: TeamNickname.resolve(team),
|
|
110
|
+
team_abbreviation: team.abbreviation.presence,
|
|
107
111
|
role: competitor.home_away.to_s,
|
|
108
112
|
score: coerce_score(competitor.score),
|
|
109
|
-
result: coerce_result(competitor.winner, lifecycle)
|
|
113
|
+
result: coerce_result(competitor.winner, lifecycle),
|
|
114
|
+
record_summary: total_record_summary(competitor)
|
|
110
115
|
)
|
|
111
116
|
end
|
|
112
117
|
private_class_method :transform_participant
|
|
113
118
|
|
|
119
|
+
def self.total_record_summary(competitor)
|
|
120
|
+
records = competitor.respond_to?(:records) ? Array(competitor.records) : []
|
|
121
|
+
total = records.find { |record| record.type.to_s == "total" }
|
|
122
|
+
total&.summary.presence
|
|
123
|
+
end
|
|
124
|
+
private_class_method :total_record_summary
|
|
125
|
+
|
|
114
126
|
def self.coerce_score(raw)
|
|
115
127
|
return nil if raw.nil? || raw == ""
|
|
116
128
|
|
|
@@ -25,7 +25,8 @@ module Jumbotron
|
|
|
25
25
|
Canonical::ProviderIdentityRef.new(provider: "espn", namespace: "team", id: team.id.to_s)
|
|
26
26
|
],
|
|
27
27
|
name: name.to_s,
|
|
28
|
-
nickname: TeamNickname.resolve(team)
|
|
28
|
+
nickname: TeamNickname.resolve(team),
|
|
29
|
+
abbreviation: team.abbreviation.presence
|
|
29
30
|
)
|
|
30
31
|
end
|
|
31
32
|
private_class_method :transform_team
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Adapters
|
|
5
|
+
module Sandbox
|
|
6
|
+
class Nfl < Base
|
|
7
|
+
TERMINAL_LIFECYCLES = %w[completed cancelled].freeze
|
|
8
|
+
INTERRUPTED_LIFECYCLES = %w[postponed suspended].freeze
|
|
9
|
+
|
|
10
|
+
adapter_id "sandbox_nfl"
|
|
11
|
+
|
|
12
|
+
provider :sandbox
|
|
13
|
+
sport "football"
|
|
14
|
+
league "nfl-sandbox"
|
|
15
|
+
|
|
16
|
+
endpoint :scoreboard,
|
|
17
|
+
resource: Providers::Sandbox::Resources::Scoreboard,
|
|
18
|
+
transformer: NflHelpers::Scoreboard
|
|
19
|
+
endpoint :projection,
|
|
20
|
+
resource: Providers::Sandbox::Resources::Projection,
|
|
21
|
+
transformer: NflHelpers::Projection
|
|
22
|
+
|
|
23
|
+
policy :far_future,
|
|
24
|
+
type: :future_game_update,
|
|
25
|
+
cadence: Cadence.new(every: 1, unit: :week),
|
|
26
|
+
eligible: lambda { |game, now:|
|
|
27
|
+
TERMINAL_LIFECYCLES.exclude?(game.lifecycle) &&
|
|
28
|
+
game.scheduled_at > now + 6.weeks
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
policy :near_future,
|
|
32
|
+
type: :future_game_update,
|
|
33
|
+
cadence: Cadence.new(every: 1, unit: :day),
|
|
34
|
+
eligible: lambda { |game, now:|
|
|
35
|
+
return false if TERMINAL_LIFECYCLES.include?(game.lifecycle)
|
|
36
|
+
|
|
37
|
+
game.scheduled_at.between?(now + 2.weeks, now + 6.weeks)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
policy :upcoming,
|
|
41
|
+
type: :future_game_update,
|
|
42
|
+
cadence: Cadence.new(every: 1, unit: :hour),
|
|
43
|
+
eligible: lambda { |game, now:|
|
|
44
|
+
return false if TERMINAL_LIFECYCLES.include?(game.lifecycle)
|
|
45
|
+
|
|
46
|
+
game.scheduled_at > now && game.scheduled_at < now + 2.weeks
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
policy :live,
|
|
50
|
+
type: :live_game_update,
|
|
51
|
+
cadence: Cadence.new(every: 1, unit: :minute),
|
|
52
|
+
eligible: lambda { |game, now:|
|
|
53
|
+
return false if TERMINAL_LIFECYCLES.include?(game.lifecycle)
|
|
54
|
+
return false if INTERRUPTED_LIFECYCLES.include?(game.lifecycle)
|
|
55
|
+
|
|
56
|
+
now >= game.scheduled_at
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
policy :interrupted,
|
|
60
|
+
type: :interrupted_game_update,
|
|
61
|
+
cadence: Cadence.new(every: 1, unit: :hour),
|
|
62
|
+
eligible: ->(game, **) { INTERRUPTED_LIFECYCLES.include?(game.lifecycle) }
|
|
63
|
+
|
|
64
|
+
policy :post_final_record,
|
|
65
|
+
type: :post_final_game_update,
|
|
66
|
+
cadence: Cadence.new(every: 15, unit: :minute),
|
|
67
|
+
eligible: lambda { |game, now:|
|
|
68
|
+
return false unless game.lifecycle == "completed"
|
|
69
|
+
return false if game.changed_at.nil? || game.changed_at < now - 6.hours
|
|
70
|
+
|
|
71
|
+
participants = game.game_participants
|
|
72
|
+
participants.present? &&
|
|
73
|
+
participants.any? { |participant| participant.record_summary_post_game.blank? }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
policy :far_future_lines,
|
|
77
|
+
type: :future_line_update,
|
|
78
|
+
cadence: Cadence.new(every: 1, unit: :week),
|
|
79
|
+
eligible: lambda { |game, now:|
|
|
80
|
+
TERMINAL_LIFECYCLES.exclude?(game.lifecycle) &&
|
|
81
|
+
game.scheduled_at > now + 6.weeks
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
policy :near_future_lines,
|
|
85
|
+
type: :future_line_update,
|
|
86
|
+
cadence: Cadence.new(every: 1, unit: :day),
|
|
87
|
+
eligible: lambda { |game, now:|
|
|
88
|
+
return false if TERMINAL_LIFECYCLES.include?(game.lifecycle)
|
|
89
|
+
|
|
90
|
+
game.scheduled_at.between?(now + 2.weeks, now + 6.weeks)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
policy :upcoming_lines,
|
|
94
|
+
type: :future_line_update,
|
|
95
|
+
cadence: Cadence.new(every: 1, unit: :hour),
|
|
96
|
+
eligible: lambda { |game, now:|
|
|
97
|
+
return false if TERMINAL_LIFECYCLES.include?(game.lifecycle)
|
|
98
|
+
|
|
99
|
+
game.scheduled_at > now && game.scheduled_at < now + 2.weeks
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
policy :in_progress_lines,
|
|
103
|
+
type: :live_line_update,
|
|
104
|
+
cadence: Cadence.new(every: 1, unit: :hour),
|
|
105
|
+
eligible: lambda { |game, now:|
|
|
106
|
+
game.lifecycle == "in_progress" && now >= game.scheduled_at
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
policy :interrupted_lines,
|
|
110
|
+
type: :interrupted_line_update,
|
|
111
|
+
cadence: Cadence.new(every: 1, unit: :hour),
|
|
112
|
+
eligible: ->(game, **) { INTERRUPTED_LIFECYCLES.include?(game.lifecycle) }
|
|
113
|
+
|
|
114
|
+
register!
|
|
115
|
+
|
|
116
|
+
def self.acquisition_scope_for(game)
|
|
117
|
+
NflHelpers::AcquisitionScope.call(game)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.line_acquisition_scope_for(game)
|
|
121
|
+
NflHelpers::LineAcquisitionScope.call(game)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.acquire_line_odds(acquisition)
|
|
125
|
+
parts = acquisition.to_h.symbolize_keys
|
|
126
|
+
Providers::Sandbox::Resources::CompetitionOdds.acquire(adapter: self, **parts)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.translate_line_odds(odds, observed_at:, acquisition:)
|
|
130
|
+
NflHelpers::CompetitionOdds.call(odds, observed_at: observed_at, acquisition: acquisition)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Adapters
|
|
5
|
+
module Sandbox
|
|
6
|
+
module NflHelpers
|
|
7
|
+
module AcquisitionScope
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call(game) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
11
|
+
group = game.schedule_group
|
|
12
|
+
event = identity_for(game, "event")
|
|
13
|
+
competition = identity_for(game, "competition")
|
|
14
|
+
home = participant_payload_for(game, "home")
|
|
15
|
+
away = participant_payload_for(game, "away")
|
|
16
|
+
return unless valid?(group, event, home, away)
|
|
17
|
+
|
|
18
|
+
{
|
|
19
|
+
endpoint: :scoreboard,
|
|
20
|
+
acquisition: {
|
|
21
|
+
game_id: game.id,
|
|
22
|
+
event_id: event.provider_id,
|
|
23
|
+
competition_id: competition&.provider_id,
|
|
24
|
+
scheduled_at: game.scheduled_at.iso8601,
|
|
25
|
+
season_year: season_identity(game.season.name),
|
|
26
|
+
season_phase_key: game.season_phase.name,
|
|
27
|
+
week_number: group.number,
|
|
28
|
+
home: home,
|
|
29
|
+
away: away
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
rescue ArgumentError, NoMethodError, TypeError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def season_identity(value)
|
|
37
|
+
Integer(value, exception: false) || value.to_s
|
|
38
|
+
end
|
|
39
|
+
private_class_method :season_identity
|
|
40
|
+
|
|
41
|
+
def identity_for(game, namespace)
|
|
42
|
+
game.provider_identities.find do |identity|
|
|
43
|
+
identity.provider == "sandbox" && identity.object_namespace == namespace
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
private_class_method :identity_for
|
|
47
|
+
|
|
48
|
+
def participant_payload_for(game, role) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
49
|
+
participant = game.game_participants.find { |row| row.role == role }
|
|
50
|
+
return if participant.nil?
|
|
51
|
+
|
|
52
|
+
identity = participant.team.provider_identities.find do |candidate|
|
|
53
|
+
candidate.provider == "espn" && candidate.object_namespace == "team"
|
|
54
|
+
end
|
|
55
|
+
return if identity.nil?
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
provider_id: identity.provider_id,
|
|
59
|
+
name: participant.team.name,
|
|
60
|
+
nickname: participant.team.nickname,
|
|
61
|
+
abbreviation: participant.team.abbreviation
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
private_class_method :participant_payload_for
|
|
65
|
+
|
|
66
|
+
def valid?(group, event, home, away)
|
|
67
|
+
group&.kind == "week" && group.number.present? && event.present? && home.present? && away.present?
|
|
68
|
+
end
|
|
69
|
+
private_class_method :valid?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Jumbotron
|
|
6
|
+
module Adapters
|
|
7
|
+
module Sandbox
|
|
8
|
+
module NflHelpers
|
|
9
|
+
class CompetitionOdds
|
|
10
|
+
BOOKMAKER_ID = "synthetic-consensus"
|
|
11
|
+
BOOKMAKER_NAME = "Sandbox Sportsbook"
|
|
12
|
+
|
|
13
|
+
def self.call(payload, observed_at:, acquisition:) # rubocop:disable Metrics/MethodLength
|
|
14
|
+
event_id, competition_id = identity_values(payload, acquisition)
|
|
15
|
+
values = payload.symbolize_keys.reverse_merge(acquisition.symbolize_keys)
|
|
16
|
+
|
|
17
|
+
Canonical::LineIngestInput.new(
|
|
18
|
+
observed_at: observed_at,
|
|
19
|
+
provider: "sandbox",
|
|
20
|
+
adapter_scope: "sandbox_nfl",
|
|
21
|
+
game_identities: game_identities(event_id, competition_id),
|
|
22
|
+
observations: observations(
|
|
23
|
+
home_spread: BigDecimal(values.fetch(:home_spread).to_s),
|
|
24
|
+
total: BigDecimal(values.fetch(:total).to_s)
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
rescue ArgumentError, KeyError => e
|
|
28
|
+
raise TransformError, "invalid sandbox line observation: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.identity_values(payload, acquisition)
|
|
32
|
+
data = payload.symbolize_keys
|
|
33
|
+
scope = acquisition.symbolize_keys
|
|
34
|
+
event_id = data[:event_id].presence || scope.fetch(:event_id)
|
|
35
|
+
[event_id, data[:competition_id].presence || scope[:competition_id] || event_id]
|
|
36
|
+
end
|
|
37
|
+
private_class_method :identity_values
|
|
38
|
+
|
|
39
|
+
def self.game_identities(event_id, competition_id)
|
|
40
|
+
[
|
|
41
|
+
Canonical::ProviderIdentityRef.new(provider: "sandbox", namespace: "event", id: event_id.to_s),
|
|
42
|
+
Canonical::ProviderIdentityRef.new(
|
|
43
|
+
provider: "sandbox",
|
|
44
|
+
namespace: "competition",
|
|
45
|
+
id: competition_id.to_s
|
|
46
|
+
)
|
|
47
|
+
]
|
|
48
|
+
end
|
|
49
|
+
private_class_method :game_identities
|
|
50
|
+
|
|
51
|
+
def self.observations(home_spread:, total:) # rubocop:disable Metrics/MethodLength
|
|
52
|
+
identity = Canonical::ProviderIdentityRef.new(
|
|
53
|
+
provider: "sandbox",
|
|
54
|
+
namespace: "bookmaker",
|
|
55
|
+
id: BOOKMAKER_ID
|
|
56
|
+
)
|
|
57
|
+
[
|
|
58
|
+
line(identity, "spread", "home", home_spread),
|
|
59
|
+
line(identity, "spread", "away", -home_spread),
|
|
60
|
+
line(identity, "total", "over", total),
|
|
61
|
+
line(identity, "total", "under", total)
|
|
62
|
+
]
|
|
63
|
+
end
|
|
64
|
+
private_class_method :observations
|
|
65
|
+
|
|
66
|
+
def self.line(identity, market, outcome, value)
|
|
67
|
+
Canonical::LineObservationInput.new(
|
|
68
|
+
bookmaker_identity: identity,
|
|
69
|
+
bookmaker_name: BOOKMAKER_NAME,
|
|
70
|
+
market: market,
|
|
71
|
+
outcome: outcome,
|
|
72
|
+
source: "observed",
|
|
73
|
+
line_value: BigDecimal(value.to_s),
|
|
74
|
+
price_american: -110
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
private_class_method :line
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Adapters
|
|
5
|
+
module Sandbox
|
|
6
|
+
module NflHelpers
|
|
7
|
+
module CurrentObservationState
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call(game)
|
|
11
|
+
SyntheticObservation::Current.new(
|
|
12
|
+
lifecycle: game.lifecycle,
|
|
13
|
+
progress: progress(game),
|
|
14
|
+
home_score: participant(game, "home").score,
|
|
15
|
+
away_score: participant(game, "away").score
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def participant(game, role)
|
|
20
|
+
game.game_participants.find { |row| row.role == role } ||
|
|
21
|
+
raise(ArgumentError, "sandbox game requires #{role} participant")
|
|
22
|
+
end
|
|
23
|
+
private_class_method :participant
|
|
24
|
+
|
|
25
|
+
def progress(game)
|
|
26
|
+
return if game.progress_state.nil?
|
|
27
|
+
|
|
28
|
+
Jumbotron::Canonical::GameProgress.new(
|
|
29
|
+
state: game.progress_state,
|
|
30
|
+
segment: Jumbotron::Canonical::GameSegment.new(
|
|
31
|
+
kind: game.progress_segment_kind,
|
|
32
|
+
number: game.progress_segment_number
|
|
33
|
+
),
|
|
34
|
+
clock: clock(game)
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
private_class_method :progress
|
|
38
|
+
|
|
39
|
+
def clock(game)
|
|
40
|
+
return if game.progress_clock_mode.nil?
|
|
41
|
+
|
|
42
|
+
Jumbotron::Canonical::GameClock.new(
|
|
43
|
+
mode: game.progress_clock_mode,
|
|
44
|
+
seconds: game.progress_clock_seconds,
|
|
45
|
+
display: game.progress_clock_display
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
private_class_method :clock
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Adapters
|
|
5
|
+
module Sandbox
|
|
6
|
+
module NflHelpers
|
|
7
|
+
module LineAcquisitionScope
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call(game) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
11
|
+
identities = game.provider_identities.index_by(&:object_namespace)
|
|
12
|
+
event = identities["event"]
|
|
13
|
+
mapping = game.sandbox_game_mapping
|
|
14
|
+
return if event.nil? || event.provider != "sandbox" || event.provider_id.blank? || mapping.nil?
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
endpoint: :competition_odds,
|
|
18
|
+
acquisition: {
|
|
19
|
+
event_id: event.provider_id,
|
|
20
|
+
competition_id: identities["competition"]&.provider_id || event.provider_id,
|
|
21
|
+
home_spread: mapping.home_spread.to_s,
|
|
22
|
+
total: mapping.total.to_s
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Adapters
|
|
5
|
+
module Sandbox
|
|
6
|
+
module NflHelpers
|
|
7
|
+
module MonotonicObservation
|
|
8
|
+
ACTIVE_ORDINALS = { 1 => 1, 2 => 2, 3 => 4, 4 => 5 }.freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def call(proposed:, current:, plan:)
|
|
13
|
+
validate_current_scores!(current, plan)
|
|
14
|
+
proposed_ordinal = ordinal(proposed.lifecycle, proposed.progress)
|
|
15
|
+
current_ordinal = ordinal(current.lifecycle, current.progress)
|
|
16
|
+
state = select_state(proposed, current, proposed_ordinal, current_ordinal)
|
|
17
|
+
build_observation(state, proposed, current, plan)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def select_state(proposed, current, proposed_ordinal, current_ordinal)
|
|
21
|
+
return current if current_ordinal > proposed_ordinal
|
|
22
|
+
return proposed unless current_ordinal == proposed_ordinal
|
|
23
|
+
return proposed unless active_progress?(proposed.progress) && active_progress?(current.progress)
|
|
24
|
+
|
|
25
|
+
proposed.with(progress: clamp_clock(proposed.progress, current.progress))
|
|
26
|
+
end
|
|
27
|
+
private_class_method :select_state
|
|
28
|
+
|
|
29
|
+
def build_observation(state, proposed, current, plan)
|
|
30
|
+
return completed_observation(plan) if state.lifecycle == "completed"
|
|
31
|
+
return state if state.lifecycle == "scheduled"
|
|
32
|
+
|
|
33
|
+
ObservationValues.observation(
|
|
34
|
+
lifecycle: state.lifecycle,
|
|
35
|
+
progress: state.progress,
|
|
36
|
+
home_score: [proposed.home_score || 0, current.home_score || 0].max,
|
|
37
|
+
away_score: [proposed.away_score || 0, current.away_score || 0].max
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
private_class_method :build_observation
|
|
41
|
+
|
|
42
|
+
def completed_observation(plan)
|
|
43
|
+
home_result, away_result = ObservationValues.results(plan.home_score, plan.away_score)
|
|
44
|
+
ObservationValues.observation(
|
|
45
|
+
lifecycle: "completed",
|
|
46
|
+
progress: nil,
|
|
47
|
+
home_score: plan.home_score,
|
|
48
|
+
away_score: plan.away_score,
|
|
49
|
+
home_result: home_result,
|
|
50
|
+
away_result: away_result
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
private_class_method :completed_observation
|
|
54
|
+
|
|
55
|
+
def active_progress?(progress)
|
|
56
|
+
progress&.state == "active"
|
|
57
|
+
end
|
|
58
|
+
private_class_method :active_progress?
|
|
59
|
+
|
|
60
|
+
def clamp_clock(proposed, current)
|
|
61
|
+
proposed_seconds = proposed.clock&.seconds
|
|
62
|
+
current_seconds = current.clock&.seconds
|
|
63
|
+
return proposed if proposed_seconds.nil? || current_seconds.nil? || proposed_seconds <= current_seconds
|
|
64
|
+
|
|
65
|
+
proposed.with(clock: current.clock)
|
|
66
|
+
end
|
|
67
|
+
private_class_method :clamp_clock
|
|
68
|
+
|
|
69
|
+
def ordinal(lifecycle, progress)
|
|
70
|
+
return 0 if lifecycle == "scheduled"
|
|
71
|
+
return 6 if lifecycle == "completed"
|
|
72
|
+
|
|
73
|
+
validate_in_progress!(lifecycle, progress)
|
|
74
|
+
return 3 if progress.state == "intermission" && progress.segment.number == 2
|
|
75
|
+
|
|
76
|
+
active_ordinal(progress)
|
|
77
|
+
end
|
|
78
|
+
private_class_method :ordinal
|
|
79
|
+
|
|
80
|
+
def validate_in_progress!(lifecycle, progress)
|
|
81
|
+
raise ArgumentError, "unsupported sandbox lifecycle #{lifecycle.inspect}" unless lifecycle == "in_progress"
|
|
82
|
+
return if %w[active intermission].include?(progress&.state)
|
|
83
|
+
|
|
84
|
+
raise ArgumentError, "in-progress sandbox game requires canonical progress"
|
|
85
|
+
end
|
|
86
|
+
private_class_method :validate_in_progress!
|
|
87
|
+
|
|
88
|
+
def active_ordinal(progress)
|
|
89
|
+
return ACTIVE_ORDINALS.fetch(progress.segment.number) if progress.state == "active"
|
|
90
|
+
|
|
91
|
+
raise ArgumentError, "sandbox intermission must follow quarter two"
|
|
92
|
+
rescue KeyError
|
|
93
|
+
raise ArgumentError, "sandbox game requires quarter progress from 1 through 4"
|
|
94
|
+
end
|
|
95
|
+
private_class_method :active_ordinal
|
|
96
|
+
|
|
97
|
+
def validate_current_scores!(current, plan)
|
|
98
|
+
return if valid_score?(current.home_score, plan.home_score) &&
|
|
99
|
+
valid_score?(current.away_score, plan.away_score)
|
|
100
|
+
|
|
101
|
+
raise ArgumentError, "persisted sandbox score exceeds selected final"
|
|
102
|
+
end
|
|
103
|
+
private_class_method :validate_current_scores!
|
|
104
|
+
|
|
105
|
+
def valid_score?(score, final_score)
|
|
106
|
+
score.nil? || score <= final_score
|
|
107
|
+
end
|
|
108
|
+
private_class_method :valid_score?
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Adapters
|
|
5
|
+
module Sandbox
|
|
6
|
+
module NflHelpers
|
|
7
|
+
module ObservationValues
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def observation(**attributes)
|
|
11
|
+
defaults = {
|
|
12
|
+
progress: nil,
|
|
13
|
+
home_score: nil,
|
|
14
|
+
away_score: nil,
|
|
15
|
+
home_result: nil,
|
|
16
|
+
away_result: nil
|
|
17
|
+
}
|
|
18
|
+
SyntheticObservation::Observation.new(**defaults, **attributes)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def progress(state:, quarter:, clock: nil)
|
|
22
|
+
Canonical::GameProgress.new(
|
|
23
|
+
state: state,
|
|
24
|
+
segment: Canonical::GameSegment.new(kind: "quarter", number: quarter),
|
|
25
|
+
clock: clock
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def clock(local_seconds:, quarter_seconds:, game_clock_seconds:)
|
|
30
|
+
elapsed = ((local_seconds / quarter_seconds.to_f) * game_clock_seconds).floor
|
|
31
|
+
remaining = [game_clock_seconds - elapsed, 0].max
|
|
32
|
+
Canonical::GameClock.new(
|
|
33
|
+
mode: "remaining",
|
|
34
|
+
seconds: remaining,
|
|
35
|
+
display: format("%<minutes>d:%<seconds>02d", minutes: remaining / 60, seconds: remaining % 60)
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def score_at(final_score, fraction)
|
|
40
|
+
(final_score * fraction).floor
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def results(home_score, away_score)
|
|
44
|
+
return %w[win loss] if home_score > away_score
|
|
45
|
+
return %w[loss win] if away_score > home_score
|
|
46
|
+
|
|
47
|
+
%w[tie tie]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Jumbotron
|
|
6
|
+
module Adapters
|
|
7
|
+
module Sandbox
|
|
8
|
+
module NflHelpers
|
|
9
|
+
module OutcomeCandidates
|
|
10
|
+
Outcome = Data.define(:home_score, :away_score)
|
|
11
|
+
SCORE_OFFSETS = [[0, 0], [3, 0], [0, 3], [-3, 0], [0, -3]].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def call(home_spread:, total:)
|
|
16
|
+
spread_value = BigDecimal(home_spread.to_s)
|
|
17
|
+
total_value = BigDecimal(total.to_s)
|
|
18
|
+
home = ((total_value - spread_value) / 2).round
|
|
19
|
+
away = ((total_value + spread_value) / 2).round
|
|
20
|
+
|
|
21
|
+
SCORE_OFFSETS.filter_map do |home_offset, away_offset|
|
|
22
|
+
candidate(home + home_offset, away + away_offset)
|
|
23
|
+
end.uniq
|
|
24
|
+
rescue ArgumentError, TypeError
|
|
25
|
+
[]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def candidate(home_score, away_score)
|
|
29
|
+
return if home_score.negative? || away_score.negative? || home_score == away_score
|
|
30
|
+
|
|
31
|
+
Outcome.new(home_score: home_score, away_score: away_score)
|
|
32
|
+
end
|
|
33
|
+
private_class_method :candidate
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|