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.
Files changed (97) hide show
  1. openscript/__init__.py +40 -0
  2. openscript/__main__.py +62 -0
  3. openscript/accounting/__init__.py +74 -0
  4. openscript/accounting/analysis.py +174 -0
  5. openscript/accounting/charges.py +397 -0
  6. openscript/accounting/equity.py +234 -0
  7. openscript/accounting/report.py +82 -0
  8. openscript/accounting/shapes.py +74 -0
  9. openscript/accounting/statistics.py +300 -0
  10. openscript/accounting/trades.py +294 -0
  11. openscript/adapter/__init__.py +32 -0
  12. openscript/adapter/answers.py +215 -0
  13. openscript/adapter/channels.py +137 -0
  14. openscript/adapter/expectations.py +67 -0
  15. openscript/adapter/facts.py +127 -0
  16. openscript/adapter/matching.py +257 -0
  17. openscript/adapter/ordering.py +187 -0
  18. openscript/adapter/page.py +130 -0
  19. openscript/adapter/reading.py +357 -0
  20. openscript/adapter/reporting.py +244 -0
  21. openscript/adapter/running.py +449 -0
  22. openscript/adapter/serving.py +229 -0
  23. openscript/adapter/sessions.py +168 -0
  24. openscript/adapter/spellings.py +184 -0
  25. openscript/bars.py +157 -0
  26. openscript/budget.py +342 -0
  27. openscript/canonical.py +192 -0
  28. openscript/civil.py +196 -0
  29. openscript/contracts.py +165 -0
  30. openscript/dates.py +302 -0
  31. openscript/diagnostics.py +104 -0
  32. openscript/hours.py +165 -0
  33. openscript/inputs.py +239 -0
  34. openscript/intervals.py +60 -0
  35. openscript/library/__init__.py +76 -0
  36. openscript/library/arithmetic.py +128 -0
  37. openscript/library/averages.py +133 -0
  38. openscript/library/bars.py +60 -0
  39. openscript/library/bookkeeping.py +166 -0
  40. openscript/library/code_points.py +85 -0
  41. openscript/library/colour.py +202 -0
  42. openscript/library/composites.py +208 -0
  43. openscript/library/counting.py +218 -0
  44. openscript/library/deviation.py +155 -0
  45. openscript/library/elementary.py +206 -0
  46. openscript/library/extremes.py +122 -0
  47. openscript/library/flows.py +220 -0
  48. openscript/library/momentum.py +203 -0
  49. openscript/library/number_text.py +223 -0
  50. openscript/library/prices.py +36 -0
  51. openscript/library/ranges.py +105 -0
  52. openscript/library/rounding.py +123 -0
  53. openscript/library/series.py +213 -0
  54. openscript/library/stateful.py +442 -0
  55. openscript/library/stateless.py +261 -0
  56. openscript/library/strength.py +180 -0
  57. openscript/library/strings.py +228 -0
  58. openscript/library/trend.py +260 -0
  59. openscript/library/values.py +91 -0
  60. openscript/logbook.py +119 -0
  61. openscript/machine.py +499 -0
  62. openscript/memory.py +204 -0
  63. openscript/opcodes.py +166 -0
  64. openscript/program.py +146 -0
  65. openscript/run.py +368 -0
  66. openscript/strategy/__init__.py +78 -0
  67. openscript/strategy/calls.py +201 -0
  68. openscript/strategy/closable.py +182 -0
  69. openscript/strategy/fills.py +131 -0
  70. openscript/strategy/holdings.py +277 -0
  71. openscript/strategy/intents.py +162 -0
  72. openscript/strategy/ledger.py +270 -0
  73. openscript/strategy/placing.py +206 -0
  74. openscript/strategy/positions.py +124 -0
  75. openscript/strategy/refusals.py +293 -0
  76. openscript/strategy/rows.py +219 -0
  77. openscript/strategy/sizing.py +229 -0
  78. openscript/strategy/statuses.py +65 -0
  79. openscript/surface/__init__.py +115 -0
  80. openscript/surface/bands.py +103 -0
  81. openscript/surface/levels.py +44 -0
  82. openscript/surface/marks.py +52 -0
  83. openscript/surface/paints.py +58 -0
  84. openscript/surface/plots.py +44 -0
  85. openscript/surface/published.py +119 -0
  86. openscript/values.py +210 -0
  87. openscript/verify.py +301 -0
  88. openscript/verify_code.py +290 -0
  89. openscript/verify_requests.py +271 -0
  90. openscript/verify_shape.py +162 -0
  91. openscript/verify_tables.py +256 -0
  92. openscript/version.py +39 -0
  93. openscript/zones.py +118 -0
  94. openscript-0.4.0.dist-info/METADATA +82 -0
  95. openscript-0.4.0.dist-info/RECORD +97 -0
  96. openscript-0.4.0.dist-info/WHEEL +5 -0
  97. 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