├── system.properties ├── library ├── settings.gradle.kts ├── src │ └── main │ │ ├── kotlin │ │ └── com │ │ │ └── npd │ │ │ └── betting │ │ │ ├── Props.kt │ │ │ ├── services │ │ │ ├── importer │ │ │ │ ├── HttpClient.kt │ │ │ │ ├── DateSerializer.kt │ │ │ │ ├── CanceledEventUpdater.kt │ │ │ │ ├── LiveEventImporter.kt │ │ │ │ └── EventImporter.kt │ │ │ ├── UserService.kt │ │ │ ├── BetService.kt │ │ │ ├── ResultService.kt │ │ │ └── EventService.kt │ │ │ ├── controllers │ │ │ ├── SinksConfiguration.kt │ │ │ ├── SportController.kt │ │ │ ├── UserController.kt │ │ │ ├── MarketController.kt │ │ │ ├── AccumulatedSink.kt │ │ │ ├── EventController.kt │ │ │ └── BetController.kt │ │ │ ├── repositories │ │ │ └── Repositories.kt │ │ │ └── model │ │ │ └── Entities.kt │ │ └── resources │ │ └── graphql │ │ └── schema.graphqls └── build.gradle.kts ├── application ├── settings.gradle.kts ├── src │ └── main │ │ ├── kotlin │ │ └── com │ │ │ └── npd │ │ │ └── betting │ │ │ ├── App.kt │ │ │ └── AppConfig.kt │ │ └── resources │ │ ├── logback-spring.xml │ │ └── application.yml ├── fly.toml ├── Dockerfile └── build.gradle.kts ├── betting-api ├── settings.gradle.kts ├── src │ └── main │ │ ├── kotlin │ │ └── com │ │ │ └── npd │ │ │ └── betting │ │ │ ├── App.kt │ │ │ ├── AppConfig.kt │ │ │ └── SecurityConfig.kt │ │ └── resources │ │ ├── logback-spring.xml │ │ └── application.yml ├── fly.toml ├── BetApi.Dockerfile └── build.gradle.kts ├── Procfile ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── settings.gradle.kts ├── .gitignore ├── test └── run.sh ├── .dockerignore ├── fly.toml ├── populate-db.sql ├── gradlew.bat ├── README.md └── gradlew /system.properties: -------------------------------------------------------------------------------- 1 | java.runtime.version=17 2 | -------------------------------------------------------------------------------- /library/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "library" 2 | -------------------------------------------------------------------------------- /application/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "application" 2 | -------------------------------------------------------------------------------- /betting-api/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "betting-api" 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: java -Dserver.port=$PORT $JAVA_OPTS -jar build/libs/ultrabet-0.0.1-SNAPSHOT.jar 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anssip/ultrabet/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "parabolic" 2 | 3 | include("library") 4 | include("application") 5 | include("betting-api") 6 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /betting-api/src/main/kotlin/com/npd/betting/App.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | import org.springframework.scheduling.annotation.EnableScheduling 6 | 7 | @SpringBootApplication 8 | open class BettingGraphqlApi { 9 | companion object { 10 | @JvmStatic 11 | fun main(args: Array) { 12 | runApplication(*args) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/Props.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties 4 | import org.springframework.stereotype.Component 5 | 6 | @ConfigurationProperties("ultrabet") 7 | @Component 8 | class Props { 9 | private lateinit var oddsApiKey: String 10 | 11 | fun setOddsApiKey(oddsApiKey: String) { 12 | this.oddsApiKey = oddsApiKey 13 | } 14 | 15 | fun getOddsApiKey(): String { 16 | return oddsApiKey 17 | } 18 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Operating System Files 2 | 3 | *.DS_Store 4 | Thumbs.db 5 | *~ 6 | .#* 7 | #* 8 | *# 9 | 10 | # Build Files # 11 | 12 | bin 13 | target 14 | build/ 15 | .gradle 16 | 17 | # Eclipse Project Files # 18 | 19 | .classpath 20 | .project 21 | .settings 22 | .attach_pid* 23 | 24 | # IntelliJ IDEA Files # 25 | 26 | *.iml 27 | *.ipr 28 | *.iws 29 | *.idea 30 | 31 | # VS Code # 32 | .vscode/ 33 | 34 | # Spring Bootstrap artifacts 35 | 36 | dependency-reduced-pom.xml 37 | 38 | mem*.db 39 | 40 | README.html 41 | -------------------------------------------------------------------------------- /application/src/main/kotlin/com/npd/betting/App.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | import org.springframework.scheduling.annotation.EnableScheduling 6 | 7 | @SpringBootApplication 8 | @EnableScheduling 9 | open class BettingGraphqlApi { 10 | companion object { 11 | @JvmStatic 12 | fun main(args: Array) { 13 | runApplication(*args) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /application/src/main/resources/logback-spring.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /betting-api/src/main/resources/logback-spring.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd $(dirname $0) 3 | 4 | cd ../complete 5 | 6 | ./mvnw clean package 7 | ret=$? 8 | if [ $ret -ne 0 ]; then 9 | exit $ret 10 | fi 11 | rm -rf target 12 | 13 | ./gradlew build 14 | ret=$? 15 | if [ $ret -ne 0 ]; then 16 | exit $ret 17 | fi 18 | rm -rf build 19 | 20 | cd ../initial 21 | 22 | ./mvnw clean compile 23 | ret=$? 24 | if [ $ret -ne 0 ]; then 25 | exit $ret 26 | fi 27 | rm -rf target 28 | 29 | ./gradlew compileJava 30 | ret=$? 31 | if [ $ret -ne 0 ]; then 32 | exit $ret 33 | fi 34 | rm -rf build 35 | 36 | exit 37 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/services/importer/HttpClient.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.services.importer 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.engine.cio.* 5 | import io.ktor.client.plugins.contentnegotiation.* 6 | import io.ktor.serialization.kotlinx.json.* 7 | import kotlinx.serialization.json.Json 8 | 9 | 10 | var httpClient = HttpClient(CIO) { 11 | install(ContentNegotiation) { 12 | json(Json { 13 | ignoreUnknownKeys = true 14 | isLenient = true 15 | }) 16 | } 17 | // install(Logging) { 18 | // logger = Logger.DEFAULT 19 | // level = LogLevel.NONE 20 | // } 21 | } 22 | -------------------------------------------------------------------------------- /betting-api/fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for parabolic-betting-api on 2024-01-26T10:18:06+02:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = 'parabolic-betting-api' 7 | primary_region = 'ams' 8 | 9 | [build] 10 | dockerfile = 'BetApi.Dockerfile' 11 | 12 | [http_service] 13 | internal_port = 8080 14 | force_https = true 15 | auto_stop_machines = true 16 | auto_start_machines = true 17 | min_machines_running = 1 18 | processes = ['app'] 19 | 20 | [[vm]] 21 | cpu_kind = 'shared' 22 | cpus = 1 23 | memory_mb = 1024 24 | -------------------------------------------------------------------------------- /application/fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for parabolic-snowy-cloud-5094 on 2024-01-25T21:35:45+02:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = 'parabolic-snowy-cloud-5094' 7 | primary_region = 'ams' 8 | 9 | [build] 10 | dockerfile = "Dockerfile" 11 | 12 | [http_service] 13 | internal_port = 8080 14 | force_https = true 15 | auto_stop_machines = false 16 | auto_start_machines = true 17 | min_machines_running = 1 18 | processes = ['app'] 19 | 20 | [[vm]] 21 | cpu_kind = 'shared' 22 | cpus = 1 23 | memory_mb = 1024 24 | -------------------------------------------------------------------------------- /application/src/main/kotlin/com/npd/betting/AppConfig.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting 2 | 3 | import graphql.scalars.ExtendedScalars 4 | import graphql.schema.idl.RuntimeWiring 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Configuration 7 | import org.springframework.graphql.execution.RuntimeWiringConfigurer 8 | 9 | @Configuration 10 | open class GraphQlConfig { 11 | @Bean 12 | open fun runtimeWiringConfigurer(): RuntimeWiringConfigurer { 13 | return RuntimeWiringConfigurer { wiringBuilder: RuntimeWiring.Builder -> wiringBuilder.scalar(ExtendedScalars.DateTime) } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /betting-api/src/main/kotlin/com/npd/betting/AppConfig.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting 2 | 3 | import graphql.scalars.ExtendedScalars 4 | import graphql.schema.idl.RuntimeWiring 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Configuration 7 | import org.springframework.graphql.execution.RuntimeWiringConfigurer 8 | 9 | @Configuration 10 | open class GraphQlConfig { 11 | @Bean 12 | open fun runtimeWiringConfigurer(): RuntimeWiringConfigurer { 13 | return RuntimeWiringConfigurer { wiringBuilder: RuntimeWiring.Builder -> wiringBuilder.scalar(ExtendedScalars.DateTime) } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # flyctl launch added from .gitignore 2 | # Operating System Files 3 | 4 | **/*.DS_Store 5 | **/Thumbs.db 6 | **/*~ 7 | **/.#* 8 | #* 9 | **/*# 10 | 11 | # Build Files # 12 | 13 | **/bin 14 | **/target 15 | **/build 16 | **/.gradle 17 | 18 | # Eclipse Project Files # 19 | 20 | **/.classpath 21 | **/.project 22 | **/.settings 23 | **/.attach_pid* 24 | 25 | # IntelliJ IDEA Files # 26 | 27 | **/*.iml 28 | **/*.ipr 29 | **/*.iws 30 | **/*.idea 31 | 32 | # VS Code # 33 | **/.vscode 34 | 35 | # Spring Bootstrap artifacts 36 | 37 | **/dependency-reduced-pom.xml 38 | 39 | **/mem*.db 40 | 41 | **/README.html 42 | **/fly.toml 43 | fly.toml 44 | -------------------------------------------------------------------------------- /application/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Gradle image to create a build artifact. 2 | # https://hub.docker.com/_/gradle 3 | FROM gradle:jdk17 AS build 4 | 5 | # Copy the code into the container 6 | COPY . /home/gradle/src 7 | WORKDIR /home/gradle/src 8 | 9 | # Build the project and dependencies 10 | RUN ./gradlew clean application:build application:bootJar --no-daemon || (echo "Gradle build failed!" && exit 1) 11 | RUN ls -la application/build/libs || (echo "Directory listing failed!" && exit 1) 12 | 13 | # After building run the thing 14 | FROM amazoncorretto:17.0.7-alpine 15 | 16 | VOLUME /tmp 17 | COPY --from=build /home/gradle/src/application/build/libs/application-0.0.1-SNAPSHOT.jar app.jar 18 | ENTRYPOINT ["java","-jar","/app.jar"] 19 | -------------------------------------------------------------------------------- /betting-api/BetApi.Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Gradle image to create a build artifact. 2 | # https://hub.docker.com/_/gradle 3 | FROM gradle:jdk17 AS build 4 | 5 | # Copy the code into the container 6 | COPY . /home/gradle/src 7 | WORKDIR /home/gradle/src 8 | 9 | # Build the project and dependencies 10 | RUN ./gradlew clean betting-api:build betting-api:bootJar --no-daemon || (echo "Gradle build failed!" && exit 1) 11 | RUN ls -la betting-api/build/libs || (echo "Directory listing failed!" && exit 1) 12 | 13 | # After building run the thing 14 | FROM amazoncorretto:17.0.7-alpine 15 | 16 | VOLUME /tmp 17 | COPY --from=build /home/gradle/src/betting-api/build/libs/betting-api-0.0.1-SNAPSHOT.jar app.jar 18 | ENTRYPOINT ["java","-jar","/app.jar"] 19 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/controllers/SinksConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.controllers 2 | 3 | import com.npd.betting.model.Event 4 | import com.npd.betting.model.MarketOption 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Configuration 7 | 8 | 9 | @Configuration 10 | open class SinksConfiguration { 11 | @Bean 12 | open fun marketOptionSink(): AccumulatingSink { 13 | return AccumulatingSink(20, java.time.Duration.ofSeconds(10)) 14 | } 15 | 16 | @Bean 17 | open fun scoreUpdatesSink(): AccumulatingSink { 18 | return AccumulatingSink(20, java.time.Duration.ofSeconds(1)) 19 | } 20 | 21 | @Bean 22 | open fun eventStatusUpdatesSink(): AccumulatingSink { 23 | return AccumulatingSink(20, java.time.Duration.ofSeconds(1)) 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/services/importer/DateSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.services.importer 2 | 3 | 4 | import kotlinx.serialization.ExperimentalSerializationApi 5 | import kotlinx.serialization.KSerializer 6 | import kotlinx.serialization.Serializer 7 | import kotlinx.serialization.descriptors.SerialDescriptor 8 | import kotlinx.serialization.encoding.Decoder 9 | import kotlinx.serialization.encoding.Encoder 10 | import java.text.SimpleDateFormat 11 | import java.util.* 12 | 13 | @OptIn(ExperimentalSerializationApi::class) 14 | @Serializer(forClass = Date::class) 15 | object DateSerializer : KSerializer { 16 | private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") 17 | 18 | override fun serialize(encoder: Encoder, value: Date) { 19 | encoder.encodeString(dateFormat.format(value)) 20 | } 21 | 22 | override val descriptor: SerialDescriptor 23 | get() = TODO("Not yet implemented") 24 | 25 | override fun deserialize(decoder: Decoder): Date { 26 | return dateFormat.parse(decoder.decodeString()) 27 | } 28 | } -------------------------------------------------------------------------------- /application/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8080 3 | spring: 4 | application: 5 | name: parabolic-bet-backend 6 | datasource: 7 | driver-class-name: org.postgresql.Driver 8 | url: ${DATABASE_URL} 9 | username: postgres 10 | password: ${DATABASE_PASSWORD} 11 | hikari: 12 | maximum-pool-size=6 13 | minimum-idle=4 14 | jpa: 15 | hibernate: 16 | ddl-auto: update 17 | show-sql: false 18 | properties: 19 | hibernate: 20 | format_sql: 21 | true 22 | graphql: 23 | path: /graphql 24 | cors: 25 | allowed-origins: '*' 26 | allowed-methods: '*' 27 | webSocket: 28 | path: /subscriptions 29 | schema: 30 | printer: 31 | enabled: true 32 | graphiql: 33 | enabled: true 34 | path: /graphiql 35 | management: 36 | endpoints: 37 | web: 38 | exposure: 39 | include: health,metrics,info 40 | logging: 41 | root: DEBUG 42 | level: 43 | com: 44 | npd: 45 | betting: DEBUG 46 | mysql: 47 | cj: info 48 | reactor: 49 | netty: 50 | http: error 51 | org: 52 | springframework: 53 | web: DEBUG 54 | http: error 55 | graphql: error 56 | jpa: info 57 | hibernate: 58 | SQL: info 59 | type: 60 | descriptor: 61 | sql: 62 | BasicBinder: info 63 | ultrabet: 64 | oddsApiKey: ${ODDS_API_KEY} 65 | 66 | -------------------------------------------------------------------------------- /betting-api/src/main/kotlin/com/npd/betting/SecurityConfig.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting 2 | 3 | import org.springframework.boot.actuate.autoconfigure.security.reactive.EndpointRequest 4 | import org.springframework.boot.autoconfigure.security.reactive.PathRequest 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Configuration 7 | import org.springframework.security.config.Customizer 8 | import org.springframework.security.config.annotation.web.builders.HttpSecurity 9 | import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer 10 | import org.springframework.security.web.SecurityFilterChain 11 | 12 | 13 | /** 14 | * Configures our application with Spring Security to restrict access to our API endpoints. 15 | */ 16 | @Configuration 17 | open class SecurityConfig { 18 | @Bean 19 | @Throws(Exception::class) 20 | open fun filterChain(http: HttpSecurity): SecurityFilterChain { 21 | return http 22 | .authorizeHttpRequests( 23 | Customizer { authorize -> 24 | authorize 25 | .requestMatchers("/actuator/health").permitAll() 26 | .requestMatchers("/graphql").authenticated() 27 | } 28 | ) 29 | .cors(Customizer.withDefaults()) 30 | .oauth2ResourceServer { oauth2: OAuth2ResourceServerConfigurer -> 31 | oauth2 32 | .jwt(Customizer.withDefaults()) 33 | } 34 | .build() 35 | } 36 | } -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/controllers/SportController.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.controllers 2 | 3 | import com.npd.betting.model.Event 4 | import com.npd.betting.model.Sport 5 | import com.npd.betting.repositories.EventRepository 6 | import com.npd.betting.repositories.SportRepository 7 | import com.npd.betting.services.importer.EventImporter 8 | import org.slf4j.Logger 9 | import org.slf4j.LoggerFactory 10 | import org.springframework.beans.factory.annotation.Autowired 11 | import org.springframework.graphql.data.method.annotation.Argument 12 | import org.springframework.graphql.data.method.annotation.SchemaMapping 13 | import org.springframework.stereotype.Controller 14 | 15 | @Controller 16 | class SportController @Autowired constructor( 17 | private val sportRepository: SportRepository, 18 | private val eventRepository: EventRepository 19 | ) { 20 | val logger: Logger = LoggerFactory.getLogger(SportController::class.java) 21 | 22 | @SchemaMapping(typeName = "Query", field = "listSports") 23 | fun getSports(@Argument group: String): List { 24 | if (group == "" || group == "all") { 25 | logger.debug("Fetching all sports") 26 | return sportRepository.findByActiveTrue() 27 | } 28 | return sportRepository.findByGroupAndActiveTrue(group) 29 | } 30 | 31 | @SchemaMapping(typeName = "Sport", field = "activeEventCount") 32 | fun activeEventCount(sport: Sport): Int { 33 | return eventRepository.countBySportIdAndCompleted(sport.id, false) 34 | } 35 | 36 | } -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for parabolic-db1 on 2024-01-28T11:18:54+02:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = 'parabolic-db1' 7 | primary_region = 'ams' 8 | 9 | [env] 10 | PRIMARY_REGION = 'ams' 11 | 12 | [[mounts]] 13 | source = 'pg_data' 14 | destination = '/data' 15 | 16 | [[services]] 17 | protocol = 'tcp' 18 | internal_port = 5432 19 | auto_start_machines = false 20 | 21 | [[services.ports]] 22 | port = 5432 23 | handlers = ['pg_tls'] 24 | 25 | [services.concurrency] 26 | type = 'connections' 27 | hard_limit = 1000 28 | soft_limit = 1000 29 | 30 | [[services]] 31 | protocol = 'tcp' 32 | internal_port = 5433 33 | auto_start_machines = false 34 | 35 | [[services.ports]] 36 | port = 5433 37 | handlers = ['pg_tls'] 38 | 39 | [services.concurrency] 40 | type = 'connections' 41 | hard_limit = 1000 42 | soft_limit = 1000 43 | 44 | [checks] 45 | [checks.pg] 46 | port = 5500 47 | type = 'http' 48 | interval = '15s' 49 | timeout = '10s' 50 | path = '/flycheck/pg' 51 | 52 | [checks.role] 53 | port = 5500 54 | type = 'http' 55 | interval = '15s' 56 | timeout = '10s' 57 | path = '/flycheck/role' 58 | 59 | [checks.vm] 60 | port = 5500 61 | type = 'http' 62 | interval = '15s' 63 | timeout = '10s' 64 | path = '/flycheck/vm' 65 | 66 | [[metrics]] 67 | port = 9187 68 | path = '/metrics' 69 | -------------------------------------------------------------------------------- /betting-api/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8080 3 | spring: 4 | application: 5 | name: parabolic-betting-api 6 | datasource: 7 | driver-class-name: org.postgresql.Driver 8 | url: ${DATABASE_URL} 9 | username: postgres 10 | password: ${DATABASE_PASSWORD} 11 | hikari: 12 | maximum-pool-size=6 13 | minimum-idle=4 14 | jpa: 15 | hibernate: 16 | ddl-auto: update 17 | show-sql: false 18 | properties: 19 | hibernate: 20 | format_sql: 21 | true 22 | graphql: 23 | path: /graphql 24 | cors: 25 | allowed-origins: '*' 26 | allowed-methods: '*' 27 | schema: 28 | printer: 29 | enabled: true 30 | graphiql: 31 | enabled: true 32 | path: /graphiql 33 | security: 34 | oauth2: 35 | resourceserver: 36 | jwt: 37 | issuer-uri: https://parabolicbet.eu.auth0.com/ 38 | management: 39 | endpoints: 40 | web: 41 | base-path: /actuator 42 | exposure: 43 | include: health,metrics,info 44 | logging: 45 | root: DEBUG 46 | level: 47 | com: 48 | npd: 49 | betting: debug 50 | mysql: 51 | cj: info 52 | reactor: 53 | netty: 54 | http: error 55 | org: 56 | springframework: 57 | web: INFO 58 | http: error 59 | graphql: DEBUG 60 | jpa: info 61 | security: DEBUG 62 | hibernate: 63 | SQL: info 64 | type: 65 | descriptor: 66 | sql: 67 | BasicBinder: info 68 | okta: 69 | oauth0: 70 | issuer: https://parabolicbet.eu.auth0.com/ 71 | client-id: IHCyxIXxF8bVJThvviAF583taf9E7HfN 72 | audience: https://parabolicbet.eu.auth0.com/api/v2/ -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/controllers/UserController.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.controllers 2 | 3 | import com.npd.betting.model.Bet 4 | import com.npd.betting.model.User 5 | import com.npd.betting.model.Wallet 6 | import com.npd.betting.repositories.UserRepository 7 | import com.npd.betting.services.UserService 8 | import jakarta.persistence.EntityManager 9 | import org.springframework.beans.factory.annotation.Autowired 10 | import org.springframework.graphql.data.method.annotation.Argument 11 | import org.springframework.graphql.data.method.annotation.MutationMapping 12 | import org.springframework.graphql.data.method.annotation.SchemaMapping 13 | import org.springframework.stereotype.Controller 14 | import java.math.BigDecimal 15 | 16 | @Controller 17 | class UserController @Autowired constructor( 18 | private val userRepository: UserRepository, 19 | private val entityManager: EntityManager, 20 | private val userService: UserService 21 | ) { 22 | 23 | @SchemaMapping(typeName = "Query", field = "me") 24 | fun me(): User { 25 | val user = userService.findAuthenticatedUser() 26 | return userRepository.findById(user.id).orElse(null) 27 | } 28 | 29 | @SchemaMapping(typeName = "User", field = "bets") 30 | fun getUserBets(user: User): List { 31 | val query = entityManager.createQuery( 32 | "SELECT u FROM User u JOIN FETCH u.bets WHERE u.id = :id", User::class.java 33 | ) 34 | query.setParameter("id", user.id) 35 | val resultList = query.resultList 36 | return if (resultList.isEmpty()) emptyList() else resultList[0].bets 37 | } 38 | 39 | @MutationMapping 40 | fun createUser(@Argument externalId: String, @Argument username: String? = null, @Argument email: String?): User { 41 | return userService.createUser(externalId, username, email, BigDecimal(0)) 42 | 43 | } 44 | } -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/services/importer/CanceledEventUpdater.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.services.importer 2 | 3 | import com.npd.betting.repositories.EventRepository 4 | import com.npd.betting.services.EventService 5 | import com.npd.betting.services.ResultService 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.runBlocking 8 | import kotlinx.coroutines.withContext 9 | import org.slf4j.Logger 10 | import org.slf4j.LoggerFactory 11 | import org.springframework.scheduling.annotation.Scheduled 12 | import org.springframework.stereotype.Component 13 | import org.springframework.transaction.annotation.Transactional 14 | import java.util.* 15 | 16 | @Component 17 | open class CanceledEventUpdater( 18 | private val eventRepository: EventRepository, 19 | private val eventService: EventService, 20 | private val resultService: ResultService 21 | ) { 22 | val logger: Logger = LoggerFactory.getLogger(CanceledEventUpdater::class.java) 23 | 24 | @Scheduled(fixedRate = 1 * 60000) // Poll the API every 30 minutes 25 | @Transactional 26 | open fun updateEvents() { 27 | logger.debug("Updating canceled events") 28 | runBlocking { 29 | updateNonStartedOldEvents() 30 | } 31 | } 32 | 33 | 34 | suspend fun updateNonStartedOldEvents() { 35 | val events = withContext(Dispatchers.IO) { 36 | eventRepository.findByIsLiveFalseAndCompletedFalse() 37 | } 38 | events.forEach() { 39 | val event = it 40 | val now = Date().time 41 | val commenceTime = event.startTime.time 42 | val diff = now - commenceTime 43 | val diffInHours = diff / (60 * 60 * 1000) 44 | if (diffInHours > 24) { 45 | logger.debug("Event ${event.id} is more than 24 hours old, marking as completed") 46 | event.completed = true 47 | eventService.updateScores(event) 48 | resultService.saveEventResult(event) 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/services/UserService.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.services 2 | 3 | import com.npd.betting.controllers.UserController 4 | import com.npd.betting.model.User 5 | import com.npd.betting.model.Wallet 6 | import com.npd.betting.repositories.UserRepository 7 | import com.npd.betting.repositories.WalletRepository 8 | import com.npd.betting.services.importer.EventImporter 9 | import org.slf4j.Logger 10 | import org.slf4j.LoggerFactory 11 | import org.springframework.beans.factory.annotation.Autowired 12 | import org.springframework.graphql.data.method.annotation.Argument 13 | import org.springframework.security.core.context.SecurityContextHolder 14 | import org.springframework.security.oauth2.jwt.Jwt 15 | import org.springframework.stereotype.Service 16 | import java.math.BigDecimal 17 | 18 | @Service 19 | class UserService @Autowired constructor( 20 | val userRepository: UserRepository, 21 | val walletRepository: WalletRepository, 22 | ) { 23 | val logger: Logger = LoggerFactory.getLogger(UserService::class.java) 24 | 25 | fun findAuthenticatedUser(): User { 26 | val principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal() as Jwt; 27 | val sub = principal.getClaimAsString("sub") 28 | 29 | val user = userRepository.findByExternalId(sub) 30 | if (user == null) { 31 | logger.info("creating new user for externalId ${sub}"); 32 | return createUser(externalId = sub, balance = BigDecimal(1000)) 33 | } 34 | logger.info("found user ${user.id}") 35 | return user 36 | } 37 | 38 | fun createUser(externalId: String, username: String? = null, email: String? = null, balance: BigDecimal): User { 39 | val user = User(username = username, email = email, externalId = externalId) 40 | val wallet = Wallet(user = user, balance = balance) 41 | user.wallet = wallet 42 | 43 | userRepository.save(user) 44 | walletRepository.save(wallet) 45 | 46 | return user 47 | } 48 | } -------------------------------------------------------------------------------- /application/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | kotlin("jvm") version "1.7.22" 5 | id("org.springframework.boot") version "3.1.5" 6 | id("io.spring.dependency-management") version "1.1.3" 7 | application 8 | } 9 | 10 | group = "com.npd.betting" 11 | version = "0.0.1-SNAPSHOT" 12 | java.sourceCompatibility = JavaVersion.VERSION_17 13 | 14 | repositories { 15 | mavenCentral() 16 | } 17 | 18 | application { 19 | mainClass.set("com.npd.betting.BettingGraphqlApi") 20 | } 21 | 22 | dependencies { 23 | implementation("org.springframework.boot:spring-boot-starter-data-jpa") 24 | implementation("org.springframework.boot:spring-boot-starter-graphql") 25 | implementation("org.springframework.boot:spring-boot-starter-webflux") 26 | implementation("org.springframework.boot:spring-boot-starter-actuator") 27 | 28 | implementation("org.hibernate:hibernate-core:6.1.7.Final") 29 | implementation("jakarta.persistence:jakarta.persistence-api:3.1.0") 30 | 31 | implementation("org.jetbrains.kotlin:kotlin-reflect") 32 | 33 | implementation("io.ktor:ktor-client-core:2.2.4") 34 | implementation("io.ktor:ktor-client-cio:2.2.4") 35 | implementation("io.ktor:ktor-client-json:2.2.4") 36 | implementation("io.ktor:ktor-client-serialization:2.2.4") 37 | implementation("io.ktor:ktor-serialization-kotlinx-json:2.2.4") 38 | implementation("io.ktor:ktor-client-content-negotiation:2.2.4") 39 | 40 | implementation("com.graphql-java:graphql-java-extended-scalars:20.0") 41 | implementation("com.graphql-java:graphiql-spring-boot-starter:5.0.2") 42 | implementation("com.graphql-java:graphql-spring-boot-starter:5.0.2") 43 | 44 | implementation(project(":library")) 45 | runtimeOnly("org.postgresql:postgresql:42.5.4") 46 | 47 | testImplementation("org.springframework.boot:spring-boot-starter-test") 48 | } 49 | 50 | tasks.withType { 51 | kotlinOptions { 52 | freeCompilerArgs = listOf("-Xjsr305=strict") 53 | jvmTarget = "17" 54 | } 55 | } 56 | 57 | tasks.withType { 58 | useJUnitPlatform() 59 | } 60 | 61 | -------------------------------------------------------------------------------- /populate-db.sql: -------------------------------------------------------------------------------- 1 | -- Insert example wallet for user 1 2 | INSERT INTO wallets (user_id, balance) 3 | VALUES (1, 100.00); 4 | 5 | -- Insert example events 6 | INSERT INTO events (is_live, name, start_time, sport) 7 | VALUES (true, 'Premier League - Liverpool vs. Manchester United', '2023-05-01 20:00:00', 'Soccer'), 8 | (true, 'NBA Finals - Game 1', '2023-06-01 19:30:00', 'Basketball'), 9 | (false, 'World Series of Poker - Main Event', '2023-07-01 12:00:00', 'Poker'); 10 | 11 | -- Insert example markets 12 | INSERT INTO markets (is_live, last_updated, name, event_id) 13 | VALUES (true, NOW(), 'Match Odds', 1), 14 | (true, NOW(), 'Over/Under 2.5 Goals', 1), 15 | (true, NOW(), 'Point Spread', 2), 16 | (true, NOW(), 'Moneyline', 2), 17 | (true, NOW(), 'Total Points', 2), 18 | (false, NOW(), 'Main Event Winner', 3), 19 | (false, NOW(), 'First Elimination', 3); 20 | 21 | -- Insert example market options 22 | INSERT INTO market_options (last_updated, name, odds, market_id) 23 | VALUES (NOW(), 'Liverpool', 1.85, 1), 24 | (NOW(), 'Manchester United', 3.25, 1), 25 | (NOW(), 'Draw', 3.50, 1), 26 | (NOW(), 'Over 2.5 Goals', 2.10, 2), 27 | (NOW(), 'Under 2.5 Goals', 1.70, 2), 28 | (NOW(), 'Los Angeles Lakers -6.5', 1.91, 3), 29 | (NOW(), 'Boston Celtics +6.5', 1.91, 3), 30 | (NOW(), 'Los Angeles Lakers', 1.25, 4), 31 | (NOW(), 'Boston Celtics', 3.75, 4), 32 | (NOW(), 'Over 200 Points', 1.80, 5), 33 | (NOW(), 'Under 200 Points', 1.90, 5), 34 | (NOW(), 'John Doe', 3.50, 6), 35 | (NOW(), 'Jane Doe', 4.00, 6), 36 | (NOW(), 'Joe Smith', 6.00, 6), 37 | (NOW(), 'Mike Johnson', 10.00, 6), 38 | (NOW(), 'Maria Garcia', 15.00, 6), 39 | (NOW(), 'Alice Kim', 20.00, 6), 40 | (NOW(), 'Bob Lee', 25.00, 6), 41 | (NOW(), 'Chris Evans', 50.00, 6), 42 | (NOW(), 'David Lee', 100.00, 6); 43 | 44 | INSERT INTO bets (user_id, market_option_id, stake, potential_winnings, created_at, status) 45 | VALUES (1, 1, 10.00, 18.50, NOW(), 'PENDING'), 46 | (1, 5, 5.00, 8.50, NOW(), 'WON'), 47 | (1, 10, 20.00, 36.00, NOW(), 'PENDING'); 48 | 49 | select * 50 | from events; 51 | 52 | select * 53 | from markets 54 | where event_id = 1; 55 | 56 | select * 57 | from market_options 58 | where market_id = 1; 59 | 60 | -------------------------------------------------------------------------------- /betting-api/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | kotlin("jvm") version "1.7.22" 5 | id("org.springframework.boot") version "3.1.5" 6 | id("io.spring.dependency-management") version "1.1.3" 7 | application 8 | } 9 | 10 | group = "com.npd.betting" 11 | version = "0.0.1-SNAPSHOT" 12 | java.sourceCompatibility = JavaVersion.VERSION_17 13 | 14 | repositories { 15 | mavenCentral() 16 | } 17 | 18 | application { 19 | mainClass.set("com.npd.betting.BettingGraphqlApi") 20 | } 21 | 22 | dependencies { 23 | implementation("org.springframework.boot:spring-boot-starter-data-jpa") 24 | implementation("org.springframework.boot:spring-boot-starter-graphql") 25 | implementation("org.springframework.boot:spring-boot-starter-webflux") 26 | implementation("org.springframework.boot:spring-boot-starter-actuator") 27 | implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") 28 | 29 | implementation("org.hibernate:hibernate-core:6.1.7.Final") 30 | implementation("jakarta.persistence:jakarta.persistence-api:3.1.0") 31 | 32 | implementation("org.jetbrains.kotlin:kotlin-reflect") 33 | 34 | implementation("io.ktor:ktor-client-core:2.2.4") 35 | implementation("io.ktor:ktor-client-cio:2.2.4") 36 | implementation("io.ktor:ktor-client-json:2.2.4") 37 | implementation("io.ktor:ktor-client-serialization:2.2.4") 38 | implementation("io.ktor:ktor-serialization-kotlinx-json:2.2.4") 39 | implementation("io.ktor:ktor-client-content-negotiation:2.2.4") 40 | 41 | implementation("com.graphql-java:graphql-java-extended-scalars:20.0") 42 | implementation("com.graphql-java:graphiql-spring-boot-starter:5.0.2") 43 | implementation("com.graphql-java:graphql-spring-boot-starter:5.0.2") 44 | 45 | implementation("com.okta.spring:okta-spring-boot-starter:3.0.5") 46 | implementation("com.auth0:auth0:2.7.0") 47 | implementation("com.auth0:java-jwt:4.4.0") 48 | implementation("com.auth0:jwks-rsa:0.22.1") 49 | 50 | implementation(project(":library")) 51 | runtimeOnly("org.postgresql:postgresql:42.5.4") 52 | 53 | testImplementation("org.springframework.boot:spring-boot-starter-test") 54 | } 55 | 56 | tasks.withType { 57 | kotlinOptions { 58 | freeCompilerArgs = listOf("-Xjsr305=strict") 59 | jvmTarget = "17" 60 | } 61 | } 62 | 63 | tasks.withType { 64 | useJUnitPlatform() 65 | } 66 | 67 | -------------------------------------------------------------------------------- /library/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | kotlin("jvm") version "1.7.22" 5 | id("org.springframework.boot") version "3.1.5" apply false 6 | id("io.spring.dependency-management") version "1.1.3" 7 | kotlin("plugin.spring") version "1.8.20" 8 | kotlin("plugin.jpa") version "1.8.20" 9 | kotlin("plugin.serialization") version "1.5.0" 10 | id("io.ktor.plugin") version "2.3.0" 11 | } 12 | 13 | group = "com.npd.betting" 14 | version = "0.0.1-SNAPSHOT" 15 | 16 | java.sourceCompatibility = JavaVersion.VERSION_17 17 | 18 | 19 | repositories { 20 | mavenCentral() 21 | gradlePluginPortal() 22 | maven(url = "https://plugins.gradle.org/m2/") 23 | } 24 | 25 | dependencies { 26 | // Importing Spring Boot's BOM 27 | implementation(platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES)) 28 | 29 | implementation("org.springframework.boot:spring-boot-starter-data-jpa") 30 | implementation("org.springframework.boot:spring-boot-starter-graphql") 31 | implementation("org.springframework.boot:spring-boot-starter-webflux") 32 | implementation("org.springframework.boot:spring-boot-starter-actuator") 33 | implementation("org.springframework.security:spring-security-core") 34 | implementation("org.springframework.security:spring-security-oauth2-jose") 35 | 36 | implementation("org.hibernate:hibernate-core:6.1.7.Final") 37 | implementation("jakarta.persistence:jakarta.persistence-api:3.1.0") 38 | 39 | implementation("org.jetbrains.kotlin:kotlin-reflect") 40 | 41 | implementation("io.ktor:ktor-client-core:2.2.4") 42 | implementation("io.ktor:ktor-client-cio:2.2.4") 43 | implementation("io.ktor:ktor-client-json:2.2.4") 44 | implementation("io.ktor:ktor-client-serialization:2.2.4") 45 | implementation("io.ktor:ktor-serialization-kotlinx-json:2.2.4") 46 | implementation("io.ktor:ktor-client-content-negotiation:2.2.4") 47 | 48 | implementation("com.graphql-java:graphql-java-extended-scalars:20.0") 49 | implementation("com.graphql-java:graphiql-spring-boot-starter:5.0.2") 50 | implementation("com.graphql-java:graphql-spring-boot-starter:5.0.2") 51 | } 52 | 53 | tasks.withType { 54 | kotlinOptions { 55 | freeCompilerArgs = listOf("-Xjsr305=strict") 56 | jvmTarget = "17" 57 | } 58 | } 59 | 60 | tasks.withType { 61 | useJUnitPlatform() 62 | } 63 | 64 | tasks.named("shadowJar") { 65 | enabled = false 66 | } 67 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/controllers/MarketController.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.controllers 2 | 3 | import com.npd.betting.model.Market 4 | import com.npd.betting.model.MarketOption 5 | import com.npd.betting.repositories.EventRepository 6 | import com.npd.betting.repositories.MarketOptionRepository 7 | import com.npd.betting.repositories.MarketRepository 8 | import jakarta.persistence.EntityManager 9 | import org.springframework.beans.factory.annotation.Autowired 10 | import org.springframework.graphql.data.method.annotation.Argument 11 | import org.springframework.graphql.data.method.annotation.MutationMapping 12 | import org.springframework.graphql.data.method.annotation.SchemaMapping 13 | import org.springframework.graphql.data.method.annotation.SubscriptionMapping 14 | import org.springframework.stereotype.Controller 15 | import reactor.core.publisher.Flux 16 | import java.math.BigDecimal 17 | 18 | @Controller 19 | class MarketController @Autowired constructor( 20 | private val marketRepository: MarketRepository, 21 | private val marketOptionRepository: MarketOptionRepository, 22 | private val eventRepository: EventRepository, 23 | private val entityManager: EntityManager, 24 | private val marketOptionSink: AccumulatingSink 25 | ) { 26 | 27 | @SchemaMapping(typeName = "Query", field = "getMarket") 28 | fun getMarket(@Argument id: Int): Market { 29 | return marketRepository.findById(id).orElse(null) 30 | } 31 | 32 | @SchemaMapping(typeName = "Query", field = "listMarkets") 33 | fun listMarkets(@Argument eventId: Int): List { 34 | return marketRepository.findByEventId(eventId) 35 | } 36 | 37 | @SchemaMapping(typeName = "Query", field = "listLiveMarkets") 38 | fun listLiveMarkets(@Argument eventId: Int): List { 39 | return marketRepository.findByEventIdAndIsLiveTrue(eventId) 40 | } 41 | 42 | @MutationMapping 43 | fun createMarket(@Argument name: String, @Argument eventId: Int): Market { 44 | val event = eventRepository.findById(eventId).orElse(null) 45 | val market = Market(name = name, event = event, isLive = false, source = "internal") 46 | marketRepository.save(market) 47 | return market 48 | } 49 | 50 | @MutationMapping 51 | fun createMarketOption(@Argument name: String, @Argument odds: Float, @Argument marketId: Int): MarketOption { 52 | val market = marketRepository.findById(marketId).orElse(null) 53 | val marketOption = MarketOption(name = name, odds = BigDecimal.valueOf(odds.toDouble()), market = market) 54 | marketOptionRepository.save(marketOption) 55 | return marketOption 56 | } 57 | 58 | @SubscriptionMapping 59 | fun liveMarketOptionsUpdated(): Flux> { 60 | return marketOptionSink.asFlux() 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/controllers/AccumulatedSink.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.controllers 2 | 3 | import com.npd.betting.services.importer.EventImporter 4 | import org.slf4j.Logger 5 | import org.slf4j.LoggerFactory 6 | import reactor.core.publisher.Flux 7 | import reactor.core.publisher.Sinks 8 | import java.time.Duration 9 | 10 | class AccumulatingSink( 11 | private val bufferSize: Int = 100, 12 | private val duration: Duration = Duration.ofSeconds(5), 13 | private val clearThreshold: Int = 1000 // Adjust the threshold as needed 14 | 15 | ) { 16 | val logger: Logger = LoggerFactory.getLogger(EventImporter::class.java) 17 | private val sinkProcessor: Sinks.Many> = Sinks.many().multicast().onBackpressureBuffer>() 18 | private val accumulatedMessages = mutableListOf() 19 | private val emittedMessages = mutableSetOf() 20 | private var emissionCount = 0 21 | 22 | init { 23 | sinkProcessor.emitNext(emptyList(), Sinks.EmitFailureHandler.FAIL_FAST) 24 | 25 | sinkProcessor.asFlux().bufferTimeout(bufferSize, duration) 26 | .doOnNext { messages -> emitAccumulated(messages.flatten()) } 27 | .subscribe() 28 | } 29 | 30 | private fun emitAccumulated(messages: List) { 31 | synchronized(accumulatedMessages) { 32 | if (messages.isNotEmpty()) { 33 | accumulatedMessages.addAll(messages) 34 | if (accumulatedMessages.size >= bufferSize) { 35 | val uniqueMessages = accumulatedMessages.filter { it !in emittedMessages } 36 | emitUniqueMessages(uniqueMessages) 37 | } 38 | } 39 | } 40 | } 41 | 42 | 43 | fun emit(message: T) { 44 | synchronized(accumulatedMessages) { 45 | accumulatedMessages.add(message) 46 | if (accumulatedMessages.size >= bufferSize) { 47 | val uniqueMessages = accumulatedMessages.filter { it !in emittedMessages } 48 | logger.info("Emitting ${uniqueMessages.size} messages") 49 | emitUniqueMessages(uniqueMessages) 50 | } 51 | } 52 | } 53 | 54 | fun complete() { 55 | synchronized(accumulatedMessages) { 56 | if (accumulatedMessages.isNotEmpty()) { 57 | val uniqueMessages = accumulatedMessages.filter { it !in emittedMessages } 58 | emitUniqueMessages(uniqueMessages) 59 | } 60 | sinkProcessor.tryEmitComplete() 61 | } 62 | } 63 | 64 | private fun emitUniqueMessages(messages: List) { 65 | accumulatedMessages.clear() 66 | emittedMessages.addAll(messages) 67 | sinkProcessor.tryEmitNext(messages) 68 | 69 | if (emissionCount++ >= clearThreshold) { 70 | clearEmittedMessages() 71 | emissionCount = 0 72 | } 73 | } 74 | 75 | private fun clearEmittedMessages() { 76 | synchronized(emittedMessages) { 77 | emittedMessages.clear() 78 | } 79 | } 80 | 81 | fun asFlux(): Flux> = sinkProcessor.asFlux() 82 | 83 | } 84 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | This is a sports betting backend implemented in Kotlin and Spring Boot. It features a GraphQL API and a WebSocket subscription API. 4 | 5 | This is a work in progress. It is not yet ready for production use yet. 6 | 7 | This apps fetches live and pre-match odds from [bets-api.com](https://the-odds-api.com/?ref=ultrabet). 8 | Register with them using my referral link to support this project. 9 | 10 | ## Demo 11 | 12 | Demo betting frontend: https://www.parabolicbet.com/ 13 | 14 | The frontend codebase is in my [ultrabet-ui repository](https://github.com/anssip/ultrabet-ui) 15 | 16 | ## Deployment 17 | 18 | This repository contains two applications. The first one is a public GraphQL API that provides odds and other public data. 19 | It also provides a WebSocket subscription API for live score updates. Finally, it is responsible for fetching the event 20 | fixtures, live scores, and odds from the odds-api.com. The second one is a private GraphQL API that provides betting 21 | functionality. 22 | 23 | The two applications are both deployed to Fly.io 24 | 25 | To deploy the application that polls odds-api and provides the public API, run: 26 | 27 | ```bash 28 | flyctl deploy -c application/fly.toml 29 | ``` 30 | 31 | To deploy the application that provides the private betting API, run: 32 | 33 | ```bash 34 | flyctl deploy -c betting-api/fly.toml 35 | ``` 36 | 37 | ## Roadmap 38 | 39 | - [ ] Core betting features (ongoing, see TODO below) 40 | - [ ] Bet types 41 | - [x] Single ( done) 42 | - [x] Parlay (done) 43 | - [ ] System 44 | - [ ] Game field live visualization 45 | - [ ] Bet builder Bet365 style 46 | - [ ] Back Office 47 | - Risk management (bettors, alerts, limits, etc) 48 | - Bet list with voiding 49 | - [ ] Wallet integration 50 | - [ ] Payment gateway integration 51 | - [ ] Multiple languages 52 | - [ ] Commercial skinning 53 | - [ ] Profit 54 | 55 | # TODO 56 | 57 | - [x] Spreads (handicap) market with odds fetching and result setting 58 | - [ ] Improve performance of the bets page query 59 | - [ ] Double chance market (calculated from h2h) 60 | - [ ] Alternative totals market (calculated from totals) 61 | - [ ] Quarter/half markets 62 | - [ ] System bet 63 | - [ ] Event view that shows all markets and options for an event 64 | - [ ] Admin queries and mutations 65 | - [ ] Pagination for bets page 66 | - [ ] Show all score updates on current score mouse hover (?) 67 | - [ ] Proper design and styling for the [betting frontend](https://www.parabolicbet.com/) 68 | - [x] Add another market type: over/under for example 69 | - [x] Set results to bets 70 | - [x] pay out winnings 71 | - [x] Add a 2nd application (gradle subproject) that contains a secured GraphQL API for placing bets and other actions that require authentication. 72 | - [x] GraphQL subscriptions 73 | - [x] Make it update market_option.last_updated_at 74 | - [x] Entities (for JPA) 75 | - [x] Repositories 76 | - [x] Queries 77 | - [x] Mutations 78 | - [x] Import feeds from bets-api.com 79 | - [x] Bet should have several MarketOptions (long bet) 80 | - [x] Deploy somewhere 81 | - [x] scheduled update of completed events. Canceled events are now left non-completed. 82 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/services/importer/LiveEventImporter.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.services.importer 2 | 3 | import com.npd.betting.Props 4 | import com.npd.betting.model.Event 5 | import com.npd.betting.repositories.EventRepository 6 | import com.npd.betting.services.EventService 7 | import io.ktor.client.request.* 8 | import io.ktor.client.statement.* 9 | import io.ktor.http.* 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.runBlocking 12 | import kotlinx.coroutines.withContext 13 | import kotlinx.serialization.decodeFromString 14 | import kotlinx.serialization.json.Json 15 | import org.slf4j.Logger 16 | import org.slf4j.LoggerFactory 17 | import org.springframework.scheduling.annotation.Scheduled 18 | import org.springframework.stereotype.Component 19 | import org.springframework.transaction.annotation.Transactional 20 | 21 | @Component 22 | open class LiveEventImporter( 23 | private val props: Props, 24 | private val eventRepository: EventRepository, 25 | private val service: EventService 26 | ) { 27 | val logger: Logger = LoggerFactory.getLogger(EventImporter::class.java) 28 | 29 | fun getEventApiURL(sport: String, eventId: String): String { 30 | return "${EventImporter.API_BASE}/sports/$sport/events/$eventId/odds/?&markets=${EventImporter.MARKETS}®ions=eu&dateFormat=unix&apiKey=${props.getOddsApiKey()}" 31 | } 32 | 33 | @Scheduled(fixedDelay = 1, timeUnit = java.util.concurrent.TimeUnit.MINUTES) 34 | @Transactional 35 | open fun import() { 36 | runBlocking { 37 | importLiveEvents() 38 | } 39 | } 40 | 41 | suspend fun importLiveEvents() { 42 | val liveEvents = withContext(Dispatchers.IO) { 43 | eventRepository.findByIsLiveTrueAndCompletedFalse() 44 | } 45 | logger.info("Found ${liveEvents.size} live events") 46 | 47 | val eventData = fetchEvents(liveEvents) 48 | logger.info("Fetched ${eventData.size} events from bets-api.com") 49 | 50 | val notfound = eventData.mapNotNull { it.second } 51 | notfound.forEach() { 52 | service.updateCompleted(it) 53 | } 54 | 55 | val sports = eventData.mapNotNull { it.first?.sport_key }.distinct() 56 | fetchScores(sports, liveEvents, eventData) 57 | } 58 | 59 | private suspend fun fetchScores( 60 | sports: List, 61 | liveEvents: List, 62 | eventData: List> 63 | ) { 64 | logger.debug("Fetching scores for $sports sports") 65 | sports.forEach() { 66 | val eventsWithScores = service.fetchScores(it) 67 | if (eventsWithScores.isNotEmpty()) { 68 | eventsWithScores.forEach() { eventWithScores: EventData -> 69 | val event = liveEvents.find() { liveEvent -> liveEvent.externalId == eventWithScores.id } 70 | if (event !== null) { 71 | val eventWithOdds = 72 | eventData.mapNotNull { it.first }.find() { eventData -> eventData.id == eventWithScores.id } 73 | eventWithScores.bookmakers = eventWithOdds?.bookmakers 74 | service.saveEventAndOdds(eventWithScores, true) 75 | service.saveScores(eventWithScores, event) 76 | } 77 | } 78 | } 79 | } 80 | } 81 | 82 | suspend fun fetchEvents(events: List): List> { 83 | val data = events.map() { 84 | logger.debug("Fetching odds for event ${it.externalId} and sport ${it.sport.key}") 85 | val response: HttpResponse = 86 | httpClient.request(getEventApiURL(it.sport.key, it.externalId!!)) { 87 | method = HttpMethod.Get 88 | } 89 | if (response.status != HttpStatusCode.OK) { 90 | logger.error("Failed to fetch events: response.status: ${response.status}: ${response.bodyAsText()}") 91 | //throw IllegalStateException("Failed to fetch events: response.status: ${response.status}: ${response.bodyAsText()}") 92 | Pair(null, it.id) 93 | } else { 94 | val responseBody = response.bodyAsText() 95 | Pair(Json.decodeFromString(responseBody), null) 96 | } 97 | } 98 | return data 99 | } 100 | 101 | } -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/services/BetService.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.services 2 | 3 | import com.npd.betting.model.* 4 | import com.npd.betting.repositories.BetOptionRepository 5 | import com.npd.betting.repositories.BetRepository 6 | import com.npd.betting.repositories.WalletRepository 7 | import org.springframework.stereotype.Service 8 | import org.springframework.transaction.annotation.Transactional 9 | import org.slf4j.Logger 10 | import org.slf4j.LoggerFactory 11 | 12 | @Service 13 | @Transactional 14 | class BetService( 15 | private val betRepository: BetRepository, 16 | private val betOptionRepository: BetOptionRepository, 17 | private val walletRepository: WalletRepository 18 | ) { 19 | val logger: Logger = LoggerFactory.getLogger(BetService::class.java) 20 | 21 | fun setH2HResults( 22 | event: Event, 23 | h2hMarket: Market, 24 | winner: EventResult, 25 | ) { 26 | logger.info("Setting results for event ${event.id} with winner ${winner.name}") 27 | val winningMarketOption: MarketOption? = h2hMarket.options.find { 28 | it.name == when (winner) { 29 | EventResult.HOME_TEAM_WIN -> event.homeTeamName 30 | EventResult.AWAY_TEAM_WIN -> event.awayTeamName 31 | else -> "Draw" 32 | } 33 | } 34 | if (winningMarketOption != null) { 35 | logger.info("Winning market option has id '${winningMarketOption.id}' Updating all bet options having this market option with status WON") 36 | val result = betOptionRepository.updateAllByMarketOptionId(winningMarketOption.id, BetStatus.WON) 37 | logger.info("Updated ${result.toString()} bet options") 38 | 39 | val losingMarketOptions = h2hMarket.options.filter { it.name != winningMarketOption.name } 40 | logger.info("Updating all bet options having these market options with status LOST: ${losingMarketOptions.map { it.id }}") 41 | losingMarketOptions.forEach { 42 | betOptionRepository.updateAllByMarketOptionId(it.id, BetStatus.LOST) 43 | } 44 | betOptionRepository.flush() 45 | } else { 46 | logger.warn("Winning market option for winner '${winner.name}' not found for market with id ${h2hMarket.id}. All bets will loose!") 47 | 48 | h2hMarket.options.forEach { 49 | betOptionRepository.updateAllByMarketOptionId(it.id, BetStatus.LOST) 50 | } 51 | } 52 | updateBetsResults() 53 | } 54 | 55 | private fun updateBetsResults() { 56 | val winningBets = betRepository.findBetsWithWinningOptions(BetStatus.PENDING) 57 | logger.info("Found ${winningBets.size} winning bets") 58 | winningBets.forEach { 59 | it.status = BetStatus.WON 60 | betRepository.save(it) 61 | // pay out winnings 62 | val wallet = it.user.wallet ?: throw Error("Wallet not found for user ${it.user.id}") 63 | wallet.balance += it.calculatePotentialWinnings() 64 | walletRepository.save(wallet) 65 | } 66 | betRepository.updateAllLosing() 67 | } 68 | 69 | fun setTotalsResult( 70 | event: Event, 71 | totalsMarket: Market, 72 | result: EventResult, 73 | ) { 74 | logger.info("Setting results for event ${event.id} with result ${result.name}") 75 | val winningMarketOption: MarketOption? = totalsMarket.options.find { 76 | it.name == "Over" && result == EventResult.OVER || it.name == "Under" && result == EventResult.UNDER 77 | } 78 | if (winningMarketOption != null) { 79 | logger.info("Winning market option has id '${winningMarketOption.id}' Updating all bet options having this market option with status WON") 80 | betOptionRepository.updateAllByMarketOptionId(winningMarketOption.id, BetStatus.WON) 81 | 82 | val losingMarketOptions = 83 | totalsMarket.options.filter { it.name != winningMarketOption.name } 84 | logger.info("Updating all bet options having these market options with status LOST: ${losingMarketOptions.map { it.id }}") 85 | losingMarketOptions.forEach { 86 | betOptionRepository.updateAllByMarketOptionId(it.id, BetStatus.LOST) 87 | } 88 | betOptionRepository.flush() 89 | } 90 | updateBetsResults() 91 | } 92 | 93 | fun setSpreadResult(event: Event, spreadMarket: Market, winner: EventResult) { 94 | setH2HResults(event, spreadMarket, winner) 95 | } 96 | 97 | } -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/services/importer/EventImporter.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.services.importer 2 | 3 | import com.npd.betting.Props 4 | import com.npd.betting.services.EventService 5 | import io.ktor.client.request.* 6 | import io.ktor.client.statement.* 7 | import io.ktor.http.* 8 | import kotlinx.coroutines.runBlocking 9 | import kotlinx.serialization.Serializable 10 | import kotlinx.serialization.builtins.ListSerializer 11 | import kotlinx.serialization.json.Json 12 | import org.slf4j.Logger 13 | import org.slf4j.LoggerFactory 14 | import org.springframework.scheduling.annotation.Scheduled 15 | import org.springframework.stereotype.Component 16 | import org.springframework.transaction.annotation.Transactional 17 | import java.util.* 18 | 19 | 20 | @Serializable 21 | data class EventData( 22 | val id: String, 23 | val sport_key: String, 24 | val sport_title: String, 25 | val commence_time: Long, 26 | val home_team: String, 27 | val away_team: String, 28 | var bookmakers: List? = null, 29 | var completed: Boolean? = false, 30 | var scores: List? = null, 31 | var last_update: Long? = null 32 | ) { 33 | fun isLive(): Boolean { 34 | return Date().time > commence_time * 1000 35 | } 36 | } 37 | 38 | @Serializable 39 | data class SportData( 40 | val key: String, 41 | val active: Boolean, 42 | val group: String, 43 | val description: String, 44 | val title: String, 45 | val has_outrights: Boolean 46 | ) 47 | 48 | @Serializable 49 | data class Score( 50 | val name: String, 51 | val score: String 52 | ) 53 | 54 | @Serializable 55 | data class Bookmaker( 56 | val key: String, 57 | val title: String, 58 | val last_update: Long? = null, 59 | val markets: List 60 | ) 61 | 62 | @Serializable 63 | data class MarketData( 64 | val key: String, 65 | val last_update: Long, 66 | val outcomes: List 67 | ) 68 | 69 | @Serializable 70 | data class MarketOptionData( 71 | val name: String, 72 | val price: Double, 73 | val point: Double? = null, 74 | val description: String? = null 75 | ) 76 | 77 | 78 | @Component 79 | open class EventImporter(private val props: Props, private val service: EventService) { 80 | val logger: Logger = LoggerFactory.getLogger(EventImporter::class.java) 81 | 82 | companion object { 83 | const val API_BASE = "https://api.the-odds-api.com/v4/" 84 | const val MARKETS = "h2h,totals,spreads" 85 | 86 | fun getEventsUrl(apiKey: String): String { 87 | return "$API_BASE/sports/upcoming/odds/?markets=$MARKETS®ions=eu&dateFormat=unix&apiKey=$apiKey" 88 | } 89 | 90 | fun getSportsUrl(apiKey: String): String { 91 | return "$API_BASE/sports/?all=true&apiKey=$apiKey" 92 | } 93 | } 94 | 95 | @Scheduled(fixedRate = 30, timeUnit = java.util.concurrent.TimeUnit.MINUTES) // Poll the API every 10 minutes 96 | @Transactional 97 | open fun importEvents() { 98 | runBlocking { 99 | doImport() 100 | } 101 | } 102 | 103 | suspend fun doImport() { 104 | val sports = this.fetchSports() 105 | logger.debug("Importing ${sports.size} sports") 106 | service.importSports(sports) 107 | 108 | val events = this.fetchEvents() 109 | logger.debug("Importing ${events.size} events") 110 | service.importEvents(events) 111 | } 112 | 113 | suspend fun fetchSports(): List { 114 | val response: HttpResponse = httpClient.get(getSportsUrl(props.getOddsApiKey())) 115 | if (response.status != HttpStatusCode.OK) { 116 | throw IllegalStateException("Failed to fetch sports") 117 | } 118 | val responseBody = response.bodyAsText() 119 | return Json.decodeFromString(ListSerializer(SportData.serializer()), responseBody) 120 | } 121 | 122 | suspend fun fetchEvents(): List { 123 | val response: HttpResponse = httpClient.get(getEventsUrl(props.getOddsApiKey())) 124 | if (response.status != HttpStatusCode.OK) { 125 | throw IllegalStateException("Failed to fetch events") 126 | } 127 | val responseBody = response.bodyAsText() 128 | return Json.decodeFromString(ListSerializer(EventData.serializer()), responseBody) 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/controllers/EventController.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.controllers 2 | 3 | import com.npd.betting.model.Event 4 | import com.npd.betting.model.Market 5 | import com.npd.betting.model.ScoreUpdate 6 | import com.npd.betting.repositories.EventRepository 7 | import com.npd.betting.repositories.SportRepository 8 | import com.npd.betting.services.EventService 9 | import com.npd.betting.services.ResultService 10 | import jakarta.persistence.EntityManager 11 | import org.slf4j.Logger 12 | import org.slf4j.LoggerFactory 13 | import org.springframework.beans.factory.annotation.Autowired 14 | import org.springframework.graphql.data.method.annotation.Argument 15 | import org.springframework.graphql.data.method.annotation.MutationMapping 16 | import org.springframework.graphql.data.method.annotation.SchemaMapping 17 | import org.springframework.graphql.data.method.annotation.SubscriptionMapping 18 | import org.springframework.stereotype.Controller 19 | import reactor.core.publisher.Flux 20 | import java.sql.Timestamp 21 | import java.time.LocalDateTime 22 | 23 | @Controller 24 | class EventController @Autowired constructor( 25 | private val eventRepository: EventRepository, 26 | private val sportRepository: SportRepository, 27 | private val resultService: ResultService, 28 | private val entityManager: EntityManager, 29 | private val scoreUpdatesSink: AccumulatingSink, 30 | private val eventStatusUpdatesSink: AccumulatingSink 31 | ) { 32 | val logger: Logger = LoggerFactory.getLogger(EventController::class.java) 33 | 34 | @SchemaMapping(typeName = "Query", field = "getEvent") 35 | fun getEvent(@Argument id: Int): Event? { 36 | return eventRepository.findById(id).orElse(null) 37 | } 38 | 39 | @SchemaMapping(typeName = "Query", field = "listEvents") 40 | fun listEvents(): List { 41 | return eventRepository.findByIsLiveFalseAndCompletedFalse() 42 | } 43 | 44 | @SchemaMapping(typeName = "Query", field = "listAllEvents") 45 | fun listAllEvents(): List { 46 | return eventRepository.findByCompletedFalse() 47 | } 48 | 49 | @SchemaMapping(typeName = "Query", field = "eventsBySportGroup") 50 | fun eventsBySportGroup(@Argument group: String): List { 51 | if (group == "all") return eventRepository.findByCompletedFalse() 52 | return eventRepository.findBySportGroupAndCompletedFalse(group) 53 | } 54 | 55 | @SchemaMapping(typeName = "Query", field = "listLiveEvents") 56 | fun listLiveEvents(): List { 57 | return eventRepository.findByIsLiveTrueAndCompletedFalse() 58 | } 59 | 60 | // @SchemaMapping(typeName = "Event", field = "scoreUpdates") 61 | // fun getEventScoreUpdates(event: Event): List { 62 | // val query = entityManager.createQuery( 63 | // "SELECT e FROM Event e JOIN FETCH e.scoreUpdates s WHERE e.id = :id", Event::class.java 64 | // ) 65 | // query.setParameter("id", event.id) 66 | // val resultList = query.resultList 67 | // return if (resultList.isEmpty()) emptyList() else resultList[0].scoreUpdates 68 | // } 69 | 70 | @MutationMapping 71 | fun createEvent( 72 | @Argument homeTeamName: String, 73 | @Argument awayTeamName: String, 74 | @Argument name: String, 75 | @Argument startTime: String, 76 | @Argument sport: String 77 | ): Event { 78 | 79 | val sportEntity = sportRepository.findByKey(sport) ?: throw Exception("Sport with key $sport does not exist") 80 | val event = Event( 81 | homeTeamName = homeTeamName, 82 | awayTeamName = awayTeamName, 83 | name = name, 84 | startTime = Timestamp.valueOf(LocalDateTime.parse(startTime)), 85 | sport = sportEntity, 86 | isLive = false, 87 | completed = false 88 | ) 89 | eventRepository.save(event) 90 | return event 91 | } 92 | 93 | @SchemaMapping(typeName = "Mutation", field = "updateResult") 94 | fun updateResult(@Argument("eventId") eventId: Int) { 95 | logger.info("Updating result for event $eventId") 96 | resultService.updateResult(eventId) 97 | } 98 | 99 | @SubscriptionMapping 100 | fun eventScoresUpdated(): Flux> { 101 | return scoreUpdatesSink.asFlux() 102 | } 103 | 104 | @SubscriptionMapping 105 | fun eventStatusUpdated(): Flux> { 106 | return eventStatusUpdatesSink.asFlux() 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/controllers/BetController.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.controllers 2 | 3 | import com.npd.betting.model.Bet 4 | import com.npd.betting.model.BetOption 5 | import com.npd.betting.model.BetStatus 6 | import com.npd.betting.repositories.* 7 | import com.npd.betting.services.EventService 8 | import com.npd.betting.services.UserService 9 | import org.slf4j.Logger 10 | import org.slf4j.LoggerFactory 11 | import org.springframework.beans.factory.annotation.Autowired 12 | import org.springframework.data.domain.PageRequest 13 | import org.springframework.graphql.data.method.annotation.Argument 14 | import org.springframework.graphql.data.method.annotation.SchemaMapping 15 | import org.springframework.stereotype.Controller 16 | import org.springframework.transaction.annotation.Transactional 17 | import java.math.BigDecimal 18 | 19 | class NotFoundException(message: String) : RuntimeException(message) 20 | class InsufficientFundsException(message: String) : RuntimeException(message) 21 | class InvalidBetStatusException(message: String) : RuntimeException(message) 22 | 23 | 24 | class BetOptionInput( 25 | val marketOptionId: Int, 26 | val stake: BigDecimal 27 | ) 28 | 29 | @Controller 30 | open class BetController @Autowired constructor( 31 | private val betRepository: BetRepository, 32 | private val userRepository: UserRepository, 33 | private val marketOptionRepository: MarketOptionRepository, 34 | private val betOptionRepository: BetOptionRepository, 35 | private val walletRepository: WalletRepository, 36 | private val userService: UserService 37 | ) { 38 | val logger: Logger = LoggerFactory.getLogger(BetController::class.java) 39 | 40 | @SchemaMapping(typeName = "Query", field = "getBet") 41 | fun getBet(@Argument id: Int): Bet? { 42 | return betRepository.findById(id).orElse(null) 43 | } 44 | 45 | @SchemaMapping(typeName = "Query", field = "listBets") 46 | fun listBets(): List { 47 | val user = userService.findAuthenticatedUser() 48 | logger.debug("Fetching bets for user ${user.id}") 49 | return betRepository.findByUserIdOrderByCreatedAtDesc(user) 50 | } 51 | 52 | @SchemaMapping(typeName = "Bet", field = "potentialWinnings") 53 | fun potentialWinnings(bet: Bet): BigDecimal { 54 | return bet.calculatePotentialWinnings() 55 | } 56 | 57 | @SchemaMapping(typeName = "Mutation", field = "placeBet") 58 | @Transactional 59 | open fun placeBet( 60 | @Argument("betType") betType: String, 61 | @Argument("marketOptions") marketOptionIds: List, 62 | @Argument("stake") stake: BigDecimal 63 | ): Bet { 64 | val user = userService.findAuthenticatedUser() 65 | 66 | user.wallet.let { wallet -> 67 | if (wallet == null) { 68 | throw RuntimeException("User has no wallet") 69 | } 70 | if (wallet.balance < stake) { 71 | throw InsufficientFundsException("Insufficient funds") 72 | } 73 | } 74 | 75 | val marketOptions = marketOptionRepository.findAllById(marketOptionIds) 76 | if (marketOptions.size != marketOptionIds.size) { 77 | throw NotFoundException("Some market options not found") 78 | } 79 | 80 | val bet = Bet(user, stake, BetStatus.PENDING) 81 | val savedBet = betRepository.save(bet) // Save the Bet entity first to generate the ID 82 | 83 | marketOptions.forEach { marketOption -> 84 | val betOption = BetOption(savedBet, marketOption) // Use the savedBet with the generated ID 85 | betOptionRepository.save(betOption) 86 | savedBet.betOptions.add(betOption) // Add the BetOption entity to the savedBet's betOptions 87 | } 88 | 89 | user.wallet.let { wallet -> 90 | wallet!!.balance -= stake 91 | walletRepository.save(wallet!!) 92 | } 93 | return savedBet 94 | } 95 | 96 | @SchemaMapping(typeName = "Mutation", field = "placeSingleBets") 97 | @Transactional 98 | open fun placeSingleBets( 99 | @Argument("options") options: List 100 | ): List { 101 | val user = userService.findAuthenticatedUser() 102 | 103 | user.wallet.let { wallet -> 104 | if (wallet == null) { 105 | throw RuntimeException("User has no wallet") 106 | } 107 | } 108 | 109 | return options.map { option -> 110 | val bet = Bet(user, option.stake, BetStatus.PENDING) 111 | val savedBet = betRepository.save(bet) // Save the Bet entity first to generate the ID 112 | 113 | val marketOption = marketOptionRepository.findById(option.marketOptionId) 114 | if (marketOption.isEmpty) { 115 | throw NotFoundException("Market option not found") 116 | } 117 | 118 | user.wallet.let { wallet -> 119 | if (wallet!!.balance < option.stake) { 120 | throw InsufficientFundsException("Insufficient funds") 121 | } 122 | wallet.balance -= option.stake 123 | walletRepository.save(wallet) 124 | } 125 | 126 | val betOption = BetOption(savedBet, marketOption.get()) // Use the savedBet with the generated ID 127 | betOptionRepository.save(betOption) 128 | savedBet.betOptions.add(betOption) 129 | savedBet 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/repositories/Repositories.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.repositories 2 | 3 | import com.npd.betting.model.* 4 | import org.springframework.data.domain.Pageable 5 | import org.springframework.data.jpa.repository.EntityGraph 6 | import org.springframework.data.jpa.repository.JpaRepository 7 | import org.springframework.data.jpa.repository.Modifying 8 | import org.springframework.data.jpa.repository.Query 9 | import org.springframework.data.repository.query.Param 10 | import org.springframework.stereotype.Repository 11 | import org.springframework.transaction.annotation.Transactional 12 | import java.sql.Timestamp 13 | 14 | // default methods in JPA repiositories 15 | // https://www.tutorialspoint.com/spring_boot_jpa/spring_boot_jpa_repository_methods.htm 16 | 17 | interface UserRepository : JpaRepository { 18 | fun findByEmail(email: String): User? 19 | fun findByExternalId(externalId: String?): User? 20 | } 21 | 22 | interface WalletRepository : JpaRepository { 23 | fun findByUserId(userId: Int): Wallet? 24 | } 25 | 26 | interface BetRepository : JpaRepository { 27 | @Query( 28 | "SELECT b FROM Bet b " 29 | + "JOIN FETCH b.betOptions bo " 30 | + "JOIN FETCH bo.marketOption mo " 31 | + "JOIN FETCH mo.market m " 32 | + "JOIN FETCH m.event e " 33 | + "JOIN FETCH e.markets em " 34 | + "JOIN FETCH em.options " 35 | + "LEFT JOIN FETCH e.scoreUpdates " 36 | + "JOIN FETCH e.sport " 37 | + "WHERE b.user = :user ORDER BY b.createdAt DESC" 38 | ) 39 | fun findByUserIdOrderByCreatedAtDesc(user: User): List 40 | 41 | @Modifying 42 | @Query("UPDATE Bet b SET b.status = com.npd.betting.model.BetStatus.WON WHERE b.status = com.npd.betting.model.BetStatus.PENDING AND 0 = (SELECT COUNT(bo) FROM BetOption bo WHERE bo.bet.id = b.id AND bo.status != com.npd.betting.model.BetStatus.WON)") 43 | fun updateAllWinning() 44 | 45 | @Modifying 46 | @Query( 47 | "UPDATE Bet b SET b.status = com.npd.betting.model.BetStatus.LOST WHERE b.status = com.npd.betting.model.BetStatus.PENDING " + 48 | "AND 0 = (SELECT COUNT(bo) FROM BetOption bo WHERE bo.bet.id = b.id AND bo.status = com.npd.betting.model.BetStatus.PENDING) " + 49 | "AND 0 < (SELECT COUNT(bo) FROM BetOption bo WHERE bo.bet.id = b.id AND bo.status = com.npd.betting.model.BetStatus.LOST)" 50 | ) 51 | fun updateAllLosing() 52 | 53 | @Query("SELECT b FROM Bet b WHERE b.status = com.npd.betting.model.BetStatus.PENDING AND 0 = (SELECT COUNT(bo) FROM BetOption bo WHERE bo.bet.id = b.id AND bo.status != com.npd.betting.model.BetStatus.WON)") 54 | fun findAllWinning(marketOptionId: Int): List 55 | 56 | @Query( 57 | "SELECT b FROM Bet b WHERE b.status = :currentStatus " + 58 | "AND 0 = (SELECT COUNT(bo) FROM BetOption bo WHERE bo.bet.id = b.id AND bo.status != com.npd.betting.model.BetStatus.WON)" 59 | ) 60 | fun findBetsWithWinningOptions(currentStatus: BetStatus): List 61 | } 62 | 63 | @Repository 64 | @Transactional 65 | interface EventRepository : JpaRepository { 66 | fun findByIsLiveTrueAndCompletedFalse(): List 67 | fun findByIsLiveFalseAndCompletedFalse(): List 68 | 69 | 70 | fun findByExternalId(externalId: String): Event? 71 | 72 | fun findBySportIdAndCompletedFalse(sportId: Int): List 73 | 74 | fun countBySportIdAndCompleted(sportId: Int, completed: Boolean): Int 75 | 76 | @EntityGraph(value = "Event.withMarketsAndOptions", type = EntityGraph.EntityGraphType.LOAD, attributePaths = ["markets", "markets.options"]) 77 | fun findByCompletedFalse(): List 78 | 79 | @EntityGraph(value = "Event.withMarketsAndOptions", type = EntityGraph.EntityGraphType.LOAD, attributePaths = ["markets", "markets.options"]) 80 | fun findBySportGroupAndCompletedFalse(@Param("group") group: String): List 81 | 82 | 83 | } 84 | 85 | 86 | interface MarketRepository : JpaRepository { 87 | fun findByEventId(eventId: Int): List 88 | fun findByEventIdAndIsLiveTrue(eventId: Int): List 89 | fun findByEventIdAndSourceAndName(eventId: Int, source: String, name: String): Market? 90 | } 91 | 92 | 93 | interface MarketOptionRepository : JpaRepository { 94 | fun findAllByLastUpdatedAfter(lastUpdated: Timestamp): List 95 | fun findByMarketId(marketId: Int): List 96 | } 97 | 98 | interface TransactionRepository : JpaRepository 99 | 100 | interface BetOptionRepository : JpaRepository { 101 | @Modifying 102 | @Query("UPDATE BetOption b SET b.status = :status WHERE b.marketOption.id = :marketOptionId") 103 | fun updateAllByMarketOptionId(marketOptionId: Int, status: BetStatus) 104 | } 105 | 106 | interface ScoreUpdateRepository : JpaRepository { 107 | fun findByEventId(eventId: Int): List 108 | fun deleteByEventId(eventId: Int) 109 | fun findFirstByEventIdAndNameOrderByTimestampDesc(id: Int, name: String): ScoreUpdate? 110 | 111 | } 112 | 113 | interface SportRepository : JpaRepository { 114 | fun findByKey(key: String): Sport? 115 | 116 | @Query("SELECT s FROM Sport s JOIN FETCH s.events e LEFT JOIN FETCH e.scoreUpdates su JOIN FETCH e.markets m JOIN FETCH m.options o WHERE e.completed = false") 117 | fun findByActiveTrue(): List 118 | 119 | @Query("SELECT s FROM Sport s JOIN FETCH s.events e JOIN FETCH e.markets m JOIN FETCH m.options o LEFT JOIN FETCH e.scoreUpdates su WHERE e.completed = false AND s.group = :group") 120 | fun findByGroupAndActiveTrue(group: String): List 121 | } 122 | -------------------------------------------------------------------------------- /library/src/main/resources/graphql/schema.graphqls: -------------------------------------------------------------------------------- 1 | scalar DateTime 2 | 3 | type User { 4 | id: ID! 5 | externalId: String 6 | username: String 7 | email: String 8 | wallet: Wallet 9 | bets: [Bet] 10 | } 11 | 12 | enum EventResult { 13 | HOME_TEAM_WIN 14 | DRAW 15 | AWAY_TEAM_WIN 16 | } 17 | 18 | """ 19 | An Event represents a sports match or competition on which users can place bets. 20 | """ 21 | type Event { 22 | id: ID! 23 | """ 24 | The id of the event in the source system. 25 | """ 26 | externalId: String 27 | """ 28 | Is this event currently live? 29 | """ 30 | isLive: Boolean! 31 | """ 32 | Is this event completed? 33 | """ 34 | completed: Boolean! 35 | name: String! 36 | homeTeamName: String! 37 | awayTeamName: String! 38 | startTime: String! 39 | sport: Sport! 40 | markets(source: String): [Market] 41 | scoreUpdates: [ScoreUpdate] 42 | result: EventResult 43 | } 44 | 45 | type Sport { 46 | id: ID! 47 | key: String! 48 | """ 49 | Is this sport in season at the moment? 50 | """ 51 | active: Boolean! 52 | group: String! 53 | description: String! 54 | title: String! 55 | """ 56 | Does this sport have outright markets? 57 | """ 58 | hasOutrights: Boolean! 59 | 60 | """ 61 | List all events available for betting in this sport. 62 | """ 63 | events: [Event] 64 | 65 | activeEventCount: Int 66 | } 67 | 68 | """ 69 | A Market represents a specific betting opportunity within an event. 70 | It's usually associated with one aspect of the event that users can bet on. 71 | A single event can have multiple markets. Some common examples of markets 72 | include Moneyline, Point Spread, and Totals (Over/Under). 73 | """ 74 | type Market { 75 | id: ID! 76 | """ 77 | What is the source or bookmaker that provides the odds for this Market? 78 | """ 79 | source: String! 80 | """ 81 | Is this Market available for live betting? 82 | """ 83 | isLive: Boolean! 84 | """ 85 | When was this Market last updated? Used to track when the odds were last 86 | updated during live betting. 87 | """ 88 | lastUpdated: String 89 | name: String! 90 | event: Event 91 | options: [MarketOption] 92 | } 93 | 94 | """ 95 | A MarketOption represents a specific choice or outcome within a market 96 | that users can bet on. Each market typically has two or more options to choose from. 97 | """ 98 | type MarketOption { 99 | id: ID! 100 | """ 101 | When was this Market last updated? Used to track when the odds were last 102 | updated during live betting. 103 | """ 104 | lastUpdated: String 105 | name: String! 106 | odds: Float! 107 | point: Float 108 | market: Market 109 | description: String 110 | } 111 | 112 | type ScoreUpdate { 113 | id: ID! 114 | event: Event! 115 | score: String! 116 | name: String! 117 | timestamp: String! 118 | } 119 | 120 | type BetOption { 121 | id: Int! 122 | bet: Bet! 123 | marketOption: MarketOption! 124 | status: BetStatus 125 | } 126 | 127 | type Bet { 128 | id: ID! 129 | user: User 130 | betOptions: [BetOption] 131 | stake: Float! 132 | potentialWinnings: Float! 133 | createdAt: String! 134 | status: BetStatus! 135 | } 136 | 137 | type Wallet { 138 | id: ID! 139 | user: User 140 | balance: Float! 141 | transactions: [Transaction] 142 | } 143 | 144 | type Transaction { 145 | id: ID! 146 | wallet: Wallet 147 | amount: Float! 148 | transactionType: TransactionType! 149 | createdAt: String! 150 | } 151 | 152 | enum BetStatus { 153 | PENDING 154 | WON 155 | LOST 156 | CANCELED 157 | } 158 | 159 | enum TransactionType { 160 | DEPOSIT 161 | WITHDRAWAL 162 | BET_PLACED 163 | BET_WON 164 | BET_REFUNDED 165 | } 166 | 167 | """ 168 | https://chat.openai.com/share/92b7bc9f-6fc6-4f57-9a4e-f217270ad271 169 | """ 170 | enum BetType { 171 | SINGLE 172 | PARLAY # same as long bet or accumulator 173 | SYSTEM 174 | } 175 | 176 | type Subscription { 177 | liveMarketOptionsUpdated: [MarketOption] 178 | eventScoresUpdated: [Event] 179 | eventStatusUpdated: [Event] 180 | } 181 | 182 | input BetOptionInput { 183 | marketOptionId: ID! 184 | stake: Float! 185 | } 186 | 187 | 188 | # Queries 189 | 190 | type Query { 191 | me: User 192 | 193 | """ 194 | List sports by group. 195 | """ 196 | listSports(group: String): [Sport] 197 | 198 | getEvent(id: ID!): Event 199 | 200 | """ 201 | List upcoming events available for betting. 202 | """ 203 | listEvents: [Event] 204 | """ 205 | List live events available for betting. 206 | """ 207 | listLiveEvents: [Event] 208 | """ 209 | List events available for betting in the specified sport group. Lists both live an upcoming events. 210 | """ 211 | eventsBySportGroup(group: String!): [Event] 212 | """ 213 | List all events available for betting. Lists both live an upcoming events. 214 | """ 215 | listAllEvents: [Event] 216 | 217 | getMarket(id: ID!): Market 218 | listMarkets(eventId: ID!): [Market] 219 | getBet(id: ID!): Bet 220 | listBets: [Bet] 221 | 222 | listLiveMarkets(eventId: ID!): [Market] 223 | 224 | # TODO: admin queries, should use pagination 225 | # getUser(id: ID!): User 226 | # listUsers: [User] 227 | # listBets: [Bet] 228 | } 229 | 230 | # Mutations 231 | 232 | type Mutation { 233 | createUser(username: String!, email: String!): User 234 | """ 235 | Places a bet on the provided market options. 236 | """ 237 | placeBet(betType: BetType!, marketOptions: [ID!]!, stake: Float!): Bet 238 | """ 239 | Place multiple single bets, one for each option provided. 240 | """ 241 | placeSingleBets(options: [BetOptionInput!]!): [Bet] 242 | 243 | updateResult(eventId: ID!): Event 244 | 245 | depositFunds(userId: ID!, amount: Float!): Wallet 246 | withdrawFunds(userId: ID!, amount: Float!): Wallet 247 | 248 | # should be available for admins only: 249 | createEvent(name: String!, startTime: String!, sport: String!): Event 250 | createMarket(name: String!, eventId: ID!): Market 251 | createMarketOption(name: String!, odds: Float!, marketId: ID!): MarketOption 252 | } 253 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/services/ResultService.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.services 2 | 3 | import com.npd.betting.model.Event 4 | import com.npd.betting.model.EventResult 5 | import com.npd.betting.model.ScoreUpdate 6 | import com.npd.betting.repositories.EventRepository 7 | import com.npd.betting.repositories.MarketRepository 8 | import com.npd.betting.repositories.ScoreUpdateRepository 9 | import org.slf4j.Logger 10 | import org.slf4j.LoggerFactory 11 | import org.springframework.stereotype.Service 12 | import org.springframework.transaction.annotation.Transactional 13 | import kotlin.jvm.optionals.getOrNull 14 | 15 | @Service 16 | @Transactional 17 | class ResultService( 18 | private val eventRepository: EventRepository, 19 | private val scoreUpdateRepository: ScoreUpdateRepository, 20 | private val betService: BetService, 21 | private val marketRepository: MarketRepository 22 | ) { 23 | val logger: Logger = LoggerFactory.getLogger(EventService::class.java) 24 | 25 | 26 | @Transactional 27 | fun updateResult(eventId: Int) { 28 | val event = 29 | eventRepository.findById(eventId).getOrNull() 30 | ?: throw Error("Cannot find event with id $eventId") 31 | saveEventResult(event) 32 | saveMatchTotalsResult(event, event.completed ?: false) 33 | 34 | } 35 | 36 | @Transactional 37 | fun saveEventResult(event: Event) { 38 | logger.info("saveEventResult(): Updating result for event ${event.id}. Event Completed ? ${event.completed}") 39 | if (event.completed == true) { 40 | saveH2HResult(event) 41 | saveSpreadsResult(event) 42 | } 43 | saveMatchTotalsResult(event, event.completed ?: false) 44 | } 45 | 46 | private fun saveSpreadsResult(event: Event) { 47 | val spreadMarkets = marketRepository.findByEventId(event.id).filter { it.name == "spreads" } 48 | if (spreadMarkets.isEmpty()) { 49 | logger.info("Cannot find spreads market for event with id ${event.id}") 50 | return 51 | } else { 52 | logger.info("Found spreads markets ${spreadMarkets.map { it.id }} for event with id ${event.id}") 53 | } 54 | spreadMarkets.forEach { spreadMarket -> 55 | val homeTeamOption = spreadMarket.options.find { it.name == event.homeTeamName } 56 | val awayTeamOption = spreadMarket.options.find { it.name == event.awayTeamName } 57 | if (homeTeamOption == null || awayTeamOption == null) { 58 | logger.error("Cannot find home or away team option for spread market ${spreadMarket.id}") 59 | return 60 | } 61 | logger.debug( 62 | "Found home team option {} with handicap {} and away team option {} with handicap {} for spread market {} for event {}", 63 | homeTeamOption.id, 64 | homeTeamOption.point, 65 | awayTeamOption.id, 66 | awayTeamOption.point, 67 | spreadMarket.id, 68 | event.id 69 | ) 70 | val homeTeamScore = 71 | (scoreUpdateRepository.findFirstByEventIdAndNameOrderByTimestampDesc(event.id, event.homeTeamName)?.score?.toInt() ?: 0) + (homeTeamOption.point?.toDouble() ?: 0.0) 72 | val awayTeamScore = 73 | (scoreUpdateRepository.findFirstByEventIdAndNameOrderByTimestampDesc(event.id, event.awayTeamName)?.score?.toInt() ?: 0) + (awayTeamOption.point?.toDouble() ?: 0.0) 74 | logger.debug("Calculated handicapped home team score: $homeTeamScore and away team score: $awayTeamScore for event ${event.id}") 75 | 76 | val winner = when { 77 | homeTeamScore > awayTeamScore -> EventResult.HOME_TEAM_WIN 78 | homeTeamScore < awayTeamScore -> EventResult.AWAY_TEAM_WIN 79 | else -> EventResult.DRAW 80 | } 81 | betService.setSpreadResult(event, spreadMarket, winner) 82 | } 83 | } 84 | 85 | private fun saveH2HResult(event: Event) { 86 | val h2hMarkets = marketRepository.findByEventId(event.id).filter { it.name == "h2h" } 87 | if (h2hMarkets.isEmpty()) { 88 | logger.info("Cannot find h2h market for event with id ${event.id}") 89 | return 90 | } else { 91 | logger.info("Found h2h markets ${h2hMarkets.map { it.id }} for event with id ${event.id}") 92 | } 93 | 94 | val homeTeamScore = 95 | scoreUpdateRepository.findFirstByEventIdAndNameOrderByTimestampDesc(event.id, event.homeTeamName) 96 | val awayTeamScore = 97 | scoreUpdateRepository.findFirstByEventIdAndNameOrderByTimestampDesc(event.id, event.awayTeamName) 98 | 99 | val winner: EventResult = when { 100 | (homeTeamScore?.score?.toInt() ?: 0) > (awayTeamScore?.score?.toInt() ?: 0) -> EventResult.HOME_TEAM_WIN 101 | (homeTeamScore?.score?.toInt() ?: 0) < (awayTeamScore?.score?.toInt() ?: 0) -> EventResult.AWAY_TEAM_WIN 102 | else -> EventResult.DRAW 103 | } 104 | event.completed = true 105 | event.result = winner 106 | eventRepository.save(event) 107 | 108 | h2hMarkets.forEach { h2hMarket -> 109 | betService.setH2HResults(event, h2hMarket, winner) 110 | } 111 | } 112 | 113 | @Transactional 114 | fun saveMatchTotalsResult(event: Event, isFinalResult: Boolean) { 115 | val markets = marketRepository.findByEventId(event.id).filter { it.name == "totals" } 116 | if (markets.isEmpty()) { 117 | logger.info("No totals market found for event ${event.id}") 118 | return 119 | } 120 | val totalScore = getTotalNumberOfScores(event) 121 | 122 | markets.forEach { 123 | val over = it.options.find { option -> option.name == "Over" } 124 | // compare total score with over point as decimal numbers 125 | val result = if (totalScore < (over!!.point?.toDouble() ?: 0.0)) { 126 | EventResult.UNDER 127 | } else { 128 | EventResult.OVER 129 | } 130 | logger.info("Totals result for event ${event.id} is ${result.name}, (total scores: $totalScore, over point: ${over.point}, final result? $isFinalResult)") 131 | if (isFinalResult || result == EventResult.OVER) { 132 | // either the game is complete or we already went over the point 133 | betService.setTotalsResult(event, it, result) 134 | } 135 | } 136 | } 137 | 138 | private fun getTotalNumberOfScores( 139 | event: Event 140 | ): Int { 141 | val scores = scoreUpdateRepository.findByEventId(event.id) 142 | if (scores.isEmpty()) { 143 | logger.info("No scores found for event ${event.id}") 144 | return 0 145 | } 146 | val homeScore = 147 | scores.filter { it.name == event.homeTeamName }.maxByOrNull { it.score.toInt() }?.score?.toInt() ?: 0 148 | val awayScore = 149 | scores.filter { it.name == event.awayTeamName }.maxByOrNull { it.score.toInt() }?.score?.toInt() ?: 0 150 | return homeScore + awayScore 151 | } 152 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Use "xargs" to parse quoted args. 209 | # 210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 211 | # 212 | # In Bash we could simply go: 213 | # 214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 215 | # set -- "${ARGS[@]}" "$@" 216 | # 217 | # but POSIX shell has neither arrays nor command substitution, so instead we 218 | # post-process each arg (as a line of input to sed) to backslash-escape any 219 | # character that might be a shell metacharacter, then use eval to reverse 220 | # that process (while maintaining the separation between arguments), and wrap 221 | # the whole thing up as a single "set" statement. 222 | # 223 | # This will of course break if any of these variables contains a newline or 224 | # an unmatched quote. 225 | # 226 | 227 | eval "set -- $( 228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 229 | xargs -n1 | 230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 231 | tr '\n' ' ' 232 | )" '"$@"' 233 | 234 | exec "$JAVACMD" "$@" 235 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/model/Entities.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.model 2 | 3 | import jakarta.persistence.* 4 | import org.hibernate.annotations.Fetch 5 | import org.hibernate.annotations.FetchMode 6 | import java.math.BigDecimal 7 | import java.sql.Timestamp 8 | 9 | @Entity 10 | @Table(name = "users") 11 | data class User( 12 | @Id 13 | @GeneratedValue(strategy = GenerationType.IDENTITY) 14 | val id: Int = 0, 15 | 16 | @Column(name = "external_id", unique = true, nullable = false) 17 | val externalId: String, 18 | 19 | @Column(name = "username", unique = true, nullable = true) 20 | val username: String?, 21 | 22 | @Column(name = "email", unique = true, nullable = true) 23 | val email: String?, 24 | 25 | @OneToOne(mappedBy = "user", cascade = [CascadeType.ALL]) 26 | var wallet: Wallet? = null, 27 | 28 | @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL]) 29 | val bets: List = emptyList() 30 | ) { 31 | override fun hashCode(): Int { 32 | return id.hashCode() 33 | } 34 | override fun equals(other: Any?): Boolean { 35 | if (this === other) return true 36 | if (javaClass != other?.javaClass) return false 37 | other as User 38 | return id == other.id 39 | } 40 | } 41 | 42 | @Entity 43 | @Table(name = "wallets") 44 | data class Wallet( 45 | @Id 46 | @GeneratedValue(strategy = GenerationType.IDENTITY) 47 | val id: Int = 0, 48 | 49 | @OneToOne 50 | @JoinColumn(name = "user_id") 51 | val user: User, 52 | 53 | @Column(name = "balance", nullable = false) 54 | var balance: BigDecimal, 55 | 56 | @OneToMany(mappedBy = "wallet", cascade = [CascadeType.ALL]) 57 | val transactions: List = emptyList() 58 | ) { 59 | override fun hashCode(): Int { 60 | return id.hashCode() 61 | } 62 | override fun equals(other: Any?): Boolean { 63 | if (this === other) return true 64 | if (javaClass != other?.javaClass) return false 65 | other as Wallet 66 | return id == other.id 67 | } 68 | } 69 | 70 | @Entity 71 | @Table(name = "transactions") 72 | data class Transaction( 73 | @Id 74 | @GeneratedValue(strategy = GenerationType.IDENTITY) 75 | val id: Int = 0, 76 | 77 | @ManyToOne 78 | @JoinColumn(name = "wallet_id") 79 | val wallet: Wallet, 80 | 81 | @Column(name = "amount", nullable = false) 82 | val amount: BigDecimal, 83 | 84 | @Enumerated(EnumType.STRING) 85 | @Column(name = "transaction_type", nullable = false) 86 | val transactionType: TransactionType, 87 | 88 | @Column(name = "created_at", nullable = false) 89 | val createdAt: Timestamp = Timestamp(System.currentTimeMillis()) 90 | ) 91 | 92 | enum class TransactionType { 93 | DEPOSIT, 94 | WITHDRAWAL, 95 | BET_PLACED, 96 | BET_WON, 97 | BET_REFUNDED 98 | } 99 | 100 | enum class EventResult { 101 | HOME_TEAM_WIN, 102 | DRAW, 103 | AWAY_TEAM_WIN, 104 | OVER, 105 | UNDER 106 | } 107 | 108 | @Entity 109 | @Table(name = "events") 110 | @NamedEntityGraph( 111 | name = "Event.withMarketsAndOptions", 112 | attributeNodes = [ 113 | NamedAttributeNode("markets", subgraph = "Market.withOptions"), 114 | NamedAttributeNode("sport") 115 | ], 116 | subgraphs = [ 117 | NamedSubgraph( 118 | name = "Market.withOptions", 119 | attributeNodes = [NamedAttributeNode("options")] 120 | ) 121 | ] 122 | ) 123 | data class Event( 124 | @Id 125 | @GeneratedValue(strategy = GenerationType.IDENTITY) 126 | val id: Int = 0, 127 | 128 | @Column(name = "external_id", unique = true) 129 | val externalId: String? = null, 130 | 131 | @Column(name = "is_live", nullable = false) 132 | var isLive: Boolean, 133 | 134 | @Column(name = "completed", nullable = false) 135 | var completed: Boolean? = false, 136 | 137 | @Column(name = "name", nullable = false) 138 | val name: String, 139 | 140 | @Column(name = "homeTeamName", nullable = false) 141 | var homeTeamName: String, 142 | 143 | @Column(name = "awayTeamName", nullable = false) 144 | var awayTeamName: String, 145 | 146 | @Column(name = "start_time", nullable = false) 147 | val startTime: Timestamp, 148 | 149 | @ManyToOne(fetch = FetchType.LAZY) 150 | @JoinColumn(name = "sport") 151 | val sport: Sport, 152 | 153 | @OneToMany(mappedBy = "event", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) 154 | val markets: List = emptyList(), 155 | 156 | @OneToMany(mappedBy = "event", cascade = [CascadeType.ALL]) 157 | val scoreUpdates: MutableSet = mutableSetOf(), 158 | 159 | @Enumerated(EnumType.STRING) 160 | @Column(name = "result") 161 | var result: EventResult? = null 162 | ) { 163 | override fun hashCode(): Int { 164 | return id.hashCode() 165 | } 166 | 167 | override fun equals(other: Any?): Boolean { 168 | if (this === other) return true 169 | if (javaClass != other?.javaClass) return false 170 | other as Event 171 | return id == other.id 172 | } 173 | 174 | } 175 | 176 | @Entity 177 | @Table(name = "sports") 178 | data class Sport( 179 | @Id 180 | @GeneratedValue(strategy = GenerationType.IDENTITY) 181 | val id: Int = 0, 182 | 183 | @Column(name = "`key`", nullable = false) 184 | val key: String, 185 | 186 | @Column(name = "title", nullable = false) 187 | val title: String, 188 | 189 | @Column(name = "description", nullable = false) 190 | val description: String, 191 | 192 | @Column(name = "groupId", nullable = false) 193 | val group: String, 194 | 195 | @Column(name = "active", nullable = false) 196 | var active: Boolean, 197 | 198 | @Column(name = "has_outrights", nullable = false) 199 | val hasOutrights: Boolean, 200 | 201 | @OneToMany(mappedBy = "sport", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) 202 | @Fetch(FetchMode.SUBSELECT) 203 | val events: Set = emptySet() 204 | ) { 205 | override fun hashCode(): Int { 206 | return id.hashCode() 207 | } 208 | 209 | override fun equals(other: Any?): Boolean { 210 | if (this === other) return true 211 | if (javaClass != other?.javaClass) return false 212 | other as Sport 213 | return id == other.id 214 | } 215 | } 216 | 217 | @Entity 218 | @Table(name = "markets") 219 | data class Market( 220 | @Id 221 | @GeneratedValue(strategy = GenerationType.IDENTITY) 222 | val id: Int = 0, 223 | 224 | @Column(name = "is_live", nullable = false) 225 | var isLive: Boolean, 226 | 227 | @Column(name = "last_updated") 228 | var lastUpdated: Timestamp? = null, 229 | 230 | @Column(name = "name", nullable = false) 231 | val name: String, 232 | 233 | @Column(name = "source", nullable = false) 234 | val source: String, 235 | 236 | @ManyToOne(fetch = FetchType.LAZY) 237 | @JoinColumn(name = "event_id") 238 | val event: Event, 239 | 240 | @OneToMany(mappedBy = "market", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) 241 | val options: Set = emptySet() 242 | ) 243 | 244 | @Entity 245 | @Table(name = "market_options") 246 | data class MarketOption( 247 | @Id 248 | @GeneratedValue(strategy = GenerationType.IDENTITY) 249 | val id: Int = 0, 250 | 251 | @Column(name = "last_updated") 252 | var lastUpdated: Timestamp? = null, 253 | 254 | @Column(name = "name", nullable = false) 255 | val name: String, 256 | 257 | @Column(name = "odds", nullable = false) 258 | var odds: BigDecimal, 259 | 260 | @Column(name = "point") 261 | var point: BigDecimal? = null, 262 | 263 | @Column(name = "description") 264 | var description: String? = null, 265 | 266 | @ManyToOne(fetch = FetchType.LAZY) 267 | @JoinColumn(name = "market_id") 268 | val market: Market, 269 | 270 | @OneToMany(mappedBy = "marketOption", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) 271 | val betOptions: MutableList = mutableListOf() 272 | ) { 273 | override fun hashCode(): Int { 274 | return id.hashCode() 275 | } 276 | 277 | override fun equals(other: Any?): Boolean { 278 | if (this === other) return true 279 | if (javaClass != other?.javaClass) return false 280 | other as MarketOption 281 | return id == other.id 282 | } 283 | } 284 | 285 | @Entity 286 | @Table(name = "score_updates") 287 | data class ScoreUpdate( 288 | @Id 289 | @GeneratedValue(strategy = GenerationType.IDENTITY) 290 | val id: Int = 0, 291 | 292 | @ManyToOne 293 | @JoinColumn(name = "event_id") 294 | val event: Event, 295 | 296 | @Column(name = "score", nullable = false) 297 | val score: String, 298 | 299 | @Column(name = "name", nullable = false) 300 | val name: String, 301 | 302 | @Column(name = "timestamp", nullable = false) 303 | val timestamp: Timestamp 304 | ) 305 | 306 | @Entity 307 | @Table(name = "bets") 308 | data class Bet( 309 | 310 | @ManyToOne(fetch = FetchType.LAZY) 311 | @JoinColumn(name = "user_id") 312 | val user: User, 313 | 314 | @Column(name = "stake", nullable = false) 315 | val stake: BigDecimal, 316 | 317 | @Column(name = "status", nullable = false) 318 | @Enumerated(EnumType.STRING) 319 | var status: BetStatus, 320 | 321 | @OneToMany(mappedBy = "bet", cascade = [CascadeType.PERSIST], fetch = FetchType.LAZY) 322 | @Fetch(FetchMode.SUBSELECT) 323 | var betOptions: MutableSet = mutableSetOf(), 324 | 325 | @Id 326 | @GeneratedValue(strategy = GenerationType.IDENTITY) 327 | @Column(name = "id") 328 | val id: Int? = null, 329 | 330 | @Column(name = "created_at", nullable = false) 331 | var createdAt: Timestamp, 332 | 333 | ) { 334 | constructor(user: User, stake: BigDecimal, status: BetStatus) : this( 335 | user, 336 | stake, 337 | status, 338 | mutableSetOf(), 339 | null, 340 | Timestamp(System.currentTimeMillis()) 341 | ) 342 | 343 | fun calculatePotentialWinnings(): BigDecimal { 344 | return betOptions.fold(stake) { total, betOption -> 345 | total * betOption.marketOption.odds 346 | } 347 | } 348 | 349 | fun addMarketOption(marketOption: MarketOption): BetOption { 350 | val betOption = BetOption(this, marketOption) 351 | betOptions.add(betOption) 352 | return betOption 353 | } 354 | 355 | fun getId(): Int { 356 | return id ?: 0 357 | } 358 | 359 | override fun hashCode(): Int { 360 | return id.hashCode() 361 | } 362 | 363 | override fun equals(other: Any?): Boolean { 364 | if (this === other) return true 365 | if (javaClass != other?.javaClass) return false 366 | other as Bet 367 | return id == other.id 368 | } 369 | } 370 | 371 | @Entity 372 | @Table(name = "bet_options") 373 | data class BetOption( 374 | @ManyToOne(fetch = FetchType.LAZY) 375 | @JoinColumn(name = "bet_id") 376 | val bet: Bet, 377 | 378 | @ManyToOne(fetch = FetchType.LAZY) 379 | @JoinColumn(name = "market_option_id") 380 | // @Fetch(FetchMode.JOIN) 381 | val marketOption: MarketOption, 382 | 383 | @Id 384 | @GeneratedValue(strategy = GenerationType.IDENTITY) 385 | val id: Int? = null, 386 | 387 | @Enumerated(EnumType.STRING) 388 | @Column(name = "status") 389 | var status: BetStatus? = BetStatus.PENDING, 390 | ) { 391 | override fun hashCode(): Int { 392 | return id.hashCode() 393 | } 394 | override fun equals(other: Any?): Boolean { 395 | if (this === other) return true 396 | if (javaClass != other?.javaClass) return false 397 | other as BetOption 398 | return id == other.id 399 | } 400 | } 401 | 402 | 403 | enum class BetStatus { 404 | PENDING, 405 | WON, 406 | LOST, 407 | CANCELED 408 | } 409 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/npd/betting/services/EventService.kt: -------------------------------------------------------------------------------- 1 | package com.npd.betting.services 2 | 3 | import com.npd.betting.Props 4 | import com.npd.betting.controllers.AccumulatingSink 5 | import com.npd.betting.model.* 6 | import com.npd.betting.repositories.* 7 | import com.npd.betting.services.importer.* 8 | import io.ktor.client.request.* 9 | import io.ktor.client.statement.* 10 | import io.ktor.http.* 11 | import kotlinx.coroutines.Dispatchers 12 | import kotlinx.coroutines.withContext 13 | import kotlinx.serialization.builtins.ListSerializer 14 | import kotlinx.serialization.json.Json 15 | import org.hibernate.Hibernate 16 | import org.slf4j.Logger 17 | import org.slf4j.LoggerFactory 18 | import org.springframework.stereotype.Service 19 | import org.springframework.transaction.annotation.Transactional 20 | import java.math.BigDecimal 21 | import java.sql.Timestamp 22 | import java.util.* 23 | import kotlin.jvm.optionals.getOrNull 24 | 25 | @Service 26 | @Transactional 27 | class EventService( 28 | private val props: Props, 29 | private val resultService: ResultService, 30 | private val eventRepository: EventRepository, 31 | private val marketRepository: MarketRepository, 32 | private val marketOptionRepository: MarketOptionRepository, 33 | private val scoreUpdateRepository: ScoreUpdateRepository, 34 | private val sportRepository: SportRepository, 35 | private val marketOptionSink: AccumulatingSink, 36 | private val scoreUpdatesSink: AccumulatingSink, 37 | private val eventStatusUpdatesSink: AccumulatingSink, 38 | ) { 39 | val logger: Logger = LoggerFactory.getLogger(EventService::class.java) 40 | 41 | fun getScoresApiURL(sport: String): String { 42 | return "${EventImporter.API_BASE}/sports/$sport/scores/?daysFrom=2&&markets=${EventImporter.MARKETS}&dateFormat=unix&apiKey=${props.getOddsApiKey()}" 43 | } 44 | 45 | suspend fun importEvents(eventsData: List) { 46 | eventsData.forEach { eventData -> 47 | val existing = eventRepository.findByExternalId(eventData.id) 48 | if (existing != null) { 49 | logger.debug( 50 | "event commence time: {}, current time: {}, is live based on time? {}", 51 | Date(eventData.commence_time * 1000), 52 | Date(), 53 | eventData.isLive() 54 | ) 55 | logger.debug("eventData.completed: ${eventData.completed}") 56 | 57 | if (existing.isLive != eventData.isLive() || existing.completed != eventData.completed) { 58 | existing.isLive = eventData.isLive() 59 | existing.completed = eventData.completed ?: existing.completed 60 | if (existing.completed == true) { 61 | updateScores(existing) 62 | withContext(Dispatchers.IO) { 63 | resultService.saveEventResult(existing) 64 | } 65 | } 66 | val saved = eventRepository.save(existing) 67 | logger.info("Event ${saved.id} is now ${if (saved.isLive) "live" else "not live"}. Emitting...") 68 | eventStatusUpdatesSink.emit(saved) 69 | } else { 70 | logger.debug("Event ${eventData.id} already exists, skipping...") 71 | } 72 | } else { 73 | saveEventAndOdds(eventData) 74 | } 75 | } 76 | } 77 | 78 | suspend fun saveEventAndOdds( 79 | eventData: EventData, 80 | update: Boolean = false 81 | ) { 82 | 83 | val event = if (update) { 84 | val existing = eventRepository.findByExternalId(eventData.id) 85 | 86 | ?: throw Exception("Event ${eventData.id} does not exist") 87 | logger.info("Updating event ${eventData.id}: ${eventData.home_team} vs ${eventData.away_team}") 88 | existing.isLive = eventData.isLive() 89 | existing.completed = eventData.completed ?: existing.completed 90 | existing.homeTeamName = eventData.home_team 91 | existing.awayTeamName = eventData.away_team 92 | 93 | if (existing.completed == true) { 94 | updateScores(existing) 95 | withContext(Dispatchers.IO) { 96 | resultService.saveEventResult(existing) 97 | } 98 | } 99 | eventRepository.save(existing) 100 | } else { 101 | val sportEntity = withContext(Dispatchers.IO) { 102 | sportRepository.findByKey(eventData.sport_key) 103 | } 104 | ?: throw Exception("Sport with key ${eventData.sport_key} does not exist") 105 | 106 | logger.info("Creating event ${eventData.id}: ${eventData.home_team} vs ${eventData.away_team}") 107 | val newEvent = Event( 108 | isLive = eventData.isLive(), 109 | name = "${eventData.home_team} vs ${eventData.away_team}", 110 | startTime = Timestamp(eventData.commence_time * 1000), 111 | sport = sportEntity, 112 | externalId = eventData.id, 113 | homeTeamName = eventData.home_team, 114 | awayTeamName = eventData.away_team, 115 | ) 116 | if (eventData.completed != null) { 117 | newEvent.completed = eventData.completed ?: newEvent.completed 118 | } 119 | if (newEvent.completed!!) { 120 | updateScores(newEvent) 121 | withContext(Dispatchers.IO) { 122 | resultService.saveEventResult(newEvent) 123 | } 124 | } 125 | withContext(Dispatchers.IO) { 126 | eventRepository.save( 127 | newEvent 128 | ) 129 | } 130 | } 131 | 132 | // save bookmakers --> odds 133 | if (eventData.bookmakers != null) { 134 | eventData.bookmakers!!.forEach { bookmaker -> 135 | bookmaker.markets.forEach { marketData -> 136 | val existingMarket = marketRepository.findByEventIdAndSourceAndName(event.id, bookmaker.key, marketData.key) 137 | if (existingMarket != null) { 138 | if (existingMarket.lastUpdated!!.time < marketData.last_update * 1000) { 139 | // update market 140 | logger.info("Event ${event.id}, market ${marketData.key}, source: ${bookmaker.key} has been updated") 141 | updateMarket(event, existingMarket, marketData) 142 | } else { 143 | logger.debug("Market did not change") 144 | } 145 | } else { 146 | createMarket(event, marketData, bookmaker) 147 | } 148 | } 149 | } 150 | } 151 | } 152 | 153 | private fun updateMarket( 154 | event: Event, 155 | existingMarket: Market, 156 | marketData: MarketData, 157 | ) { 158 | existingMarket.isLive = event.isLive 159 | existingMarket.lastUpdated = Timestamp(marketData.last_update * 1000) 160 | 161 | // Initialize the options field 162 | Hibernate.initialize(existingMarket.options) 163 | 164 | marketData.outcomes.forEach { marketOptionData -> 165 | val existingMarketOption = existingMarket.options.find { it.name == marketOptionData.name } 166 | 167 | if (existingMarketOption != null && existingMarketOption.odds != BigDecimal(marketOptionData.price)) { 168 | existingMarketOption.odds = BigDecimal(marketOptionData.price) 169 | existingMarketOption.point = marketOptionData.point?.let { BigDecimal(it) } 170 | existingMarketOption.description = marketOptionData.description 171 | existingMarketOption.lastUpdated = Timestamp(marketData.last_update * 1000) 172 | logger.info("Event ${event.id}, market ${marketData.key}, source: ${existingMarket.source}, option ${marketOptionData.name} has been updated") 173 | marketOptionSink.emit(existingMarketOption) 174 | } else { 175 | // is this a valid case? 176 | } 177 | marketRepository.save(existingMarket) 178 | } 179 | } 180 | 181 | private fun createMarket( 182 | event: Event, 183 | marketData: MarketData, 184 | bookmaker: Bookmaker 185 | ) { 186 | val market = Market( 187 | isLive = event.isLive, 188 | lastUpdated = Timestamp(marketData.last_update * 1000), 189 | name = marketData.key, 190 | event = event, 191 | source = bookmaker.key 192 | ) 193 | val savedMarket = marketRepository.save(market) 194 | 195 | marketData.outcomes.forEach { marketOptionData -> 196 | val marketOption = MarketOption( 197 | name = marketOptionData.name, 198 | odds = BigDecimal(marketOptionData.price), 199 | point = marketOptionData.point?.let { BigDecimal(it) }, 200 | description = marketOptionData.description, 201 | market = savedMarket, 202 | lastUpdated = Timestamp(marketData.last_update * 1000) 203 | ) 204 | marketOptionRepository.save(marketOption) 205 | } 206 | } 207 | 208 | fun saveScores(eventDataWithScores: EventData, event: Event) { 209 | if (eventDataWithScores.scores != null) { 210 | eventDataWithScores.scores!!.forEach { scoreData -> 211 | val existingScores = scoreUpdateRepository.findByEventId(event.id) 212 | val score = ScoreUpdate( 213 | name = scoreData.name, 214 | score = scoreData.score, 215 | event = event, 216 | timestamp = Timestamp(Date().time) 217 | ) 218 | if (existingScores.isNotEmpty()) { 219 | if (existingScores.find { it.name == scoreData.name && it.score == scoreData.score } == null) { 220 | // we don't have this score yet, create it 221 | logger.info("Event ${event.id}, has new score ${scoreData.name} ${scoreData.score}") 222 | scoreUpdateRepository.save(score) 223 | val updated = eventRepository.findById(event.id).get() 224 | scoreUpdatesSink.emit(updated) 225 | } else { 226 | logger.debug("Score ${scoreData.name} ${scoreData.score} already exists, skipping...") 227 | } 228 | } else { 229 | // no existing scores 230 | scoreUpdateRepository.save(score) 231 | } 232 | } 233 | val updated = eventRepository.findById(event.id).get() 234 | resultService.saveMatchTotalsResult(updated, false) 235 | } 236 | } 237 | 238 | fun importSports(sports: List) { 239 | sports.forEach { sportData -> 240 | val existing = sportRepository.findByKey(sportData.key) 241 | if (existing != null) { 242 | logger.debug("Sport ${sportData.key} already exists, updating active value...") 243 | existing.active = sportData.active 244 | sportRepository.save(existing) 245 | } else { 246 | val newSport = Sport( 247 | key = sportData.key, 248 | active = sportData.active, 249 | title = sportData.title, 250 | group = sportData.group, 251 | hasOutrights = sportData.has_outrights, 252 | description = sportData.description 253 | ) 254 | sportRepository.save( 255 | newSport 256 | ) 257 | } 258 | } 259 | } 260 | 261 | fun emitEventStatusUpdate(event: Event) { 262 | logger.info("Emitting event status update for event ${event.id}") 263 | eventStatusUpdatesSink.emit(event) 264 | } 265 | 266 | suspend fun fetchScores(sport: String): List { 267 | val response: HttpResponse = 268 | httpClient.request(getScoresApiURL(sport)) { 269 | method = HttpMethod.Get 270 | } 271 | if (response.status != HttpStatusCode.OK) { 272 | throw IllegalStateException("Failed to fetch scores: ${response.status}: ${response.bodyAsText()}") 273 | } 274 | val responseBody = response.bodyAsText() 275 | return Json.decodeFromString(ListSerializer(EventData.serializer()), responseBody) 276 | } 277 | 278 | suspend fun fetchEventScores(event: Event): EventData? { 279 | val response: HttpResponse = 280 | httpClient.request(getScoresApiURL(event.sport.key) + "&eventIds=${event.externalId}&daysFrom=1") { 281 | method = HttpMethod.Get 282 | } 283 | if (response.status != HttpStatusCode.OK) { 284 | throw IllegalStateException("Failed to fetch scores for one event: ${response.status}: ${response.bodyAsText()}") 285 | } 286 | val events = Json.decodeFromString(ListSerializer(EventData.serializer()), response.bodyAsText()) 287 | return events.firstOrNull() 288 | } 289 | 290 | suspend fun updateCompleted(eventId: Int) { 291 | val event: Event = withContext(Dispatchers.IO) { 292 | eventRepository.findById(eventId) 293 | }.get() 294 | event.completed = event.startTime.before(Date()) 295 | logger.info("Updating event ${event.id} completed to ${event.completed}") 296 | 297 | withContext(Dispatchers.IO) { 298 | eventRepository.save(event) 299 | } 300 | if (event.completed!!) { 301 | updateScores(event) 302 | withContext(Dispatchers.IO) { 303 | resultService.saveEventResult(event) 304 | } 305 | withContext(Dispatchers.IO) { 306 | emitEventStatusUpdate(event) 307 | } 308 | } 309 | } 310 | 311 | suspend fun updateScores(event: Event) { 312 | logger.info("Event ${event.id} is completed, fetching final scores") 313 | val eventDataWithScores = fetchEventScores(event) 314 | logger.info("saving final scores for event ${event.id}") 315 | if (eventDataWithScores != null) { 316 | withContext(Dispatchers.IO) { 317 | saveScores(eventDataWithScores, event) 318 | } 319 | } 320 | } 321 | } 322 | --------------------------------------------------------------------------------