Skip to content

Commit 6249c23

Browse files
committed
feat: validate data source exists for target/trading symbol pair (#247)
validate_symbol now checks two things: 1. target_symbol != trading_symbol (prevents EUR/EUR) 2. A data source is registered for the target_symbol/trading_symbol combination (e.g. BTC/EUR) to ensure price history tracking
1 parent d617e44 commit 6249c23

2 files changed

Lines changed: 105 additions & 30 deletions

File tree

investing_algorithm_framework/app/context.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,32 +53,55 @@ def __init__(
5353

5454
def _validate_target_symbol(self, target_symbol, market=None):
5555
"""
56-
Validate that the target_symbol combined with the portfolio's
57-
trading_symbol does not result in trading_symbol/trading_symbol
58-
(e.g., EUR/EUR), which is a nonsensical order.
56+
Validate the target_symbol for order creation:
57+
1. Prevents orders where target_symbol equals trading_symbol
58+
(e.g. EUR/EUR).
59+
2. Checks that a data source exists for the
60+
target_symbol/trading_symbol combination (e.g. BTC/EUR).
5961
6062
Args:
6163
target_symbol: The symbol of the asset to trade
6264
market: The market to check against
6365
6466
Raises:
65-
OperationalException: If target_symbol equals
66-
the trading_symbol
67+
OperationalException: If validation fails
6768
"""
6869
portfolio = self.portfolio_service.find({"market": market})
69-
trading_symbol = portfolio.trading_symbol.upper()
70+
trading_symbol = portfolio.trading_symbol
7071

71-
if target_symbol.upper() == trading_symbol:
72+
# Check target_symbol != trading_symbol
73+
if target_symbol.upper() == trading_symbol.upper():
7274
raise OperationalException(
7375
f"target_symbol '{target_symbol}' is the same as "
74-
f"the trading_symbol '{portfolio.trading_symbol}'. "
76+
f"the trading_symbol '{trading_symbol}'. "
7577
f"This would result in a "
76-
f"'{portfolio.trading_symbol}/{portfolio.trading_symbol}' "
78+
f"'{trading_symbol}/{trading_symbol}' "
7779
f"order which is not valid. "
7880
f"To skip this check, set validate_symbol=False "
7981
f"or omit the parameter."
8082
)
8183

84+
# Check that a data source is registered for this pair
85+
expected_symbol = f"{target_symbol}/{trading_symbol}".upper()
86+
known_symbols = set()
87+
88+
if self.data_provider_service.data_provider_index is not None:
89+
for data_source, _ in \
90+
self.data_provider_service \
91+
.data_provider_index.get_all():
92+
if data_source.symbol is not None:
93+
known_symbols.add(data_source.symbol.upper())
94+
95+
if expected_symbol not in known_symbols:
96+
sorted_symbols = sorted(known_symbols)
97+
raise OperationalException(
98+
f"No data source registered for '{expected_symbol}'. "
99+
f"A data source is required to track price history. "
100+
f"Registered data source symbols: {sorted_symbols}. "
101+
f"To skip this check, set validate_symbol=False "
102+
f"or omit the parameter."
103+
)
104+
82105
@property
83106
def config(self):
84107
"""

tests/app/algorithm/test_validate_symbol.py

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from investing_algorithm_framework import PortfolioConfiguration, \
2-
MarketCredential, OrderSide
2+
MarketCredential, OrderSide, DataSource
33
from investing_algorithm_framework.domain import OperationalException
44
from tests.resources import TestBase
55
from tests.resources.strategies_for_testing import StrategyOne
@@ -8,8 +8,9 @@
88
class TestValidateSymbol(TestBase):
99
"""Tests for opt-in symbol validation on order creation (issue #247).
1010
11-
Validates that target_symbol is not the same as trading_symbol,
12-
which would result in a nonsensical order (e.g. EUR/EUR).
11+
Validates:
12+
1. target_symbol is not the same as trading_symbol (e.g. EUR/EUR)
13+
2. A data source exists for the target_symbol/trading_symbol pair
1314
"""
1415
portfolio_configurations = [
1516
PortfolioConfiguration(
@@ -28,8 +29,21 @@ class TestValidateSymbol(TestBase):
2829
"EUR": 1000
2930
}
3031

31-
def test_default_allows_trading_symbol_as_target(self):
32-
"""Default behavior: no validation, even trading_symbol is accepted."""
32+
def _register_data_source(self, symbol):
33+
"""Register a data source symbol in the data provider index."""
34+
data_source = DataSource(
35+
identifier=f"{symbol}_ohlcv",
36+
symbol=symbol,
37+
data_type="OHLCV",
38+
time_frame="1d",
39+
market="BITVAVO",
40+
)
41+
data_provider_service = self.app.container.data_provider_service()
42+
data_provider_service.data_provider_index\
43+
.data_providers_lookup[data_source] = None
44+
45+
def test_default_allows_any_symbol(self):
46+
"""Default behavior: no validation, any symbol is accepted."""
3347
self.app.add_strategy(StrategyOne)
3448
self.app.context.create_limit_order(
3549
target_symbol="EUR",
@@ -41,8 +55,8 @@ def test_default_allows_trading_symbol_as_target(self):
4155
order = order_repository.find({"target_symbol": "EUR"})
4256
self.assertIsNotNone(order)
4357

44-
def test_validate_symbol_false_allows_trading_symbol_as_target(self):
45-
"""Explicit validate_symbol=False allows trading_symbol as target."""
58+
def test_validate_symbol_false_allows_any_symbol(self):
59+
"""Explicit validate_symbol=False skips all validation."""
4660
self.app.add_strategy(StrategyOne)
4761
self.app.context.create_limit_order(
4862
target_symbol="EUR",
@@ -55,9 +69,8 @@ def test_validate_symbol_false_allows_trading_symbol_as_target(self):
5569
order = order_repository.find({"target_symbol": "EUR"})
5670
self.assertIsNotNone(order)
5771

58-
def test_validate_symbol_true_rejects_trading_symbol_as_target(self):
59-
"""validate_symbol=True raises when target_symbol equals
60-
trading_symbol (e.g. EUR/EUR)."""
72+
def test_rejects_target_equals_trading_symbol(self):
73+
"""Rejects orders where target_symbol == trading_symbol (EUR/EUR)."""
6174
self.app.add_strategy(StrategyOne)
6275

6376
with self.assertRaises(OperationalException) as cm:
@@ -70,12 +83,11 @@ def test_validate_symbol_true_rejects_trading_symbol_as_target(self):
7083
)
7184

7285
error_msg = str(cm.exception)
73-
self.assertIn("EUR", error_msg)
74-
self.assertIn("trading_symbol", error_msg)
7586
self.assertIn("EUR/EUR", error_msg)
87+
self.assertIn("trading_symbol", error_msg)
7688

77-
def test_validate_symbol_true_case_insensitive(self):
78-
"""validate_symbol=True catches trading_symbol regardless of case."""
89+
def test_rejects_target_equals_trading_symbol_case_insensitive(self):
90+
"""Case-insensitive check for target == trading_symbol."""
7991
self.app.add_strategy(StrategyOne)
8092

8193
with self.assertRaises(OperationalException):
@@ -87,10 +99,27 @@ def test_validate_symbol_true_case_insensitive(self):
8799
validate_symbol=True,
88100
)
89101

90-
def test_validate_symbol_true_accepts_valid_target(self):
91-
"""validate_symbol=True passes for a target that differs
92-
from trading_symbol."""
102+
def test_rejects_missing_data_source(self):
103+
"""Rejects when no data source is registered for the pair."""
104+
self.app.add_strategy(StrategyOne)
105+
106+
with self.assertRaises(OperationalException) as cm:
107+
self.app.context.create_limit_order(
108+
target_symbol="BTC",
109+
amount=1,
110+
price=10,
111+
order_side=OrderSide.BUY,
112+
validate_symbol=True,
113+
)
114+
115+
error_msg = str(cm.exception)
116+
self.assertIn("BTC/EUR", error_msg)
117+
self.assertIn("No data source registered", error_msg)
118+
119+
def test_accepts_with_matching_data_source(self):
120+
"""Passes when a data source exists for target/trading pair."""
93121
self.app.add_strategy(StrategyOne)
122+
self._register_data_source("BTC/EUR")
94123

95124
self.app.context.create_limit_order(
96125
target_symbol="BTC",
@@ -103,19 +132,42 @@ def test_validate_symbol_true_accepts_valid_target(self):
103132
order = order_repository.find({"target_symbol": "BTC"})
104133
self.assertIsNotNone(order)
105134

106-
def test_validate_symbol_error_message(self):
107-
"""Error message explains the EUR/EUR problem and how to skip."""
135+
def test_rejects_wrong_data_source(self):
136+
"""Rejects when only a different pair is registered."""
108137
self.app.add_strategy(StrategyOne)
138+
self._register_data_source("ETH/EUR")
109139

110140
with self.assertRaises(OperationalException) as cm:
111141
self.app.context.create_limit_order(
112-
target_symbol="EUR",
142+
target_symbol="BTC",
113143
amount=1,
114144
price=10,
115145
order_side=OrderSide.BUY,
116146
validate_symbol=True,
117147
)
118148

119149
error_msg = str(cm.exception)
120-
self.assertIn("EUR/EUR", error_msg)
150+
self.assertIn("BTC/EUR", error_msg)
151+
self.assertIn("ETH/EUR", error_msg)
152+
153+
def test_error_message_lists_registered_symbols(self):
154+
"""Error message includes registered data source symbols."""
155+
self.app.add_strategy(StrategyOne)
156+
self._register_data_source("BTC/EUR")
157+
self._register_data_source("ETH/EUR")
158+
159+
with self.assertRaises(OperationalException) as cm:
160+
self.app.context.create_limit_order(
161+
target_symbol="SOL",
162+
amount=1,
163+
price=10,
164+
order_side=OrderSide.BUY,
165+
validate_symbol=True,
166+
)
167+
168+
error_msg = str(cm.exception)
169+
self.assertIn("SOL/EUR", error_msg)
170+
self.assertIn("BTC/EUR", error_msg)
171+
self.assertIn("ETH/EUR", error_msg)
172+
self.assertIn("validate_symbol=False", error_msg)
121173
self.assertIn("validate_symbol=False", error_msg)

0 commit comments

Comments
 (0)