hkjc 0.3.21__tar.gz → 0.3.22__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {hkjc-0.3.21 → hkjc-0.3.22}/PKG-INFO +1 -1
- {hkjc-0.3.21 → hkjc-0.3.22}/dashboard/app.py +18 -15
- {hkjc-0.3.21 → hkjc-0.3.22}/dashboard/static/script.js +329 -249
- {hkjc-0.3.21 → hkjc-0.3.22}/dashboard/static/styles.css +13 -0
- {hkjc-0.3.21 → hkjc-0.3.22}/dashboard/templates/index.html +12 -5
- {hkjc-0.3.21 → hkjc-0.3.22}/pyproject.toml +1 -1
- {hkjc-0.3.21 → hkjc-0.3.22}/src/hkjc/historical.py +26 -6
- {hkjc-0.3.21 → hkjc-0.3.22}/src/hkjc/live.py +22 -21
- {hkjc-0.3.21 → hkjc-0.3.22}/src/hkjc/utils.py +6 -0
- {hkjc-0.3.21 → hkjc-0.3.22}/uv.lock +1 -1
- {hkjc-0.3.21 → hkjc-0.3.22}/.gitignore +0 -0
- {hkjc-0.3.21 → hkjc-0.3.22}/.python-version +0 -0
- {hkjc-0.3.21 → hkjc-0.3.22}/README.md +0 -0
- {hkjc-0.3.21 → hkjc-0.3.22}/dashboard/templates/horse-info.html +0 -0
- {hkjc-0.3.21 → hkjc-0.3.22}/src/hkjc/__init__.py +0 -0
- {hkjc-0.3.21 → hkjc-0.3.22}/src/hkjc/features.py +0 -0
- {hkjc-0.3.21 → hkjc-0.3.22}/src/hkjc/harville_model.py +0 -0
- {hkjc-0.3.21 → hkjc-0.3.22}/src/hkjc/processing.py +0 -0
- {hkjc-0.3.21 → hkjc-0.3.22}/src/hkjc/py.typed +0 -0
- {hkjc-0.3.21 → hkjc-0.3.22}/src/hkjc/speedpro.py +0 -0
- {hkjc-0.3.21 → hkjc-0.3.22}/src/hkjc/strategy/place_only.py +0 -0
- {hkjc-0.3.21 → hkjc-0.3.22}/src/hkjc/strategy/qpbanker.py +0 -0
@@ -32,21 +32,24 @@ app = Flask(__name__)
|
|
32
32
|
def disp_race_info():
|
33
33
|
race_info = _fetch_live_races('', '')
|
34
34
|
|
35
|
-
|
36
|
-
|
37
|
-
for
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
#
|
48
|
-
|
49
|
-
|
35
|
+
try:
|
36
|
+
df_speedpro = speedpro_energy(race_info['Date'])
|
37
|
+
for race_num, race in race_info['Races'].items():
|
38
|
+
for i, runner in enumerate(race['Runners']):
|
39
|
+
df = (df_speedpro
|
40
|
+
.filter(pl.col('RaceNo') == race_num)
|
41
|
+
.filter(pl.col('RunnerNumber') == int(runner['No']))
|
42
|
+
)
|
43
|
+
race_info['Races'][race_num]['Runners'][i]['SPEnergy'] = df['SpeedPRO_Energy_Difference'].item(
|
44
|
+
0)
|
45
|
+
race_info['Races'][race_num]['Runners'][i]['Fitness'] = df['FitnessRatings'].item(
|
46
|
+
0)
|
47
|
+
except: # fill with dummy value if SpeedPro not available
|
48
|
+
for race_num, race in race_info['Races'].items():
|
49
|
+
for i, runner in enumerate(race['Runners']):
|
50
|
+
race_info['Races'][race_num]['Runners'][i]['SPEnergy'] = 0
|
51
|
+
race_info['Races'][race_num]['Runners'][i]['Fitness'] = 0
|
52
|
+
|
50
53
|
return render_template('index.html',
|
51
54
|
race_info=race_info)
|
52
55
|
|
@@ -6,10 +6,41 @@
|
|
6
6
|
// CONFIGURATION
|
7
7
|
// ============================================================================
|
8
8
|
const CONFIG = {
|
9
|
-
ODDS_POLL_INTERVAL: 90000, // 90 seconds
|
10
|
-
|
9
|
+
ODDS_POLL_INTERVAL: 90000, // 90 seconds (base)
|
10
|
+
POLL_JITTER: 10000, // ±5 seconds jitter for polling
|
11
|
+
MIN_REQUEST_DELAY: 80, // Min delay between requests (ms)
|
12
|
+
MAX_REQUEST_DELAY: 250, // Max delay between requests (ms)
|
11
13
|
MAX_CONCURRENT_REQUESTS: 3, // Max simultaneous requests
|
12
|
-
REQUEST_TIMEOUT: 10000
|
14
|
+
REQUEST_TIMEOUT: 10000, // 10 second timeout
|
15
|
+
DEBUG: false // Enable debug logging
|
16
|
+
};
|
17
|
+
|
18
|
+
// ============================================================================
|
19
|
+
// UTILITIES
|
20
|
+
// ============================================================================
|
21
|
+
const Utils = {
|
22
|
+
/**
|
23
|
+
* Get runner row data from HTML element
|
24
|
+
*/
|
25
|
+
getRunnerData(row) {
|
26
|
+
return {
|
27
|
+
raceNum: row.dataset.race,
|
28
|
+
horseNo: row.dataset.horse,
|
29
|
+
going: row.dataset.going || '',
|
30
|
+
track: row.dataset.track || '',
|
31
|
+
distance: row.dataset.dist || '',
|
32
|
+
runnerNo: row.querySelector('.odds-win')?.dataset.horse || row.dataset.horse
|
33
|
+
};
|
34
|
+
},
|
35
|
+
|
36
|
+
/**
|
37
|
+
* Conditional debug logging
|
38
|
+
*/
|
39
|
+
log(...args) {
|
40
|
+
if (CONFIG.DEBUG) {
|
41
|
+
console.log(...args);
|
42
|
+
}
|
43
|
+
}
|
13
44
|
};
|
14
45
|
|
15
46
|
// ============================================================================
|
@@ -24,7 +55,7 @@ const State = {
|
|
24
55
|
polling: {
|
25
56
|
oddsIntervalId: null
|
26
57
|
},
|
27
|
-
|
58
|
+
raceReadiness: new Map()
|
28
59
|
};
|
29
60
|
|
30
61
|
// ============================================================================
|
@@ -34,9 +65,6 @@ const RequestQueue = {
|
|
34
65
|
queue: [],
|
35
66
|
activeCount: 0,
|
36
67
|
|
37
|
-
/**
|
38
|
-
* Add request to queue with priority support
|
39
|
-
*/
|
40
68
|
async enqueue(requestFn, priority = 0) {
|
41
69
|
return new Promise((resolve, reject) => {
|
42
70
|
this.queue.push({ requestFn, resolve, reject, priority });
|
@@ -45,9 +73,6 @@ const RequestQueue = {
|
|
45
73
|
});
|
46
74
|
},
|
47
75
|
|
48
|
-
/**
|
49
|
-
* Process queued requests with concurrency limit
|
50
|
-
*/
|
51
76
|
async process() {
|
52
77
|
if (this.activeCount >= CONFIG.MAX_CONCURRENT_REQUESTS || this.queue.length === 0) {
|
53
78
|
return;
|
@@ -63,22 +88,19 @@ const RequestQueue = {
|
|
63
88
|
reject(error);
|
64
89
|
} finally {
|
65
90
|
this.activeCount--;
|
66
|
-
|
67
|
-
await this.delay(CONFIG.REQUEST_DELAY);
|
91
|
+
await this.delay(this.getRandomDelay());
|
68
92
|
this.process();
|
69
93
|
}
|
70
94
|
},
|
71
95
|
|
72
|
-
|
73
|
-
|
74
|
-
|
96
|
+
getRandomDelay() {
|
97
|
+
return Math.random() * (CONFIG.MAX_REQUEST_DELAY - CONFIG.MIN_REQUEST_DELAY) + CONFIG.MIN_REQUEST_DELAY;
|
98
|
+
},
|
99
|
+
|
75
100
|
clear() {
|
76
101
|
this.queue = [];
|
77
102
|
},
|
78
103
|
|
79
|
-
/**
|
80
|
-
* Helper delay function
|
81
|
-
*/
|
82
104
|
delay(ms) {
|
83
105
|
return new Promise(resolve => setTimeout(resolve, ms));
|
84
106
|
}
|
@@ -88,9 +110,6 @@ const RequestQueue = {
|
|
88
110
|
// API MODULE - All server communication
|
89
111
|
// ============================================================================
|
90
112
|
const API = {
|
91
|
-
/**
|
92
|
-
* Generic fetch wrapper with timeout and error handling
|
93
|
-
*/
|
94
113
|
async fetch(url, options = {}) {
|
95
114
|
const controller = new AbortController();
|
96
115
|
const timeoutId = setTimeout(() => controller.abort(), CONFIG.REQUEST_TIMEOUT);
|
@@ -117,19 +136,13 @@ const API = {
|
|
117
136
|
}
|
118
137
|
},
|
119
138
|
|
120
|
-
/**
|
121
|
-
* Fetch live odds for a race
|
122
|
-
*/
|
123
139
|
async fetchOdds(raceNum) {
|
124
140
|
return RequestQueue.enqueue(async () => {
|
125
141
|
const response = await this.fetch(`/live_odds/${raceNum}`);
|
126
142
|
return await response.json();
|
127
|
-
}, 1);
|
143
|
+
}, 1);
|
128
144
|
},
|
129
145
|
|
130
|
-
/**
|
131
|
-
* Fetch horse history
|
132
|
-
*/
|
133
146
|
async fetchHorseHistory(horseNo, going, track, distance) {
|
134
147
|
return RequestQueue.enqueue(async () => {
|
135
148
|
const params = new URLSearchParams({
|
@@ -139,17 +152,14 @@ const API = {
|
|
139
152
|
});
|
140
153
|
const response = await this.fetch(`/horse_info/${horseNo}?${params}`);
|
141
154
|
return await response.text();
|
142
|
-
}, 0);
|
155
|
+
}, 0);
|
143
156
|
},
|
144
157
|
|
145
|
-
/**
|
146
|
-
* Fetch speedmap
|
147
|
-
*/
|
148
158
|
async fetchSpeedmap(raceNum) {
|
149
159
|
return RequestQueue.enqueue(async () => {
|
150
160
|
const response = await this.fetch(`/speedmap/${raceNum}`);
|
151
161
|
return await response.text();
|
152
|
-
}, 0);
|
162
|
+
}, 0);
|
153
163
|
}
|
154
164
|
};
|
155
165
|
|
@@ -181,14 +191,51 @@ const Cache = {
|
|
181
191
|
}
|
182
192
|
};
|
183
193
|
|
194
|
+
// ============================================================================
|
195
|
+
// RACE READINESS MODULE - Check if speed pro data is available
|
196
|
+
// ============================================================================
|
197
|
+
const RaceReadiness = {
|
198
|
+
isReady(raceNum) {
|
199
|
+
if (State.raceReadiness.has(raceNum)) {
|
200
|
+
return State.raceReadiness.get(raceNum);
|
201
|
+
}
|
202
|
+
|
203
|
+
const ready = this.checkRaceData(raceNum);
|
204
|
+
State.raceReadiness.set(raceNum, ready);
|
205
|
+
return ready;
|
206
|
+
},
|
207
|
+
|
208
|
+
checkRaceData(raceNum) {
|
209
|
+
const raceContent = document.getElementById(`race-${raceNum}`);
|
210
|
+
if (!raceContent) return false;
|
211
|
+
|
212
|
+
const runnerRows = raceContent.querySelectorAll('.runner-row');
|
213
|
+
if (runnerRows.length === 0) return false;
|
214
|
+
|
215
|
+
// Check if any runner has non-zero fitness or energy
|
216
|
+
return Array.from(runnerRows).some(row => {
|
217
|
+
const fitness = parseFloat(row.cells[8]?.textContent.trim()) || 0;
|
218
|
+
const energy = parseFloat(row.cells[9]?.textContent.trim()) || 0;
|
219
|
+
return fitness !== 0 || energy !== 0;
|
220
|
+
});
|
221
|
+
},
|
222
|
+
|
223
|
+
recheck(raceNum) {
|
224
|
+
State.raceReadiness.delete(raceNum);
|
225
|
+
return this.isReady(raceNum);
|
226
|
+
}
|
227
|
+
};
|
228
|
+
|
184
229
|
// ============================================================================
|
185
230
|
// ODDS MODULE - Live odds management
|
186
231
|
// ============================================================================
|
187
232
|
const Odds = {
|
188
|
-
/**
|
189
|
-
* Fetch and cache odds for a race
|
190
|
-
*/
|
191
233
|
async load(raceNum) {
|
234
|
+
if (!RaceReadiness.isReady(raceNum)) {
|
235
|
+
Utils.log(`Skipping odds for race ${raceNum} - race not ready`);
|
236
|
+
return null;
|
237
|
+
}
|
238
|
+
|
192
239
|
try {
|
193
240
|
const data = await API.fetchOdds(raceNum);
|
194
241
|
Cache.set('odds', raceNum, data);
|
@@ -200,33 +247,23 @@ const Odds = {
|
|
200
247
|
}
|
201
248
|
},
|
202
249
|
|
203
|
-
/**
|
204
|
-
* Update odds display in UI
|
205
|
-
*/
|
206
250
|
updateDisplay(raceNum, oddsData) {
|
207
251
|
if (!oddsData?.Raw || !oddsData?.Fit) return;
|
208
252
|
|
209
|
-
// Update WIN columns
|
210
253
|
this.updateCells(`.odds-win[data-race="${raceNum}"]`, oddsData.Raw.WIN);
|
211
254
|
this.updateCells(`.odds-win-fit[data-race="${raceNum}"]`, oddsData.Fit.WIN);
|
212
|
-
|
213
|
-
// Update PLA columns
|
214
255
|
this.updateCells(`.odds-pla[data-race="${raceNum}"]`, oddsData.Raw.PLA);
|
215
256
|
this.updateCells(`.odds-pla-fit[data-race="${raceNum}"]`, oddsData.Fit.PLA);
|
216
257
|
|
217
|
-
// Update odds tables in expanded sections
|
218
258
|
document.querySelectorAll(`.odds-table[data-race="${raceNum}"]`).forEach(table => {
|
219
|
-
const horseNo = table.
|
259
|
+
const horseNo = table.dataset.horse;
|
220
260
|
this.updateOddsTable(table, horseNo, oddsData);
|
221
261
|
});
|
222
262
|
},
|
223
263
|
|
224
|
-
/**
|
225
|
-
* Helper to update cells with odds data
|
226
|
-
*/
|
227
264
|
updateCells(selector, oddsData) {
|
228
265
|
document.querySelectorAll(selector).forEach(cell => {
|
229
|
-
const horseNo = cell.
|
266
|
+
const horseNo = cell.dataset.horse;
|
230
267
|
const odds = oddsData?.[horseNo];
|
231
268
|
if (odds !== undefined) {
|
232
269
|
cell.textContent = odds;
|
@@ -234,9 +271,6 @@ const Odds = {
|
|
234
271
|
});
|
235
272
|
},
|
236
273
|
|
237
|
-
/**
|
238
|
-
* Update combination odds table
|
239
|
-
*/
|
240
274
|
updateOddsTable(table, horseNo, oddsData) {
|
241
275
|
const allHorses = Object.keys(oddsData.Raw.WIN || {}).sort((a, b) => parseInt(a) - parseInt(b));
|
242
276
|
const otherHorses = allHorses.filter(h => h !== horseNo);
|
@@ -254,15 +288,12 @@ const Odds = {
|
|
254
288
|
});
|
255
289
|
};
|
256
290
|
|
257
|
-
updateRow(0, oddsData.Raw.QIN);
|
258
|
-
updateRow(1, oddsData.Fit.QIN);
|
259
|
-
updateRow(2, oddsData.Raw.QPL);
|
260
|
-
updateRow(3, oddsData.Fit.QPL);
|
291
|
+
updateRow(0, oddsData.Raw.QIN);
|
292
|
+
updateRow(1, oddsData.Fit.QIN);
|
293
|
+
updateRow(2, oddsData.Raw.QPL);
|
294
|
+
updateRow(3, oddsData.Fit.QPL);
|
261
295
|
},
|
262
296
|
|
263
|
-
/**
|
264
|
-
* Create HTML for odds section
|
265
|
-
*/
|
266
297
|
createSection(raceNum, horseNo, oddsData) {
|
267
298
|
if (!oddsData?.Raw || !oddsData?.Fit) return '';
|
268
299
|
|
@@ -271,15 +302,6 @@ const Odds = {
|
|
271
302
|
|
272
303
|
if (otherHorses.length === 0) return '';
|
273
304
|
|
274
|
-
let html = '<div class="odds-table-container">';
|
275
|
-
html += `<table class="odds-table" data-race="${raceNum}" data-horse="${horseNo}">`;
|
276
|
-
html += '<thead><tr><th>Type</th>';
|
277
|
-
|
278
|
-
// Header row
|
279
|
-
otherHorses.forEach(h => html += `<th>vs ${h}</th>`);
|
280
|
-
html += '</tr></thead><tbody>';
|
281
|
-
|
282
|
-
// Data rows
|
283
305
|
const createRow = (label, data) => {
|
284
306
|
let row = `<tr><td class="odds-type-label">${label}</td>`;
|
285
307
|
otherHorses.forEach(h => {
|
@@ -289,18 +311,26 @@ const Odds = {
|
|
289
311
|
return row + '</tr>';
|
290
312
|
};
|
291
313
|
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
314
|
+
return `
|
315
|
+
<div class="odds-table-container">
|
316
|
+
<table class="odds-table" data-race="${raceNum}" data-horse="${horseNo}">
|
317
|
+
<thead>
|
318
|
+
<tr>
|
319
|
+
<th>Type</th>
|
320
|
+
${otherHorses.map(h => `<th>vs ${h}</th>`).join('')}
|
321
|
+
</tr>
|
322
|
+
</thead>
|
323
|
+
<tbody>
|
324
|
+
${createRow('QIN', oddsData.Raw.QIN)}
|
325
|
+
${createRow('QIN (fit)', oddsData.Fit.QIN)}
|
326
|
+
${createRow('QPL', oddsData.Raw.QPL)}
|
327
|
+
${createRow('QPL (fit)', oddsData.Fit.QPL)}
|
328
|
+
</tbody>
|
329
|
+
</table>
|
330
|
+
</div>
|
331
|
+
`;
|
299
332
|
},
|
300
333
|
|
301
|
-
/**
|
302
|
-
* Start polling for live odds
|
303
|
-
*/
|
304
334
|
startPolling() {
|
305
335
|
const allRaceNums = Array.from(document.querySelectorAll('.race-content'))
|
306
336
|
.map(el => el.id.replace('race-', ''));
|
@@ -310,21 +340,26 @@ const Odds = {
|
|
310
340
|
|
311
341
|
// Clear existing interval
|
312
342
|
if (State.polling.oddsIntervalId) {
|
313
|
-
|
343
|
+
clearTimeout(State.polling.oddsIntervalId);
|
314
344
|
}
|
315
345
|
|
316
|
-
// Set up polling
|
317
|
-
|
318
|
-
|
319
|
-
|
346
|
+
// Set up polling with jitter
|
347
|
+
const scheduleNextPoll = () => {
|
348
|
+
const jitter = Math.random() * CONFIG.POLL_JITTER - (CONFIG.POLL_JITTER / 2);
|
349
|
+
const nextInterval = CONFIG.ODDS_POLL_INTERVAL + jitter;
|
350
|
+
|
351
|
+
State.polling.oddsIntervalId = setTimeout(() => {
|
352
|
+
allRaceNums.forEach(raceNum => this.load(raceNum));
|
353
|
+
scheduleNextPoll();
|
354
|
+
}, nextInterval);
|
355
|
+
};
|
356
|
+
|
357
|
+
scheduleNextPoll();
|
320
358
|
},
|
321
359
|
|
322
|
-
/**
|
323
|
-
* Stop polling
|
324
|
-
*/
|
325
360
|
stopPolling() {
|
326
361
|
if (State.polling.oddsIntervalId) {
|
327
|
-
|
362
|
+
clearTimeout(State.polling.oddsIntervalId);
|
328
363
|
State.polling.oddsIntervalId = null;
|
329
364
|
}
|
330
365
|
}
|
@@ -334,19 +369,86 @@ const Odds = {
|
|
334
369
|
// RUNNER DETAILS MODULE - Horse history and details
|
335
370
|
// ============================================================================
|
336
371
|
const RunnerDetails = {
|
337
|
-
|
338
|
-
|
339
|
-
|
372
|
+
extractRunningStyles(historyHtml) {
|
373
|
+
const parser = new DOMParser();
|
374
|
+
const doc = parser.parseFromString(historyHtml, 'text/html');
|
375
|
+
const table = doc.querySelector('table');
|
376
|
+
|
377
|
+
if (!table) return [];
|
378
|
+
|
379
|
+
// Find Style column
|
380
|
+
const headerRow = table.querySelector('thead tr');
|
381
|
+
if (!headerRow) return [];
|
382
|
+
|
383
|
+
const headers = Array.from(headerRow.querySelectorAll('th'));
|
384
|
+
const styleColumnIndex = headers.findIndex(th =>
|
385
|
+
th.textContent.trim().toLowerCase() === 'style'
|
386
|
+
);
|
387
|
+
|
388
|
+
if (styleColumnIndex === -1) return [];
|
389
|
+
|
390
|
+
// Extract styles from top 5 rows
|
391
|
+
const rows = Array.from(table.querySelectorAll('tbody tr'));
|
392
|
+
const styles = [];
|
393
|
+
|
394
|
+
for (let i = 0; i < Math.min(5, rows.length); i++) {
|
395
|
+
const cells = rows[i].querySelectorAll('td');
|
396
|
+
if (styleColumnIndex < cells.length) {
|
397
|
+
const styleText = cells[styleColumnIndex].textContent.trim();
|
398
|
+
if (styleText) {
|
399
|
+
styles.push(styleText);
|
400
|
+
}
|
401
|
+
}
|
402
|
+
}
|
403
|
+
|
404
|
+
return styles;
|
405
|
+
},
|
406
|
+
|
407
|
+
calculateMode(arr) {
|
408
|
+
if (!arr || arr.length === 0) return null;
|
409
|
+
|
410
|
+
const frequency = {};
|
411
|
+
let maxFreq = 0;
|
412
|
+
let mode = null;
|
413
|
+
|
414
|
+
arr.forEach(value => {
|
415
|
+
frequency[value] = (frequency[value] || 0) + 1;
|
416
|
+
if (frequency[value] > maxFreq) {
|
417
|
+
maxFreq = frequency[value];
|
418
|
+
mode = value;
|
419
|
+
}
|
420
|
+
});
|
421
|
+
|
422
|
+
return mode;
|
423
|
+
},
|
424
|
+
|
425
|
+
updateFavStyle(raceNum, horseNo, historyHtml) {
|
426
|
+
const cell = document.querySelector(`.fav-style[data-race="${raceNum}"][data-horse="${horseNo}"]`);
|
427
|
+
if (!cell) return;
|
428
|
+
|
429
|
+
try {
|
430
|
+
const styles = this.extractRunningStyles(historyHtml);
|
431
|
+
const favStyle = this.calculateMode(styles);
|
432
|
+
cell.textContent = favStyle || '-';
|
433
|
+
} catch (error) {
|
434
|
+
console.error(`Error updating fav style for horse ${horseNo}:`, error);
|
435
|
+
cell.textContent = '-';
|
436
|
+
}
|
437
|
+
},
|
438
|
+
|
340
439
|
async load(raceNum, horseNo, going, track, distance) {
|
341
440
|
const cacheKey = `${raceNum}-${horseNo}`;
|
342
441
|
|
343
442
|
if (Cache.has('runnerDetails', cacheKey)) {
|
344
|
-
|
443
|
+
const cachedHtml = Cache.get('runnerDetails', cacheKey);
|
444
|
+
this.updateFavStyle(raceNum, horseNo, cachedHtml);
|
445
|
+
return cachedHtml;
|
345
446
|
}
|
346
447
|
|
347
448
|
try {
|
348
449
|
const html = await API.fetchHorseHistory(horseNo, going, track, distance);
|
349
450
|
Cache.set('runnerDetails', cacheKey, html);
|
451
|
+
this.updateFavStyle(raceNum, horseNo, html);
|
350
452
|
return html;
|
351
453
|
} catch (error) {
|
352
454
|
console.error(`Error loading history for horse ${horseNo}:`, error);
|
@@ -354,52 +456,29 @@ const RunnerDetails = {
|
|
354
456
|
}
|
355
457
|
},
|
356
458
|
|
357
|
-
/**
|
358
|
-
* Create complete details content (odds + history)
|
359
|
-
*/
|
360
459
|
createContent(raceNum, runnerNo, horseNo, oddsData, historyHtml) {
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
if (oddsData) {
|
365
|
-
content += '<div class="details-left">';
|
366
|
-
content += Odds.createSection(raceNum, runnerNo, oddsData);
|
367
|
-
content += '</div>';
|
368
|
-
}
|
369
|
-
|
370
|
-
// Add history section
|
371
|
-
content += '<div class="details-right">';
|
372
|
-
content += historyHtml;
|
373
|
-
content += '</div>';
|
374
|
-
|
375
|
-
content += '</div>';
|
376
|
-
|
377
|
-
return content;
|
460
|
+
const oddsSection = oddsData ? `<div class="details-left">${Odds.createSection(raceNum, runnerNo, oddsData)}</div>` : '';
|
461
|
+
const historySection = `<div class="details-right">${historyHtml}</div>`;
|
462
|
+
return `<div class="runner-details-wrapper">${oddsSection}${historySection}</div>`;
|
378
463
|
},
|
379
464
|
|
380
|
-
/**
|
381
|
-
* Toggle single runner details
|
382
|
-
*/
|
383
465
|
async toggle(rowElement, raceNum, horseNo, going, track, distance) {
|
384
466
|
const detailsRow = document.getElementById(`runner-details-${raceNum}-${horseNo}`);
|
385
467
|
const isExpanded = detailsRow.classList.contains('expanded');
|
386
468
|
|
387
469
|
if (isExpanded) {
|
388
|
-
// Collapse
|
389
470
|
detailsRow.classList.remove('expanded');
|
390
471
|
rowElement.querySelector('.expand-icon').classList.remove('expanded');
|
391
472
|
} else {
|
392
|
-
// Expand
|
393
473
|
detailsRow.classList.add('expanded');
|
394
474
|
rowElement.querySelector('.expand-icon').classList.add('expanded');
|
395
475
|
|
396
476
|
const detailsContent = document.getElementById(`runner-details-content-${raceNum}-${horseNo}`);
|
397
|
-
const runnerNo = rowElement.querySelector('.odds-win')?.
|
477
|
+
const runnerNo = rowElement.querySelector('.odds-win')?.dataset.horse || horseNo;
|
398
478
|
const oddsData = Cache.get('odds', raceNum);
|
399
479
|
const cacheKey = `${raceNum}-${horseNo}`;
|
400
480
|
|
401
481
|
if (!Cache.has('runnerDetails', cacheKey)) {
|
402
|
-
// Show loading state
|
403
482
|
detailsContent.innerHTML = this.createContent(
|
404
483
|
raceNum, runnerNo, horseNo, oddsData,
|
405
484
|
'<div class="details-loading">Loading race history...</div>'
|
@@ -411,7 +490,7 @@ const RunnerDetails = {
|
|
411
490
|
} catch (error) {
|
412
491
|
detailsContent.innerHTML = this.createContent(
|
413
492
|
raceNum, runnerNo, horseNo, oddsData,
|
414
|
-
`<div class="details-loading error">Error
|
493
|
+
`<div class="details-loading error">Error: ${error.message}</div>`
|
415
494
|
);
|
416
495
|
}
|
417
496
|
} else {
|
@@ -423,100 +502,111 @@ const RunnerDetails = {
|
|
423
502
|
UI.updateExpandAllButtonState(raceNum);
|
424
503
|
},
|
425
504
|
|
426
|
-
|
427
|
-
|
428
|
-
|
505
|
+
async loadRunnerData(raceNum, runnerData, oddsData) {
|
506
|
+
const { horseNo, runnerNo, going, track, distance } = runnerData;
|
507
|
+
const cacheKey = `${raceNum}-${horseNo}`;
|
508
|
+
const detailsContent = document.getElementById(`runner-details-content-${raceNum}-${horseNo}`);
|
509
|
+
const detailsRow = document.getElementById(`runner-details-${raceNum}-${horseNo}`);
|
510
|
+
|
511
|
+
if (!Cache.has('runnerDetails', cacheKey)) {
|
512
|
+
try {
|
513
|
+
const historyHtml = await this.load(raceNum, horseNo, going, track, distance);
|
514
|
+
|
515
|
+
if (detailsRow.classList.contains('expanded')) {
|
516
|
+
const currentOddsData = Cache.get('odds', raceNum);
|
517
|
+
detailsContent.innerHTML = this.createContent(raceNum, runnerNo, horseNo, currentOddsData, historyHtml);
|
518
|
+
}
|
519
|
+
} catch (error) {
|
520
|
+
if (detailsRow.classList.contains('expanded')) {
|
521
|
+
const currentOddsData = Cache.get('odds', raceNum);
|
522
|
+
detailsContent.innerHTML = this.createContent(
|
523
|
+
raceNum, runnerNo, horseNo, currentOddsData,
|
524
|
+
`<div class="details-loading error">Error: ${error.message}</div>`
|
525
|
+
);
|
526
|
+
}
|
527
|
+
}
|
528
|
+
}
|
529
|
+
},
|
530
|
+
|
429
531
|
async toggleAll(raceNum, buttonElement) {
|
430
532
|
const raceContent = document.getElementById(`race-${raceNum}`);
|
431
533
|
const runnerRows = Array.from(raceContent.querySelectorAll('.runner-row'));
|
432
534
|
const isExpanding = !buttonElement.classList.contains('all-expanded');
|
433
535
|
|
434
536
|
if (isExpanding) {
|
435
|
-
// Expand all
|
436
537
|
const oddsData = Cache.get('odds', raceNum);
|
437
538
|
|
438
|
-
//
|
539
|
+
// Expand UI immediately for all runners
|
439
540
|
const runners = runnerRows.map(row => {
|
440
|
-
const
|
441
|
-
const
|
442
|
-
const
|
443
|
-
const
|
444
|
-
const detailsContent = document.getElementById(`runner-details-content-${raceNum}-${horseNo}`);
|
445
|
-
const cacheKey = `${raceNum}-${horseNo}`;
|
541
|
+
const runnerData = Utils.getRunnerData(row);
|
542
|
+
const cacheKey = `${raceNum}-${runnerData.horseNo}`;
|
543
|
+
const detailsRow = document.getElementById(`runner-details-${raceNum}-${runnerData.horseNo}`);
|
544
|
+
const detailsContent = document.getElementById(`runner-details-content-${raceNum}-${runnerData.horseNo}`);
|
446
545
|
|
447
|
-
// Expand UI
|
448
546
|
detailsRow.classList.add('expanded');
|
449
547
|
row.querySelector('.expand-icon').classList.add('expanded');
|
450
548
|
|
451
|
-
// Show initial state
|
452
549
|
if (Cache.has('runnerDetails', cacheKey)) {
|
453
550
|
const historyHtml = Cache.get('runnerDetails', cacheKey);
|
454
|
-
detailsContent.innerHTML = this.createContent(raceNum, runnerNo, horseNo, oddsData, historyHtml);
|
551
|
+
detailsContent.innerHTML = this.createContent(raceNum, runnerData.runnerNo, runnerData.horseNo, oddsData, historyHtml);
|
455
552
|
} else {
|
456
553
|
detailsContent.innerHTML = this.createContent(
|
457
|
-
raceNum, runnerNo, horseNo, oddsData,
|
554
|
+
raceNum, runnerData.runnerNo, runnerData.horseNo, oddsData,
|
458
555
|
'<div class="details-loading">Loading race history...</div>'
|
459
556
|
);
|
460
557
|
}
|
461
558
|
|
462
|
-
return
|
559
|
+
return runnerData;
|
463
560
|
});
|
464
561
|
|
465
|
-
// Update button state immediately after expanding UI
|
466
562
|
UI.updateExpandAllButtonState(raceNum);
|
467
563
|
|
468
|
-
// Load uncached histories
|
564
|
+
// Load uncached histories in background
|
469
565
|
(async () => {
|
470
|
-
for (const
|
471
|
-
|
472
|
-
if (!runner.detailsRow.classList.contains('expanded')) {
|
473
|
-
continue; // Skip if user collapsed this runner
|
474
|
-
}
|
475
|
-
|
476
|
-
if (!Cache.has('runnerDetails', runner.cacheKey)) {
|
477
|
-
try {
|
478
|
-
const historyHtml = await this.load(
|
479
|
-
raceNum,
|
480
|
-
runner.horseNo,
|
481
|
-
runner.params[2],
|
482
|
-
runner.params[3],
|
483
|
-
runner.params[4]
|
484
|
-
);
|
485
|
-
|
486
|
-
// Check again if still expanded after async load
|
487
|
-
if (runner.detailsRow.classList.contains('expanded')) {
|
488
|
-
const currentOddsData = Cache.get('odds', raceNum);
|
489
|
-
runner.detailsContent.innerHTML = this.createContent(
|
490
|
-
raceNum, runner.runnerNo, runner.horseNo, currentOddsData, historyHtml
|
491
|
-
);
|
492
|
-
}
|
493
|
-
} catch (error) {
|
494
|
-
// Check if still expanded before showing error
|
495
|
-
if (runner.detailsRow.classList.contains('expanded')) {
|
496
|
-
const currentOddsData = Cache.get('odds', raceNum);
|
497
|
-
runner.detailsContent.innerHTML = this.createContent(
|
498
|
-
raceNum, runner.runnerNo, runner.horseNo, currentOddsData,
|
499
|
-
`<div class="details-loading error">Error: ${error.message}</div>`
|
500
|
-
);
|
501
|
-
}
|
502
|
-
}
|
503
|
-
}
|
566
|
+
for (const runnerData of runners) {
|
567
|
+
await this.loadRunnerData(raceNum, runnerData, oddsData);
|
504
568
|
}
|
505
569
|
})();
|
506
570
|
} else {
|
507
571
|
// Collapse all
|
508
572
|
runnerRows.forEach(row => {
|
509
|
-
const
|
510
|
-
const horseNo = params[1];
|
573
|
+
const horseNo = row.dataset.horse;
|
511
574
|
const detailsRow = document.getElementById(`runner-details-${raceNum}-${horseNo}`);
|
512
|
-
|
513
575
|
detailsRow.classList.remove('expanded');
|
514
576
|
row.querySelector('.expand-icon').classList.remove('expanded');
|
515
577
|
});
|
516
578
|
|
517
|
-
// Update button state immediately after collapsing
|
518
579
|
UI.updateExpandAllButtonState(raceNum);
|
519
580
|
}
|
581
|
+
},
|
582
|
+
|
583
|
+
async prefetchAll() {
|
584
|
+
Utils.log('Starting background prefetch of runner histories...');
|
585
|
+
|
586
|
+
const allRaces = Array.from(document.querySelectorAll('.race-content'));
|
587
|
+
if (allRaces.length === 0) return;
|
588
|
+
|
589
|
+
for (const raceContent of allRaces) {
|
590
|
+
const raceNum = raceContent.id.replace('race-', '');
|
591
|
+
const runnerRows = Array.from(raceContent.querySelectorAll('.runner-row'));
|
592
|
+
|
593
|
+
for (const row of runnerRows) {
|
594
|
+
const { horseNo, going, track, distance } = Utils.getRunnerData(row);
|
595
|
+
if (!horseNo) continue;
|
596
|
+
|
597
|
+
const cacheKey = `${raceNum}-${horseNo}`;
|
598
|
+
if (Cache.has('runnerDetails', cacheKey)) continue;
|
599
|
+
|
600
|
+
try {
|
601
|
+
await this.load(raceNum, horseNo, going, track, distance);
|
602
|
+
Utils.log(`✓ Prefetched race ${raceNum} - horse ${horseNo}`);
|
603
|
+
} catch (error) {
|
604
|
+
console.error(`✗ Error prefetching race ${raceNum} - horse ${horseNo}:`, error);
|
605
|
+
}
|
606
|
+
}
|
607
|
+
}
|
608
|
+
|
609
|
+
Utils.log('✓ Background prefetch completed');
|
520
610
|
}
|
521
611
|
};
|
522
612
|
|
@@ -524,36 +614,45 @@ const RunnerDetails = {
|
|
524
614
|
// SPEEDMAP MODULE
|
525
615
|
// ============================================================================
|
526
616
|
const Speedmap = {
|
527
|
-
|
528
|
-
|
529
|
-
|
530
|
-
|
531
|
-
|
532
|
-
|
617
|
+
ensureImageElement(raceNum, container) {
|
618
|
+
if (!container.querySelector('img')) {
|
619
|
+
container.innerHTML = `<img id="speedmap-${raceNum}" class="speedmap-image" alt="Race ${raceNum} Speed Map" />`;
|
620
|
+
}
|
621
|
+
return document.getElementById(`speedmap-${raceNum}`);
|
622
|
+
},
|
533
623
|
|
534
|
-
|
624
|
+
async load(raceNum) {
|
625
|
+
const speedmapContainer = document.querySelector(`#race-${raceNum} .speedmap-container`);
|
626
|
+
const speedmapImg = document.getElementById(`speedmap-${raceNum}`);
|
627
|
+
if (!speedmapImg || !speedmapContainer) return;
|
628
|
+
|
629
|
+
if (!RaceReadiness.isReady(raceNum)) {
|
630
|
+
speedmapImg.style.display = 'none';
|
631
|
+
speedmapContainer.innerHTML = '<div class="race-not-ready">Race not ready</div>';
|
632
|
+
return;
|
633
|
+
}
|
634
|
+
|
535
635
|
if (Cache.has('speedmaps', raceNum)) {
|
536
|
-
|
537
|
-
|
538
|
-
|
539
|
-
|
540
|
-
|
541
|
-
|
542
|
-
|
636
|
+
const img = this.ensureImageElement(raceNum, speedmapContainer);
|
637
|
+
img.src = Cache.get('speedmaps', raceNum);
|
638
|
+
img.style.display = 'block';
|
639
|
+
return;
|
640
|
+
}
|
641
|
+
|
642
|
+
speedmapImg.style.display = 'none';
|
643
|
+
|
543
644
|
try {
|
544
645
|
const base64String = await API.fetchSpeedmap(raceNum);
|
545
646
|
Cache.set('speedmaps', raceNum, base64String);
|
546
|
-
|
547
|
-
|
647
|
+
const img = this.ensureImageElement(raceNum, speedmapContainer);
|
648
|
+
img.src = base64String;
|
649
|
+
img.style.display = 'block';
|
548
650
|
} catch (error) {
|
549
651
|
console.error(`Error loading speedmap for race ${raceNum}:`, error);
|
550
652
|
speedmapImg.style.display = 'none';
|
551
653
|
}
|
552
654
|
},
|
553
655
|
|
554
|
-
/**
|
555
|
-
* Load all speedmaps sequentially via queue
|
556
|
-
*/
|
557
656
|
async loadAll() {
|
558
657
|
const speedmapImages = document.querySelectorAll('.speedmap-image');
|
559
658
|
for (const img of speedmapImages) {
|
@@ -567,24 +666,17 @@ const Speedmap = {
|
|
567
666
|
// UI MODULE - User interface interactions
|
568
667
|
// ============================================================================
|
569
668
|
const UI = {
|
570
|
-
/**
|
571
|
-
* Show a specific race tab
|
572
|
-
*/
|
573
669
|
showRace(raceNum) {
|
574
670
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
575
671
|
document.getElementById(`tab-${raceNum}`).classList.add('active');
|
576
672
|
document.querySelectorAll('.race-content').forEach(content => content.classList.remove('active'));
|
577
673
|
document.getElementById(`race-${raceNum}`).classList.add('active');
|
578
674
|
|
579
|
-
// Fetch odds if not cached
|
580
675
|
if (!Cache.has('odds', raceNum)) {
|
581
676
|
Odds.load(raceNum);
|
582
677
|
}
|
583
678
|
},
|
584
679
|
|
585
|
-
/**
|
586
|
-
* Update expand/collapse all button state
|
587
|
-
*/
|
588
680
|
updateExpandAllButtonState(raceNum) {
|
589
681
|
const raceContent = document.getElementById(`race-${raceNum}`);
|
590
682
|
const expandAllBtn = raceContent.querySelector('.expand-all-btn');
|
@@ -599,59 +691,53 @@ const UI = {
|
|
599
691
|
}
|
600
692
|
},
|
601
693
|
|
602
|
-
/**
|
603
|
-
* Sort table by column
|
604
|
-
*/
|
605
694
|
sortTable(raceNum, columnIndex, type) {
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
|
610
|
-
|
611
|
-
|
612
|
-
|
695
|
+
const table = document.querySelector(`#race-${raceNum} .runners-table`);
|
696
|
+
const tbody = table.querySelector('tbody');
|
697
|
+
const header = table.querySelectorAll('thead th')[columnIndex];
|
698
|
+
const allHeaders = table.querySelectorAll('thead th.sortable');
|
699
|
+
const rows = Array.from(tbody.querySelectorAll('tr')).filter((row, index) => index % 2 === 0);
|
700
|
+
const isAscending = !header.classList.contains('sort-asc');
|
701
|
+
|
613
702
|
// Reset all headers
|
614
|
-
|
615
|
-
|
616
|
-
|
617
|
-
|
618
|
-
|
619
|
-
|
620
|
-
// Sort rows
|
621
|
-
rows.sort((a, b) => {
|
622
|
-
let aComp, bComp;
|
623
|
-
|
624
|
-
if (type === 'number') {
|
625
|
-
aComp = parseFloat(a.cells[columnIndex].textContent.trim());
|
626
|
-
bComp = parseFloat(b.cells[columnIndex].textContent.trim());
|
627
|
-
if (isNaN(aComp)) aComp = -Infinity;
|
628
|
-
if (isNaN(bComp)) bComp = -Infinity;
|
629
|
-
} else {
|
630
|
-
aComp = a.cells[columnIndex].textContent.trim().toLowerCase();
|
631
|
-
bComp = b.cells[columnIndex].textContent.trim().toLowerCase();
|
632
|
-
}
|
703
|
+
allHeaders.forEach(h => {
|
704
|
+
h.classList.remove('sort-asc', 'sort-desc');
|
705
|
+
const arrow = h.querySelector('.sort-arrow');
|
706
|
+
if (arrow) arrow.textContent = '⇅';
|
707
|
+
});
|
633
708
|
|
709
|
+
// Sort rows
|
710
|
+
rows.sort((a, b) => {
|
711
|
+
let aComp, bComp;
|
712
|
+
|
713
|
+
if (type === 'number') {
|
714
|
+
aComp = parseFloat(a.cells[columnIndex].textContent.trim());
|
715
|
+
bComp = parseFloat(b.cells[columnIndex].textContent.trim());
|
716
|
+
if (isNaN(aComp)) aComp = -Infinity;
|
717
|
+
if (isNaN(bComp)) bComp = -Infinity;
|
718
|
+
} else {
|
719
|
+
aComp = a.cells[columnIndex].textContent.trim().toLowerCase();
|
720
|
+
bComp = b.cells[columnIndex].textContent.trim().toLowerCase();
|
721
|
+
}
|
722
|
+
|
634
723
|
return isAscending ?
|
635
724
|
(aComp > bComp ? 1 : aComp < bComp ? -1 : 0) :
|
636
725
|
(aComp < bComp ? 1 : aComp > bComp ? -1 : 0);
|
637
|
-
|
638
|
-
|
726
|
+
});
|
727
|
+
|
639
728
|
// Update header
|
640
|
-
|
641
|
-
|
642
|
-
|
643
|
-
|
729
|
+
header.classList.add(isAscending ? 'sort-asc' : 'sort-desc');
|
730
|
+
const arrow = header.querySelector('.sort-arrow');
|
731
|
+
if (arrow) arrow.textContent = isAscending ? '▲' : '▼';
|
732
|
+
|
644
733
|
// Reorder rows
|
645
|
-
|
734
|
+
rows.forEach(row => {
|
646
735
|
const detailsRow = row.nextElementSibling;
|
647
|
-
|
736
|
+
tbody.appendChild(row);
|
648
737
|
if (detailsRow) tbody.appendChild(detailsRow);
|
649
738
|
});
|
650
739
|
},
|
651
740
|
|
652
|
-
/**
|
653
|
-
* Open track workout video
|
654
|
-
*/
|
655
741
|
openTrackWorkoutVideo(raceDate, raceNum) {
|
656
742
|
const dateParts = raceDate.split('-');
|
657
743
|
const dateFormatted = dateParts.join('');
|
@@ -665,24 +751,18 @@ const UI = {
|
|
665
751
|
// APPLICATION INITIALIZATION
|
666
752
|
// ============================================================================
|
667
753
|
const App = {
|
668
|
-
/**
|
669
|
-
* Initialize the application
|
670
|
-
*/
|
671
754
|
init() {
|
672
755
|
console.log('Initializing HKJC Race Info...');
|
673
756
|
|
674
|
-
// Load speedmaps
|
675
757
|
Speedmap.loadAll();
|
676
|
-
|
677
|
-
// Start odds polling
|
678
758
|
Odds.startPolling();
|
759
|
+
RunnerDetails.prefetchAll().catch(error => {
|
760
|
+
console.error('Error in background prefetch:', error);
|
761
|
+
});
|
679
762
|
|
680
763
|
console.log('Application initialized successfully');
|
681
764
|
},
|
682
765
|
|
683
|
-
/**
|
684
|
-
* Cleanup on page unload
|
685
|
-
*/
|
686
766
|
cleanup() {
|
687
767
|
Odds.stopPolling();
|
688
768
|
RequestQueue.clear();
|
@@ -156,6 +156,14 @@ body {
|
|
156
156
|
margin-top: -280px;
|
157
157
|
}
|
158
158
|
|
159
|
+
.race-not-ready {
|
160
|
+
padding: 20px;
|
161
|
+
color: #666;
|
162
|
+
font-size: 1em;
|
163
|
+
font-style: italic;
|
164
|
+
text-align: center;
|
165
|
+
}
|
166
|
+
|
159
167
|
.expand-all-container {
|
160
168
|
margin-bottom: 10px;
|
161
169
|
}
|
@@ -247,6 +255,11 @@ body {
|
|
247
255
|
text-align: center;
|
248
256
|
color: #667eea;
|
249
257
|
font-size: 0.7em;
|
258
|
+
transition: transform 0.2s ease;
|
259
|
+
}
|
260
|
+
|
261
|
+
.expand-icon.expanded {
|
262
|
+
transform: rotate(90deg);
|
250
263
|
}
|
251
264
|
|
252
265
|
.runner-details-row {
|
@@ -89,16 +89,17 @@
|
|
89
89
|
<th class="sortable" onclick="sortTable('{{ race_num }}', 9, 'number')">
|
90
90
|
Energy Diff <span class="sort-arrow">⇅</span>
|
91
91
|
</th>
|
92
|
-
<th
|
92
|
+
<th>Fav Style</th>
|
93
|
+
<th class="sortable" onclick="sortTable('{{ race_num }}', 11, 'number')">
|
93
94
|
WIN <span class="sort-arrow">⇅</span>
|
94
95
|
</th>
|
95
|
-
<th class="sortable" onclick="sortTable('{{ race_num }}',
|
96
|
+
<th class="sortable" onclick="sortTable('{{ race_num }}', 12, 'number')">
|
96
97
|
WIN (fit) <span class="sort-arrow">⇅</span>
|
97
98
|
</th>
|
98
|
-
<th class="sortable" onclick="sortTable('{{ race_num }}',
|
99
|
+
<th class="sortable" onclick="sortTable('{{ race_num }}', 13, 'number')">
|
99
100
|
PLA <span class="sort-arrow">⇅</span>
|
100
101
|
</th>
|
101
|
-
<th class="sortable" onclick="sortTable('{{ race_num }}',
|
102
|
+
<th class="sortable" onclick="sortTable('{{ race_num }}', 14, 'number')">
|
102
103
|
PLA (fit) <span class="sort-arrow">⇅</span>
|
103
104
|
</th>
|
104
105
|
</tr>
|
@@ -106,6 +107,11 @@
|
|
106
107
|
<tbody>
|
107
108
|
{% for runner in race.Runners %}
|
108
109
|
<tr class="runner-row"
|
110
|
+
data-race="{{ race_num }}"
|
111
|
+
data-horse="{{ runner.HorseNo }}"
|
112
|
+
data-going="{{ race.Going }}"
|
113
|
+
data-track="{{ race.Track }}"
|
114
|
+
data-dist="{{ race.Dist }}"
|
109
115
|
onclick="toggleRunnerHistory(this, '{{ race_num }}', '{{ runner.HorseNo }}', '{{ race.Going }}', '{{ race.Track }}', '{{ race.Dist }}')">
|
110
116
|
<td>
|
111
117
|
<span class="expand-icon">▶</span>
|
@@ -122,13 +128,14 @@
|
|
122
128
|
<td>{{ runner.Rtg }}</td>
|
123
129
|
<td>{{ runner.Fitness }}</td>
|
124
130
|
<td>{{ runner.SPEnergy }}</td>
|
131
|
+
<td class="fav-style" data-race="{{ race_num }}" data-horse="{{ runner.HorseNo }}">-</td>
|
125
132
|
<td class="odds-win" data-race="{{ race_num }}" data-horse="{{ runner.No }}">-</td>
|
126
133
|
<td class="odds-win-fit" data-race="{{ race_num }}" data-horse="{{ runner.No }}">-</td>
|
127
134
|
<td class="odds-pla" data-race="{{ race_num }}" data-horse="{{ runner.No }}">-</td>
|
128
135
|
<td class="odds-pla-fit" data-race="{{ race_num }}" data-horse="{{ runner.No }}">-</td>
|
129
136
|
</tr>
|
130
137
|
<tr class="runner-details-row" id="runner-details-{{ race_num }}-{{ runner.HorseNo }}">
|
131
|
-
<td colspan="
|
138
|
+
<td colspan="15" class="runner-details-cell">
|
132
139
|
<div class="runner-details-content" id="runner-details-content-{{ race_num }}-{{ runner.HorseNo }}">
|
133
140
|
<div class="details-loading">Loading details...</div>
|
134
141
|
</div>
|
@@ -15,12 +15,21 @@ HKJC_HORSE_URL_TEMPLATE = "https://racing.hkjc.com/racing/information/English/Ho
|
|
15
15
|
incidents = ['DISQ', 'DNF', 'FE', 'ML', 'PU', 'TNP', 'TO',
|
16
16
|
'UR', 'VOID', 'WR', 'WV', 'WV-A', 'WX', 'WX-A', 'WXNR']
|
17
17
|
|
18
|
+
REQUEST_TIMEOUT = 10
|
19
|
+
|
20
|
+
HTML_HEADERS = {
|
21
|
+
"Origin": "https://racing.hkjc.com",
|
22
|
+
"Referer": "https://racing.hkjc.com",
|
23
|
+
"Content-Type": "text/plain",
|
24
|
+
"Accept": "*/*",
|
25
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
|
26
|
+
}
|
18
27
|
|
19
28
|
@ttl_cache(maxsize=100, ttl=3600)
|
20
29
|
def _soupify(url: str) -> BeautifulSoup:
|
21
30
|
"""Fetch and parse a webpage and return BeautifulSoup object
|
22
31
|
"""
|
23
|
-
response = requests.get(url, timeout=
|
32
|
+
response = requests.get(url, timeout=REQUEST_TIMEOUT, headers=HTML_HEADERS)
|
24
33
|
response.raise_for_status()
|
25
34
|
return BeautifulSoup(response.content, 'html.parser')
|
26
35
|
|
@@ -43,6 +52,9 @@ def _soupify_horse_page(horse_no: str) -> BeautifulSoup:
|
|
43
52
|
def _classify_running_style(df: pl.DataFrame, running_pos_col="RunningPosition") -> pl.DataFrame:
|
44
53
|
"""Classify running style based on RunningPosition column
|
45
54
|
"""
|
55
|
+
if df.height == 0:
|
56
|
+
return df
|
57
|
+
|
46
58
|
# Split the RunningPosition column into separate columns and convert to integers
|
47
59
|
df = df.with_columns(
|
48
60
|
pl.col(running_pos_col)
|
@@ -77,9 +89,9 @@ def _extract_horse_data(horse_no: str) -> pl.DataFrame:
|
|
77
89
|
table = soup.find('table', class_='bigborder')
|
78
90
|
horse_data = _parse_html_table(table).filter(
|
79
91
|
pl.col('Date') != '') # Remove empty rows
|
80
|
-
horse_data
|
81
|
-
|
82
|
-
|
92
|
+
if horse_data.height > 0:
|
93
|
+
horse_data = _classify_running_style(horse_data)
|
94
|
+
horse_data = horse_data.with_columns(pl.lit(horse_no).alias('HorseNo'))
|
83
95
|
|
84
96
|
return horse_data
|
85
97
|
|
@@ -87,6 +99,9 @@ def _extract_horse_data(horse_no: str) -> pl.DataFrame:
|
|
87
99
|
def _clean_horse_data(df: pl.DataFrame) -> pl.DataFrame:
|
88
100
|
""" Clean and convert horse data to suitable data types
|
89
101
|
"""
|
102
|
+
if df.height == 0:
|
103
|
+
return df
|
104
|
+
|
90
105
|
df = df.with_columns(
|
91
106
|
pl.col('Pla').str.split(' ').list.first().alias('Pla')
|
92
107
|
).filter(~pl.col('Pla').is_in(incidents))
|
@@ -105,7 +120,8 @@ def _clean_horse_data(df: pl.DataFrame) -> pl.DataFrame:
|
|
105
120
|
.with_columns(
|
106
121
|
(
|
107
122
|
pl.col("FinishTime").str.splitn(".", 2).struct.field("field_0").cast(pl.Int64) * 60 +
|
108
|
-
pl.col("FinishTime").str.splitn(
|
123
|
+
pl.col("FinishTime").str.splitn(
|
124
|
+
".", 2).struct.field("field_1").cast(pl.Float64)
|
109
125
|
).cast(pl.Float64).round(2).alias("FinishTime")
|
110
126
|
))
|
111
127
|
|
@@ -134,6 +150,9 @@ def get_horse_data(horse_no: str) -> pl.DataFrame:
|
|
134
150
|
def _clean_race_data(df: pl.DataFrame) -> pl.DataFrame:
|
135
151
|
""" Clean and convert horse data to suitable data types
|
136
152
|
"""
|
153
|
+
if df.height == 0:
|
154
|
+
return df
|
155
|
+
|
137
156
|
df = df.with_columns(
|
138
157
|
pl.col('Pla').str.split(' ').list.first().alias('Pla')
|
139
158
|
).filter(~pl.col('Pla').is_in(incidents))
|
@@ -150,7 +169,8 @@ def _clean_race_data(df: pl.DataFrame) -> pl.DataFrame:
|
|
150
169
|
df = df.with_columns(
|
151
170
|
(
|
152
171
|
pl.col("FinishTime").str.splitn(":", 2).struct.field("field_0").cast(pl.Int64) * 60 +
|
153
|
-
pl.col("FinishTime").str.splitn(
|
172
|
+
pl.col("FinishTime").str.splitn(
|
173
|
+
":", 2).struct.field("field_1").cast(pl.Float64)
|
154
174
|
).cast(pl.Float64).round(2).alias("FinishTime")
|
155
175
|
)
|
156
176
|
|
@@ -7,6 +7,8 @@ import requests
|
|
7
7
|
from cachetools.func import ttl_cache
|
8
8
|
import numpy as np
|
9
9
|
|
10
|
+
from .utils import _try_int
|
11
|
+
|
10
12
|
HKJC_LIVEODDS_ENDPOINT = "https://info.cld.hkjc.com/graphql/base/"
|
11
13
|
|
12
14
|
RACEMTG_PAYLOAD = {
|
@@ -241,25 +243,29 @@ query racing($date: String, $venueCode: String, $oddsTypes: [OddsType], $raceNo:
|
|
241
243
|
}""",
|
242
244
|
}
|
243
245
|
|
246
|
+
JSON_HEADERS = {
|
247
|
+
"Origin": "https://bet.hkjc.com",
|
248
|
+
"Referer": "https://bet.hkjc.com",
|
249
|
+
"Content-Type": "application/json",
|
250
|
+
"Accept": "application/json",
|
251
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
|
252
|
+
}
|
253
|
+
|
254
|
+
REQUEST_TIMEOUT = 30
|
255
|
+
|
244
256
|
|
245
257
|
@ttl_cache(maxsize=12, ttl=1000)
|
246
|
-
def _fetch_live_races(date: str=None, venue_code: str=None) -> dict:
|
258
|
+
def _fetch_live_races(date: str = None, venue_code: str = None) -> dict:
|
247
259
|
"""Fetch live race data from HKJC GraphQL endpoint."""
|
248
260
|
payload = RACEMTG_PAYLOAD.copy()
|
249
261
|
payload["variables"] = payload["variables"].copy()
|
250
262
|
payload["variables"]["date"] = date
|
251
263
|
payload["variables"]["venueCode"] = venue_code
|
252
264
|
|
253
|
-
headers =
|
254
|
-
"Origin": "https://bet.hkjc.com",
|
255
|
-
"Referer": "https://bet.hkjc.com",
|
256
|
-
"Content-Type": "application/json",
|
257
|
-
"Accept": "application/json",
|
258
|
-
"User-Agent": "python-hkjc-fetch/0.1",
|
259
|
-
}
|
265
|
+
headers = JSON_HEADERS
|
260
266
|
|
261
267
|
r = requests.post(HKJC_LIVEODDS_ENDPOINT, json=payload,
|
262
|
-
headers=headers, timeout=
|
268
|
+
headers=headers, timeout=REQUEST_TIMEOUT)
|
263
269
|
if r.status_code != 200:
|
264
270
|
raise RuntimeError(f"Request failed: {r.status_code} - {r.text}")
|
265
271
|
|
@@ -279,9 +285,9 @@ def _fetch_live_races(date: str=None, venue_code: str=None) -> dict:
|
|
279
285
|
runners = [{'No': runner['no'],
|
280
286
|
'Name': runner['name_en'],
|
281
287
|
'Dr': runner['barrierDrawNumber'],
|
282
|
-
'Rtg':
|
283
|
-
'Wt':
|
284
|
-
'Handicap':
|
288
|
+
'Rtg': _try_int(runner['currentRating']),
|
289
|
+
'Wt': _try_int(runner['currentWeight']),
|
290
|
+
'Handicap': _try_int(runner['handicapWeight']),
|
285
291
|
'HorseNo': runner['horse']['code']
|
286
292
|
} for runner in race['runners'] if runner['status'] != "Standby"]
|
287
293
|
race_info['Races'][race_num] = {
|
@@ -307,16 +313,10 @@ def _fetch_live_odds(date: str, venue_code: str, race_number: int, odds_type: Tu
|
|
307
313
|
payload["variables"]["raceNo"] = race_number
|
308
314
|
payload["variables"]["oddsTypes"] = odds_type
|
309
315
|
|
310
|
-
headers =
|
311
|
-
"Origin": "https://bet.hkjc.com",
|
312
|
-
"Referer": "https://bet.hkjc.com",
|
313
|
-
"Content-Type": "application/json",
|
314
|
-
"Accept": "application/json",
|
315
|
-
"User-Agent": "python-hkjc-fetch/0.1",
|
316
|
-
}
|
316
|
+
headers = JSON_HEADERS
|
317
317
|
|
318
318
|
r = requests.post(HKJC_LIVEODDS_ENDPOINT, json=payload,
|
319
|
-
headers=headers, timeout=
|
319
|
+
headers=headers, timeout=REQUEST_TIMEOUT)
|
320
320
|
if r.status_code != 200:
|
321
321
|
raise RuntimeError(f"Request failed: {r.status_code} - {r.text}")
|
322
322
|
|
@@ -354,7 +354,8 @@ def live_odds(date: str, venue_code: str, race_number: int, odds_type: List[str]
|
|
354
354
|
N = len(race_info['Races'][race_number]['Runners'])
|
355
355
|
|
356
356
|
if (race_info['Date'] != date) or (race_info['Venue'] != venue_code):
|
357
|
-
print(
|
357
|
+
print(
|
358
|
+
f"[WARNING] Requested {date} {venue_code} but server returned {race_info['Date']} {race_info['Venue']}.")
|
358
359
|
date = race_info['Date']
|
359
360
|
venue_code = race_info['Venue']
|
360
361
|
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|