v4.0.17 no-backtest

This commit is contained in:
Ichinga Samuel
2026-02-21 07:43:33 +01:00
parent 844e39194b
commit 97aea6952e
124 changed files with 8198 additions and 14349 deletions
@@ -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
View File
@@ -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)
+188 -60
View File
@@ -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
+781 -19
View File
@@ -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
+308 -41
View File
@@ -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()
-49
View File
@@ -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
+342 -17
View File
@@ -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