Skip to content

Commit da5565b

Browse files
committed
feat: add order metadata with reason tracking
- Add metadata_json column to SQLOrder model with __init__/reconstructor pattern - Tag orders with reasons (buy_signal, sell_signal, scale_out, stop_loss, take_profit) - Add metadata support in vector backtest service and trade service - Add 43 tests across 7 test files for metadata persistence and propagation
1 parent 011a6c5 commit da5565b

12 files changed

Lines changed: 1411 additions & 11 deletions

File tree

investing_algorithm_framework/app/context.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,8 @@ def create_limit_order(
161161
market=None,
162162
execute=True,
163163
validate=True,
164-
sync=True
164+
sync=True,
165+
metadata=None
165166
) -> Order:
166167
"""
167168
Function to create a limit order. This function will create a limit
@@ -257,6 +258,9 @@ def create_limit_order(
257258
"trading_symbol": portfolio.trading_symbol,
258259
}
259260

261+
if metadata is not None:
262+
order_data["metadata"] = metadata
263+
260264
if BACKTESTING_FLAG in self.configuration_service.config \
261265
and self.configuration_service.config[BACKTESTING_FLAG]:
262266
order_data["created_at"] = \

investing_algorithm_framework/app/strategy.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,8 @@ def run_strategy(self, context: Context, data: Dict[str, Any]):
566566
price=price,
567567
execute=True,
568568
validate=True,
569-
sync=True
569+
sync=True,
570+
metadata={"order_reason": "buy_signal"}
570571
)
571572

572573
# Retrieve and apply stop loss and take profit rules
@@ -637,7 +638,8 @@ def run_strategy(self, context: Context, data: Dict[str, Any]):
637638
execute=True,
638639
validate=True,
639640
sync=True,
640-
price=price
641+
price=price,
642+
metadata={"order_reason": "sell_signal"}
641643
)
642644

643645
# Reset scale-out counter and start cooldown
@@ -683,7 +685,8 @@ def run_strategy(self, context: Context, data: Dict[str, Any]):
683685
execute=True,
684686
validate=True,
685687
sync=True,
686-
price=price
688+
price=price,
689+
metadata={"order_reason": "scale_out"}
687690
)
688691

689692
self._scale_out_counts[symbol] = so_index + 1
@@ -935,7 +938,8 @@ def create_limit_order(
935938
market=None,
936939
execute=True,
937940
validate=True,
938-
sync=True
941+
sync=True,
942+
metadata=None
939943
) -> Order:
940944
"""
941945
Function to create a limit order. This function will create
@@ -981,7 +985,8 @@ def create_limit_order(
981985
market=market,
982986
execute=execute,
983987
validate=validate,
984-
sync=sync
988+
sync=sync,
989+
metadata=metadata
985990
)
986991

