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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion basana/core/bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ async def main(self):
end = begin + datetime.timedelta(seconds=self._bar_duration, milliseconds=-1)
while True:
sleep_time = (end - dt.utc_now()).total_seconds() + self._flush_delay
if sleep_time > 0:
if sleep_time > 0: # pragma: no branch
await asyncio.sleep(sleep_time)
self._flush(begin, end)
begin += datetime.timedelta(seconds=self._bar_duration)
Expand Down
4 changes: 2 additions & 2 deletions basana/core/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ async def __aexit__(self, exc_type, exc_value, traceback):
def _cancel(self) -> List[asyncio.Task]:
pending = [task for task in self._tasks if not task.done()]
for task in pending:
if not task.done():
if not task.done(): # pragma: no branch
task.cancel()
return pending

Expand Down Expand Up @@ -146,7 +146,7 @@ async def wait(self, timeout: Optional[Union[int, float]] = None) -> bool:
async def _task_main(self, task_id: int):
current_task = asyncio.current_task()
# Register ourselves in the task registry if not already there. This happens with eager tasks (Python >= 3.12).
if current_task not in self._tasks:
if current_task not in self._tasks: # pragma: no branch
assert current_task is not None
self._tasks[task_id] = current_task

Expand Down
4 changes: 2 additions & 2 deletions basana/external/binance/websockets.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ async def handle_message(self, message: dict) -> bool:
))
self.schedule_resubscription([channel.alias])
# Get the event source for the channel alias.
if event_source := self.get_channel_event_source(channel.alias):
if event_source := self.get_channel_event_source(channel.alias): # pragma: no branch
coro = event_source.push_from_message(message)

ret = False
Expand All @@ -155,7 +155,7 @@ def _get_next_msg_id(self) -> int:

def _keep_alive_channel(self, channel: Channel) -> dispatcher.SchedulerJob:
async def scheduler_job():
if self._next_keep_alive[channel.alias] <= self._dispatcher.now():
if self._next_keep_alive[channel.alias] <= self._dispatcher.now(): # pragma: no branch
logger.debug(logs.StructuredMessage("Channel keep alive", alias=channel.alias))
try:
await channel.keep_alive(self._cli)
Expand Down
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ log_format = %(asctime)s - %(levelname)s - %(name)s - %(message)s
log_date_format = %Y-%m-%d %H:%M:%S.%f
asyncio_mode = auto

[coverage:run]
branch = True

[coverage:report]
fail_under = 100
show_missing = True
Expand Down
36 changes: 30 additions & 6 deletions tests/test_backtesting_charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,12 @@
],
}

