Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions PokerKit/Sources/PokerKit/OpeningRange.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import Foundation

public enum OpeningAction: String, Sendable {
case raise = "Raise"
case fold = "Fold"
}

public struct OpeningDecision: Sendable {
public let action: OpeningAction
public let handScore: Double
public let scoreThreshold: Double
public let openPercentage: Double

/// A short, human-readable justification for the range viewer's detail view.
public var reasoning: String {
let pct = String(format: "%.0f", openPercentage)
switch action {
case .raise:
return "Hand strength score \(formatted(handScore)) clears the open-raise threshold of "
+ "\(formatted(scoreThreshold)) (top \(pct)% of hands) for this position and stack."
case .fold:
return "Hand strength score \(formatted(handScore)) is below the open-raise threshold of "
+ "\(formatted(scoreThreshold)) (top \(pct)% of hands) for this position and stack."
}
}

private func formatted(_ value: Double) -> String {
value == value.rounded() ? String(format: "%.0f", value) : String(format: "%.1f", value)
}
}

/// An approximate, unopened-pot **opening (raise-first-in)** range model for standard
/// tournament stack depths (roughly 20-100bb) — the "should I open-raise this hand"
/// decision that covers most of an MTT before the stack gets short enough for
/// `PushFoldRange` to take over.
///
/// This is a **study aid, not a solver** — same posture as `PushFoldRange`, and for the
/// same reason: real GTO-solved opening ranges depend on exact stack depths, rake,
/// opponent tendencies, and postflop strategy in a way a static table can't capture.
/// What's encoded here is a hand-tuned approximation of the general *shape* of
/// widely-published open-raise charts: tight up front, widening through the button, wide
/// from the small blind (heads-up against the big blind). See `RANGES.md` for the exact
/// source basis, what's directly sourced vs. extrapolated, and one deliberate place this
/// table is tightened *below* its cited source out of caution (small blind).
///
/// Reuses `ChenScore` to rank the 169 starting hands (same ranking already used by
/// `PushFoldRange`) and `PushFoldRange.scoreThreshold(forPercentage:)` to turn a "top X%"
/// figure into a score cutoff — there is no second hand-ranking system in this codebase.
public enum OpeningRange {
/// Stack breakpoints (effective bb), ascending. Percentages below are only anchored
/// at these three points — deliberately fewer than `PushFoldRange`'s ten, because the
/// source material backing this table is thinner across stack depths (see
/// `RANGES.md`). `openPercentage` linearly interpolates between them and clamps
/// outside [20, 100].
private static let breakpoints: [Double] = [20, 40, 100]

/// % of the 169 starting hands to open-raise, indexed by position then aligned 1:1
/// with `breakpoints` (20bb, 40bb, 100bb). Narrows as the stack deepens; widens as
/// position gets later. See `RANGES.md` for the source basis of each column.
private static let openPercentByPosition: [Position: [Double]] = [
.utg: [16, 13, 10],
.middlePosition: [24, 21, 18],
.hijack: [27, 24, 21],
.cutoff: [34, 31, 28],
.button: [49, 46, 43],
.smallBlind: [51, 48, 45],
]

public static func openPercentage(position: Position, effectiveStackBB: Double) -> Double {
let table = openPercentByPosition[position]!
let stack = min(max(effectiveStackBB, breakpoints.first!), breakpoints.last!)

guard let upperIndex = breakpoints.firstIndex(where: { $0 >= stack }) else {
return table.last!
}
if breakpoints[upperIndex] == stack || upperIndex == 0 {
return table[upperIndex]
}

let lowerIndex = upperIndex - 1
let lowerBB = breakpoints[lowerIndex]
let upperBB = breakpoints[upperIndex]
let fraction = (stack - lowerBB) / (upperBB - lowerBB)
return table[lowerIndex] + fraction * (table[upperIndex] - table[lowerIndex])
}

public static func decide(hand: HoleCards, position: Position, effectiveStackBB: Double) -> OpeningDecision {
let percentage = openPercentage(position: position, effectiveStackBB: effectiveStackBB)
let threshold = PushFoldRange.scoreThreshold(forPercentage: percentage)
let handScore = ChenScore.score(for: hand)
let action: OpeningAction = handScore >= threshold ? .raise : .fold
return OpeningDecision(action: action, handScore: handScore, scoreThreshold: threshold, openPercentage: percentage)
}
}
9 changes: 9 additions & 0 deletions PokerKit/Sources/PokerKit/PreflopGrid.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,13 @@ public enum PreflopGrid {
row.map { PushFoldRange.decide(hand: $0, position: position, effectiveStackBB: effectiveStackBB) }
}
}

