Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New functions #39

Merged
merged 9 commits into from
Apr 17, 2023
Merged
93 changes: 90 additions & 3 deletions betfairutil/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
from bisect import bisect_left
from bisect import bisect_right
from copy import deepcopy
from math import asin
from math import cos
from math import radians
from math import sin
from math import sqrt
from pathlib import Path
from typing import (
Expand All @@ -16,6 +20,7 @@
Mapping,
Optional,
Sequence,
Set,
Tuple,
TYPE_CHECKING,
Union,
Expand Down Expand Up @@ -1445,6 +1450,7 @@
RACE_ID_PATTERN = re.compile(r"\d{8}\.\d{4}")
_INVERSE_GOLDEN_RATIO = 2.0 / (1 + sqrt(5.0))
_ORIGINAL_OPEN = open
_AVERAGE_EARTH_RADIUS_IN_METERS = 6371008.8


class _OpenMocker:
Expand Down Expand Up @@ -2061,13 +2067,15 @@ def get_final_market_definition_from_prices_file(
import orjson
import smart_open

market_definition = None
the_line = None
with smart_open.open(path_to_prices_file, "rb") as f:
for line in f:
if b"marketDefinition" in line:
market_definition = orjson.loads(line)["mc"][0]["marketDefinition"]
the_line = line

return market_definition
if the_line is not None:
market_definition = orjson.loads(the_line)["mc"][0]["marketDefinition"]
return market_definition


def create_market_definition_generator_from_prices_file(
Expand Down Expand Up @@ -2119,6 +2127,18 @@ def get_pre_event_volume_traded_from_prices_file(
return pre_event_volume_traded


def get_inplay_publish_time_from_prices_file(
path_to_prices_file: Union[str, Path], as_datetime: bool = False
) -> Optional[Union[int, datetime.datetime]]:
g = create_market_book_generator_from_prices_file(path_to_prices_file)
for market_book in g:
if market_book["inplay"]:
publish_time = market_book["publishTime"]
if as_datetime:
publish_time = publish_time_to_datetime(publish_time)
return publish_time


def _is_exchange_win_market(d: Dict[str, Any]) -> bool:
return d["marketType"] == "WIN" and d["marketId"].startswith("1.")

Expand Down Expand Up @@ -2194,6 +2214,12 @@ def get_race_distance_in_metres_from_race_card(
return distance_in_metres


def get_is_jump_from_race_card(race_card: Union[Dict[str, Any], str, Path]) -> bool:
race_card = _load_json_object(race_card)
race_type = race_card["race"]["raceType"]["full"]
return race_type in ("Chase", "Hurdle")


def get_win_market_id_from_race_card(
race_card: Union[Dict[str, Any], str, Path], as_integer: bool = False
) -> Optional[Union[int, str]]:
Expand Down Expand Up @@ -3052,3 +3078,64 @@ def random_from_positive_int(i: int):


random_from_event_id = random_from_positive_int


def calculate_haversine_distance_between_runners(
rrc_a: Dict[str, Any], rrc_b: Dict[str, Any]
) -> float:
"""
Given two rrc objects from the Betfair race stream, calculate the approximate distance
between the horses in metres using the haversine formula

:param rrc_a: A rrc object from the Betfair race stream as a dictionary
:param rrc_b: Another rrc object from the Betfair race stream as a dictionary
:return: The approximate distance between the two horses in metres
"""
delta_longitude = radians(rrc_a["long"]) - radians(rrc_b["long"])
delta_latitude = radians(rrc_a["lat"]) - radians(rrc_b["lat"])
d = (
sin(delta_latitude * 0.5) ** 2
+ cos(radians(rrc_a["lat"]))
* cos(radians(rrc_b["lat"]))
* sin(delta_longitude * 0.5) ** 2
)

return 2 * _AVERAGE_EARTH_RADIUS_IN_METERS * asin(sqrt(d))


def get_number_of_jumps_remaining(rc: Dict[str, Any]) -> Optional[int]:
"""
Given a race change object, work out how many jumps there are between the _leader_ and the
finishing line. If there are no jump locations present in the race change object, either
because this is a flat race or because it comes from older data that lacks the jump
locations, then None will be returned

:param rc: A Betfair race change object as a Python dictionary
:return: The number of jumps between the leader and the finishing line unless there are no
jump locations present in the race change object in which case None
"""
distance_remaining = (rc.get("rpc") or {}).get("prg")
jumps = (rc.get("rpc") or {}).get("J") or []
if distance_remaining is not None and len(jumps) > 0:
return sum(j["L"] < distance_remaining for j in jumps)


def get_race_leaders(rc: Dict[str, Any]) -> Set[int]:
"""
Given a race change object, return the set of selection IDs of horses which are closest to
the finishing line

:param rc: A Betfair race change object as a Python dictionary
:return: A set containing the selection IDs corresponding to the horses which are in the
lead. The size of the set may exceed 1 if multiple horses are tied for the lead
"""
distances_remaining = sorted(rrc["prg"] for rrc in (rc.get("rrc") or []))
if len(distances_remaining) > 0:
leader_distance_remaining = distances_remaining[0]
return {
rrc["id"]
for rrc in (rc.get("rrc") or [])
if rrc["prg"] == leader_distance_remaining
}
else:
return set()
174 changes: 127 additions & 47 deletions tests/test_non_prices.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from betfairutil import calculate_available_volume
from betfairutil import calculate_book_percentage
from betfairutil import calculate_haversine_distance_between_runners
from betfairutil import calculate_market_book_diff
from betfairutil import calculate_order_book_imbalance
from betfairutil import calculate_total_matched
Expand All @@ -30,16 +31,20 @@
from betfairutil import get_bsp_from_market_definition
from betfairutil import get_bsp_from_prices_file
from betfairutil import get_bsp_from_race_result
from betfairutil import get_event_id_from_string
from betfairutil import get_final_market_definition_from_prices_file
from betfairutil import get_first_market_definition_from_prices_file
from betfairutil import get_event_id_from_string
from betfairutil import get_inplay_publish_time_from_prices_file
from betfairutil import get_is_jump_from_race_card
from betfairutil import get_market_books_from_prices_file
from betfairutil import get_market_id_from_string
from betfairutil import get_market_time_as_datetime
from betfairutil import get_minimum_book_percentage_market_books_from_prices_file
from betfairutil import get_number_of_jumps_remaining
from betfairutil import get_pre_event_volume_traded_from_prices_file
from betfairutil import get_race_change_from_race_file
from betfairutil import get_race_id_from_string
from betfairutil import get_race_leaders
from betfairutil import get_runner_book_from_market_book
from betfairutil import get_second_best_price
from betfairutil import get_second_best_price_size
Expand All @@ -56,6 +61,7 @@
from betfairutil import iterate_other_active_runners
from betfairutil import market_book_to_data_frame
from betfairutil import prices_file_to_csv_file
from betfairutil import publish_time_to_datetime
from betfairutil import random_from_market_id
from betfairutil import read_prices_file
from betfairutil import read_race_file
Expand All @@ -74,6 +80,7 @@ def race_card():
{"marketId": "1.456", "marketType": "WIN", "numberOfWinners": 1},
],
"distance": 1000,
"raceType": {"full": "Chase"},
}
}

Expand Down Expand Up @@ -215,6 +222,15 @@ def race_change():
"ord": [],
"J": [],
},
"rrc": [
{
"ft": 1670024522300,
"id": 50749188,
"long": -76.6608639,
"lat": 40.3955184,
"prg": 1106.4,
}
],
}