@pytest.mark.parametrize("order_plan, candlesticks", [
(order_plan, True),
(order_plan, False),
@pytest.mark.parametrize("order_plan, candlesticks, include_buys, include_sells", [
(order_plan, True, True, True),
(order_plan, False, True, True),
(order_plan, True, False, False),
])
async def test_save_line_chart(order_plan, candlesticks, backtesting_dispatcher, caplog):
async def test_save_line_chart(order_plan, candlesticks, include_buys, include_sells, backtesting_dispatcher, caplog):
e = exchange.Exchange(
backtesting_dispatcher,
{
Expand All @@ -50,14 +51,20 @@ async def test_save_line_chart(order_plan, candlesticks, backtesting_dispatcher,
)
pair = Pair("ORCL", "USD")
line_charts = charts.LineCharts(e)
line_charts.add_pair(pair, candlesticks=candlesticks)
line_charts.add_pair(pair, include_buys=include_buys, include_sells=include_sells, candlesticks=candlesticks)
line_charts.add_balance("USD")
line_charts.add_pair_indicator(
"CONSTANT", pair, charts.DataPointFromSequence([100]), marker={"symbol": "arrow"}
)
# Add an indicator with no marker (empty dict) and one that returns None.
line_charts.add_pair_indicator("NO_MARKER", pair, charts.DataPointFromSequence([50]))
line_charts.add_pair_indicator("SOMETIMES_NONE", pair, charts.DataPointFromSequence([]))
line_charts.add_portfolio_value("USD")
line_charts.add_portfolio_value("INVALID")
# Add a custom chart with a no-marker line and one that returns None.
line_charts.add_custom("CUSTOM", "line_name", lambda _: 3, marker={"symbol": "arrow"})
line_charts.add_custom("CUSTOM", "no_marker_line", lambda _: 1)
line_charts.add_custom("CUSTOM", "sometimes_none", lambda _: None)

async def on_bar(bar_event):
order_requests = order_plan.get(bar_event.when.date(), [])
Expand All @@ -72,4 +79,21 @@ async def on_bar(bar_event):

with helpers.temp_file_name(suffix=".png") as tmp_file_name:
line_charts.save(tmp_file_name)
assert os.stat(tmp_file_name).st_size > 100
assert os.stat(tmp_file_name).st_size > 100


def test_data_point_from_empty_sequence():
dp = charts.DataPointFromSequence([])
assert dp(datetime.datetime.now()) is None


def test_build_figure_no_charts(backtesting_dispatcher):
e = exchange.Exchange(backtesting_dispatcher, {})
line_charts = charts.LineCharts(e)
# When no charts are added, _build_figure returns None and save is a no-op.
assert line_charts._build_figure() is None
with helpers.temp_file_name(suffix=".png", delete=False) as tmp_file_name:
# Delete the file first so we can verify save() doesn't create it.
os.unlink(tmp_file_name)
line_charts.save(tmp_file_name)
assert not os.path.exists(tmp_file_name)
5 changes: 5 additions & 0 deletions tests/test_backtesting_exchange_loans.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,11 @@ async def test_borrow_and_repay(
loans = await e.get_loans(borrowed_symbol=loan_symbol, is_open=True)
assert loans == []

# Also test get_loans without is_open filter (covers the is_open is None branch).
all_loans = await e.get_loans(borrowed_symbol=loan_symbol)
assert len(all_loans) == 1
assert not all_loans[0].is_open

loan = await e.get_loan(loan.id)
assert not loan.is_open
assert loan.borrowed_symbol == loan_symbol
Expand Down
24 changes: 24 additions & 0 deletions tests/test_backtesting_fees.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,30 @@ def test_percentage_fee_base_currency_with_partial_fills_for_sell():
)


def test_percentage_fee_no_pending_fee():
# Test that when charged_fee already covers the total, pending_fee >= 0 and no additional fee is returned.
fee_strategy = fees.Percentage(Decimal("1"))
order = orders.MarketOrder("1", OrderOperation.BUY, btc_pair, btc_pair_info, Decimal("0.01"))

# Fill #1: charge a higher fee than 1% (simulating rounding overcharge).
balance_updates = {
"BTC": Decimal("0.001"),
"USD": Decimal("-0.9"),
}
# Manually overcharge by setting charged_fee to more than the 1% of total.
order.add_fill(
Fill(dt.utc_now(), balance_updates, {"USD": Decimal("-0.10")}, Decimal(9))
)

# Fill #2: the total fee is less than already charged, so pending_fee >= 0 (no extra fee returned).
balance_updates2 = {
"BTC": Decimal("0.001"),
"USD": Decimal("-0.9"),
}
result = fee_strategy.calculate_fees(order, balance_updates2)
assert result == {}


def test_percentage_fee_base_currency_with_minimum():
fee_strategy = fees.Percentage(Decimal("1"), min_fee=Decimal("0.001"), fee_currency=fees.FeeCurrency.BASE)
order = orders.MarketOrder("1", OrderOperation.BUY, btc_pair, btc_pair_info, Decimal("0.05"))
Expand Down
31 changes: 31 additions & 0 deletions tests/test_backtesting_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Basana
#
# Copyright 2022 Gabriel Martin Becedillas Ruiz
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from basana.backtesting.helpers import ExchangeObjectContainer


class MockItem:
def __init__(self, is_open: bool):
self.id = "mock-id"
self.is_open = is_open


def test_exchange_object_container_add_closed_item():
container = ExchangeObjectContainer()
item = MockItem(is_open=False)
container.add(item)
# A closed item should not appear in _open_items.
assert item not in container._open_items
16 changes: 16 additions & 0 deletions tests/test_backtesting_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,22 @@
quote_currency: Decimal("43870.10"),
}
),
# Sell stop limit: stop hit during bar but limit price is outside the bar range (below bar low).
(
StopLimitOrder(
uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1"), Decimal("35000"), Decimal("25000")
),
(Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")),
{}
),
# Buy stop limit: stop hit during bar but limit price is outside the bar range (above bar high).
(
StopLimitOrder(
uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("41000"), Decimal("60000")
),
(Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")),
{}
),
])
def test_try_fill_with_infinite_liquidity(order, ohlcv, expected_balance_updates, backtesting_dispatcher):
e = exchange.Exchange(backtesting_dispatcher, {}) # Just for rounding purposes
Expand Down
86 changes: 84 additions & 2 deletions tests/test_binance_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import aioresponses
import pytest

from basana.external.binance import client
from basana.external.binance import client, helpers as binance_helpers


@pytest.fixture()
Expand All @@ -46,4 +46,86 @@ async def test_error_parsing(status_code, response_body, expected, binance_http_
)
with pytest.raises(client.Error) as excinfo:
await c.spot_account.get_account_information()
assert str(excinfo.value) == expected
assert str(excinfo.value) == expected


async def test_get_exchange_info_no_symbol(binance_http_api_mock):
binance_http_api_mock.get(
re.compile(r"http://binance.mock/api/v3/exchangeInfo"), status=200,
payload={"symbols": []}
)
c = client.APIClient(
api_key="key", api_secret="secret", config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}}
)
result = await c.get_exchange_info()
assert result == {"symbols": []}


