Skip to content

Commit 1664827

Browse files
authored
Merge pull request #557 from coding-kitties/dev
Release: order convenience functions, vector backtest bundle fix, scenario CI optimizations
2 parents e4a438c + 6a99c67 commit 1664827

18 files changed

Lines changed: 1123 additions & 441 deletions

docusaurus/docs/Getting Started/orders.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,69 @@ In an event-driven backtest, the trigger condition is evaluated against each can
166166
- A **STOP_LIMIT** that triggers becomes a resting limit order at `price` from the triggering candle onwards, and fills under the normal limit-fill rules (`Low <= price` for BUY, `High >= price` for SELL).
167167
- The triggering timestamp is stored on the order as `triggered_at` for auditability.
168168

169+
## Convenience Helpers
170+
171+
For common sizing patterns the framework exposes five high-level helpers on `Context` (also available as `self.*` from a `TradingStrategy`). They all produce **LIMIT** orders and require an explicit `price`, delegating internally to `create_limit_order`. The `order_target*` family looks up the current position and submits a BUY or SELL for the difference (no order is placed when the position already matches the target).
172+
173+
| Helper | Computes | Use case |
174+
|--------|----------|----------|
175+
| `order_value(symbol, value, side, price)` | `amount = value / price` | Fixed currency-amount orders (e.g. DCA) |
176+
| `order_percent(symbol, percent, side, price)` | `(net_size * percent / 100) / price` | Allocate a % of portfolio net size |
177+
| `order_target(symbol, target_amount, price)` | `target_amount - current_amount` | Reach an exact position size in units |
178+
| `order_target_value(symbol, target_value, price)` | `(target_value / price) - current_amount` | Reach an exact position value |
179+
| `order_target_percent(symbol, target_percent, price)` | `((net_size * target_percent / 100) / price) - current_amount` | Portfolio rebalancing |
180+
181+
```python
182+
from investing_algorithm_framework import OrderSide, TradingStrategy
183+
184+
class RebalanceStrategy(TradingStrategy):
185+
186+
def apply_strategy(self, context, data):
187+
price = data["BTC/EUR"]["close"].iloc[-1]
188+
189+
# Spend exactly 500 EUR on BTC
190+
context.order_value(
191+
target_symbol="BTC",
192+
value=500,
193+
order_side=OrderSide.BUY,
194+
price=price,
195+
)
196+
197+
# Allocate 25% of portfolio to ETH
198+
context.order_percent(
199+
target_symbol="ETH",
200+
percent=25,
201+
order_side=OrderSide.BUY,
202+
price=data["ETH/EUR"]["close"].iloc[-1],
203+
)
204+
205+
# End up holding exactly 1.5 BTC (buys or sells the difference)
206+
context.order_target(
207+
target_symbol="BTC",
208+
target_amount=1.5,
209+
price=price,
210+
)
211+
212+
# Rebalance BTC to 10% of portfolio
213+
context.order_target_percent(
214+
target_symbol="BTC",
215+
target_percent=10,
216+
price=price,
217+
)
218+
```
219+
220+
**Validation**
221+
222+
- `value`, `percent`, `price` must be positive.
223+
- `target_amount`, `target_value`, `target_percent` must be non-negative.
224+
- `order_target*` returns `None` (no order placed) when the current position already matches the target.
225+
226+
All helpers accept the same optional `market`, `precision` and `metadata` parameters as `create_limit_order`.
227+
228+
:::tip MARKET variant & auto-price
229+
The helpers currently require an explicit `price` and always produce LIMIT orders. A future enhancement (tracked as a follow-up to #440) will add `order_type=OrderType.MARKET` support and fall back to `context.get_latest_price()` when `price` is omitted.
230+
:::
231+
169232
## Order Parameters
170233

171234
### Market Order Parameters

investing_algorithm_framework/app/context.py

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,245 @@ def create_limit_buy_order(
673673
market=market,
674674
)
675675

676+
def order_value(
677+
self,
678+
target_symbol,
679+
value,
680+
order_side,
681+
price,
682+
market=None,
683+
precision=None,
684+
metadata=None,
685+
) -> Order:
686+
"""
687+
Place a LIMIT order for a fixed currency amount.
688+
689+
The order amount is computed as ``value / price``.
690+
691+
Args:
692+
target_symbol: The symbol of the asset to trade.
693+
value: Currency amount to spend (BUY) or to sell (SELL).
694+
order_side: ``OrderSide.BUY`` or ``OrderSide.SELL``.
695+
price: Limit price.
696+
market: Optional market identifier.
697+
precision: Optional decimal precision to round the
698+
computed amount down.
699+
metadata: Optional order metadata.
700+
701+
Returns:
702+
Order: The created order.
703+
"""
704+
if value is None or value <= 0:
705+
raise OperationalException(
706+
"`value` must be a positive number."
707+
)
708+
if price is None or price <= 0:
709+
raise OperationalException(
710+
"`price` must be a positive number."
711+
)
712+
amount = value / price
713+
if precision is not None:
714+
amount = RoundingService.round_down(amount, precision)
715+
return self.create_limit_order(
716+
target_symbol=target_symbol,
717+
price=price,
718+
order_side=order_side,
719+
amount=amount,
720+
market=market,
721+
metadata=metadata,
722+
)
723+
724+
def order_percent(
725+
self,
726+
target_symbol,
727+
percent,
728+
order_side,
729+
price,
730+
market=None,
731+
precision=None,
732+
metadata=None,
733+
) -> Order:
734+
"""
735+
Place a LIMIT order for ``percent`` of the portfolio's net size.
736+
737+
Args:
738+
target_symbol: The symbol of the asset to trade.
739+
percent: Percentage (0-100) of portfolio net size to allocate.
740+
order_side: ``OrderSide.BUY`` or ``OrderSide.SELL``.
741+
price: Limit price.
742+
market: Optional market identifier.
743+
precision: Optional decimal precision to round the
744+
computed amount down.
745+
metadata: Optional order metadata.
746+
747+
Returns:
748+
Order: The created order.
749+
"""
750+
if percent is None or percent <= 0:
751+
raise OperationalException(
752+
"`percent` must be a positive number."
753+
)
754+
portfolio = self.portfolio_service.find({"market": market})
755+
net_size = portfolio.get_net_size()
756+
value = net_size * (percent / 100.0)
757+
return self.order_value(
758+
target_symbol=target_symbol,
759+
value=value,
760+
order_side=order_side,
761+
price=price,
762+
market=market,
763+
precision=precision,
764+
metadata=metadata,
765+
)
766+
767+
def order_target(
768+
self,
769+
target_symbol,
770+
target_amount,
771+
price,
772+
market=None,
773+
precision=None,
774+
metadata=None,
775+
) -> Order:
776+
"""
777+
Adjust the position in ``target_symbol`` to hold exactly
778+
``target_amount`` units.
779+
780+
The difference between the current position size and
781+
``target_amount`` is submitted as a BUY (positive diff) or
782+
SELL (negative diff) LIMIT order. If the position already
783+
matches the target, no order is placed.
784+
785+
Args:
786+
target_symbol: The symbol of the asset to adjust.
787+
target_amount: Desired position size in units.
788+
price: Limit price.
789+
market: Optional market identifier.
790+
precision: Optional decimal precision to round the diff down.
791+
metadata: Optional order metadata.
792+
793+
Returns:
794+
Order: The created order, or ``None`` if no adjustment
795+
was required.
796+
"""
797+
if target_amount is None or target_amount < 0:
798+
raise OperationalException(
799+
"`target_amount` must be a non-negative number."
800+
)
801+
if price is None or price <= 0:
802+
raise OperationalException(
803+
"`price` must be a positive number."
804+
)
805+
portfolio = self.portfolio_service.find({"market": market})
806+
try:
807+
position = self.position_service.find(
808+
{"symbol": target_symbol, "portfolio": portfolio.id}
809+
)
810+
current_amount = position.get_amount() \
811+
if position is not None else 0
812+
except OperationalException:
813+
current_amount = 0
814+
diff = target_amount - current_amount
815+
if precision is not None:
816+
sign = 1 if diff >= 0 else -1
817+
diff = sign * RoundingService.round_down(abs(diff), precision)
818+
if diff == 0:
819+
return None
820+
order_side = OrderSide.BUY if diff > 0 else OrderSide.SELL
821+
return self.create_limit_order(
822+
target_symbol=target_symbol,
823+
price=price,
824+
order_side=order_side,
825+
amount=abs(diff),
826+
market=market,
827+
metadata=metadata,
828+
)
829+
830+
def order_target_value(
831+
self,
832+
target_symbol,
833+
target_value,
834+
price,
835+
market=None,
836+
precision=None,
837+
metadata=None,
838+
) -> Order:
839+
"""
840+
Adjust the position in ``target_symbol`` so its market value at
841+
``price`` equals ``target_value``.
842+
843+
Args:
844+
target_symbol: The symbol of the asset to adjust.
845+
target_value: Desired position market value in trading currency.
846+
price: Limit price.
847+
market: Optional market identifier.
848+
precision: Optional decimal precision to round the diff down.
849+
metadata: Optional order metadata.
850+
851+
Returns:
852+
Order: The created order, or ``None`` if no adjustment
853+
was required.
854+
"""
855+
if target_value is None or target_value < 0:
856+
raise OperationalException(
857+
"`target_value` must be a non-negative number."
858+
)
859+
if price is None or price <= 0:
860+
raise OperationalException(
861+
"`price` must be a positive number."
862+
)
863+
target_amount = target_value / price
864+
return self.order_target(
865+
target_symbol=target_symbol,
866+
target_amount=target_amount,
867+
price=price,
868+
market=market,
869+
precision=precision,
870+
metadata=metadata,
871+
)
872+
873+
def order_target_percent(
874+
self,
875+
target_symbol,
876+
target_percent,
877+
price,
878+
market=None,
879+
precision=None,
880+
metadata=None,
881+
) -> Order:
882+
"""
883+
Adjust the position in ``target_symbol`` so its market value
884+
equals ``target_percent`` of the portfolio's net size.
885+
886+
Args:
887+
target_symbol: The symbol of the asset to adjust.
888+
target_percent: Desired position size as a percentage (0-100)
889+
of portfolio net size.
890+
price: Limit price.
891+
market: Optional market identifier.
892+
precision: Optional decimal precision to round the diff down.
893+
metadata: Optional order metadata.
894+
895+
Returns:
896+
Order: The created order, or ``None`` if no adjustment
897+
was required.
898+
"""
899+
if target_percent is None or target_percent < 0:
900+
raise OperationalException(
901+
"`target_percent` must be a non-negative number."
902+
)
903+
portfolio = self.portfolio_service.find({"market": market})
904+
net_size = portfolio.get_net_size()
905+
target_value = net_size * (target_percent / 100.0)
906+
return self.order_target_value(
907+
target_symbol=target_symbol,
908+
target_value=target_value,
909+
price=price,
910+
market=market,
911+
precision=precision,
912+
metadata=metadata,
913+
)
914+
676915
def get_portfolio(self, market=None) -> Portfolio:
677916
"""
678917
Function to get the portfolio of the algorithm. This function

investing_algorithm_framework/infrastructure/services/backtesting/backtest_service.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1672,9 +1672,61 @@ def _batch_save_and_checkpoint(
16721672
if len(backtests) == 0:
16731673
return
16741674

1675+
# When iterating across multiple date ranges, a bundle for the
1676+
# same ``algorithm_id`` may already exist on disk from a previous
1677+
# iteration. ``save_bundle`` keys solely on ``algorithm_id``, so
1678+
# a naive save would overwrite the prior bundle and lose its
1679+
# backtest_runs. Merge with the existing bundle so all date
1680+
# ranges' runs end up in the single per-algorithm bundle.
1681+
backtests_to_save: List[Backtest] = []
1682+
for bt in backtests:
1683+
existing_path = resolve_backtest_path(
1684+
storage_directory, bt.algorithm_id
1685+
)
1686+
if existing_path is None:
1687+
backtests_to_save.append(bt)
1688+
continue
1689+
1690+
try:
1691+
existing = Backtest.open(existing_path)
1692+
except Exception as e:
1693+
logger.warning(
1694+
f"Could not open existing bundle for "
1695+
f"{bt.algorithm_id} at {existing_path}: {e}; "
1696+
"overwriting."
1697+
)
1698+
backtests_to_save.append(bt)
1699+
continue
1700+
1701+
# If the existing bundle already contains a run for one of
1702+
# the new date ranges (e.g. a forced rerun), drop the
1703+
# overlapping runs from the existing bundle before merging
1704+
# so we don't end up with duplicates.
1705+
new_ranges = {
1706+
(run.backtest_start_date, run.backtest_end_date)
1707+
for run in bt.get_all_backtest_runs()
1708+
}
1709+
kept_runs = [
1710+
run for run in existing.get_all_backtest_runs()
1711+
if (run.backtest_start_date, run.backtest_end_date)
1712+
not in new_ranges
1713+
]
1714+
existing.backtest_runs = kept_runs
1715+
1716+
if kept_runs:
1717+
merged = combine_backtests([existing, bt])
1718+
# Preserve the freshly produced backtest's metadata /
1719+
# parameters by letting the new backtest's values win
1720+
# on key conflicts (combine_backtests uses dict update
1721+
# in iteration order — ``bt`` is second so its values
1722+
# already win).
1723+
backtests_to_save.append(merged)
1724+
else:
1725+
backtests_to_save.append(bt)
1726+
16751727
# Save backtests to disk
16761728
save_backtests_to_directory(
1677-
backtests=backtests,
1729+
backtests=backtests_to_save,
16781730
directory_path=storage_directory,
16791731
show_progress=show_progress
16801732
)

0 commit comments

Comments
 (0)