diff --git a/app/src/main/java/net/pokeranalytics/android/calculus/optimalduration/CashGameOptimalDurationCalculator.kt b/app/src/main/java/net/pokeranalytics/android/calculus/optimalduration/CashGameOptimalDurationCalculator.kt new file mode 100644 index 00000000..b7fb8fc0 --- /dev/null +++ b/app/src/main/java/net/pokeranalytics/android/calculus/optimalduration/CashGameOptimalDurationCalculator.kt @@ -0,0 +1,160 @@ +package net.pokeranalytics.android.calculus.optimalduration + +import io.realm.Realm +import net.pokeranalytics.android.calculus.Calculator +import net.pokeranalytics.android.calculus.Stat +import net.pokeranalytics.android.model.filter.Query +import net.pokeranalytics.android.model.filter.QueryCondition +import net.pokeranalytics.android.model.realm.Session +import org.apache.commons.math3.fitting.PolynomialCurveFitter +import org.apache.commons.math3.fitting.WeightedObservedPoints +import java.util.* +import kotlin.math.pow +import kotlin.math.round + +/*** + * This class attempts to find the optimal game duration, + * meaning the duration where the player will maximize its results, based on his history. + * The results stands for cash game, and are separated between live and online. + * Various reasons can prevent the algorithm to find a duration, see below. + */ +class CashGameOptimalDurationCalculator { + + companion object { + + private const val bucket = 60 * 60 * 1000L // the duration of bucket + private const val bucketInterval = 4 // number of duration tests inside the bucket to find the best duration + private const val minimumValidityCount = 10 // the number of sessions inside a bucket to start having a reasonable average + private const val intervalValidity = 3 // the minimum number of unit between the shortest & longest valid buckets + private const val polynomialDegree = 7 // the degree of the computed polynomial + + /*** + * Starts the calculation + * [isLive] is a boolean to indicate if we're looking at live or online games + * [completion] will be called if an optimal duration has been found, otherwise won't + */ + fun start(isLive: Boolean, completion: (Double) -> (Unit)) { + + val realm = Realm.getDefaultInstance() + + val query = Query().add(QueryCondition.IsCash) // cash game + query.add(if (isLive) { QueryCondition.IsLive } else { QueryCondition.IsOnline }) // live / online + query.add(QueryCondition.EndDateNotNull) // ended + query.add(QueryCondition.BigBlindNotNull) // has BB value + + val sessions = query.queryWith(realm.where(Session::class.java)).findAll() + val sessionsByDuration = sessions.groupBy { + round((it.netDuration / bucket).toDouble()) * bucket + } + + // define validity interval + var start: Double? = null + var end: Double? = null + var validBuckets = 0 + for (key in sessionsByDuration.keys.sorted()) { + val sessionCount = sessionsByDuration[key]?.size ?: 0 + if (start == null && sessionCount >= minimumValidityCount) { + start = key + } + if (sessionCount >= minimumValidityCount) { + end = key + validBuckets++ + } + } + if (!(start != null && end != null && (end - start) >= intervalValidity)) { + return + } + + // define if we have enough sessions + if (sessions.size < 50) { + return + } + + val options = Calculator.Options() + options.query = query + val report = Calculator.computeStats(realm, options) + val stdBB = report.results.firstOrNull()?.computedStat(Stat.STANDARD_DEVIATION_BB)?.value + + val p = polynomialRegression(sessions, stdBB) + + var bestAverage = 0.0 + var bestHourlyRate = 0.0 + var bestDuration = 0.0 + var maxDuration = 0.0 + + val keys = sessionsByDuration.keys.filter { it >= start && it <= end }.sorted() + + for (key in keys) { + + val sessionCount = sessionsByDuration[key]?.size ?: 0 + + if (sessionCount < minimumValidityCount / 2) continue // if too few sessions we don't consider the duration valid + + for (i in 0 until bucketInterval) { + + val duration = key + i * bucket / bucketInterval + + val averageResult = getBB(duration, p) + val hourly = averageResult / duration + if (averageResult > bestAverage && hourly > 2 / 3 * bestHourlyRate) { + bestAverage = averageResult + bestDuration = duration + } + + if (duration > 0 && hourly > bestHourlyRate) { + bestHourlyRate = hourly + } + if (duration > maxDuration){ + maxDuration = duration + } + } + + } + + if (bestDuration > 0.0) { + completion(bestDuration) + } + + realm.close() + + } + + private fun getBB(netDuration: Double, polynomial: DoubleArray): Double { + var y = 0.0 + for (i in polynomial.indices) { + y += polynomial[i] * netDuration.pow(i) + } + return y + } + + private fun polynomialRegression(sessions: List, bbStandardDeviation: Double?): DoubleArray { + + val stdBB = bbStandardDeviation ?: Double.MAX_VALUE + + val points = WeightedObservedPoints() + val now = Date().time + + sessions.forEach { + var weight = 5.0 + + val endTime = it.endDate?.time ?: 0L + + val age = now - endTime + if (age > 2 * 365 * 24 * 3600 * 1000L) { // if more than 2 years loses 1 point + weight -= 1.0 + } + if (it.bbNet > 2 * stdBB) { // if very big result loses 3 points + weight -= 3.0 + } + + points.add(weight, it.netDuration.toDouble(), it.bbNet) + + } + + // polynomial of 7 degree, same as iOS + return PolynomialCurveFitter.create(polynomialDegree).fit(points.toList()) + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/net/pokeranalytics/android/calculus/optimalduration/OptimalDurationCalculator.kt b/app/src/main/java/net/pokeranalytics/android/calculus/optimalduration/OptimalDurationCalculator.kt deleted file mode 100644 index d468be9c..00000000 --- a/app/src/main/java/net/pokeranalytics/android/calculus/optimalduration/OptimalDurationCalculator.kt +++ /dev/null @@ -1,125 +0,0 @@ -package net.pokeranalytics.android.calculus.optimalduration - -import io.realm.Realm -import net.pokeranalytics.android.calculus.Calculator -import net.pokeranalytics.android.calculus.Stat -import net.pokeranalytics.android.exceptions.PAIllegalStateException -import net.pokeranalytics.android.model.filter.Query -import net.pokeranalytics.android.model.filter.QueryCondition -import net.pokeranalytics.android.model.realm.Session -import org.apache.commons.math3.fitting.PolynomialCurveFitter -import org.apache.commons.math3.fitting.WeightedObservedPoints -import java.util.* -import kotlin.math.pow - -class OptimalDurationCalculator { - - companion object { - - fun start(session: Session, completion: (Double) -> (Unit)) { - - if (!session.isCashGame()) { - throw PAIllegalStateException("this only makes sense for cash game sessions") - } - - val realm = Realm.getDefaultInstance() - - val isLive = session.bankroll?.live ?: true - - val query = Query().add(QueryCondition.IsCash) // cash game - query.add(if (isLive) { QueryCondition.IsLive } else { QueryCondition.IsOnline }) // live / online - query.add(QueryCondition.EndDateNotNull) // ended - query.add(QueryCondition.BigBlindNotNull) // has BB value - - val sessions = query.queryWith(realm.where(Session::class.java)).findAll() - val sessionsByDuration = sessions.groupBy { - it.netDuration // TODO select a value per 15 min or something... - } - - // define if we have enough sessions - if (sessions.size < 50) { - return - } - - val options = Calculator.Options() - options.query = query - val report = Calculator.computeStats(realm, options) - val stdBB = report.results.firstOrNull()?.computedStat(Stat.STANDARD_DEVIATION_BB)?.value - - val p = polynomialRegression(sessions, stdBB) - - var bestAverage = 0.0 - var bestHourlyRate = 0.0 - var bestDuration = 0.0 - var maxDuration = 0.0 - - sessionsByDuration.keys.forEach { - - val duration = it.toDouble() - if (duration < 0) { // TODO define whats valid - return - } - - val averageResult = getBB(duration, p) - val hourly = averageResult / duration - if (averageResult > bestAverage && hourly > 2 / 3 * bestHourlyRate) { - bestAverage = averageResult - bestDuration = duration - } - - if (duration > 0 && hourly > bestHourlyRate) { - bestHourlyRate = hourly - } - if (duration > maxDuration){ - maxDuration = duration - } - - } - - if (bestDuration > 0.0) { - completion(bestDuration) - } - - realm.close() - - } - - private fun getBB(netDuration: Double, polynomial: DoubleArray): Double { - var y = 0.0 - for (i in polynomial.indices) { - y += polynomial[i] * netDuration.pow(i) - } - return y - } - - private fun polynomialRegression(sessions: List, bbStandardDeviation: Double?): DoubleArray { - - val stdBB = bbStandardDeviation ?: Double.MAX_VALUE - - val points = WeightedObservedPoints() - val now = Date().time - - sessions.forEach { - var weight = 5.0 - - val endTime = it.endDate?.time ?: 0L - - val age = now - endTime - if (age > 2 * 365 * 24 * 3600 * 1000L) { // if more than 2 years loses 1 point - weight -= 1.0 - } - if (it.bbNet > 2 * stdBB) { // if very big result loses 3 points - weight -= 3.0 - } - - points.add(weight, it.netDuration.toDouble(), it.bbNet) - - } - - // polynomial of 7 degree, same as iOS - return PolynomialCurveFitter.create(7).fit(points.toList()) - } - - } - -} \ No newline at end of file diff --git a/app/src/test/java/net/pokeranalytics/android/BasicUnitTest.kt b/app/src/test/java/net/pokeranalytics/android/BasicUnitTest.kt index 70a56bec..e84b3a1e 100644 --- a/app/src/test/java/net/pokeranalytics/android/BasicUnitTest.kt +++ b/app/src/test/java/net/pokeranalytics/android/BasicUnitTest.kt @@ -5,6 +5,7 @@ import net.pokeranalytics.android.util.Parser import net.pokeranalytics.android.util.extensions.kmbFormatted import org.junit.Assert import org.junit.Test +import kotlin.math.round class BasicUnitTest : RealmUnitTest() { @@ -56,4 +57,11 @@ class BasicUnitTest : RealmUnitTest() { Assert.assertEquals("1000", str2) } + @Test + fun testRound() { + val f = 4.536666 + val r = round(f) + Assert.assertEquals(r, 5.0, 0.00001) + } + }