openscript 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openscript/__init__.py +40 -0
- openscript/__main__.py +62 -0
- openscript/accounting/__init__.py +74 -0
- openscript/accounting/analysis.py +174 -0
- openscript/accounting/charges.py +397 -0
- openscript/accounting/equity.py +234 -0
- openscript/accounting/report.py +82 -0
- openscript/accounting/shapes.py +74 -0
- openscript/accounting/statistics.py +300 -0
- openscript/accounting/trades.py +294 -0
- openscript/adapter/__init__.py +32 -0
- openscript/adapter/answers.py +215 -0
- openscript/adapter/channels.py +137 -0
- openscript/adapter/expectations.py +67 -0
- openscript/adapter/facts.py +127 -0
- openscript/adapter/matching.py +257 -0
- openscript/adapter/ordering.py +187 -0
- openscript/adapter/page.py +130 -0
- openscript/adapter/reading.py +357 -0
- openscript/adapter/reporting.py +244 -0
- openscript/adapter/running.py +449 -0
- openscript/adapter/serving.py +229 -0
- openscript/adapter/sessions.py +168 -0
- openscript/adapter/spellings.py +184 -0
- openscript/bars.py +157 -0
- openscript/budget.py +342 -0
- openscript/canonical.py +192 -0
- openscript/civil.py +196 -0
- openscript/contracts.py +165 -0
- openscript/dates.py +302 -0
- openscript/diagnostics.py +104 -0
- openscript/hours.py +165 -0
- openscript/inputs.py +239 -0
- openscript/intervals.py +60 -0
- openscript/library/__init__.py +76 -0
- openscript/library/arithmetic.py +128 -0
- openscript/library/averages.py +133 -0
- openscript/library/bars.py +60 -0
- openscript/library/bookkeeping.py +166 -0
- openscript/library/code_points.py +85 -0
- openscript/library/colour.py +202 -0
- openscript/library/composites.py +208 -0
- openscript/library/counting.py +218 -0
- openscript/library/deviation.py +155 -0
- openscript/library/elementary.py +206 -0
- openscript/library/extremes.py +122 -0
- openscript/library/flows.py +220 -0
- openscript/library/momentum.py +203 -0
- openscript/library/number_text.py +223 -0
- openscript/library/prices.py +36 -0
- openscript/library/ranges.py +105 -0
- openscript/library/rounding.py +123 -0
- openscript/library/series.py +213 -0
- openscript/library/stateful.py +442 -0
- openscript/library/stateless.py +261 -0
- openscript/library/strength.py +180 -0
- openscript/library/strings.py +228 -0
- openscript/library/trend.py +260 -0
- openscript/library/values.py +91 -0
- openscript/logbook.py +119 -0
- openscript/machine.py +499 -0
- openscript/memory.py +204 -0
- openscript/opcodes.py +166 -0
- openscript/program.py +146 -0
- openscript/run.py +368 -0
- openscript/strategy/__init__.py +78 -0
- openscript/strategy/calls.py +201 -0
- openscript/strategy/closable.py +182 -0
- openscript/strategy/fills.py +131 -0
- openscript/strategy/holdings.py +277 -0
- openscript/strategy/intents.py +162 -0
- openscript/strategy/ledger.py +270 -0
- openscript/strategy/placing.py +206 -0
- openscript/strategy/positions.py +124 -0
- openscript/strategy/refusals.py +293 -0
- openscript/strategy/rows.py +219 -0
- openscript/strategy/sizing.py +229 -0
- openscript/strategy/statuses.py +65 -0
- openscript/surface/__init__.py +115 -0
- openscript/surface/bands.py +103 -0
- openscript/surface/levels.py +44 -0
- openscript/surface/marks.py +52 -0
- openscript/surface/paints.py +58 -0
- openscript/surface/plots.py +44 -0
- openscript/surface/published.py +119 -0
- openscript/values.py +210 -0
- openscript/verify.py +301 -0
- openscript/verify_code.py +290 -0
- openscript/verify_requests.py +271 -0
- openscript/verify_shape.py +162 -0
- openscript/verify_tables.py +256 -0
- openscript/version.py +39 -0
- openscript/zones.py +118 -0
- openscript-0.4.0.dist-info/METADATA +82 -0
- openscript-0.4.0.dist-info/RECORD +97 -0
- openscript-0.4.0.dist-info/WHEEL +5 -0
- openscript-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""The strategy's own order and fill ledger, ``stdlib.md`` 17.7.
|
|
2
|
+
|
|
3
|
+
**Each strategy owns one.** It is the strategy's record of what it has actually
|
|
4
|
+
done, it is what every position figure in the language is folded from, and it is
|
|
5
|
+
per strategy rather than per account because an account position is shared with
|
|
6
|
+
every other strategy and every manual trade in the same contract.
|
|
7
|
+
|
|
8
|
+
**Nothing is read from a host's position row.** The engine is neither handed one
|
|
9
|
+
nor asks for one. What it reads back is what it sent and what the destination
|
|
10
|
+
said became of it, which is the only record whose owner is this strategy.
|
|
11
|
+
|
|
12
|
+
**Frames fold at a bar boundary**, never during an execution, so every position
|
|
13
|
+
fact is constant for the length of one execution and a re-execution of a moving
|
|
14
|
+
bar sees exactly what the first execution saw (``host-interface.md`` 7.4). The
|
|
15
|
+
intake takes one frame, returns nothing and is the only way in.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
20
|
+
|
|
21
|
+
from ..diagnostics import Diagnostic
|
|
22
|
+
from .calls import call_of
|
|
23
|
+
from .intents import Identity, IntentBar, OrderFrame, OrderIntent
|
|
24
|
+
from .placing import intent_for, orders_for
|
|
25
|
+
from .positions import Positions
|
|
26
|
+
from .refusals import SentOnBar, refusal_in_call, refusal_in_order
|
|
27
|
+
from .rows import UNKNOWN_INTENT, FrameOutcome, LedgerRow, Reduction, fold_frame
|
|
28
|
+
from .statuses import PLACED
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class LedgerOptions:
|
|
33
|
+
"""What the declaration and the chart fix before bar 0."""
|
|
34
|
+
|
|
35
|
+
instrument: Identity = Identity()
|
|
36
|
+
product: str = "intraday"
|
|
37
|
+
qty_type: str = "units"
|
|
38
|
+
declared_qty: float = 1.0
|
|
39
|
+
#: The instrument's tick size, absent where the host states none, so a price
|
|
40
|
+
#: is refused against nothing rather than against a default.
|
|
41
|
+
tick_size: Optional[float] = None
|
|
42
|
+
#: Entries allowed in one direction before one is refused.
|
|
43
|
+
pyramiding: float = 1.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class PlacedCall:
|
|
48
|
+
"""What one order call sent, or why it sent nothing.
|
|
49
|
+
|
|
50
|
+
The two are exclusive, and that is the shape rather than an accident: a
|
|
51
|
+
refused call mints no id, appends no row and returns no intent.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
intents: Tuple[OrderIntent, ...] = ()
|
|
55
|
+
refusal: Optional[Diagnostic] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Context:
|
|
59
|
+
"""What the mapping and the refusals are handed for one bar.
|
|
60
|
+
|
|
61
|
+
A view rather than the ledger itself, so that the two files reading it state
|
|
62
|
+
what they read: the declaration, the leg, and the rows of this ledger.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, ledger: "Ledger", bar: IntentBar) -> None:
|
|
66
|
+
options = ledger.options
|
|
67
|
+
self.instrument = options.instrument
|
|
68
|
+
self.product = options.product
|
|
69
|
+
self.qty_type = options.qty_type
|
|
70
|
+
self.declared_qty = options.declared_qty
|
|
71
|
+
self.tick_size = options.tick_size
|
|
72
|
+
self.pyramiding = options.pyramiding
|
|
73
|
+
self.bar = bar
|
|
74
|
+
self._ledger = ledger
|
|
75
|
+
|
|
76
|
+
def size(self) -> float:
|
|
77
|
+
return self._ledger.size()
|
|
78
|
+
|
|
79
|
+
def size_of(self, ref: int) -> float:
|
|
80
|
+
return self._ledger.size_of(ref)
|
|
81
|
+
|
|
82
|
+
def avg_price(self) -> Optional[float]:
|
|
83
|
+
return self._ledger.avg_price()
|
|
84
|
+
|
|
85
|
+
def mint(self) -> int:
|
|
86
|
+
return self._ledger.mint()
|
|
87
|
+
|
|
88
|
+
def rows(self) -> Sequence[LedgerRow]:
|
|
89
|
+
return self._ledger.rows()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Ledger:
|
|
93
|
+
"""One strategy's rows, its position book, and the frames it has been handed."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, options: LedgerOptions = LedgerOptions()) -> None:
|
|
96
|
+
self.options = options
|
|
97
|
+
self._by_intent: Dict[int, LedgerRow] = {}
|
|
98
|
+
self._placed: List[LedgerRow] = []
|
|
99
|
+
self._positions = Positions()
|
|
100
|
+
self._waiting: List[OrderFrame] = []
|
|
101
|
+
self._next = 1
|
|
102
|
+
#: The bar ``_sent`` describes, so the list empties when a new one begins.
|
|
103
|
+
self._at = -1
|
|
104
|
+
self._sent: List[SentOnBar] = []
|
|
105
|
+
|
|
106
|
+
# -- what a bar sends ---------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def place(self, name: str, args: Sequence[Any], bar: IntentBar, position: Any) -> PlacedCall:
|
|
109
|
+
"""Step 9 sent an order call. It becomes intents, and each becomes a row.
|
|
110
|
+
|
|
111
|
+
A row is appended when the order is sent, at ``placed``, which is the
|
|
112
|
+
engine's own status: an intent has left and nothing has come back.
|
|
113
|
+
|
|
114
|
+
**The whole call is read, mapped and refused before any of it is sent.**
|
|
115
|
+
Every order the call produces is held against the refusals first, and only
|
|
116
|
+
then does any of them take an id, so a call that sends two sends both or
|
|
117
|
+
neither.
|
|
118
|
+
"""
|
|
119
|
+
if bar.index != self._at:
|
|
120
|
+
self._at = bar.index
|
|
121
|
+
self._sent = []
|
|
122
|
+
|
|
123
|
+
ctx = _Context(self, bar)
|
|
124
|
+
call = call_of(name, args, position)
|
|
125
|
+
refused = refusal_in_call(call, ctx)
|
|
126
|
+
if refused is not None:
|
|
127
|
+
return PlacedCall(refusal=refused)
|
|
128
|
+
|
|
129
|
+
orders = orders_for(call, ctx)
|
|
130
|
+
for order in orders:
|
|
131
|
+
bad = refusal_in_order(call, order, ctx, self._sent)
|
|
132
|
+
if bad is not None:
|
|
133
|
+
return PlacedCall(refusal=bad)
|
|
134
|
+
|
|
135
|
+
intents: List[OrderIntent] = []
|
|
136
|
+
for order in orders:
|
|
137
|
+
intent_id = self._next
|
|
138
|
+
self._next += 1
|
|
139
|
+
intent = intent_for(order.placement, intent_id, ctx)
|
|
140
|
+
intents.append(intent)
|
|
141
|
+
# Only an order that places one appends a row: a cancellation and a
|
|
142
|
+
# bracket carry an id a host can quote and nothing has been ordered
|
|
143
|
+
# by either. What a cancellation does to an order, and what a
|
|
144
|
+
# bracket's level does when it is reached, both arrive as frames.
|
|
145
|
+
placement = order.placement
|
|
146
|
+
if placement.kind == "place" and placement.side is not None:
|
|
147
|
+
self._append(intent, bar, order.reduces)
|
|
148
|
+
# One entry per appended row, in the same order, which is what
|
|
149
|
+
# lets a refused bar take back exactly the orders it appended.
|
|
150
|
+
self._sent.append(SentOnBar(call.name, position.line, placement.side))
|
|
151
|
+
return PlacedCall(intents=tuple(intents))
|
|
152
|
+
|
|
153
|
+
def discard(self, from_row: int) -> None:
|
|
154
|
+
"""The bar sent nothing after all: every row it appended is taken back.
|
|
155
|
+
|
|
156
|
+
A refusal anywhere on a bar hands none of the bar's orders over, because
|
|
157
|
+
every call is mapped before any of them is routed, so the rows of the
|
|
158
|
+
calls that had already been mapped have to go too. Otherwise the ledger
|
|
159
|
+
reports an order at ``placed`` with an empty reference that no destination
|
|
160
|
+
was ever handed, and a host reconciling after a stopped run sees an order
|
|
161
|
+
it never received.
|
|
162
|
+
|
|
163
|
+
Taken back rather than never written, because a row has to be there while
|
|
164
|
+
the rest of the bar is mapped: a cancellation asks whether a tag names a
|
|
165
|
+
working order, pyramiding counts what a position already holds, and a
|
|
166
|
+
close measures what one tag entered.
|
|
167
|
+
|
|
168
|
+
The intent ids the bar minted are not reissued. An id is unique within a
|
|
169
|
+
run, a frame that quoted one must never match a later order, and a gap in
|
|
170
|
+
the numbering is invisible to a host.
|
|
171
|
+
"""
|
|
172
|
+
dropped = len(self._placed) - from_row
|
|
173
|
+
if dropped <= 0:
|
|
174
|
+
return
|
|
175
|
+
for row in self._placed[from_row:]:
|
|
176
|
+
self._by_intent.pop(row.intent_id, None)
|
|
177
|
+
del self._placed[from_row:]
|
|
178
|
+
del self._sent[max(0, len(self._sent) - dropped):]
|
|
179
|
+
|
|
180
|
+
# -- what the destination answers ---------------------------------------
|
|
181
|
+
|
|
182
|
+
def deliver(self, frame: OrderFrame) -> None:
|
|
183
|
+
"""A frame from the destination, held until the next bar boundary."""
|
|
184
|
+
self._waiting.append(frame)
|
|
185
|
+
|
|
186
|
+
def settle(self) -> Tuple[FrameOutcome, ...]:
|
|
187
|
+
"""Folds every frame that has arrived, in the order it arrived in.
|
|
188
|
+
|
|
189
|
+
A host does not have to put its frames in order before it sends them. A
|
|
190
|
+
repeat, a pair that crossed in flight and one that arrives after the order
|
|
191
|
+
ended are ordinary traffic, and the fold is what says what each does.
|
|
192
|
+
"""
|
|
193
|
+
if not self._waiting:
|
|
194
|
+
return ()
|
|
195
|
+
frames = self._waiting
|
|
196
|
+
self._waiting = []
|
|
197
|
+
outcomes: List[FrameOutcome] = []
|
|
198
|
+
for frame in frames:
|
|
199
|
+
row = self._by_intent.get(frame.intent_id)
|
|
200
|
+
if row is None:
|
|
201
|
+
# Step 1. It is not an order this strategy placed. Refused and
|
|
202
|
+
# recorded, and nothing is folded.
|
|
203
|
+
outcomes.append(FrameOutcome(intent_id=frame.intent_id, refused=UNKNOWN_INTENT))
|
|
204
|
+
continue
|
|
205
|
+
outcome = fold_frame(row, frame)
|
|
206
|
+
# Step 6, and the reason the row carries a position reference: the
|
|
207
|
+
# fill settles the position that order belongs to and not whichever
|
|
208
|
+
# position the leg holds now.
|
|
209
|
+
if outcome.delta > 0 and outcome.price is not None:
|
|
210
|
+
units = outcome.delta if row.side == "buy" else -outcome.delta
|
|
211
|
+
self._positions.settle(row.position_ref, units, outcome.price)
|
|
212
|
+
outcomes.append(outcome)
|
|
213
|
+
return tuple(outcomes)
|
|
214
|
+
|
|
215
|
+
# -- what a script reads ------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def size(self) -> float:
|
|
218
|
+
"""The leg's net position in units, ``0`` while flat."""
|
|
219
|
+
return self._positions.size()
|
|
220
|
+
|
|
221
|
+
def size_of(self, ref: int) -> float:
|
|
222
|
+
"""The settled size of one position reference."""
|
|
223
|
+
return self._positions.size_of(ref)
|
|
224
|
+
|
|
225
|
+
def avg_price(self) -> Optional[float]:
|
|
226
|
+
"""The average price of the open position, absent while flat."""
|
|
227
|
+
return self._positions.avg_price()
|
|
228
|
+
|
|
229
|
+
def mint(self) -> int:
|
|
230
|
+
"""A fresh position reference."""
|
|
231
|
+
return self._positions.mint()
|
|
232
|
+
|
|
233
|
+
def rows(self) -> Sequence[LedgerRow]:
|
|
234
|
+
"""Every row, oldest first, for a host that reports what the run did."""
|
|
235
|
+
return self._placed
|
|
236
|
+
|
|
237
|
+
def row_for(self, intent_id: int) -> Optional[LedgerRow]:
|
|
238
|
+
"""The row one intent appended, or none where the intent appended none."""
|
|
239
|
+
return self._by_intent.get(intent_id)
|
|
240
|
+
|
|
241
|
+
# -- the append ---------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def _append(self, intent: OrderIntent, bar: IntentBar, reduces: Optional[Reduction]) -> None:
|
|
244
|
+
placement = intent.placement
|
|
245
|
+
row = LedgerRow(
|
|
246
|
+
intent_id=intent.intent_id,
|
|
247
|
+
tag=placement.tag,
|
|
248
|
+
# The only leg has no name of its own: a name comes from a leg
|
|
249
|
+
# declaration, and those are planned (``stdlib.md`` 17.6).
|
|
250
|
+
leg="",
|
|
251
|
+
position_ref=placement.position_ref,
|
|
252
|
+
instrument=intent.instrument,
|
|
253
|
+
product=intent.product,
|
|
254
|
+
side=placement.side,
|
|
255
|
+
qty=placement.qty,
|
|
256
|
+
order_type=placement.order_type,
|
|
257
|
+
price=placement.limit,
|
|
258
|
+
trigger=placement.trigger,
|
|
259
|
+
placed_at=bar.time,
|
|
260
|
+
updated_at=bar.time,
|
|
261
|
+
reduces=reduces,
|
|
262
|
+
# What this order puts into its position, in units, where the engine
|
|
263
|
+
# can read it. The unit is per order rather than per run, so a
|
|
264
|
+
# quantity the engine worked out is readable in a declaration whose
|
|
265
|
+
# own unit is not.
|
|
266
|
+
units=placement.qty if placement.qty_type == "units" else None,
|
|
267
|
+
status=PLACED,
|
|
268
|
+
)
|
|
269
|
+
self._placed.append(row)
|
|
270
|
+
self._by_intent[row.intent_id] = row
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""One order call becomes one or more intents, ``stdlib.md`` 17.2 and 17.3.
|
|
2
|
+
|
|
3
|
+
**This file says what a call means; ``sizing`` says how much it sends and against
|
|
4
|
+
which position.**
|
|
5
|
+
|
|
6
|
+
**Every order states its own side and its own quantity outright.** Nothing here
|
|
7
|
+
computes a delta against an account position, for the reason 17.1 gives: an
|
|
8
|
+
account position is held per contract, so a second strategy, a manual trade or
|
|
9
|
+
this same script started twice all land in that one row, and an order sized
|
|
10
|
+
against it is sized against somebody else's trade. What a flattening order does
|
|
11
|
+
compute against is the strategy's own position, folded from its own settled
|
|
12
|
+
fills, which is a different number with a different owner.
|
|
13
|
+
|
|
14
|
+
**A bracket is an instruction, not an order.** ``exit`` and ``order.bracket`` set
|
|
15
|
+
the leg's own protective level, which is what 17.2 means when it says a leg
|
|
16
|
+
carries at most one stop and at most one target at a time. They send an intent so
|
|
17
|
+
that a host has something to act on, and they append no ledger row and move no
|
|
18
|
+
position, because nothing has been ordered until the level is reached. The tag
|
|
19
|
+
they take defaults to the empty string and rides along as a label, so a bracket
|
|
20
|
+
naming a tag nothing was ever placed with is an ordinary call.
|
|
21
|
+
|
|
22
|
+
**A tag on a call that flattens is the other kind.** ``close``'s tag defaults to
|
|
23
|
+
absence and names the part of the position that tag entered, which is a reference
|
|
24
|
+
to rows this ledger holds.
|
|
25
|
+
|
|
26
|
+
**Nothing here refuses anything.** ``refusals`` says what the language will not
|
|
27
|
+
do, and the ledger asks it first. The two were one file once in the engine this
|
|
28
|
+
one is written beside, and what that produced was a call whose quantity did not
|
|
29
|
+
make sense returning an empty list: no order, no refusal, and a script that had
|
|
30
|
+
been told nothing.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from typing import Any, List, Optional, Tuple
|
|
34
|
+
|
|
35
|
+
from .calls import OrderCall
|
|
36
|
+
from .closable import closable_units, closing_for, closing_side
|
|
37
|
+
from .holdings import protecting
|
|
38
|
+
from .intents import NO_POSITION_REF, OrderIntent, Placement
|
|
39
|
+
from .sizing import Entry, MappedOrder, adding, entering, flattening
|
|
40
|
+
from .statuses import is_terminal
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def type_of(limit: Optional[float], trigger: Optional[float]) -> str:
|
|
44
|
+
"""The type the prices imply, ``stdlib.md`` 17.2.
|
|
45
|
+
|
|
46
|
+
Stated on the intent rather than left to the host, so a destination never has
|
|
47
|
+
to infer it.
|
|
48
|
+
"""
|
|
49
|
+
if limit is not None and trigger is not None:
|
|
50
|
+
return "stopLimit"
|
|
51
|
+
if limit is not None:
|
|
52
|
+
return "limit"
|
|
53
|
+
if trigger is not None:
|
|
54
|
+
return "stop"
|
|
55
|
+
return "market"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _cancelling(tag: str) -> Placement:
|
|
59
|
+
"""A cancellation names the tag it cancels and nothing else.
|
|
60
|
+
|
|
61
|
+
No side, no quantity and no price: it is not an order, and what becomes of
|
|
62
|
+
the order it names arrives as a frame about that order.
|
|
63
|
+
"""
|
|
64
|
+
return Placement(kind="cancel", tag=tag, position_ref=NO_POSITION_REF)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _bracketing(
|
|
68
|
+
ctx: Any,
|
|
69
|
+
tag: str,
|
|
70
|
+
qty: Optional[float],
|
|
71
|
+
target: Optional[float],
|
|
72
|
+
stop: Optional[float],
|
|
73
|
+
profit: Optional[float],
|
|
74
|
+
loss: Optional[float],
|
|
75
|
+
) -> Tuple[MappedOrder, ...]:
|
|
76
|
+
"""A protective level attached to a tag, ``stdlib.md`` 17.2 and 17.3."""
|
|
77
|
+
# A call that names no level at all removes one, which is a standing level of
|
|
78
|
+
# 17.9 and is planned. There is nothing to send.
|
|
79
|
+
if target is None and stop is None and profit is None and loss is None:
|
|
80
|
+
return ()
|
|
81
|
+
held = protecting(ctx)
|
|
82
|
+
return (
|
|
83
|
+
adding(
|
|
84
|
+
Placement(
|
|
85
|
+
kind="bracket",
|
|
86
|
+
qty=qty,
|
|
87
|
+
# A quantity the script stated is in the declaration's own unit;
|
|
88
|
+
# a bracket that names no part of the position states none.
|
|
89
|
+
qty_type="units" if qty is None else ctx.qty_type,
|
|
90
|
+
target=target,
|
|
91
|
+
stop=stop,
|
|
92
|
+
profit=profit,
|
|
93
|
+
loss=loss,
|
|
94
|
+
tag=tag,
|
|
95
|
+
# The position the level protects, and zero where the leg holds
|
|
96
|
+
# none, which is the value a cancellation already carries for the
|
|
97
|
+
# same reason: neither is an order and neither has a position of
|
|
98
|
+
# its own. Minting one here would hand a host a reference no
|
|
99
|
+
# order ever carries.
|
|
100
|
+
position_ref=NO_POSITION_REF if held is None else held,
|
|
101
|
+
)
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def orders_for(call: OrderCall, ctx: Any) -> Tuple[MappedOrder, ...]:
|
|
107
|
+
"""The orders one call sends, in the order it sends them.
|
|
108
|
+
|
|
109
|
+
A call that has nothing to send sends nothing: flattening a leg that holds
|
|
110
|
+
nothing and reversing a position that does not exist are both instructions
|
|
111
|
+
about a position the strategy does not hold, and inventing a side for either
|
|
112
|
+
would be the engine deciding a direction the script never stated.
|
|
113
|
+
"""
|
|
114
|
+
name = call.name
|
|
115
|
+
|
|
116
|
+
if name in ("buy", "sell", "order.place"):
|
|
117
|
+
side = call.side
|
|
118
|
+
# A side that is not one of the two is not a direction, and a buy is not
|
|
119
|
+
# the safe guess: a computed value outside the set sends nothing rather
|
|
120
|
+
# than the opposite of what the script meant. A side the script stated as
|
|
121
|
+
# absent never reaches here at all: that is OS7002.
|
|
122
|
+
if side is None:
|
|
123
|
+
return ()
|
|
124
|
+
return entering(
|
|
125
|
+
ctx,
|
|
126
|
+
Entry(
|
|
127
|
+
side=side,
|
|
128
|
+
limit=call.limit,
|
|
129
|
+
trigger=call.trigger,
|
|
130
|
+
# A type outside the set falls back to the one the prices imply,
|
|
131
|
+
# which is the correspondence 17.2 fixes between the two.
|
|
132
|
+
order_type=call.order_type or type_of(call.limit, call.trigger),
|
|
133
|
+
tag=call.tag or "",
|
|
134
|
+
),
|
|
135
|
+
call.qty,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if name == "close":
|
|
139
|
+
# The side comes from the part the call names, which is the leg only
|
|
140
|
+
# where it names no tag.
|
|
141
|
+
closing = closing_for(ctx, call.tag)
|
|
142
|
+
if closing is None:
|
|
143
|
+
return ()
|
|
144
|
+
return flattening(
|
|
145
|
+
ctx, closable_units(ctx, call.tag), closing, call.qty, call.tag or "", call.tag
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if name == "order.reverse":
|
|
149
|
+
size = ctx.size()
|
|
150
|
+
closing = closing_side(size)
|
|
151
|
+
if closing is None:
|
|
152
|
+
return ()
|
|
153
|
+
tag = call.tag or ""
|
|
154
|
+
# What is left to close rather than the whole leg, so that a reverse
|
|
155
|
+
# after a close on the same bar does not send the position twice.
|
|
156
|
+
out: List[MappedOrder] = list(
|
|
157
|
+
flattening(ctx, closable_units(ctx, None), closing, None, tag, None)
|
|
158
|
+
)
|
|
159
|
+
# The replacement is a position of its own, minted here, so that a fill
|
|
160
|
+
# on the outgoing order settles the position it belonged to.
|
|
161
|
+
ref = ctx.mint()
|
|
162
|
+
out.append(
|
|
163
|
+
adding(
|
|
164
|
+
Placement(
|
|
165
|
+
kind="place",
|
|
166
|
+
side=closing,
|
|
167
|
+
qty=abs(size) if call.qty is None else call.qty,
|
|
168
|
+
qty_type="units" if call.qty is None else ctx.qty_type,
|
|
169
|
+
order_type="market",
|
|
170
|
+
tag=tag,
|
|
171
|
+
position_ref=ref,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return tuple(out)
|
|
176
|
+
|
|
177
|
+
if name == "exit":
|
|
178
|
+
return _bracketing(
|
|
179
|
+
ctx, call.tag or "", call.qty, call.target, call.stop, call.profit, call.loss
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if name == "order.bracket":
|
|
183
|
+
return _bracketing(ctx, call.tag or "", None, None, None, call.profit, call.loss)
|
|
184
|
+
|
|
185
|
+
if name == "cancel":
|
|
186
|
+
return (adding(_cancelling(call.tag or "")),)
|
|
187
|
+
|
|
188
|
+
if name == "cancelAll":
|
|
189
|
+
tags: List[str] = []
|
|
190
|
+
for row in ctx.rows():
|
|
191
|
+
if not is_terminal(row.status) and row.tag not in tags:
|
|
192
|
+
tags.append(row.tag)
|
|
193
|
+
return tuple(adding(_cancelling(tag)) for tag in tags)
|
|
194
|
+
|
|
195
|
+
return ()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def intent_for(placement: Placement, intent_id: int, ctx: Any) -> OrderIntent:
|
|
199
|
+
"""The intent one placement becomes, ``host-interface.md`` 7.1."""
|
|
200
|
+
return OrderIntent(
|
|
201
|
+
intent_id=intent_id,
|
|
202
|
+
placement=placement,
|
|
203
|
+
instrument=ctx.instrument,
|
|
204
|
+
product=ctx.product,
|
|
205
|
+
bar=ctx.bar,
|
|
206
|
+
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""The positions a leg holds, folded from settled fills and nothing else, 17.1 and 17.7.
|
|
2
|
+
|
|
3
|
+
**A fill settles the position its own order names, never whichever position the
|
|
4
|
+
leg holds now.** That is what the position reference is for: during a flip a leg
|
|
5
|
+
holds two positions at once, the outgoing one and its replacement, and a fill
|
|
6
|
+
that arrives late would otherwise be applied to the position that replaced the
|
|
7
|
+
one it belonged to.
|
|
8
|
+
|
|
9
|
+
**Reducing a position does not move its average price.** The units that leave
|
|
10
|
+
leave at the price the position was opened at, so what remains is still the
|
|
11
|
+
average of what was bought. An engine that took the closing fill's price into
|
|
12
|
+
the average would report an entry at a price nothing was entered at, and every
|
|
13
|
+
level a script measures from the entry would be measured from that.
|
|
14
|
+
|
|
15
|
+
Nothing here is money. Realised profit, equity and the trade list are the
|
|
16
|
+
planned entries of ``stdlib.md`` 17.4, and this holds the two facts the five
|
|
17
|
+
position calls of this release read: how much is held, and at what average.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sign(value: float) -> int:
|
|
25
|
+
"""The sign of a quantity, as the three way answer the arithmetic below needs."""
|
|
26
|
+
if value > 0:
|
|
27
|
+
return 1
|
|
28
|
+
if value < 0:
|
|
29
|
+
return -1
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Held:
|
|
35
|
+
"""One position: its signed size, and the signed cost of what is open."""
|
|
36
|
+
|
|
37
|
+
size: float = 0.0
|
|
38
|
+
cost: float = 0.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Positions:
|
|
42
|
+
"""The book a leg's fills settle into, one entry per position reference."""
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
self._held: Dict[int, Held] = {}
|
|
46
|
+
self._minted: List[int] = []
|
|
47
|
+
self._next = 1
|
|
48
|
+
|
|
49
|
+
def size_of(self, ref: int) -> float:
|
|
50
|
+
"""The settled size of one reference, signed the way a position is.
|
|
51
|
+
|
|
52
|
+
Per reference rather than per leg, because which position an order is
|
|
53
|
+
sent against is a question about one position and the leg's net cannot
|
|
54
|
+
answer it: two positions that net to zero are not the same thing as no
|
|
55
|
+
position at all. Zero for a reference this book has never been given a
|
|
56
|
+
fill for, which is both a reference minted for an order that has not
|
|
57
|
+
settled and one that is not a reference at all.
|
|
58
|
+
"""
|
|
59
|
+
one = self._held.get(ref)
|
|
60
|
+
return 0.0 if one is None else one.size
|
|
61
|
+
|
|
62
|
+
def mint(self) -> int:
|
|
63
|
+
"""A fresh position, for the replacement half of a flip."""
|
|
64
|
+
ref = self._next
|
|
65
|
+
self._next += 1
|
|
66
|
+
self._held[ref] = Held()
|
|
67
|
+
self._minted.append(ref)
|
|
68
|
+
return ref
|
|
69
|
+
|
|
70
|
+
def settle(self, ref: int, units: float, price: float) -> None:
|
|
71
|
+
"""Step 6 of the fold: ``units`` at ``price`` settle against one position."""
|
|
72
|
+
position = self._held.get(ref)
|
|
73
|
+
if position is None:
|
|
74
|
+
position = Held()
|
|
75
|
+
self._held[ref] = position
|
|
76
|
+
self._minted.append(ref)
|
|
77
|
+
before = position.size
|
|
78
|
+
|
|
79
|
+
if before == 0 or sign(units) == sign(before):
|
|
80
|
+
position.size = before + units
|
|
81
|
+
position.cost += units * price
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
average = position.cost / before
|
|
85
|
+
after = before + units
|
|
86
|
+
position.size = after
|
|
87
|
+
# A destination that filled more than the order asked takes the leg
|
|
88
|
+
# through zero. The remainder is a position in the other direction and
|
|
89
|
+
# it opened at this fill's price, which is the truthful reading of what
|
|
90
|
+
# the account now holds. Dropping it would leave the strategy blind to a
|
|
91
|
+
# position it is carrying.
|
|
92
|
+
at = average if sign(after) == sign(before) else price
|
|
93
|
+
position.cost = 0.0 if after == 0 else after * at
|
|
94
|
+
|
|
95
|
+
def size(self) -> float:
|
|
96
|
+
"""The leg's net position in units, ``0`` while flat."""
|
|
97
|
+
total = 0.0
|
|
98
|
+
for position in self._held.values():
|
|
99
|
+
total += position.size
|
|
100
|
+
return total
|
|
101
|
+
|
|
102
|
+
def avg_price(self) -> Optional[float]:
|
|
103
|
+
"""The average price of what the leg holds, absent while flat.
|
|
104
|
+
|
|
105
|
+
Absent rather than zero, because zero is a price and a script comparing
|
|
106
|
+
against it would take a branch that looks correct (``stdlib.md`` 17.4).
|
|
107
|
+
|
|
108
|
+
Averaged over the positions on the side of the leg's net, and not over
|
|
109
|
+
every position open at once. Summed across both sides the cost of a
|
|
110
|
+
position on the way out is subtracted from the cost of the one on the
|
|
111
|
+
way in, and the quotient is a price nothing was entered at.
|
|
112
|
+
"""
|
|
113
|
+
net = self.size()
|
|
114
|
+
if net == 0:
|
|
115
|
+
return None
|
|
116
|
+
side = sign(net)
|
|
117
|
+
size = 0.0
|
|
118
|
+
cost = 0.0
|
|
119
|
+
for position in self._held.values():
|
|
120
|
+
if sign(position.size) != side:
|
|
121
|
+
continue
|
|
122
|
+
size += position.size
|
|
123
|
+
cost += position.cost
|
|
124
|
+
return None if size == 0 else cost / size
|