Skip to content

Commit 22f279a

Browse files
committed
fix: tick-based exit orders and position flip exit preservation
Three fixes for strategy.exit() with profit/loss tick parameters: 1. Tick-based exits no longer treated as market orders - orders with profit_ticks/loss_ticks but no limit/stop are now correctly identified as pending orders instead of filling immediately at entry price. 2. Tick-to-price conversion after entry fill - when an entry fills, pending tick-based exits now get their limit/stop calculated from the entry price and added to the orderbook on the same bar. 3. Exit orders preserved during position flips - when a position flip temporarily makes open_trades empty, exit orders belonging to the new entry (still in entry_orders) are no longer incorrectly removed. Also removes unused lib parameter from _round_price in script_runner.
1 parent 7cb7dd2 commit 22f279a

2 files changed

Lines changed: 47 additions & 8 deletions

File tree

src/pynecore/core/script_runner.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def import_script(script_path: Path) -> ModuleType:
6464
return module
6565

6666

67-
def _round_price(price: float, lib: ModuleType):
67+
def _round_price(price: float):
6868
"""
6969
Round price to 6 significant digits to clean float32 storage artifacts
7070
without destroying sub-mintick precision.
@@ -92,10 +92,10 @@ def _set_lib_properties(ohlcv: OHLCV, bar_index: int, tz: 'ZoneInfo', lib: Modul
9292

9393
lib.bar_index = lib.last_bar_index = bar_index
9494

95-
lib.open = _round_price(ohlcv.open, lib)
96-
lib.high = _round_price(ohlcv.high, lib)
97-
lib.low = _round_price(ohlcv.low, lib)
98-
lib.close = _round_price(ohlcv.close, lib)
95+
lib.open = _round_price(ohlcv.open)
96+
lib.high = _round_price(ohlcv.high)
97+
lib.low = _round_price(ohlcv.low)
98+
lib.close = _round_price(ohlcv.close)
9999

100100
lib.volume = ohlcv.volume
101101

src/pynecore/lib/strategy/__init__.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,12 @@ def __init__(
150150
self.loss_ticks = loss_ticks
151151
self.trail_points_ticks = trail_points_ticks
152152

153-
# Check if this is a market order (no limit, stop, or trail price)
154-
self.is_market_order = self.limit is None and self.stop is None
153+
# Check if this is a market order (no limit, stop, trail, or tick-based prices)
154+
self.is_market_order = (self.limit is None and self.stop is None
155+
and self.trail_price is None
156+
and self.profit_ticks is None
157+
and self.loss_ticks is None
158+
and self.trail_points_ticks is None)
155159

156160
self.cancelled = False
157161
self.bar_index = -1 # Will be set when order is added to position
@@ -842,9 +846,11 @@ def _fill_order(self, order: Order, price: float, h: float, l: float):
842846
self.open_commission = 0.0
843847

844848
# Cancel all exit orders when position is closed (TradingView behavior)
845-
# Exit orders without from_entry are canceled when position is flat
849+
# Skip exits that have a pending entry (needed during position flips)
846850
exit_orders_to_remove = list(self.exit_orders.values())
847851
for exit_order in exit_orders_to_remove:
852+
if exit_order.order_id in self.entry_orders:
853+
continue
848854
self._remove_order(exit_order)
849855

850856
# Increment intraday filled orders counter for ALL filled orders
@@ -1306,21 +1312,29 @@ def process_orders(self):
13061312
if entry_price is not None:
13071313
# Determine direction from the order
13081314
direction = 1.0 if order.size < 0 else -1.0 # Exit order size is negative of position
1315+
changed = False
13091316

13101317
# Calculate limit from profit_ticks if specified
13111318
if order.profit_ticks is not None and order.limit is None:
13121319
order.limit = entry_price + direction * syminfo.mintick * order.profit_ticks
13131320
order.limit = _price_round(order.limit, direction)
1321+
changed = True
13141322

13151323
# Calculate stop from loss_ticks if specified
13161324
if order.loss_ticks is not None and order.stop is None:
13171325
order.stop = entry_price - direction * syminfo.mintick * order.loss_ticks
13181326
order.stop = _price_round(order.stop, -direction)
1327+
changed = True
13191328

13201329
# Calculate trail_price from trail_points_ticks if specified
13211330
if order.trail_points_ticks is not None and order.trail_price is None:
13221331
order.trail_price = entry_price + direction * syminfo.mintick * order.trail_points_ticks
13231332
order.trail_price = _price_round(order.trail_price, direction)
1333+
changed = True
1334+
1335+
# Update orderbook only when prices were actually calculated
1336+
if changed:
1337+
self.orderbook.add_order(order)
13241338

13251339
# Check for stop/limit orders that should be converted to market orders
13261340
for order in self.orderbook.iter_orders():
@@ -1405,6 +1419,31 @@ def process_orders(self):
14051419
else:
14061420
self.fill_order(order, fill_price, self.l, self.o)
14071421

1422+
# Convert tick-based exit prices for entries that just filled this bar
1423+
for order in self.exit_orders.values():
1424+
entry_price = None
1425+
for trade in self.open_trades:
1426+
if trade.entry_id == order.order_id:
1427+
entry_price = trade.entry_price
1428+
break
1429+
if entry_price is not None:
1430+
direction = 1.0 if order.size < 0 else -1.0
1431+
changed = False
1432+
if order.profit_ticks is not None and order.limit is None:
1433+
order.limit = entry_price + direction * syminfo.mintick * order.profit_ticks
1434+
order.limit = _price_round(order.limit, direction)
1435+
changed = True
1436+
if order.loss_ticks is not None and order.stop is None:
1437+
order.stop = entry_price - direction * syminfo.mintick * order.loss_ticks
1438+
order.stop = _price_round(order.stop, -direction)
1439+
changed = True
1440+
if order.trail_points_ticks is not None and order.trail_price is None:
1441+
order.trail_price = entry_price + direction * syminfo.mintick * order.trail_points_ticks
1442+
order.trail_price = _price_round(order.trail_price, direction)
1443+
changed = True
1444+
if changed:
1445+
self.orderbook.add_order(order)
1446+
14081447
# Adapt orphaned exits from rejected entries to new position (TradingView behavior)
14091448
# When strategy.exit() is called without from_entry, TV keeps the exit even after
14101449
# its entry is rejected by margin. The exit adapts to close any new position that opens.

0 commit comments

Comments
 (0)