/// The opening (raise-first-in) decision for every cell, for a given position and
/// effective stack. Same enumeration as `decisions(position:effectiveStackBB:)`, just
/// mapped through `OpeningRange` instead of `PushFoldRange`.
public static func openingDecisions(position: Position, effectiveStackBB: Double) -> [[OpeningDecision]] {
hands.map { row in
row.map { OpeningRange.decide(hand: $0, position: position, effectiveStackBB: effectiveStackBB) }
}
}
}
2 changes: 1 addition & 1 deletion PokerKit/Sources/PokerKit/StudyTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public enum StudyTool: String, CaseIterable, Identifiable, Hashable, Sendable {
public var summary: String {
switch self {
case .preflopRanges:
return "Build and review opening/3-bet/4-bet ranges by position and stack depth."
return "Opening (raise-first-in) ranges for standard stacks, plus push/fold shove ranges for short stacks — by position and effective stack."
case .pushFold:
return "Shove-or-fold decisions for short stacks (~1-20bb), by position and effective stack."
case .bankroll:
Expand Down
96 changes: 96 additions & 0 deletions PokerKit/Tests/PokerKitTests/OpeningRangeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import Testing
@testable import PokerKit

@Test func premiumHandAlwaysOpens() {
let aa = HoleCards(canonical: "AA")!
for position in Position.allCases {
for stack: Double in [20, 40, 60, 100] {
let decision = OpeningRange.decide(hand: aa, position: position, effectiveStackBB: stack)
#expect(decision.action == .raise, "AA should open from \(position) at \(stack)bb")
}
}
}

@Test func openingTrashHandFoldsDeepAndEarly() {
// 72o from UTG at 100bb is the textbook clear fold — nowhere near an opening hand.
let trash = HoleCards(canonical: "72o")!
let decision = OpeningRange.decide(hand: trash, position: .utg, effectiveStackBB: 100)
#expect(decision.action == .fold)
}

@Test func openingWidensAsStackShortens() {
// A hand that opens at a deeper stack must also open at any shorter stack (down to
// the 20bb floor of this model), from the same position — ranges only widen as the
// stack gets shorter, same invariant as PushFoldRange.
let hand = HoleCards(canonical: "A9s")!
let position = Position.utg
var lastWasOpen = false
for stack: Double in stride(from: 100, through: 20, by: -5) {
let decision = OpeningRange.decide(hand: hand, position: position, effectiveStackBB: stack)
if lastWasOpen {
#expect(decision.action == .raise, "Range should not tighten as stack shortens (stack \(stack))")
}
lastWasOpen = decision.action == .raise
}
}

@Test func openingWidensByPosition() {
// If a hand opens from an earlier position, it must also open from every later
// position at the same stack — later positions are always >= as wide.
let orderedPositions = Position.allCases // already earliest-to-latest
for stack: Double in [20, 40, 60, 100] {
for handString in ["KQo", "A9s", "88", "T9s", "A5s"] {
let hand = HoleCards(canonical: handString)!
var sawOpen = false
for position in orderedPositions {
let decision = OpeningRange.decide(hand: hand, position: position, effectiveStackBB: stack)
if sawOpen {
#expect(decision.action == .raise, "\(handString) should still open from \(position) at \(stack)bb once it opens from an earlier position")
}
sawOpen = sawOpen || decision.action == .raise
}
}
}
}

@Test func openPercentageInterpolatesBetweenBreakpoints() {
// 20bb and 40bb are both explicit breakpoints for UTG (16% and 13%); 30bb should
// land strictly between them.
let at20 = OpeningRange.openPercentage(position: .utg, effectiveStackBB: 20)
let at30 = OpeningRange.openPercentage(position: .utg, effectiveStackBB: 30)
let at40 = OpeningRange.openPercentage(position: .utg, effectiveStackBB: 40)
#expect(at40 < at30)
#expect(at30 < at20)
}

@Test func openPercentageClampsOutsideDefinedRange() {
let below = OpeningRange.openPercentage(position: .button, effectiveStackBB: 5)
let at20 = OpeningRange.openPercentage(position: .button, effectiveStackBB: 20)
#expect(below == at20)

let above = OpeningRange.openPercentage(position: .button, effectiveStackBB: 200)
let at100 = OpeningRange.openPercentage(position: .button, effectiveStackBB: 100)
#expect(above == at100)
}

@Test func openingButtonIsWiderThanUTGAtSameStack() {
let utgPct = OpeningRange.openPercentage(position: .utg, effectiveStackBB: 100)
let btnPct = OpeningRange.openPercentage(position: .button, effectiveStackBB: 100)
#expect(btnPct > utgPct)
}

@Test func smallBlindIsWiderThanButtonAtSameStack() {
// Matches the cited source's qualitative shape: SB opens wider than BTN since SB is
// only getting through one player (BB), even though the absolute SB number here is
// deliberately tightened below the source's raw figure — see RANGES.md.
for stack: Double in [20, 40, 100] {
let btnPct = OpeningRange.openPercentage(position: .button, effectiveStackBB: stack)
let sbPct = OpeningRange.openPercentage(position: .smallBlind, effectiveStackBB: stack)
#expect(sbPct > btnPct, "SB should open wider than BTN at \(stack)bb")
}
}

@Test func reasoningMentionsRaiseOrFold() {
let decision = OpeningRange.decide(hand: HoleCards(canonical: "AA")!, position: .utg, effectiveStackBB: 100)
#expect(decision.reasoning.contains("open-raise threshold"))
}
20 changes: 20 additions & 0 deletions PokerKit/Tests/PokerKitTests/PreflopGridTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ import Testing
}
}

@Test func gridOpeningDecisionsMatchDirectOpeningRangeDecisions() {
for row in 0..<PreflopGrid.ranks.count {
for col in 0..<PreflopGrid.ranks.count {
let hand = PreflopGrid.hands[row][col]
let expected = OpeningRange.decide(hand: hand, position: .cutoff, effectiveStackBB: 60)
let actual = PreflopGrid.openingDecisions(position: .cutoff, effectiveStackBB: 60)[row][col]
#expect(actual.action == expected.action)
}
}
}

@Test func aaAlwaysOpensAcrossGrid() {
for position in Position.allCases {
for stack: Double in [20, 40, 60, 100] {
let decisions = PreflopGrid.openingDecisions(position: position, effectiveStackBB: stack)
#expect(decisions[0][0].action == .raise, "AA should open from \(position) at \(stack)bb")
}
}
}

@Test func sevenTwoOffsuitOnlyShovesAtVeryShortStacks() {
let sevenIndex = PreflopGrid.ranks.firstIndex(of: .seven)!
let twoIndex = PreflopGrid.ranks.firstIndex(of: .two)!
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ bankroll tracker to keep the whole thing honest.

Five tools, wired up and live in the app today:

- **Preflop Ranges** — a 13×13 grid viewer of push/fold shove ranges by
position and effective stack depth (not yet a full opening/3-bet/4-bet
range builder — see Roadmap).
- **Preflop Ranges** — a 13×13 grid viewer with two modes: push/fold shove
ranges for short stacks (~1–20bb), and opening (raise-first-in) ranges for
standard stacks (~20–100bb), by position and effective stack. Both are
hand-tuned study aids, not solver output — see `ai-docs/RANGES.md` for the
source basis. Still no 3-bet/4-bet ranges — see Roadmap.
- **Push/Fold Trainer** — shove-or-fold drills for short stacks (~1–20bb), by
position and effective stack.
- **Hand History Import & Leaks** — parse PokerStars hand-history exports and
Expand All @@ -46,7 +48,7 @@ Five tools, wired up and live in the app today:
- **Bankroll Tracker** — buy-ins, cashes, ROI, and win-rate across logged
sessions.

All of the above are backed by a tested `PokerKit` domain layer (83 passing
All of the above are backed by a tested `PokerKit` domain layer (94 passing
tests) and a green CI pipeline (`.github/workflows/ci.yml`) that runs the
`PokerKit` test suite and builds the iOS app on every push and PR. See
**[ROADMAP.md](ROADMAP.md)** for what's next (ICM/bankroll depth, a
Expand Down
35 changes: 23 additions & 12 deletions ai-docs/PREFLOP-GRID.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ Source: `PokerKit/Sources/PokerKit/PreflopGrid.swift`. Tests:

## What it does

Enumerates the classic 13×13 starting-hand grid and lays out push/fold
decisions across it. It is purely an enumeration/layout helper — it
Enumerates the classic 13×13 starting-hand grid and lays out push/fold and
opening decisions across it. It is purely an enumeration/layout helper — it
introduces no new range model, reusing `PushFoldRange.decide` and
`ChenScore` (`RANGES.md`) for every cell's actual decision.
`OpeningRange.decide` (both backed by `ChenScore` — see `RANGES.md`) for
every cell's actual decision.

## Layout convention

Expand All @@ -28,12 +29,22 @@ hands, each represented once).
## `decisions(position:effectiveStackBB:) -> [[PushFoldDecision]]`

Maps `PushFoldRange.decide(hand:position:effectiveStackBB:)` over every cell
of `hands`, indexed `[row][col]` to match. This is the only function the app
calls — `PreflopRangeView` renders one `Color.accentColor` (shove) or
`Color(.secondarySystemBackground)` (fold) cell per decision, with a position
picker and a stack slider (1–20bb) driving the two parameters live.

`gridDecisionsMatchDirectPushFoldRangeDecisions` (test) is the load-bearing
guarantee here: every grid cell's decision is required to exactly match
calling `PushFoldRange.decide` directly for that same hand/position/stack —
the grid is not allowed to drift into its own logic.
of `hands`, indexed `[row][col]` to match.

## `openingDecisions(position:effectiveStackBB:) -> [[OpeningDecision]]`

Same shape, mapping `OpeningRange.decide(hand:position:effectiveStackBB:)`
instead. `PreflopRangeView` has a "Push/Fold" vs. "Opening" segmented control
that picks which of these two functions backs the grid, plus a position
picker and a stack slider (1–20bb for push/fold, 20–100bb for opening)
driving the shared parameters live. Both render one `Color.accentColor`
(shove/raise) or `Color(.secondarySystemBackground)` (fold) cell per
decision — the view only cares about `action == .push` / `action == .raise`,
never the two decision types at once.

`gridDecisionsMatchDirectPushFoldRangeDecisions` and
`gridOpeningDecisionsMatchDirectOpeningRangeDecisions` (tests) are the
load-bearing guarantee here: every grid cell's decision is required to
exactly match calling the corresponding model directly for that same
hand/position/stack — the grid is not allowed to drift into its own logic for
either model.
Loading
Loading