hkjc 0.3.24__tar.gz → 0.4.1__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.24 → hkjc-0.4.1}/PKG-INFO +1 -1
- hkjc-0.4.1/dashboard/app.py +225 -0
- hkjc-0.4.1/dashboard/static/app.js +43 -0
- hkjc-0.4.1/dashboard/static/core.js +201 -0
- hkjc-0.4.1/dashboard/static/race-data.js +481 -0
- hkjc-0.4.1/dashboard/static/race-ui.js +379 -0
- hkjc-0.4.1/dashboard/static/script.js +1469 -0
- {hkjc-0.3.24 → hkjc-0.4.1}/dashboard/static/styles.css +310 -10
- hkjc-0.4.1/dashboard/static/trade-calculator.js +370 -0
- hkjc-0.4.1/dashboard/templates/index.html +259 -0
- {hkjc-0.3.24 → hkjc-0.4.1}/pyproject.toml +1 -1
- {hkjc-0.3.24 → hkjc-0.4.1}/src/hkjc/processing.py +7 -20
- {hkjc-0.3.24 → hkjc-0.4.1}/uv.lock +1 -1
- hkjc-0.3.24/dashboard/app.py +0 -133
- hkjc-0.3.24/dashboard/static/script.js +0 -997
- hkjc-0.3.24/dashboard/templates/index.html +0 -166
- {hkjc-0.3.24 → hkjc-0.4.1}/.gitignore +0 -0
- {hkjc-0.3.24 → hkjc-0.4.1}/.python-version +0 -0
- {hkjc-0.3.24 → hkjc-0.4.1}/README.md +0 -0
- {hkjc-0.3.24 → hkjc-0.4.1}/dashboard/templates/horse-info.html +0 -0
- {hkjc-0.3.24 → hkjc-0.4.1}/src/hkjc/__init__.py +0 -0
- {hkjc-0.3.24 → hkjc-0.4.1}/src/hkjc/features.py +0 -0
- {hkjc-0.3.24 → hkjc-0.4.1}/src/hkjc/harville_model.py +0 -0
- {hkjc-0.3.24 → hkjc-0.4.1}/src/hkjc/historical.py +0 -0
- {hkjc-0.3.24 → hkjc-0.4.1}/src/hkjc/live.py +0 -0
- {hkjc-0.3.24 → hkjc-0.4.1}/src/hkjc/py.typed +0 -0
- {hkjc-0.3.24 → hkjc-0.4.1}/src/hkjc/speedpro.py +0 -0
- {hkjc-0.3.24 → hkjc-0.4.1}/src/hkjc/strategy/place_only.py +0 -0
- {hkjc-0.3.24 → hkjc-0.4.1}/src/hkjc/strategy/qpbanker.py +0 -0
- {hkjc-0.3.24 → hkjc-0.4.1}/src/hkjc/utils.py +0 -0
@@ -0,0 +1,225 @@
|
|
1
|
+
from flask import Flask, jsonify, render_template, request, make_response
|
2
|
+
from flask_caching import Cache
|
3
|
+
|
4
|
+
import polars as pl
|
5
|
+
import numpy as np
|
6
|
+
|
7
|
+
from hkjc.live import live_odds, _fetch_live_races
|
8
|
+
from hkjc.harville_model import fit_harville_to_odds
|
9
|
+
from hkjc.historical import get_horse_data
|
10
|
+
from hkjc.speedpro import speedmap, speedpro_energy
|
11
|
+
from hkjc.strategy import qpbanker, place_only
|
12
|
+
from hkjc import generate_all_qp_trades, generate_all_pla_trades, pareto_filter
|
13
|
+
|
14
|
+
|
15
|
+
def arr_to_dict(arr: np.ndarray, dtype=float):
|
16
|
+
"""Convert 0-indexed numpy array into 1-indexed nested dictionary
|
17
|
+
|
18
|
+
Args:
|
19
|
+
arr (np.ndarray): 0-indexed numpy array
|
20
|
+
dtype (type, optional): data type. Defaults to float.
|
21
|
+
|
22
|
+
Returns:
|
23
|
+
dict: 1-indexed nested dictionary
|
24
|
+
"""
|
25
|
+
if arr.ndim == 1:
|
26
|
+
return {i+1: dtype(np.round(v, 1)) for i, v in enumerate(arr) if not (np.isnan(v) or np.isinf(v))}
|
27
|
+
|
28
|
+
return {i+1: arr_to_dict(v) for i, v in enumerate(arr)}
|
29
|
+
|
30
|
+
|
31
|
+
app = Flask(__name__)
|
32
|
+
config = {
|
33
|
+
"CACHE_TYPE": "RedisCache",
|
34
|
+
"CACHE_REDIS_HOST": "localhost",
|
35
|
+
"CACHE_REDIS_PORT": "6379"
|
36
|
+
}
|
37
|
+
app.config.from_mapping(config)
|
38
|
+
cache = Cache(app)
|
39
|
+
|
40
|
+
|
41
|
+
@app.route('/')
|
42
|
+
def disp_race_info():
|
43
|
+
race_info = _fetch_live_races('', '')
|
44
|
+
|
45
|
+
try:
|
46
|
+
df_speedpro = speedpro_energy(race_info['Date'])
|
47
|
+
for race_num, race in race_info['Races'].items():
|
48
|
+
for i, runner in enumerate(race['Runners']):
|
49
|
+
df = (df_speedpro
|
50
|
+
.filter(pl.col('RaceNo') == race_num)
|
51
|
+
.filter(pl.col('RunnerNumber') == int(runner['No']))
|
52
|
+
)
|
53
|
+
race_info['Races'][race_num]['Runners'][i]['SPEnergy'] = df['SpeedPRO_Energy_Difference'].item(
|
54
|
+
0)
|
55
|
+
race_info['Races'][race_num]['Runners'][i]['Fitness'] = df['FitnessRatings'].item(
|
56
|
+
0)
|
57
|
+
except: # fill with dummy value if SpeedPro not available
|
58
|
+
for race_num, race in race_info['Races'].items():
|
59
|
+
for i, runner in enumerate(race['Runners']):
|
60
|
+
race_info['Races'][race_num]['Runners'][i]['SPEnergy'] = 0
|
61
|
+
race_info['Races'][race_num]['Runners'][i]['Fitness'] = 0
|
62
|
+
|
63
|
+
return render_template('index.html',
|
64
|
+
race_info=race_info)
|
65
|
+
|
66
|
+
|
67
|
+
turf_going_dict = {'FIRM': 'F',
|
68
|
+
'GOOD TO FIRM': 'GF',
|
69
|
+
'GOOD': 'G',
|
70
|
+
'GOOD TO YIELDING': 'GY',
|
71
|
+
'YIELDING': 'Y',
|
72
|
+
'YIELDING TO SOFT': 'YS',
|
73
|
+
'SOFT': 'S',
|
74
|
+
'HEAVY': 'H'}
|
75
|
+
aw_going_dict = {'WET FAST': 'WF',
|
76
|
+
'FAST': 'FT',
|
77
|
+
'GOOD': 'GD',
|
78
|
+
'SLOW': 'SL',
|
79
|
+
'WET SLOW': 'WS',
|
80
|
+
'RAIN AFFECTED': 'RA',
|
81
|
+
'NORMAL WATERING': 'NW'}
|
82
|
+
going_dict = {'TURF': turf_going_dict, 'ALL WEATHER TRACK': aw_going_dict}
|
83
|
+
|
84
|
+
|
85
|
+
@app.route('/horse_info/<horse_no>', methods=['GET'])
|
86
|
+
# cache horse history for 1 day
|
87
|
+
@cache.cached(timeout=86400, query_string=True)
|
88
|
+
def disp_horse_info(horse_no):
|
89
|
+
# read optional filters
|
90
|
+
dist = request.args.get('dist', type=int)
|
91
|
+
track = request.args.get('track')
|
92
|
+
going = request.args.get('going')
|
93
|
+
venue = request.args.get('venue')
|
94
|
+
|
95
|
+
if track not in going_dict.keys():
|
96
|
+
track = None
|
97
|
+
if venue not in ['HV', 'ST']:
|
98
|
+
venue = None
|
99
|
+
if (going is not None) and (track is not None) and (going in going_dict[track].keys()):
|
100
|
+
going = going_dict[track][going] # translate going to code
|
101
|
+
else:
|
102
|
+
going = None
|
103
|
+
|
104
|
+
df = get_horse_data(horse_no)
|
105
|
+
|
106
|
+
if df.height > 0:
|
107
|
+
if dist is not None:
|
108
|
+
df = df.filter(pl.col('Dist') == dist)
|
109
|
+
if track and track.upper() == 'TURF':
|
110
|
+
df = df.filter(pl.col('Track') == 'Turf')
|
111
|
+
elif track and track.upper() == 'ALL WEATHER TRACK':
|
112
|
+
df = df.filter(pl.col('Track') == 'AWT')
|
113
|
+
if going is not None:
|
114
|
+
df = df.filter(pl.col('G').str.starts_with(going[0]))
|
115
|
+
if venue is not None:
|
116
|
+
df = df.filter(pl.col('Venue') == venue)
|
117
|
+
|
118
|
+
return render_template('horse-info.html', df=df.head(5))
|
119
|
+
|
120
|
+
|
121
|
+
@app.route('/live_odds/<int:race_no>')
|
122
|
+
def disp_live_odds(race_no=1):
|
123
|
+
odds_dict = live_odds('', '', race_no)
|
124
|
+
fitted_odds = fit_harville_to_odds(odds_dict)['odds_fit']
|
125
|
+
odds_json = {'Raw': {k: arr_to_dict(v) for k, v in odds_dict.items()},
|
126
|
+
'Fit': {k: arr_to_dict(v) for k, v in fitted_odds.items()}
|
127
|
+
}
|
128
|
+
|
129
|
+
return jsonify(odds_json)
|
130
|
+
|
131
|
+
|
132
|
+
@app.route('/speedmap/<int:race_no>')
|
133
|
+
def disp_speedmap(race_no=1):
|
134
|
+
return speedmap(race_no)
|
135
|
+
|
136
|
+
|
137
|
+
@app.route('/qp/<int:race_no>/<int:banker>/<cover>', methods=['GET'])
|
138
|
+
def disp_qp_metrics(race_no=1, banker=1, cover='2'):
|
139
|
+
use_filter = request.args.get('filter')
|
140
|
+
use_filter = use_filter and (use_filter.lower() == 'true')
|
141
|
+
|
142
|
+
odds = live_odds('', '', race_number=race_no)
|
143
|
+
|
144
|
+
if use_filter:
|
145
|
+
res = fit_harville_to_odds(odds)
|
146
|
+
if res['success']:
|
147
|
+
odds['PLA'] = res['odds_fit']['PLA']
|
148
|
+
covered = [int(v) for v in cover.split(',') if int(v) != banker]
|
149
|
+
ev = qpbanker.expected_value(odds['PLA'], odds['QPL'], banker, covered)
|
150
|
+
win = qpbanker.win_probability(odds['PLA'], banker, covered)
|
151
|
+
avg_odds = qpbanker.average_odds(odds['QPL'], banker, covered)
|
152
|
+
|
153
|
+
return {'Banker': banker, 'Covered': covered, 'ExpValue': round(ev, 2), 'WinProb': round(win, 2), 'AvgOdds': round(avg_odds, 2)}
|
154
|
+
|
155
|
+
@app.route('/pla/<int:race_no>/<cover>')
|
156
|
+
def disp_pla_metrics(race_no=1, cover=[]):
|
157
|
+
odds = live_odds('', '', race_number=race_no)
|
158
|
+
|
159
|
+
res = fit_harville_to_odds(odds)
|
160
|
+
if not res['success']:
|
161
|
+
raise RuntimeError(
|
162
|
+
f"[ERROR] Harville model fitting failed: {res.get('message','')}")
|
163
|
+
|
164
|
+
covered = [int(v) for v in cover.split(',')]
|
165
|
+
ev = place_only.expected_value(odds['PLA'], res['prob_fit']['P'], covered)
|
166
|
+
win = place_only.win_probability(res['prob_fit']['P'], covered)
|
167
|
+
avg_odds = place_only.average_odds(odds['PLA'], covered)
|
168
|
+
|
169
|
+
return {'Covered': covered, 'ExpValue': round(ev, 2), 'WinProb': round(win, 2), 'AvgOdds': round(avg_odds, 2)}
|
170
|
+
|
171
|
+
def elimination(lst):
|
172
|
+
cond = [~pl.col('Covered').list.contains(l) for l in lst]
|
173
|
+
return pl.all_horizontal(cond)
|
174
|
+
|
175
|
+
|
176
|
+
def format_trade(trade):
|
177
|
+
return {'Banker': trade.get('Banker',None),
|
178
|
+
'Covered': trade['Covered'],
|
179
|
+
'WinProb': round(trade['WinProb'], 2),
|
180
|
+
'ExpValue': round(trade['ExpValue'], 2),
|
181
|
+
'AvgOdds': round(trade['AvgOdds'], 2)}
|
182
|
+
|
183
|
+
|
184
|
+
@app.route('/qprec/<int:race_no>/<int:banker>', methods=['GET'])
|
185
|
+
def disp_qp_recs(race_no=1, banker=1, exclude=[], maxC=None):
|
186
|
+
use_filter = request.args.get('filter')
|
187
|
+
use_filter = use_filter and (use_filter.lower() == 'true')
|
188
|
+
exclude = request.args.get('exclude')
|
189
|
+
if exclude:
|
190
|
+
excluded = [int(v) for v in exclude.split(',')]
|
191
|
+
maxC = request.args.get('maxC', type=int)
|
192
|
+
|
193
|
+
df_trades = generate_all_qp_trades(
|
194
|
+
'', '', race_no, fit_harville=use_filter)
|
195
|
+
df_trades = df_trades.filter(pl.col('Banker') == banker)
|
196
|
+
df_trades = df_trades.filter(pl.col('ExpValue') >= 0.05)
|
197
|
+
if exclude:
|
198
|
+
df_trades = df_trades.filter(elimination(excluded))
|
199
|
+
if maxC:
|
200
|
+
df_trades = df_trades.filter(pl.col('NumCovered') <= maxC)
|
201
|
+
pareto_trades = pareto_filter(df_trades, ['NumCovered'], ['WinProb', 'ExpValue']).sort(
|
202
|
+
'WinProb', descending=True)
|
203
|
+
|
204
|
+
return [format_trade(t) for t in pareto_trades.iter_rows(named=True)]
|
205
|
+
|
206
|
+
|
207
|
+
|
208
|
+
|
209
|
+
@app.route('/plarec/<int:race_no>', methods=['GET'])
|
210
|
+
def disp_pla_recs(race_no=1, exclude=[], maxC=None):
|
211
|
+
exclude = request.args.get('exclude')
|
212
|
+
if exclude:
|
213
|
+
excluded = [int(v) for v in exclude.split(',')]
|
214
|
+
maxC = request.args.get('maxC', type=int)
|
215
|
+
|
216
|
+
df_trades = generate_all_pla_trades('', '', race_no)
|
217
|
+
df_trades = df_trades.filter(pl.col('ExpValue') >= 0.05)
|
218
|
+
if exclude:
|
219
|
+
df_trades = df_trades.filter(elimination(excluded))
|
220
|
+
if maxC:
|
221
|
+
df_trades = df_trades.filter(pl.col('NumCovered') <= maxC)
|
222
|
+
pareto_trades = pareto_filter(df_trades, ['NumCovered'], ['WinProb', 'ExpValue']).sort(
|
223
|
+
'WinProb', descending=True)
|
224
|
+
|
225
|
+
return [format_trade(t) for t in pareto_trades.iter_rows(named=True)]
|
@@ -0,0 +1,43 @@
|
|
1
|
+
// HKJC Race Info - Application Bootstrap
|
2
|
+
const App = {
|
3
|
+
init() {
|
4
|
+
console.log('Initializing HKJC Race Info...');
|
5
|
+
const savedRaceTab = sessionStorage.getItem('activeRaceTab');
|
6
|
+
if (savedRaceTab && Utils.$(`#tab-${savedRaceTab}`)) UI.showRace(savedRaceTab);
|
7
|
+
|
8
|
+
const activeRaceNum = Utils.$('.race-content.active')?.id.replace('race-', '');
|
9
|
+
Speedmap.loadAll();
|
10
|
+
FavStyleChart.initAll();
|
11
|
+
PreferredHighlight.initAll();
|
12
|
+
TradeCalculator.initAll();
|
13
|
+
Odds.startPolling();
|
14
|
+
RunnerDetails.prefetchAll(activeRaceNum).catch(error =>
|
15
|
+
console.error('Error in background prefetch:', error));
|
16
|
+
console.log('Application initialized successfully');
|
17
|
+
},
|
18
|
+
|
19
|
+
cleanup() {
|
20
|
+
Odds.stopPolling();
|
21
|
+
RequestQueue.clear();
|
22
|
+
Cache.clear();
|
23
|
+
}
|
24
|
+
};
|
25
|
+
|
26
|
+
window.showRace = (raceNum) => UI.showRace(raceNum);
|
27
|
+
window.toggleRunnerHistory = (row, raceNum, horseNo, going, track, dist) =>
|
28
|
+
RunnerDetails.toggle(row, raceNum, horseNo, going, track, dist);
|
29
|
+
window.toggleAllRunners = (raceNum, btn) => RunnerDetails.toggleAll(raceNum, btn);
|
30
|
+
window.sortTable = (raceNum, colIdx, type) => UI.sortTable(raceNum, colIdx, type);
|
31
|
+
window.openTrackWorkoutVideo = (date, raceNum) => UI.openTrackWorkoutVideo(date, raceNum);
|
32
|
+
window.openTipsIndex = (raceNum) => UI.openTipsIndex(raceNum);
|
33
|
+
window.showTradeTab = (raceNum, tabName) => TradeCalculator.showTab(raceNum, tabName);
|
34
|
+
window.toggleParetoExclude = (raceNum, type, runnerNo) => TradeCalculator.toggleParetoExclude(raceNum, type, runnerNo);
|
35
|
+
window.toggleCover = (raceNum, type, runnerNo) => TradeCalculator.toggleCover(raceNum, type, runnerNo);
|
36
|
+
window.selectBanker = (raceNum, runnerNo) => TradeCalculator.selectBanker(raceNum, runnerNo);
|
37
|
+
window.toggleFilter = (raceNum) => TradeCalculator.toggleFilter(raceNum);
|
38
|
+
window.calculatePLA = (raceNum) => TradeCalculator.calculatePLA(raceNum);
|
39
|
+
window.calculateQPL = (raceNum) => TradeCalculator.calculateQPL(raceNum);
|
40
|
+
|
41
|
+
document.addEventListener('DOMContentLoaded', () => App.init());
|
42
|
+
window.addEventListener('beforeunload', () => App.cleanup());
|
43
|
+
|
@@ -0,0 +1,201 @@
|
|
1
|
+
// HKJC Race Info - Core Infrastructure
|
2
|
+
const CONFIG = {
|
3
|
+
ODDS_POLL_INTERVAL: 90000,
|
4
|
+
POLL_JITTER: 10000,
|
5
|
+
MIN_REQUEST_DELAY: 80,
|
6
|
+
MAX_REQUEST_DELAY: 250,
|
7
|
+
MAX_CONCURRENT_REQUESTS: 3,
|
8
|
+
REQUEST_TIMEOUT: 10000,
|
9
|
+
HIGHLIGHT_DEBOUNCE_DELAY: 100,
|
10
|
+
DEBUG: false
|
11
|
+
};
|
12
|
+
|
13
|
+
const Utils = {
|
14
|
+
getRunnerData(row) {
|
15
|
+
return {
|
16
|
+
raceNum: row.dataset.race,
|
17
|
+
horseNo: row.dataset.horse,
|
18
|
+
going: row.dataset.going || '',
|
19
|
+
track: row.dataset.track || '',
|
20
|
+
distance: row.dataset.dist || '',
|
21
|
+
venue: row.dataset.venue || '',
|
22
|
+
runnerNo: row.querySelector('.odds-win')?.dataset.horse || row.dataset.horse
|
23
|
+
};
|
24
|
+
},
|
25
|
+
|
26
|
+
log(...args) { if (CONFIG.DEBUG) console.log(...args); },
|
27
|
+
|
28
|
+
$(selector, context = document) { return context.querySelector(selector); },
|
29
|
+
$$(selector, context = document) { return Array.from(context.querySelectorAll(selector)); },
|
30
|
+
|
31
|
+
addClass(elements, className) {
|
32
|
+
const classes = Array.isArray(className) ? className : [className];
|
33
|
+
const els = Array.isArray(elements) ? elements : [elements];
|
34
|
+
els.forEach(el => el && classes.forEach(cls => el.classList.add(cls)));
|
35
|
+
},
|
36
|
+
|
37
|
+
removeClass(elements, className) {
|
38
|
+
const classes = Array.isArray(className) ? className : [className];
|
39
|
+
const els = Array.isArray(elements) ? elements : [elements];
|
40
|
+
els.forEach(el => el && classes.forEach(cls => el.classList.remove(cls)));
|
41
|
+
},
|
42
|
+
|
43
|
+
toggleClass(elements, className, force) {
|
44
|
+
const els = Array.isArray(elements) ? elements : [elements];
|
45
|
+
els.forEach(el => el?.classList.toggle(className, force));
|
46
|
+
},
|
47
|
+
|
48
|
+
parseNum(value, fallback = 0) {
|
49
|
+
const num = parseFloat(value);
|
50
|
+
return isNaN(num) ? fallback : num;
|
51
|
+
},
|
52
|
+
|
53
|
+
makeCacheKey(...parts) {
|
54
|
+
return parts.join('-');
|
55
|
+
},
|
56
|
+
|
57
|
+
validateRaceNum(raceNum) {
|
58
|
+
const num = parseInt(raceNum);
|
59
|
+
if (isNaN(num) || num < 1 || num > 99) throw new Error(`Invalid race number: ${raceNum}`);
|
60
|
+
return num.toString();
|
61
|
+
},
|
62
|
+
|
63
|
+
validateHorseNo(horseNo) {
|
64
|
+
if (!horseNo || typeof horseNo !== 'string' || !horseNo.trim())
|
65
|
+
throw new Error(`Invalid horse number: ${horseNo}`);
|
66
|
+
return horseNo.trim();
|
67
|
+
},
|
68
|
+
|
69
|
+
validateDate(dateStr) {
|
70
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr))
|
71
|
+
throw new Error(`Invalid date format: ${dateStr}. Expected YYYY-MM-DD`);
|
72
|
+
return dateStr;
|
73
|
+
}
|
74
|
+
};
|
75
|
+
|
76
|
+
const State = {
|
77
|
+
caches: { runnerDetails: new Map(), speedmaps: new Map(), odds: new Map() },
|
78
|
+
polling: { oddsIntervalId: null },
|
79
|
+
raceReadiness: new Map()
|
80
|
+
};
|
81
|
+
|
82
|
+
const RequestQueue = {
|
83
|
+
queue: [],
|
84
|
+
activeCount: 0,
|
85
|
+
|
86
|
+
enqueue(requestFn, priority = 0) {
|
87
|
+
return new Promise((resolve, reject) => {
|
88
|
+
this.queue.push({ requestFn, resolve, reject, priority });
|
89
|
+
this.queue.sort((a, b) => b.priority - a.priority);
|
90
|
+
this.process();
|
91
|
+
});
|
92
|
+
},
|
93
|
+
|
94
|
+
async process() {
|
95
|
+
if (this.activeCount >= CONFIG.MAX_CONCURRENT_REQUESTS || !this.queue.length) return;
|
96
|
+
|
97
|
+
const { requestFn, resolve, reject } = this.queue.shift();
|
98
|
+
this.activeCount++;
|
99
|
+
|
100
|
+
try {
|
101
|
+
resolve(await requestFn());
|
102
|
+
} catch (error) {
|
103
|
+
reject(error);
|
104
|
+
} finally {
|
105
|
+
this.activeCount--;
|
106
|
+
await new Promise(r => setTimeout(r,
|
107
|
+
Math.random() * (CONFIG.MAX_REQUEST_DELAY - CONFIG.MIN_REQUEST_DELAY) + CONFIG.MIN_REQUEST_DELAY));
|
108
|
+
this.process();
|
109
|
+
}
|
110
|
+
},
|
111
|
+
|
112
|
+
clear() { this.queue = []; }
|
113
|
+
};
|
114
|
+
|
115
|
+
const API = {
|
116
|
+
async fetch(url, options = {}) {
|
117
|
+
const controller = new AbortController();
|
118
|
+
const timeoutId = setTimeout(() => controller.abort(), CONFIG.REQUEST_TIMEOUT);
|
119
|
+
|
120
|
+
try {
|
121
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
122
|
+
clearTimeout(timeoutId);
|
123
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
124
|
+
return response;
|
125
|
+
} catch (error) {
|
126
|
+
clearTimeout(timeoutId);
|
127
|
+
if (error.name === 'AbortError') throw new Error('Request timeout');
|
128
|
+
throw error;
|
129
|
+
}
|
130
|
+
},
|
131
|
+
|
132
|
+
fetchOdds(raceNum) {
|
133
|
+
return RequestQueue.enqueue(async () =>
|
134
|
+
(await this.fetch(`/live_odds/${Utils.validateRaceNum(raceNum)}`)).json(), 1);
|
135
|
+
},
|
136
|
+
|
137
|
+
fetchHorseHistory(horseNo, going, track, distance, venue) {
|
138
|
+
return RequestQueue.enqueue(async () => {
|
139
|
+
const params = new URLSearchParams({ going: going || '', track: track || '', dist: distance || '', venue: venue || '' });
|
140
|
+
return (await this.fetch(`/horse_info/${Utils.validateHorseNo(horseNo)}?${params}`)).text();
|
141
|
+
}, 0);
|
142
|
+
},
|
143
|
+
|
144
|
+
fetchSpeedmap(raceNum) {
|
145
|
+
return RequestQueue.enqueue(async () =>
|
146
|
+
(await this.fetch(`/speedmap/${Utils.validateRaceNum(raceNum)}`)).text(), 0);
|
147
|
+
},
|
148
|
+
|
149
|
+
fetchParetoData(raceNum, exclude = '', abortSignal = null) {
|
150
|
+
return RequestQueue.enqueue(async () => {
|
151
|
+
const url = `/plarec/${Utils.validateRaceNum(raceNum)}${exclude ? `?exclude=${exclude}` : ''}`;
|
152
|
+
return (await this.fetch(url, { signal: abortSignal })).json();
|
153
|
+
}, 1);
|
154
|
+
},
|
155
|
+
|
156
|
+
fetchQParetoData(raceNum, banker, exclude = '', filter = false, abortSignal = null) {
|
157
|
+
return RequestQueue.enqueue(async () => {
|
158
|
+
const url = `/qprec/${Utils.validateRaceNum(raceNum)}/${banker}?exclude=${exclude}&filter=${filter}`;
|
159
|
+
return (await this.fetch(url, { signal: abortSignal })).json();
|
160
|
+
}, 1);
|
161
|
+
},
|
162
|
+
|
163
|
+
fetchPLAData(raceNum, cover) {
|
164
|
+
return RequestQueue.enqueue(async () =>
|
165
|
+
(await this.fetch(`/pla/${Utils.validateRaceNum(raceNum)}/${cover}`)).json(), 0);
|
166
|
+
},
|
167
|
+
|
168
|
+
fetchQPLData(raceNum, banker, cover, filter) {
|
169
|
+
return RequestQueue.enqueue(async () => {
|
170
|
+
const url = `/qp/${Utils.validateRaceNum(raceNum)}/${banker}/${cover}?filter=${filter}`;
|
171
|
+
return (await this.fetch(url)).json();
|
172
|
+
}, 0);
|
173
|
+
}
|
174
|
+
};
|
175
|
+
|
176
|
+
const Cache = {
|
177
|
+
get(cacheName, key) { return State.caches[cacheName]?.get(key); },
|
178
|
+
set(cacheName, key, value) {
|
179
|
+
if (!State.caches[cacheName]) State.caches[cacheName] = new Map();
|
180
|
+
State.caches[cacheName].set(key, value);
|
181
|
+
},
|
182
|
+
has(cacheName, key) { return State.caches[cacheName]?.has(key) || false; },
|
183
|
+
clear(cacheName) {
|
184
|
+
cacheName ? State.caches[cacheName]?.clear() : Object.values(State.caches).forEach(c => c.clear());
|
185
|
+
}
|
186
|
+
};
|
187
|
+
|
188
|
+
const RaceReadiness = {
|
189
|
+
isReady(raceNum) {
|
190
|
+
if (State.raceReadiness.has(raceNum)) return State.raceReadiness.get(raceNum);
|
191
|
+
const runnerRows = Utils.$$(`#race-${raceNum} .runner-row`);
|
192
|
+
const ready = runnerRows.length > 0 && runnerRows.some(row => {
|
193
|
+
const fitness = Utils.parseNum(row.cells[10]?.textContent.trim());
|
194
|
+
const energy = Utils.parseNum(row.cells[11]?.textContent.trim());
|
195
|
+
return fitness !== 0 || energy !== 0;
|
196
|
+
});
|
197
|
+
State.raceReadiness.set(raceNum, ready);
|
198
|
+
return ready;
|
199
|
+
}
|
200
|
+
};
|
201
|
+
|