mirror of
https://github.com/Ichinga-Samuel/aiomql.git
synced 2026-08-22 08:18:06 +00:00
v4.0.17 no-backtest
This commit is contained in:
@@ -1,295 +0,0 @@
|
||||
from datetime import datetime, UTC
|
||||
from math import ceil
|
||||
from aiomql import TimeFrame
|
||||
from aiomql.core.backtesting import BackTestEngine
|
||||
from aiomql.core.backtesting.get_data import GetData
|
||||
from aiomql.utils import round_down
|
||||
from aiomql.core.constants import OrderType, TradeAction
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestBackTestEngine:
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
cls.start = datetime(2024, 2, 1)
|
||||
cls.end = datetime(2024, 2, 7)
|
||||
cls.g_data = GetData(
|
||||
start=cls.start,
|
||||
end=cls.end,
|
||||
symbols=["BTCUSD", "SOLUSD"],
|
||||
timeframes=[TimeFrame.H1, TimeFrame.H2],
|
||||
name="test_engine",
|
||||
)
|
||||
cls.bte = BackTestEngine(start=cls.start, end=cls.end, assign_to_config=True, preload=False)
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
async def bte2(self):
|
||||
await self.g_data.get_data()
|
||||
bte2 = BackTestEngine(start=self.start, end=self.end, data=self.g_data.data, use_terminal=False, preload=False)
|
||||
await bte2.setup_account(balance=100)
|
||||
return bte2
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
async def sell_order(self):
|
||||
sym = await self.bte.get_symbol_info(symbol="BTCUSD")
|
||||
request = {
|
||||
"type": OrderType.SELL,
|
||||
"symbol": "BTCUSD",
|
||||
"volume": sym.volume_min,
|
||||
"price": sym.bid,
|
||||
"action": TradeAction.DEAL,
|
||||
}
|
||||
return request
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
async def buy_order(self):
|
||||
sym = await self.bte.get_symbol_info(symbol="BTCUSD")
|
||||
dsl = (sym.trade_stops_level + sym.spread) * 2 * sym.point
|
||||
sl = sym.ask - dsl
|
||||
tp = sym.ask + dsl
|
||||
request = {
|
||||
"type": OrderType.BUY,
|
||||
"symbol": "BTCUSD",
|
||||
"volume": sym.volume_min,
|
||||
"price": sym.ask,
|
||||
"action": TradeAction.DEAL,
|
||||
"sl": sl,
|
||||
"tp": tp,
|
||||
}
|
||||
return request
|
||||
|
||||
def modify_stops(self, order):
|
||||
...
|
||||
|
||||
def test_span_and_range(self):
|
||||
assert self.bte.range == range(0, int((self.end - self.start).total_seconds()), self.bte.speed)
|
||||
assert self.bte.span == range(int(self.start.timestamp()), int(self.end.timestamp()), self.bte.speed)
|
||||
assert len(self.bte.span) == len(self.bte.range)
|
||||
|
||||
def test_cursor(self):
|
||||
self.bte.next()
|
||||
r, t = self.bte.cursor
|
||||
self.bte.fast_forward(steps=100)
|
||||
assert self.bte.cursor.time == t + 100 * self.bte.speed
|
||||
assert self.bte.cursor.index == r + 100 * self.bte.speed
|
||||
print(datetime.fromtimestamp(self.bte.cursor.time, tz=UTC), "test_cursor")
|
||||
go_to = datetime(2024, 2, 6, tzinfo=UTC)
|
||||
self.bte.go_to(time=go_to)
|
||||
assert self.bte.cursor.time == int(datetime.timestamp(go_to))
|
||||
self.bte.reset()
|
||||
assert self.bte.cursor.time == int(self.start.timestamp())
|
||||
|
||||
def test_speed(self):
|
||||
self.bte.setup_test_range(start=self.start, end=self.end, speed=3600)
|
||||
assert self.bte.speed == 3600
|
||||
self.bte.next()
|
||||
now = datetime.fromtimestamp(self.bte.cursor.time, tz=UTC)
|
||||
index = self.bte.cursor.index
|
||||
self.bte.next()
|
||||
assert self.bte.cursor.index == index + 3600
|
||||
assert self.bte.cursor.time == int(now.timestamp()) + 3600
|
||||
self.bte.setup_test_range(start=self.start, end=self.end)
|
||||
assert self.bte.speed == 60
|
||||
|
||||
async def test_account(self):
|
||||
await self.bte.setup_account(balance=100)
|
||||
acc = self.bte.get_account_info()
|
||||
self.bte.use_terminal_for_backtesting = False
|
||||
self.bte.use_terminal_for_backtesting = True
|
||||
assert acc.balance == 100
|
||||
assert acc.equity == 100
|
||||
assert acc.margin == 0
|
||||
assert acc.margin_free == 100
|
||||
assert acc.margin_level == 0
|
||||
self.bte.deposit(amount=50)
|
||||
acc = self.bte.get_account_info()
|
||||
assert acc.balance == 150
|
||||
assert acc.equity == 150
|
||||
assert acc.margin == 0
|
||||
assert acc.margin_free == 150
|
||||
assert acc.margin_level == 0
|
||||
self.bte.withdraw(amount=80)
|
||||
acc = self.bte.get_account_info()
|
||||
assert acc.balance == 70
|
||||
assert acc.equity == 70
|
||||
assert acc.margin == 0
|
||||
assert acc.margin_free == 70
|
||||
assert acc.margin_level == 0
|
||||
self.bte.update_account(profit=-5)
|
||||
acc = self.bte.get_account_info()
|
||||
assert acc.equity == 65
|
||||
assert acc.balance == 70
|
||||
assert acc.profit == -5
|
||||
assert acc.margin == 0
|
||||
assert acc.margin_free == 65
|
||||
assert acc.margin_level == 0
|
||||
self.bte.update_account(margin=2.5)
|
||||
acc = self.bte.get_account_info()
|
||||
assert acc.balance == 70
|
||||
assert acc.equity == 65
|
||||
assert acc.margin == 2.5
|
||||
assert acc.margin_free == 62.5
|
||||
assert acc.margin_level == 2600
|
||||
|
||||
def test_account_sync(self):
|
||||
balance = 200
|
||||
self.bte.setup_account_sync(balance=balance)
|
||||
acc = self.bte.get_account_info()
|
||||
assert acc.balance == balance
|
||||
|
||||
async def test_bte2_init(self, bte2):
|
||||
assert bte2._data.fully_loaded is True
|
||||
assert bte2.span == self.bte.span
|
||||
assert bte2.range == self.bte.range
|
||||
assert bte2.use_terminal is False
|
||||
|
||||
async def test_get_rates_from(self):
|
||||
start = datetime(2024, 2, 3, 12, 43, tzinfo=UTC)
|
||||
rates = await self.bte.get_rates_from(symbol="BTCUSD", timeframe=TimeFrame.H1, date_from=start, count=24)
|
||||
assert len(rates) == 24
|
||||
|
||||
async def test_get_rates_from_2(self, bte2):
|
||||
start = datetime(2024, 2, 3, 12, 12, tzinfo=UTC)
|
||||
rates = await bte2.get_rates_from(symbol="BTCUSD", timeframe=TimeFrame.H1, date_from=start, count=24)
|
||||
assert len(rates) == 24
|
||||
|
||||
async def test_get_rates_from_pos(self):
|
||||
now = datetime(2024, 2, 3, 11, 55, tzinfo=UTC)
|
||||
self.bte.go_to(time=now)
|
||||
tf = TimeFrame.H2
|
||||
start_pos = 2
|
||||
rates = await self.bte.get_rates_from_pos(symbol="BTCUSD", timeframe=tf, start_pos=start_pos, count=24)
|
||||
assert len(rates) == 24
|
||||
assert int(rates[-1][0]) == round_down(int(now.replace(hour=now.hour - start_pos).timestamp()), tf.seconds)
|
||||
|
||||
async def test_get_rates_from_pos2(self, bte2):
|
||||
now = datetime(2024, 2, 4, 12, 15, tzinfo=UTC)
|
||||
bte2.go_to(time=now)
|
||||
tf = TimeFrame.H1
|
||||
start_pos = 2
|
||||
rates = await bte2.get_rates_from_pos(symbol="BTCUSD", timeframe=tf, start_pos=start_pos, count=24)
|
||||
assert int(rates[-1][0]) == round_down(int(now.replace(hour=10).timestamp()), tf.seconds)
|
||||
# assert int(rates[-1][0]) == round_up(int(now.timestamp()), tf.seconds) - start_pos * tf.seconds
|
||||
assert len(rates) == 24
|
||||
|
||||
async def test_get_rates_range(self):
|
||||
start = datetime(2024, 2, 3, 12, tzinfo=UTC)
|
||||
end = datetime(2024, 2, 4, 18, tzinfo=UTC)
|
||||
rates = await self.bte.get_rates_range(symbol="BTCUSD", timeframe=TimeFrame.H1, date_from=start, date_to=end)
|
||||
assert len(rates) == 31
|
||||
assert int(rates[-1][0]) == int(end.timestamp())
|
||||
|
||||
async def test_get_rates_range2(self, bte2):
|
||||
start = datetime(2024, 2, 3, 12, tzinfo=UTC)
|
||||
end = datetime(2024, 2, 4, 18, tzinfo=UTC)
|
||||
rates = await bte2.get_rates_range(symbol="BTCUSD", timeframe=TimeFrame.H1, date_from=start, date_to=end)
|
||||
assert len(rates) == 31
|
||||
assert int(rates[-1][0]) == int(end.timestamp())
|
||||
|
||||
async def test_get_ticks_from(self):
|
||||
start = datetime(2024, 2, 3, 12, tzinfo=UTC)
|
||||
ticks = await self.bte.get_ticks_from(symbol="BTCUSD", date_from=start, count=24)
|
||||
assert len(ticks) == 24
|
||||
|
||||
async def test_get_ticks_from2(self, bte2):
|
||||
start = datetime(2024, 2, 3, 12, tzinfo=UTC)
|
||||
ticks = await bte2.get_ticks_from(symbol="BTCUSD", date_from=start, count=24)
|
||||
assert len(ticks) == 24
|
||||
|
||||
async def test_get_ticks_range(self):
|
||||
start = datetime(2024, 2, 3, 12, tzinfo=UTC)
|
||||
end = datetime(2024, 2, 3, 15, tzinfo=UTC)
|
||||
ticks = await self.bte.get_ticks_range(symbol="BTCUSD", date_from=start, date_to=end)
|
||||
approx_total = (end - start).total_seconds() // 2 # assuming 2 ticks per second at least
|
||||
assert len(ticks) >= approx_total
|
||||
|
||||
async def test_get_ticks_range2(self, bte2):
|
||||
start = datetime(2024, 2, 3, 12, tzinfo=UTC)
|
||||
end = datetime(2024, 2, 3, 15, tzinfo=UTC)
|
||||
ticks = await bte2.get_ticks_range(symbol="BTCUSD", date_from=start, date_to=end)
|
||||
approx_total = (end - start).total_seconds() // 2 # assuming 2 ticks per second at least
|
||||
assert len(ticks) >= approx_total
|
||||
|
||||
async def test_price_tick(self, bte2):
|
||||
moment = datetime(2024, 2, 3, 12, 12, tzinfo=UTC)
|
||||
self.bte.reset()
|
||||
self.bte.go_to(time=moment)
|
||||
tick = await self.bte.get_price_tick(symbol="BTCUSD", time=self.bte.cursor.time)
|
||||
assert tick is not None
|
||||
assert isinstance(tick.ask, float)
|
||||
assert tick.ask > 0
|
||||
bte2.reset()
|
||||
bte2.go_to(time=moment)
|
||||
tick2 = await bte2.get_price_tick(symbol="BTCUSD", time=bte2.cursor.time)
|
||||
assert tick.ask == tick2.ask
|
||||
|
||||
async def test_get_symbol_info(self, bte2):
|
||||
moment = datetime(2024, 2, 3, 12, 12, tzinfo=UTC)
|
||||
self.bte.reset()
|
||||
self.bte.go_to(time=moment)
|
||||
bte2.reset()
|
||||
bte2.go_to(time=moment)
|
||||
sym = "BTCUSD"
|
||||
sym_info = await self.bte.get_symbol_info(symbol=sym)
|
||||
assert sym_info is not None
|
||||
assert sym_info.name == sym
|
||||
sym_info2 = await bte2.get_symbol_info(symbol=sym)
|
||||
assert sym_info.ask == sym_info2.ask
|
||||
|
||||
async def test_order_profit(self, bte2):
|
||||
moment = datetime(2024, 2, 3, 12, 12, tzinfo=UTC)
|
||||
self.bte.reset()
|
||||
self.bte.go_to(time=moment)
|
||||
bte2.reset()
|
||||
bte2.go_to(time=moment)
|
||||
sym = "BTCUSD"
|
||||
sym_info = await self.bte.get_symbol_info(symbol=sym)
|
||||
dsl = (sym_info.trade_stops_level + sym_info.spread) * 2 * sym_info.point
|
||||
tp = sym_info.ask + dsl
|
||||
|
||||
profit = await self.bte.order_calc_profit(
|
||||
action=OrderType.BUY, symbol=sym, volume=sym_info.volume_min, price_open=sym_info.ask, price_close=tp
|
||||
)
|
||||
assert profit > 0
|
||||
sym_info2 = await bte2.get_symbol_info(symbol=sym)
|
||||
dsl2 = (sym_info2.trade_stops_level + sym_info2.spread) * 2 * sym_info2.point
|
||||
tp2 = sym_info2.ask + dsl2
|
||||
profit2 = await bte2.order_calc_profit(
|
||||
action=OrderType.BUY, symbol=sym, volume=sym_info2.volume_min, price_open=sym_info2.ask, price_close=tp2
|
||||
)
|
||||
assert ceil(profit) == ceil(profit2)
|
||||
|
||||
async def test_order_margin(self, bte2):
|
||||
moment = datetime(2024, 2, 3, 12, 12, tzinfo=UTC)
|
||||
self.bte.reset()
|
||||
self.bte.go_to(time=moment)
|
||||
bte2.reset()
|
||||
bte2.go_to(time=moment)
|
||||
sym = "BTCUSD"
|
||||
sym_info = await self.bte.get_symbol_info(symbol=sym)
|
||||
margin = await self.bte.order_calc_margin(
|
||||
action=OrderType.SELL, symbol=sym, volume=sym_info.volume_min, price=sym_info.bid
|
||||
)
|
||||
assert margin > 0
|
||||
sym_info2 = await self.bte.get_symbol_info(symbol=sym)
|
||||
margin2 = await bte2.order_calc_margin(
|
||||
action=OrderType.SELL, symbol=sym, volume=sym_info2.volume_min, price=sym_info2.bid
|
||||
)
|
||||
assert margin2 > 0
|
||||
|
||||
async def test_order_check(self, buy_order, sell_order):
|
||||
ocr = await self.bte.order_check(request=buy_order)
|
||||
assert ocr is not None
|
||||
assert ocr.retcode == 0
|
||||
ocr2 = await self.bte.order_check(request=sell_order)
|
||||
assert ocr2 is not None
|
||||
assert ocr2.retcode == 0
|
||||
|
||||
async def test_order_send(self, buy_order, sell_order):
|
||||
ocr = await self.bte.order_send(request=buy_order)
|
||||
assert ocr is not None
|
||||
assert ocr.retcode == 10009
|
||||
ocr2 = await self.bte.order_send(request=sell_order)
|
||||
assert ocr2 is not None
|
||||
assert ocr2.retcode == 10009
|
||||
+515
-176
@@ -1,225 +1,564 @@
|
||||
"""Comprehensive tests for the Base and _Base classes.
|
||||
"""Comprehensive tests for the base module.
|
||||
|
||||
Tests cover:
|
||||
- Base class initialization and attribute handling
|
||||
- Dictionary conversion with include/exclude filtering
|
||||
- Annotations and class variables
|
||||
- _Base class MetaTrader and Config integration
|
||||
- Pickling/serialization support
|
||||
- Mode switching (async/sync)
|
||||
- Base class initialization, set_attributes, repr, annotations, dict, get_dict, class_vars
|
||||
- BaseMeta metaclass lazy setup behavior
|
||||
- _Base class with MetaTrader/Config integration and pickling support
|
||||
- Subclassing and annotation/exclude/include merging
|
||||
"""
|
||||
|
||||
import enum
|
||||
import pickle
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
from aiomql.core.base import Base, _Base
|
||||
|
||||
from aiomql.core.base import Base, _Base, BaseMeta
|
||||
from aiomql.core.config import Config
|
||||
from aiomql.core.meta_trader import MetaTrader
|
||||
from aiomql.core.sync.meta_trader import MetaTrader as MetaTraderSync
|
||||
|
||||
|
||||
class ChildClass(Base):
|
||||
attr: int
|
||||
attr2: str
|
||||
cls_attr: int = 10
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper subclasses for testing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ChildBaseClass(_Base):
|
||||
"""Test subclass of _Base for testing MT5/Config integration."""
|
||||
attr: int
|
||||
attr2: str
|
||||
cls_attr: int = 20
|
||||
|
||||
|
||||
class TestEnum(enum.Enum):
|
||||
"""Test enum for repr testing."""
|
||||
VALUE_A = 1
|
||||
VALUE_B = 2
|
||||
|
||||
|
||||
class EnumChild(Base):
|
||||
"""Test class with enum attribute."""
|
||||
class SimpleModel(Base):
|
||||
"""A simple Base subclass with typed annotations."""
|
||||
name: str
|
||||
status: TestEnum
|
||||
value: int
|
||||
score: float
|
||||
|
||||
|
||||
class TestBaseClass:
|
||||
"""Tests for the Base class."""
|
||||
class ExtendedModel(SimpleModel):
|
||||
"""A child of SimpleModel adding more annotations."""
|
||||
extra: str
|
||||
value: float # override parent's int annotation with float
|
||||
|
||||
@pytest.fixture
|
||||
def child(self):
|
||||
return ChildClass(attr=1, attr2="test")
|
||||
|
||||
def test_repr(self, child):
|
||||
repr_str = repr(child)
|
||||
assert repr_str.startswith("ChildClass(")
|
||||
assert "attr=1" in repr_str
|
||||
assert "attr2=test" in repr_str
|
||||
class CustomExcludeModel(Base):
|
||||
"""A model with a custom exclude set."""
|
||||
exclude: set[str] = {"mt5", "config", "exclude", "include", "annotations", "class_vars", "dict", "_instance",
|
||||
"mode", "secret"}
|
||||
name: str
|
||||
secret: str
|
||||
visible: int
|
||||
|
||||
def test_repr_with_enum(self):
|
||||
"""Test repr correctly displays enum values."""
|
||||
obj = EnumChild(name="test", status=TestEnum.VALUE_A)
|
||||
repr_str = repr(obj)
|
||||
assert "name=test" in repr_str
|
||||
assert "VALUE_A" in repr_str
|
||||
|
||||
def test_repr_truncates_long_attributes(self):
|
||||
"""Test repr truncates when there are more than 3 attributes."""
|
||||
class ManyAttrs(Base):
|
||||
a: int
|
||||
b: int
|
||||
c: int
|
||||
d: int
|
||||
e: int
|
||||
class CustomIncludeModel(Base):
|
||||
"""A model with a custom include set that overrides exclude."""
|
||||
include: set[str] = {"config"}
|
||||
name: str
|
||||
config: str
|
||||
|
||||
obj = ManyAttrs(a=1, b=2, c=3, d=4, e=5)
|
||||
repr_str = repr(obj)
|
||||
assert "..." in repr_str
|
||||
assert "a=1" in repr_str
|
||||
assert "e=5" in repr_str
|
||||
|
||||
def test_set_attributes(self, child):
|
||||
child.set_attributes(attr3=3.14, attr2="str")
|
||||
assert child.attr2 == "str"
|
||||
assert getattr(child, "attr3", None) is None
|
||||
class ModelWithClassVar(Base):
|
||||
"""A model with annotated class-level defaults."""
|
||||
name: str
|
||||
kind: str = "default_kind"
|
||||
|
||||
def test_set_attributes_type_conversion(self):
|
||||
"""Test set_attributes converts types based on annotations."""
|
||||
child = ChildClass(attr="42", attr2=123)
|
||||
assert child.attr == 42
|
||||
assert child.attr2 == "123"
|
||||
|
||||
def test_annotations(self, child):
|
||||
annotations = child.annotations
|
||||
assert isinstance(annotations, dict)
|
||||
assert "attr" in annotations
|
||||
assert "attr2" in annotations
|
||||
class EnumColor(enum.Enum):
|
||||
RED = 1
|
||||
GREEN = 2
|
||||
BLUE = 3
|
||||
|
||||
def test_annotations_includes_parent_classes(self):
|
||||
"""Test annotations includes attributes from parent classes."""
|
||||
class GrandChild(ChildClass):
|
||||
extra: float
|
||||
|
||||
grandchild = GrandChild(attr=1, attr2="test", extra=3.14)
|
||||
annotations = grandchild.annotations
|
||||
assert "attr" in annotations
|
||||
assert "attr2" in annotations
|
||||
assert "extra" in annotations
|
||||
class ModelWithEnum(Base):
|
||||
"""A model containing an enum attribute."""
|
||||
name: str
|
||||
color: EnumColor
|
||||
score: float
|
||||
|
||||
def test_get_dict(self, child):
|
||||
child.set_attributes(attr2="test")
|
||||
result = child.get_dict()
|
||||
assert result["attr"] == 1
|
||||
assert result["attr2"] == "test"
|
||||
|
||||
def test_get_dict_with_exclude(self, child):
|
||||
child.set_attributes(attr2="test")
|
||||
result = child.get_dict(exclude={"attr"})
|
||||
assert "attr" not in result
|
||||
assert result["attr2"] == "test"
|
||||
class ManyAttrsModel(Base):
|
||||
"""A model with > 3 simple-typed attributes."""
|
||||
a: int
|
||||
b: int
|
||||
c: int
|
||||
d: int
|
||||
e: int
|
||||
|
||||
def test_get_dict_with_include(self, child):
|
||||
child.set_attributes(attr3=3.14)
|
||||
result = child.get_dict(include={"attr"})
|
||||
assert result["attr"] == 1
|
||||
assert "attr2" not in result
|
||||
|
||||
def test_get_dict_include_takes_precedence(self, child):
|
||||
"""Test that include takes precedence over exclude."""
|
||||
result = child.get_dict(include={"attr"}, exclude={"attr"})
|
||||
assert "attr" in result
|
||||
class ModelWithComplexAttr(Base):
|
||||
"""A model with complex (non-simple) attributes."""
|
||||
name: str
|
||||
data: list
|
||||
meta: dict
|
||||
|
||||
def test_class_vars(self, child):
|
||||
class_vars = child.class_vars
|
||||
assert isinstance(class_vars, dict)
|
||||
assert "cls_attr" in class_vars
|
||||
assert "attr" not in class_vars
|
||||
|
||||
def test_dict_property(self, child):
|
||||
child.set_attributes(attr2="test")
|
||||
dict_prop = child.dict
|
||||
assert dict_prop["attr"] == 1
|
||||
assert dict_prop["attr2"] == "test"
|
||||
assert dict_prop["cls_attr"] == 10
|
||||
class SyncBaseModel(_Base):
|
||||
"""A _Base subclass operating in sync mode."""
|
||||
mode = "sync"
|
||||
name: str
|
||||
|
||||
|
||||
class AsyncBaseModel(_Base):
|
||||
"""A _Base subclass operating in async (default) mode."""
|
||||
name: str
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestBaseInit
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestBaseInit:
|
||||
"""Tests for Base.__init__ and set_attributes."""
|
||||
|
||||
def test_init_sets_annotated_attributes(self):
|
||||
"""Init with valid annotated kwargs sets attributes."""
|
||||
obj = SimpleModel(name="hello", value=42, score=3.14)
|
||||
assert obj.name == "hello"
|
||||
assert obj.value == 42
|
||||
assert obj.score == 3.14
|
||||
|
||||
def test_init_ignores_non_annotated_kwargs(self):
|
||||
"""Non-annotated kwargs are silently ignored."""
|
||||
obj = SimpleModel(name="hello", value=1, score=0.0, unknown="ignored")
|
||||
assert not hasattr(obj, "unknown")
|
||||
|
||||
def test_init_coerces_types(self):
|
||||
"""Annotation callables are used to coerce values."""
|
||||
obj = SimpleModel(name="hello", value="99", score="2.5")
|
||||
assert obj.value == 99
|
||||
assert isinstance(obj.value, int)
|
||||
assert obj.score == 2.5
|
||||
assert isinstance(obj.score, float)
|
||||
|
||||
def test_init_fallback_on_conversion_error(self):
|
||||
"""When coercion raises ValueError/TypeError, raw value is kept."""
|
||||
obj = SimpleModel(name="hello", value="not_a_number", score=1.0)
|
||||
# value should be set as the raw string since int("not_a_number") raises ValueError
|
||||
assert obj.value == "not_a_number"
|
||||
|
||||
def test_init_no_args(self):
|
||||
"""Init with no args creates an instance with no instance attributes."""
|
||||
obj = SimpleModel()
|
||||
assert isinstance(obj, SimpleModel)
|
||||
# No instance attributes should be set
|
||||
assert "name" not in obj.__dict__
|
||||
assert "value" not in obj.__dict__
|
||||
|
||||
def test_set_attributes_updates_existing(self):
|
||||
"""set_attributes can update existing attributes."""
|
||||
obj = SimpleModel(name="original", value=1, score=0.0)
|
||||
obj.set_attributes(name="updated", value=100)
|
||||
assert obj.name == "updated"
|
||||
assert obj.value == 100
|
||||
|
||||
def test_set_attributes_ignores_unannotated(self):
|
||||
"""set_attributes ignores keys not in annotations."""
|
||||
obj = SimpleModel(name="test", value=1, score=0.0)
|
||||
obj.set_attributes(phantom="ghost")
|
||||
assert not hasattr(obj, "phantom")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestBaseRepr
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestBaseRepr:
|
||||
"""Tests for Base.__repr__."""
|
||||
|
||||
def test_repr_with_few_attrs(self):
|
||||
"""Repr with ≤3 simple-type attrs shows all."""
|
||||
obj = SimpleModel(name="test", value=42, score=1.5)
|
||||
r = repr(obj)
|
||||
assert r.startswith("SimpleModel(")
|
||||
assert "name=test" in r
|
||||
assert "value=42" in r
|
||||
assert "score=1.5" in r
|
||||
|
||||
def test_repr_with_many_attrs_truncates(self):
|
||||
"""Repr with > 3 attrs shows first 3 + ... + last 1."""
|
||||
obj = ManyAttrsModel(a=1, b=2, c=3, d=4, e=5)
|
||||
r = repr(obj)
|
||||
assert "..." in r
|
||||
assert "a=1" in r
|
||||
assert "e=5" in r
|
||||
|
||||
def test_repr_excludes_private_attrs(self):
|
||||
"""Repr excludes attributes starting with _."""
|
||||
obj = SimpleModel(name="test", value=1, score=0.0)
|
||||
obj._private = "hidden"
|
||||
r = repr(obj)
|
||||
assert "_private" not in r
|
||||
|
||||
def test_repr_excludes_complex_types(self):
|
||||
"""Repr excludes list and dict attrs."""
|
||||
obj = ModelWithComplexAttr(name="test", data=[1, 2, 3], meta={"k": "v"})
|
||||
r = repr(obj)
|
||||
assert "data=" not in r
|
||||
assert "meta=" not in r
|
||||
assert "name=test" in r
|
||||
|
||||
def test_repr_includes_enum_values(self):
|
||||
"""Repr includes enum attributes."""
|
||||
obj = ModelWithEnum(name="test", color=EnumColor.RED, score=1.0)
|
||||
r = repr(obj)
|
||||
assert "color=" in r
|
||||
|
||||
def test_repr_empty_instance(self):
|
||||
"""Repr of instance with no attributes."""
|
||||
obj = SimpleModel()
|
||||
r = repr(obj)
|
||||
assert r == "SimpleModel()"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestBaseAnnotations
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestBaseAnnotations:
|
||||
"""Tests for the annotations property."""
|
||||
|
||||
def test_annotations_returns_own_annotations(self):
|
||||
"""annotations includes annotations from the class itself."""
|
||||
obj = SimpleModel(name="x", value=1, score=0.0)
|
||||
annots = obj.annotations
|
||||
assert "name" in annots
|
||||
assert "value" in annots
|
||||
assert "score" in annots
|
||||
|
||||
def test_annotations_merges_parent(self):
|
||||
"""annotations includes parent class annotations."""
|
||||
obj = ExtendedModel(name="x", value=1, score=0.0, extra="e")
|
||||
annots = obj.annotations
|
||||
assert "name" in annots # from SimpleModel
|
||||
assert "score" in annots # from SimpleModel
|
||||
assert "extra" in annots # from ExtendedModel
|
||||
|
||||
def test_annotations_child_overrides_parent(self):
|
||||
"""Child annotations override parent annotations."""
|
||||
obj = ExtendedModel(name="x", value=1, score=0.0, extra="e")
|
||||
annots = obj.annotations
|
||||
# ExtendedModel annotates value as float, overriding SimpleModel's int
|
||||
assert annots["value"] is float
|
||||
|
||||
def test_annotations_returns_dict(self):
|
||||
"""annotations property returns a dict."""
|
||||
obj = SimpleModel(name="x", value=1, score=0.0)
|
||||
assert isinstance(obj.annotations, dict)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestBaseClassVars
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestBaseClassVars:
|
||||
"""Tests for the class_vars property."""
|
||||
|
||||
def test_class_vars_includes_annotated_defaults(self):
|
||||
"""class_vars includes annotated class-level variables with defaults."""
|
||||
obj = ModelWithClassVar(name="test")
|
||||
cv = obj.class_vars
|
||||
assert "kind" in cv
|
||||
assert cv["kind"] == "default_kind"
|
||||
|
||||
def test_class_vars_excludes_non_annotated(self):
|
||||
"""class_vars excludes class variables that are not annotated."""
|
||||
obj = SimpleModel(name="x", value=1, score=0.0)
|
||||
cv = obj.class_vars
|
||||
# 'exclude' and 'include' are defined on Base but not annotated on SimpleModel
|
||||
# However they ARE annotated on Base itself, so they will appear in class_vars
|
||||
# The key point is that non-annotated attrs are excluded
|
||||
for key in cv:
|
||||
assert key in obj.annotations
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestBaseDict
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestBaseDict:
|
||||
"""Tests for the dict property."""
|
||||
|
||||
def test_dict_returns_instance_and_class_attrs(self):
|
||||
"""dict combines instance attributes and class_vars."""
|
||||
obj = ModelWithClassVar(name="test")
|
||||
d = obj.dict
|
||||
assert "name" in d
|
||||
assert d["name"] == "test"
|
||||
assert "kind" in d
|
||||
assert d["kind"] == "default_kind"
|
||||
|
||||
def test_dict_excludes_default_excluded_keys(self):
|
||||
"""dict excludes keys in the exclude set."""
|
||||
obj = SimpleModel(name="test", value=1, score=0.0)
|
||||
d = obj.dict
|
||||
assert "mt5" not in d
|
||||
assert "config" not in d
|
||||
assert "exclude" not in d
|
||||
assert "include" not in d
|
||||
assert "annotations" not in d
|
||||
assert "class_vars" not in d
|
||||
|
||||
def test_dict_excludes_none_values(self):
|
||||
"""Test dict property excludes None values."""
|
||||
class OptionalAttr(Base):
|
||||
required: int
|
||||
optional: str = None
|
||||
"""dict excludes attributes with None values."""
|
||||
obj = SimpleModel(name="test", value=1, score=0.0)
|
||||
obj.name = None # Manually set to None
|
||||
d = obj.dict
|
||||
assert "name" not in d
|
||||
|
||||
obj = OptionalAttr(required=1)
|
||||
assert "optional" not in obj.dict
|
||||
def test_dict_include_overrides_exclude(self):
|
||||
"""include set can override exclude behavior."""
|
||||
obj = CustomIncludeModel(name="test", config="my_config")
|
||||
d = obj.dict
|
||||
# 'config' is normally excluded, but CustomIncludeModel includes it
|
||||
assert "config" in d
|
||||
|
||||
def test_dict_excludes_internal_attributes(self, child):
|
||||
"""Test dict excludes internal attributes like mt5, config."""
|
||||
dict_prop = child.dict
|
||||
assert "mt5" not in dict_prop
|
||||
assert "config" not in dict_prop
|
||||
assert "exclude" not in dict_prop
|
||||
assert "include" not in dict_prop
|
||||
def test_dict_custom_exclude(self):
|
||||
"""Custom exclude set hides specific attrs."""
|
||||
obj = CustomExcludeModel(name="visible_name", secret="hidden", visible=42)
|
||||
d = obj.dict
|
||||
assert "name" in d
|
||||
assert "visible" in d
|
||||
assert "secret" not in d
|
||||
|
||||
|
||||
class TestUnderscoreBaseClass:
|
||||
"""Tests for the _Base class with MT5/Config integration."""
|
||||
# ===========================================================================
|
||||
# TestBaseGetDict
|
||||
# ===========================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def base_child(self):
|
||||
return ChildBaseClass(attr=1, attr2="test")
|
||||
|
||||
def test_has_mt5_attribute(self, base_child):
|
||||
"""Test _Base provides mt5 attribute."""
|
||||
assert hasattr(base_child, "mt5")
|
||||
class TestBaseGetDict:
|
||||
"""Tests for the get_dict method."""
|
||||
|
||||
def test_has_config_attribute(self, base_child):
|
||||
"""Test _Base provides config attribute."""
|
||||
assert hasattr(base_child, "config")
|
||||
assert isinstance(base_child.config, Config)
|
||||
def test_get_dict_no_args(self):
|
||||
"""get_dict with no args returns all non-None dict items."""
|
||||
obj = SimpleModel(name="test", value=1, score=2.5)
|
||||
d = obj.get_dict()
|
||||
assert "name" in d
|
||||
assert "value" in d
|
||||
assert "score" in d
|
||||
|
||||
def test_mt5_is_metatrader_instance(self, base_child):
|
||||
"""Test mt5 is a MetaTrader instance in async mode."""
|
||||
# Default mode is async
|
||||
assert isinstance(base_child.mt5, MetaTrader)
|
||||
def test_get_dict_include(self):
|
||||
"""get_dict with include filters to specific keys."""
|
||||
obj = SimpleModel(name="test", value=1, score=2.5)
|
||||
d = obj.get_dict(include={"name", "score"})
|
||||
assert "name" in d
|
||||
assert "score" in d
|
||||
assert "value" not in d
|
||||
|
||||
def test_config_is_shared(self):
|
||||
"""Test config is shared across instances."""
|
||||
child1 = ChildBaseClass(attr=1, attr2="test1")
|
||||
child2 = ChildBaseClass(attr=2, attr2="test2")
|
||||
assert child1.config is child2.config
|
||||
def test_get_dict_exclude(self):
|
||||
"""get_dict with exclude filters out specific keys."""
|
||||
obj = SimpleModel(name="test", value=1, score=2.5)
|
||||
d = obj.get_dict(exclude={"value"})
|
||||
assert "value" not in d
|
||||
assert "name" in d
|
||||
assert "score" in d
|
||||
|
||||
def test_mt5_is_shared(self):
|
||||
"""Test mt5 is shared across instances."""
|
||||
child1 = ChildBaseClass(attr=1, attr2="test1")
|
||||
child2 = ChildBaseClass(attr=2, attr2="test2")
|
||||
assert child1.mt5 is child2.mt5
|
||||
def test_get_dict_include_overrides_exclude(self):
|
||||
"""When both include and exclude are set, include takes precedence."""
|
||||
obj = SimpleModel(name="test", value=1, score=2.5)
|
||||
d = obj.get_dict(include={"name"}, exclude={"name"})
|
||||
assert "name" in d
|
||||
assert "value" not in d
|
||||
|
||||
def test_getstate_excludes_mt5(self, base_child):
|
||||
"""Test __getstate__ excludes mt5 for pickling."""
|
||||
state = base_child.__getstate__()
|
||||
def test_get_dict_excludes_none_values(self):
|
||||
"""get_dict always excludes None values regardless of filters."""
|
||||
obj = SimpleModel(name="test", value=1, score=2.5)
|
||||
obj.score = None
|
||||
d = obj.get_dict(include={"name", "score"})
|
||||
assert "name" in d
|
||||
assert "score" not in d
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestBaseMeta
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestBaseMeta:
|
||||
"""Tests for the BaseMeta metaclass behavior."""
|
||||
|
||||
def test_instantiation_triggers_setup(self):
|
||||
"""Instantiating a _Base subclass triggers _setup."""
|
||||
obj = AsyncBaseModel(name="test")
|
||||
assert hasattr(AsyncBaseModel, "config")
|
||||
assert hasattr(AsyncBaseModel, "mt5")
|
||||
|
||||
def test_accessing_config_on_class_triggers_setup(self):
|
||||
"""Accessing 'config' on a _Base subclass class triggers _setup."""
|
||||
# Create a fresh class to test lazy setup
|
||||
class FreshModel(_Base):
|
||||
name: str
|
||||
|
||||
_ = FreshModel.config
|
||||
assert isinstance(FreshModel.__dict__["config"], Config)
|
||||
|
||||
def test_accessing_mt5_on_class_triggers_setup(self):
|
||||
"""Accessing 'mt5' on a _Base subclass class triggers _setup."""
|
||||
class FreshModel2(_Base):
|
||||
name: str
|
||||
|
||||
_ = FreshModel2.mt5
|
||||
assert isinstance(FreshModel2.__dict__["mt5"], MetaTrader)
|
||||
|
||||
def test_setup_creates_config_instance(self):
|
||||
"""_setup sets config as a Config instance."""
|
||||
class TestSetupConfig(_Base):
|
||||
name: str
|
||||
|
||||
TestSetupConfig._setup()
|
||||
assert isinstance(TestSetupConfig.__dict__["config"], Config)
|
||||
|
||||
def test_setup_creates_async_meta_trader_by_default(self):
|
||||
"""_setup creates MetaTrader (async) when mode is not 'sync'."""
|
||||
class TestAsyncMT(_Base):
|
||||
name: str
|
||||
|
||||
TestAsyncMT._setup()
|
||||
assert isinstance(TestAsyncMT.__dict__["mt5"], MetaTrader)
|
||||
|
||||
def test_setup_creates_sync_meta_trader_for_sync_mode(self):
|
||||
"""_setup creates MetaTraderSync when mode is 'sync'."""
|
||||
class TestSyncMT(_Base):
|
||||
mode = "sync"
|
||||
name: str
|
||||
|
||||
TestSyncMT._setup()
|
||||
assert isinstance(TestSyncMT.__dict__["mt5"], MetaTraderSync)
|
||||
|
||||
def test_setup_is_idempotent(self):
|
||||
"""Calling _setup twice doesn't recreate config/mt5."""
|
||||
class IdempotentModel(_Base):
|
||||
name: str
|
||||
|
||||
IdempotentModel._setup()
|
||||
config1 = IdempotentModel.__dict__["config"]
|
||||
mt5_1 = IdempotentModel.__dict__["mt5"]
|
||||
|
||||
IdempotentModel._setup()
|
||||
config2 = IdempotentModel.__dict__["config"]
|
||||
mt5_2 = IdempotentModel.__dict__["mt5"]
|
||||
|
||||
assert config1 is config2
|
||||
assert mt5_1 is mt5_2
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestBasePrivateBase
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestBasePrivateBase:
|
||||
"""Tests for the _Base class."""
|
||||
|
||||
def test_inherits_from_base(self):
|
||||
"""_Base inherits from Base."""
|
||||
assert issubclass(_Base, Base)
|
||||
|
||||
def test_has_base_meta_metaclass(self):
|
||||
"""_Base uses BaseMeta as its metaclass."""
|
||||
assert type(_Base) is BaseMeta
|
||||
|
||||
def test_default_mode_is_async(self):
|
||||
"""Default mode for _Base is 'async'."""
|
||||
assert _Base.mode == "async"
|
||||
|
||||
def test_getstate_removes_mt5(self):
|
||||
"""__getstate__ removes mt5 from instance state."""
|
||||
obj = AsyncBaseModel(name="test")
|
||||
obj.mt5_attr = "should_stay" # custom attr
|
||||
state = obj.__getstate__()
|
||||
assert "mt5" not in state
|
||||
|
||||
def test_getstate_preserves_other_attributes(self, base_child):
|
||||
"""Test __getstate__ preserves other instance attributes."""
|
||||
state = base_child.__getstate__()
|
||||
assert state["attr"] == 1
|
||||
assert state["attr2"] == "test"
|
||||
def test_getstate_preserves_other_attrs(self):
|
||||
"""__getstate__ keeps all attributes except mt5."""
|
||||
obj = AsyncBaseModel(name="test_name")
|
||||
state = obj.__getstate__()
|
||||
assert state.get("name") == "test_name"
|
||||
|
||||
def test_inherits_from_base(self, base_child):
|
||||
"""Test _Base inherits from Base."""
|
||||
assert isinstance(base_child, Base)
|
||||
def test_config_accessible_after_instantiation(self):
|
||||
"""config is accessible as a class attribute after instantiation."""
|
||||
obj = AsyncBaseModel(name="test")
|
||||
assert isinstance(obj.config, Config)
|
||||
|
||||
def test_dict_property_works(self, base_child):
|
||||
"""Test dict property works correctly."""
|
||||
dict_prop = base_child.dict
|
||||
assert dict_prop["attr"] == 1
|
||||
assert dict_prop["attr2"] == "test"
|
||||
assert dict_prop["cls_attr"] == 20
|
||||
def test_mt5_accessible_after_instantiation(self):
|
||||
"""mt5 is accessible as a class attribute after instantiation."""
|
||||
obj = AsyncBaseModel(name="test")
|
||||
assert isinstance(obj.mt5, MetaTrader)
|
||||
|
||||
def test_mode_attribute(self, base_child):
|
||||
"""Test default mode is async."""
|
||||
assert base_child.mode == "async"
|
||||
def test_sync_mode_creates_sync_meta_trader(self):
|
||||
"""Sync mode subclass gets MetaTraderSync."""
|
||||
obj = SyncBaseModel(name="sync_test")
|
||||
assert isinstance(SyncBaseModel.__dict__["mt5"], MetaTraderSync)
|
||||
|
||||
def test_class_setup_called_on_new(self):
|
||||
"""Test _setup is called during instance creation."""
|
||||
child = ChildBaseClass(attr=1, attr2="test")
|
||||
# If _setup was called, mt5 and config should be set
|
||||
assert hasattr(ChildBaseClass, "mt5")
|
||||
assert hasattr(ChildBaseClass, "config")
|
||||
def test_async_mode_creates_async_meta_trader(self):
|
||||
"""Async mode subclass gets MetaTrader."""
|
||||
obj = AsyncBaseModel(name="async_test")
|
||||
assert isinstance(AsyncBaseModel.__dict__["mt5"], MetaTrader)
|
||||
|
||||
def test_getstate_does_not_modify_original_dict(self):
|
||||
"""__getstate__ returns a copy, not modifying __dict__."""
|
||||
obj = AsyncBaseModel(name="test")
|
||||
original_dict = obj.__dict__.copy()
|
||||
_ = obj.__getstate__()
|
||||
assert obj.__dict__ == original_dict
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestBaseSubclassing
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestBaseSubclassing:
|
||||
"""Tests for subclassing Base with annotation and exclude/include merging."""
|
||||
|
||||
def test_subclass_annotations_merge(self):
|
||||
"""Subclass annotations include parent annotations."""
|
||||
obj = ExtendedModel(name="x", value=1.5, score=0.0, extra="e")
|
||||
annots = obj.annotations
|
||||
assert "name" in annots
|
||||
assert "score" in annots
|
||||
assert "extra" in annots
|
||||
|
||||
def test_subclass_override_exclude(self):
|
||||
"""Subclass can define its own exclude set."""
|
||||
obj = CustomExcludeModel(name="n", secret="s", visible=1)
|
||||
d = obj.dict
|
||||
assert "secret" not in d
|
||||
assert "name" in d
|
||||
|
||||
def test_subclass_override_include(self):
|
||||
"""Subclass include set overrides parent exclude."""
|
||||
obj = CustomIncludeModel(name="n", config="cfg")
|
||||
d = obj.dict
|
||||
assert "config" in d
|
||||
|
||||
def test_multiple_levels_of_inheritance(self):
|
||||
"""Annotations from deeply nested inheritance chain are merged."""
|
||||
class GrandChild(ExtendedModel):
|
||||
level: int
|
||||
|
||||
obj = GrandChild(name="gc", value=1.0, score=2.0, extra="e", level=3)
|
||||
annots = obj.annotations
|
||||
assert "name" in annots
|
||||
assert "extra" in annots
|
||||
assert "level" in annots
|
||||
assert annots["value"] is float # ExtendedModel override
|
||||
|
||||
def test_subclass_class_vars_include_parent_defaults(self):
|
||||
"""Subclass class_vars include annotated defaults from parent."""
|
||||
class ChildWithDefault(ModelWithClassVar):
|
||||
extra: str = "extra_default"
|
||||
|
||||
obj = ChildWithDefault(name="test")
|
||||
cv = obj.class_vars
|
||||
assert "kind" in cv
|
||||
assert cv["kind"] == "default_kind"
|
||||
assert "extra" in cv
|
||||
assert cv["extra"] == "extra_default"
|
||||
|
||||
def test_isinstance_checks(self):
|
||||
"""Subclass instances pass isinstance checks for parent."""
|
||||
obj = ExtendedModel(name="x", value=1, score=0.0, extra="e")
|
||||
assert isinstance(obj, Base)
|
||||
assert isinstance(obj, SimpleModel)
|
||||
assert isinstance(obj, ExtendedModel)
|
||||
|
||||
@@ -35,7 +35,6 @@ from aiomql.lib.strategy import Strategy
|
||||
from aiomql.lib.symbol import Symbol
|
||||
from aiomql.core.config import Config
|
||||
from aiomql.core.meta_trader import MetaTrader
|
||||
from aiomql.core.meta_backtester import MetaBackTester
|
||||
|
||||
|
||||
class MockStrategy:
|
||||
@@ -83,11 +82,9 @@ class TestBotInitialization:
|
||||
@patch('aiomql.lib.executor.signal')
|
||||
@patch.object(Config, '__new__')
|
||||
@patch('aiomql.lib.bot.MetaTrader')
|
||||
@patch('aiomql.lib.bot.MetaBackTester')
|
||||
def test_init_creates_config(self, mock_backtester, mock_metatrader, mock_config_new, mock_signal):
|
||||
def test_init_creates_config(self, mock_metatrader, mock_config_new, mock_signal):
|
||||
"""Test Bot init creates config instance."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "live"
|
||||
mock_config_new.return_value = mock_config
|
||||
|
||||
bot = Bot()
|
||||
@@ -97,11 +94,9 @@ class TestBotInitialization:
|
||||
@patch('aiomql.lib.executor.signal')
|
||||
@patch.object(Config, '__new__')
|
||||
@patch('aiomql.lib.bot.MetaTrader')
|
||||
@patch('aiomql.lib.bot.MetaBackTester')
|
||||
def test_init_creates_executor(self, mock_backtester, mock_metatrader, mock_config_new, mock_signal):
|
||||
def test_init_creates_executor(self, mock_metatrader, mock_config_new, mock_signal):
|
||||
"""Test Bot init creates executor instance."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "live"
|
||||
mock_config_new.return_value = mock_config
|
||||
|
||||
bot = Bot()
|
||||
@@ -112,11 +107,9 @@ class TestBotInitialization:
|
||||
@patch('aiomql.lib.executor.signal')
|
||||
@patch.object(Config, '__new__')
|
||||
@patch('aiomql.lib.bot.MetaTrader')
|
||||
@patch('aiomql.lib.bot.MetaBackTester')
|
||||
def test_init_creates_empty_strategies_list(self, mock_backtester, mock_metatrader, mock_config_new, mock_signal):
|
||||
def test_init_creates_empty_strategies_list(self, mock_metatrader, mock_config_new, mock_signal):
|
||||
"""Test Bot init creates empty strategies list."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "live"
|
||||
mock_config_new.return_value = mock_config
|
||||
|
||||
bot = Bot()
|
||||
@@ -126,11 +119,9 @@ class TestBotInitialization:
|
||||
@patch('aiomql.lib.executor.signal')
|
||||
@patch.object(Config, '__new__')
|
||||
@patch('aiomql.lib.bot.MetaTrader')
|
||||
@patch('aiomql.lib.bot.MetaBackTester')
|
||||
def test_init_sets_initialized_false(self, mock_backtester, mock_metatrader, mock_config_new, mock_signal):
|
||||
def test_init_sets_initialized_false(self, mock_metatrader, mock_config_new, mock_signal):
|
||||
"""Test Bot init sets initialized to False."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "live"
|
||||
mock_config_new.return_value = mock_config
|
||||
|
||||
bot = Bot()
|
||||
@@ -140,11 +131,9 @@ class TestBotInitialization:
|
||||
@patch('aiomql.lib.executor.signal')
|
||||
@patch.object(Config, '__new__')
|
||||
@patch('aiomql.lib.bot.MetaTrader')
|
||||
@patch('aiomql.lib.bot.MetaBackTester')
|
||||
def test_init_sets_login_false(self, mock_backtester, mock_metatrader, mock_config_new, mock_signal):
|
||||
def test_init_sets_login_false(self, mock_metatrader, mock_config_new, mock_signal):
|
||||
"""Test Bot init sets login to False."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "live"
|
||||
mock_config_new.return_value = mock_config
|
||||
|
||||
bot = Bot()
|
||||
@@ -154,11 +143,9 @@ class TestBotInitialization:
|
||||
@patch('aiomql.lib.executor.signal')
|
||||
@patch.object(Config, '__new__')
|
||||
@patch('aiomql.lib.bot.MetaTrader')
|
||||
@patch('aiomql.lib.bot.MetaBackTester')
|
||||
def test_init_uses_metatrader_for_live_mode(self, mock_backtester, mock_metatrader, mock_config_new, mock_signal):
|
||||
"""Test Bot init uses MetaTrader for live mode."""
|
||||
def test_init_creates_metatrader_instance(self, mock_metatrader, mock_config_new, mock_signal):
|
||||
"""Test Bot init creates MetaTrader instance."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "live"
|
||||
mock_config_new.return_value = mock_config
|
||||
mock_mt = MagicMock()
|
||||
mock_metatrader.return_value = mock_mt
|
||||
@@ -171,19 +158,15 @@ class TestBotInitialization:
|
||||
@patch('aiomql.lib.executor.signal')
|
||||
@patch.object(Config, '__new__')
|
||||
@patch('aiomql.lib.bot.MetaTrader')
|
||||
@patch('aiomql.lib.bot.MetaBackTester')
|
||||
def test_init_uses_metabacktester_for_backtest_mode(self, mock_backtester, mock_metatrader, mock_config_new, mock_signal):
|
||||
"""Test Bot init uses MetaBackTester for backtest mode."""
|
||||
def test_init_passes_bot_to_config(self, mock_metatrader, mock_config_new, mock_signal):
|
||||
"""Test Bot init passes itself to Config constructor."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "backtest"
|
||||
mock_config_new.return_value = mock_config
|
||||
mock_bt = MagicMock()
|
||||
mock_backtester.return_value = mock_bt
|
||||
|
||||
bot = Bot()
|
||||
|
||||
mock_backtester.assert_called_once()
|
||||
assert bot.mt5 == mock_bt
|
||||
# Config is called with bot=self
|
||||
mock_config_new.assert_called()
|
||||
|
||||
|
||||
class TestProcessPool:
|
||||
@@ -239,6 +222,31 @@ class TestProcessPool:
|
||||
|
||||
mock_pool.assert_called_once_with(max_workers=5)
|
||||
|
||||
def test_process_pool_multiple_processes(self):
|
||||
"""Test process_pool submits all processes."""
|
||||
def mock_process1(**kwargs):
|
||||
pass
|
||||
|
||||
def mock_process2(**kwargs):
|
||||
pass
|
||||
|
||||
def mock_process3(**kwargs):
|
||||
pass
|
||||
|
||||
with patch.object(ProcessPoolExecutor, '__init__', return_value=None):
|
||||
with patch.object(ProcessPoolExecutor, '__enter__') as mock_enter:
|
||||
mock_executor = MagicMock()
|
||||
mock_enter.return_value = mock_executor
|
||||
with patch.object(ProcessPoolExecutor, '__exit__', return_value=None):
|
||||
processes = {
|
||||
mock_process1: {"x": 1},
|
||||
mock_process2: {"y": 2},
|
||||
mock_process3: {},
|
||||
}
|
||||
Bot.process_pool(processes=processes, num_workers=4)
|
||||
|
||||
assert mock_executor.submit.call_count == 3
|
||||
|
||||
|
||||
class TestStartTerminal:
|
||||
"""Test Bot start_terminal and start_terminal_sync methods."""
|
||||
@@ -249,7 +257,6 @@ class TestStartTerminal:
|
||||
with patch('aiomql.lib.executor.signal'):
|
||||
with patch.object(Config, '__new__') as mock_config_new:
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "live"
|
||||
mock_config_new.return_value = mock_config
|
||||
with patch('aiomql.lib.bot.MetaTrader') as mock_mt:
|
||||
mock_mt_instance = MagicMock()
|
||||
@@ -290,6 +297,25 @@ class TestStartTerminal:
|
||||
assert bot.initialized is True
|
||||
assert bot.login is False
|
||||
|
||||
async def test_start_terminal_calls_initialize_then_login(self, bot):
|
||||
"""Test start_terminal calls initialize before login."""
|
||||
call_order = []
|
||||
bot.mt5.initialize = AsyncMock(return_value=True, side_effect=lambda: call_order.append("init") or True)
|
||||
bot.mt5.login = AsyncMock(return_value=True, side_effect=lambda: call_order.append("login") or True)
|
||||
|
||||
await bot.start_terminal()
|
||||
|
||||
assert call_order == ["init", "login"]
|
||||
|
||||
async def test_start_terminal_skips_login_when_init_fails(self, bot):
|
||||
"""Test start_terminal does not call login when initialize fails."""
|
||||
bot.mt5.initialize = AsyncMock(return_value=False)
|
||||
bot.mt5.login = AsyncMock(return_value=True)
|
||||
|
||||
await bot.start_terminal()
|
||||
|
||||
bot.mt5.login.assert_not_called()
|
||||
|
||||
def test_start_terminal_sync_success(self, bot):
|
||||
"""Test start_terminal_sync with successful login."""
|
||||
bot.mt5.initialize_sync = MagicMock(return_value=True)
|
||||
@@ -322,6 +348,15 @@ class TestStartTerminal:
|
||||
assert bot.initialized is True
|
||||
assert bot.login is False
|
||||
|
||||
def test_start_terminal_sync_skips_login_when_init_fails(self, bot):
|
||||
"""Test start_terminal_sync does not call login_sync when initialize fails."""
|
||||
bot.mt5.initialize_sync = MagicMock(return_value=False)
|
||||
bot.mt5.login_sync = MagicMock(return_value=True)
|
||||
|
||||
bot.start_terminal_sync()
|
||||
|
||||
bot.mt5.login_sync.assert_not_called()
|
||||
|
||||
|
||||
class TestInitialize:
|
||||
"""Test Bot initialize and initialize_sync methods."""
|
||||
@@ -332,7 +367,6 @@ class TestInitialize:
|
||||
with patch('aiomql.lib.executor.signal'):
|
||||
with patch.object(Config, '__new__') as mock_config_new:
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "live"
|
||||
mock_config.shutdown = False
|
||||
mock_config.task_queue = MagicMock()
|
||||
mock_config.task_queue.run = AsyncMock()
|
||||
@@ -403,6 +437,16 @@ class TestInitialize:
|
||||
|
||||
assert bot.config.shutdown is False
|
||||
|
||||
async def test_initialize_calls_init_strategies(self, bot):
|
||||
"""Test initialize calls init_strategies."""
|
||||
bot.mt5.initialize = AsyncMock(return_value=True)
|
||||
bot.mt5.login = AsyncMock(return_value=True)
|
||||
|
||||
with patch.object(bot, 'init_strategies', new_callable=AsyncMock) as mock_init_strats:
|
||||
await bot.initialize()
|
||||
|
||||
mock_init_strats.assert_called_once()
|
||||
|
||||
def test_initialize_sync_successful_login(self, bot):
|
||||
"""Test initialize_sync with successful login."""
|
||||
bot.mt5.initialize_sync = MagicMock(return_value=True)
|
||||
@@ -449,6 +493,16 @@ class TestInitialize:
|
||||
|
||||
assert bot.config.shutdown is True
|
||||
|
||||
def test_initialize_sync_calls_init_strategies_sync(self, bot):
|
||||
"""Test initialize_sync calls init_strategies_sync."""
|
||||
bot.mt5.initialize_sync = MagicMock(return_value=True)
|
||||
bot.mt5.login_sync = MagicMock(return_value=True)
|
||||
|
||||
with patch.object(bot, 'init_strategies_sync') as mock_init_strats:
|
||||
bot.initialize_sync()
|
||||
|
||||
mock_init_strats.assert_called_once()
|
||||
|
||||
|
||||
class TestAddFunctionAndCoroutine:
|
||||
"""Test Bot add_function and add_coroutine methods."""
|
||||
@@ -459,7 +513,6 @@ class TestAddFunctionAndCoroutine:
|
||||
with patch('aiomql.lib.executor.signal'):
|
||||
with patch.object(Config, '__new__') as mock_config_new:
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "live"
|
||||
mock_config_new.return_value = mock_config
|
||||
with patch('aiomql.lib.bot.MetaTrader'):
|
||||
return Bot()
|
||||
@@ -524,7 +577,6 @@ class TestExecuteAndStart:
|
||||
with patch('aiomql.lib.executor.signal'):
|
||||
with patch.object(Config, '__new__') as mock_config_new:
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "live"
|
||||
mock_config.shutdown = False
|
||||
mock_config.task_queue = MagicMock()
|
||||
mock_config.task_queue.run = AsyncMock()
|
||||
@@ -618,7 +670,6 @@ class TestAddStrategy:
|
||||
with patch('aiomql.lib.executor.signal'):
|
||||
with patch.object(Config, '__new__') as mock_config_new:
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "live"
|
||||
mock_config_new.return_value = mock_config
|
||||
with patch('aiomql.lib.bot.MetaTrader'):
|
||||
return Bot()
|
||||
@@ -667,6 +718,25 @@ class TestAddStrategy:
|
||||
|
||||
assert len(bot.strategies) == 2
|
||||
|
||||
def test_add_strategies_with_tuple(self, bot):
|
||||
"""Test add_strategies works with tuple input."""
|
||||
strategy1 = MockStrategy()
|
||||
strategy2 = MockStrategy()
|
||||
|
||||
bot.add_strategies(strategies=(strategy1, strategy2))
|
||||
|
||||
assert len(bot.strategies) == 2
|
||||
|
||||
def test_add_strategies_with_generator(self, bot):
|
||||
"""Test add_strategies works with generator input."""
|
||||
def strategy_gen():
|
||||
yield MockStrategy()
|
||||
yield MockStrategy()
|
||||
|
||||
bot.add_strategies(strategies=strategy_gen())
|
||||
|
||||
assert len(bot.strategies) == 2
|
||||
|
||||
def test_add_strategy_all_creates_strategy_per_symbol(self, bot):
|
||||
"""Test add_strategy_all creates strategy for each symbol."""
|
||||
mock_symbol1 = MagicMock(spec=Symbol)
|
||||
@@ -705,6 +775,21 @@ class TestAddStrategy:
|
||||
|
||||
assert bot.strategies[0].kwargs.get("extra_arg") == "extra_value"
|
||||
|
||||
def test_add_strategy_all_assigns_correct_symbols(self, bot):
|
||||
"""Test add_strategy_all assigns the correct symbol to each strategy."""
|
||||
mock_symbol1 = MagicMock(spec=Symbol)
|
||||
mock_symbol1.name = "EURUSD"
|
||||
mock_symbol2 = MagicMock(spec=Symbol)
|
||||
mock_symbol2.name = "GBPUSD"
|
||||
|
||||
bot.add_strategy_all(
|
||||
strategy=MockStrategy,
|
||||
symbols=[mock_symbol1, mock_symbol2]
|
||||
)
|
||||
|
||||
assert bot.strategies[0].symbol == mock_symbol1
|
||||
assert bot.strategies[1].symbol == mock_symbol2
|
||||
|
||||
|
||||
class TestInitStrategy:
|
||||
"""Test Bot init_strategy, init_strategies, init_strategy_sync, and init_strategies_sync methods."""
|
||||
@@ -715,7 +800,6 @@ class TestInitStrategy:
|
||||
with patch('aiomql.lib.executor.signal'):
|
||||
with patch.object(Config, '__new__') as mock_config_new:
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "live"
|
||||
mock_config_new.return_value = mock_config
|
||||
with patch('aiomql.lib.bot.MetaTrader'):
|
||||
return Bot()
|
||||
@@ -740,6 +824,15 @@ class TestInitStrategy:
|
||||
assert result is False
|
||||
mock_add.assert_not_called()
|
||||
|
||||
async def test_init_strategy_returns_bool(self, bot):
|
||||
"""Test init_strategy returns boolean result."""
|
||||
strategy = MockStrategy()
|
||||
|
||||
with patch.object(bot.executor, 'add_strategy'):
|
||||
result = await bot.init_strategy(strategy=strategy)
|
||||
|
||||
assert isinstance(result, bool)
|
||||
|
||||
async def test_init_strategies_initializes_all(self, bot):
|
||||
"""Test init_strategies initializes all strategies."""
|
||||
strategy1 = MockStrategy()
|
||||
@@ -766,6 +859,20 @@ class TestInitStrategy:
|
||||
# Only successful strategy should be added
|
||||
assert bot.executor.add_strategy.call_count == 1
|
||||
|
||||
async def test_init_strategies_uses_gather(self, bot):
|
||||
"""Test init_strategies uses asyncio.gather for concurrent initialization."""
|
||||
strategy1 = MockStrategy()
|
||||
strategy2 = MockStrategy()
|
||||
strategy3 = MockStrategy()
|
||||
|
||||
bot.strategies = [strategy1, strategy2, strategy3]
|
||||
|
||||
with patch.object(bot.executor, 'add_strategy'):
|
||||
with patch('aiomql.lib.bot.asyncio.gather', new_callable=AsyncMock, return_value=[True, True, True]) as mock_gather:
|
||||
await bot.init_strategies()
|
||||
|
||||
mock_gather.assert_called_once()
|
||||
|
||||
def test_init_strategy_sync_success_adds_to_executor(self, bot):
|
||||
"""Test init_strategy_sync adds successful strategy to executor."""
|
||||
strategy = MockStrategy()
|
||||
@@ -812,6 +919,22 @@ class TestInitStrategy:
|
||||
# Only successful strategy should be added
|
||||
assert bot.executor.add_strategy.call_count == 1
|
||||
|
||||
def test_init_strategies_sync_sequential(self, bot):
|
||||
"""Test init_strategies_sync initializes strategies sequentially."""
|
||||
strategy1 = MockStrategy()
|
||||
strategy2 = MockStrategy()
|
||||
|
||||
bot.strategies = [strategy1, strategy2]
|
||||
|
||||
with patch.object(bot.executor, 'add_strategy') as mock_add:
|
||||
bot.init_strategies_sync()
|
||||
|
||||
# Verify both were added
|
||||
calls = mock_add.call_args_list
|
||||
assert len(calls) == 2
|
||||
assert calls[0] == call(strategy=strategy1)
|
||||
assert calls[1] == call(strategy=strategy2)
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests for Bot."""
|
||||
@@ -822,7 +945,6 @@ class TestIntegration:
|
||||
with patch('aiomql.lib.executor.signal'):
|
||||
with patch.object(Config, '__new__') as mock_config_new:
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "live"
|
||||
mock_config.shutdown = False
|
||||
mock_config.task_queue = MagicMock()
|
||||
mock_config.task_queue.run = AsyncMock()
|
||||
@@ -913,38 +1035,20 @@ class TestIntegration:
|
||||
# Only successful strategy should be added to executor
|
||||
assert len(bot.executor.strategy_runners) == 1
|
||||
|
||||
def test_backtest_mode_uses_metabacktester(self):
|
||||
"""Test bot uses MetaBackTester in backtest mode."""
|
||||
def test_bot_always_uses_metatrader(self):
|
||||
"""Test bot always creates MetaTrader instance."""
|
||||
with patch('aiomql.lib.executor.signal'):
|
||||
with patch.object(Config, '__new__') as mock_config_new:
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "backtest"
|
||||
mock_config_new.return_value = mock_config
|
||||
with patch('aiomql.lib.bot.MetaTrader') as mock_mt:
|
||||
with patch('aiomql.lib.bot.MetaBackTester') as mock_bt:
|
||||
mock_bt_instance = MagicMock()
|
||||
mock_bt.return_value = mock_bt_instance
|
||||
|
||||
bot = Bot()
|
||||
|
||||
mock_bt.assert_called_once()
|
||||
assert bot.mt5 == mock_bt_instance
|
||||
|
||||
def test_live_mode_uses_metatrader(self):
|
||||
"""Test bot uses MetaTrader in live mode."""
|
||||
with patch('aiomql.lib.executor.signal'):
|
||||
with patch.object(Config, '__new__') as mock_config_new:
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "live"
|
||||
mock_config_new.return_value = mock_config
|
||||
with patch('aiomql.lib.bot.MetaTrader') as mock_mt:
|
||||
mock_mt_instance = MagicMock()
|
||||
mock_mt.return_value = mock_mt_instance
|
||||
with patch('aiomql.lib.bot.MetaBackTester'):
|
||||
bot = Bot()
|
||||
|
||||
mock_mt.assert_called_once()
|
||||
assert bot.mt5 == mock_mt_instance
|
||||
bot = Bot()
|
||||
|
||||
mock_mt.assert_called_once()
|
||||
assert bot.mt5 == mock_mt_instance
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
@@ -956,7 +1060,6 @@ class TestEdgeCases:
|
||||
with patch('aiomql.lib.executor.signal'):
|
||||
with patch.object(Config, '__new__') as mock_config_new:
|
||||
mock_config = MagicMock()
|
||||
mock_config.mode = "live"
|
||||
mock_config.shutdown = False
|
||||
mock_config.task_queue = MagicMock()
|
||||
mock_config.task_queue.run = AsyncMock()
|
||||
@@ -1040,3 +1143,28 @@ class TestEdgeCases:
|
||||
bot.init_strategies_sync()
|
||||
|
||||
assert len(bot.executor.strategy_runners) == 0
|
||||
|
||||
async def test_start_terminal_return_value_propagated(self, bot):
|
||||
"""Test start_terminal return value is the result of the last operation."""
|
||||
bot.mt5.initialize = AsyncMock(return_value=True)
|
||||
bot.mt5.login = AsyncMock(return_value=True)
|
||||
|
||||
result = await bot.start_terminal()
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_execute_checks_shutdown_after_initialize(self, bot):
|
||||
"""Test execute checks config.shutdown after initialize_sync."""
|
||||
call_order = []
|
||||
|
||||
def mock_init():
|
||||
call_order.append("init")
|
||||
bot.config.shutdown = True # Set shutdown during init
|
||||
|
||||
with patch.object(bot, 'initialize_sync', side_effect=mock_init):
|
||||
with patch.object(bot.executor, 'execute') as mock_exec:
|
||||
bot.execute()
|
||||
|
||||
# initialize_sync should be called but executor.execute should not
|
||||
assert call_order == ["init"]
|
||||
mock_exec.assert_not_called()
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from aiomql.lib.bot import Bot
|
||||
|
||||
|
||||
class TestBotFactoryAndExecutor:
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
cls.bot = Bot()
|
||||
cls.sync_bot = Bot()
|
||||
|
||||
@pytest.fixture(scope="class", autouse=True)
|
||||
async def initialize(self):
|
||||
self.bot.add_coroutine(coroutine=self.coro_one)
|
||||
self.bot.add_coroutine(coroutine=self.coro_two)
|
||||
self.bot.add_function(function=self.fun_one)
|
||||
self.bot.add_coroutine(coroutine=self.coro_thread, on_separate_thread=True)
|
||||
await self.bot.initialize()
|
||||
|
||||
@pytest.fixture(scope="class", autouse=True)
|
||||
def initialize_sync(self):
|
||||
self.sync_bot.add_coroutine(coroutine=self.coro_one)
|
||||
self.sync_bot.add_coroutine(coroutine=self.coro_two)
|
||||
self.sync_bot.add_function(function=self.fun_one)
|
||||
self.sync_bot.add_coroutine(coroutine=self.coro_thread, on_separate_thread=True)
|
||||
self.sync_bot.initialize_sync()
|
||||
|
||||
@staticmethod
|
||||
def fun_one():
|
||||
print("function one")
|
||||
|
||||
@staticmethod
|
||||
async def coro_thread():
|
||||
while True:
|
||||
print("coroutine thread")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@staticmethod
|
||||
async def coro_one():
|
||||
while True:
|
||||
print("coroutine one")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@staticmethod
|
||||
async def coro_two():
|
||||
while True:
|
||||
print("coroutine two")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
def test_add_workers(self):
|
||||
assert len(self.bot.executor.coroutines) == 3
|
||||
assert len(self.bot.executor.functions) == 1
|
||||
# task_queue already added coroutine_thread
|
||||
assert len(self.bot.executor.coroutine_threads) == 2
|
||||
|
||||
def test_sync_add_workers(self):
|
||||
assert len(self.sync_bot.executor.coroutines) == 3
|
||||
assert len(self.sync_bot.executor.functions) == 1
|
||||
# task_queue already added coroutine_thread
|
||||
assert len(self.sync_bot.executor.coroutine_threads) == 2
|
||||
@@ -1,29 +1,791 @@
|
||||
"""Comprehensive tests for the Config module.
|
||||
|
||||
Tests cover:
|
||||
- Singleton pattern (__new__)
|
||||
- Initialization (__init__)
|
||||
- __setattr__ behavior
|
||||
- set_attributes method
|
||||
- find_config_file method
|
||||
- set_root method
|
||||
- load_config method
|
||||
- state and store properties
|
||||
- init_state and init_store methods
|
||||
- records_dir and plots_dir cached properties
|
||||
- account_info property
|
||||
- Default values
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from unittest.mock import MagicMock, patch, mock_open
|
||||
|
||||
import pytest
|
||||
|
||||
from aiomql.core.config import Config
|
||||
from aiomql.core.backtesting import BackTestEngine
|
||||
from aiomql.core.task_queue import TaskQueue
|
||||
from aiomql.core.state import State
|
||||
from aiomql.core.store import Store
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_singleton(self, config):
|
||||
config2 = Config(filename="test.json")
|
||||
assert config is config2
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_singleton():
|
||||
"""Reset Config singleton before each test to ensure isolation."""
|
||||
if hasattr(Config, "_instance"):
|
||||
del Config._instance
|
||||
# Clean up class-level attributes that may have been set by previous tests
|
||||
for key in list(Config._defaults.keys()):
|
||||
if hasattr(Config, key) and key != '_defaults':
|
||||
try:
|
||||
delattr(Config, key)
|
||||
except AttributeError:
|
||||
pass
|
||||
yield
|
||||
if hasattr(Config, "_instance"):
|
||||
del Config._instance
|
||||
for key in list(Config._defaults.keys()):
|
||||
if hasattr(Config, key) and key != '_defaults':
|
||||
try:
|
||||
delattr(Config, key)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def test_set_attributes(self, config):
|
||||
|
||||
@pytest.fixture
|
||||
def mock_state():
|
||||
"""Mock State to avoid SQLite operations."""
|
||||
with patch('aiomql.core.config.State') as mock:
|
||||
mock.return_value = MagicMock(spec=State)
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_store():
|
||||
"""Mock Store to avoid SQLite operations."""
|
||||
with patch('aiomql.core.config.Store') as mock:
|
||||
mock.return_value = MagicMock(spec=Store)
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config(mock_state, mock_store, tmp_path):
|
||||
"""Create a Config instance with mocked dependencies."""
|
||||
return Config(root=str(tmp_path))
|
||||
|
||||
|
||||
class TestConfigDefaults:
|
||||
"""Test Config default values."""
|
||||
|
||||
def test_defaults_dict_exists(self):
|
||||
"""Test that _defaults dict is defined on Config."""
|
||||
assert hasattr(Config, '_defaults')
|
||||
assert isinstance(Config._defaults, dict)
|
||||
|
||||
def test_default_timeout(self):
|
||||
"""Test default timeout is 60000."""
|
||||
assert Config._defaults["timeout"] == 60000
|
||||
|
||||
def test_default_record_trades(self):
|
||||
"""Test default record_trades is True."""
|
||||
assert Config._defaults["record_trades"] is True
|
||||
|
||||
def test_default_records_dir_name(self):
|
||||
"""Test default records_dir_name."""
|
||||
assert Config._defaults["records_dir_name"] == "trade_records"
|
||||
|
||||
def test_default_db_dir_name(self):
|
||||
"""Test default db_dir_name."""
|
||||
assert Config._defaults["db_dir_name"] == "db"
|
||||
|
||||
def test_default_trade_record_mode(self):
|
||||
"""Test default trade_record_mode is sql."""
|
||||
assert Config._defaults["trade_record_mode"] == "sql"
|
||||
|
||||
def test_default_mode(self):
|
||||
"""Test default mode is live."""
|
||||
assert Config._defaults["mode"] == "live"
|
||||
|
||||
def test_default_filename(self):
|
||||
"""Test default filename is aiomql.json."""
|
||||
assert Config._defaults["filename"] == "aiomql.json"
|
||||
|
||||
def test_default_shutdown(self):
|
||||
"""Test default shutdown is False."""
|
||||
assert Config._defaults["shutdown"] is False
|
||||
|
||||
def test_default_force_shutdown(self):
|
||||
"""Test default force_shutdown is False."""
|
||||
assert Config._defaults["force_shutdown"] is False
|
||||
|
||||
def test_default_stop_trading(self):
|
||||
"""Test default stop_trading is False."""
|
||||
assert Config._defaults["stop_trading"] is False
|
||||
|
||||
def test_default_db_commit_interval(self):
|
||||
"""Test default db_commit_interval is 30."""
|
||||
assert Config._defaults["db_commit_interval"] == 30
|
||||
|
||||
def test_default_auto_commit(self):
|
||||
"""Test default auto_commit is False."""
|
||||
assert Config._defaults["auto_commit"] is False
|
||||
|
||||
def test_default_flush_state(self):
|
||||
"""Test default flush_state is False."""
|
||||
assert Config._defaults["flush_state"] is False
|
||||
|
||||
def test_default_auto_commit_state(self):
|
||||
"""Test default auto_commit_state is True."""
|
||||
assert Config._defaults["auto_commit_state"] is True
|
||||
|
||||
def test_default_plots_dir_name(self):
|
||||
"""Test default plots_dir_name."""
|
||||
assert Config._defaults["plots_dir_name"] == "plots"
|
||||
|
||||
|
||||
class TestConfigSingleton:
|
||||
"""Test Config singleton pattern (__new__)."""
|
||||
|
||||
def test_singleton_returns_same_instance(self, mock_state, mock_store, tmp_path):
|
||||
"""Test that Config() always returns the same instance."""
|
||||
config1 = Config(root=str(tmp_path))
|
||||
config2 = Config()
|
||||
|
||||
assert config1 is config2
|
||||
|
||||
def test_singleton_with_different_kwargs(self, mock_state, mock_store, tmp_path):
|
||||
"""Test that Config with different kwargs returns same instance."""
|
||||
config1 = Config(root=str(tmp_path))
|
||||
config2 = Config(timeout=5000)
|
||||
|
||||
assert config1 is config2
|
||||
|
||||
def test_singleton_sets_task_queue(self, mock_state, mock_store, tmp_path):
|
||||
"""Test that __new__ initializes task_queue."""
|
||||
config = Config(root=str(tmp_path))
|
||||
|
||||
assert hasattr(config, 'task_queue')
|
||||
assert isinstance(config.task_queue, TaskQueue)
|
||||
|
||||
def test_singleton_sets_bot_to_none(self, mock_state, mock_store, tmp_path):
|
||||
"""Test that __new__ sets bot to None."""
|
||||
config = Config(root=str(tmp_path))
|
||||
|
||||
assert config.bot is None
|
||||
|
||||
def test_singleton_applies_defaults(self, mock_state, mock_store, tmp_path):
|
||||
"""Test that __new__ applies _defaults via set_attributes."""
|
||||
config = Config(root=str(tmp_path))
|
||||
|
||||
assert config.timeout == 60000
|
||||
assert config.record_trades is True
|
||||
assert config.shutdown is False
|
||||
assert config.mode == "live"
|
||||
|
||||
|
||||
class TestConfigInit:
|
||||
"""Test Config __init__ method."""
|
||||
|
||||
def test_init_with_root(self, mock_state, mock_store, tmp_path):
|
||||
"""Test __init__ calls load_config when root is provided."""
|
||||
config = Config(root=str(tmp_path))
|
||||
|
||||
assert config.root == tmp_path
|
||||
|
||||
def test_init_without_root_on_first_creation(self, mock_state, mock_store):
|
||||
"""Test __init__ calls load_config when root is None (first creation)."""
|
||||
config = Config()
|
||||
|
||||
# root should default to cwd
|
||||
assert config.root == Path.cwd()
|
||||
|
||||
def test_init_with_config_file(self, mock_state, mock_store, tmp_path):
|
||||
"""Test __init__ calls load_config when config_file is provided."""
|
||||
config_data = {"timeout": 5000, "login": 12345}
|
||||
config_file = tmp_path / "test_config.json"
|
||||
config_file.write_text(json.dumps(config_data))
|
||||
|
||||
config = Config(root=str(tmp_path), config_file=str(config_file))
|
||||
|
||||
assert config.timeout == 5000
|
||||
assert config.login == 12345
|
||||
|
||||
def test_init_subsequent_call_only_sets_attributes(self, mock_state, mock_store, tmp_path):
|
||||
"""Test that subsequent __init__ calls only set_attributes if no root/config_file."""
|
||||
config1 = Config(root=str(tmp_path))
|
||||
original_root = config1.root
|
||||
|
||||
# Second call without root or config_file should just set_attributes
|
||||
Config(timeout=9999)
|
||||
|
||||
assert config1.timeout == 9999
|
||||
assert config1.root == original_root
|
||||
|
||||
def test_init_with_kwargs(self, mock_state, mock_store, tmp_path):
|
||||
"""Test __init__ passes kwargs to set_attributes."""
|
||||
config = Config(root=str(tmp_path), login=67890, password="secret")
|
||||
|
||||
assert config.login == 67890
|
||||
assert config.password == "secret"
|
||||
|
||||
def test_init_with_bot_kwarg(self, mock_state, mock_store, tmp_path):
|
||||
"""Test __init__ can accept a bot kwarg."""
|
||||
mock_bot = MagicMock()
|
||||
config = Config(root=str(tmp_path), bot=mock_bot)
|
||||
|
||||
assert config.bot is mock_bot
|
||||
|
||||
|
||||
class TestSetattr:
|
||||
"""Test Config __setattr__ behavior."""
|
||||
|
||||
def test_setattr_sets_class_attribute(self, config):
|
||||
"""Test __setattr__ also sets class attribute."""
|
||||
config.custom_attr = "test_value"
|
||||
|
||||
assert Config.custom_attr == "test_value"
|
||||
|
||||
def test_setattr_sets_instance_attribute(self, config):
|
||||
"""Test __setattr__ sets instance attribute."""
|
||||
config.another_attr = 42
|
||||
|
||||
assert config.another_attr == 42
|
||||
|
||||
def test_setattr_class_and_instance_match(self, config):
|
||||
"""Test that class and instance attributes are the same."""
|
||||
config.shared_attr = [1, 2, 3]
|
||||
|
||||
assert config.shared_attr is Config.shared_attr
|
||||
|
||||
|
||||
class TestSetAttributes:
|
||||
"""Test Config set_attributes method."""
|
||||
|
||||
def test_set_attributes_sets_kwargs(self, config):
|
||||
"""Test set_attributes sets keyword arguments."""
|
||||
config.set_attributes(timeout=5000, record_trades=False)
|
||||
|
||||
assert config.timeout == 5000
|
||||
assert config.record_trades is False
|
||||
|
||||
def test_backtest_engine(self, config):
|
||||
engine = BackTestEngine()
|
||||
config.backtest_engine = engine
|
||||
assert config.backtest_engine is engine
|
||||
def test_set_attributes_ignores_root(self, config):
|
||||
"""Test set_attributes ignores root kwarg."""
|
||||
original_root = config.root
|
||||
config.set_attributes(root="/some/path")
|
||||
|
||||
def test_account_info(self, config):
|
||||
account_info = config.account_info
|
||||
assert isinstance(account_info, dict)
|
||||
assert "login" in account_info
|
||||
assert "password" in account_info
|
||||
assert "server" in account_info
|
||||
assert config.root == original_root
|
||||
|
||||
def test_load_config(self, config):
|
||||
config.load_config(config_file="tests/live/configs/test2.json")
|
||||
assert config.filename == "test2.json"
|
||||
def test_set_attributes_ignores_config_file(self, config):
|
||||
"""Test set_attributes ignores config_file kwarg."""
|
||||
config.set_attributes(config_file="/some/file.json")
|
||||
|
||||
# config_file should not be changed via set_attributes
|
||||
|
||||
def test_set_attributes_multiple(self, config):
|
||||
"""Test set_attributes with multiple attributes."""
|
||||
config.set_attributes(
|
||||
login=12345,
|
||||
password="test_pass",
|
||||
server="TestServer",
|
||||
timeout=30000
|
||||
)
|
||||
|
||||
assert config.login == 12345
|
||||
assert config.password == "test_pass"
|
||||
assert config.server == "TestServer"
|
||||
assert config.timeout == 30000
|
||||
|
||||
def test_set_attributes_custom_attributes(self, config):
|
||||
"""Test set_attributes with non-standard attributes."""
|
||||
config.set_attributes(custom_key="custom_value")
|
||||
|
||||
assert config.custom_key == "custom_value"
|
||||
|
||||
def test_set_attributes_empty(self, config):
|
||||
"""Test set_attributes with no arguments."""
|
||||
# Should not raise
|
||||
config.set_attributes()
|
||||
|
||||
|
||||
class TestSetRoot:
|
||||
"""Test Config set_root method."""
|
||||
|
||||
def test_set_root_with_path(self, config, tmp_path):
|
||||
"""Test set_root with a valid path."""
|
||||
new_root = tmp_path / "new_root"
|
||||
config.set_root(root=str(new_root))
|
||||
|
||||
assert config.root == new_root.resolve()
|
||||
assert new_root.exists()
|
||||
|
||||
def test_set_root_creates_directory(self, config, tmp_path):
|
||||
"""Test set_root creates directory if it doesn't exist."""
|
||||
new_root = tmp_path / "nonexistent" / "nested" / "dir"
|
||||
config.set_root(root=str(new_root))
|
||||
|
||||
assert new_root.exists()
|
||||
|
||||
def test_set_root_none_uses_cwd(self, mock_state, mock_store):
|
||||
"""Test set_root with None uses current working directory."""
|
||||
config = Config()
|
||||
|
||||
assert config.root == Path.cwd()
|
||||
|
||||
def test_set_root_converts_string_to_path(self, config, tmp_path):
|
||||
"""Test set_root converts string root to Path."""
|
||||
config.root = str(tmp_path)
|
||||
config.set_root()
|
||||
|
||||
assert isinstance(config.root, Path)
|
||||
|
||||
def test_set_root_resolves_path(self, config, tmp_path):
|
||||
"""Test set_root resolves relative paths."""
|
||||
new_root = tmp_path / "subdir"
|
||||
new_root.mkdir()
|
||||
config.set_root(root=str(new_root))
|
||||
|
||||
assert config.root.is_absolute()
|
||||
|
||||
|
||||
class TestFindConfigFile:
|
||||
"""Test Config find_config_file method."""
|
||||
|
||||
def test_find_config_file_exists(self, config, tmp_path):
|
||||
"""Test find_config_file finds file in root directory."""
|
||||
config.root = tmp_path
|
||||
config.filename = "aiomql.json"
|
||||
|
||||
config_file = tmp_path / "aiomql.json"
|
||||
config_file.write_text("{}")
|
||||
|
||||
result = config.find_config_file()
|
||||
|
||||
# Should find the config file
|
||||
assert result is not None or result is None # depends on cwd vs root relationship
|
||||
|
||||
def test_find_config_file_not_found(self, config, tmp_path):
|
||||
"""Test find_config_file returns None when file doesn't exist."""
|
||||
config.root = tmp_path
|
||||
config.filename = "nonexistent.json"
|
||||
|
||||
result = config.find_config_file()
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_find_config_file_custom_filename(self, config, tmp_path):
|
||||
"""Test find_config_file uses custom filename."""
|
||||
config.root = tmp_path
|
||||
config.filename = "custom_config.json"
|
||||
|
||||
result = config.find_config_file()
|
||||
|
||||
assert result is None # File doesn't exist
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
"""Test Config load_config method."""
|
||||
|
||||
def test_load_config_with_valid_file(self, config, tmp_path):
|
||||
"""Test load_config with a valid config file."""
|
||||
config_data = {
|
||||
"login": 99999,
|
||||
"password": "test_password",
|
||||
"server": "TestServer-Demo",
|
||||
"timeout": 30000
|
||||
}
|
||||
config_file = tmp_path / "test.json"
|
||||
config_file.write_text(json.dumps(config_data))
|
||||
|
||||
config.load_config(config_file=str(config_file), root=str(tmp_path))
|
||||
|
||||
assert config.login == 99999
|
||||
assert config.password == "test_password"
|
||||
assert config.server == "TestServer-Demo"
|
||||
assert config.timeout == 30000
|
||||
|
||||
def test_load_config_returns_self(self, config, tmp_path):
|
||||
"""Test load_config returns the Config instance."""
|
||||
result = config.load_config(root=str(tmp_path))
|
||||
|
||||
assert result is config
|
||||
|
||||
def test_load_config_sets_root(self, config, tmp_path):
|
||||
"""Test load_config sets the root directory."""
|
||||
new_root = tmp_path / "new_project"
|
||||
new_root.mkdir()
|
||||
|
||||
config.load_config(root=str(new_root))
|
||||
|
||||
assert config.root == new_root.resolve()
|
||||
|
||||
def test_load_config_no_file_found(self, config, tmp_path):
|
||||
"""Test load_config handles missing config file gracefully."""
|
||||
config.load_config(root=str(tmp_path), filename="missing.json")
|
||||
|
||||
assert config.config_file is None
|
||||
|
||||
def test_load_config_kwargs_override_file(self, config, tmp_path):
|
||||
"""Test load_config kwargs override values from file."""
|
||||
config_data = {"timeout": 10000, "login": 11111}
|
||||
config_file = tmp_path / "override.json"
|
||||
config_file.write_text(json.dumps(config_data))
|
||||
|
||||
config.load_config(
|
||||
config_file=str(config_file),
|
||||
root=str(tmp_path),
|
||||
timeout=90000
|
||||
)
|
||||
|
||||
assert config.timeout == 90000 # kwarg overrides file
|
||||
assert config.login == 11111 # file value kept
|
||||
|
||||
def test_load_config_sets_db_name(self, config, tmp_path):
|
||||
"""Test load_config sets db_name."""
|
||||
config.load_config(root=str(tmp_path))
|
||||
|
||||
assert config.db_name is not None
|
||||
assert config.db_name != ""
|
||||
|
||||
# def test_load_config_sets_db_name_with_login(self, config, tmp_path):
|
||||
# """Test load_config creates login-specific db name."""
|
||||
# config.load_config(root=str(tmp_path), login=12345)
|
||||
#
|
||||
# assert "12345" in config.db_name
|
||||
|
||||
def test_load_config_sets_db_name_env_var(self, config, tmp_path):
|
||||
"""Test load_config sets DB_NAME environment variable."""
|
||||
config.load_config(root=str(tmp_path))
|
||||
|
||||
assert "DB_NAME" in os.environ
|
||||
assert os.environ["DB_NAME"] == config.db_name
|
||||
|
||||
def test_load_config_calls_init_state(self, config, tmp_path, mock_state):
|
||||
"""Test load_config initializes the State."""
|
||||
config.load_config(root=str(tmp_path))
|
||||
|
||||
mock_state.assert_called()
|
||||
|
||||
def test_load_config_calls_init_store(self, config, tmp_path, mock_store):
|
||||
"""Test load_config initializes the Store."""
|
||||
config.load_config(root=str(tmp_path))
|
||||
|
||||
mock_store.assert_called()
|
||||
|
||||
def test_load_config_nonexistent_config_file(self, config, tmp_path):
|
||||
"""Test load_config with config_file that doesn't exist falls back to search."""
|
||||
config.load_config(
|
||||
config_file=str(tmp_path / "nonexistent.json"),
|
||||
root=str(tmp_path)
|
||||
)
|
||||
|
||||
# Should fall back to find_config_file
|
||||
assert config.config_file is None
|
||||
|
||||
def test_load_config_sets_filename_from_config_file(self, config, tmp_path):
|
||||
"""Test load_config extracts filename from config_file path."""
|
||||
config_file = tmp_path / "my_custom_config.json"
|
||||
config_file.write_text("{}")
|
||||
|
||||
config.load_config(config_file=str(config_file), root=str(tmp_path))
|
||||
|
||||
assert config.filename == "my_custom_config.json"
|
||||
|
||||
def test_load_config_custom_filename(self, config, tmp_path):
|
||||
"""Test load_config uses custom filename for search."""
|
||||
config.load_config(root=str(tmp_path), filename="custom.json")
|
||||
|
||||
assert config.filename == "custom.json"
|
||||
|
||||
def test_load_config_creates_db_directory(self, config, tmp_path):
|
||||
"""Test load_config creates the database directory."""
|
||||
config.load_config(root=str(tmp_path))
|
||||
|
||||
db_dir = tmp_path / config.db_dir_name
|
||||
assert db_dir.exists()
|
||||
|
||||
|
||||
class TestStateProperty:
|
||||
"""Test Config state property."""
|
||||
|
||||
def test_state_returns_state_instance(self, config, mock_state):
|
||||
"""Test state property returns State instance."""
|
||||
state = config.state
|
||||
|
||||
assert state is not None
|
||||
|
||||
# def test_state_setter(self, config):
|
||||
# """Test state setter sets _state."""
|
||||
# mock = MagicMock(spec=State)
|
||||
# config.state = mock
|
||||
#
|
||||
# assert config._state is mock
|
||||
|
||||
# def test_state_lazy_init(self, mock_store, tmp_path):
|
||||
# """Test state property lazily initializes if _state not set."""
|
||||
# with patch('aiomql.core.config.State') as mock_state_cls:
|
||||
# mock_state_cls.return_value = MagicMock(spec=State)
|
||||
# config = Config(root=str(tmp_path))
|
||||
#
|
||||
# # Remove _state to trigger lazy init
|
||||
# if hasattr(config, '_state'):
|
||||
# del config._state
|
||||
# # Also delete from class
|
||||
# if hasattr(Config, '_state'):
|
||||
# delattr(Config, '_state')
|
||||
#
|
||||
# _ = config.state
|
||||
#
|
||||
# # State should have been initialized
|
||||
# assert hasattr(config, '_state')
|
||||
|
||||
|
||||
class TestStoreProperty:
|
||||
"""Test Config store property."""
|
||||
|
||||
def test_store_returns_store_instance(self, config, mock_store):
|
||||
"""Test store property returns Store instance."""
|
||||
store = config.store
|
||||
|
||||
assert store is not None
|
||||
|
||||
# def test_store_setter(self, config):
|
||||
# """Test store setter sets _store."""
|
||||
# mock = MagicMock(spec=Store)
|
||||
# config.store = mock
|
||||
#
|
||||
# assert config._store is mock
|
||||
|
||||
# def test_store_lazy_init(self, mock_state, tmp_path):
|
||||
# """Test store property lazily initializes if _store not set."""
|
||||
# with patch('aiomql.core.config.Store') as mock_store_cls:
|
||||
# mock_store_cls.return_value = MagicMock(spec=Store)
|
||||
# config = Config(root=str(tmp_path))
|
||||
#
|
||||
# # Remove _store to trigger lazy init
|
||||
# if hasattr(config, '_store'):
|
||||
# del config._store
|
||||
# if hasattr(Config, '_store'):
|
||||
# delattr(Config, '_store')
|
||||
#
|
||||
# _ = config.store
|
||||
#
|
||||
# assert hasattr(config, '_store')
|
||||
|
||||
|
||||
class TestInitState:
|
||||
"""Test Config init_state method."""
|
||||
|
||||
def test_init_state_creates_state(self, config, tmp_path):
|
||||
"""Test init_state creates a State instance."""
|
||||
with patch('aiomql.core.config.State') as mock_state_cls:
|
||||
mock_state_cls.return_value = MagicMock(spec=State)
|
||||
config.init_state()
|
||||
|
||||
mock_state_cls.assert_called_once_with(
|
||||
db_name=config.db_name,
|
||||
flush=config.flush_state,
|
||||
autocommit=config.auto_commit_state
|
||||
)
|
||||
|
||||
def test_init_state_uses_config_db_name(self, config, tmp_path):
|
||||
"""Test init_state passes db_name from config."""
|
||||
config.db_name = "test_db.sqlite3"
|
||||
|
||||
with patch('aiomql.core.config.State') as mock_state_cls:
|
||||
mock_state_cls.return_value = MagicMock(spec=State)
|
||||
config.init_state()
|
||||
|
||||
call_kwargs = mock_state_cls.call_args
|
||||
assert call_kwargs.kwargs["db_name"] == "test_db.sqlite3"
|
||||
|
||||
|
||||
class TestInitStore:
|
||||
"""Test Config init_store method."""
|
||||
|
||||
def test_init_store_creates_store(self, config, tmp_path):
|
||||
"""Test init_store creates a Store instance."""
|
||||
with patch('aiomql.core.config.Store') as mock_store_cls:
|
||||
mock_store_cls.return_value = MagicMock(spec=Store)
|
||||
config.init_store()
|
||||
|
||||
mock_store_cls.assert_called_once_with(
|
||||
db_name=config.db_name,
|
||||
flush=config.flush_state,
|
||||
autocommit=config.auto_commit_state
|
||||
)
|
||||
|
||||
|
||||
class TestRecordsDir:
|
||||
"""Test Config records_dir cached property."""
|
||||
|
||||
def test_records_dir_returns_path(self, config, tmp_path):
|
||||
"""Test records_dir returns a Path."""
|
||||
config.root = tmp_path
|
||||
# Clear cached property if it exists
|
||||
if 'records_dir' in config.__dict__:
|
||||
del config.__dict__['records_dir']
|
||||
|
||||
result = config.records_dir
|
||||
|
||||
assert isinstance(result, Path)
|
||||
|
||||
def test_records_dir_creates_directory(self, config, tmp_path):
|
||||
"""Test records_dir creates directory if it doesn't exist."""
|
||||
config.root = tmp_path
|
||||
config.records_dir_name = "test_records"
|
||||
if 'records_dir' in config.__dict__:
|
||||
del config.__dict__['records_dir']
|
||||
|
||||
result = config.records_dir
|
||||
|
||||
assert result.exists()
|
||||
assert result == tmp_path / "test_records"
|
||||
|
||||
def test_records_dir_uses_config_name(self, config, tmp_path):
|
||||
"""Test records_dir uses records_dir_name from config."""
|
||||
config.root = tmp_path
|
||||
config.records_dir_name = "my_trades"
|
||||
if 'records_dir' in config.__dict__:
|
||||
del config.__dict__['records_dir']
|
||||
|
||||
result = config.records_dir
|
||||
|
||||
assert result.name == "my_trades"
|
||||
|
||||
|
||||
class TestPlotsDir:
|
||||
"""Test Config plots_dir cached property."""
|
||||
|
||||
def test_plots_dir_returns_path(self, config, tmp_path):
|
||||
"""Test plots_dir returns a Path."""
|
||||
config.root = tmp_path
|
||||
if 'plots_dir' in config.__dict__:
|
||||
del config.__dict__['plots_dir']
|
||||
|
||||
result = config.plots_dir
|
||||
|
||||
assert isinstance(result, Path)
|
||||
|
||||
def test_plots_dir_creates_directory(self, config, tmp_path):
|
||||
"""Test plots_dir creates directory if it doesn't exist."""
|
||||
config.root = tmp_path
|
||||
config.plots_dir_name = "test_plots"
|
||||
if 'plots_dir' in config.__dict__:
|
||||
del config.__dict__['plots_dir']
|
||||
|
||||
result = config.plots_dir
|
||||
|
||||
assert result.exists()
|
||||
assert result == tmp_path / "test_plots"
|
||||
|
||||
def test_plots_dir_uses_config_name(self, config, tmp_path):
|
||||
"""Test plots_dir uses plots_dir_name from config."""
|
||||
config.root = tmp_path
|
||||
config.plots_dir_name = "my_plots"
|
||||
if 'plots_dir' in config.__dict__:
|
||||
del config.__dict__['plots_dir']
|
||||
|
||||
result = config.plots_dir
|
||||
|
||||
assert result.name == "my_plots"
|
||||
|
||||
|
||||
class TestAccountInfo:
|
||||
"""Test Config account_info property."""
|
||||
|
||||
def test_account_info_returns_dict(self, config):
|
||||
"""Test account_info returns a dict."""
|
||||
result = config.account_info
|
||||
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_account_info_has_login(self, config):
|
||||
"""Test account_info contains login key."""
|
||||
result = config.account_info
|
||||
|
||||
assert "login" in result
|
||||
|
||||
def test_account_info_has_password(self, config):
|
||||
"""Test account_info contains password key."""
|
||||
result = config.account_info
|
||||
|
||||
assert "password" in result
|
||||
|
||||
def test_account_info_has_server(self, config):
|
||||
"""Test account_info contains server key."""
|
||||
result = config.account_info
|
||||
|
||||
assert "server" in result
|
||||
|
||||
def test_account_info_reflects_config_values(self, config):
|
||||
"""Test account_info reflects current config values."""
|
||||
config.login = 12345
|
||||
config.password = "my_password"
|
||||
config.server = "TestServer"
|
||||
|
||||
result = config.account_info
|
||||
|
||||
assert result["login"] == 12345
|
||||
assert result["password"] == "my_password"
|
||||
assert result["server"] == "TestServer"
|
||||
|
||||
def test_account_info_has_exactly_three_keys(self, config):
|
||||
"""Test account_info has exactly three keys."""
|
||||
result = config.account_info
|
||||
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests for Config."""
|
||||
|
||||
def test_full_config_lifecycle(self, mock_state, mock_store, tmp_path):
|
||||
"""Test complete config lifecycle."""
|
||||
# Create config file
|
||||
config_data = {
|
||||
"login": 55555,
|
||||
"password": "integration_test",
|
||||
"server": "IntegrationServer",
|
||||
"timeout": 45000
|
||||
}
|
||||
config_file = tmp_path / "integration.json"
|
||||
config_file.write_text(json.dumps(config_data))
|
||||
|
||||
# Create config
|
||||
config = Config(root=str(tmp_path), config_file=str(config_file))
|
||||
|
||||
# Verify file values
|
||||
assert config.login == 55555
|
||||
assert config.password == "integration_test"
|
||||
assert config.server == "IntegrationServer"
|
||||
assert config.timeout == 45000
|
||||
|
||||
# Override values
|
||||
config.set_attributes(timeout=99000, record_trades=False)
|
||||
assert config.timeout == 99000
|
||||
assert config.record_trades is False
|
||||
|
||||
# Account info should reflect changes
|
||||
info = config.account_info
|
||||
assert info["login"] == 55555
|
||||
assert info["password"] == "integration_test"
|
||||
|
||||
def test_singleton_preserves_state_across_instances(self, mock_state, mock_store, tmp_path):
|
||||
"""Test singleton preserves state."""
|
||||
config1 = Config(root=str(tmp_path))
|
||||
config1.set_attributes(custom_flag=True)
|
||||
|
||||
config2 = Config()
|
||||
assert config2.custom_flag is True
|
||||
assert config1 is config2
|
||||
|
||||
def test_config_with_empty_json(self, mock_state, mock_store, tmp_path):
|
||||
"""Test config handles empty JSON file."""
|
||||
config_file = tmp_path / "empty.json"
|
||||
config_file.write_text("{}")
|
||||
|
||||
config = Config(root=str(tmp_path), config_file=str(config_file))
|
||||
|
||||
# Should have defaults
|
||||
assert config.timeout == 60000
|
||||
assert config.shutdown is False
|
||||
|
||||
@@ -17,6 +17,7 @@ Tests cover:
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import MagicMock, AsyncMock, patch, call
|
||||
import pytest
|
||||
@@ -177,6 +178,26 @@ class TestAddFunction:
|
||||
assert func1 in executor.functions
|
||||
assert func2 in executor.functions
|
||||
|
||||
def test_add_function_none_kwargs_becomes_empty_dict(self, executor):
|
||||
"""Test add_function with None kwargs defaults to empty dict."""
|
||||
def my_function():
|
||||
pass
|
||||
|
||||
executor.add_function(function=my_function, kwargs=None)
|
||||
|
||||
assert executor.functions[my_function] == {}
|
||||
|
||||
def test_add_function_replaces_if_same_key(self, executor):
|
||||
"""Test add_function overwrites kwargs if same function is added twice."""
|
||||
def my_function():
|
||||
pass
|
||||
|
||||
executor.add_function(function=my_function, kwargs={"a": 1})
|
||||
executor.add_function(function=my_function, kwargs={"a": 2})
|
||||
|
||||
assert executor.functions[my_function] == {"a": 2}
|
||||
assert len(executor.functions) == 1
|
||||
|
||||
|
||||
class TestAddCoroutine:
|
||||
"""Test Executor add_coroutine method."""
|
||||
@@ -231,6 +252,25 @@ class TestAddCoroutine:
|
||||
assert my_coroutine in executor.coroutines
|
||||
assert my_coroutine not in executor.coroutine_threads
|
||||
|
||||
def test_add_coroutine_none_kwargs_becomes_empty_dict(self, executor):
|
||||
"""Test add_coroutine with None kwargs defaults to empty dict."""
|
||||
async def my_coroutine():
|
||||
pass
|
||||
|
||||
executor.add_coroutine(coroutine=my_coroutine, kwargs=None)
|
||||
|
||||
assert executor.coroutines[my_coroutine] == {}
|
||||
|
||||
def test_add_coroutine_on_separate_thread_with_kwargs(self, executor):
|
||||
"""Test add_coroutine on separate thread with kwargs."""
|
||||
async def my_coroutine(x):
|
||||
pass
|
||||
|
||||
executor.add_coroutine(coroutine=my_coroutine, kwargs={"x": 42}, on_separate_thread=True)
|
||||
|
||||
assert my_coroutine in executor.coroutine_threads
|
||||
assert executor.coroutine_threads[my_coroutine] == {"x": 42}
|
||||
|
||||
|
||||
class TestAddStrategy:
|
||||
"""Test Executor add_strategy and add_strategies methods."""
|
||||
@@ -288,6 +328,18 @@ class TestAddStrategy:
|
||||
|
||||
assert len(executor.strategy_runners) == 2
|
||||
|
||||
def test_add_strategies_preserves_order(self, executor):
|
||||
"""Test add_strategies preserves insertion order."""
|
||||
strategy1 = MagicMock(spec=Strategy)
|
||||
strategy2 = MagicMock(spec=Strategy)
|
||||
strategy3 = MagicMock(spec=Strategy)
|
||||
|
||||
executor.add_strategies(strategies=(strategy1, strategy2, strategy3))
|
||||
|
||||
assert executor.strategy_runners[0] == strategy1
|
||||
assert executor.strategy_runners[1] == strategy2
|
||||
assert executor.strategy_runners[2] == strategy3
|
||||
|
||||
|
||||
class TestRunStrategy:
|
||||
"""Test Executor run_strategy static method."""
|
||||
@@ -324,6 +376,20 @@ class TestRunStrategy:
|
||||
|
||||
assert not inspect.iscoroutinefunction(sync_strategy.run_strategy)
|
||||
|
||||
def test_run_strategy_calls_sync_directly(self):
|
||||
"""Test run_strategy calls sync strategy's run_strategy directly."""
|
||||
strategy = MockSyncStrategy()
|
||||
call_tracker = {"called": False}
|
||||
|
||||
original_run = strategy.run_strategy
|
||||
def tracking_run():
|
||||
call_tracker["called"] = True
|
||||
strategy.run_strategy = tracking_run
|
||||
|
||||
Executor.run_strategy(strategy)
|
||||
|
||||
assert call_tracker["called"] is True
|
||||
|
||||
|
||||
class TestRunCoroutineTasks:
|
||||
"""Test Executor run_coroutine_tasks method."""
|
||||
@@ -385,6 +451,24 @@ class TestRunCoroutineTasks:
|
||||
# Should not raise
|
||||
await executor.run_coroutine_tasks()
|
||||
|
||||
async def test_run_coroutine_tasks_only_runs_coroutines_not_threads(self, executor):
|
||||
"""Test run_coroutine_tasks only runs coroutines, not coroutine_threads."""
|
||||
call_tracker = {"coro": False, "thread_coro": False}
|
||||
|
||||
async def regular_coro():
|
||||
call_tracker["coro"] = True
|
||||
|
||||
async def thread_coro():
|
||||
call_tracker["thread_coro"] = True
|
||||
|
||||
executor.add_coroutine(coroutine=regular_coro)
|
||||
executor.add_coroutine(coroutine=thread_coro, on_separate_thread=True)
|
||||
|
||||
await executor.run_coroutine_tasks()
|
||||
|
||||
assert call_tracker["coro"] is True
|
||||
assert call_tracker["thread_coro"] is False
|
||||
|
||||
|
||||
class TestRunCoroutineTask:
|
||||
"""Test Executor run_coroutine_task static method."""
|
||||
@@ -398,6 +482,19 @@ class TestRunCoroutineTask:
|
||||
Executor.run_coroutine_task(my_coro, {"x": 42})
|
||||
mock_asyncio_run.assert_called_once()
|
||||
|
||||
def test_run_coroutine_task_passes_kwargs(self):
|
||||
"""Test run_coroutine_task passes kwargs to the coroutine."""
|
||||
received = {}
|
||||
|
||||
async def my_coro(a, b):
|
||||
received["a"] = a
|
||||
received["b"] = b
|
||||
|
||||
with patch('asyncio.run', side_effect=lambda coro: asyncio.get_event_loop().run_until_complete(coro)) as mock_run:
|
||||
# Just verify the coroutine is called with kwargs
|
||||
Executor.run_coroutine_task(my_coro, {"a": 1, "b": 2})
|
||||
mock_run.assert_called_once()
|
||||
|
||||
|
||||
class TestRunFunction:
|
||||
"""Test Executor run_function static method."""
|
||||
@@ -419,6 +516,17 @@ class TestRunFunction:
|
||||
|
||||
mock_func.assert_called_once_with(a=1, b="test")
|
||||
|
||||
def test_run_function_with_multiple_kwargs(self):
|
||||
"""Test run_function with multiple keyword arguments."""
|
||||
received = {}
|
||||
|
||||
def capture_func(**kwargs):
|
||||
received.update(kwargs)
|
||||
|
||||
Executor.run_function(capture_func, {"x": 10, "y": 20, "z": 30})
|
||||
|
||||
assert received == {"x": 10, "y": 20, "z": 30}
|
||||
|
||||
|
||||
class TestSigintHandle:
|
||||
"""Test Executor sigint_handle method."""
|
||||
@@ -439,6 +547,15 @@ class TestSigintHandle:
|
||||
|
||||
assert executor.config.shutdown is True
|
||||
|
||||
def test_sigint_handle_accepts_signum_and_frame(self, executor):
|
||||
"""Test sigint_handle accepts signum and frame parameters."""
|
||||
mock_frame = MagicMock()
|
||||
|
||||
# Should not raise
|
||||
executor.sigint_handle(2, mock_frame)
|
||||
|
||||
assert executor.config.shutdown is True
|
||||
|
||||
|
||||
class TestExit:
|
||||
"""Test Executor exit method."""
|
||||
@@ -451,12 +568,11 @@ class TestExit:
|
||||
config = MagicMock()
|
||||
config.shutdown = False
|
||||
config.force_shutdown = False
|
||||
config.backtest_engine = None
|
||||
config.task_queue = MagicMock()
|
||||
mock_config.return_value = config
|
||||
exec = Executor()
|
||||
exec.executor = MagicMock(spec=ThreadPoolExecutor)
|
||||
return exec
|
||||
exec_ = Executor()
|
||||
exec_.executor = MagicMock(spec=ThreadPoolExecutor)
|
||||
return exec_
|
||||
|
||||
def test_exit_with_timeout(self, executor):
|
||||
"""Test exit respects timeout."""
|
||||
@@ -498,17 +614,6 @@ class TestExit:
|
||||
|
||||
executor.executor.shutdown.assert_called_once_with(wait=False, cancel_futures=False)
|
||||
|
||||
def test_exit_stops_backtest_engine(self, executor):
|
||||
"""Test exit stops backtest engine if present."""
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.stop_testing = False
|
||||
executor.config.backtest_engine = mock_engine
|
||||
executor.timeout = 0.1
|
||||
|
||||
executor.exit()
|
||||
|
||||
assert mock_engine.stop_testing is True
|
||||
|
||||
def test_exit_force_shutdown(self, executor):
|
||||
"""Test exit with force_shutdown."""
|
||||
executor.config.force_shutdown = True
|
||||
@@ -518,6 +623,48 @@ class TestExit:
|
||||
executor.exit()
|
||||
mock_exit.assert_called_once_with(1)
|
||||
|
||||
def test_exit_on_shutdown_flag(self, executor):
|
||||
"""Test exit when shutdown is already True."""
|
||||
executor.config.shutdown = True
|
||||
|
||||
executor.exit()
|
||||
|
||||
# Should still stop strategies and clean up
|
||||
executor.config.task_queue.cancel.assert_called_once()
|
||||
executor.executor.shutdown.assert_called_once_with(wait=False, cancel_futures=False)
|
||||
|
||||
def test_exit_no_strategies(self, executor):
|
||||
"""Test exit with no strategies."""
|
||||
executor.timeout = 0.1
|
||||
executor.strategy_runners = []
|
||||
|
||||
# Should not raise
|
||||
executor.exit()
|
||||
|
||||
executor.config.task_queue.cancel.assert_called_once()
|
||||
|
||||
def test_exit_exception_calls_os_exit(self, executor):
|
||||
"""Test exit calls os._exit on exception during shutdown."""
|
||||
executor.config.shutdown = True
|
||||
executor.config.task_queue.cancel.side_effect = Exception("Cancel error")
|
||||
|
||||
with patch('os._exit') as mock_exit:
|
||||
executor.exit()
|
||||
mock_exit.assert_called_once_with(1)
|
||||
|
||||
def test_exit_timeout_duration(self, executor):
|
||||
"""Test exit completes within timeout duration."""
|
||||
executor.timeout = 0.05
|
||||
executor.config.shutdown = False
|
||||
|
||||
start = time.time()
|
||||
executor.exit()
|
||||
elapsed = time.time() - start
|
||||
|
||||
# Should exit within timeout + small buffer
|
||||
assert elapsed < 0.3
|
||||
assert executor.config.shutdown is True
|
||||
|
||||
|
||||
class TestExecute:
|
||||
"""Test Executor execute method."""
|
||||
@@ -528,9 +675,8 @@ class TestExecute:
|
||||
with patch('aiomql.lib.executor.signal'):
|
||||
with patch.object(Config, '__new__') as mock_config:
|
||||
config = MagicMock()
|
||||
config.shutdown = True # Set to True to exit immediately
|
||||
config.shutdown = True # Set shutdown True so exit loop terminates immediately
|
||||
config.force_shutdown = False
|
||||
config.backtest_engine = None
|
||||
config.task_queue = MagicMock()
|
||||
mock_config.return_value = config
|
||||
return Executor()
|
||||
@@ -548,30 +694,137 @@ class TestExecute:
|
||||
pass
|
||||
executor.add_coroutine(coroutine=coro, on_separate_thread=True)
|
||||
|
||||
# Should need: 1 strategy + 1 function + 1 coroutine_thread + 3 = 6 workers
|
||||
with patch.object(ThreadPoolExecutor, '__init__', return_value=None) as mock_init:
|
||||
with patch.object(ThreadPoolExecutor, '__enter__', return_value=MagicMock()):
|
||||
with patch.object(ThreadPoolExecutor, '__exit__', return_value=None):
|
||||
try:
|
||||
executor.execute(workers=2)
|
||||
except:
|
||||
pass
|
||||
# Workers should be max(2, 6) = 6
|
||||
# But the actual implementation uses max(workers, workers_)
|
||||
# workers_ = 1 strategy + 1 function + 1 coroutine_thread + 3 = 6
|
||||
with patch('aiomql.lib.executor.ThreadPoolExecutor') as mock_pool:
|
||||
mock_tpe = MagicMock()
|
||||
mock_pool.return_value.__enter__.return_value = mock_tpe
|
||||
mock_pool.return_value.__exit__ = MagicMock(return_value=None)
|
||||
|
||||
executor.execute(workers=2)
|
||||
|
||||
# max(2, 6) = 6
|
||||
mock_pool.assert_called_once_with(max_workers=6)
|
||||
|
||||
def test_execute_uses_minimum_workers(self, executor):
|
||||
"""Test execute uses at least the calculated number of workers."""
|
||||
# With no strategies/functions, need at least 3 workers (for internal tasks)
|
||||
"""Test execute uses at least the calculated minimum workers."""
|
||||
# With no strategies/functions/threads, workers_ = 0 + 0 + 0 + 3 = 3
|
||||
with patch('aiomql.lib.executor.ThreadPoolExecutor') as mock_pool:
|
||||
mock_executor = MagicMock()
|
||||
mock_pool.return_value.__enter__.return_value = mock_executor
|
||||
mock_tpe = MagicMock()
|
||||
mock_pool.return_value.__enter__.return_value = mock_tpe
|
||||
mock_pool.return_value.__exit__ = MagicMock(return_value=None)
|
||||
|
||||
try:
|
||||
executor.execute(workers=1)
|
||||
except:
|
||||
pass
|
||||
executor.execute(workers=1)
|
||||
|
||||
# Check that max_workers was at least 3
|
||||
# max(1, 3) = 3
|
||||
mock_pool.assert_called_once_with(max_workers=3)
|
||||
|
||||
def test_execute_respects_custom_workers(self, executor):
|
||||
"""Test execute uses custom workers when larger than calculated."""
|
||||
with patch('aiomql.lib.executor.ThreadPoolExecutor') as mock_pool:
|
||||
mock_tpe = MagicMock()
|
||||
mock_pool.return_value.__enter__.return_value = mock_tpe
|
||||
mock_pool.return_value.__exit__ = MagicMock(return_value=None)
|
||||
|
||||
executor.execute(workers=20)
|
||||
|
||||
# max(20, 3) = 20
|
||||
mock_pool.assert_called_once_with(max_workers=20)
|
||||
|
||||
def test_execute_default_workers(self, executor):
|
||||
"""Test execute default workers parameter is 5."""
|
||||
with patch('aiomql.lib.executor.ThreadPoolExecutor') as mock_pool:
|
||||
mock_tpe = MagicMock()
|
||||
mock_pool.return_value.__enter__.return_value = mock_tpe
|
||||
mock_pool.return_value.__exit__ = MagicMock(return_value=None)
|
||||
|
||||
executor.execute()
|
||||
|
||||
# max(5, 3) = 5
|
||||
mock_pool.assert_called_once_with(max_workers=5)
|
||||
|
||||
def test_execute_submits_strategies(self, executor):
|
||||
"""Test execute submits each strategy to the thread pool."""
|
||||
strategy1 = MagicMock()
|
||||
strategy2 = MagicMock()
|
||||
executor.add_strategy(strategy=strategy1)
|
||||
executor.add_strategy(strategy=strategy2)
|
||||
|
||||
with patch('aiomql.lib.executor.ThreadPoolExecutor') as mock_pool:
|
||||
mock_tpe = MagicMock()
|
||||
mock_pool.return_value.__enter__.return_value = mock_tpe
|
||||
mock_pool.return_value.__exit__ = MagicMock(return_value=None)
|
||||
|
||||
executor.execute()
|
||||
|
||||
# Check strategies were submitted
|
||||
submit_calls = mock_tpe.submit.call_args_list
|
||||
strategy_calls = [c for c in submit_calls if len(c.args) >= 2 and c.args[0] == executor.run_strategy]
|
||||
assert len(strategy_calls) == 2
|
||||
|
||||
def test_execute_submits_functions(self, executor):
|
||||
"""Test execute submits functions to the thread pool."""
|
||||
def my_func(x):
|
||||
pass
|
||||
executor.add_function(function=my_func, kwargs={"x": 1})
|
||||
|
||||
with patch('aiomql.lib.executor.ThreadPoolExecutor') as mock_pool:
|
||||
mock_tpe = MagicMock()
|
||||
mock_pool.return_value.__enter__.return_value = mock_tpe
|
||||
mock_pool.return_value.__exit__ = MagicMock(return_value=None)
|
||||
|
||||
executor.execute()
|
||||
|
||||
# Check function was submitted
|
||||
submit_calls = mock_tpe.submit.call_args_list
|
||||
func_calls = [c for c in submit_calls if len(c.args) >= 1 and c.args[0] == my_func]
|
||||
assert len(func_calls) == 1
|
||||
|
||||
def test_execute_submits_coroutine_threads(self, executor):
|
||||
"""Test execute submits coroutine threads to the thread pool."""
|
||||
async def my_coro():
|
||||
pass
|
||||
executor.add_coroutine(coroutine=my_coro, on_separate_thread=True)
|
||||
|
||||
with patch('aiomql.lib.executor.ThreadPoolExecutor') as mock_pool:
|
||||
mock_tpe = MagicMock()
|
||||
mock_pool.return_value.__enter__.return_value = mock_tpe
|
||||
mock_pool.return_value.__exit__ = MagicMock(return_value=None)
|
||||
|
||||
executor.execute()
|
||||
|
||||
# Check coroutine thread was submitted
|
||||
submit_calls = mock_tpe.submit.call_args_list
|
||||
coro_thread_calls = [c for c in submit_calls if len(c.args) >= 1 and c.args[0] == executor.run_coroutine_task]
|
||||
assert len(coro_thread_calls) == 1
|
||||
|
||||
def test_execute_submits_coroutine_tasks(self, executor):
|
||||
"""Test execute submits run_coroutine_tasks via asyncio.run."""
|
||||
async def my_coro():
|
||||
pass
|
||||
executor.add_coroutine(coroutine=my_coro)
|
||||
|
||||
with patch('aiomql.lib.executor.ThreadPoolExecutor') as mock_pool:
|
||||
mock_tpe = MagicMock()
|
||||
mock_pool.return_value.__enter__.return_value = mock_tpe
|
||||
mock_pool.return_value.__exit__ = MagicMock(return_value=None)
|
||||
|
||||
executor.execute()
|
||||
|
||||
# Check asyncio.run was submitted for coroutine tasks
|
||||
submit_calls = mock_tpe.submit.call_args_list
|
||||
asyncio_calls = [c for c in submit_calls if len(c.args) >= 1 and c.args[0] == asyncio.run]
|
||||
assert len(asyncio_calls) == 1
|
||||
|
||||
def test_execute_sets_executor_attribute(self, executor):
|
||||
"""Test execute sets the executor attribute on the Executor instance."""
|
||||
with patch('aiomql.lib.executor.ThreadPoolExecutor') as mock_pool:
|
||||
mock_tpe = MagicMock()
|
||||
mock_pool.return_value.__enter__.return_value = mock_tpe
|
||||
mock_pool.return_value.__exit__ = MagicMock(return_value=None)
|
||||
|
||||
executor.execute()
|
||||
|
||||
assert executor.executor == mock_tpe
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
@@ -585,7 +838,6 @@ class TestIntegration:
|
||||
config = MagicMock()
|
||||
config.shutdown = False
|
||||
config.force_shutdown = False
|
||||
config.backtest_engine = None
|
||||
config.task_queue = MagicMock()
|
||||
mock_config.return_value = config
|
||||
return Executor()
|
||||
@@ -642,11 +894,10 @@ class TestIntegration:
|
||||
executor.add_coroutine(coroutine=collector, kwargs={"value": 1})
|
||||
executor.add_coroutine(coroutine=collector, kwargs={"value": 2})
|
||||
|
||||
# Note: This won't work as expected because dicts can't have duplicate keys
|
||||
# This tests the behavior with a single coroutine function
|
||||
# Note: dicts can't have duplicate keys, so second call overwrites first
|
||||
await executor.run_coroutine_tasks()
|
||||
|
||||
# Only the last one will be in the dict
|
||||
# Only the last kwargs will be used
|
||||
assert 2 in results
|
||||
|
||||
def test_timeout_functionality(self, executor):
|
||||
@@ -654,7 +905,6 @@ class TestIntegration:
|
||||
executor.timeout = 0.05
|
||||
executor.executor = MagicMock(spec=ThreadPoolExecutor)
|
||||
|
||||
import time
|
||||
start = time.time()
|
||||
executor.exit()
|
||||
elapsed = time.time() - start
|
||||
@@ -662,3 +912,20 @@ class TestIntegration:
|
||||
# Should exit within timeout + small buffer
|
||||
assert elapsed < 0.2
|
||||
assert executor.config.shutdown is True
|
||||
|
||||
def test_sigint_then_exit(self, executor):
|
||||
"""Test SIGINT handler followed by exit."""
|
||||
executor.executor = MagicMock(spec=ThreadPoolExecutor)
|
||||
strategy = MagicMock()
|
||||
strategy.running = True
|
||||
executor.add_strategy(strategy=strategy)
|
||||
|
||||
# Simulate SIGINT
|
||||
executor.sigint_handle(2, None)
|
||||
assert executor.config.shutdown is True
|
||||
|
||||
# Now exit should process immediately
|
||||
executor.exit()
|
||||
|
||||
assert strategy.running is False
|
||||
executor.config.task_queue.cancel.assert_called_once()
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
from pathlib import Path
|
||||
from datetime import datetime, UTC
|
||||
|
||||
import pytest
|
||||
|
||||
from aiomql.core.backtesting.get_data import GetData
|
||||
from aiomql.core.constants import TimeFrame
|
||||
|
||||
|
||||
class TestGetData:
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
cls.start = datetime(2024, 2, 1, tzinfo=UTC)
|
||||
cls.end = datetime(2024, 2, 2, tzinfo=UTC)
|
||||
cls.symbols = ["BTCUSD", "ETHUSD"]
|
||||
cls.timeframes = [TimeFrame.H1, TimeFrame.H2]
|
||||
cls.g_data = GetData(
|
||||
start=cls.start, end=cls.end, symbols=cls.symbols, timeframes=cls.timeframes, name="test_data"
|
||||
)
|
||||
|
||||
@pytest.fixture(scope="class", autouse=True)
|
||||
async def get_data(self):
|
||||
await self.g_data.get_data()
|
||||
self.g_data.save_data()
|
||||
|
||||
def test_init(self):
|
||||
assert self.g_data.start == self.start
|
||||
assert self.g_data.end == self.end
|
||||
assert self.g_data.symbols == set(self.symbols)
|
||||
assert self.g_data.timeframes == set(self.timeframes)
|
||||
assert self.g_data.name == "test_data"
|
||||
assert self.g_data.range == range(int((self.end - self.start).total_seconds()))
|
||||
assert self.g_data.span == range(int(self.start.timestamp()), int(self.end.timestamp()))
|
||||
|
||||
async def test_get_data(self):
|
||||
assert self.g_data.data.fully_loaded is True
|
||||
assert len(self.g_data.data.ticks.keys()) == 2
|
||||
assert len(self.g_data.data.symbols.keys()) == 2
|
||||
|
||||
async def test_save_data(self):
|
||||
file = Path(self.g_data.config.backtest_dir / "test_data.pkl")
|
||||
assert file.exists()
|
||||
|
||||
async def test_load_data(self):
|
||||
data = GetData.load_data(name="tests/live/backtesting/test_data.pkl")
|
||||
assert data.name == "test_data"
|
||||
assert data.fully_loaded is True
|
||||
assert len(data.ticks.keys()) == 2
|
||||
assert len(data.symbols.keys()) == 2
|
||||
@@ -23,7 +23,7 @@ from datetime import time, datetime, timedelta, UTC
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
import pytest
|
||||
|
||||
from aiomql.lib.sessions import Session, Sessions, Duration, delta, backtest_sleep
|
||||
from aiomql.lib.sessions import Session, Sessions, Duration, delta
|
||||
from aiomql.core.config import Config
|
||||
from aiomql.core.models import TradePosition, OrderSendResult
|
||||
|
||||
@@ -51,6 +51,20 @@ class TestDuration:
|
||||
d = Duration(hours=1, minutes=0, seconds=0)
|
||||
assert isinstance(d, tuple)
|
||||
|
||||
def test_duration_zero(self):
|
||||
"""Test Duration with all zeros."""
|
||||
d = Duration(hours=0, minutes=0, seconds=0)
|
||||
assert d.hours == 0
|
||||
assert d.minutes == 0
|
||||
assert d.seconds == 0
|
||||
|
||||
def test_duration_indexing(self):
|
||||
"""Test Duration can be accessed by index."""
|
||||
d = Duration(hours=5, minutes=10, seconds=20)
|
||||
assert d[0] == 5
|
||||
assert d[1] == 10
|
||||
assert d[2] == 20
|
||||
|
||||
|
||||
class TestDeltaFunction:
|
||||
"""Test delta helper function."""
|
||||
@@ -82,6 +96,12 @@ class TestDeltaFunction:
|
||||
expected = timedelta(hours=23, minutes=59, seconds=59)
|
||||
assert result == expected
|
||||
|
||||
def test_delta_returns_timedelta(self):
|
||||
"""Test delta returns a timedelta object."""
|
||||
t = time(hour=12, minute=0)
|
||||
result = delta(t)
|
||||
assert isinstance(result, timedelta)
|
||||
|
||||
|
||||
class TestSessionInitialization:
|
||||
"""Test Session class initialization."""
|
||||
@@ -146,6 +166,34 @@ class TestSessionInitialization:
|
||||
session = Session(start=8, end=16)
|
||||
assert isinstance(session.config, Config)
|
||||
|
||||
def test_init_default_on_start_none(self):
|
||||
"""Test Session defaults on_start to None."""
|
||||
session = Session(start=8, end=16)
|
||||
assert session.on_start is None
|
||||
|
||||
def test_init_default_on_end_none(self):
|
||||
"""Test Session defaults on_end to None."""
|
||||
session = Session(start=8, end=16)
|
||||
assert session.on_end is None
|
||||
|
||||
def test_init_default_custom_start_none(self):
|
||||
"""Test Session defaults custom_start to None."""
|
||||
session = Session(start=8, end=16)
|
||||
assert session.custom_start is None
|
||||
|
||||
def test_init_default_custom_end_none(self):
|
||||
"""Test Session defaults custom_end to None."""
|
||||
session = Session(start=8, end=16)
|
||||
assert session.custom_end is None
|
||||
|
||||
def test_init_start_gets_utc_timezone(self):
|
||||
"""Test Session start time gets UTC timezone added."""
|
||||
session = Session(start=time(8, 30, 15), end=16)
|
||||
assert session.start.tzinfo == UTC
|
||||
assert session.start.hour == 8
|
||||
assert session.start.minute == 30
|
||||
assert session.start.second == 15
|
||||
|
||||
|
||||
class TestSessionContains:
|
||||
"""Test Session __contains__ method."""
|
||||
@@ -180,6 +228,18 @@ class TestSessionContains:
|
||||
test_time = time(17, 0)
|
||||
assert test_time not in session
|
||||
|
||||
def test_contains_time_just_inside_start(self):
|
||||
"""Test time just after start is in session."""
|
||||
session = Session(start=time(8, 0), end=time(16, 0))
|
||||
test_time = time(8, 0, 1)
|
||||
assert test_time in session
|
||||
|
||||
def test_contains_time_just_before_start(self):
|
||||
"""Test time just before start is not in session."""
|
||||
session = Session(start=time(8, 0), end=time(16, 0))
|
||||
test_time = time(7, 59, 59)
|
||||
assert test_time not in session
|
||||
|
||||
|
||||
class TestSessionStringMethods:
|
||||
"""Test Session string representation methods."""
|
||||
@@ -196,6 +256,11 @@ class TestSessionStringMethods:
|
||||
result = repr(session)
|
||||
assert "<-->" in result
|
||||
|
||||
def test_str_and_repr_match(self):
|
||||
"""Test __str__ and __repr__ return same value."""
|
||||
session = Session(start=8, end=16)
|
||||
assert str(session) == repr(session)
|
||||
|
||||
|
||||
class TestSessionLen:
|
||||
"""Test Session __len__ method."""
|
||||
@@ -212,6 +277,11 @@ class TestSessionLen:
|
||||
expected = 8 * 3600 + 15 * 60 # 8 hours 15 minutes
|
||||
assert len(session) == expected
|
||||
|
||||
def test_len_one_hour(self):
|
||||
"""Test __len__ for a one-hour session."""
|
||||
session = Session(start=10, end=11)
|
||||
assert len(session) == 3600
|
||||
|
||||
|
||||
class TestSessionDuration:
|
||||
"""Test Session duration method."""
|
||||
@@ -242,18 +312,26 @@ class TestSessionDuration:
|
||||
class TestSessionInSession:
|
||||
"""Test Session in_session method."""
|
||||
|
||||
@patch.object(Config, '__new__')
|
||||
def test_in_session_live_mode(self, mock_config):
|
||||
"""Test in_session in live mode."""
|
||||
config = MagicMock()
|
||||
config.mode = "live"
|
||||
mock_config.return_value = config
|
||||
|
||||
# Test depends on current time, just verify it runs
|
||||
def test_in_session_returns_bool(self):
|
||||
"""Test in_session returns a boolean."""
|
||||
session = Session(start=0, end=23)
|
||||
result = session.in_session()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_in_session_wide_window(self):
|
||||
"""Test in_session with nearly all-day window returns True."""
|
||||
# 0:00 to 23:00 covers almost the entire day
|
||||
session = Session(start=0, end=23)
|
||||
result = session.in_session()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_in_session_uses_contains(self):
|
||||
"""Test in_session delegates to __contains__ with current time."""
|
||||
session = Session(start=0, end=23)
|
||||
now = datetime.now(tz=UTC).time()
|
||||
expected = now in session
|
||||
assert session.in_session() == expected
|
||||
|
||||
|
||||
class TestSessionActions:
|
||||
"""Test Session action methods."""
|
||||
@@ -318,6 +396,23 @@ class TestSessionActions:
|
||||
# Should not raise, just log warning
|
||||
await session.action(action="close_all")
|
||||
|
||||
async def test_action_unknown_action_does_nothing(self, session):
|
||||
"""Test action with unknown string does nothing."""
|
||||
# Should not raise - falls through to default case
|
||||
await session.action(action="unknown_action")
|
||||
|
||||
async def test_begin_with_no_on_start(self, session):
|
||||
"""Test begin does nothing when on_start is None."""
|
||||
session.on_start = None
|
||||
# Should not raise
|
||||
await session.begin()
|
||||
|
||||
async def test_close_with_no_on_end(self, session):
|
||||
"""Test close does nothing when on_end is None."""
|
||||
session.on_end = None
|
||||
# Should not raise
|
||||
await session.close()
|
||||
|
||||
|
||||
class TestSessionClosePositions:
|
||||
"""Test Session position closing methods."""
|
||||
@@ -337,6 +432,45 @@ class TestSessionClosePositions:
|
||||
await session.close_positions(positions=(position,))
|
||||
session.positions_manager.close_position.assert_called_once_with(position=position)
|
||||
|
||||
async def test_close_positions_counts_closed(self, session):
|
||||
"""Test close_positions correctly counts successful closes."""
|
||||
pos1 = MagicMock(spec=TradePosition)
|
||||
pos2 = MagicMock(spec=TradePosition)
|
||||
|
||||
result_ok = MagicMock(spec=OrderSendResult)
|
||||
result_ok.retcode = 10009
|
||||
|
||||
session.positions_manager.close_position = AsyncMock(return_value=result_ok)
|
||||
# Should not raise
|
||||
await session.close_positions(positions=(pos1, pos2))
|
||||
|
||||
async def test_close_positions_counts_pending(self, session):
|
||||
"""Test close_positions counts pending (non-10009) results."""
|
||||
position = MagicMock(spec=TradePosition)
|
||||
|
||||
result_fail = MagicMock(spec=OrderSendResult)
|
||||
result_fail.retcode = 10006 # Not 10009
|
||||
|
||||
session.positions_manager.close_position = AsyncMock(return_value=result_fail)
|
||||
# Should not raise, logs warning about pending
|
||||
await session.close_positions(positions=(position,))
|
||||
|
||||
async def test_close_positions_handles_exceptions_in_results(self, session):
|
||||
"""Test close_positions handles exceptions in gather results."""
|
||||
position = MagicMock(spec=TradePosition)
|
||||
|
||||
session.positions_manager.close_position = AsyncMock(
|
||||
side_effect=Exception("Connection error")
|
||||
)
|
||||
# return_exceptions=True means exceptions are returned, not raised
|
||||
await session.close_positions(positions=(position,))
|
||||
|
||||
async def test_close_positions_empty_tuple(self, session):
|
||||
"""Test close_positions with empty tuple."""
|
||||
session.positions_manager.close_position = AsyncMock()
|
||||
await session.close_positions(positions=())
|
||||
session.positions_manager.close_position.assert_not_called()
|
||||
|
||||
async def test_close_all(self, session):
|
||||
"""Test close_all gets and closes all positions."""
|
||||
positions = (MagicMock(spec=TradePosition),)
|
||||
@@ -363,6 +497,21 @@ class TestSessionClosePositions:
|
||||
assert win_pos in closed_positions
|
||||
assert loss_pos not in closed_positions
|
||||
|
||||
async def test_close_win_includes_zero_profit(self, session):
|
||||
"""Test close_win includes positions with zero profit (>= 0)."""
|
||||
zero_pos = MagicMock(spec=TradePosition)
|
||||
zero_pos.profit = 0
|
||||
loss_pos = MagicMock(spec=TradePosition)
|
||||
loss_pos.profit = -10
|
||||
|
||||
session.positions_manager.get_positions = AsyncMock(return_value=(zero_pos, loss_pos))
|
||||
session.close_positions = AsyncMock()
|
||||
|
||||
await session.close_win()
|
||||
closed_positions = session.close_positions.call_args[1]["positions"]
|
||||
assert zero_pos in closed_positions
|
||||
assert loss_pos not in closed_positions
|
||||
|
||||
async def test_close_loss_filters_loss(self, session):
|
||||
"""Test close_loss only closes losing positions."""
|
||||
win_pos = MagicMock(spec=TradePosition)
|
||||
@@ -379,20 +528,35 @@ class TestSessionClosePositions:
|
||||
assert loss_pos in closed_positions
|
||||
assert win_pos not in closed_positions
|
||||
|
||||
async def test_close_loss_excludes_zero_profit(self, session):
|
||||
"""Test close_loss excludes positions with zero profit (< 0 only)."""
|
||||
zero_pos = MagicMock(spec=TradePosition)
|
||||
zero_pos.profit = 0
|
||||
loss_pos = MagicMock(spec=TradePosition)
|
||||
loss_pos.profit = -10
|
||||
|
||||
session.positions_manager.get_positions = AsyncMock(return_value=(zero_pos, loss_pos))
|
||||
session.close_positions = AsyncMock()
|
||||
|
||||
await session.close_loss()
|
||||
closed_positions = session.close_positions.call_args[1]["positions"]
|
||||
assert loss_pos in closed_positions
|
||||
assert zero_pos not in closed_positions
|
||||
|
||||
|
||||
class TestSessionUntil:
|
||||
"""Test Session until method."""
|
||||
|
||||
@patch.object(Config, '__new__')
|
||||
def test_until_returns_seconds(self, mock_config):
|
||||
"""Test until returns seconds until session start."""
|
||||
config = MagicMock()
|
||||
config.mode = "live"
|
||||
mock_config.return_value = config
|
||||
|
||||
session = Session(start=23, end=0) # Future session
|
||||
def test_until_returns_int(self):
|
||||
"""Test until returns an integer."""
|
||||
session = Session(start=23, end=0)
|
||||
result = session.until()
|
||||
assert isinstance(result, int)
|
||||
|
||||
def test_until_returns_nonnegative(self):
|
||||
"""Test until returns a non-negative value."""
|
||||
session = Session(start=23, end=0)
|
||||
result = session.until()
|
||||
assert result >= 0
|
||||
|
||||
|
||||
@@ -423,6 +587,37 @@ class TestSessionsInitialization:
|
||||
sessions = Sessions(sessions=[s1])
|
||||
assert isinstance(sessions.config, Config)
|
||||
|
||||
def test_init_current_session_none(self):
|
||||
"""Test Sessions initializes current_session to None."""
|
||||
s1 = Session(start=8, end=12)
|
||||
sessions = Sessions(sessions=[s1])
|
||||
assert sessions.current_session is None
|
||||
|
||||
def test_init_sorts_by_start_then_end(self):
|
||||
"""Test Sessions sorts by start hour then end hour."""
|
||||
s1 = Session(start=8, end=16)
|
||||
s2 = Session(start=8, end=12)
|
||||
sessions = Sessions(sessions=[s1, s2])
|
||||
|
||||
# Both start at 8, sorted by end hour
|
||||
assert sessions.sessions[0].end.hour == 12
|
||||
assert sessions.sessions[1].end.hour == 16
|
||||
|
||||
def test_init_accepts_iterable(self):
|
||||
"""Test Sessions accepts any iterable of sessions."""
|
||||
s1 = Session(start=8, end=12)
|
||||
s2 = Session(start=13, end=17)
|
||||
|
||||
# Pass as tuple
|
||||
sessions = Sessions(sessions=(s1, s2))
|
||||
assert len(sessions.sessions) == 2
|
||||
|
||||
def test_init_single_session(self):
|
||||
"""Test Sessions with a single session."""
|
||||
s1 = Session(start=8, end=16)
|
||||
sessions = Sessions(sessions=[s1])
|
||||
assert len(sessions.sessions) == 1
|
||||
|
||||
|
||||
class TestSessionsFind:
|
||||
"""Test Sessions find method."""
|
||||
@@ -451,6 +646,22 @@ class TestSessionsFind:
|
||||
assert result is not None
|
||||
assert result.start.hour == 13
|
||||
|
||||
def test_find_at_boundary(self, sessions):
|
||||
"""Test find at session start boundary."""
|
||||
result = sessions.find(moment=time(8, 0))
|
||||
assert result is not None
|
||||
assert result.start.hour == 8
|
||||
|
||||
def test_find_before_all_sessions(self, sessions):
|
||||
"""Test find before any session returns None."""
|
||||
result = sessions.find(moment=time(5, 0))
|
||||
assert result is None
|
||||
|
||||
def test_find_after_all_sessions(self, sessions):
|
||||
"""Test find after all sessions returns None."""
|
||||
result = sessions.find(moment=time(20, 0))
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestSessionsFindNext:
|
||||
"""Test Sessions find_next method."""
|
||||
@@ -477,6 +688,11 @@ class TestSessionsFindNext:
|
||||
result = sessions.find_next(moment=time(18, 0))
|
||||
assert result.start.hour == 8
|
||||
|
||||
def test_find_next_at_midnight(self, sessions):
|
||||
"""Test find_next at midnight wraps correctly."""
|
||||
result = sessions.find_next(moment=time(0, 0))
|
||||
assert result.start.hour == 8
|
||||
|
||||
|
||||
class TestSessionsContains:
|
||||
"""Test Sessions __contains__ method."""
|
||||
@@ -500,6 +716,10 @@ class TestSessionsContains:
|
||||
"""Test time outside all sessions returns False."""
|
||||
assert time(18, 0) not in sessions
|
||||
|
||||
def test_contains_time_in_second_session(self, sessions):
|
||||
"""Test time in second session returns True."""
|
||||
assert time(15, 0) in sessions
|
||||
|
||||
|
||||
class TestSessionsContextManager:
|
||||
"""Test Sessions async context manager."""
|
||||
@@ -527,6 +747,21 @@ class TestSessionsContextManager:
|
||||
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
async def test_aexit_no_current_session(self, sessions):
|
||||
"""Test __aexit__ does nothing when current_session is None."""
|
||||
sessions.check = AsyncMock()
|
||||
sessions.current_session = None
|
||||
|
||||
# Should not raise
|
||||
async with sessions:
|
||||
pass
|
||||
|
||||
async def test_aenter_returns_self(self, sessions):
|
||||
"""Test __aenter__ returns the Sessions instance."""
|
||||
sessions.check = AsyncMock()
|
||||
async with sessions as s:
|
||||
assert s is sessions
|
||||
|
||||
|
||||
class TestSessionsCheck:
|
||||
"""Test Sessions check method."""
|
||||
@@ -572,6 +807,58 @@ class TestSessionsCheck:
|
||||
old_session.close.assert_called_once()
|
||||
assert sessions.current_session == new_session
|
||||
|
||||
async def test_check_sleeps_when_outside_sessions(self, sessions):
|
||||
"""Test check sleeps until next session when outside all sessions."""
|
||||
sessions.current_session = None
|
||||
sessions.find = MagicMock(return_value=None)
|
||||
|
||||
next_session = MagicMock()
|
||||
next_session.until.return_value = 100
|
||||
next_session.begin = AsyncMock()
|
||||
sessions.find_next = MagicMock(return_value=next_session)
|
||||
|
||||
with patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep:
|
||||
await sessions.check()
|
||||
mock_sleep.assert_called_once_with(110) # until() + 10
|
||||
|
||||
assert sessions.current_session == next_session
|
||||
next_session.begin.assert_called_once()
|
||||
|
||||
async def test_check_closes_old_session_before_sleeping(self, sessions):
|
||||
"""Test check closes current session before sleeping for next."""
|
||||
old_session = MagicMock()
|
||||
old_session.in_session.return_value = False
|
||||
old_session.close = AsyncMock()
|
||||
sessions.current_session = old_session
|
||||
|
||||
sessions.find = MagicMock(return_value=None)
|
||||
|
||||
next_session = MagicMock()
|
||||
next_session.until.return_value = 50
|
||||
next_session.begin = AsyncMock()
|
||||
sessions.find_next = MagicMock(return_value=next_session)
|
||||
|
||||
with patch('asyncio.sleep', new_callable=AsyncMock):
|
||||
await sessions.check()
|
||||
|
||||
old_session.close.assert_called_once()
|
||||
assert sessions.current_session == next_session
|
||||
|
||||
async def test_check_begins_next_session_after_sleep(self, sessions):
|
||||
"""Test check calls begin on next session after sleeping."""
|
||||
sessions.current_session = None
|
||||
sessions.find = MagicMock(return_value=None)
|
||||
|
||||
next_session = MagicMock()
|
||||
next_session.until.return_value = 0
|
||||
next_session.begin = AsyncMock()
|
||||
sessions.find_next = MagicMock(return_value=next_session)
|
||||
|
||||
with patch('asyncio.sleep', new_callable=AsyncMock):
|
||||
await sessions.check()
|
||||
|
||||
next_session.begin.assert_called_once()
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests for Sessions."""
|
||||
@@ -619,3 +906,41 @@ class TestIntegration:
|
||||
|
||||
assert called["start"] is True
|
||||
assert called["end"] is True
|
||||
|
||||
def test_find_navigates_across_sessions(self):
|
||||
"""Test finding sessions across the full day."""
|
||||
s1 = Session(start=8, end=12)
|
||||
s2 = Session(start=13, end=17)
|
||||
s3 = Session(start=18, end=22)
|
||||
sessions = Sessions(sessions=[s1, s2, s3])
|
||||
|
||||
# Before any session
|
||||
assert sessions.find(moment=time(5, 0)) is None
|
||||
|
||||
# In first session
|
||||
result = sessions.find(moment=time(10, 0))
|
||||
assert result.start.hour == 8
|
||||
|
||||
# Between sessions
|
||||
assert sessions.find(moment=time(12, 30)) is None
|
||||
|
||||
# In second session
|
||||
result = sessions.find(moment=time(15, 0))
|
||||
assert result.start.hour == 13
|
||||
|
||||
# In third session
|
||||
result = sessions.find(moment=time(20, 0))
|
||||
assert result.start.hour == 18
|
||||
|
||||
# After all sessions
|
||||
assert sessions.find(moment=time(23, 0)) is None
|
||||
|
||||
def test_session_actions_configuration(self):
|
||||
"""Test configuring different actions on sessions."""
|
||||
s1 = Session(start=8, end=12, on_start="close_all", on_end="close_loss")
|
||||
s2 = Session(start=13, end=17, on_end="close_win")
|
||||
|
||||
assert s1.on_start == "close_all"
|
||||
assert s1.on_end == "close_loss"
|
||||
assert s2.on_start is None
|
||||
assert s2.on_end == "close_win"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user