jumbotron 0.1.1

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.
Files changed (161) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +5 -0
  4. data/app/adapters/jumbotron/adapters/base.rb +136 -0
  5. data/app/adapters/jumbotron/adapters/cadence.rb +42 -0
  6. data/app/adapters/jumbotron/adapters/discovery_definition.rb +7 -0
  7. data/app/adapters/jumbotron/adapters/espn/nfl.rb +169 -0
  8. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/acquisition_scope.rb +29 -0
  9. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/competition_odds.rb +185 -0
  10. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/lifecycle.rb +30 -0
  11. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/line_acquisition_scope.rb +26 -0
  12. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/progress.rb +54 -0
  13. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/scoreboard.rb +146 -0
  14. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/season_phase.rb +33 -0
  15. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/teams.rb +35 -0
  16. data/app/adapters/jumbotron/adapters/line_acquisition.rb +19 -0
  17. data/app/adapters/jumbotron/adapters/operation.rb +23 -0
  18. data/app/adapters/jumbotron/adapters/policy_definition.rb +14 -0
  19. data/app/adapters/jumbotron/adapters/registry.rb +78 -0
  20. data/app/adapters/jumbotron/adapters.rb +11 -0
  21. data/app/boot/jumbotron/boot/enqueue_discoveries.rb +41 -0
  22. data/app/clients/jumbotron/clients/espn/competition_odds/get.rb +26 -0
  23. data/app/clients/jumbotron/clients/espn/competition_odds/get_input.rb +16 -0
  24. data/app/clients/jumbotron/clients/espn/scoreboard/get.rb +18 -0
  25. data/app/clients/jumbotron/clients/espn/scoreboard/get_input.rb +17 -0
  26. data/app/clients/jumbotron/clients/espn/teams/get.rb +18 -0
  27. data/app/clients/jumbotron/clients/espn/teams/get_input.rb +15 -0
  28. data/app/clients/jumbotron/clients/espn.rb +44 -0
  29. data/app/clients/jumbotron/clients.rb +8 -0
  30. data/app/deserializers/jumbotron/deserializers/clients/espn/competition.rb +79 -0
  31. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/draw_odds.rb +31 -0
  32. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/get.rb +66 -0
  33. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/item.rb +60 -0
  34. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/listed_provider.rb +31 -0
  35. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/market_window.rb +31 -0
  36. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/point_spread.rb +27 -0
  37. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/price.rb +35 -0
  38. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/side_quote.rb +35 -0
  39. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/team_odds.rb +40 -0
  40. data/app/deserializers/jumbotron/deserializers/clients/espn/competitor.rb +49 -0
  41. data/app/deserializers/jumbotron/deserializers/clients/espn/event.rb +81 -0
  42. data/app/deserializers/jumbotron/deserializers/clients/espn/scoreboard/get.rb +88 -0
  43. data/app/deserializers/jumbotron/deserializers/clients/espn/season.rb +27 -0
  44. data/app/deserializers/jumbotron/deserializers/clients/espn/status.rb +69 -0
  45. data/app/deserializers/jumbotron/deserializers/clients/espn/support.rb +61 -0
  46. data/app/deserializers/jumbotron/deserializers/clients/espn/team.rb +42 -0
  47. data/app/deserializers/jumbotron/deserializers/clients/espn/teams/get.rb +76 -0
  48. data/app/deserializers/jumbotron/deserializers/clients/espn/venue.rb +34 -0
  49. data/app/deserializers/jumbotron/deserializers/clients/espn/week.rb +23 -0
  50. data/app/deserializers/jumbotron/deserializers/public/game.rb +26 -0
  51. data/app/deserializers/jumbotron/deserializers/public/game_ids.rb +28 -0
  52. data/app/deserializers/jumbotron/deserializers/public/includes.rb +32 -0
  53. data/app/deserializers/jumbotron/deserializers/public/schedule.rb +151 -0
  54. data/app/deserializers/jumbotron/deserializers/public/schedule_groups.rb +91 -0
  55. data/app/errors/jumbotron/error.rb +6 -0
  56. data/app/errors/jumbotron/errors/adapters.rb +143 -0
  57. data/app/errors/jumbotron/errors/canonical.rb +67 -0
  58. data/app/errors/jumbotron/errors/public.rb +27 -0
  59. data/app/errors/jumbotron/invalid_request_error.rb +6 -0
  60. data/app/errors/jumbotron/not_found_error.rb +6 -0
  61. data/app/errors/jumbotron/unsupported_capability_error.rb +6 -0
  62. data/app/jobs/jumbotron/application_job.rb +6 -0
  63. data/app/jobs/jumbotron/discovery_job.rb +14 -0
  64. data/app/jobs/jumbotron/game_update_job.rb +12 -0
  65. data/app/jobs/jumbotron/line_update_job.rb +12 -0
  66. data/app/models/jumbotron/application_record.rb +8 -0
  67. data/app/models/jumbotron/bookmaker.rb +11 -0
  68. data/app/models/jumbotron/canonical.rb +128 -0
  69. data/app/models/jumbotron/game.rb +92 -0
  70. data/app/models/jumbotron/game_participant.rb +10 -0
  71. data/app/models/jumbotron/historical_change.rb +9 -0
  72. data/app/models/jumbotron/league.rb +12 -0
  73. data/app/models/jumbotron/line_observation.rb +18 -0
  74. data/app/models/jumbotron/observation_batch.rb +14 -0
  75. data/app/models/jumbotron/provider_identity.rb +32 -0
  76. data/app/models/jumbotron/public/translate_outcome.rb +57 -0
  77. data/app/models/jumbotron/public.rb +88 -0
  78. data/app/models/jumbotron/schedule_group.rb +22 -0
  79. data/app/models/jumbotron/season.rb +13 -0
  80. data/app/models/jumbotron/season_phase.rb +12 -0
  81. data/app/models/jumbotron/sport.rb +10 -0
  82. data/app/models/jumbotron/team.rb +11 -0
  83. data/app/models/jumbotron/venue.rb +10 -0
  84. data/app/providers/jumbotron/providers/espn/cooldown.rb +88 -0
  85. data/app/providers/jumbotron/providers/espn/cooldown_active_error.rb +13 -0
  86. data/app/providers/jumbotron/providers/espn/provider_failure.rb +40 -0
  87. data/app/providers/jumbotron/providers/espn/resources/competition_odds.rb +29 -0
  88. data/app/providers/jumbotron/providers/espn/resources/scoreboard.rb +90 -0
  89. data/app/providers/jumbotron/providers/espn/resources/teams.rb +19 -0
  90. data/app/scheduling/jumbotron/scheduling/backends/sidekiq_cron.rb +70 -0
  91. data/app/scheduling/jumbotron/scheduling/backends/solid_queue.rb +92 -0
  92. data/app/scheduling/jumbotron/scheduling/clock.rb +61 -0
  93. data/app/scheduling/jumbotron/scheduling/desired_schedules.rb +51 -0
  94. data/app/scheduling/jumbotron/scheduling/detect_integration.rb +45 -0
  95. data/app/scheduling/jumbotron/scheduling/materializer.rb +13 -0
  96. data/app/scheduling/jumbotron/scheduling/owned.rb +19 -0
  97. data/app/scheduling/jumbotron/scheduling/schedule_definition.rb +7 -0
  98. data/app/serializers/jumbotron/serializers/clients/espn/competition_odds/get.rb +17 -0
  99. data/app/serializers/jumbotron/serializers/clients/espn/scoreboard/get.rb +22 -0
  100. data/app/serializers/jumbotron/serializers/clients/espn/teams/get.rb +20 -0
  101. data/app/services/jumbotron/services/adapters/build_acquisition_scopes.rb +37 -0
  102. data/app/services/jumbotron/services/adapters/build_line_acquisition_scopes.rb +39 -0
  103. data/app/services/jumbotron/services/adapters/ensure_league.rb +29 -0
  104. data/app/services/jumbotron/services/adapters/execute_endpoint.rb +49 -0
  105. data/app/services/jumbotron/services/adapters/execute_line_acquisition_scope.rb +75 -0
  106. data/app/services/jumbotron/services/adapters/execute_synchronization_scope.rb +64 -0
  107. data/app/services/jumbotron/services/adapters/resolve_discovery.rb +53 -0
  108. data/app/services/jumbotron/services/adapters/resolve_game_update_policy.rb +74 -0
  109. data/app/services/jumbotron/services/adapters/resolve_line_update_policy.rb +74 -0
  110. data/app/services/jumbotron/services/adapters/select_eligible_games.rb +41 -0
  111. data/app/services/jumbotron/services/adapters/validate_sync_request.rb +51 -0
  112. data/app/services/jumbotron/services/canonical/attach_provider_identities.rb +59 -0
  113. data/app/services/jumbotron/services/canonical/derive_consensus_for_games.rb +18 -0
  114. data/app/services/jumbotron/services/canonical/derive_consensus_line.rb +70 -0
  115. data/app/services/jumbotron/services/canonical/ensure_bookmaker.rb +70 -0
  116. data/app/services/jumbotron/services/canonical/ensure_schedule_group.rb +40 -0
  117. data/app/services/jumbotron/services/canonical/ensure_season.rb +22 -0
  118. data/app/services/jumbotron/services/canonical/ensure_season_phase.rb +22 -0
  119. data/app/services/jumbotron/services/canonical/persist_line_observations.rb +145 -0
  120. data/app/services/jumbotron/services/canonical/record_observation_evidence.rb +51 -0
  121. data/app/services/jumbotron/services/canonical/resolve_current_lines.rb +21 -0
  122. data/app/services/jumbotron/services/canonical/resolve_current_lines_for_games.rb +69 -0
  123. data/app/services/jumbotron/services/canonical/upsert_game.rb +186 -0
  124. data/app/services/jumbotron/services/canonical/upsert_game_participant.rb +88 -0
  125. data/app/services/jumbotron/services/canonical/upsert_team.rb +90 -0
  126. data/app/services/jumbotron/services/canonical/upsert_venue.rb +92 -0
  127. data/app/services/jumbotron/services/public/assemble_public_game.rb +131 -0
  128. data/app/services/jumbotron/services/public/assemble_public_schedule.rb +63 -0
  129. data/app/services/jumbotron/services/public/derive_consensus_for_games.rb +21 -0
  130. data/app/services/jumbotron/services/public/load_games_by_ids.rb +18 -0
  131. data/app/services/jumbotron/services/public/load_included_lines.rb +47 -0
  132. data/app/services/jumbotron/services/public/map_public_consensus_lines.rb +44 -0
  133. data/app/services/jumbotron/services/public/map_public_current_lines.rb +27 -0
  134. data/app/services/jumbotron/services/public/read_game.rb +32 -0
  135. data/app/services/jumbotron/services/public/read_schedule.rb +116 -0
  136. data/app/services/jumbotron/services/public/read_schedule_groups.rb +90 -0
  137. data/app/services/jumbotron/services/public/resolve_current_lines_for_games.rb +21 -0
  138. data/app/synchronization/jumbotron/synchronization/lease.rb +127 -0
  139. data/app/workflows/jumbotron/workflows/execute_discovery_workflow.rb +131 -0
  140. data/app/workflows/jumbotron/workflows/execute_game_update_policy_workflow.rb +159 -0
  141. data/app/workflows/jumbotron/workflows/execute_line_update_policy_workflow.rb +156 -0
  142. data/app/workflows/jumbotron/workflows/public_read.rb +29 -0
  143. data/app/workflows/jumbotron/workflows/read_consensus_workflow.rb +37 -0
  144. data/app/workflows/jumbotron/workflows/read_current_lines_workflow.rb +27 -0
  145. data/app/workflows/jumbotron/workflows/read_game_workflow.rb +34 -0
  146. data/app/workflows/jumbotron/workflows/read_schedule_groups_workflow.rb +21 -0
  147. data/app/workflows/jumbotron/workflows/read_schedule_workflow.rb +48 -0
  148. data/app/workflows/jumbotron/workflows/synchronize_adapter_workflow.rb +183 -0
  149. data/db/migrate/20260812000001_create_jumbotron_foundational_catalog.rb +68 -0
  150. data/db/migrate/20260812000002_create_jumbotron_game_graph.rb +70 -0
  151. data/db/migrate/20260812000003_add_freshness_to_jumbotron_provider_mutated_entities.rb +31 -0
  152. data/db/migrate/20260812000004_create_jumbotron_observation_evidence.rb +34 -0
  153. data/db/migrate/20260813000001_create_jumbotron_schedule_groups.rb +24 -0
  154. data/db/migrate/20260814000001_create_jumbotron_line_observations.rb +38 -0
  155. data/db/migrate/20260820000001_add_progress_to_jumbotron_games.rb +14 -0
  156. data/lib/jumbotron/client.rb +40 -0
  157. data/lib/jumbotron/engine.rb +32 -0
  158. data/lib/jumbotron/version.rb +5 -0
  159. data/lib/jumbotron.rb +13 -0
  160. data/lib/tasks/jumbotron_schedules.rake +12 -0
  161. metadata +239 -0
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Adapters
6
+ class ResolveLineUpdatePolicy < CommandTower::Services::ApplicationService
7
+ LINE_POLICY_TYPES = %i[
8
+ future_line_update
9
+ live_line_update
10
+ interrupted_line_update
11
+ ].freeze
12
+
13
+ validate :adapter_id, required: true
14
+ validate :policy_id, required: true
15
+
16
+ def call
17
+ Jumbotron::Adapters::Registry.ensure_loaded!
18
+ adapter = Jumbotron::Adapters::Registry.find(adapter_id)
19
+ if adapter.nil?
20
+ context.fail!(
21
+ application_error: Jumbotron::Errors::Adapters::UnknownAdapterError.new(
22
+ details: { adapter_id: adapter_id }
23
+ )
24
+ )
25
+ return
26
+ end
27
+
28
+ unless adapter.registered?
29
+ context.fail!(
30
+ application_error: Jumbotron::Errors::Adapters::NotRegisteredError.new(
31
+ details: { adapter: adapter.to_s }
32
+ )
33
+ )
34
+ return
35
+ end
36
+
37
+ key = policy_id.to_sym
38
+ if adapter.discovery_ids.include?(key)
39
+ context.fail!(
40
+ application_error: Jumbotron::Errors::Adapters::DiscoveryNotExecutableError.new(
41
+ details: { policy_id: policy_id }
42
+ )
43
+ )
44
+ return
45
+ end
46
+
47
+ policy = lookup_policy(adapter, key)
48
+ return if policy.nil?
49
+
50
+ unless LINE_POLICY_TYPES.include?(policy.type)
51
+ context.fail!(application_error: Jumbotron::Errors::Adapters::InvalidLinePolicyTypeError.new)
52
+ return
53
+ end
54
+
55
+ context.adapter = adapter
56
+ context.policy = policy
57
+ end
58
+
59
+ private
60
+
61
+ def lookup_policy(adapter, key)
62
+ adapter.policy(key)
63
+ rescue Jumbotron::Adapters::UnknownPolicyError
64
+ context.fail!(
65
+ application_error: Jumbotron::Errors::Adapters::UnknownPolicyError.new(
66
+ details: { policy_id: policy_id }
67
+ )
68
+ )
69
+ nil
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Adapters
6
+ class SelectEligibleGames < CommandTower::Services::ApplicationService
7
+ validate :adapter, required: true
8
+ validate :policy, required: true
9
+ validate :now, required: true
10
+
11
+ def call
12
+ league = find_league
13
+ if league.nil?
14
+ context.fail!(
15
+ application_error: Jumbotron::Errors::Adapters::LeagueNotFoundError.new(
16
+ details: { league_id: "#{adapter.sport}/#{adapter.league}" }
17
+ )
18
+ )
19
+ return
20
+ end
21
+
22
+ games = Game.where(league_id: league.id)
23
+ .includes(:season, :season_phase, :schedule_group)
24
+ .select { |game| policy.eligible?(game, now: now) }
25
+
26
+ context.league = league
27
+ context.games = games
28
+ end
29
+
30
+ private
31
+
32
+ def find_league
33
+ sport = Sport.where("LOWER(name) = ?", adapter.sport.downcase).first
34
+ return if sport.nil?
35
+
36
+ sport.leagues.where("LOWER(name) = ?", adapter.league.downcase).first
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Adapters
6
+ class ValidateSyncRequest < CommandTower::Services::ApplicationService
7
+ validate :adapter, required: true
8
+ validate :endpoint, required: true
9
+ validate :league_id, required: true
10
+
11
+ def call
12
+ unless adapter.respond_to?(:registered?) && adapter.respond_to?(:operation) &&
13
+ adapter.respond_to?(:endpoints)
14
+ context.fail!(application_error: Jumbotron::Errors::Adapters::InvalidAdapterError.new)
15
+ return
16
+ end
17
+
18
+ unless adapter.registered?
19
+ context.fail!(
20
+ application_error: Jumbotron::Errors::Adapters::NotRegisteredError.new(
21
+ details: { adapter: adapter.to_s }
22
+ )
23
+ )
24
+ return
25
+ end
26
+
27
+ unless adapter.endpoints.key?(endpoint.to_sym)
28
+ context.fail!(
29
+ application_error: Jumbotron::Errors::Adapters::UnknownEndpointError.new(
30
+ details: { endpoint: endpoint }
31
+ )
32
+ )
33
+ return
34
+ end
35
+
36
+ league = Jumbotron::League.find_by(id: league_id)
37
+ if league.nil?
38
+ context.fail!(
39
+ application_error: Jumbotron::Errors::Adapters::LeagueNotFoundError.new(
40
+ details: { league_id: league_id }
41
+ )
42
+ )
43
+ return
44
+ end
45
+
46
+ context.league = league
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Canonical
6
+ class AttachProviderIdentities < CommandTower::Services::ApplicationService
7
+ validate :target, required: true
8
+ validate :refs, is_a: Array, required: true
9
+
10
+ def call
11
+ conflict = refs.find { |ref| conflicting_identity?(ref) }
12
+ if conflict
13
+ context.fail!(
14
+ application_error: Jumbotron::Errors::Canonical::ProviderIdentityConflictError.new
15
+ )
16
+ return
17
+ end
18
+
19
+ refs.each { |ref| ensure_identity!(ref) }
20
+ context.target = target
21
+ rescue ActiveRecord::RecordInvalid => e
22
+ context.fail!(
23
+ application_error: Jumbotron::Errors::Canonical::MutationFailedError.new(
24
+ details: { message: e.message }
25
+ )
26
+ )
27
+ end
28
+
29
+ private
30
+
31
+ def conflicting_identity?(ref)
32
+ identity = find_identity(ref)
33
+ return false unless identity
34
+
35
+ identity.target_type != target.class.name || identity.target_id != target.id
36
+ end
37
+
38
+ def ensure_identity!(ref)
39
+ return if find_identity(ref)
40
+
41
+ ProviderIdentity.create!(
42
+ provider: ref.provider,
43
+ object_namespace: ref.namespace,
44
+ provider_id: ref.id,
45
+ target: target
46
+ )
47
+ end
48
+
49
+ def find_identity(ref)
50
+ ProviderIdentity.find_by(
51
+ provider: ref.provider,
52
+ object_namespace: ref.namespace,
53
+ provider_id: ref.id
54
+ )
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Canonical
6
+ class DeriveConsensusForGames < CommandTower::Services::ApplicationService
7
+ validate :current_lines, required: false
8
+
9
+ def call
10
+ grouped = Array(current_lines).group_by(&:game_id)
11
+ context.consensus_lines = grouped.flat_map do |game_id, lines|
12
+ DeriveConsensusLine.from_current_lines(game_id: game_id, current_lines: lines)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Jumbotron
6
+ module Services
7
+ module Canonical
8
+ class DeriveConsensusLine < CommandTower::Services::ApplicationService
9
+ CONSENSUS_MARKETS = %w[spread total].freeze
10
+ LIVE_ODDS_NAME = /\blive odds\b/i
11
+
12
+ validate :game, required: true
13
+ validate :current_lines, required: false
14
+
15
+ def call
16
+ currents = current_lines
17
+ if currents.nil?
18
+ resolved = ResolveCurrentLines.call(game: game)
19
+ unless resolved.success?
20
+ context.fail!(application_error: resolved.errors.first)
21
+ return
22
+ end
23
+ currents = Array(resolved.data[:current_lines])
24
+ end
25
+
26
+ context.consensus_lines = self.class.from_current_lines(
27
+ game_id: game.id,
28
+ current_lines: currents
29
+ )
30
+ end
31
+
32
+ def self.from_current_lines(game_id:, current_lines:)
33
+ currents = Array(current_lines)
34
+ latest_at = currents.filter_map(&:observed_at).max
35
+ eligible = currents.select { |line| eligible?(line, latest_at) }
36
+ eligible.group_by { |line| [line.market, line.outcome] }.map do |(_key, members)|
37
+ to_consensus(game_id, members)
38
+ end
39
+ end
40
+
41
+ def self.eligible?(line, latest_at)
42
+ return false unless CONSENSUS_MARKETS.include?(line.market)
43
+ return false if line.line_value.nil?
44
+ return false if line.bookmaker_name.to_s.match?(LIVE_ODDS_NAME)
45
+ return false if latest_at.nil?
46
+
47
+ line.observed_at == latest_at
48
+ end
49
+
50
+ def self.to_consensus(game_id, members)
51
+ values = members.map { |line| BigDecimal(line.line_value.to_s) }
52
+ mean = values.sum / values.size
53
+ Jumbotron::Canonical::ConsensusLine.new(
54
+ game_id: game_id,
55
+ market: members.first.market,
56
+ outcome: members.first.outcome,
57
+ line_value: mean,
58
+ constituent_count: members.size,
59
+ constituents: members.map do |line|
60
+ Jumbotron::Canonical::ConsensusConstituent.new(
61
+ line_observation_id: line.line_observation_id,
62
+ bookmaker_id: line.bookmaker_id
63
+ )
64
+ end
65
+ )
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Canonical
6
+ class EnsureBookmaker < CommandTower::Services::ApplicationService
7
+ validate :identity, required: true
8
+ validate :name, is_a: String, required: true
9
+ validate :observed_at, required: true
10
+
11
+ def call
12
+ existing = find_identity
13
+ bookmaker = if existing&.target.is_a?(Bookmaker)
14
+ update_existing!(existing.target)
15
+ else
16
+ create_new!
17
+ end
18
+
19
+ attach = AttachProviderIdentities.call(target: bookmaker, refs: [identity])
20
+ unless attach.success?
21
+ context.fail!(application_error: attach.errors.first)
22
+ return
23
+ end
24
+
25
+ context.bookmaker = bookmaker
26
+ context.created = existing.nil?
27
+ rescue ActiveRecord::RecordNotUnique
28
+ recovered = find_identity
29
+ context.bookmaker = update_existing!(recovered.target)
30
+ context.created = false
31
+ rescue ActiveRecord::RecordInvalid => e
32
+ context.fail!(
33
+ application_error: Jumbotron::Errors::Canonical::MutationFailedError.new(
34
+ details: { message: e.message }
35
+ )
36
+ )
37
+ end
38
+
39
+ private
40
+
41
+ def find_identity
42
+ ProviderIdentity.find_by(
43
+ provider: identity.provider,
44
+ object_namespace: identity.namespace,
45
+ provider_id: identity.id
46
+ )
47
+ end
48
+
49
+ def create_new!
50
+ Bookmaker.create!(
51
+ name: name,
52
+ observed_at: observed_at,
53
+ changed_at: observed_at
54
+ )
55
+ end
56
+
57
+ def update_existing!(bookmaker)
58
+ locked = Bookmaker.lock.find(bookmaker.id)
59
+ if locked.name != name
60
+ locked.name = name
61
+ locked.changed_at = observed_at
62
+ end
63
+ locked.observed_at = observed_at
64
+ locked.save!
65
+ locked
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Canonical
6
+ class EnsureScheduleGroup < CommandTower::Services::ApplicationService
7
+ validate :season, required: true
8
+ validate :season_phase, required: false
9
+ validate :schedule_group_input, required: false
10
+
11
+ def call
12
+ context.schedule_group = resolve_group
13
+ rescue ActiveRecord::RecordInvalid => e
14
+ context.fail!(
15
+ application_error: Jumbotron::Errors::Canonical::MutationFailedError.new(
16
+ details: { message: e.message }
17
+ )
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ def resolve_group
24
+ input = schedule_group_input
25
+ return if input.nil?
26
+
27
+ group = ScheduleGroup.find_or_initialize_by(
28
+ season_id: season.id,
29
+ season_phase_id: season_phase&.id,
30
+ kind: input.kind.to_s,
31
+ name: input.name.to_s
32
+ )
33
+ group.number = input.number
34
+ group.save!
35
+ group
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Canonical
6
+ class EnsureSeason < CommandTower::Services::ApplicationService
7
+ validate :league, required: true
8
+ validate :year, required: true
9
+
10
+ def call
11
+ context.season = Season.find_or_create_by!(league_id: league.id, name: year.to_s)
12
+ rescue ActiveRecord::RecordInvalid => e
13
+ context.fail!(
14
+ application_error: Jumbotron::Errors::Canonical::MutationFailedError.new(
15
+ details: { message: e.message }
16
+ )
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Canonical
6
+ class EnsureSeasonPhase < CommandTower::Services::ApplicationService
7
+ validate :season, required: true
8
+ validate :phase_key, required: true
9
+
10
+ def call
11
+ context.season_phase = SeasonPhase.find_or_create_by!(season_id: season.id, name: phase_key.to_s)
12
+ rescue ActiveRecord::RecordInvalid => e
13
+ context.fail!(
14
+ application_error: Jumbotron::Errors::Canonical::MutationFailedError.new(
15
+ details: { message: e.message }
16
+ )
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Jumbotron
6
+ module Services
7
+ module Canonical
8
+ class PersistLineObservations < CommandTower::Services::ApplicationService
9
+ validate :ingest, required: true
10
+
11
+ def call
12
+ unless ingest.is_a?(Jumbotron::Canonical::LineIngestInput)
13
+ context.fail!(
14
+ application_error: Jumbotron::Errors::Canonical::InvalidLineIngestError.new(
15
+ details: { message: "ingest must be Canonical::LineIngestInput" }
16
+ )
17
+ )
18
+ return
19
+ end
20
+
21
+ if ingest.game_identities.blank? || ingest.provider.blank? || ingest.adapter_scope.blank? ||
22
+ ingest.observed_at.blank?
23
+ context.fail!(
24
+ application_error: Jumbotron::Errors::Canonical::InvalidLineIngestError.new(
25
+ details: { message: "provider, adapter_scope, observed_at, and game_identities are required" }
26
+ )
27
+ )
28
+ return
29
+ end
30
+
31
+ created = 0
32
+ updated = 0
33
+ bookmakers_created = 0
34
+ resolved_ids = {}
35
+
36
+ transaction do
37
+ game = resolve_game!
38
+ batch = ObservationBatch.create!(
39
+ provider: ingest.provider,
40
+ adapter_scope: ingest.adapter_scope,
41
+ observed_at: ingest.observed_at,
42
+ metadata: {}
43
+ )
44
+
45
+ ingest.observations.each do |row|
46
+ bookmaker = resolved_ids[bookmaker_key(row.bookmaker_identity)]
47
+ unless bookmaker
48
+ ensured = EnsureBookmaker.call(
49
+ identity: row.bookmaker_identity,
50
+ name: row.bookmaker_name,
51
+ observed_at: ingest.observed_at
52
+ )
53
+ fail_transaction!(ensured) unless ensured.success?
54
+
55
+ bookmaker = ensured.data[:bookmaker]
56
+ resolved_ids[bookmaker_key(row.bookmaker_identity)] = bookmaker
57
+ bookmakers_created += 1 if ensured.data[:created]
58
+ end
59
+
60
+ case persist_row!(game, bookmaker, batch, row)
61
+ when :created
62
+ created += 1
63
+ when :updated
64
+ updated += 1
65
+ end
66
+ end
67
+
68
+ context.observation_batch_id = batch.id
69
+ context.bookmakers_resolved = resolved_ids.size
70
+ context.bookmakers_created = bookmakers_created
71
+ context.observations_created = created
72
+ context.observations_updated = updated
73
+ end
74
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
75
+ context.fail!(
76
+ application_error: Jumbotron::Errors::Canonical::MutationFailedError.new(
77
+ details: { message: e.message }
78
+ )
79
+ )
80
+ end
81
+
82
+ private
83
+
84
+ def resolve_game!
85
+ ingest.game_identities.each do |ref|
86
+ identity = ProviderIdentity.find_by(
87
+ provider: ref.provider,
88
+ object_namespace: ref.namespace,
89
+ provider_id: ref.id
90
+ )
91
+ return identity.target if identity&.target.is_a?(Game)
92
+ end
93
+
94
+ fail_transaction!(Jumbotron::Errors::Canonical::GameNotFoundError.new)
95
+ end
96
+
97
+ def persist_row!(game, bookmaker, batch, row)
98
+ existing = LineObservation.lock.where(
99
+ game_id: game.id,
100
+ bookmaker_id: bookmaker.id,
101
+ market: row.market,
102
+ outcome: row.outcome,
103
+ source: row.source
104
+ ).order(observed_at: :desc, id: :desc).first
105
+
106
+ if existing && same_values?(existing, row)
107
+ existing.observed_at = ingest.observed_at
108
+ existing.save!
109
+ return :updated
110
+ end
111
+
112
+ LineObservation.create!(
113
+ game: game,
114
+ bookmaker: bookmaker,
115
+ observation_batch: batch,
116
+ market: row.market,
117
+ outcome: row.outcome,
118
+ source: row.source,
119
+ line_value: row.line_value,
120
+ price_american: row.price_american,
121
+ observed_at: ingest.observed_at,
122
+ changed_at: ingest.observed_at
123
+ )
124
+ :created
125
+ end
126
+
127
+ def same_values?(existing, row)
128
+ same_decimal?(existing.line_value, row.line_value) &&
129
+ existing.price_american == row.price_american
130
+ end
131
+
132
+ def same_decimal?(left, right)
133
+ return true if left.nil? && right.nil?
134
+ return false if left.nil? || right.nil?
135
+
136
+ BigDecimal(left.to_s) == BigDecimal(right.to_s)
137
+ end
138
+
139
+ def bookmaker_key(identity)
140
+ [identity.provider, identity.namespace, identity.id]
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Canonical
6
+ class RecordObservationEvidence < CommandTower::Services::ApplicationService
7
+ validate :change_set, required: true
8
+ validate :provider, is_a: String, required: true
9
+ validate :adapter_scope, is_a: String, required: true
10
+ validate :observed_at, required: true
11
+
12
+ def call
13
+ unless change_set.any?
14
+ context.observation_batch_id = nil
15
+ context.historical_change_count = 0
16
+ return
17
+ end
18
+
19
+ batch = ObservationBatch.create!(
20
+ provider: provider,
21
+ adapter_scope: adapter_scope,
22
+ observed_at: observed_at,
23
+ metadata: {}
24
+ )
25
+
26
+ change_set.each do |entry|
27
+ HistoricalChange.create!(
28
+ observation_batch: batch,
29
+ subject_type: entry.subject.class.name,
30
+ subject_id: entry.subject.id,
31
+ attribute_name: entry.attribute,
32
+ previous_value: entry.previous,
33
+ new_value: entry.new_value,
34
+ observed_at: observed_at,
35
+ provider: provider
36
+ )
37
+ end
38
+
39
+ context.observation_batch_id = batch.id
40
+ context.historical_change_count = change_set.size
41
+ rescue ActiveRecord::RecordInvalid => e
42
+ context.fail!(
43
+ application_error: Jumbotron::Errors::Canonical::ObservationEvidenceFailedError.new(
44
+ details: { message: e.message }
45
+ )
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Canonical
6
+ class ResolveCurrentLines < CommandTower::Services::ApplicationService
7
+ validate :game, required: true
8
+
9
+ def call
10
+ resolved = ResolveCurrentLinesForGames.call(game_ids: [game.id])
11
+ unless resolved.success?
12
+ context.fail!(application_error: resolved.errors.first)
13
+ return
14
+ end
15
+
16
+ context.current_lines = Array(resolved.data[:current_lines])
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end