Skip to content

Commit d6bcef8

Browse files
committed
feat: add opt-in validate_symbol parameter to order creation (#247)
Add validate_symbol parameter (default False) to create_limit_order and create_order in Context and TradingStrategy. When enabled, validates the target_symbol against registered data sources and portfolio positions, raising OperationalException for unknown symbols. Closes #247
1 parent 188ec26 commit d6bcef8

3 files changed

Lines changed: 219 additions & 4 deletions

File tree

investing_algorithm_framework/app/context.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,59 @@ def __init__(
5151
self.trade_take_profit_service: TradeTakeProfitService = \
5252
trade_take_profit_service
5353

54+
def _validate_target_symbol(self, target_symbol, market=None):
55+
"""
56+
Validate that the target_symbol is a known symbol by checking
57+
against registered data sources and existing portfolio positions.
58+
59+
Args:
60+
target_symbol: The symbol to validate (e.g., "BTC" or "BTC/EUR")
61+
market: The market to check against
62+
63+
Raises:
64+
OperationalException: If the symbol is not recognized
65+
"""
66+
known_symbols = set()
67+
68+
# Collect symbols from registered data sources
69+
if self.data_provider_service.data_provider_index is not None:
70+
for data_source, _ in \
71+
self.data_provider_service \
72+
.data_provider_index.get_all():
73+
if data_source.symbol is not None:
74+
known_symbols.add(data_source.symbol.upper())
75+
76+
# Collect symbols from existing portfolio positions
77+
portfolio = self.portfolio_service.find({"market": market})
78+
79+
if portfolio is not None:
80+
positions = self.position_service.get_all(
81+
{"portfolio": portfolio.id}
82+
)
83+
84+
for position in positions:
85+
if position.symbol is not None:
86+
symbol = f"{position.symbol.upper()}" \
87+
f"/{portfolio.trading_symbol.upper()}"
88+
known_symbols.add(symbol)
89+
90+
# Build the full symbol for comparison
91+
full_symbol = target_symbol.upper()
92+
93+
if "/" not in full_symbol and portfolio is not None:
94+
full_symbol = \
95+
f"{full_symbol}/{portfolio.trading_symbol.upper()}"
96+
97+
if full_symbol not in known_symbols:
98+
sorted_symbols = sorted(known_symbols)
99+
raise OperationalException(
100+
f"Symbol '{target_symbol}' is not a known asset. "
101+
f"Known symbols: {sorted_symbols}. "
102+
f"Check for typos or add a DataSource for this symbol. "
103+
f"To skip this check, set validate_symbol=False "
104+
f"or omit the parameter."
105+
)
106+
54107
@property
55108
def config(self):
56109
"""
@@ -78,7 +131,8 @@ def create_order(
78131
market=None,
79132
execute=True,
80133
validate=True,
81-
sync=True
134+
sync=True,
135+
validate_symbol=False
82136
) -> Order:
83137
"""
84138
Function to create an order. This function will create an order
@@ -96,10 +150,15 @@ def create_order(
96150
validate: If set to True, the order will be validated
97151
sync: If set to True, the created order will be synced
98152
with the portfolio of the algorithm.
153+
validate_symbol: Default False. If set to True,
154+
the target_symbol will be validated against known symbols.
99155
100156
Returns:
101157
The order created
102158
"""
159+
if validate_symbol:
160+
self._validate_target_symbol(target_symbol, market=market)
161+
103162
portfolio = self.portfolio_service.find({"market": market})
104163
order_data = {
105164
"target_symbol": target_symbol,
@@ -162,7 +221,8 @@ def create_limit_order(
162221
execute=True,
163222
validate=True,
164223
sync=True,
165-
metadata=None
224+
metadata=None,
225+
validate_symbol=False
166226
) -> Order:
167227
"""
168228
Function to create a limit order. This function will create a limit
@@ -193,10 +253,16 @@ def create_limit_order(
193253
sync (optional): Default True. If set to True,
194254
the created order will be synced with the
195255
portfolio of the algorithm
256+
validate_symbol (optional): Default False. If set to True,
257+
the target_symbol will be validated against known symbols
258+
from registered data sources and portfolio positions.
196259
197260
Returns:
198261
Order: Instance of the order created
199262
"""
263+
if validate_symbol:
264+
self._validate_target_symbol(target_symbol, market=market)
265+
200266
portfolio = self.portfolio_service.find({"market": market})
201267

202268
if percentage_of_portfolio is not None:

investing_algorithm_framework/app/strategy.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -939,7 +939,8 @@ def create_limit_order(
939939
execute=True,
940940
validate=True,
941941
sync=True,
942-
metadata=None
942+
metadata=None,
943+
validate_symbol=False
943944
) -> Order:
944945
"""
945946
Function to create a limit order. This function will create
@@ -986,7 +987,8 @@ def create_limit_order(
986987
execute=execute,
987988
validate=validate,
988989
sync=sync,
989-
metadata=metadata
990+
metadata=metadata,
991+
validate_symbol=validate_symbol
990992
)
991993

992994
def close_position(
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
from investing_algorithm_framework import PortfolioConfiguration, \
2+
MarketCredential, OrderSide, DataSource
3+
from investing_algorithm_framework.domain import OperationalException
4+
from tests.resources import TestBase
5+
from tests.resources.strategies_for_testing import StrategyOne
6+
7+
8+
class TestValidateSymbol(TestBase):
9+
"""Tests for opt-in symbol validation on order creation (issue #247)."""
10+
portfolio_configurations = [
11+
PortfolioConfiguration(
12+
market="BITVAVO",
13+
trading_symbol="EUR"
14+
)
15+
]
16+
market_credentials = [
17+
MarketCredential(
18+
market="BITVAVO",
19+
api_key="api_key",
20+
secret_key="secret_key"
21+
)
22+
]
23+
external_balances = {
24+
"EUR": 1000
25+
}
26+
27+
def _register_data_source(self, symbol):
28+
"""Register a data source symbol in the data provider index."""
29+
data_source = DataSource(
30+
identifier=f"{symbol}_ohlcv",
31+
symbol=symbol,
32+
data_type="OHLCV",
33+
time_frame="1d",
34+
market="BITVAVO",
35+
)
36+
data_provider_service = self.app.container.data_provider_service()
37+
data_provider_service.data_provider_index\
38+
.data_providers_lookup[data_source] = None
39+
40+
def test_default_allows_unknown_symbol(self):
41+
"""Default behavior: no symbol validation, any symbol is accepted."""
42+
self.app.add_strategy(StrategyOne)
43+
self.app.context.create_limit_order(
44+
target_symbol="UNKNOWN_TOKEN",
45+
amount=1,
46+
price=10,
47+
order_side=OrderSide.BUY,
48+
)
49+
order_repository = self.app.container.order_repository()
50+
order = order_repository.find({"target_symbol": "UNKNOWN_TOKEN"})
51+
self.assertIsNotNone(order)
52+
53+
def test_validate_symbol_false_allows_unknown_symbol(self):
54+
"""Explicit validate_symbol=False allows any symbol."""
55+
self.app.add_strategy(StrategyOne)
56+
self.app.context.create_limit_order(
57+
target_symbol="UNKNOWN_TOKEN",
58+
amount=1,
59+
price=10,
60+
order_side=OrderSide.BUY,
61+
validate_symbol=False,
62+
)
63+
order_repository = self.app.container.order_repository()
64+
order = order_repository.find({"target_symbol": "UNKNOWN_TOKEN"})
65+
self.assertIsNotNone(order)
66+
67+
def test_validate_symbol_true_rejects_unknown_symbol(self):
68+
"""validate_symbol=True raises for a symbol not in data sources."""
69+
self.app.add_strategy(StrategyOne)
70+
self._register_data_source("BTC/EUR")
71+
72+
with self.assertRaises(OperationalException) as cm:
73+
self.app.context.create_limit_order(
74+
target_symbol="BT/EUR",
75+
amount=1,
76+
price=10,
77+
order_side=OrderSide.BUY,
78+
validate_symbol=True,
79+
)
80+
81+
self.assertIn("BT/EUR", str(cm.exception))
82+
self.assertIn("not a known asset", str(cm.exception))
83+
self.assertIn("BTC/EUR", str(cm.exception))
84+
85+
def test_validate_symbol_true_accepts_known_symbol(self):
86+
"""validate_symbol=True passes for a symbol in data sources."""
87+
self.app.add_strategy(StrategyOne)
88+
self._register_data_source("BTC/EUR")
89+
90+
self.app.context.create_limit_order(
91+
target_symbol="BTC",
92+
amount=1,
93+
price=10,
94+
order_side=OrderSide.BUY,
95+
validate_symbol=True,
96+
)
97+
order_repository = self.app.container.order_repository()
98+
order = order_repository.find({"target_symbol": "BTC"})
99+
self.assertIsNotNone(order)
100+
101+
def test_validate_symbol_true_multiple_data_sources(self):
102+
"""validate_symbol=True checks across all registered data sources."""
103+
self.app.add_strategy(StrategyOne)
104+
self._register_data_source("BTC/EUR")
105+
self._register_data_source("ETH/EUR")
106+
107+
# ETH should pass
108+
self.app.context.create_limit_order(
109+
target_symbol="ETH",
110+
amount=1,
111+
price=10,
112+
order_side=OrderSide.BUY,
113+
validate_symbol=True,
114+
)
115+
order_repository = self.app.container.order_repository()
116+
order = order_repository.find({"target_symbol": "ETH"})
117+
self.assertIsNotNone(order)
118+
119+
# SOL should fail
120+
with self.assertRaises(OperationalException):
121+
self.app.context.create_limit_order(
122+
target_symbol="SOL",
123+
amount=1,
124+
price=10,
125+
order_side=OrderSide.BUY,
126+
validate_symbol=True,
127+
)
128+
129+
def test_validate_symbol_error_message_contains_known_symbols(self):
130+
"""Error message lists known symbols for user reference."""
131+
self.app.add_strategy(StrategyOne)
132+
self._register_data_source("BTC/EUR")
133+
self._register_data_source("ETH/EUR")
134+
135+
with self.assertRaises(OperationalException) as cm:
136+
self.app.context.create_limit_order(
137+
target_symbol="TYPO",
138+
amount=1,
139+
price=10,
140+
order_side=OrderSide.BUY,
141+
validate_symbol=True,
142+
)
143+
144+
error_msg = str(cm.exception)
145+
self.assertIn("BTC/EUR", error_msg)
146+
self.assertIn("ETH/EUR", error_msg)
147+
self.assertIn("validate_symbol=False", error_msg)

0 commit comments

Comments
 (0)