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,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
|