987992
def close_position(

investing_algorithm_framework/infrastructure/models/order/order.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import json
12
import logging
23
from datetime import datetime, timezone
34

4-
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
5-
from sqlalchemy.orm import relationship
5+
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
6+
from sqlalchemy.orm import relationship, reconstructor
67

78
from investing_algorithm_framework.domain import OrderType, \
89
OrderSide, Order, OrderStatus
@@ -52,20 +53,41 @@ class SQLOrder(Order, SQLBaseModel, SQLAlchemyModelExtension):
5253
order_fee_rate = Column(SqliteDecimal(), default=None)
5354
slippage = Column(SqliteDecimal(), default=None)
5455
sell_order_metadata_id = Column(Integer, ForeignKey('orders.id'))
56+
metadata_json = Column(Text, default=None)
5557
trade_allocations = relationship(
5658
'SQLTradeAllocation', back_populates='order'
5759
)
5860

61+
def __init__(self, metadata=None, **kwargs):
62+
super().__init__(metadata=metadata, **kwargs)
63+
# Sync metadata dict to JSON column for DB persistence
64+
if self.metadata:
65+
self.metadata_json = json.dumps(self.metadata)
66+
67+
@reconstructor
68+
def init_on_load(self):
69+
"""Deserialize metadata from JSON when loaded from DB."""
70+
if self.metadata_json:
71+
self.metadata = json.loads(self.metadata_json)
72+
else:
73+
self.metadata = {}
74+
5975
def update(self, data):
6076

77+
if "metadata" in data:
78+
metadata_val = data.pop("metadata")
79+
self.metadata = metadata_val if metadata_val else {}
80+
self.metadata_json = json.dumps(self.metadata) \
81+
if self.metadata else None
82+
6183
if "status" in data and data["status"] is not None:
6284
data["status"] = OrderStatus.from_value(data["status"]).value
6385

6486
super().update(data)
6587

6688
@staticmethod
6789
def from_order(order):
68-
return SQLOrder(
90+
sql_order = SQLOrder(
6991
external_id=order.external_id,
7092
amount=order.get_amount(),
7193
filled=order.get_filled(),
@@ -78,7 +100,9 @@ def from_order(order):
78100
trading_symbol=order.get_trading_symbol(),
79101
created_at=order.get_created_at(),
80102
updated_at=order.get_updated_at(),
103+
metadata=order.metadata,
81104
)
105+
return sql_order
82106

83107
@staticmethod
84108
def from_ccxt_order(ccxt_order):

investing_algorithm_framework/infrastructure/services/backtesting/vector_backtest_service.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ def _close_trade(sym, sym_data, price, date):
270270
order_fee_rate=tc.fee_percentage / 100
271271
if tc.fee_percentage else None,
272272
slippage=price - sell_fill,
273+
metadata={"order_reason": "sell_signal"},
273274
)
274275
orders.append(sell_order)
275276
trade_orders = lt.orders
@@ -308,6 +309,7 @@ def _close_trade(sym, sym_data, price, date):
308309
order_fee_rate=tc.fee_percentage / 100
309310
if tc.fee_percentage else None,
310311
slippage=price - sell_fill,
312+
metadata={"order_reason": "sell_signal"},
311313
)
312314
orders.append(sell_o)
313315
ot_orders = ot.orders
@@ -330,7 +332,8 @@ def _close_trade(sym, sym_data, price, date):
330332
sym_data['entry_count'] = 0
331333
sym_data['scale_out_count'] = 0
332334

333-
def _open_trade(sym, sym_data, price, date, capital):
335+
def _open_trade(sym, sym_data, price, date, capital,
336+
order_reason="buy_signal"):
334337
"""Helper to open a new trade for a symbol."""
335338
nonlocal current_unallocated, total_allocated
336339

