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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Services
|
|
5
|
+
module Sandbox
|
|
6
|
+
class ProjectGame < CommandTower::Services::ApplicationService
|
|
7
|
+
validate :projection, required: true
|
|
8
|
+
validate :projected_game, required: true
|
|
9
|
+
validate :observed_at, required: true
|
|
10
|
+
validate :change_set, required: true
|
|
11
|
+
|
|
12
|
+
def call # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
13
|
+
executed = Jumbotron::Services::Adapters::ExecuteEndpoint.call(
|
|
14
|
+
adapter: Jumbotron::Adapters::Sandbox::Nfl,
|
|
15
|
+
endpoint: :projection,
|
|
16
|
+
observed_at: observed_at,
|
|
17
|
+
acquisition: acquisition
|
|
18
|
+
)
|
|
19
|
+
return fail_from(executed) unless executed.success?
|
|
20
|
+
|
|
21
|
+
input = Array(executed.data[:sync_input]&.games).one? ? executed.data[:sync_input].games.first : nil
|
|
22
|
+
return reject("projection endpoint did not return one game") if input.nil?
|
|
23
|
+
|
|
24
|
+
graph = Jumbotron::Services::Canonical::UpsertGameGraph.call(
|
|
25
|
+
game_input: input,
|
|
26
|
+
league: projection.sandbox_season.league,
|
|
27
|
+
observed_at: observed_at,
|
|
28
|
+
change_set: change_set
|
|
29
|
+
)
|
|
30
|
+
return fail_from(graph) unless graph.success?
|
|
31
|
+
|
|
32
|
+
mapping = projection.sandbox_game_mappings.find_or_initialize_by(
|
|
33
|
+
source_game_id: projected_game.source_game_id
|
|
34
|
+
)
|
|
35
|
+
context.created = mapping.new_record?
|
|
36
|
+
mapping.assign_attributes(
|
|
37
|
+
sandbox_game: graph.data[:game],
|
|
38
|
+
home_spread: projected_game.home_spread,
|
|
39
|
+
total: projected_game.total,
|
|
40
|
+
line_fingerprint: projected_game.line_fingerprint
|
|
41
|
+
)
|
|
42
|
+
mapping.save!
|
|
43
|
+
context.mapping = mapping
|
|
44
|
+
context.game = graph.data[:game]
|
|
45
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
|
|
46
|
+
reject(e.message)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def acquisition
|
|
52
|
+
projected_game.to_h.merge(
|
|
53
|
+
scheduled_at: projected_game.scheduled_at.iso8601,
|
|
54
|
+
home: projected_game.home.to_h,
|
|
55
|
+
away: projected_game.away.to_h
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def fail_from(result)
|
|
60
|
+
context.fail!(application_error: Array(result.errors).first)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def reject(message)
|
|
64
|
+
context.fail!(
|
|
65
|
+
application_error: Jumbotron::Errors::Sandbox::ProjectionRejectedError.new(
|
|
66
|
+
details: { code: "sandbox_projection_incomplete", message: message }
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Services
|
|
5
|
+
module Sandbox
|
|
6
|
+
class RegisterProjection < CommandTower::Services::ApplicationService
|
|
7
|
+
validate :request, required: true
|
|
8
|
+
|
|
9
|
+
def call # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
10
|
+
source_season, source_phase = resolve_source
|
|
11
|
+
return if source_season.nil?
|
|
12
|
+
|
|
13
|
+
existing = Jumbotron::SandboxProjection.find_by(name: request.name)
|
|
14
|
+
return reuse_or_reject(existing, source_season, source_phase) if existing
|
|
15
|
+
|
|
16
|
+
sandbox_league = ensure_sandbox_league
|
|
17
|
+
return if sandbox_league.nil?
|
|
18
|
+
|
|
19
|
+
sandbox_season = Jumbotron::Season.find_or_create_by!(league: sandbox_league, name: request.name)
|
|
20
|
+
context.projection = Jumbotron::SandboxProjection.create!(
|
|
21
|
+
name: request.name,
|
|
22
|
+
source_season: source_season,
|
|
23
|
+
source_anchor_phase: source_phase,
|
|
24
|
+
sandbox_season: sandbox_season,
|
|
25
|
+
adapter_id: "sandbox_nfl",
|
|
26
|
+
calendar_time_zone: "America/New_York"
|
|
27
|
+
)
|
|
28
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
|
|
29
|
+
reject("sandbox_projection_conflict", e.message)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def resolve_source # rubocop:disable Metrics/AbcSize
|
|
35
|
+
selector = request.source
|
|
36
|
+
sport = Jumbotron::Sport.where("LOWER(name) = ?", selector.sport.downcase).first
|
|
37
|
+
league = sport.leagues.where("LOWER(name) = ?", selector.league.downcase).first if sport
|
|
38
|
+
season = league.seasons.where("LOWER(name) = ?", selector.season.downcase).first if league
|
|
39
|
+
phase = season.season_phases.where("LOWER(name) = ?", selector.season_phase.downcase).first if season
|
|
40
|
+
return [season, phase] if season && phase
|
|
41
|
+
|
|
42
|
+
reject("source_season_not_found", "registered source season or phase was not found")
|
|
43
|
+
[nil, nil]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ensure_sandbox_league
|
|
47
|
+
result = Jumbotron::Services::Adapters::EnsureLeague.call(
|
|
48
|
+
adapter: Jumbotron::Adapters::Sandbox::Nfl
|
|
49
|
+
)
|
|
50
|
+
return result.data[:league] if result.success?
|
|
51
|
+
|
|
52
|
+
reject("sandbox_projection_conflict", result.errors.first.to_s)
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def reuse_or_reject(existing, source_season, source_phase)
|
|
57
|
+
if existing.source_season_id == source_season.id &&
|
|
58
|
+
existing.source_anchor_phase_id == source_phase.id
|
|
59
|
+
context.projection = existing
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
reject("sandbox_projection_conflict", "sandbox name is already registered to another source")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def reject(code, message)
|
|
67
|
+
context.fail!(
|
|
68
|
+
application_error: Jumbotron::Errors::Sandbox::ProjectionRejectedError.new(
|
|
69
|
+
details: { code: code, message: message }
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Services
|
|
5
|
+
module Sandbox
|
|
6
|
+
class RemoveEmptyProjectionGeometry < CommandTower::Services::ApplicationService
|
|
7
|
+
validate :projection, required: true
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
remove_empty_groups
|
|
11
|
+
remove_empty_phases
|
|
12
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
13
|
+
reject(e)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def reject(error)
|
|
19
|
+
context.fail!(
|
|
20
|
+
application_error: Jumbotron::Errors::Sandbox::ProjectionRejectedError.new(
|
|
21
|
+
details: {
|
|
22
|
+
code: "sandbox_projection_incomplete",
|
|
23
|
+
message: "stale sandbox schedule geometry could not be removed",
|
|
24
|
+
failure: error.class.name
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def remove_empty_groups
|
|
31
|
+
projection.sandbox_season.schedule_groups
|
|
32
|
+
.left_outer_joins(:games)
|
|
33
|
+
.where(jumbotron_games: { id: nil })
|
|
34
|
+
.destroy_all
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def remove_empty_phases
|
|
38
|
+
projection.sandbox_season.season_phases
|
|
39
|
+
.left_outer_joins(:games, :schedule_groups)
|
|
40
|
+
.where(jumbotron_games: { id: nil }, jumbotron_schedule_groups: { id: nil })
|
|
41
|
+
.destroy_all
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Services
|
|
5
|
+
module Sandbox
|
|
6
|
+
class RemoveMappedGame < CommandTower::Services::ApplicationService
|
|
7
|
+
validate :mapping, required: true
|
|
8
|
+
|
|
9
|
+
def call # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
10
|
+
game = mapping.sandbox_game
|
|
11
|
+
mapping.destroy!
|
|
12
|
+
game.reload
|
|
13
|
+
Jumbotron::SandboxGamePlan.find_by(game_id: game.id)&.destroy!
|
|
14
|
+
game.line_observations.destroy_all
|
|
15
|
+
game.game_participants.destroy_all
|
|
16
|
+
game.provider_identities.destroy_all
|
|
17
|
+
game.destroy!
|
|
18
|
+
context.removed_game_id = game.id
|
|
19
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
20
|
+
context.fail!(
|
|
21
|
+
application_error: Jumbotron::Errors::Sandbox::ProjectionRejectedError.new(
|
|
22
|
+
details: { code: "sandbox_projection_incomplete", message: e.message }
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Jumbotron
|
|
6
|
+
module Services
|
|
7
|
+
module Sandbox
|
|
8
|
+
class ResolveGameObservation < CommandTower::Services::ApplicationService
|
|
9
|
+
validate :game_id, required: true
|
|
10
|
+
validate :observed_at, required: true
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
game = resolve_game
|
|
14
|
+
return if game.nil?
|
|
15
|
+
|
|
16
|
+
plan = resolve_plan(game)
|
|
17
|
+
return if plan.nil?
|
|
18
|
+
|
|
19
|
+
assign_observation(game, plan)
|
|
20
|
+
rescue ArgumentError, TypeError => e
|
|
21
|
+
fail_invalid_progression(e)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def resolve_game
|
|
27
|
+
game = Game.find_by(id: game_id)
|
|
28
|
+
return game if sandbox_game?(game)
|
|
29
|
+
|
|
30
|
+
context.fail!(application_error: Errors::Sandbox::InvalidGameError.new)
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def assign_observation(game, plan)
|
|
35
|
+
game.reload
|
|
36
|
+
context.plan = plan
|
|
37
|
+
context.scheduled_at = game.scheduled_at
|
|
38
|
+
context.observation = observation_for(game, plan)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fail_invalid_progression(error)
|
|
42
|
+
context.fail!(
|
|
43
|
+
application_error: Errors::Sandbox::InvalidProgressionStateError.new(
|
|
44
|
+
details: { message: error.message }
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def sandbox_game?(game)
|
|
50
|
+
return false if game.nil? || game.league.name != "nfl-sandbox"
|
|
51
|
+
|
|
52
|
+
game.provider_identities.any? do |identity|
|
|
53
|
+
identity.provider == "sandbox" && identity.object_namespace == "event"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def resolve_plan(game)
|
|
58
|
+
game.with_lock do
|
|
59
|
+
game.reload
|
|
60
|
+
game.sandbox_game_plan || create_plan(game)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def create_plan(game)
|
|
65
|
+
lines = Canonical::DeriveConsensusLine.call(game: game)
|
|
66
|
+
return fail_missing_lines unless lines.success?
|
|
67
|
+
|
|
68
|
+
candidates = outcome_candidates(Array(lines.data[:consensus_lines]))
|
|
69
|
+
return fail_missing_lines if candidates.empty?
|
|
70
|
+
|
|
71
|
+
selected = candidates.fetch(SecureRandom.random_number(candidates.length))
|
|
72
|
+
game.create_sandbox_game_plan!(
|
|
73
|
+
home_score: selected.home_score,
|
|
74
|
+
away_score: selected.away_score,
|
|
75
|
+
selected_at: observed_at
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def outcome_candidates(lines)
|
|
80
|
+
spread = lines.find { |line| line.market == "spread" && line.outcome == "home" }
|
|
81
|
+
total = lines.find { |line| line.market == "total" && line.outcome == "over" }
|
|
82
|
+
return [] if spread.nil? || total.nil?
|
|
83
|
+
|
|
84
|
+
Jumbotron::Adapters::Sandbox::NflHelpers::OutcomeCandidates.call(
|
|
85
|
+
home_spread: spread.line_value,
|
|
86
|
+
total: total.line_value
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def fail_missing_lines
|
|
91
|
+
context.fail!(application_error: Errors::Sandbox::OutcomeLineUnavailableError.new)
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def observation_for(game, plan)
|
|
96
|
+
Jumbotron::Adapters::Sandbox::NflHelpers::SyntheticObservation.call(
|
|
97
|
+
now: observed_at,
|
|
98
|
+
kickoff: game.scheduled_at,
|
|
99
|
+
plan: plan,
|
|
100
|
+
current: Jumbotron::Adapters::Sandbox::NflHelpers::CurrentObservationState.call(game)
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Services
|
|
5
|
+
module Sandbox
|
|
6
|
+
class SeedProjectedGame < CommandTower::Services::ApplicationService
|
|
7
|
+
validate :game, required: true
|
|
8
|
+
validate :observed_at, required: true
|
|
9
|
+
validate :change_set, required: true
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
clear_previous_generation
|
|
13
|
+
return unless persist_lines
|
|
14
|
+
return unless usable_consensus?
|
|
15
|
+
return unless apply_scoreboard
|
|
16
|
+
|
|
17
|
+
context.game = game.reload
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def clear_previous_generation
|
|
23
|
+
game.sandbox_game_plan&.destroy!
|
|
24
|
+
game.line_observations.destroy_all
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def persist_lines
|
|
28
|
+
scope = Jumbotron::Adapters::Sandbox::Nfl.line_acquisition_scope_for(game.reload)
|
|
29
|
+
return reject("sandbox line scope is unavailable") if scope.nil?
|
|
30
|
+
|
|
31
|
+
result = Jumbotron::Services::Adapters::ExecuteLineAcquisitionScope.call(
|
|
32
|
+
adapter: Jumbotron::Adapters::Sandbox::Nfl,
|
|
33
|
+
acquisition: scope.fetch(:acquisition),
|
|
34
|
+
observed_at: observed_at
|
|
35
|
+
)
|
|
36
|
+
return true if result.success? && result.data[:outcome] == :succeeded
|
|
37
|
+
|
|
38
|
+
reject("sandbox line persistence failed")
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def usable_consensus? # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
43
|
+
result = Jumbotron::Services::Canonical::DeriveConsensusLine.call(game: game.reload)
|
|
44
|
+
return reject("sandbox consensus could not be derived") unless result.success?
|
|
45
|
+
|
|
46
|
+
lines = Array(result.data[:consensus_lines])
|
|
47
|
+
spread = lines.find { |line| line.market == "spread" && line.outcome == "home" }
|
|
48
|
+
total = lines.find { |line| line.market == "total" && line.outcome == "over" }
|
|
49
|
+
return true if spread && total && spread.line_value == game.sandbox_game_mapping.home_spread &&
|
|
50
|
+
total.line_value == game.sandbox_game_mapping.total
|
|
51
|
+
|
|
52
|
+
reject("sandbox consensus does not match selected line basis")
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def apply_scoreboard # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
57
|
+
scope = Jumbotron::Adapters::Sandbox::Nfl.acquisition_scope_for(game.reload)
|
|
58
|
+
return reject("sandbox scoreboard scope is unavailable") if scope.nil?
|
|
59
|
+
|
|
60
|
+
executed = Jumbotron::Services::Adapters::ExecuteEndpoint.call(
|
|
61
|
+
adapter: Jumbotron::Adapters::Sandbox::Nfl,
|
|
62
|
+
endpoint: :scoreboard,
|
|
63
|
+
observed_at: observed_at,
|
|
64
|
+
acquisition: scope.fetch(:acquisition)
|
|
65
|
+
)
|
|
66
|
+
return fail_from(executed) unless executed.success?
|
|
67
|
+
|
|
68
|
+
input = executed.data[:sync_input].games.first
|
|
69
|
+
result = Jumbotron::Services::Canonical::UpsertGameGraph.call(
|
|
70
|
+
game_input: input,
|
|
71
|
+
league: game.league,
|
|
72
|
+
observed_at: observed_at,
|
|
73
|
+
change_set: change_set
|
|
74
|
+
)
|
|
75
|
+
return true if result.success?
|
|
76
|
+
|
|
77
|
+
fail_from(result)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def fail_from(result)
|
|
81
|
+
context.fail!(application_error: Array(result.errors).first)
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def reject(message)
|
|
86
|
+
context.fail!(
|
|
87
|
+
application_error: Jumbotron::Errors::Sandbox::ProjectionRejectedError.new(
|
|
88
|
+
details: { code: "sandbox_projection_incomplete", message: message }
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Jumbotron
|
|
7
|
+
module Services
|
|
8
|
+
module Sandbox
|
|
9
|
+
class SelectLineBasis < CommandTower::Services::ApplicationService
|
|
10
|
+
SPREADS = (-21..21).reject(&:zero?).map { |value| BigDecimal(value.to_s) / 2 }.freeze
|
|
11
|
+
TOTALS = (75..109).map { |value| BigDecimal(value.to_s) / 2 }.freeze
|
|
12
|
+
|
|
13
|
+
validate :source_game_id, required: true
|
|
14
|
+
validate :reset_fingerprint, required: true
|
|
15
|
+
validate :source_spread, required: false
|
|
16
|
+
validate :source_total, required: false
|
|
17
|
+
|
|
18
|
+
def call # rubocop:disable Metrics/AbcSize
|
|
19
|
+
source_pair = normalized_source_pair
|
|
20
|
+
pair = source_pair || fallback_pair
|
|
21
|
+
context.home_spread = pair.fetch(:home_spread)
|
|
22
|
+
context.total = pair.fetch(:total)
|
|
23
|
+
context.line_source = source_pair ? "source_consensus" : "synthetic_fallback"
|
|
24
|
+
context.line_fingerprint = Digest::SHA256.hexdigest(
|
|
25
|
+
"#{reset_fingerprint}:#{source_game_id}:#{context.home_spread}:#{context.total}"
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def normalized_source_pair
|
|
32
|
+
return if source_spread.nil? || source_total.nil?
|
|
33
|
+
|
|
34
|
+
spread = round_half(source_spread)
|
|
35
|
+
total = round_half(source_total)
|
|
36
|
+
return unless total.positive? && implied_scores(spread, total).all?(&:positive?)
|
|
37
|
+
|
|
38
|
+
{ home_spread: spread, total: total }
|
|
39
|
+
rescue ArgumentError, TypeError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fallback_pair
|
|
44
|
+
candidates = weighted_fallback_candidates
|
|
45
|
+
index = Digest::SHA256.hexdigest("#{reset_fingerprint}:#{source_game_id}").to_i(16) % candidates.length
|
|
46
|
+
candidates.fetch(index)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def weighted_fallback_candidates
|
|
50
|
+
SPREADS.product(TOTALS).filter_map do |spread, total|
|
|
51
|
+
next unless implied_scores(spread, total).all? { |score| score.between?(10, 38) }
|
|
52
|
+
|
|
53
|
+
pair = { home_spread: spread, total: total }
|
|
54
|
+
weight = (spread.abs <= 6.5 ? 2 : 1) * (total.between?(41.5, 48.5) ? 2 : 1)
|
|
55
|
+
Array.new(weight, pair)
|
|
56
|
+
end.flatten
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def implied_scores(spread, total)
|
|
60
|
+
[(total - spread) / 2, (total + spread) / 2]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def round_half(value)
|
|
64
|
+
BigDecimal((BigDecimal(value.to_s) * 2).round.to_s) / 2
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Services
|
|
5
|
+
module Sandbox
|
|
6
|
+
class ValidateProjectedSeason < CommandTower::Services::ApplicationService
|
|
7
|
+
validate :projection, required: true
|
|
8
|
+
validate :snapshot, required: true
|
|
9
|
+
validate :now, required: true
|
|
10
|
+
|
|
11
|
+
def call # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
12
|
+
mappings = projection.sandbox_game_mappings.includes(
|
|
13
|
+
sandbox_game: %i[sandbox_game_plan provider_identities line_observations schedule_group]
|
|
14
|
+
).to_a
|
|
15
|
+
return reject("mapping coverage is incomplete") unless mapping_coverage?(mappings)
|
|
16
|
+
return reject("sandbox game identity is invalid") unless mappings.all? { |mapping| valid_identity?(mapping) }
|
|
17
|
+
|
|
18
|
+
complete = mappings.all? { |mapping| complete_state?(mapping) }
|
|
19
|
+
return reject("sandbox lines or outcomes are incomplete") unless complete
|
|
20
|
+
return reject("sandbox week windows overlap") unless non_overlapping?(mappings)
|
|
21
|
+
|
|
22
|
+
start_games = mappings.filter_map do |mapping|
|
|
23
|
+
game = mapping.sandbox_game
|
|
24
|
+
game if game.schedule_group.number == snapshot.start_week
|
|
25
|
+
end
|
|
26
|
+
context.start_week_game_count = start_games.count
|
|
27
|
+
context.naturally_locked_game_count = start_games.count { |game| game.scheduled_at <= now }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def mapping_coverage?(mappings)
|
|
33
|
+
mappings.map(&:source_game_id).sort == snapshot.games.map(&:source_game_id).sort &&
|
|
34
|
+
mappings.map(&:sandbox_game_id).uniq.length == mappings.length
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def valid_identity?(mapping)
|
|
38
|
+
game = mapping.sandbox_game
|
|
39
|
+
game.season_id == projection.sandbox_season_id &&
|
|
40
|
+
game.league.name == "nfl-sandbox" &&
|
|
41
|
+
game.provider_identities.any? do |identity|
|
|
42
|
+
identity.provider == "sandbox" && identity.object_namespace == "event"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def complete_state?(mapping) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
47
|
+
game = mapping.sandbox_game
|
|
48
|
+
return false if game.sandbox_game_plan.nil?
|
|
49
|
+
|
|
50
|
+
result = Jumbotron::Services::Canonical::DeriveConsensusLine.call(game: game)
|
|
51
|
+
return false unless result.success?
|
|
52
|
+
|
|
53
|
+
lines = Array(result.data[:consensus_lines])
|
|
54
|
+
spread = lines.find { |line| line.market == "spread" && line.outcome == "home" }
|
|
55
|
+
total = lines.find { |line| line.market == "total" && line.outcome == "over" }
|
|
56
|
+
spread&.line_value == mapping.home_spread && total&.line_value == mapping.total
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def non_overlapping?(mappings)
|
|
60
|
+
windows = mappings.group_by { |mapping| mapping.sandbox_game.schedule_group_id }.values.map do |members|
|
|
61
|
+
times = members.map { |mapping| mapping.sandbox_game.scheduled_at }
|
|
62
|
+
times.minmax
|
|
63
|
+
end.sort_by(&:first)
|
|
64
|
+
windows.each_cons(2).all? { |left, right| left.last < right.first }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def reject(message)
|
|
68
|
+
context.fail!(
|
|
69
|
+
application_error: Jumbotron::Errors::Sandbox::ProjectionRejectedError.new(
|
|
70
|
+
details: { code: "sandbox_projection_incomplete", message: message }
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -21,8 +21,9 @@ module Jumbotron
|
|
|
21
21
|
error = Array(result.errors).first
|
|
22
22
|
code = error.respond_to?(:code) ? error.code.to_s : "invalid_request"
|
|
23
23
|
message = error.respond_to?(:message) ? error.message : error.to_s
|
|
24
|
+
details = error.respond_to?(:details) && error.details.is_a?(Hash) ? error.details.except(:message) : {}
|
|
24
25
|
status = Public::TranslateOutcome::NOT_FOUND_CODES.include?(code) ? :not_found : :unprocessable_entity
|
|
25
|
-
failure(errors: [{ code: code, message: message }], http_status: status)
|
|
26
|
+
failure(errors: [{ code: code, message: message, details: details }], http_status: status)
|
|
26
27
|
end
|
|
27
28
|
end
|
|
28
29
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Workflows
|
|
5
|
+
module Sandbox
|
|
6
|
+
class ReadProjectionWorkflow < CommandTower::Workflows::ApplicationWorkflow
|
|
7
|
+
include Jumbotron::Workflows::PublicRead
|
|
8
|
+
|
|
9
|
+
retry_strategy :none
|
|
10
|
+
|
|
11
|
+
def call(**kwargs)
|
|
12
|
+
deserialized = Jumbotron::Deserializers::Public::SandboxProjection.call(kwargs)
|
|
13
|
+
return map_deserializer_failure(deserialized) unless deserialized.success?
|
|
14
|
+
|
|
15
|
+
read = Jumbotron::Services::Public::ReadSandboxProjection.call(name: deserialized.input.name)
|
|
16
|
+
return map_service_failure(read) unless read.success?
|
|
17
|
+
|
|
18
|
+
assembled = Jumbotron::Services::Public::AssembleSandboxProjection.call(
|
|
19
|
+
projection: read.data[:projection]
|
|
20
|
+
)
|
|
21
|
+
return map_service_failure(assembled) unless assembled.success?
|
|
22
|
+
|
|
23
|
+
success(payload: { sandbox_projection: assembled.data[:public_projection] }, http_status: :ok)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jumbotron
|
|
4
|
+
module Workflows
|
|
5
|
+
module Sandbox
|
|
6
|
+
class RegisterProjectionWorkflow < CommandTower::Workflows::ApplicationWorkflow
|
|
7
|
+
include Jumbotron::Workflows::PublicRead
|
|
8
|
+
|
|
9
|
+
retry_strategy :none
|
|
10
|
+
|
|
11
|
+
def call(**kwargs)
|
|
12
|
+
deserialized = Jumbotron::Deserializers::Public::RegisterSandboxProjection.call(kwargs)
|
|
13
|
+
return map_deserializer_failure(deserialized) unless deserialized.success?
|
|
14
|
+
|
|
15
|
+
transaction do
|
|
16
|
+
registered = Jumbotron::Services::Sandbox::RegisterProjection.call(request: deserialized.input)
|
|
17
|
+
fail_transaction!(map_service_failure(registered)) unless registered.success?
|
|
18
|
+
|
|
19
|
+
assembled = Jumbotron::Services::Public::AssembleSandboxProjection.call(
|
|
20
|
+
projection: registered.data[:projection]
|
|
21
|
+
)
|
|
22
|
+
fail_transaction!(map_service_failure(assembled)) unless assembled.success?
|
|
23
|
+
|
|
24
|
+
success(
|
|
25
|
+
payload: { sandbox_projection: assembled.data[:public_projection] },
|
|
26
|
+
http_status: :ok
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|