jumbotron 0.1.7 → 0.3.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/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/public/register_sandbox_projection.rb +42 -0
- data/app/deserializers/jumbotron/deserializers/public/reset_sandbox_projection.rb +51 -0
- data/app/deserializers/jumbotron/deserializers/public/sandbox_projection.rb +17 -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/game.rb +11 -0
- data/app/models/jumbotron/public/translate_outcome.rb +13 -7
- data/app/models/jumbotron/public.rb +19 -1
- 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/canonical/upsert_game_graph.rb +126 -0
- data/app/services/jumbotron/services/canonical/upsert_game_participant.rb +23 -3
- data/app/services/jumbotron/services/public/assemble_sandbox_projection.rb +39 -0
- data/app/services/jumbotron/services/public/assemble_sandbox_projection_reset.rb +58 -0
- data/app/services/jumbotron/services/public/read_sandbox_projection.rb +32 -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/read_projection_workflow.rb +28 -0
- 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/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/docs/upgrades/0.3.0.md +31 -0
- data/lib/jumbotron/client.rb +21 -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 +63 -2
|
@@ -7,6 +7,16 @@ module Jumbotron
|
|
|
7
7
|
has_many :schedule_groups, inverse_of: :season, dependent: :restrict_with_exception
|
|
8
8
|
has_many :games, inverse_of: :season, dependent: :restrict_with_exception
|
|
9
9
|
has_many :provider_identities, as: :target, inverse_of: :target, dependent: :restrict_with_exception
|
|
10
|
+
has_many :source_sandbox_projections,
|
|
11
|
+
class_name: "Jumbotron::SandboxProjection",
|
|
12
|
+
foreign_key: :source_season_id,
|
|
13
|
+
inverse_of: :source_season,
|
|
14
|
+
dependent: :restrict_with_exception
|
|
15
|
+
has_one :sandbox_projection,
|
|
16
|
+
class_name: "Jumbotron::SandboxProjection",
|
|
17
|
+
foreign_key: :sandbox_season_id,
|
|
18
|
+
inverse_of: :sandbox_season,
|
|
19
|
+
dependent: :restrict_with_exception
|
|
10
20
|
|
|
11
21
|
validates :name, presence: true
|
|
12
22
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Providers
|
|
5
|
+
module Sandbox
|
|
6
|
+
module Resources
|
|
7
|
+
class CompetitionOdds
|
|
8
|
+
def self.acquire(adapter:, **query)
|
|
9
|
+
return invalid_adapter unless adapter.provider == "sandbox"
|
|
10
|
+
|
|
11
|
+
CommandTower::Clients::ClientResult.success(output: query.symbolize_keys)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.invalid_adapter
|
|
15
|
+
CommandTower::Clients::ClientResult.failure(
|
|
16
|
+
error: ArgumentError.new("sandbox provider requires a sandbox adapter")
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
private_class_method :invalid_adapter
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Providers
|
|
5
|
+
module Sandbox
|
|
6
|
+
module Resources
|
|
7
|
+
module Projection
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def acquire(adapter:, **projection)
|
|
11
|
+
unless adapter.provider.to_s == "sandbox"
|
|
12
|
+
return CommandTower::Clients::ClientResult.failure(
|
|
13
|
+
error: ArgumentError.new("sandbox projection requires sandbox adapter")
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
CommandTower::Clients::ClientResult.success(output: projection)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Providers
|
|
5
|
+
module Sandbox
|
|
6
|
+
module Resources
|
|
7
|
+
class Scoreboard
|
|
8
|
+
def self.acquire(adapter:, **query)
|
|
9
|
+
return invalid_adapter unless adapter.provider == "sandbox"
|
|
10
|
+
|
|
11
|
+
CommandTower::Clients::ClientResult.success(output: query.symbolize_keys)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.invalid_adapter
|
|
15
|
+
CommandTower::Clients::ClientResult.failure(
|
|
16
|
+
error: ArgumentError.new("sandbox provider requires a sandbox adapter")
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
private_class_method :invalid_adapter
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -6,6 +6,7 @@ module Jumbotron
|
|
|
6
6
|
class ExecuteLineAcquisitionScope < CommandTower::Services::ApplicationService
|
|
7
7
|
validate :adapter, required: true
|
|
8
8
|
validate :acquisition, is_a: Hash, required: true
|
|
9
|
+
validate :observed_at, required: false
|
|
9
10
|
|
|
10
11
|
def call
|
|
11
12
|
if adapter.provider_cooling_down?
|
|
@@ -43,7 +44,7 @@ module Jumbotron
|
|
|
43
44
|
|
|
44
45
|
ingest = adapter.translate_line_odds(
|
|
45
46
|
provider_result.output,
|
|
46
|
-
observed_at: Time.current,
|
|
47
|
+
observed_at: observed_at || Time.current,
|
|
47
48
|
acquisition: acquisition
|
|
48
49
|
)
|
|
49
50
|
persisted = Canonical::PersistLineObservations.call(ingest: ingest)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Services
|
|
5
|
+
module Canonical
|
|
6
|
+
class UpsertGameGraph < CommandTower::Services::ApplicationService
|
|
7
|
+
validate :game_input, required: true
|
|
8
|
+
validate :league, required: true
|
|
9
|
+
validate :observed_at, required: true
|
|
10
|
+
validate :change_set, required: true
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
season = resolve(EnsureSeason.call(league: league, year: game_input.season_year), :season)
|
|
14
|
+
return if season.nil?
|
|
15
|
+
|
|
16
|
+
phase = resolve(
|
|
17
|
+
EnsureSeasonPhase.call(season: season, phase_key: game_input.season_phase_key),
|
|
18
|
+
:season_phase
|
|
19
|
+
)
|
|
20
|
+
return if phase.nil?
|
|
21
|
+
|
|
22
|
+
group = resolve_group(season, phase)
|
|
23
|
+
return if context.failure?
|
|
24
|
+
|
|
25
|
+
venue = resolve(
|
|
26
|
+
UpsertVenue.call(
|
|
27
|
+
venue_input: game_input.venue,
|
|
28
|
+
observed_at: observed_at,
|
|
29
|
+
change_set: change_set
|
|
30
|
+
),
|
|
31
|
+
:venue,
|
|
32
|
+
allow_nil: true
|
|
33
|
+
)
|
|
34
|
+
return if context.failure?
|
|
35
|
+
|
|
36
|
+
game = upsert_game(season, phase, group, venue)
|
|
37
|
+
return if game.nil?
|
|
38
|
+
return unless attach_identities?(game)
|
|
39
|
+
return unless upsert_participants?(game)
|
|
40
|
+
|
|
41
|
+
context.game = game
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def resolve_group(season, phase)
|
|
47
|
+
resolve(
|
|
48
|
+
EnsureScheduleGroup.call(
|
|
49
|
+
season: season,
|
|
50
|
+
season_phase: phase,
|
|
51
|
+
schedule_group_input: game_input.schedule_group
|
|
52
|
+
),
|
|
53
|
+
:schedule_group,
|
|
54
|
+
allow_nil: true
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def upsert_game(season, phase, group, venue)
|
|
59
|
+
resolve(
|
|
60
|
+
UpsertGame.call(
|
|
61
|
+
game_input: game_input,
|
|
62
|
+
league: league,
|
|
63
|
+
season: season,
|
|
64
|
+
season_phase: phase,
|
|
65
|
+
schedule_group: group,
|
|
66
|
+
venue: venue,
|
|
67
|
+
observed_at: observed_at,
|
|
68
|
+
change_set: change_set
|
|
69
|
+
),
|
|
70
|
+
:game
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def attach_identities?(game)
|
|
75
|
+
result = AttachProviderIdentities.call(target: game, refs: game_input.provider_identities)
|
|
76
|
+
return true if result.success?
|
|
77
|
+
|
|
78
|
+
fail_from(result)
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def upsert_participants?(game)
|
|
83
|
+
game_input.participants.each do |participant_input|
|
|
84
|
+
result = UpsertGameParticipant.call(
|
|
85
|
+
game: game,
|
|
86
|
+
participant_input: participant_input,
|
|
87
|
+
observed_at: observed_at,
|
|
88
|
+
change_set: change_set,
|
|
89
|
+
preserve_existing_team: sandbox_game_input?
|
|
90
|
+
)
|
|
91
|
+
unless result.success?
|
|
92
|
+
fail_from(result)
|
|
93
|
+
return false
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def sandbox_game_input?
|
|
100
|
+
game_input.provider_identities.any? { |identity| identity.provider == "sandbox" }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def resolve(result, key, allow_nil: false)
|
|
104
|
+
unless result.success?
|
|
105
|
+
fail_from(result)
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
value = result.data[key]
|
|
110
|
+
return value if allow_nil || !value.nil?
|
|
111
|
+
|
|
112
|
+
context.fail!(
|
|
113
|
+
application_error: Jumbotron::Errors::Canonical::MutationFailedError.new(
|
|
114
|
+
details: { message: "#{key} was not resolved" }
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fail_from(result)
|
|
121
|
+
context.fail!(application_error: Array(result.errors).first)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -8,9 +8,11 @@ module Jumbotron
|
|
|
8
8
|
validate :participant_input, required: true
|
|
9
9
|
validate :observed_at, required: true
|
|
10
10
|
validate :change_set, required: true
|
|
11
|
+
validate :preserve_existing_team, required: false
|
|
11
12
|
|
|
12
13
|
def call
|
|
13
|
-
|
|
14
|
+
team = existing_team if preserve_existing_team
|
|
15
|
+
team_result = team || UpsertTeam.call(
|
|
14
16
|
team_input: Jumbotron::Canonical::TeamInput.new(
|
|
15
17
|
provider_identities: participant_input.provider_identities,
|
|
16
18
|
name: participant_input.team_name,
|
|
@@ -20,12 +22,20 @@ module Jumbotron
|
|
|
20
22
|
observed_at: observed_at,
|
|
21
23
|
change_set: change_set
|
|
22
24
|
)
|
|
23
|
-
unless
|
|
25
|
+
unless team
|
|
26
|
+
unless team_result.success?
|
|
27
|
+
context.fail!(application_error: team_result.errors.first)
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
team = team_result.data[:team]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if team.nil?
|
|
24
35
|
context.fail!(application_error: team_result.errors.first)
|
|
25
36
|
return
|
|
26
37
|
end
|
|
27
38
|
|
|
28
|
-
team = team_result.data[:team]
|
|
29
39
|
participant = game.game_participants.lock.find_by(team_id: team.id)
|
|
30
40
|
context.game_participant = resolve_participant(participant, team)
|
|
31
41
|
rescue ActiveRecord::RecordInvalid => e
|
|
@@ -38,6 +48,16 @@ module Jumbotron
|
|
|
38
48
|
|
|
39
49
|
private
|
|
40
50
|
|
|
51
|
+
def existing_team
|
|
52
|
+
ref = participant_input.provider_identities.first
|
|
53
|
+
identity = Jumbotron::ProviderIdentity.find_by(
|
|
54
|
+
provider: ref.provider,
|
|
55
|
+
object_namespace: ref.namespace,
|
|
56
|
+
provider_id: ref.id
|
|
57
|
+
)
|
|
58
|
+
identity&.target if identity&.target.is_a?(Jumbotron::Team)
|
|
59
|
+
end
|
|
60
|
+
|
|
41
61
|
def resolve_participant(participant, team)
|
|
42
62
|
if participant
|
|
43
63
|
update_existing!(participant)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Services
|
|
5
|
+
module Public
|
|
6
|
+
class AssembleSandboxProjection < CommandTower::Services::ApplicationService
|
|
7
|
+
validate :projection, required: true
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
context.public_projection = Jumbotron::Public::SandboxProjection.new(
|
|
11
|
+
name: projection.name,
|
|
12
|
+
source_label: source_label,
|
|
13
|
+
source: selector(projection.source_season, projection.source_anchor_phase.name),
|
|
14
|
+
sandbox: selector(projection.sandbox_season, projection.source_anchor_phase.name)
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def source_label
|
|
21
|
+
[
|
|
22
|
+
projection.source_season.league.name.upcase,
|
|
23
|
+
projection.source_season.name,
|
|
24
|
+
projection.source_anchor_phase.name.titleize
|
|
25
|
+
].join(" ")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def selector(season, phase_name)
|
|
29
|
+
Jumbotron::Public::SandboxScheduleSelector.new(
|
|
30
|
+
sport: season.league.sport.name,
|
|
31
|
+
league: season.league.name,
|
|
32
|
+
season: season.name,
|
|
33
|
+
season_phase: phase_name
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Services
|
|
5
|
+
module Public
|
|
6
|
+
class AssembleSandboxProjectionReset < CommandTower::Services::ApplicationService
|
|
7
|
+
validate :projection, required: true
|
|
8
|
+
validate :snapshot, required: true
|
|
9
|
+
validate :counts, required: true
|
|
10
|
+
validate :reset_at, required: true
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
context.public_reset = Jumbotron::Public::SandboxProjectionReset.new(
|
|
14
|
+
projection: projection_identity,
|
|
15
|
+
source_anchor_date: snapshot.source_anchor_date,
|
|
16
|
+
target_anchor_date: snapshot.target_anchor_date,
|
|
17
|
+
date_shift_days: snapshot.date_shift_days,
|
|
18
|
+
projected_game_count: snapshot.games.count,
|
|
19
|
+
added_game_count: counts.fetch(:added),
|
|
20
|
+
updated_game_count: counts.fetch(:updated),
|
|
21
|
+
removed_game_count: counts.fetch(:removed),
|
|
22
|
+
start_week_game_count: counts.fetch(:start_week),
|
|
23
|
+
naturally_locked_game_count: counts.fetch(:naturally_locked),
|
|
24
|
+
reset_at: reset_at
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def projection_identity
|
|
31
|
+
Jumbotron::Public::SandboxProjection.new(
|
|
32
|
+
name: projection.name,
|
|
33
|
+
source_label: source_label,
|
|
34
|
+
source: selector(projection.source_season, projection.source_anchor_phase.name),
|
|
35
|
+
sandbox: selector(projection.sandbox_season, projection.source_anchor_phase.name)
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def source_label
|
|
40
|
+
[
|
|
41
|
+
projection.source_season.league.name.upcase,
|
|
42
|
+
projection.source_season.name,
|
|
43
|
+
projection.source_anchor_phase.name.titleize
|
|
44
|
+
].join(" ")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def selector(season, phase_name)
|
|
48
|
+
Jumbotron::Public::SandboxScheduleSelector.new(
|
|
49
|
+
sport: season.league.sport.name,
|
|
50
|
+
league: season.league.name,
|
|
51
|
+
season: season.name,
|
|
52
|
+
season_phase: phase_name
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Services
|
|
5
|
+
module Public
|
|
6
|
+
class ReadSandboxProjection < CommandTower::Services::ApplicationService
|
|
7
|
+
validate :name, required: true
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
projection = Jumbotron::SandboxProjection.includes(
|
|
11
|
+
:source_anchor_phase,
|
|
12
|
+
source_season: { league: :sport },
|
|
13
|
+
sandbox_season: { league: :sport }
|
|
14
|
+
).find_by(name:)
|
|
15
|
+
return reject if projection.nil?
|
|
16
|
+
|
|
17
|
+
context.projection = projection
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def reject
|
|
23
|
+
context.fail!(
|
|
24
|
+
application_error: Jumbotron::Errors::Public::NotFoundError.new(
|
|
25
|
+
details: { code: "sandbox_not_found", message: "sandbox projection was not found" }
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
# This service validates and snapshots the complete source projection atomically.
|
|
7
|
+
# rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
8
|
+
module Jumbotron
|
|
9
|
+
module Services
|
|
10
|
+
module Sandbox
|
|
11
|
+
class BuildSourceSnapshot < CommandTower::Services::ApplicationService
|
|
12
|
+
validate :projection, required: true
|
|
13
|
+
validate :start_week, required: true
|
|
14
|
+
validate :anchor_day, required: true
|
|
15
|
+
validate :now, required: true
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
source_games = load_source_games
|
|
19
|
+
return if source_games.nil?
|
|
20
|
+
|
|
21
|
+
calendar = resolve_calendar(source_games)
|
|
22
|
+
return if calendar.nil?
|
|
23
|
+
|
|
24
|
+
raw_games = source_games.map { |game| raw_game(game) }
|
|
25
|
+
return if context.failure?
|
|
26
|
+
|
|
27
|
+
fingerprint = fingerprint_for(raw_games, calendar)
|
|
28
|
+
projected_games = raw_games.map { |raw| projected_game(raw, calendar, fingerprint) }
|
|
29
|
+
return if context.failure?
|
|
30
|
+
|
|
31
|
+
context.snapshot = Jumbotron::SandboxProjectionData::Snapshot.new(
|
|
32
|
+
games: projected_games,
|
|
33
|
+
source_anchor_date: calendar.fetch(:source_anchor_date),
|
|
34
|
+
target_anchor_date: calendar.fetch(:target_anchor_date),
|
|
35
|
+
date_shift_days: calendar.fetch(:date_shift_days),
|
|
36
|
+
fingerprint: fingerprint,
|
|
37
|
+
start_week: start_week
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def load_source_games
|
|
44
|
+
games = projection.source_season.games.includes(
|
|
45
|
+
:schedule_group,
|
|
46
|
+
:season_phase,
|
|
47
|
+
game_participants: { team: :provider_identities }
|
|
48
|
+
).order(:scheduled_at, :id).to_a
|
|
49
|
+
return games if games.present? && games.all? { |game| complete_game?(game) }
|
|
50
|
+
|
|
51
|
+
reject("sandbox_projection_incomplete", "source season contains incomplete game structure")
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def complete_game?(game)
|
|
56
|
+
game.scheduled_at.present? &&
|
|
57
|
+
game.schedule_group&.kind == "week" &&
|
|
58
|
+
game.schedule_group.number.present? &&
|
|
59
|
+
game.season_phase.present? &&
|
|
60
|
+
%w[home away].all? { |role| participant(game, role).present? }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resolve_calendar(games)
|
|
64
|
+
group = anchor_groups.one? ? anchor_groups.first : nil
|
|
65
|
+
return reject_calendar("source_week_not_found", "start week is missing or ambiguous") if group.nil?
|
|
66
|
+
|
|
67
|
+
group_games = games.select { |game| game.schedule_group_id == group.id }
|
|
68
|
+
return reject_calendar("source_week_not_found", "start week has no games") if group_games.empty?
|
|
69
|
+
return reject_calendar("sandbox_period_overlap", "source week windows overlap") unless non_overlapping?(games)
|
|
70
|
+
|
|
71
|
+
zone = ActiveSupport::TimeZone[projection.calendar_time_zone]
|
|
72
|
+
dates = group_games.map { |game| game.scheduled_at.in_time_zone(zone).to_date }
|
|
73
|
+
first_date = dates.min
|
|
74
|
+
last_date = dates.max
|
|
75
|
+
span = (last_date - first_date).to_i + 1
|
|
76
|
+
return reject_calendar("anchor_day_out_of_range", "anchor day exceeds source week span") if anchor_day > span
|
|
77
|
+
|
|
78
|
+
source_anchor = first_date + (anchor_day - 1)
|
|
79
|
+
target_anchor = now.in_time_zone(zone).to_date
|
|
80
|
+
{
|
|
81
|
+
zone: zone,
|
|
82
|
+
source_anchor_date: source_anchor,
|
|
83
|
+
target_anchor_date: target_anchor,
|
|
84
|
+
date_shift_days: (target_anchor - source_anchor).to_i
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def anchor_groups
|
|
89
|
+
projection.source_anchor_phase.schedule_groups.where(
|
|
90
|
+
kind: "week",
|
|
91
|
+
number: start_week
|
|
92
|
+
).to_a
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def non_overlapping?(games)
|
|
96
|
+
windows = games.group_by(&:schedule_group_id).values.map do |members|
|
|
97
|
+
times = members.map(&:scheduled_at)
|
|
98
|
+
times.minmax
|
|
99
|
+
end.sort_by(&:first)
|
|
100
|
+
windows.each_cons(2).all? { |left, right| left.last < right.first }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def raw_game(game)
|
|
104
|
+
source_spread, source_total = source_consensus(game)
|
|
105
|
+
{
|
|
106
|
+
game: game,
|
|
107
|
+
source_spread: source_spread,
|
|
108
|
+
source_total: source_total,
|
|
109
|
+
home: team_data(participant(game, "home").team),
|
|
110
|
+
away: team_data(participant(game, "away").team)
|
|
111
|
+
}
|
|
112
|
+
rescue ArgumentError => e
|
|
113
|
+
reject("sandbox_projection_incomplete", e.message)
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def source_consensus(game)
|
|
118
|
+
result = Jumbotron::Services::Canonical::DeriveConsensusLine.call(game: game)
|
|
119
|
+
return [nil, nil] unless result.success?
|
|
120
|
+
|
|
121
|
+
lines = Array(result.data[:consensus_lines])
|
|
122
|
+
spread = lines.find { |line| line.market == "spread" && line.outcome == "home" }
|
|
123
|
+
total = lines.find { |line| line.market == "total" && line.outcome == "over" }
|
|
124
|
+
[spread&.line_value, total&.line_value]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def participant(game, role)
|
|
128
|
+
game.game_participants.find { |row| row.role == role }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def team_data(team)
|
|
132
|
+
identity = team.provider_identities.find do |candidate|
|
|
133
|
+
candidate.provider == "espn" && candidate.object_namespace == "team"
|
|
134
|
+
end
|
|
135
|
+
raise ArgumentError, "source team is missing ESPN identity" if identity.nil?
|
|
136
|
+
|
|
137
|
+
Jumbotron::SandboxProjectionData::Team.new(
|
|
138
|
+
provider_id: identity.provider_id,
|
|
139
|
+
name: team.name,
|
|
140
|
+
nickname: team.nickname,
|
|
141
|
+
abbreviation: team.abbreviation
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def fingerprint_for(raw_games, calendar)
|
|
146
|
+
facts = raw_games.compact.map do |raw|
|
|
147
|
+
game = raw.fetch(:game)
|
|
148
|
+
[
|
|
149
|
+
game.id,
|
|
150
|
+
game.scheduled_at.iso8601,
|
|
151
|
+
game.season_phase.name,
|
|
152
|
+
game.schedule_group.number,
|
|
153
|
+
raw[:source_spread]&.to_s,
|
|
154
|
+
raw[:source_total]&.to_s
|
|
155
|
+
]
|
|
156
|
+
end
|
|
157
|
+
Digest::SHA256.hexdigest(
|
|
158
|
+
JSON.generate(
|
|
159
|
+
version: 1,
|
|
160
|
+
projection: projection.name,
|
|
161
|
+
start_week: start_week,
|
|
162
|
+
anchor_day: anchor_day,
|
|
163
|
+
now: now.iso8601,
|
|
164
|
+
shift: calendar.fetch(:date_shift_days),
|
|
165
|
+
games: facts
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def projected_game(raw, calendar, fingerprint)
|
|
171
|
+
game = raw.fetch(:game)
|
|
172
|
+
line = Jumbotron::Services::Sandbox::SelectLineBasis.call(
|
|
173
|
+
source_game_id: game.id,
|
|
174
|
+
reset_fingerprint: fingerprint,
|
|
175
|
+
source_spread: raw[:source_spread],
|
|
176
|
+
source_total: raw[:source_total]
|
|
177
|
+
)
|
|
178
|
+
unless line.success?
|
|
179
|
+
reject("sandbox_projection_incomplete", "sandbox line basis could not be selected")
|
|
180
|
+
return
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
Jumbotron::SandboxProjectionData::Game.new(
|
|
184
|
+
source_game_id: game.id,
|
|
185
|
+
event_id: "#{projection.name}:source-game:#{game.id}",
|
|
186
|
+
competition_id: "#{projection.name}:source-competition:#{game.id}",
|
|
187
|
+
scheduled_at: shift_time(game.scheduled_at, calendar),
|
|
188
|
+
season: projection.sandbox_season.name,
|
|
189
|
+
season_phase: game.season_phase.name,
|
|
190
|
+
week_number: game.schedule_group.number,
|
|
191
|
+
week_name: game.schedule_group.name,
|
|
192
|
+
neutral_site: game.neutral_site,
|
|
193
|
+
home: raw.fetch(:home),
|
|
194
|
+
away: raw.fetch(:away),
|
|
195
|
+
home_spread: line.data[:home_spread],
|
|
196
|
+
total: line.data[:total],
|
|
197
|
+
line_fingerprint: line.data[:line_fingerprint],
|
|
198
|
+
line_source: line.data[:line_source]
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def shift_time(time, calendar)
|
|
203
|
+
time.in_time_zone(calendar.fetch(:zone))
|
|
204
|
+
.advance(days: calendar.fetch(:date_shift_days))
|
|
205
|
+
.utc
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def reject_calendar(code, message)
|
|
209
|
+
reject(code, message)
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def reject(code, message)
|
|
214
|
+
context.fail!(
|
|
215
|
+
application_error: Jumbotron::Errors::Sandbox::ProjectionRejectedError.new(
|
|
216
|
+
details: { code: code, message: message }
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
# rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength
|