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,293 @@
|
|
|
1
|
+
"""What the order layer will not do, ``errors.md`` OS7002 to OS7013 and OS7017.
|
|
2
|
+
|
|
3
|
+
**An order is the one place in the language where doing nothing quietly is worse
|
|
4
|
+
than stopping loudly.** Everywhere else an absent value propagates and the study
|
|
5
|
+
draws a gap; here it would be a position the script believes it holds and does
|
|
6
|
+
not, or one it holds and does not know about.
|
|
7
|
+
|
|
8
|
+
**A refusal happens before anything is sent.** The ledger asks this file about
|
|
9
|
+
every order a call produces before any of them takes an id, so a refused call
|
|
10
|
+
appends no row and returns no intent: there is nothing for a host to send and
|
|
11
|
+
nothing to take back afterwards.
|
|
12
|
+
|
|
13
|
+
**What is not refused here.** A rule this file cannot evaluate truthfully is not
|
|
14
|
+
evaluated at all. A quantity that is not a whole number of lots, OS7005, and an
|
|
15
|
+
order outside the instrument's session, OS7012, are facts about a leg no file can
|
|
16
|
+
declare yet. The capital a strategy has left, OS7011, is money and the money
|
|
17
|
+
layer does not join the execution path in this release. OS7014 and OS7015 are the
|
|
18
|
+
host's, under ``host-interface.md`` 7.6. And a stated close quantity held against
|
|
19
|
+
a position that is still there is checked in full only in a declaration counting
|
|
20
|
+
in units, which is the narrowing OS7017 itself carries.
|
|
21
|
+
|
|
22
|
+
Nothing here holds a message. ``spec/errors.json`` is the catalogue and the
|
|
23
|
+
authority, and a second engine that typed the sentences out again would be a
|
|
24
|
+
second place for them to be wrong: what travels is the code, the position and the
|
|
25
|
+
values the message names.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from typing import Any, Optional, Sequence, Tuple
|
|
29
|
+
|
|
30
|
+
from ..diagnostics import Diagnostic, failure
|
|
31
|
+
from .calls import OrderCall
|
|
32
|
+
from .closable import closable
|
|
33
|
+
from .intents import Placement
|
|
34
|
+
from .positions import sign
|
|
35
|
+
from .sizing import MappedOrder
|
|
36
|
+
from .statuses import is_terminal
|
|
37
|
+
|
|
38
|
+
#: How a message names an instrument the host gave no symbol for. The same words
|
|
39
|
+
#: the engine uses for the same gap elsewhere, because a reader meeting both
|
|
40
|
+
#: should not have to work out that they mean one thing.
|
|
41
|
+
UNNAMED_INSTRUMENT = "the chart's instrument"
|
|
42
|
+
|
|
43
|
+
#: The calls that take the declaration's size when they are given none.
|
|
44
|
+
SIZED_BY_DECLARATION: Tuple[str, ...] = ("buy", "sell", "order.place")
|
|
45
|
+
|
|
46
|
+
#: How far off a tick a price may be and still be on it. A price computed from a
|
|
47
|
+
#: tick size rarely divides by it exactly in binary, and a tolerance relative to
|
|
48
|
+
#: the count keeps that true at every magnitude while staying far tighter than
|
|
49
|
+
#: the half tick that would let a price between two ticks through.
|
|
50
|
+
TICK_TOLERANCE = 1e-9
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SentOnBar:
|
|
54
|
+
"""An order this bar has already sent, which is what OS7013 is asked about.
|
|
55
|
+
|
|
56
|
+
The bar's own record, and the only rule left that is scoped to a bar. What a
|
|
57
|
+
close is measured against is a different question with a different scope, and
|
|
58
|
+
it is answered from the ledger's rows. The record is the bar's rather than
|
|
59
|
+
the execution's, so a bar declared ``onUnconfirmed`` and executed again is
|
|
60
|
+
still one bar for it: the orders of its earlier executions really were handed
|
|
61
|
+
over.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
__slots__ = ("name", "line", "side")
|
|
65
|
+
|
|
66
|
+
def __init__(self, name: str, line: int, side: str) -> None:
|
|
67
|
+
self.name = name
|
|
68
|
+
self.line = line
|
|
69
|
+
self.side = side
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _name_of(instrument: Any) -> str:
|
|
73
|
+
symbol = getattr(instrument, "symbol", None)
|
|
74
|
+
return UNNAMED_INSTRUMENT if symbol is None else symbol
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _spell(name: str, line: int) -> str:
|
|
78
|
+
"""An order call as a message names one: what was called, and where."""
|
|
79
|
+
return f"{name}() on line {line}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _part_of(tag: Optional[str]) -> str:
|
|
83
|
+
"""What a close is closing, as a message names it: the leg, or one tag of it."""
|
|
84
|
+
return "the leg" if tag is None else f'the tag "{tag}"'
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _price_missing(call: OrderCall) -> Optional[str]:
|
|
88
|
+
"""The price argument a named type needs and was not given, 17.2.
|
|
89
|
+
|
|
90
|
+
Only a call that names a type can disagree with its own prices. ``buy`` and
|
|
91
|
+
``sell`` take no type and their prices imply one, so the two always agree.
|
|
92
|
+
"""
|
|
93
|
+
named = call.order_type
|
|
94
|
+
if named in ("limit", "stopLimit") and call.limit is None:
|
|
95
|
+
return "price"
|
|
96
|
+
if named in ("stop", "stopLimit") and call.trigger is None:
|
|
97
|
+
return "trigger"
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _on_tick(price: float, tick: float) -> bool:
|
|
102
|
+
steps = price / tick
|
|
103
|
+
nearest = round(steps)
|
|
104
|
+
return abs(steps - nearest) <= TICK_TOLERANCE * max(1.0, abs(steps))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _entries_open(ctx: Any, side: str) -> int:
|
|
108
|
+
"""The entries already open in one direction, which is what pyramiding counts.
|
|
109
|
+
|
|
110
|
+
Over every position the leg still holds on that side rather than over one of
|
|
111
|
+
them: an entry placed while the whole of a position is on its way out opens a
|
|
112
|
+
position of its own, so a count keyed to a single reference would report none
|
|
113
|
+
and let a declaration of one entry hold two.
|
|
114
|
+
|
|
115
|
+
An order that has not filled is not an open entry, because OS7008's own fix
|
|
116
|
+
tells a reader to test ``pos.size`` and that is folded from settled fills. A
|
|
117
|
+
count including an order still waiting at the venue would refuse a script that
|
|
118
|
+
had done exactly what the fix asked, which is the one thing a fix may never do.
|
|
119
|
+
"""
|
|
120
|
+
holding = 1 if side == "buy" else -1
|
|
121
|
+
found = 0
|
|
122
|
+
for row in ctx.rows():
|
|
123
|
+
if row.side != side or row.filled_qty == 0:
|
|
124
|
+
continue
|
|
125
|
+
if sign(ctx.size_of(row.position_ref)) != holding:
|
|
126
|
+
continue
|
|
127
|
+
found += 1
|
|
128
|
+
return found
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _beyond_the_close(call: OrderCall, ctx: Any) -> Optional[Diagnostic]:
|
|
132
|
+
"""A close asked to send more than it is closing, OS7017.
|
|
133
|
+
|
|
134
|
+
Refused rather than clamped, because the quantity is an argument the script
|
|
135
|
+
wrote and is therefore a claim about the strategy's own position. Sending
|
|
136
|
+
what is there would leave the script believing it closed the number it asked
|
|
137
|
+
for, and reading the call as a reversal would make ``close`` open a position.
|
|
138
|
+
|
|
139
|
+
In full only where the two numbers count the same thing, and in part
|
|
140
|
+
everywhere: nothing left to close is zero in every unit, so a close of any
|
|
141
|
+
quantity against nothing is a false claim whatever the declaration counts in.
|
|
142
|
+
An assumed zero is not a measurement, so it is not named in a message either.
|
|
143
|
+
"""
|
|
144
|
+
if call.name != "close":
|
|
145
|
+
return None
|
|
146
|
+
stated = call.qty
|
|
147
|
+
if stated is None:
|
|
148
|
+
return None
|
|
149
|
+
left = closable(ctx, call.tag)
|
|
150
|
+
held = left.units
|
|
151
|
+
if stated <= held:
|
|
152
|
+
return None
|
|
153
|
+
if ctx.qty_type != "units" and not (held == 0 and left.counted):
|
|
154
|
+
return None
|
|
155
|
+
return failure(
|
|
156
|
+
"OS7017", call.position, qty=stated, part=_part_of(call.tag), held=held
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def refusal_in_call(call: OrderCall, ctx: Any) -> Optional[Diagnostic]:
|
|
161
|
+
"""What the call itself is wrong about, before anything is computed from it.
|
|
162
|
+
|
|
163
|
+
Four refusals. Three are about what the script wrote and nothing else, so they
|
|
164
|
+
are the same on every bar and on every host. The fourth, OS7017, is the one
|
|
165
|
+
quantity a script writes that is a claim about the leg rather than about
|
|
166
|
+
itself, so it is answered here, against the leg, before the call is mapped.
|
|
167
|
+
"""
|
|
168
|
+
# OS7002. Every argument the script wrote that came out absent, named in
|
|
169
|
+
# signature order, so a call with two is reported on the first one a reader
|
|
170
|
+
# would fix. An argument the script did not write is not one of them.
|
|
171
|
+
if call.absent:
|
|
172
|
+
return failure("OS7002", call.position, name=call.name, argument=call.absent[0])
|
|
173
|
+
|
|
174
|
+
# OS7004. Direction is chosen by the function and not by the sign, so a
|
|
175
|
+
# negative quantity is a calculation that went the wrong way.
|
|
176
|
+
stated = call.qty
|
|
177
|
+
if stated is not None and not stated > 0:
|
|
178
|
+
return failure("OS7004", call.position, name=call.name, qty=stated)
|
|
179
|
+
if stated is None and call.name in SIZED_BY_DECLARATION and not ctx.declared_qty > 0:
|
|
180
|
+
return failure("OS7004", call.position, name=call.name, qty=ctx.declared_qty)
|
|
181
|
+
|
|
182
|
+
# OS7017. After OS7004, so a negative quantity is still answered by the code
|
|
183
|
+
# that is about the sign rather than by the one that is about the size.
|
|
184
|
+
crossing = _beyond_the_close(call, ctx)
|
|
185
|
+
if crossing is not None:
|
|
186
|
+
return crossing
|
|
187
|
+
|
|
188
|
+
# OS7007. Filling the price in from the bar's close would make the order a
|
|
189
|
+
# market order wearing another name.
|
|
190
|
+
missing = _price_missing(call)
|
|
191
|
+
if missing is not None and call.order_type is not None:
|
|
192
|
+
return failure("OS7007", call.position, type=call.order_type, argument=missing)
|
|
193
|
+
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _unknown_tag(call: OrderCall, placement: Placement, ctx: Any) -> Optional[Diagnostic]:
|
|
198
|
+
"""A cancellation naming an order that is not there to cancel, OS7009."""
|
|
199
|
+
for row in ctx.rows():
|
|
200
|
+
if row.tag == placement.tag and not is_terminal(row.status):
|
|
201
|
+
return None
|
|
202
|
+
return failure("OS7009", call.position, tag=placement.tag)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _wrong_side(call: OrderCall, placement: Placement, ctx: Any) -> Optional[Diagnostic]:
|
|
206
|
+
"""A protective level on the wrong side of the entry, OS7010.
|
|
207
|
+
|
|
208
|
+
Only against a position that is open, because the level is measured from the
|
|
209
|
+
position's average entry price and a leg that holds nothing has none. An entry
|
|
210
|
+
and its bracket on one bar is the common shape and is not this case: the entry
|
|
211
|
+
has not filled, so there is nothing yet for the level to be on the wrong side
|
|
212
|
+
of, which is why a bracket may also state its levels as distances.
|
|
213
|
+
|
|
214
|
+
A level exactly at the entry is not refused. Moving every stop to its own
|
|
215
|
+
entry is a rule the language names (17.11), so the price that rule produces
|
|
216
|
+
cannot be one the language will not take.
|
|
217
|
+
"""
|
|
218
|
+
entry = ctx.avg_price()
|
|
219
|
+
size = ctx.size()
|
|
220
|
+
if entry is None or size == 0:
|
|
221
|
+
return None
|
|
222
|
+
long = size > 0
|
|
223
|
+
side = "long" if long else "short"
|
|
224
|
+
|
|
225
|
+
stop = placement.stop
|
|
226
|
+
if stop is not None and (stop > entry if long else stop < entry):
|
|
227
|
+
return failure("OS7010", call.position, side=side, entry=entry, leg="stop", price=stop)
|
|
228
|
+
target = placement.target
|
|
229
|
+
if target is not None and (target < entry if long else target > entry):
|
|
230
|
+
return failure("OS7010", call.position, side=side, entry=entry, leg="limit", price=target)
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _ordering(
|
|
235
|
+
call: OrderCall, order: MappedOrder, ctx: Any, sent: Sequence[SentOnBar]
|
|
236
|
+
) -> Optional[Diagnostic]:
|
|
237
|
+
"""What one order is wrong about, given the instrument and the leg."""
|
|
238
|
+
placement = order.placement
|
|
239
|
+
side = placement.side
|
|
240
|
+
if side is None:
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
# OS7006. A price between two ticks cannot exist at the exchange, and
|
|
244
|
+
# rounding it here would move the order off the level the script computed.
|
|
245
|
+
tick = ctx.tick_size
|
|
246
|
+
if tick is not None and tick > 0:
|
|
247
|
+
for price in (placement.limit, placement.trigger):
|
|
248
|
+
if price is None or _on_tick(price, tick):
|
|
249
|
+
continue
|
|
250
|
+
return failure(
|
|
251
|
+
"OS7006", call.position, symbol=_name_of(ctx.instrument), tick=tick, price=price
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# OS7008. Refusing rather than silently adding keeps a backtest from building
|
|
255
|
+
# a position the declaration forbade. Whether this order is an entry is the
|
|
256
|
+
# mapping's answer and not the leg's net: the net reads flat while an entry is
|
|
257
|
+
# still going, and an order that comes off a position is not an entry whatever
|
|
258
|
+
# it says.
|
|
259
|
+
if order.reduces is None:
|
|
260
|
+
found = _entries_open(ctx, side)
|
|
261
|
+
if found >= ctx.pyramiding:
|
|
262
|
+
return failure("OS7008", call.position, max=ctx.pyramiding, found=found)
|
|
263
|
+
|
|
264
|
+
# OS7013. Which of the two to honour has no defensible answer, so neither is
|
|
265
|
+
# placed: this call sends nothing, and the bar stops before anything the bar
|
|
266
|
+
# decided reaches the destination.
|
|
267
|
+
for one in sent:
|
|
268
|
+
if one.side != side:
|
|
269
|
+
return failure(
|
|
270
|
+
"OS7013",
|
|
271
|
+
call.position,
|
|
272
|
+
first=_spell(one.name, one.line),
|
|
273
|
+
second=_spell(call.name, call.position.line),
|
|
274
|
+
bar=ctx.bar.index,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def refusal_in_order(
|
|
281
|
+
call: OrderCall, order: MappedOrder, ctx: Any, sent: Sequence[SentOnBar]
|
|
282
|
+
) -> Optional[Diagnostic]:
|
|
283
|
+
"""What one order this call produced is wrong about.
|
|
284
|
+
|
|
285
|
+
Asked of every order a call sends, before any of them is given an id, so a
|
|
286
|
+
call that sends two sends both or neither.
|
|
287
|
+
"""
|
|
288
|
+
placement = order.placement
|
|
289
|
+
if placement.kind == "cancel":
|
|
290
|
+
return _unknown_tag(call, placement, ctx)
|
|
291
|
+
if placement.kind == "bracket":
|
|
292
|
+
return _wrong_side(call, placement, ctx)
|
|
293
|
+
return _ordering(call, order, ctx, sent)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""One row of the ledger, and the fold of one frame into it, ``stdlib.md`` 17.7 and 17.8.
|
|
2
|
+
|
|
3
|
+
**A row is appended when the order is sent and is never rewritten by
|
|
4
|
+
guesswork.** Every change to it comes from a frame the destination sent, so the
|
|
5
|
+
sequence that produced a position can be replayed rather than inferred.
|
|
6
|
+
|
|
7
|
+
**The fold is where double counting happens**, which is why it is written once,
|
|
8
|
+
here, and why every step of it is the numbered step 17.8 gives. Frames repeat,
|
|
9
|
+
cross in flight and arrive after the order they are about has ended, and an
|
|
10
|
+
engine that added each frame's quantity to a running total would report a
|
|
11
|
+
position the strategy never held. Taking the greatest cumulative quantity
|
|
12
|
+
instead makes a repeat cost nothing and makes a terminal frame that overtook a
|
|
13
|
+
partial one carry the whole remainder in one piece.
|
|
14
|
+
|
|
15
|
+
**A row also carries what its order takes out of the position**, because that is
|
|
16
|
+
the only record of it that outlives the bar the order was sent on. A position
|
|
17
|
+
moves when a fill settles, so an order that has been sent and not answered has
|
|
18
|
+
moved nothing and is invisible to every position figure; the row is where it is
|
|
19
|
+
visible. ``closable`` reads the reduction back, and what makes the reading work
|
|
20
|
+
is that a row already says whether the order is still going: an order neither
|
|
21
|
+
terminal nor fully filled is one the destination still has.
|
|
22
|
+
|
|
23
|
+
Steps 1 and 6 are not here. Locating a row is a question about the whole ledger
|
|
24
|
+
and settling a fill is the position book's, so this file says how many units
|
|
25
|
+
settled and at what price, and the ledger says where.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
from .intents import Identity
|
|
32
|
+
from .statuses import PLACED, is_terminal, rank_of, status_from
|
|
33
|
+
|
|
34
|
+
#: Why a frame was refused, ``stdlib.md`` 17.14. Both are the host's mistake and
|
|
35
|
+
#: both are recorded rather than raised: a frame naming an order this strategy
|
|
36
|
+
#: never placed is a fact about the host, and one reporting a fill with no price
|
|
37
|
+
#: cannot be marked against anything.
|
|
38
|
+
UNKNOWN_INTENT = "unknownIntent"
|
|
39
|
+
FILL_WITH_NO_PRICE = "fillWithNoPrice"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Reduction:
|
|
44
|
+
"""What one order claimed of the part it reduces, in units, when it was sent.
|
|
45
|
+
|
|
46
|
+
Absent on an order that adds to a position, and absent on one whose quantity
|
|
47
|
+
the engine cannot count in units, which are two different facts with the
|
|
48
|
+
same consequence: neither subtracts anything from what is left to close.
|
|
49
|
+
|
|
50
|
+
``claimed`` is a claim and not a size. On an order the engine sized itself
|
|
51
|
+
the two are the same number; on one it could not read they are not, and
|
|
52
|
+
there it is the whole of what was left to close at the moment the order
|
|
53
|
+
left, which is the reading ``stdlib.md`` 17.1 makes for an order it cannot
|
|
54
|
+
count.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
#: The tag the close named, or absent where it reduces the leg as a whole.
|
|
58
|
+
part: Optional[str]
|
|
59
|
+
#: The units of that part this order claimed when it was sent.
|
|
60
|
+
claimed: float
|
|
61
|
+
#: Whether ``claimed`` is the order's own quantity or the most it could have
|
|
62
|
+
#: been. False on an order whose quantity the engine cannot read in units.
|
|
63
|
+
counted: bool
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class LedgerRow:
|
|
68
|
+
"""A row of the ledger, ``stdlib.md`` 17.7's own field list."""
|
|
69
|
+
|
|
70
|
+
intent_id: int
|
|
71
|
+
tag: str
|
|
72
|
+
leg: str
|
|
73
|
+
position_ref: int
|
|
74
|
+
instrument: Identity
|
|
75
|
+
product: str
|
|
76
|
+
side: str
|
|
77
|
+
qty: float
|
|
78
|
+
order_type: str
|
|
79
|
+
price: Optional[float]
|
|
80
|
+
trigger: Optional[float]
|
|
81
|
+
placed_at: Optional[float]
|
|
82
|
+
updated_at: Optional[float]
|
|
83
|
+
#: What this order claimed of the position, absent where it claimed nothing.
|
|
84
|
+
#: Fixed when the order was sent and never rewritten, because it is a record
|
|
85
|
+
#: of what the engine measured at that moment and not a running total.
|
|
86
|
+
reduces: Optional[Reduction] = None
|
|
87
|
+
#: This order's own quantity in units, absent where the engine cannot read
|
|
88
|
+
#: it. ``reduces`` says what an order takes out of a position and this says
|
|
89
|
+
#: what it puts into one.
|
|
90
|
+
units: Optional[float] = None
|
|
91
|
+
#: The destination's own reference, recorded, shown, and never parsed.
|
|
92
|
+
order_ref: str = ""
|
|
93
|
+
status: str = PLACED
|
|
94
|
+
filled_qty: float = 0.0
|
|
95
|
+
avg_fill_price: Optional[float] = None
|
|
96
|
+
rejection: str = ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class FrameOutcome:
|
|
101
|
+
"""What one frame did.
|
|
102
|
+
|
|
103
|
+
A refused frame changes nothing and is recorded. ``after_terminal`` is the
|
|
104
|
+
``fillAfterTerminal`` of 17.11: a fill the strategy could not have expected
|
|
105
|
+
is the event a trader most needs named.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
intent_id: object
|
|
109
|
+
refused: Optional[str] = None
|
|
110
|
+
changed: bool = False
|
|
111
|
+
#: Units this frame added, which is what settles against a position.
|
|
112
|
+
delta: float = 0.0
|
|
113
|
+
price: Optional[float] = None
|
|
114
|
+
after_terminal: bool = False
|
|
115
|
+
#: What ``fillAfterTerminal`` carries, and empty on every other outcome: the
|
|
116
|
+
#: tag, the quantity this frame added and the terminal word it arrived after.
|
|
117
|
+
#: A fill the strategy could not have expected is the event a trader most
|
|
118
|
+
#: needs named, so the payload travels with the outcome rather than being
|
|
119
|
+
#: rebuilt by whoever logs it.
|
|
120
|
+
event: dict = field(default_factory=dict)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def working_units(row: LedgerRow) -> float:
|
|
124
|
+
"""How much of this row's claim on the part it reduces is still outstanding.
|
|
125
|
+
|
|
126
|
+
Four facts decide the number, and each of them is a case a strategy meets. A
|
|
127
|
+
partial fill of an order the engine counted releases what settled. A partial
|
|
128
|
+
fill of one it could not count leaves the claim whole until the order ends,
|
|
129
|
+
because the claim is in units and the answer is in whatever unit the order
|
|
130
|
+
was written in, and subtracting one from the other is drift. An order that
|
|
131
|
+
has ended releases the rest, which is how a strategy whose close was refused
|
|
132
|
+
closes again. And an order that adds claims nothing at all: an entry that has
|
|
133
|
+
not settled is not a position, and counting it would send a close for units
|
|
134
|
+
that may never exist.
|
|
135
|
+
"""
|
|
136
|
+
reduces = row.reduces
|
|
137
|
+
if reduces is None or is_terminal(row.status):
|
|
138
|
+
return 0.0
|
|
139
|
+
if reduces.counted:
|
|
140
|
+
return max(0.0, reduces.claimed - row.filled_qty)
|
|
141
|
+
return reduces.claimed
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def refused(intent_id: object, why: str) -> FrameOutcome:
|
|
145
|
+
"""A frame that folded nothing, carrying which of 17.14's two it was."""
|
|
146
|
+
return FrameOutcome(intent_id=intent_id, refused=why)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def fold_frame(row: LedgerRow, frame) -> FrameOutcome:
|
|
150
|
+
"""Folds one frame into one row, ``stdlib.md`` 17.8 steps 2 to 5 and 7."""
|
|
151
|
+
# A word outside the vocabulary leaves the status alone and the rest of the
|
|
152
|
+
# frame folds anyway. Mapping a destination's own words onto the vocabulary
|
|
153
|
+
# is the host's job under 17.7, and an engine that guessed at one would
|
|
154
|
+
# decide an order was dead on a word it had never seen. Refusing the whole
|
|
155
|
+
# frame instead would throw away the cumulative quantity it carries, which
|
|
156
|
+
# is real whatever the word beside it says.
|
|
157
|
+
status = status_from(frame.status)
|
|
158
|
+
was_terminal = is_terminal(row.status)
|
|
159
|
+
|
|
160
|
+
# Step 2. The cumulative quantity never decreases, so a frame reporting less
|
|
161
|
+
# than the row already holds contributes nothing.
|
|
162
|
+
filled = max(row.filled_qty, frame.filled_qty)
|
|
163
|
+
delta = filled - row.filled_qty
|
|
164
|
+
|
|
165
|
+
# Step 3. The destination computed its average over the cumulative quantity,
|
|
166
|
+
# so the row takes that average whole. The engine never averages two
|
|
167
|
+
# averages of its own.
|
|
168
|
+
price = frame.avg_fill_price
|
|
169
|
+
if delta > 0 and price is None:
|
|
170
|
+
return refused(frame.intent_id, FILL_WITH_NO_PRICE)
|
|
171
|
+
|
|
172
|
+
# Step 4, which a frame arriving at a terminal row does not run: the status
|
|
173
|
+
# records how the order ended and the quantity records what traded, and the
|
|
174
|
+
# two are both true.
|
|
175
|
+
moved = (
|
|
176
|
+
status is not None
|
|
177
|
+
and status != PLACED
|
|
178
|
+
and not was_terminal
|
|
179
|
+
and rank_of(status) >= rank_of(row.status)
|
|
180
|
+
and status != row.status
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
text = frame.text if frame.text is not None else ""
|
|
184
|
+
new_text = text != "" and text != row.rejection
|
|
185
|
+
|
|
186
|
+
# Step 5, and then step 7: a repeated frame and one overtaken by a later
|
|
187
|
+
# frame both end here, having changed nothing. No fill, no event, no report
|
|
188
|
+
# row, no recalculation.
|
|
189
|
+
if not (moved or delta > 0 or new_text):
|
|
190
|
+
return FrameOutcome(intent_id=frame.intent_id)
|
|
191
|
+
|
|
192
|
+
if moved and status is not None:
|
|
193
|
+
row.status = status
|
|
194
|
+
if delta > 0:
|
|
195
|
+
row.filled_qty = filled
|
|
196
|
+
row.avg_fill_price = price
|
|
197
|
+
if new_text:
|
|
198
|
+
row.rejection = text
|
|
199
|
+
if isinstance(frame.order_ref, str):
|
|
200
|
+
row.order_ref = frame.order_ref
|
|
201
|
+
if frame.sent_instrument is not None:
|
|
202
|
+
row.instrument = frame.sent_instrument
|
|
203
|
+
if isinstance(frame.sent_product, str):
|
|
204
|
+
row.product = frame.sent_product
|
|
205
|
+
if isinstance(frame.time, (int, float)) and not isinstance(frame.time, bool):
|
|
206
|
+
row.updated_at = float(frame.time)
|
|
207
|
+
|
|
208
|
+
return FrameOutcome(
|
|
209
|
+
intent_id=frame.intent_id,
|
|
210
|
+
changed=True,
|
|
211
|
+
delta=delta,
|
|
212
|
+
price=price if delta > 0 else None,
|
|
213
|
+
after_terminal=was_terminal and delta > 0,
|
|
214
|
+
event=(
|
|
215
|
+
{"tag": row.tag, "added": delta, "after": row.status}
|
|
216
|
+
if was_terminal and delta > 0
|
|
217
|
+
else {}
|
|
218
|
+
),
|
|
219
|
+
)
|