async def test_non_json_response(binance_http_api_mock):
# Respond without application/json content type – json_response should be None and raise_for_error should trigger.
binance_http_api_mock.get(
re.compile(r"http://binance.mock/api/v3/exchangeInfo.*"),
status=403, body="Forbidden", content_type="text/plain"
)
c = client.APIClient(
api_key="key", api_secret="secret", config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}}
)
with pytest.raises(client.Error):
await c.get_exchange_info()


async def test_spot_get_open_orders_no_symbol(binance_http_api_mock):
binance_http_api_mock.get(
re.compile(r"http://binance.mock/api/v3/openOrders.*"), status=200, payload=[]
)
c = client.APIClient(
api_key="key", api_secret="secret", config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}}
)
result = await c.spot_account.get_open_orders()
assert result == []


async def test_spot_get_trades_no_order_id(binance_http_api_mock):
binance_http_api_mock.get(
re.compile(r"http://binance.mock/api/v3/myTrades.*"), status=200, payload=[]
)
c = client.APIClient(
api_key="key", api_secret="secret", config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}}
)
result = await c.spot_account.get_trades("BTCUSDT")
assert result == []


async def test_margin_get_open_orders_no_symbol(binance_http_api_mock):
binance_http_api_mock.get(
re.compile(r"http://binance.mock/sapi/v1/margin/openOrders.*"), status=200, payload=[]
)
c = client.APIClient(
api_key="key", api_secret="secret", config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}}
)
result = await c.cross_margin_account.get_open_orders()
assert result == []


async def test_margin_get_trades_no_order_id(binance_http_api_mock):
binance_http_api_mock.get(
re.compile(r"http://binance.mock/sapi/v1/margin/myTrades.*"), status=200, payload=[]
)
c = client.APIClient(
api_key="key", api_secret="secret", config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}}
)
result = await c.cross_margin_account.get_trades("BTCUSDT")
assert result == []


def test_get_signature_no_params():
# get_signature with empty qs_params should produce a valid signature (skipping urlencode for empty qs_params).
sig = binance_helpers.get_signature("secret", qs_params={}, data={})
assert isinstance(sig, str)
assert len(sig) == 64 # SHA256 hex digest


def test_get_optional_decimal_missing_key():
# When the key is not present, get_optional_decimal should return None.
result = binance_helpers.get_optional_decimal({}, "missing_key", skip_zero=False)
assert result is None
10 changes: 9 additions & 1 deletion tests/test_binance_exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ async def test_pair_info_explicit_session(binance_http_api_mock, realtime_dispat
assert pair_info.quote_precision == 2
assert "SPOT" in pair_info.permissions

# Second call should use the cache (no additional HTTP request needed).
pair_info2 = await e.get_pair_info(pair.Pair("BTC", "USDT"))
assert pair_info2 is pair_info


async def test_symbol_to_pair(binance_http_api_mock, realtime_dispatcher):
binance_http_api_mock.get(
Expand All @@ -99,4 +103,8 @@ async def test_symbol_to_pair(binance_http_api_mock, realtime_dispatcher):
)

p = await e.symbol_to_pair("BTCUSDT")
assert p == pair.Pair("BTC", "USDT")
assert p == pair.Pair("BTC", "USDT")

# Second call should use the cache (no additional HTTP request needed).
p2 = await e.symbol_to_pair("BTCUSDT")
assert p2 == p
34 changes: 34 additions & 0 deletions tests/test_binance_exchange_cross_margin.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,40 @@ async def test_get_open_orders(
helpers.assert_expected_attrs(open_orders[0], expected_first_open_order)


async def test_order_info_without_trades(binance_http_api_mock, binance_exchange):
order_payload = {
"symbol": "BTCUSDT",
"orderId": 16582318135,
"orderListId": -1,
"clientOrderId": "test_client_id",
"price": "15000.00000000",
"origQty": "0.00100000",
"executedQty": "0.00000000",
"cummulativeQuoteQty": "0.00000000",
"status": "CANCELED",
"timeInForce": "GTC",
"type": "LIMIT",
"side": "BUY",
}
binance_http_api_mock.get(
re.compile(r"http://binance.mock/sapi/v1/margin/order\\?.*"), status=200, payload=order_payload
)

order_info = await binance_exchange.cross_margin_account.get_order_info(
Pair("BTC", "USDT"), order_id="16582318135", include_trades=False
)
assert order_info is not None
assert order_info.trades == []


async def test_get_open_orders_no_pair(binance_http_api_mock, binance_exchange):
binance_http_api_mock.get(
re.compile(r"http://binance.mock/sapi/v1/margin/openOrders\\?.*"), status=200, payload=[]
)
open_orders = await binance_exchange.cross_margin_account.get_open_orders()
assert open_orders == []


@pytest.mark.parametrize("pair, order_id, client_order_id, response_payload, expected_attrs", [
(
Pair("BTC", "USDT"), "16582318135", None,
Expand Down
Loading
Loading