Skip to content

Commit 8bdb003

Browse files
jlhe97claude
andauthored
Step 4: opening repertoire and tendency analysis
analysis/openings.py: parses PGN games, extracts first N half-moves as the opening key, groups by colour, computes frequency and W/D/L with win_pct per line, sorted by frequency. Configurable depth and top-N limit. analysis/stats.py: overall W/D/L by colour, average game length, top opening responses vs 1.e4 and 1.d4 as Black. Fixed "loss"+"s" → "losss" KeyError in both modules (use explicit mapping instead of string concatenation). 32 new tests covering opening line extraction, result mapping, repertoire analysis, stats aggregation, vs_e4/vs_d4 filtering. README updated with Step 4 docs, example output, and Python API. https://claude.ai/code/session_01VQfqug9MDEyRFydkmES4n2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent cdbc0d0 commit 8bdb003

5 files changed

Lines changed: 563 additions & 1 deletion

File tree

README.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,65 @@ profile = find_profile("Smith, John") # tries username guesses, returns first m
187187
pgn = games_as_pgn("username", months=3)
188188
```
189189

190+
## Step 4 — Opening and tendency analysis
191+
192+
Given a list of PGN strings and a player name, produces a full opening
193+
repertoire breakdown and broad tendency statistics.
194+
195+
### Opening repertoire
196+
197+
```bash
198+
python -m analysis.openings games.pgn "Smith, John"
199+
python -m analysis.openings games.pgn "Smith, John" --depth 8 --top 10
200+
```
201+
202+
Output (JSON):
203+
```json
204+
{
205+
"as_white": [
206+
{"line": "1. e4 e5 2. Nf3 Nc6 3. Bb5", "count": 18, "wins": 10, "draws": 5, "losses": 3, "win_pct": 55.6}
207+
],
208+
"as_black": [
209+
{"line": "1. e4 c5 2. Nf3 d6 3. d4 cxd4", "count": 12, "wins": 6, "draws": 4, "losses": 2, "win_pct": 50.0}
210+
]
211+
}
212+
```
213+
214+
### Tendency statistics
215+
216+
```bash
217+
python -m analysis.stats games.pgn "Smith, John"
218+
```
219+
220+
Output (JSON):
221+
```json
222+
{
223+
"total": 50,
224+
"as_white": {"count": 27, "wins": 14, "draws": 8, "losses": 5, "win_pct": 51.9},
225+
"as_black": {"count": 23, "wins": 10, "draws": 9, "losses": 4, "win_pct": 43.5},
226+
"overall": {"wins": 24, "draws": 17, "losses": 9, "win_pct": 48.0},
227+
"avg_length": 38.4,
228+
"vs_e4": [...],
229+
"vs_d4": [...]
230+
}
231+
```
232+
233+
### Python API
234+
235+
```python
236+
from analysis.openings import analyse_openings
237+
from analysis.stats import analyse_stats
238+
239+
pgn_strings = [game["pgn"] for game in games] # from megabase or lookup
240+
241+
openings = analyse_openings(pgn_strings, "Smith, John", depth=6, top=10)
242+
stats = analyse_stats(pgn_strings, "Smith, John")
243+
```
244+
190245
## Roadmap
191246

192247
- [x] Step 1 — Scrape tournament entry lists (kingregistration, chessaction)
193248
- [x] Step 2 — Index ChessBase MegaDatabase for fast player lookups
194249
- [x] Step 3 — Look up each player on Lichess and chess.com
195-
- [ ] Step 4 — Analyse openings and tendencies
250+
- [x] Step 4 — Analyse openings and tendencies
196251
- [ ] Step 5 — Generate per-opponent dossier report

analysis/__init__.py

Whitespace-only changes.

analysis/openings.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""
2+
Opening repertoire analysis.
3+
4+
Given a list of PGN strings and a player name, produces ranked opening
5+
lines for each colour with frequency and W/D/L breakdown.
6+
7+
Usage:
8+
python -m analysis.openings games.pgn "Smith, John"
9+
python -m analysis.openings games.pgn "Smith, John" --depth 8 --top 10
10+
"""
11+
12+
import io
13+
import sys
14+
import json
15+
import argparse
16+
from collections import defaultdict
17+
18+
import chess.pgn
19+
20+
21+
def _parse_game(pgn_text: str) -> chess.pgn.Game | None:
22+
try:
23+
return chess.pgn.read_game(io.StringIO(pgn_text))
24+
except Exception:
25+
return None
26+
27+
28+
def _opening_line(game: chess.pgn.Game, depth: int) -> str:
29+
"""Return the first `depth` half-moves as a SAN string, e.g. '1. e4 e5 2. Nf3'."""
30+
board = game.board()
31+
parts = []
32+
node = game
33+
half = 0
34+
while node.variations and half < depth:
35+
node = node.variations[0]
36+
move_num = board.fullmove_number
37+
is_white = board.turn == chess.WHITE
38+
san = board.san(node.move)
39+
if is_white:
40+
parts.append(f"{move_num}. {san}")
41+
else:
42+
parts.append(san)
43+
board.push(node.move)
44+
half += 1
45+
return " ".join(parts)
46+
47+
48+
def _result_for_player(game: chess.pgn.Game, player: str) -> str:
49+
"""Return 'win', 'draw', or 'loss' from the given player's perspective."""
50+
headers = game.headers
51+
result = headers.get("Result", "*")
52+
white = headers.get("White", "")
53+
black = headers.get("Black", "")
54+
55+
player_l = player.lower()
56+
is_white = player_l in white.lower()
57+
is_black = player_l in black.lower()
58+
59+
if result == "1-0":
60+
if is_white: return "win"
61+
if is_black: return "loss"
62+
elif result == "0-1":
63+
if is_black: return "win"
64+
if is_white: return "loss"
65+
elif result in ("1/2-1/2", "½-½"):
66+
return "draw"
67+
return "unknown"
68+
69+
70+
def _tally(records: dict) -> dict:
71+
"""Sort opening records by count and compute win_pct."""
72+
rows = []
73+
for line, r in records.items():
74+
total = r["wins"] + r["draws"] + r["losses"]
75+
rows.append({
76+
"line": line,
77+
"count": total,
78+
"wins": r["wins"],
79+
"draws": r["draws"],
80+
"losses": r["losses"],
81+
"win_pct": round(100 * r["wins"] / total, 1) if total else 0.0,
82+
})
83+
return sorted(rows, key=lambda x: x["count"], reverse=True)
84+
85+
86+
def analyse_openings(pgn_strings: list[str], player: str,
87+
depth: int = 6, top: int = 0) -> dict:
88+
"""
89+
Analyse opening repertoire for `player` across the given PGN games.
90+
91+
Returns:
92+
{
93+
"as_white": [{"line": "1. e4", "count": 30, "wins": 15, ...}, ...],
94+
"as_black": [...],
95+
}
96+
Sorted by frequency. Pass top > 0 to limit to N lines per colour.
97+
"""
98+
white_lines: dict[str, dict] = defaultdict(lambda: {"wins": 0, "draws": 0, "losses": 0})
99+
black_lines: dict[str, dict] = defaultdict(lambda: {"wins": 0, "draws": 0, "losses": 0})
100+
101+
player_l = player.lower()
102+
103+
for pgn_text in pgn_strings:
104+
game = _parse_game(pgn_text)
105+
if game is None:
106+
continue
107+
108+
headers = game.headers
109+
white = headers.get("White", "").lower()
110+
black = headers.get("Black", "").lower()
111+
112+
is_white = player_l in white
113+
is_black = player_l in black
114+
if not is_white and not is_black:
115+
continue
116+
117+
line = _opening_line(game, depth)
118+
result = _result_for_player(game, player)
119+
if result == "unknown":
120+
continue
121+
122+
key = {"win": "wins", "draw": "draws", "loss": "losses"}[result]
123+
bucket = white_lines if is_white else black_lines
124+
bucket[line][key] += 1
125+
126+
as_white = _tally(white_lines)
127+
as_black = _tally(black_lines)
128+
129+
if top:
130+
as_white = as_white[:top]
131+
as_black = as_black[:top]
132+
133+
return {"as_white": as_white, "as_black": as_black}
134+
135+
136+
def main() -> None:
137+
parser = argparse.ArgumentParser(description="Analyse opening repertoire from a PGN file.")
138+
parser.add_argument("pgn_file", help="PGN file containing the player's games")
139+
parser.add_argument("player", help="Player name to analyse")
140+
parser.add_argument("--depth", type=int, default=6,
141+
help="Half-moves to use as opening key (default: 6)")
142+
parser.add_argument("--top", type=int, default=10,
143+
help="Show top N openings per colour (default: 10, 0 = all)")
144+
args = parser.parse_args()
145+
146+
with open(args.pgn_file, encoding="utf-8", errors="replace") as fh:
147+
content = fh.read()
148+
149+
# Split multi-game PGN by blank line before each [Event tag
150+
import re
151+
raw_games = re.split(r"\n(?=\[)", content.strip())
152+
print(f"Parsing {len(raw_games)} game(s)…", file=sys.stderr)
153+
154+
result = analyse_openings(raw_games, args.player, depth=args.depth, top=args.top)
155+
print(json.dumps(result, indent=2))
156+
157+
158+
if __name__ == "__main__":
159+
main()

analysis/stats.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""
2+
Broad player tendency statistics.
3+
4+
Usage:
5+
python -m analysis.stats games.pgn "Smith, John"
6+
"""
7+
8+
import io
9+
import sys
10+
import json
11+
import argparse
12+
import re
13+
14+
import chess.pgn
15+
16+
from analysis.openings import _parse_game, _result_for_player, analyse_openings
17+
18+
19+
def _game_length(game: chess.pgn.Game) -> int:
20+
"""Return the number of half-moves played."""
21+
return sum(1 for _ in game.mainline_moves())
22+
23+
24+
def analyse_stats(pgn_strings: list[str], player: str) -> dict:
25+
"""
26+
Compute broad tendencies for `player` across the given PGN games.
27+
28+
Returns:
29+
{
30+
"total": 50,
31+
"as_white": {"count": 25, "wins": 12, "draws": 8, "losses": 5, "win_pct": 48.0},
32+
"as_black": {"count": 25, "wins": 10, "draws": 9, "losses": 6, "win_pct": 40.0},
33+
"overall": {"wins": 22, "draws": 17, "losses": 11, "win_pct": 44.0},
34+
"avg_length": 35.2,
35+
"vs_e4": [...], # top opening lines as Black vs 1. e4
36+
"vs_d4": [...], # top opening lines as Black vs 1. d4
37+
}
38+
"""
39+
player_l = player.lower()
40+
41+
buckets = {
42+
"white": {"wins": 0, "draws": 0, "losses": 0},
43+
"black": {"wins": 0, "draws": 0, "losses": 0},
44+
}
45+
lengths: list[int] = []
46+
valid_pgns: list[str] = []
47+
48+
for pgn_text in pgn_strings:
49+
game = _parse_game(pgn_text)
50+
if game is None:
51+
continue
52+
53+
headers = game.headers
54+
white = headers.get("White", "").lower()
55+
black = headers.get("Black", "").lower()
56+
57+
is_white = player_l in white
58+
is_black = player_l in black
59+
if not is_white and not is_black:
60+
continue
61+
62+
result = _result_for_player(game, player)
63+
if result == "unknown":
64+
continue
65+
66+
key = {"win": "wins", "draw": "draws", "loss": "losses"}[result]
67+
colour = "white" if is_white else "black"
68+
buckets[colour][key] += 1
69+
lengths.append(_game_length(game))
70+
valid_pgns.append(pgn_text)
71+
72+
def _pct(wins, total):
73+
return round(100 * wins / total, 1) if total else 0.0
74+
75+
w = buckets["white"]
76+
b = buckets["black"]
77+
white_total = w["wins"] + w["draws"] + w["losses"]
78+
black_total = b["wins"] + b["draws"] + b["losses"]
79+
total = white_total + black_total
80+
overall_wins = w["wins"] + b["wins"]
81+
overall_draws = w["draws"] + b["draws"]
82+
overall_losses = w["losses"] + b["losses"]
83+
84+
# Black repertoire vs 1.e4 and 1.d4
85+
e4_games = [p for p in valid_pgns if _first_white_move(p) == "e4"]
86+
d4_games = [p for p in valid_pgns if _first_white_move(p) == "d4"]
87+
88+
vs_e4 = analyse_openings(e4_games, player, depth=8, top=5)["as_black"]
89+
vs_d4 = analyse_openings(d4_games, player, depth=8, top=5)["as_black"]
90+
91+
return {
92+
"total": total,
93+
"as_white": {
94+
"count": white_total,
95+
"wins": w["wins"], "draws": w["draws"], "losses": w["losses"],
96+
"win_pct": _pct(w["wins"], white_total),
97+
},
98+
"as_black": {
99+
"count": black_total,
100+
"wins": b["wins"], "draws": b["draws"], "losses": b["losses"],
101+
"win_pct": _pct(b["wins"], black_total),
102+
},
103+
"overall": {
104+
"wins": overall_wins, "draws": overall_draws, "losses": overall_losses,
105+
"win_pct": _pct(overall_wins, total),
106+
},
107+
"avg_length": round(sum(lengths) / len(lengths), 1) if lengths else 0.0,
108+
"vs_e4": vs_e4,
109+
"vs_d4": vs_d4,
110+
}
111+
112+
113+
def _first_white_move(pgn_text: str) -> str | None:
114+
"""Return the UCI destination square of White's first move (e.g. 'e4')."""
115+
game = _parse_game(pgn_text)
116+
if game is None:
117+
return None
118+
for move in game.mainline_moves():
119+
return chess.square_name(move.to_square)
120+
return None
121+
122+
123+
def main() -> None:
124+
parser = argparse.ArgumentParser(description="Compute player tendency stats from a PGN file.")
125+
parser.add_argument("pgn_file", help="PGN file containing the player's games")
126+
parser.add_argument("player", help="Player name to analyse")
127+
args = parser.parse_args()
128+
129+
with open(args.pgn_file, encoding="utf-8", errors="replace") as fh:
130+
content = fh.read()
131+
132+
raw_games = re.split(r"\n(?=\[)", content.strip())
133+
print(f"Parsing {len(raw_games)} game(s)…", file=sys.stderr)
134+
135+
result = analyse_stats(raw_games, args.player)
136+
print(json.dumps(result, indent=2))
137+
138+
139+
if __name__ == "__main__":
140+
main()

0 commit comments

Comments
 (0)