Expand Down Expand Up @@ -287,6 +303,55 @@ def path_to_prices_file(
return path_to_prices_file


@pytest.fixture
def path_to_prices_file_with_inplay_transition(
market_book: Dict[str, Any], market_definition: Dict[str, Any], tmp_path: Path
):
path_to_prices_file = tmp_path / f"1.123.json.gz"
with smart_open.open(path_to_prices_file, "w") as f:
market_definition["inPlay"] = False
f.write(
json.dumps(
{
"op": "mcm",
"clk": 0,
"pt": market_book["publishTime"],
"mc": [
{
"id": "1.123",
"marketDefinition": market_definition,
"rc": [
{"id": 123, "trd": [[1.98, 1]]},
{"id": 456, "trd": [[1.98, 1]]},
],
}
],
}
)
)
f.write("\n")
market_definition["inPlay"] = True
f.write(
json.dumps(
{
"op": "mcm",
"clk": 0,
"pt": market_book["publishTime"],
"mc": [
{
"id": "1.123",
"marketDefinition": market_definition,
"rc": [],
}
],
}
)
)
f.write("\n")

return path_to_prices_file


def test_side():
assert Side.LAY.other_side == Side.BACK

Expand Down Expand Up @@ -703,7 +768,6 @@ def test_read_race_file(race_change: Dict[str, Any], path_to_race_file: Path):
assert len(rcs) == 1

del rcs[0]["pt"]
del rcs[0]["rrc"]
del rcs[0]["streaming_snap"]
del rcs[0]["streaming_unique_id"]
del rcs[0]["streaming_update"]
Expand Down Expand Up @@ -761,52 +825,11 @@ def test_get_final_market_definition_from_prices_file(


def test_get_pre_event_volume_traded_from_prices_file(
market_definition: Dict[str, Any],
market_book: Dict[str, Any],
tmp_path: Path,
path_to_prices_file_with_inplay_transition: Path,
):
path_to_prices_file = tmp_path / f"1.123.json.gz"
with smart_open.open(path_to_prices_file, "w") as f:
market_definition["inPlay"] = False
f.write(
json.dumps(
{
"op": "mcm",
"clk": 0,
"pt": market_book["publishTime"],
"mc": [
{
"id": "1.123",
"marketDefinition": market_definition,
"rc": [
{"id": 123, "trd": [[1.98, 1]]},
{"id": 456, "trd": [[1.98, 1]]},
],
}
],
}
)
)
f.write("\n")
market_definition["inPlay"] = True
f.write(
json.dumps(
{
"op": "mcm",
"clk": 0,
"pt": market_book["publishTime"],
"mc": [
{
"id": "1.123",
"marketDefinition": market_definition,
"rc": [],
}
],
}
)
)
f.write("\n")
volume_traded = get_pre_event_volume_traded_from_prices_file(path_to_prices_file)
volume_traded = get_pre_event_volume_traded_from_prices_file(
path_to_prices_file_with_inplay_transition
)
assert volume_traded == 2


Expand Down Expand Up @@ -1152,3 +1175,60 @@ def test_calculate_available_volume(market_book: Dict[str, Any]):
)
assert calculate_available_volume(market_book, Side.BACK, 1.05) == 4
assert calculate_available_volume(market_book, Side.BACK, 1.02) == 2


def test_get_inplay_publish_time_from_prices_file(
market_book: MarketBook,
path_to_prices_file_with_inplay_transition: Path,
):
assert (
get_inplay_publish_time_from_prices_file(
path_to_prices_file_with_inplay_transition
)
== market_book["publishTime"]
)
assert get_inplay_publish_time_from_prices_file(
path_to_prices_file_with_inplay_transition, as_datetime=True
) == publish_time_to_datetime(market_book["publishTime"])

# Empty file
with smart_open.open(path_to_prices_file_with_inplay_transition, "w"):
pass

assert (
get_inplay_publish_time_from_prices_file(
path_to_prices_file_with_inplay_transition
)
is None
)


def test_get_is_jump_from_race_card(race_card: Dict[str, Any]):
is_jump = get_is_jump_from_race_card(race_card)
assert is_jump


def test_calculate_haversine_distance_between_runners(race_change: Dict[str, Any]):
haversine_distance = calculate_haversine_distance_between_runners(
race_change["rrc"][0], race_change["rrc"][0]
)

assert haversine_distance == 0


def test_get_race_leaders(race_change: Dict[str, Any]):
race_leaders = get_race_leaders(race_change)
assert race_leaders == {race_change["rrc"][0]["id"]}

race_change["rrc"] = None
race_leaders = get_race_leaders(race_change)
assert race_leaders == set()


def test_get_number_of_jumps_remaining(race_change: Dict[str, Any]):
number_of_jumps_remaining = get_number_of_jumps_remaining(race_change)
assert number_of_jumps_remaining is None

race_change["rpc"]["J"] = [{"L": 1000}]
number_of_jumps_remaining = get_number_of_jumps_remaining(race_change)
assert number_of_jumps_remaining == 1