tradingmachine.orders.stepped_stop
¶
The stepped_stop synthetic order type: a native stop moved to set levels at set profits, and switched to trailing at the last.
This is an adjustable stop, the Atlas's G8. The stop-limit is placed at stop_price, and each rule names a gain, the profit in points from entry_price that sets it off, and either a stop_at_gain, where to move the stop measured from entry_price, or a trail_points, which starts the stop trailing as a TrailingStopOrder does. A market that jumps past several gains applies them all in one modification, a stop is only ever moved in the position's favour, and a trailing rule must be the last. Set transaction_type to the side that opened the position, so a long position is protected by asking for buy.
Typical usage example:
order = stepped_stop.SteppedStopOrder( share, transaction_type="buy", product="mis", order_type="limit", quantity=10, price=1000.0, entry_price=1000.0, stop_price=990.0, stop_limit_offset=2.0, rules=[{"gain": 20, "stop_at_gain": 0}, {"gain": 60, "trail_points": 25}], dry_run=True, ) answer = order.place()
SteppedStopOrder
¶
SteppedStopOrder(instrument, *, transaction_type, product, order_type, quantity, entry_price, stop_price, stop_limit_offset, rules, price=None, trigger_price=None, validity=None, disclosed_quantity=None, after_market=False, tag=None, price_reference=None, quantity_reference=None, closes_position=False, reduce_only=False, hold_limits=None, dry_run=False, step_ticks=None)
Bases: SyntheticOrder
A native stop moved to set levels at set profits, and switched to trailing at the last.
This is an adjustable stop, the Atlas's G8. The stop-limit is placed at stop_price, and each rule names a gain, the profit in points from entry_price that sets it off, and either a stop_at_gain, where to move the stop measured from entry_price, or a trail_points, which starts the stop trailing as a TrailingStopOrder does. A market that jumps past several gains applies them all in one modification, a stop is only ever moved in the position's favour, and a trailing rule must be the last. Set transaction_type to the side that opened the position, so a long position is protected by asking for buy.
The order template's attributes are described on SyntheticOrder.
Attributes:
| Name | Type | Description |
|---|---|---|
entry_price |
The float price in rupees the position was opened at, which every gain is measured from. Above zero. |
|
stop_price |
The float trigger price in rupees the stop starts at. Above zero. |
|
stop_limit_offset |
The float distance in rupees past the trigger that the stop's limit sits. Above zero. |
|
rules |
The list of 1 to 20 dict rules, each with a |
|
step_ticks |
The int number of ticks the trigger must be able to move before it is moved once trailing, or None to let UBI use 1. |
Initialises the order template and this type's own settings.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
instrument
|
TradeableInstrument
|
The instruments.TradeableInstrument to place the order in. |
required |
transaction_type
|
str
|
The str side of the order, |
required |
product
|
str
|
The str product, |
required |
order_type
|
str
|
The str kind of order, |
required |
quantity
|
int | None
|
The int quantity in underlying units, not lots, or None when a quantity reference supplies it. |
required |
entry_price
|
float
|
The float price in rupees the position was opened at, which every gain is measured from. Above zero. |
required |
stop_price
|
float
|
The float trigger price in rupees the stop starts at. Above zero. |
required |
stop_limit_offset
|
float
|
The float distance in rupees past the trigger that the stop's limit sits. Above zero. |
required |
rules
|
list[dict]
|
The list of 1 to 20 dict rules, each with a |
required |
price
|
float | None
|
The float limit price in rupees, or None for an order type that takes no price or when a price reference supplies it. |
None
|
trigger_price
|
float | None
|
The float trigger price in rupees of the order itself, or None for an order type that takes no trigger. |
None
|
validity
|
str | None
|
The str validity, |
None
|
disclosed_quantity
|
int | None
|
The int quantity to show on the exchange, or None to disclose the whole order. |
None
|
after_market
|
bool
|
A bool that is True to send the order as an after-market order. |
False
|
tag
|
str | None
|
A str of up to twenty letters and digits to label the order with, or None. |
None
|
price_reference
|
dict | None
|
A dict describing the price for UBI to work out, such as |
None
|
quantity_reference
|
dict | None
|
A dict describing the quantity for UBI to work out, such as |
None
|
closes_position
|
bool
|
A bool that is True when every order this type sends closes a position, so it may use the share of a broker's daily order cap kept for exits. |
False
|
reduce_only
|
bool
|
A bool that is True to have UBI refuse, with HTTP 409, any leg that is not on the closing side of the net position held when it is sent or is bigger than that position. |
False
|
hold_limits
|
bool | None
|
A bool that is True to have UBI hold each order that would rest at the broker at a fixed limit price until the other side of the book reaches it, False to send them as they come, or None to let UBI use the type's default. |
None
|
dry_run
|
bool
|
A bool that is True to have UBI check the order and answer with the |
False
|
step_ticks
|
int | None
|
The int number of ticks the trigger must be able to move before it is moved once trailing, or None to let UBI use 1. |
None
|
Source code in src/tradingmachine/orders/stepped_stop.py
synthetic_fields
¶
Gives this type's own settings, the fields of the synthetic object besides type.
Returns:
| Type | Description |
|---|---|
dict
|
A dict of UBI field names to values, where a value of None means the field is left out. |
Source code in src/tradingmachine/orders/stepped_stop.py
Example programs¶
Preview and rest a stepped stop for a one-share short that locks in profit in steps¶
Run it with .venv/bin/python examples/orders/stepped_stop/stepped_stop_order/preview_stepped_stop_for_short.py.
"""Preview and rest a stepped stop for a one-share short that locks in profit in steps.
A stepped stop protects a position, and UBI refuses it with HTTP 409 when no position is held on the side that opened it, so the program opens its own: after a dry run of the stop, it sells one Vodafone Idea share short at the best bid as an intraday position. UBI then rests a buy stop-limit 8% above the market, and its rules would move the stop to 2% of risk at a 5% gain and to 3% of locked-in profit at an 8% gain, moving it only in steps of at least two ticks once trailing. The order's own price is set to the stop's price rather than the market, because UBI sends only the stop and a template priced at the market could otherwise trade. Neither the stop nor any milestone is reached in the seconds the order lives; the program prints the parent and the resting stop, cancels it and buys the share back. It refuses to start when Vodafone Idea is already held intraday, so that it never acts on a position it did not open.
Typical usage example:
.venv/bin/python examples/orders/stepped_stop/stepped_stop_order/preview_stepped_stop_for_short.py
"""
import time
from tradingmachine.accounts import account
from tradingmachine.assets import equities
from tradingmachine.assets import instruments
from tradingmachine.orders import stepped_stop
from tradingmachine.unified_broker_interface import exceptions
class SteppedStopForShort:
"""A native stop above the market moved by profit milestones for a short, run by the order engine as a stepped stop.
Attributes:
trading_account: The tradingmachine.accounts.account.Account, used to read the engine's answer when it comes late.
share: The tradingmachine.assets.equities.Equity for Vodafone Idea on the NSE.
quantity_before: The int intraday quantity of Vodafone Idea held before the program opened its position.
order: The tradingmachine.orders.stepped_stop.SteppedStopOrder the program places, or None before run() builds it.
"""
def __init__(self):
"""Looks up the shares the program trades.
Raises:
tradingmachine.assets.exceptions.InstrumentError: A share could not be found in UBI.
"""
self.trading_account = account.Account()
self.share = equities.Equity(exchange="nse", symbol="IDEA")
self.quantity_before = 0
self.order = None
def price_from_market(
self,
instrument: instruments.TradeableInstrument,
percent: float,
) -> float:
"""Gives a price a percentage away from an instrument's last price, rounded to its tick size.
Args:
instrument: The tradingmachine.assets.instruments.TradeableInstrument to price.
percent: The float percentage to move from the last price, negative for a price below the market.
Returns:
The float price in rupees.
Raises:
ValueError: UBI has no last price for the instrument.
"""
last_price = instrument.last_price
if last_price is None:
raise ValueError(f"UBI has no last price for {instrument!r}")
tick_size = 0.05
if instrument.tick_size is not None:
tick_size = float(instrument.tick_size)
ticks = round(last_price * (1 + percent / 100) / tick_size)
return round(ticks * tick_size, 2)
def build_order(self, dry_run: bool) -> stepped_stop.SteppedStopOrder:
"""Builds a stop 8% above the market that moves to 2% of risk at a 5% gain and locks in 3% at an 8% gain.
Args:
dry_run: A bool that is True to build the order as a dry run, which UBI only checks and prices.
Returns:
The tradingmachine.orders.stepped_stop.SteppedStopOrder, not yet placed.
Raises:
ValueError: UBI has no last price for a share.
"""
entry_price = self.price_from_market(self.share, 0)
stop_price = self.price_from_market(self.share, 8)
first_gain = round(entry_price - self.price_from_market(self.share, -5), 2)
first_stop_at_gain = round(
entry_price - self.price_from_market(self.share, 2), 2
)
second_gain = round(entry_price - self.price_from_market(self.share, -8), 2)
second_stop_at_gain = round(
entry_price - self.price_from_market(self.share, -3), 2
)
return stepped_stop.SteppedStopOrder(
self.share,
transaction_type="sell",
product="mis",
order_type="limit",
quantity=1,
price=stop_price,
entry_price=entry_price,
stop_price=stop_price,
stop_limit_offset=0.05,
rules=[
{
"gain": first_gain,
"stop_at_gain": first_stop_at_gain,
},
{
"gain": second_gain,
"stop_at_gain": second_stop_at_gain,
},
],
step_ticks=2,
dry_run=dry_run,
)
def print_parent(self) -> None:
"""Prints the state the order engine holds the order in and each leg it has.
Returns:
None.
Raises:
tradingmachine.unified_broker_interface.exceptions.UnifiedBrokerInterfaceError: UBI could not read the parent.
"""
parent = self.order.parent
print(f"Parent state: {parent['state']}")
for leg in parent["legs"]:
print(
f" leg {leg.get('role')}: {leg.get('transaction_type')} "
f"{leg.get('quantity')} at {leg.get('price')} "
f"trigger {leg.get('trigger_price')}, {leg.get('state')}"
)
def print_broker_orders(self) -> None:
"""Prints the broker orders the order has placed so far.
Returns:
None.
Raises:
tradingmachine.unified_broker_interface.exceptions.UnifiedBrokerInterfaceError: UBI could not read the order book.
"""
broker_orders = self.order.orders
if broker_orders is None:
print("No broker order has been placed yet.")
return
for row in broker_orders.to_dict("records"):
print(
f" order {row.get('order_id')}: {row.get('transaction_type')} "
f"{row.get('quantity')} at {row.get('price')}, {row.get('status')}"
)
def place_order(self) -> dict:
"""Places the order, sending it again when a broker refuses it, and reading the engine's stored answer when the engine answers too late, so that the order can always be cancelled.
Returns:
The dict answer of the placement, whose `parent_id` is also kept on the order.
Raises:
tradingmachine.unified_broker_interface.exceptions.OrderRejectedError: Brokers refused the order three times.
tradingmachine.unified_broker_interface.exceptions.OrderOutcomeUnknownError: The engine's answer could not be read within thirty seconds.
tradingmachine.unified_broker_interface.exceptions.UnifiedBrokerInterfaceError: UBI refused the order or could not be reached.
"""
for attempt in range(3):
try:
return self.order.place()
except exceptions.OrderRejectedError as error:
if attempt == 2:
raise
print(f"A broker refused the order, so it is sent again: {error}")
time.sleep(1)
except exceptions.OrderOutcomeUnknownError as error:
return self.read_late_answer(error)
return {}
def read_late_answer(
self,
error: exceptions.OrderOutcomeUnknownError,
) -> dict:
"""Reads the order engine's stored answer to a placement it answered too late, and keeps its parent id.
Args:
error: The tradingmachine.unified_broker_interface.exceptions.OrderOutcomeUnknownError the placement raised.
Returns:
The dict answer the placement would have given.
Raises:
tradingmachine.unified_broker_interface.exceptions.OrderOutcomeUnknownError: The engine's answer could not be read within thirty seconds.
"""
intent_id = error.detail.get("intent_id")
print(f"The engine answered late, so intent {intent_id} is read.")
for attempt in range(15):
time.sleep(2)
try:
stored = self.trading_account.intent(intent_id)
except exceptions.NotFoundError:
continue
answer = stored["response"]
self.order.parent_id = answer.get("parent_id")
return answer
raise error
def cancel_order(self) -> None:
"""Cancels the order in the order engine, with every leg it still has at a broker, and prints the result.
A broker can refuse or be slow to answer one leg's cancel, which leaves the parent `cancelling`, and the engine itself can answer late, so the cancel is sent again until the parent is `cancelled`, up to five times.
Returns:
None.
Raises:
RuntimeError: The parent was still not cancelled after five attempts, so a leg may still be live.
tradingmachine.unified_broker_interface.exceptions.UnifiedBrokerInterfaceError: UBI refused the cancel or could not be reached.
"""
if self.order is None or self.order.parent_id is None:
print("No order was placed, so there is nothing to cancel.")
return
for attempt in range(5):
try:
answer = self.order.cancel()
except exceptions.ConflictError:
print(f"The parent has already finished: {self.order.parent['state']}")
return
except exceptions.OrderOutcomeUnknownError:
print("The engine answered the cancel late, so it is sent again.")
time.sleep(3)
continue
print(
f"Cancelled: state {answer['state']}, "
f"{len(answer['cancelled_legs'])} legs cancelled"
)
for leg in answer["cancelled_legs"]:
print(
f" {leg.get('broker')} {leg.get('order_id')}: {leg.get('outcome')}"
)
if answer["state"] == "cancelled":
return
time.sleep(3)
raise RuntimeError(
f"Parent {self.order.parent_id} is still not cancelled, so check it by hand"
)
def held_quantity(self) -> int:
"""Gives the net intraday quantity of Vodafone Idea held now.
Returns:
The int quantity, positive when long, negative when short and 0 when nothing is held.
Raises:
tradingmachine.unified_broker_interface.exceptions.UnifiedBrokerInterfaceError: UBI could not read the positions.
"""
positions = self.share.net_positions
if positions is None:
return 0
total = 0
for row in positions.to_dict("records"):
if row["product"] == "intraday":
total = total + int(row["quantity"])
return total
def open_position(self) -> None:
"""Sells one share short at the best bid as an intraday position and waits until UBI reports it.
The sell is a marketable limit half a percent below the best bid rather than a market order, because brokers refuse market orders sent through an API, and it is immediate-or-cancel so that nothing is left resting if it cannot fill at once. UBI chooses the broker for each order, so a sell one broker refuses is sent again up to four times, and a sell the engine answers late is not sent again but waited for.
Returns:
None.
Raises:
TimeoutError: The position did not appear within thirty seconds.
tradingmachine.unified_broker_interface.exceptions.UnifiedBrokerInterfaceError: UBI refused the order or could not be reached.
"""
for attempt in range(4):
try:
answer = self.share.sell_at_marketable_price(
quantity=1,
product="mis",
validity="ioc",
buffer_percent=0.5,
)
except exceptions.OrderRejectedError as error:
print(f"A broker refused the sell, so it is sent again: {error}")
continue
except exceptions.OrderOutcomeUnknownError:
print("The engine answered the sell late, so the position is watched.")
break
print(f"Opened with marketable sell {answer.get('order_id')}")
break
for attempt in range(30):
if self.held_quantity() < self.quantity_before:
print(f"Now holding {self.held_quantity()} intraday")
return
time.sleep(1)
raise TimeoutError("The sell did not show as a position within 30 seconds")
def close_position(self) -> None:
"""Buys back the share this program sold, if the position shows that it was sold, trying up to five times.
The buy is sent with `reduce_position`, whose quantity reference UBI sizes and routes against the broker that actually holds the position, so the account is left as it was found rather than short at one broker and long at another. It is a limit half a percent above the last price, because brokers refuse market orders sent through an API.
Returns:
None.
Raises:
tradingmachine.unified_broker_interface.exceptions.UnifiedBrokerInterfaceError: UBI could not be reached.
"""
for attempt in range(5):
if self.held_quantity() >= self.quantity_before:
print(f"Back to {self.held_quantity()} intraday, as before.")
return
try:
answer = self.share.reduce_position(
quantity=1,
product="mis",
price=self.price_from_market(self.share, 0.5),
)
except exceptions.OrderRejectedError as error:
print(f"A broker refused the buy, so it is sent again: {error}")
time.sleep(2)
continue
except exceptions.OrderOutcomeUnknownError:
print(
"The engine answered the buy late, so the position is read again."
)
time.sleep(10)
continue
print(f"Reducing the position with buy {answer.get('order_id')}")
time.sleep(4)
print("The position could still be open, so check it by hand.")
def run(self) -> None:
"""Previews the stepped stop, opens the short, places the stop, prints the parent and the resting stop, cancels it and closes the position.
Returns:
None.
Raises:
RuntimeError: Vodafone Idea was already held intraday, so the program does not touch it.
ValueError: UBI has no last price for a share.
TimeoutError: The opening order did not show as a position in time.
tradingmachine.unified_broker_interface.exceptions.UnifiedBrokerInterfaceError: UBI refused a request or could not be reached.
"""
preview = self.build_order(dry_run=True).place()
print("A dry run, which sends and records nothing, says UBI would send:")
print(preview.get("request", preview))
self.quantity_before = self.held_quantity()
if self.quantity_before != 0:
raise RuntimeError(
f"Vodafone Idea is already held intraday ({self.quantity_before}), so the program leaves that position alone"
)
try:
self.open_position()
self.order = self.build_order(dry_run=False)
answer = self.place_order()
print(
f"Placed a {self.order.SYNTHETIC_TYPE} order: outcome "
f"{answer.get('outcome')}, parent {self.order.parent_id}"
)
try:
time.sleep(2)
self.print_parent()
self.print_broker_orders()
finally:
self.cancel_order()
finally:
self.close_position()
if __name__ == "__main__":
SteppedStopForShort().run()
Rest a stepped stop for a one-share long, with milestones that would move it to breakeven and then start trailing¶
Run it with .venv/bin/python examples/orders/stepped_stop/stepped_stop_order/stepped_stop_for_long.py.
"""Rest a stepped stop for a one-share long, with milestones that would move it to breakeven and then start trailing.
A stepped stop protects a position, and UBI refuses it with HTTP 409 when no position is held on the side that opened it, so the program opens its own: it buys one Vodafone Idea share at the best offer as an intraday position. UBI then rests a sell stop-limit 8% below the market, and its rules would move the stop to breakeven at a 6% gain and start trailing 3% behind at a 9% gain. The order's own price is set to the stop's price rather than the market, because UBI sends only the stop and a template priced at the market could otherwise trade. Neither the stop nor any milestone is reached in the seconds the order lives; the program prints the parent and the resting stop, cancels it and sells the share back. It refuses to start when Vodafone Idea is already held intraday, so that it never acts on a position it did not open.
Typical usage example:
.venv/bin/python examples/orders/stepped_stop/stepped_stop_order/stepped_stop_for_long.py
"""
import time
from tradingmachine.accounts import account
from tradingmachine.assets import equities
from tradingmachine.assets import instruments
from tradingmachine.orders import stepped_stop
from tradingmachine.unified_broker_interface import exceptions
class SteppedStopForLong:
"""A native stop below the market moved by profit milestones, run by the order engine as a stepped stop.
Attributes:
trading_account: The tradingmachine.accounts.account.Account, used to read the engine's answer when it comes late.
share: The tradingmachine.assets.equities.Equity for Vodafone Idea on the NSE.
quantity_before: The int intraday quantity of Vodafone Idea held before the program opened its position.
order: The tradingmachine.orders.stepped_stop.SteppedStopOrder the program places, or None before run() builds it.
"""
def __init__(self):
"""Looks up the shares the program trades.
Raises:
tradingmachine.assets.exceptions.InstrumentError: A share could not be found in UBI.
"""
self.trading_account = account.Account()
self.share = equities.Equity(exchange="nse", symbol="IDEA")
self.quantity_before = 0
self.order = None
def price_from_market(
self,
instrument: instruments.TradeableInstrument,
percent: float,
) -> float:
"""Gives a price a percentage away from an instrument's last price, rounded to its tick size.
Args:
instrument: The tradingmachine.assets.instruments.TradeableInstrument to price.
percent: The float percentage to move from the last price, negative for a price below the market.
Returns:
The float price in rupees.
Raises:
ValueError: UBI has no last price for the instrument.
"""
last_price = instrument.last_price
if last_price is None:
raise ValueError(f"UBI has no last price for {instrument!r}")
tick_size = 0.05
if instrument.tick_size is not None:
tick_size = float(instrument.tick_size)
ticks = round(last_price * (1 + percent / 100) / tick_size)
return round(ticks * tick_size, 2)
def build_order(self, dry_run: bool) -> stepped_stop.SteppedStopOrder:
"""Builds a stop 8% below the market that moves to breakeven at a 6% gain and trails 3% behind from a 9% gain.
Args:
dry_run: A bool that is True to build the order as a dry run, which UBI only checks and prices.
Returns:
The tradingmachine.orders.stepped_stop.SteppedStopOrder, not yet placed.
Raises:
ValueError: UBI has no last price for a share.
"""
entry_price = self.price_from_market(self.share, 0)
stop_price = self.price_from_market(self.share, -8)
breakeven_gain = round(self.price_from_market(self.share, 6) - entry_price, 2)
trailing_gain = round(self.price_from_market(self.share, 9) - entry_price, 2)
trail_points = round(entry_price - self.price_from_market(self.share, -3), 2)
return stepped_stop.SteppedStopOrder(
self.share,
transaction_type="buy",
product="mis",
order_type="limit",
quantity=1,
price=stop_price,
entry_price=entry_price,
stop_price=stop_price,
stop_limit_offset=0.05,
rules=[
{
"gain": breakeven_gain,
"stop_at_gain": 0,
},
{
"gain": trailing_gain,
"trail_points": trail_points,
},
],
dry_run=dry_run,
)
def print_parent(self) -> None:
"""Prints the state the order engine holds the order in and each leg it has.
Returns:
None.
Raises:
tradingmachine.unified_broker_interface.exceptions.UnifiedBrokerInterfaceError: UBI could not read the parent.
"""
parent = self.order.parent
print(f"Parent state: {parent['state']}")
for leg in parent["legs"]:
print(
f" leg {leg.get('role')}: {leg.get('transaction_type')} "
f"{leg.get('quantity')} at {leg.get('price')} "
f"trigger {leg.get('trigger_price')}, {leg.get('state')}"
)
def print_broker_orders(self) -> None:
"""Prints the broker orders the order has placed so far.
Returns:
None.
Raises:
tradingmachine.unified_broker_interface.exceptions.UnifiedBrokerInterfaceError: UBI could not read the order book.
"""
broker_orders = self.order.orders
if broker_orders is None:
print("No broker order has been placed yet.")
return
for row in broker_orders.to_dict("records"):
print(
f" order {row.get('order_id')}: {row.get('transaction_type')} "
f"{row.get('quantity')} at {row.get('price')}, {row.get('status')}"
)
def place_order(self) -> dict:
"""Places the order, sending it again when a broker refuses it, and reading the engine's stored answer when the engine answers too late, so that the order can always be cancelled.
Returns:
The dict answer of the placement, whose `parent_id` is also kept on the order.
Raises:
tradingmachine.unified_broker_interface.exceptions.OrderRejectedError: Brokers refused the order three times.
tradingmachine.unified_broker_interface.exceptions.OrderOutcomeUnknownError: The engine's answer could not be read within thirty seconds.
tradingmachine.unified_broker_interface.exceptions.UnifiedBrokerInterfaceError: UBI refused the order or could not be reached.
"""
for attempt in range(3):
try:
return self.order.place()
except exceptions.OrderRejectedError as error:
if attempt == 2:
raise
print(f"A broker refused the order, so it is sent again: {error}")
time.sleep(1)
except exceptions.OrderOutcomeUnknownError as error:
return self.read_late_answer(error)
return {}
def read_late_answer(
self,
error: exceptions.OrderOutcomeUnknownError,
) -> dict:
"""Reads the order engine's stored answer to a placement it answered too late, and keeps its parent id.
Args:
error: The tradingmachine.unified_broker_interface.exceptions.OrderOutcomeUnknownError the placement raised.
Returns:
The dict answer the placement would have given.
Raises:
tradingmachine.unified_broker_interface.exceptions.OrderOutcomeUnknownError: The engine's answer could not be read within thirty seconds.
"""
intent_id = error.detail.get("intent_id")
print(f"The engine answered late, so intent {intent_id} is read.")
for attempt in range(15):
time.sleep(2)
try:
stored = self.trading_account.intent(intent_id)
except exceptions.NotFoundError:
continue
answer = stored["response"]
self.order.parent_id = answer.get("parent_id")
return answer
raise error
def cancel_order(self) -> None:
"""Cancels the order in the order engine, with every leg it still has at a broker, and prints the result.
A broker can refuse or be slow to answer one leg's cancel, which leaves the parent `cancelling`, and the engine itself can answer late, so the cancel is sent again until the parent is `cancelled`, up to five times.
Returns:
None.
Raises:
RuntimeError: The parent was still not cancelled after five attempts, so a leg may still be live.
tradingmachine.unified_broker_interface.exceptions.UnifiedBrokerInterfaceError: UBI refused the cancel or could not be reached.
"""
if self.order is None or self.order.parent_id is None:
print("No order was placed, so there is nothing to cancel.")
return
for attempt in range(5):
try:
answer = self.order.cancel()
except exceptions.ConflictError:
print(f"The parent has already finished: {self.order.parent['state']}")
return
except exceptions.OrderOutcomeUnknownError:
print("The engine answered the cancel late, so it is sent again.")
time.sleep(3)
continue
print(
f"Cancelled: state {answer['state']}, "
f"{len(answer['cancelled_legs'])} legs cancelled"
)
for leg in answer["cancelled_legs"]:
print(
f" {leg.get('broker')} {leg.get('order_id')}: {leg.get('outcome')}"
)
if answer["state"] == "cancelled":
return
time.sleep(3)
raise RuntimeError(
f"Parent {self.order.parent_id} is still not cancelled, so check it by hand"
)
def held_quantity(self) -> int:
"""Gives the net intraday quantity of Vodafone Idea held now.
Returns:
The int quantity, positive when long, negative when short and 0 when nothing is held.
Raises:
tradingmachine.unified_broker_interface.exceptions.UnifiedBrokerInterfaceError: UBI could not read the positions.
"""
positions = self.share.net_positions
if positions is None:
return 0
total = 0
for row in positions.to_dict("records"):
if row["product"] == "intraday":
total = total + int(row["quantity"])
return total
def open_position(self) -> None:
"""Buys one share at the best offer as an intraday position and waits until UBI reports it.
The buy is a marketable limit half a percent above the best offer rather than a market order, because brokers refuse market orders sent through an API, and it is immediate-or-cancel so that nothing is left resting if it cannot fill at once. UBI chooses the broker for each order, so a buy one broker refuses is sent again up to four times, and a buy the engine answers late is not sent again but waited for.
Returns:
None.
Raises:
TimeoutError: The position did not appear within thirty seconds.
tradingmachine.unified_broker_interface.exceptions.UnifiedBrokerInterfaceError: UBI refused the order or could not be reached.
"""
for attempt in range(4):
try:
answer = self.share.buy_at_marketable_price(
quantity=1,
product="mis",
validity="ioc",
buffer_percent=0.5,
)
except exceptions.OrderRejectedError as error:
print(f"A broker refused the buy, so it is sent again: {error}")
continue
except exceptions.OrderOutcomeUnknownError:
print("The engine answered the buy late, so the position is watched.")
break
print(f"Opened with marketable buy {answer.get('order_id')}")
break
for attempt in range(30):
if self.held_quantity() > self.quantity_before:
print(f"Now holding {self.held_quantity()} intraday")
return
time.sleep(1)
raise TimeoutError("The buy did not show as a position within 30 seconds")
def close_position(self) -> None:
"""Sells back the share this program bought, if the position shows that it was bought, trying up to five times.
The sell is sent with `reduce_position`, whose quantity reference UBI sizes and routes against the broker that actually holds the position, so the account is left as it was found rather than long at one broker and short at another. It is a limit half a percent below the last price, because brokers refuse market orders sent through an API.
Returns:
None.
Raises:
tradingmachine.unified_broker_interface.exceptions.UnifiedBrokerInterfaceError: UBI could not be reached.
"""
for attempt in range(5):
if self.held_quantity() <= self.quantity_before:
print(f"Back to {self.held_quantity()} intraday, as before.")
return
try:
answer = self.share.reduce_position(
quantity=1,
product="mis",
price=self.price_from_market(self.share, -0.5),
)
except exceptions.OrderRejectedError as error:
print(f"A broker refused the sell, so it is sent again: {error}")
time.sleep(2)
continue
except exceptions.OrderOutcomeUnknownError:
print(
"The engine answered the sell late, so the position is read again."
)
time.sleep(10)
continue
print(f"Reducing the position with sell {answer.get('order_id')}")
time.sleep(4)
print("The position could still be open, so check it by hand.")
def run(self) -> None:
"""Opens the position, places the stepped stop, prints the parent and the resting stop, cancels it and closes the position.
Returns:
None.
Raises:
RuntimeError: Vodafone Idea was already held intraday, so the program does not touch it.
ValueError: UBI has no last price for a share.
TimeoutError: The opening order did not show as a position in time.
tradingmachine.unified_broker_interface.exceptions.UnifiedBrokerInterfaceError: UBI refused a request or could not be reached.
"""
self.quantity_before = self.held_quantity()
if self.quantity_before != 0:
raise RuntimeError(
f"Vodafone Idea is already held intraday ({self.quantity_before}), so the program leaves that position alone"
)
try:
self.open_position()
self.order = self.build_order(dry_run=False)
answer = self.place_order()
print(
f"Placed a {self.order.SYNTHETIC_TYPE} order: outcome "
f"{answer.get('outcome')}, parent {self.order.parent_id}"
)
try:
time.sleep(2)
self.print_parent()
self.print_broker_orders()
finally:
self.cancel_order()
finally:
self.close_position()
if __name__ == "__main__":
SteppedStopForLong().run()