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,397 @@
1
+ """What a fill costs, as an ordered list of lines the platform brings.
2
+
3
+ **The language has three commission spellings and a slippage in ticks, and a real
4
+ cost stack is not shaped like that.** It is a flat fee, a percentage, a charge
5
+ levied on a charge, and a tax that applies to one side of the trade only.
6
+ Teaching the language one market's stack would be teaching it a market, so the
7
+ platform passes a schedule instead and the language keeps the three spellings it
8
+ has, which turn into a schedule of one line.
9
+
10
+ **Order is part of the result.** The lines are applied in the order they are
11
+ declared, a line charged on other lines may only name lines declared before it,
12
+ and that is what makes a schedule evaluable in exactly one order. Two engines
13
+ that disagree about the order disagree about the money, and a disagreement in the
14
+ last bit is still a failed conformance comparison (``conformance.md`` 6).
15
+
16
+ **Where it is applied is not here.** ``stdlib.md`` 17.1 puts slippage and
17
+ commission on the destination: the engine folds the price it is told and never
18
+ adjusts one, so a cost model inside the ledger would be the engine moving a
19
+ price, which is the one thing that invariant forbids. This module says what a
20
+ charge is and works out what one fill came to.
21
+
22
+ **What is charged to a fill, and only to a fill.** Every line is measured against
23
+ this fill and nothing else, so a tier that changes with the month's cumulative
24
+ volume, a cap counted per day and margin and its interest are outside this model
25
+ rather than approximated inside it. A cost model that quietly approximates is a
26
+ report that is wrong in the strategy's favour and says nothing about it.
27
+ """
28
+
29
+ import math
30
+ from dataclasses import dataclass
31
+ from typing import List, Optional, Sequence, Tuple
32
+
33
+ from .shapes import Contract, RecordedFill
34
+
35
+ #: What a line's rate is measured against. Four bases cover every stack the
36
+ #: documentation describes without naming a market: a fraction of turnover, money
37
+ #: per unit, money per fill, and a fraction of the lines named before this one,
38
+ #: which is the charge on a charge.
39
+ BASES: Tuple[str, ...] = ("turnover", "units", "order", "charges")
40
+
41
+ #: Which side of the trade a line applies to. A transaction tax levied on one
42
+ #: side is expressed exactly rather than smeared across both fills at half the
43
+ #: rate, which is what makes a reader's figure disagree with their broker's.
44
+ SIDES: Tuple[str, ...] = ("buy", "sell", "both")
45
+
46
+ #: The most digits money is rounded to. A rounding scale is a power of ten and a
47
+ #: binary64 holds about fifteen significant decimal digits, so past this the scale
48
+ #: itself is approximate and the rounding stops being arithmetic and becomes
49
+ #: noise.
50
+ MAX_DIGITS = 15
51
+
52
+ #: A percentage, as the declaration states one, over the fraction a rate is.
53
+ PERCENT = 100
54
+
55
+ #: What a refusal calls the setting it is about, ``errors.md`` OS6021.
56
+ SETTING = "The charge schedule"
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ChargeLine:
61
+ """One line of a schedule, applied to one fill."""
62
+
63
+ #: The platform's own word.
64
+ name: str
65
+ base: str
66
+ side: str = "both"
67
+ #: Fraction for turnover and charges, money per unit for units, money for
68
+ #: order.
69
+ rate: float = 0.0
70
+ #: Floor per application.
71
+ min: Optional[float] = None
72
+ #: Cap per application.
73
+ max: Optional[float] = None
74
+ #: Base ``charges`` only: names of lines declared before this one.
75
+ of: Tuple[str, ...] = ()
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class ChargeSchedule:
80
+ """The whole cost model a run was carried out under."""
81
+
82
+ currency: str = "CUR"
83
+ digits: int = 2
84
+ slippage_ticks: float = 0.0
85
+ #: Applied in order, and order is part of the result.
86
+ lines: Tuple[ChargeLine, ...] = ()
87
+ #: Where the schedule came from. One derived from the declaration is derived
88
+ #: again on replay rather than stored, because what the program states is
89
+ #: stored once, as the program.
90
+ source: str = "declaration"
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class ChargeBreakdown:
95
+ """What one fill was charged, line by line and in total."""
96
+
97
+ lines: Tuple[Tuple[str, float], ...] = ()
98
+ total: float = 0.0
99
+
100
+
101
+ def round_money(amount: float, digits: int) -> float:
102
+ """One money figure, rounded once, halves to even.
103
+
104
+ Half to even, and not the language's own rounding. ``round()`` in the language
105
+ is halves away from zero, because a price a trader reads should agree with
106
+ what they would write down (``stdlib.md`` 8.1). Money folded over thousands of
107
+ fills is a different question: away from zero biases every exact half upward,
108
+ and half a unit of the last digit per fill is a bias that grows with the length
109
+ of the backtest.
110
+
111
+ A digit count this cannot round by is one ``schedule_problem`` refuses before
112
+ the first bar. If one arrives anyway the amount is returned as it stands,
113
+ because an unrounded figure is worth more than a scale of ten to an impossible
114
+ power.
115
+ """
116
+ if not math.isfinite(amount):
117
+ return amount
118
+ if isinstance(digits, bool) or not isinstance(digits, int) or digits < 0 or digits > MAX_DIGITS:
119
+ return amount
120
+
121
+ scale = 10**digits
122
+ scaled = amount * scale
123
+ below = math.floor(scaled)
124
+ fraction = scaled - below
125
+
126
+ whole = below
127
+ if fraction > 0.5:
128
+ whole = below + 1
129
+ elif fraction == 0.5 and below % 2 != 0:
130
+ whole = below + 1
131
+
132
+ money = whole / scale
133
+ # A negative zero is the same money as a zero and a different set of bytes.
134
+ return 0.0 if money == 0 else money
135
+
136
+
137
+ def _bounded(raw: float, line: ChargeLine) -> float:
138
+ """The floor and the cap, per application.
139
+
140
+ Which is applied first does not decide the answer, because a floor above a cap
141
+ is refused before the first bar rather than resolved here by whichever
142
+ comparison runs first.
143
+ """
144
+ amount = raw
145
+ if line.min is not None and amount < line.min:
146
+ amount = line.min
147
+ if line.max is not None and amount > line.max:
148
+ amount = line.max
149
+ return amount
150
+
151
+
152
+ def _base_of(
153
+ line: ChargeLine, turnover: float, units: float, applied: Sequence[Tuple[str, float]]
154
+ ) -> float:
155
+ """What a line's rate is measured against, for this fill.
156
+
157
+ The earlier lines are searched rather than indexed. An index would be a map,
158
+ and this module depends on no map's iteration order; a schedule is a handful
159
+ of lines, so the search costs nothing and the promise costs one less thing to
160
+ be careful about.
161
+ """
162
+ if line.base == "turnover":
163
+ return turnover
164
+ if line.base == "units":
165
+ return units
166
+ if line.base == "order":
167
+ return 1.0
168
+ # A name whose line did not apply to this side is absent and adds nothing,
169
+ # which is a charge levied on a charge that was never taken.
170
+ total = 0.0
171
+ for named in line.of:
172
+ for one, amount in applied:
173
+ if one == named:
174
+ total += amount
175
+ return total
176
+
177
+
178
+ def charge_for(
179
+ schedule: ChargeSchedule, fill: RecordedFill, contract: Contract
180
+ ) -> ChargeBreakdown:
181
+ """What one fill cost, line by line and in total.
182
+
183
+ **The lines are the arithmetic and the total is the money.** Each line's
184
+ amount is computed in binary64 and left unrounded, and the per-fill total is
185
+ rounded once, half to even, to the contract's digits. Rounding each line would
186
+ round once per line, and two engines rounding in two places disagree in the
187
+ last bit. So a reader adding the lines up by hand may land a fraction of the
188
+ last digit away from the total, and that is the honest way round: the total is
189
+ the figure the report accumulates.
190
+
191
+ **A line that does not apply to this side is not in the breakdown at all.** A
192
+ name beside a zero reads as a charge that was levied and came to nothing, and
193
+ a later line levied on that name is levied on nothing, which is exactly what a
194
+ tax on one side of the trade does.
195
+ """
196
+ turnover = fill.units * fill.price * contract.point_value
197
+ applied: List[Tuple[str, float]] = []
198
+
199
+ for line in schedule.lines:
200
+ if line.side != "both" and line.side != fill.side:
201
+ continue
202
+ base = _base_of(line, turnover, fill.units, applied)
203
+ applied.append((line.name, _bounded(line.rate * base, line)))
204
+
205
+ exact = 0.0
206
+ for _name, amount in applied:
207
+ exact += amount
208
+
209
+ return ChargeBreakdown(lines=tuple(applied), total=round_money(exact, contract.digits))
210
+
211
+
212
+ def _commission_line(commission: float, commission_type: str) -> ChargeLine:
213
+ """The one line a declared commission is, in the base its spelling names."""
214
+ if commission_type == "perUnit":
215
+ return ChargeLine(name="commission", base="units", rate=commission)
216
+ if commission_type == "percent":
217
+ return ChargeLine(name="commission", base="turnover", rate=commission / PERCENT)
218
+ return ChargeLine(name="commission", base="order", rate=commission)
219
+
220
+
221
+ def schedule_from_declaration(
222
+ commission: float,
223
+ commission_type: str,
224
+ slippage: float,
225
+ currency: str,
226
+ digits: int,
227
+ ) -> ChargeSchedule:
228
+ """The declaration's own cost model, as the one schedule this module evaluates.
229
+
230
+ **The declaration is not a second cost engine.** Its three commission
231
+ spellings are a schedule of one line: a flat fee is a line charged per fill, a
232
+ per unit fee is a line charged per unit, and a percentage is a line charged on
233
+ turnover.
234
+
235
+ **A commission of zero is no line at all**, rather than a line charging
236
+ nothing. A zero line would put a name in every breakdown and would make a
237
+ declaration that states no commission indistinguishable from one that states a
238
+ commission.
239
+
240
+ **A flat fee is charged per fill**, which is the one place ``language.md``
241
+ 13.3 lets a reasonable person read the words two ways. A charge is attributed
242
+ to the fill that incurred it everywhere in this module, because that is what
243
+ attributes it to a trade, so a round trip of two fills is charged twice.
244
+
245
+ The currency and the digit count are parameters because neither is the
246
+ declaration's to state: the declaration's currency is a label and is often
247
+ left blank, and money rounding is a fact about the contract.
248
+ """
249
+ return ChargeSchedule(
250
+ currency=currency,
251
+ digits=digits,
252
+ slippage_ticks=slippage,
253
+ lines=() if commission == 0 else (_commission_line(commission, commission_type),),
254
+ source="declaration",
255
+ )
256
+
257
+
258
+ def _money_problem(schedule: ChargeSchedule, contract: Optional[Contract]) -> Optional[str]:
259
+ """The currency the money is in and the digits it is rounded to.
260
+
261
+ A schedule states both and so does the contract, and the two are compared here
262
+ rather than one of them being quietly preferred. A schedule in another currency
263
+ charges a fill in money the contract is not priced in, and a total nobody can
264
+ add to the profit is worse than no total.
265
+ """
266
+ digits = schedule.digits
267
+ if isinstance(digits, bool) or not isinstance(digits, int) or digits < 0 or digits > MAX_DIGITS:
268
+ return (
269
+ f"it rounds money to {digits} digits, and a digit count is a whole "
270
+ f"number from 0 to {MAX_DIGITS}"
271
+ )
272
+ if schedule.currency.strip() == "":
273
+ return "it names no currency, so what it charges is a number with no unit on it"
274
+ if contract is None:
275
+ return None
276
+ if schedule.currency != contract.currency:
277
+ return (
278
+ f"it charges in {schedule.currency} and the contract is priced in "
279
+ f"{contract.currency}"
280
+ )
281
+ if digits != contract.digits:
282
+ return f"it rounds money to {digits} digits and the contract rounds to {contract.digits}"
283
+ return None
284
+
285
+
286
+ def _slippage_problem(schedule: ChargeSchedule, contract: Optional[Contract]) -> Optional[str]:
287
+ """The slippage, which this module refuses and does not apply.
288
+
289
+ A slippage in ticks with no tick size to measure a tick in would charge
290
+ nothing at all, and a backtest that silently charges nothing is one that lies
291
+ in the strategy's favour.
292
+ """
293
+ ticks = schedule.slippage_ticks
294
+ if not math.isfinite(ticks) or ticks < 0:
295
+ return (
296
+ f"it states {ticks} ticks of slippage, and slippage is adverse, so it "
297
+ "is never negative"
298
+ )
299
+ if ticks == 0 or contract is None:
300
+ return None
301
+ tick = contract.tick_size
302
+ if tick is None or not tick > 0:
303
+ return (
304
+ f"it states {ticks} ticks of slippage and the contract has no tick "
305
+ "size to measure a tick in"
306
+ )
307
+ return None
308
+
309
+
310
+ def _bound_problem(line: ChargeLine) -> Optional[str]:
311
+ """The floor and the cap: money, not negative, and the floor no higher than the cap."""
312
+ if line.min is not None and (not math.isfinite(line.min) or line.min < 0):
313
+ return f'the line "{line.name}" has a floor of {line.min}, and a bound on a charge is money'
314
+ if line.max is not None and (not math.isfinite(line.max) or line.max < 0):
315
+ return f'the line "{line.name}" has a cap of {line.max}, and a bound on a charge is money'
316
+ if line.min is not None and line.max is not None and line.min > line.max:
317
+ return f'the line "{line.name}" has a floor of {line.min} above its cap of {line.max}'
318
+ return None
319
+
320
+
321
+ def _levy_problem(line: ChargeLine, declared: Sequence[str]) -> Optional[str]:
322
+ """What a line is levied on, which is the rule the whole ordering exists for.
323
+
324
+ A line levied on lines not declared before it has no single evaluation order,
325
+ so two engines would charge two different amounts and both would be
326
+ defensible. Naming itself, naming a line declared after it and naming a line
327
+ that is not in the schedule at all are one problem in three spellings.
328
+ """
329
+ name = line.name
330
+ if line.base != "charges":
331
+ if not line.of:
332
+ return None
333
+ return (
334
+ f'the line "{name}" names {len(line.of)} lines to be levied on and its '
335
+ f"base is {line.base}, so the names are read by nothing"
336
+ )
337
+ if not line.of:
338
+ return f'the line "{name}" is levied on charges and names none, so it is levied on nothing'
339
+
340
+ seen: List[str] = []
341
+ for named in line.of:
342
+ if named not in declared:
343
+ return f'the line "{name}" is levied on "{named}", which is not declared before it'
344
+ if named in seen:
345
+ return (
346
+ f'the line "{name}" is levied on "{named}" twice, so that line is '
347
+ "charged on twice over"
348
+ )
349
+ seen.append(named)
350
+ return None
351
+
352
+
353
+ def _line_problem(line: ChargeLine, declared: Sequence[str]) -> Optional[str]:
354
+ name = line.name
355
+ if name.strip() == "":
356
+ return "a line carries no name, and a line levied on charges names the lines it is levied on"
357
+ if name in declared:
358
+ return f'two lines are named "{name}", so a line levied on that name is levied on two answers'
359
+ if not math.isfinite(line.rate) or line.rate < 0:
360
+ return (
361
+ f'the line "{name}" charges a rate of {line.rate}, and a charge is '
362
+ "money taken, never given"
363
+ )
364
+ return _bound_problem(line) or _levy_problem(line, declared)
365
+
366
+
367
+ def _lines_problem(lines: Sequence[ChargeLine]) -> Optional[str]:
368
+ declared: List[str] = []
369
+ for line in lines:
370
+ problem = _line_problem(line, declared)
371
+ if problem is not None:
372
+ return problem
373
+ declared.append(line.name)
374
+ return None
375
+
376
+
377
+ def schedule_problem(
378
+ schedule: ChargeSchedule, contract: Optional[Contract] = None
379
+ ) -> Optional[Tuple[str, str]]:
380
+ """Why this schedule cannot be carried out, or none.
381
+
382
+ **Asked before the first bar, and answered once.** Everything here is a fact
383
+ about the schedule rather than about any fill, so a run that would produce a
384
+ number nobody can explain is refused while nothing has been computed and the
385
+ cost of correcting it is one run.
386
+
387
+ The first problem found is the one reported. A list of everything wrong with a
388
+ schedule reads as a worse schedule than it is, and it is corrected one line at
389
+ a time regardless. What comes back is the setting and the problem, which are
390
+ the two values OS6021's message names: the sentence itself is the catalogue's.
391
+ """
392
+ problem = (
393
+ _money_problem(schedule, contract)
394
+ or _slippage_problem(schedule, contract)
395
+ or _lines_problem(schedule.lines)
396
+ )
397
+ return None if problem is None else (SETTING, problem)
@@ -0,0 +1,234 @@
1
+ """The equity curve: one point per bar in the report window.
2
+
3
+ **Marked to the close and to nothing else.** An intrabar extreme is a price the
4
+ strategy could not have acted on, so it is not a profit it had, and a curve drawn
5
+ through the extremes flatters every run that ever held a losing position. Where a
6
+ bar's close is absent the previous mark carries and the point says so, rather
7
+ than a zero a reader would compare against.
8
+
9
+ **Drawdown is stated on equity including open profit**, and the basis is carried
10
+ on every point rather than left to the reader. A report whose drawdown basis is
11
+ not written down is a report whose worst figure means a different thing to each
12
+ reader.
13
+
14
+ **A trade's charges land on the bar it opened** and **its gross on the bar it
15
+ closed.** The trade list does not carry the timing of the fills underneath it, so
16
+ the cost has to land somewhere, and the open is the one place that is never later
17
+ than the truth. The gross lands at the close because that is the bar it stopped
18
+ being an opinion and became a number; while the trade is open it is in the open
19
+ profit instead, marked to the close, and the two never overlap.
20
+
21
+ **What a trade list cannot say, said here rather than discovered later.** A trade
22
+ holds one entry price weighted over its entry fills, so a trade whose size changed
23
+ while it was open is not visible in it. A partial close is marked at the full size
24
+ from the bar the reduction settled on, so the open profit of the part already
25
+ closed is counted twice over. A scale-in is the worse of the two, because it is
26
+ wrong from the beginning: a trade that buys a hundred at ten and another hundred
27
+ at twenty is marked, from the bar it first opened, as two hundred units bought at
28
+ fifteen, and the curve reports a drawdown the account never had. Neither reaches
29
+ the realised total, which is folded from the fills. A curve folded from the fills
30
+ rather than from the trades would have neither, and that is a different fold
31
+ rather than a correction to this one.
32
+
33
+ **A percentage here is a fraction of its basis.** A hundredth of a percent down is
34
+ ``-0.0001`` and not ``-0.01``, for every figure in this module that divides. The
35
+ multiplication by a hundred belongs to whatever prints it.
36
+ """
37
+
38
+ from dataclasses import dataclass
39
+ from typing import List, Optional, Sequence, Tuple
40
+
41
+ from .shapes import BarMark, Contract
42
+ from .trades import Trade
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class EquityPoint:
47
+ """One report bar's standing, folded from the fills settled up to it."""
48
+
49
+ bar_index: int
50
+ time: Optional[float]
51
+ #: Cumulative gross of closed trades.
52
+ realised: float
53
+ #: Cumulative.
54
+ charges: float
55
+ #: Marked to this bar's close.
56
+ open_profit: float
57
+ #: capital + realised - charges.
58
+ cash: float
59
+ #: cash + open profit.
60
+ equity: float
61
+ #: The open position's magnitude at this close.
62
+ exposure: float
63
+ #: equity less the running peak, zero or negative.
64
+ drawdown: float
65
+ #: That distance against the peak, a fraction.
66
+ drawdown_percent: float
67
+ #: equity less the running trough, zero or positive: drawdown's mirror, so
68
+ #: the run's best stretch is measured the same way its worst one is.
69
+ run_up: float
70
+ #: That distance against the trough, a fraction, and zero where the trough
71
+ #: is not above zero. Not the same guard drawdown gets, because the two
72
+ #: bases are not the same kind of number: a peak starts at the capital and
73
+ #: only rises, a trough starts there and only falls, so an account that lost
74
+ #: everything has a trough at or below zero and reports zero here from that
75
+ #: bar on. Zero is the wrong answer for a run that recovered and is reported
76
+ #: anyway, because a percentage against a negative basis turns a positive
77
+ #: climb into a negative fraction. ``run_up`` itself is unaffected.
78
+ run_up_percent: float
79
+
80
+
81
+ def open_on_bar(trade: Trade, bar_index: int) -> bool:
82
+ """Whether this trade was still held at the close of this bar.
83
+
84
+ The boundaries are the whole of the rule and both are decisions. A trade is
85
+ held from the close of the bar it opened on, because an entry that settled
86
+ during a bar is a position that bar ended holding. It is not held at the close
87
+ of the bar it closed on, because that bar ended flat. So a trade opened and
88
+ closed inside one bar is held at no close at all.
89
+ """
90
+ if bar_index < trade.opened_on_bar:
91
+ return False
92
+ return trade.closed_on_bar is None or bar_index < trade.closed_on_bar
93
+
94
+
95
+ def ratio_of(value: float, basis: float) -> float:
96
+ """A ratio against a basis that may not be there to divide by.
97
+
98
+ Capital of zero and a peak of zero are both reachable, and both turn an honest
99
+ division into a value a document cannot carry. A basis that is not positive has
100
+ no ratio to state, so the figure beside it, which is money and is always true,
101
+ is the one a reader is left with.
102
+ """
103
+ return value / basis if basis > 0 else 0.0
104
+
105
+
106
+ def _closed_by(trade: Trade, bar_index: int) -> bool:
107
+ """Whether this bar is the bar the trade closed on, or one after it."""
108
+ return trade.closed_on_bar is not None and trade.closed_on_bar <= bar_index
109
+
110
+
111
+ def equity_over(
112
+ trades: Sequence[Trade],
113
+ marks: Sequence[BarMark],
114
+ contract: Contract,
115
+ capital: float,
116
+ ) -> Tuple[EquityPoint, ...]:
117
+ """The curve, one point per report bar, in the order the bars arrived.
118
+
119
+ Warmup bars are swept and not reported: their orders were real, so a trade
120
+ opened during the warmup is already in the fold at the first point, with its
121
+ charges already paid and its position already marked. A curve that began its
122
+ fold at the first report bar would lose both, and would lose them silently.
123
+
124
+ The running peak starts at the capital rather than at the first point, so a run
125
+ that is down from its first bar is in drawdown at its first bar. Starting it at
126
+ the first point would report every run as having begun at its high.
127
+
128
+ The trades arrive in the order they opened and the bars in the order they were
129
+ loaded, and both are swept with a pointer rather than searched: a report over
130
+ fifty thousand bars that rescans its trade list on every one of them is a
131
+ report nobody waits for.
132
+ """
133
+ points: List[EquityPoint] = []
134
+ held: List[Trade] = []
135
+ at = 0
136
+ realised = 0.0
137
+ charges = 0.0
138
+ peak = capital
139
+ trough = capital
140
+ mark: Optional[float] = None
141
+
142
+ for bar in marks:
143
+ # Opened by this bar, charges and all.
144
+ while at < len(trades) and trades[at].opened_on_bar <= bar.bar_index:
145
+ charges += trades[at].charges
146
+ held.append(trades[at])
147
+ at += 1
148
+
149
+ # Closed by this bar, gross and all. A trade that opened and closed inside
150
+ # one bar is taken on and given up here in that order, so its cost and its
151
+ # gross are both in this point and its position is in none.
152
+ closed_here = False
153
+ for trade in held:
154
+ if _closed_by(trade, bar.bar_index):
155
+ realised += trade.gross_profit
156
+ closed_here = True
157
+ if closed_here:
158
+ held = [trade for trade in held if not _closed_by(trade, bar.bar_index)]
159
+
160
+ # A close the host did not have leaves the previous mark standing. It is
161
+ # carried across the warmup boundary too, so the first report bar of a run
162
+ # whose close is absent is marked at the last price there was.
163
+ if bar.close is not None:
164
+ mark = bar.close
165
+ if not bar.in_report:
166
+ continue
167
+
168
+ open_profit = 0.0
169
+ exposure = 0.0
170
+ for trade in held:
171
+ # Before the first close there has ever been, a trade is marked at its
172
+ # own entry: no profit, and the position still visible in the exposure.
173
+ price = trade.entry_price if mark is None else mark
174
+ way = 1 if trade.side == "long" else -1
175
+ open_profit += way * (price - trade.entry_price) * trade.units * contract.point_value
176
+ exposure += abs(trade.units * price * contract.point_value)
177
+
178
+ cash = capital + realised - charges
179
+ equity = cash + open_profit
180
+ if equity > peak:
181
+ peak = equity
182
+ if equity < trough:
183
+ trough = equity
184
+ drawdown = equity - peak
185
+ run_up = equity - trough
186
+ points.append(
187
+ EquityPoint(
188
+ bar_index=bar.bar_index,
189
+ time=bar.time,
190
+ realised=realised,
191
+ charges=charges,
192
+ open_profit=open_profit,
193
+ cash=cash,
194
+ equity=equity,
195
+ exposure=exposure,
196
+ drawdown=drawdown,
197
+ drawdown_percent=ratio_of(drawdown, peak),
198
+ run_up=run_up,
199
+ run_up_percent=ratio_of(run_up, trough),
200
+ )
201
+ )
202
+
203
+ return tuple(points)
204
+
205
+
206
+ def bars_in_market_over(trades: Sequence[Trade], equity: Sequence[EquityPoint]) -> int:
207
+ """How many of these bars ended with something held.
208
+
209
+ Swept rather than searched: a sorted list of the bars trades opened on, a
210
+ sorted list of the bars they closed on, and the running difference between how
211
+ many of each have gone by. That is the boundary rule ``open_on_bar`` states,
212
+ arrived at from the other side, and a count that disagreed with the curve
213
+ beside it about which bars were in the market is exactly the kind of defect a
214
+ reader finds by adding two of the printed figures up.
215
+ """
216
+ opens = sorted(trade.opened_on_bar for trade in trades)
217
+ closes = sorted(
218
+ trade.closed_on_bar for trade in trades if trade.closed_on_bar is not None
219
+ )
220
+
221
+ opened = 0
222
+ closed = 0
223
+ live = 0
224
+ bars = 0
225
+ for point in equity:
226
+ while opened < len(opens) and opens[opened] <= point.bar_index:
227
+ live += 1
228
+ opened += 1
229
+ while closed < len(closes) and closes[closed] <= point.bar_index:
230
+ live -= 1
231
+ closed += 1
232
+ if live > 0:
233
+ bars += 1
234
+ return bars