Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions keel/execution/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1689,6 +1689,36 @@ def _roll_stop(
)
return None

# A stop AT OR ABOVE the target is not a tighter stop; it is a coin flip.
#
# The replacement is a single native bracket carrying both prices, so a stop that has caught
# up with the target describes two exits racing at the same level, where whichever side the
# venue evaluates first decides whether this position took a profit or a loss.
# `keel_broker_api.orders.BracketGTC` refuses exactly this shape at construction -- "a coin
# flip wearing a protective order's name" -- but the live path does not build one of those
# yet (#502 stage 2 is blocked on #524's port migration), so nothing between the ratchet and
# Coinbase has been checking it.
#
# Reachable rather than theoretical: `trail_stop_atr` computes `price - atr * multiplier` and
# the live agent cycles ONCE A DAY, so a gap through the target that has not yet been
# reconciled leaves a position whose recorded target sits below the newly computed stop.
#
# Refusing here is the conservative half: the roll is abandoned and the EXISTING bracket stays
# in force, so the position keeps the protection it already had. Placing the inverted pair
# instead would cancel a working bracket to install a coin flip -- and if the venue refused it,
# leave the position naked until the next sweep.
if new_stop >= target:
log_event(
logger,
logging.WARNING,
"executor.stop_roll_refused",
product=product_id,
new_stop=new_stop,
target=target,
reason="new_stop is at or above the target -- the existing bracket stays in force",
)
return None

# THE CRASH LEDGER, written BEFORE the venue is touched (#519).
#
# Everything below this line can die mid-flight, and until this record existed one of those
Expand Down
81 changes: 81 additions & 0 deletions tests/execution/test_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,87 @@ def test_roll_to_break_even_never_widens_the_stop(repo):
assert repo.get_state("open_stop:BTC-USD") == Decimal("49000")


def test_a_roll_that_reaches_the_target_is_refused_and_the_bracket_stays(repo):
"""**A stop at or above the target is not a tighter stop; it is a coin flip.**

The replacement is a single native bracket carrying both prices, so a stop that has caught up
with the target describes two exits racing at the same level -- whichever side the venue
evaluates first decides whether this position took a profit or a loss.
`keel_broker_api.orders.BracketGTC` refuses exactly this at construction, but the live path
does not build one yet (#502 stage 2 is blocked on #524), so nothing between the ratchet and
Coinbase was checking it.

Refusing is the conservative half, and this asserts that half: the roll is abandoned, the
EXISTING bracket is untouched (`pending`, not `canceled`), and the recorded stop is unchanged,
so the position keeps the protection it already had. The alternative -- cancel a working
bracket to install an inverted one -- risks a venue refusal that leaves the position naked.
"""
broker = FakeBroker()
stop_id = place_bracket(
broker,
repo,
_config(),
product_id="BTC-USD",
qty=Decimal("0.01"),
stop=Decimal("49000"),
target=Decimal("53000"),
rule_name="pullback_continuation",
now_ts=NOW_TS,
)

# A break-even roll to a price ABOVE the recorded target. It tightens (53500 > 49000, so the
# ratchet is satisfied) and is still nonsense.
result = roll_to_break_even(
broker,
repo,
_config(),
product_id="BTC-USD",
old_stop_order_id=stop_id,
entry_price=Decimal("53500"),
qty=Decimal("0.01"),
rule_name="pullback_continuation",
now_ts=NOW_TS + 100,
)

assert result is None
assert repo.get_order(stop_id)["status"] == "pending", "a working bracket was cancelled"
assert repo.get_state("open_stop:BTC-USD") == Decimal("49000")
assert repo.get_state("open_target:BTC-USD") == Decimal("53000")


def test_a_roll_exactly_onto_the_target_is_refused_too(repo):
"""`>=`, not `>`. Equal is the subtler half: two equal prices read as an ordinary pair of
numbers, and what they describe is a stop and a target racing at the SAME price. `BracketGTC`
refuses equal legs as firmly as inverted ones, for the same reason."""
broker = FakeBroker()
stop_id = place_bracket(
broker,
repo,
_config(),
product_id="BTC-USD",
qty=Decimal("0.01"),
stop=Decimal("49000"),
target=Decimal("53000"),
rule_name="pullback_continuation",
now_ts=NOW_TS,
)

result = roll_to_break_even(
broker,
repo,
_config(),
product_id="BTC-USD",
old_stop_order_id=stop_id,
entry_price=Decimal("53000"),
qty=Decimal("0.01"),
rule_name="pullback_continuation",
now_ts=NOW_TS + 100,
)

assert result is None
assert repo.get_state("open_stop:BTC-USD") == Decimal("49000")


# -- ATR trailing stop -----------------------------------------------------------------------


Expand Down