diff --git a/app/build.gradle b/app/build.gradle index ad117724..0427f5a0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -128,6 +128,9 @@ dependencies { // CSV Parser: https://mvnrepository.com/artifact/org.apache.commons/commons-csv implementation 'org.apache.commons:commons-csv:1.7' + // Polynomial Regression + implementation 'org.apache.commons:commons-math3:3.6.1' + // Instrumented Tests androidTestImplementation 'androidx.test:core:1.2.0' androidTestImplementation 'androidx.test:runner:1.2.0' diff --git a/app/src/main/java/net/pokeranalytics/android/PokerAnalyticsApplication.kt b/app/src/main/java/net/pokeranalytics/android/PokerAnalyticsApplication.kt index 68f629df..96b37bf7 100644 --- a/app/src/main/java/net/pokeranalytics/android/PokerAnalyticsApplication.kt +++ b/app/src/main/java/net/pokeranalytics/android/PokerAnalyticsApplication.kt @@ -79,6 +79,11 @@ class PokerAnalyticsApplication : Application() { // this.createFakeSessions() } +// CashGameOptimalDurationCalculator.start(true) { +// val hours = it / 3600 / 1000 +// Timber.d("Optimal duration = ${it}, $hours") +// } + Patcher.patchAll(this.applicationContext) val locale = Locale.getDefault() @@ -97,7 +102,7 @@ class PokerAnalyticsApplication : Application() { if (sessionsCount < 10) { GlobalScope.launch { - FakeDataManager.createFakeSessions(200) + FakeDataManager.createFakeSessions(400) } } diff --git a/app/src/main/java/net/pokeranalytics/android/calculus/Calculator.kt b/app/src/main/java/net/pokeranalytics/android/calculus/Calculator.kt index 7185f0d9..b934a185 100644 --- a/app/src/main/java/net/pokeranalytics/android/calculus/Calculator.kt +++ b/app/src/main/java/net/pokeranalytics/android/calculus/Calculator.kt @@ -21,6 +21,8 @@ import net.pokeranalytics.android.util.extensions.startOfDay import java.util.* import kotlin.math.max import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sqrt /** * The class performing statIds computation @@ -119,7 +121,7 @@ class Calculator { val computeStandardDeviation: Boolean get() { this.stats.forEach { - if (it == STANDARD_DEVIATION_BB_PER_100_HANDS || it == STANDARD_DEVIATION || it == STANDARD_DEVIATION_HOURLY) { + if (it.isStandardDeviation) { return true } } @@ -335,6 +337,11 @@ class Calculator { ) ) } + var averageBB = 0.0 + if (bbSessionCount > 0) { + averageBB = bbSum / bbSessionCount + results.addStat(AVERAGE_NET_BB, averageBB) + } val shouldIterateOverComputables = (options.progressValues == Options.ProgressValues.STANDARD || options.computeLongestStreak) @@ -561,15 +568,19 @@ class Calculator { // Session var stdSum = 0.0 + var stdBBSum = 0.0 var stdBBper100HandsSum = 0.0 computables.forEach { session -> - stdSum += Math.pow(session.ratedNet - average, 2.0) - stdBBper100HandsSum += Math.pow(session.bbPer100Hands - bbPer100Hands, 2.0) + stdSum += (session.ratedNet - average).pow(2.0) + stdBBSum += (session.bbNet - averageBB).pow(2.0) + stdBBper100HandsSum += (session.bbPer100Hands - bbPer100Hands).pow(2.0) } - val standardDeviation = Math.sqrt(stdSum / computables.size) - val standardDeviationBBper100Hands = Math.sqrt(stdBBper100HandsSum / computables.size) + val standardDeviation = sqrt(stdSum / computables.size) + val standardDeviationBB = sqrt(stdBBSum / computables.size) + val standardDeviationBBper100Hands = sqrt(stdBBper100HandsSum / computables.size) results.addStat(STANDARD_DEVIATION, standardDeviation) + results.addStat(STANDARD_DEVIATION_BB, standardDeviationBB) results.addStat(STANDARD_DEVIATION_BB_PER_100_HANDS, standardDeviationBBper100Hands) // Session Set @@ -578,9 +589,9 @@ class Calculator { sessionSets.forEach { set -> val ssStats = SSStats(set, computableGroup.query) val sHourlyRate = ssStats.hourlyRate - hourlyStdSum += Math.pow(sHourlyRate - hourlyRate, 2.0) + hourlyStdSum += (sHourlyRate - hourlyRate).pow(2.0) } - val hourlyStandardDeviation = Math.sqrt(hourlyStdSum / sessionSets.size) + val hourlyStandardDeviation = sqrt(hourlyStdSum / sessionSets.size) results.addStat(STANDARD_DEVIATION_HOURLY, hourlyStandardDeviation) } diff --git a/app/src/main/java/net/pokeranalytics/android/calculus/Stat.kt b/app/src/main/java/net/pokeranalytics/android/calculus/Stat.kt index 6116c964..e05ebb6a 100644 --- a/app/src/main/java/net/pokeranalytics/android/calculus/Stat.kt +++ b/app/src/main/java/net/pokeranalytics/android/calculus/Stat.kt @@ -52,6 +52,7 @@ enum class Stat(override var uniqueIdentifier: Int) : IntIdentifiable, RowRepres BB_SESSION_COUNT(26), TOTAL_BUYIN(27), RISK_OF_RUIN(28), + STANDARD_DEVIATION_BB(29), ; companion object : IntSearchable { @@ -119,6 +120,7 @@ enum class Stat(override var uniqueIdentifier: Int) : IntIdentifiable, RowRepres STANDARD_DEVIATION -> R.string.standard_deviation STANDARD_DEVIATION_HOURLY -> R.string.standard_deviation_per_hour STANDARD_DEVIATION_BB_PER_100_HANDS -> R.string.standard_deviation_bb_per_100_hands + STANDARD_DEVIATION_BB -> R.string.bb_standard_deviation HANDS_PLAYED -> R.string.number_of_hands LOCATIONS_PLAYED -> R.string.locations_played LONGEST_STREAKS -> R.string.longest_streaks @@ -169,7 +171,7 @@ enum class Stat(override var uniqueIdentifier: Int) : IntIdentifiable, RowRepres } // white amountsr AVERAGE_BUYIN, STANDARD_DEVIATION, STANDARD_DEVIATION_HOURLY, - STANDARD_DEVIATION_BB_PER_100_HANDS, TOTAL_BUYIN -> { + STANDARD_DEVIATION_BB_PER_100_HANDS, STANDARD_DEVIATION_BB, TOTAL_BUYIN -> { return TextFormat(value.toCurrency(currency)) } LONGEST_STREAKS -> { @@ -200,6 +202,7 @@ enum class Stat(override var uniqueIdentifier: Int) : IntIdentifiable, RowRepres NUMBER_OF_GAMES -> R.string.number_of_records NET_RESULT -> R.string.total STANDARD_DEVIATION -> R.string.net_result + STANDARD_DEVIATION_BB -> R.string.average_net_result_bb_ STANDARD_DEVIATION_HOURLY -> R.string.hour_rate_without_pauses STANDARD_DEVIATION_BB_PER_100_HANDS -> R.string.net_result_bb_per_100_hands WIN_RATIO, HOURLY_DURATION -> return this.localizedTitle(context) @@ -235,13 +238,22 @@ enum class Stat(override var uniqueIdentifier: Int) : IntIdentifiable, RowRepres val hasProgressGraph: Boolean get() { return when (this) { - HOURLY_DURATION, AVERAGE_HOURLY_DURATION, + HOURLY_DURATION, AVERAGE_HOURLY_DURATION, STANDARD_DEVIATION_BB, STANDARD_DEVIATION, STANDARD_DEVIATION_HOURLY, STANDARD_DEVIATION_BB_PER_100_HANDS -> false else -> true } } + val isStandardDeviation: Boolean + get() { + return when (this) { + STANDARD_DEVIATION, STANDARD_DEVIATION_BB, + STANDARD_DEVIATION_HOURLY, STANDARD_DEVIATION_BB_PER_100_HANDS -> true + else -> false + } + } + val legendHideRightValue: Boolean get() { return when (this) { 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..25583d84 --- /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 + * return a duration or null if it could not be computed + */ + fun start(isLive: Boolean): Double? { + + 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 null + } + + // define if we have enough sessions + if (sessions.size < 50) { + return null + } + + 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) { + return bestDuration + } + + realm.close() + return null + } + + 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/model/filter/Query.kt b/app/src/main/java/net/pokeranalytics/android/model/filter/Query.kt index 57569c43..d56f03a4 100644 --- a/app/src/main/java/net/pokeranalytics/android/model/filter/Query.kt +++ b/app/src/main/java/net/pokeranalytics/android/model/filter/Query.kt @@ -12,7 +12,7 @@ fun List.mapFirstCondition() : List { class Query { constructor(vararg elements: QueryCondition) { - if (elements.size > 0) { + if (elements.isNotEmpty()) { this.add(elements.asList()) } } @@ -23,22 +23,25 @@ class Query { return this._conditions } - fun add(vararg elements: QueryCondition) { - if (elements.size > 0) { + fun add(vararg elements: QueryCondition): Query { + if (elements.isNotEmpty()) { this.add(elements.asList()) } + return this } - fun add(queryCondition: QueryCondition) { + fun add(queryCondition: QueryCondition): Query { this._conditions.add(queryCondition) + return this } - fun remove(queryCondition: QueryCondition) { - this._conditions.remove(queryCondition) + fun add(queryConditions: List): Query{ + this._conditions.addAll(queryConditions) + return this } - fun add(queryConditions: List) { - this._conditions.addAll(queryConditions) + fun remove(queryCondition: QueryCondition) { + this._conditions.remove(queryCondition) } val defaultName: String @@ -59,28 +62,32 @@ class Query { inline fun queryWith(query: RealmQuery): RealmQuery { var realmQuery = query - val queryFromTime = this.conditions.filter { - it is QueryCondition.StartedFromTime - }.firstOrNull() - val queryToTime = this.conditions.filter { - it is QueryCondition.EndedToTime - }.firstOrNull() - - this.conditions.forEach { - if (it is QueryCondition.StartedFromTime) { - realmQuery = it.queryWith(realmQuery, queryToTime) - } else if (it is QueryCondition.EndedToTime) { - realmQuery = it.queryWith(realmQuery, queryFromTime) - } else { - realmQuery = it.queryWith(realmQuery) - } + val queryFromTime = this.conditions.firstOrNull { + it is QueryCondition.StartedFromTime + } + val queryToTime = this.conditions.firstOrNull { + it is QueryCondition.EndedToTime + } + + this.conditions.forEach { + realmQuery = when (it) { + is QueryCondition.StartedFromTime -> { + it.queryWith(realmQuery, queryToTime) + } + is QueryCondition.EndedToTime -> { + it.queryWith(realmQuery, queryFromTime) + } + else -> { + it.queryWith(realmQuery) + } + } } // println("<<<<<< ${realmQuery.description}") - val queryLast = this.conditions.filter { - it is QueryCondition.Last - }.firstOrNull() - queryLast?.let {qc -> + val queryLast = this.conditions.firstOrNull { + it is QueryCondition.Last + } + queryLast?.let {qc -> (qc as QueryCondition.Last).singleValue?.let { return realmQuery.limit(it.toLong()) } diff --git a/app/src/main/java/net/pokeranalytics/android/model/filter/QueryCondition.kt b/app/src/main/java/net/pokeranalytics/android/model/filter/QueryCondition.kt index d450c5e9..4417b496 100644 --- a/app/src/main/java/net/pokeranalytics/android/model/filter/QueryCondition.kt +++ b/app/src/main/java/net/pokeranalytics/android/model/filter/QueryCondition.kt @@ -307,6 +307,9 @@ sealed class QueryCondition : FilterElementRow { abstract class TrueQueryCondition : QueryCondition() { override var operator: Operator = Operator.TRUE } + abstract class NotNullQueryCondition : QueryCondition() { + override var operator: Operator = Operator.NOTNULL + } object IsLive : TrueQueryCondition() @@ -602,9 +605,9 @@ sealed class QueryCondition : FilterElementRow { } } - object DateNotNull : QueryCondition() { - override var operator = Operator.NOTNULL - } + object DateNotNull : NotNullQueryCondition() + object EndDateNotNull : NotNullQueryCondition() + object BigBlindNotNull : NotNullQueryCondition() class StartedFromTime() : TimeQuery() { override var operator = Operator.MORE diff --git a/app/src/main/java/net/pokeranalytics/android/model/realm/ComputableResult.kt b/app/src/main/java/net/pokeranalytics/android/model/realm/ComputableResult.kt index ccca2ccb..5beffd8b 100644 --- a/app/src/main/java/net/pokeranalytics/android/model/realm/ComputableResult.kt +++ b/app/src/main/java/net/pokeranalytics/android/model/realm/ComputableResult.kt @@ -6,19 +6,19 @@ import net.pokeranalytics.android.model.filter.QueryCondition open class ComputableResult() : RealmObject(), Filterable { - var ratedNet: Double = 0.0 + var ratedNet: Double = 0.0 - var bbNet: BB = 0.0 + var bbNet: BB = 0.0 - var hasBigBlind: Int = 0 + var hasBigBlind: Int = 0 - var isPositive: Int = 0 + var isPositive: Int = 0 - var ratedBuyin: Double = 0.0 + var ratedBuyin: Double = 0.0 - var estimatedHands: Double = 0.0 + var estimatedHands: Double = 0.0 - var bbPer100Hands: BB = 0.0 + var bbPer100Hands: BB = 0.0 var session: Session? = null @@ -35,7 +35,8 @@ open class ComputableResult() : RealmObject(), Filterable { this.bbNet = session.bbNet this.hasBigBlind = if (session.cgBigBlind != null) 1 else 0 this.estimatedHands = session.estimatedHands - this.bbPer100Hands = session.bbNet / (session.numberOfHandsPerHour * session.hourlyDuration) * 100 + this.bbPer100Hands = + session.bbNet / (session.numberOfHandsPerHour * session.hourlyDuration) * 100 } @@ -51,11 +52,11 @@ open class ComputableResult() : RealmObject(), Filterable { companion object { - fun fieldNameForQueryType(queryCondition: Class < out QueryCondition >): String? { - Session.fieldNameForQueryType(queryCondition)?.let { - return "session.$it" - } - return null + fun fieldNameForQueryType(queryCondition: Class): String? { + Session.fieldNameForQueryType(queryCondition)?.let { + return "session.$it" + } + return null } } diff --git a/app/src/main/java/net/pokeranalytics/android/model/realm/Session.kt b/app/src/main/java/net/pokeranalytics/android/model/realm/Session.kt index ab747e4e..a23d6b68 100644 --- a/app/src/main/java/net/pokeranalytics/android/model/realm/Session.kt +++ b/app/src/main/java/net/pokeranalytics/android/model/realm/Session.kt @@ -118,6 +118,8 @@ open class Session : RealmObject(), Savable, Editable, StaticRowRepresentableDat CustomFieldAmountQuery::class.java, CustomFieldNumberQuery::class.java -> "customFieldEntries.numericValue" CustomFieldQuery::class.java -> "customFieldEntries.customFields.id" DateNotNull::class.java -> "startDate" + EndDateNotNull::class.java -> "endDate" + BigBlindNotNull::class.java -> "cgBigBlind" else -> null } } diff --git a/app/src/main/java/net/pokeranalytics/android/ui/modules/session/SessionFragment.kt b/app/src/main/java/net/pokeranalytics/android/ui/modules/session/SessionFragment.kt index c9235d49..9894de1d 100644 --- a/app/src/main/java/net/pokeranalytics/android/ui/modules/session/SessionFragment.kt +++ b/app/src/main/java/net/pokeranalytics/android/ui/modules/session/SessionFragment.kt @@ -11,8 +11,13 @@ import androidx.lifecycle.ViewModelProviders import androidx.recyclerview.widget.DiffUtil import com.crashlytics.android.Crashlytics import kotlinx.android.synthetic.main.fragment_session.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.launch import net.pokeranalytics.android.R import net.pokeranalytics.android.calculus.bankroll.BankrollReportManager +import net.pokeranalytics.android.calculus.optimalduration.CashGameOptimalDurationCalculator import net.pokeranalytics.android.exceptions.PAIllegalStateException import net.pokeranalytics.android.model.LiveData import net.pokeranalytics.android.model.extensions.SessionState @@ -34,8 +39,10 @@ import net.pokeranalytics.android.ui.view.RowRepresentableDiffCallback import net.pokeranalytics.android.ui.view.SmoothScrollLinearLayoutManager import net.pokeranalytics.android.ui.view.rowrepresentable.SessionRow import net.pokeranalytics.android.util.extensions.findById +import net.pokeranalytics.android.util.extensions.formattedHourlyDuration import net.pokeranalytics.android.util.extensions.getNextMinuteInMilliseconds import java.util.* +import kotlin.coroutines.CoroutineContext class SessionFragment : RealmFragment(), RowRepresentableDelegate { @@ -64,6 +71,9 @@ class SessionFragment : RealmFragment(), RowRepresentableDelegate { } } + private val coroutineContext: CoroutineContext + get() = Dispatchers.Main + override fun onResume() { super.onResume() @@ -344,10 +354,17 @@ class SessionFragment : RealmFragment(), RowRepresentableDelegate { * Update the state of the session (start / pause) */ private fun manageSessionState() { - when (currentSession.getState()) { + when (val state = currentSession.getState()) { SessionState.PENDING, SessionState.PLANNED, SessionState.PAUSED -> { + + // if not started computed cash game optimal duration + if (state != SessionState.PAUSED && this.currentSession.isCashGame()) { + computeOptimalDuration() + } + currentSession.startOrContinue() this.recyclerView.smoothScrollToPosition(0) + } SessionState.STARTED -> { currentSession.pause() @@ -358,6 +375,28 @@ class SessionFragment : RealmFragment(), RowRepresentableDelegate { updateSessionUI() } + private fun computeOptimalDuration() { + + GlobalScope.launch(coroutineContext) { + + var optimalDuration: Double? = null + + val cr = GlobalScope.async { + optimalDuration = CashGameOptimalDurationCalculator.start(currentSession.isLive) + } + cr.await() + + if (!isDetached) { + optimalDuration?.let { + val formattedDuration = it.formattedHourlyDuration() + val message = requireContext().getString(R.string.stop_notification_in_, formattedDuration) + Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show() + } + } + } + + } + /** * Stop the current session */