@@ -366,6 +369,7 @@ def _open_trade(sym, sym_data, price, date, capital):
366369
order_fee_rate=tc.fee_percentage / 100
367370
if tc.fee_percentage else None,
368371
slippage=fill_price - price,
372+
metadata={"order_reason": order_reason},
369373
)
370374
orders.append(buy_order)
371375
trade = Trade(
@@ -468,6 +472,7 @@ def _partial_close(sym, sym_data, price, date, sell_pct):
468472
order_fee_rate=tc.fee_percentage / 100
469473
if tc.fee_percentage else None,
470474
slippage=price - sell_fill,
475+
metadata={"order_reason": "scale_out"},
471476
)
472477
orders.append(sell_order)
473478
trade_orders = lt.orders
@@ -669,7 +674,8 @@ def _partial_close(sym, sym_data, price, date, sell_pct):
669674
else:
670675
_open_trade(
671676
symbol, data, current_price,
672-
current_date, capital
677+
current_date, capital,
678+
order_reason="scale_in"
673679
)
674680
signal_events.append({
675681
"date": current_date,

investing_algorithm_framework/services/trade_service/trade_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,7 @@ def get_triggered_stop_loss_orders(self):
10041004
"order_type": OrderType.LIMIT.value,
10051005
"order_side": OrderSide.SELL.value,
10061006
"portfolio_id": portfolio_id,
1007+
"metadata": {"order_reason": "stop_loss"},
10071008
"stop_losses": stop_loss_metadata,
10081009
"trades": [{
10091010
"trade_id": trade.id,
@@ -1110,6 +1111,7 @@ def get_triggered_take_profit_orders(self):
11101111
"order_type": OrderType.LIMIT.value,
11111112
"order_side": OrderSide.SELL.value,
11121113
"portfolio_id": portfolio_id,
1114+
"metadata": {"order_reason": "take_profit"},
11131115
"take_profits": take_profit_metadata,
11141116
"trades": [{
11151117
"trade_id": trade.id,
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""
2+
Tests for order_reason metadata in the production (live) order path.
3+
4+
Verifies that metadata passed through create_limit_order() in
5+
non-backtest mode survives the full pipeline:
6+
context.create_limit_order(metadata=...) → order_service.create()
7+
→ SQLOrder(**data) → DB → repository.get() → metadata intact
8+
"""
9+
from investing_algorithm_framework import PortfolioConfiguration, \
10+
MarketCredential, OrderStatus
11+
from investing_algorithm_framework.domain import OrderSide
12+
from tests.resources import TestBase
13+
14+
15+
class TestProductionOrderMetadata(TestBase):
16+
"""
17+
Tests for metadata persistence in the production (non-backtest) path.
18+
Uses TestBase which sets up a full app with DB, order executor, etc.
19+
"""
20+
portfolio_configurations = [
21+
PortfolioConfiguration(
22+
market="BITVAVO",
23+
trading_symbol="EUR"
24+
)
25+
]
26+
market_credentials = [
27+
MarketCredential(
28+
market="BITVAVO",
29+
api_key="api_key",
30+
secret_key="secret_key"
31+
)
32+
]
33+
external_balances = {
34+
"EUR": 1000
35+
}
36+
37+
def test_create_limit_buy_order_with_metadata(self):
38+
"""
39+
Production path: create_limit_order with metadata → DB → read back.
40+
"""
41+
self.app.context.create_limit_order(
42+
target_symbol="BTC",
43+
amount=1,
44+
price=10,
45+
order_side="BUY",
46+
metadata={"order_reason": "buy_signal"},
47+
)
48+
order_repository = self.app.container.order_repository()
49+
order = order_repository.find({"target_symbol": "BTC"})
50+
self.assertIsNotNone(order)
51+
self.assertEqual(
52+
order.metadata.get("order_reason"), "buy_signal"
53+
)
54+
55+
def test_create_limit_sell_order_with_metadata(self):
56+
"""
57+
Production path: sell order with sell_signal metadata persisted.
58+
Uses order_service.create() which is how SL/TP orders are created.
59+
"""
60+
order_service = self.app.container.order_service()
61+
# Create a filled buy first to establish position
62+
buy_order = order_service.create({
63+
"target_symbol": "BTC",
64+
"trading_symbol": "EUR",
65+
"amount": 10,
66+
"filled": 10,
67+
"remaining": 0,
68+
"price": 10,
69+
"order_side": "BUY",
70+
"order_type": "LIMIT",
71+
"portfolio_id": 1,
72+
"status": "CLOSED",
73+
})
74+
order_service.check_pending_orders()
75+
# Now create sell
76+
sell_order = order_service.create({
77+
"target_symbol": "BTC",
78+
"trading_symbol": "EUR",
79+
"amount": 1,
80+
"price": 10,
81+
"order_side": "SELL",
82+
"order_type": "LIMIT",
83+
"portfolio_id": 1,
84+
"status": "OPEN",
85+
"metadata": {"order_reason": "sell_signal"},
86+
})
87+
self.assertEqual(
88+
sell_order.metadata.get("order_reason"), "sell_signal"
89+
)
90+
91+
def test_create_order_without_metadata_returns_empty_dict(self):
92+
"""
93+
Production path: order without metadata has empty dict.
94+
"""
95+
self.app.context.create_limit_order(
96+
target_symbol="BTC",
97+
amount=1,
98+
price=10,
99+
order_side="BUY",
100+
)
101+
order_repository = self.app.container.order_repository()
102+
order = order_repository.find({"target_symbol": "BTC"})
103+
self.assertEqual(order.metadata, {})
104+
105+
def test_metadata_with_custom_fields_persists(self):
106+
"""
107+
Production path: arbitrary metadata dict persists through DB.
108+
"""
109+
self.app.context.create_limit_order(
110+
target_symbol="BTC",
111+
amount=1,
112+
price=10,
113+
order_side="BUY",
114+
metadata={
115+
"order_reason": "scale_in",
116+
"scale_in_index": 2,
117+
"strategy_id": "momentum_v3",
118+
},
119+
)
120+
order_repository = self.app.container.order_repository()
121+
order = order_repository.find({"target_symbol": "BTC"})
122+
self.assertEqual(order.metadata["order_reason"], "scale_in")
123+
self.assertEqual(order.metadata["scale_in_index"], 2)
124+
self.assertEqual(order.metadata["strategy_id"], "momentum_v3")

0 commit comments

Comments
 (0)