outcometick 1.4.0
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.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/api/lib/backtest-contract.mjs +318 -0
- package/api/lib/backtest-datasets.mjs +225 -0
- package/api/lib/backtest-manifest.mjs +345 -0
- package/api/lib/coverage-window.mjs +42 -0
- package/api/lib/data-taxonomy.mjs +175 -0
- package/api/lib/venue-path.mjs +16 -0
- package/bin/ot.mjs +4 -0
- package/cli/api-client.mjs +71 -0
- package/cli/commands/fetch.mjs +43 -0
- package/cli/commands/run.mjs +269 -0
- package/cli/commands/status.mjs +102 -0
- package/cli/commands/submit.mjs +77 -0
- package/cli/local-data.mjs +177 -0
- package/cli/ot.mjs +223 -0
- package/index.d.ts +195 -0
- package/index.mjs +2 -0
- package/package.json +58 -0
- package/runner/analyze/index.mjs +40 -0
- package/runner/analyze/javascript.mjs +380 -0
- package/runner/analyze/python.mjs +85 -0
- package/runner/analyze/python_analyze.py +320 -0
- package/runner/archive.mjs +185 -0
- package/runner/engine/book.mjs +226 -0
- package/runner/engine/portfolio.mjs +292 -0
- package/runner/engine/replay.mjs +496 -0
- package/runner/engine/report.mjs +417 -0
- package/runner/events.mjs +190 -0
- package/runner/harness/node/harness.mjs +467 -0
- package/runner/harness/node/sdk/index.d.ts +195 -0
- package/runner/harness/node/sdk/index.mjs +71 -0
- package/runner/harness/node/sdk/package.json +8 -0
- package/runner/harness/protocol.mjs +255 -0
- package/runner/harness/python/harness.py +374 -0
- package/runner/harness/python/otengine.py +523 -0
- package/runner/harness/python/otreplay.py +409 -0
- package/runner/harness/python/outcometick.py +67 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// The order book, and how a returned Order becomes fills.
|
|
2
|
+
//
|
|
3
|
+
// This is the module the slippage panel is a report of, so its bias is fixed:
|
|
4
|
+
// where the archive is ambiguous, resolve AGAINST the strategy. An optimistic
|
|
5
|
+
// matcher is what makes a backtest flatter, and a flattering backtest is worth
|
|
6
|
+
// less than no backtest.
|
|
7
|
+
//
|
|
8
|
+
// Three rules that are not negotiable:
|
|
9
|
+
//
|
|
10
|
+
// - An order fills against the depth that was RESTING at that millisecond.
|
|
11
|
+
// Not the best price during the second, not the next tick's price.
|
|
12
|
+
// - Size beyond the visible depth walks the book and the shortfall is
|
|
13
|
+
// reported. It is never assumed to fill at the top of book.
|
|
14
|
+
// - We only ever take. Posting a quote and waiting to be filled needs a
|
|
15
|
+
// queue-position model, and guessing at it inflates market-making returns by
|
|
16
|
+
// multiples — so it is refused rather than modelled badly.
|
|
17
|
+
//
|
|
18
|
+
// Prices are outcome-token prices in dollars, 0..1. A binary market quotes a
|
|
19
|
+
// probability, so "price" and "implied probability" are the same number.
|
|
20
|
+
//
|
|
21
|
+
// SHAPE: a binary market has TWO tradeable tokens (UP, DOWN), and each has its
|
|
22
|
+
// own bid and ask ladder. Buying UP lifts UP's asks; getting out of UP sells
|
|
23
|
+
// into UP's bids. An earlier version of this file modelled one ladder per token
|
|
24
|
+
// and could not express an exit at all.
|
|
25
|
+
|
|
26
|
+
/** The two outcome tokens of an Up/Down market. */
|
|
27
|
+
export const SIDES = Object.freeze(['UP', 'DOWN']);
|
|
28
|
+
export const isSide = (s) => SIDES.includes(s);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Prices are quantised to 1e-4 before any comparison.
|
|
32
|
+
*
|
|
33
|
+
* Venue tick sizes are 0.01 or 0.001 and the archive carries floats. Letting
|
|
34
|
+
* `0.1 + 0.2 > 0.3` decide whether an order fills is exactly the
|
|
35
|
+
* non-determinism this product promises does not exist, so every comparison
|
|
36
|
+
* downstream is on integers.
|
|
37
|
+
*/
|
|
38
|
+
export const PRICE_SCALE = 10_000;
|
|
39
|
+
export const toTicks = (px) => Math.round(px * PRICE_SCALE);
|
|
40
|
+
export const fromTicks = (t) => t / PRICE_SCALE;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One ladder — all the resting size on one side of one token.
|
|
44
|
+
*
|
|
45
|
+
* `dir` is the direction "better" runs in: asks are best-cheapest (+1, sorted
|
|
46
|
+
* ascending, walk from the front), bids are best-dearest (-1, sorted
|
|
47
|
+
* descending). Keeping both in one class with a direction is what stops the
|
|
48
|
+
* bid path and the ask path drifting into two slightly different matchers.
|
|
49
|
+
*/
|
|
50
|
+
class Ladder {
|
|
51
|
+
constructor(dir) {
|
|
52
|
+
this.dir = dir; // +1 asks (ascending), -1 bids (descending)
|
|
53
|
+
/** @type {Array<{ticks:number,size:number}>} best-first */
|
|
54
|
+
this.levels = [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#order(a, b) { return this.dir > 0 ? a - b : b - a; }
|
|
58
|
+
|
|
59
|
+
/** Replace the whole ladder (a snapshot). */
|
|
60
|
+
reset(levels) {
|
|
61
|
+
this.levels = (levels ?? [])
|
|
62
|
+
.map(([px, size]) => ({ ticks: toTicks(px), size: Number(size) }))
|
|
63
|
+
.filter((l) => l.size > 0 && Number.isFinite(l.ticks))
|
|
64
|
+
.sort((a, b) => this.#order(a.ticks, b.ticks));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Apply one delta. A size of zero removes the level. */
|
|
68
|
+
apply(px, size) {
|
|
69
|
+
const ticks = toTicks(px);
|
|
70
|
+
const n = Number(size);
|
|
71
|
+
const i = this.levels.findIndex((l) => l.ticks === ticks);
|
|
72
|
+
if (!(n > 0)) {
|
|
73
|
+
if (i >= 0) this.levels.splice(i, 1);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (i >= 0) { this.levels[i].size = n; return; }
|
|
77
|
+
let j = this.levels.length;
|
|
78
|
+
while (j > 0 && this.#order(this.levels[j - 1].ticks, ticks) > 0) j -= 1;
|
|
79
|
+
this.levels.splice(j, 0, { ticks, size: n });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Best resting price, or null when empty. */
|
|
83
|
+
best() { return this.levels.length ? fromTicks(this.levels[0].ticks) : null; }
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Size resting at prices at least as good as `bound`.
|
|
87
|
+
*
|
|
88
|
+
* "At least as good" is direction-aware: for asks that means at or below the
|
|
89
|
+
* bound, for bids at or above it. Omit the bound for the whole ladder.
|
|
90
|
+
*/
|
|
91
|
+
depth(bound = null) {
|
|
92
|
+
const cap = bound == null ? null : toTicks(bound);
|
|
93
|
+
let total = 0;
|
|
94
|
+
for (const l of this.levels) {
|
|
95
|
+
if (cap != null && this.#order(l.ticks, cap) > 0) break;
|
|
96
|
+
total += l.size;
|
|
97
|
+
}
|
|
98
|
+
return total;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
view(n = 10) { return this.levels.slice(0, n).map((l) => [fromTicks(l.ticks), l.size]); }
|
|
102
|
+
|
|
103
|
+
/** Consume up to `size` from the best end, respecting `bound`. */
|
|
104
|
+
take(size, bound) {
|
|
105
|
+
const cap = bound == null ? null : toTicks(bound);
|
|
106
|
+
const fills = [];
|
|
107
|
+
let remaining = size;
|
|
108
|
+
let notional = 0;
|
|
109
|
+
while (remaining > 0 && this.levels.length > 0) {
|
|
110
|
+
const level = this.levels[0];
|
|
111
|
+
if (cap != null && this.#order(level.ticks, cap) > 0) break;
|
|
112
|
+
const take = Math.min(remaining, level.size);
|
|
113
|
+
const px = fromTicks(level.ticks);
|
|
114
|
+
fills.push({ px, size: take });
|
|
115
|
+
notional += px * take;
|
|
116
|
+
remaining -= take;
|
|
117
|
+
level.size -= take;
|
|
118
|
+
if (level.size <= 0) this.levels.shift();
|
|
119
|
+
}
|
|
120
|
+
return { fills, remaining, notional };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A single market's book: two tokens, each with bids and asks. */
|
|
125
|
+
export class Book {
|
|
126
|
+
constructor(marketId) {
|
|
127
|
+
this.marketId = marketId;
|
|
128
|
+
this.ts = 0;
|
|
129
|
+
this.ladders = {
|
|
130
|
+
UP: { asks: new Ladder(1), bids: new Ladder(-1) },
|
|
131
|
+
DOWN: { asks: new Ladder(1), bids: new Ladder(-1) },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Replace one or both tokens wholesale. */
|
|
136
|
+
snapshot(ts, levels) {
|
|
137
|
+
this.ts = ts;
|
|
138
|
+
for (const side of SIDES) {
|
|
139
|
+
const l = levels?.[side];
|
|
140
|
+
if (!l) continue;
|
|
141
|
+
this.ladders[side].asks.reset(l.asks);
|
|
142
|
+
this.ladders[side].bids.reset(l.bids);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
delta(ts, side, kind, px, size) {
|
|
147
|
+
this.ts = ts;
|
|
148
|
+
if (!isSide(side)) throw new Error(`unknown side ${side}`);
|
|
149
|
+
if (kind !== 'asks' && kind !== 'bids') throw new Error(`unknown ladder ${kind}`);
|
|
150
|
+
this.ladders[side][kind].apply(px, size);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `book.best(side)` — the price to BUY that outcome at, i.e. the best ask.
|
|
155
|
+
*
|
|
156
|
+
* This is the number a strategy means by "the price of UP", and it is what
|
|
157
|
+
* the SDK examples pass as a buy limit. The bid is reachable through
|
|
158
|
+
* `bestBid`, which is what an exit prices against.
|
|
159
|
+
*/
|
|
160
|
+
best(side) { return this.ladders[side]?.asks.best() ?? null; }
|
|
161
|
+
|
|
162
|
+
bestBid(side) { return this.ladders[side]?.bids.best() ?? null; }
|
|
163
|
+
|
|
164
|
+
/** Visible size available to buy at or under `bound`. */
|
|
165
|
+
depth(side, bound = null) { return this.ladders[side]?.asks.depth(bound) ?? 0; }
|
|
166
|
+
|
|
167
|
+
bidDepth(side, bound = null) { return this.ladders[side]?.bids.depth(bound) ?? 0; }
|
|
168
|
+
|
|
169
|
+
levels(side, n = 10) { return this.ladders[side]?.asks.view(n) ?? []; }
|
|
170
|
+
|
|
171
|
+
bidLevels(side, n = 10) { return this.ladders[side]?.bids.view(n) ?? []; }
|
|
172
|
+
|
|
173
|
+
/** Mid price of a token, or null when either ladder is empty. */
|
|
174
|
+
mid(side) {
|
|
175
|
+
const a = this.best(side);
|
|
176
|
+
const b = this.bestBid(side);
|
|
177
|
+
return a == null || b == null ? null : (a + b) / 2;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
get empty() {
|
|
181
|
+
return SIDES.every((s) => this.ladders[s].asks.levels.length === 0
|
|
182
|
+
&& this.ladders[s].bids.levels.length === 0);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Match a taker order against resting depth, consuming what it takes.
|
|
188
|
+
*
|
|
189
|
+
* Consuming matters: two orders returned from the same event must not both fill
|
|
190
|
+
* against the same depth, or "close and reverse in one event" prices the
|
|
191
|
+
* reversal off size the close already ate.
|
|
192
|
+
*
|
|
193
|
+
* `limit` is a bound in whichever direction protects the trader — a CEILING
|
|
194
|
+
* when opening (nothing fills above it) and a FLOOR when reducing (nothing
|
|
195
|
+
* fills below it). The SDK reference states only the buy case; a floor is the
|
|
196
|
+
* only reading of a sell limit that is not simply harmful, since the alternative
|
|
197
|
+
* would let an exit dump into an empty book at any price.
|
|
198
|
+
*
|
|
199
|
+
* @param {Book} book
|
|
200
|
+
* @param {{side:string,size:number,limit:number|null,reduce_only?:boolean}} order
|
|
201
|
+
*/
|
|
202
|
+
export function matchOrder(book, order) {
|
|
203
|
+
const size = Number(order.size);
|
|
204
|
+
const blank = { fills: [], filled: 0, unfilled: Math.max(0, size || 0), notional: 0, avgPx: null, worstPx: null, quotedPx: null, reduceOnly: Boolean(order.reduce_only) };
|
|
205
|
+
if (!isSide(order.side) || !(size > 0)) return blank;
|
|
206
|
+
|
|
207
|
+
const reducing = Boolean(order.reduce_only);
|
|
208
|
+
const ladder = reducing ? book.ladders[order.side].bids : book.ladders[order.side].asks;
|
|
209
|
+
const quotedPx = ladder.best();
|
|
210
|
+
|
|
211
|
+
const { fills, remaining, notional } = ladder.take(size, order.limit ?? null);
|
|
212
|
+
const filled = size - remaining;
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
fills,
|
|
216
|
+
filled,
|
|
217
|
+
unfilled: remaining,
|
|
218
|
+
notional,
|
|
219
|
+
avgPx: filled > 0 ? notional / filled : null,
|
|
220
|
+
worstPx: fills.length ? fills[fills.length - 1].px : null,
|
|
221
|
+
// The counterfactual the slippage panel measures against: what the whole
|
|
222
|
+
// size would have cost at the price on the screen when the order was sent.
|
|
223
|
+
quotedPx,
|
|
224
|
+
reduceOnly: reducing,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Positions, fills and what a trade earned.
|
|
2
|
+
//
|
|
3
|
+
// A binary outcome token is worth $1 if its side settles and $0 otherwise, and
|
|
4
|
+
// collateral is posted in full — so buying 500 UP at 0.54 costs $270 and
|
|
5
|
+
// returns either $500 or $0. Every number in the report is denominated in
|
|
6
|
+
// dollars of that collateral, not in contracts.
|
|
7
|
+
//
|
|
8
|
+
// Cost basis is a running average per (market, side). Not FIFO lots: the SDK
|
|
9
|
+
// exposes exactly one `average entry` through ctx.position(), and a report
|
|
10
|
+
// showing lot-level detail the strategy could not see would be describing a
|
|
11
|
+
// different position from the one it traded.
|
|
12
|
+
|
|
13
|
+
import { matchOrder, isSide } from './book.mjs';
|
|
14
|
+
|
|
15
|
+
/** Settlement value of one contract, given the official outcome. */
|
|
16
|
+
export const contractValue = (side, outcome) => (outcome === side ? 1 : 0);
|
|
17
|
+
|
|
18
|
+
const EPS = 1e-9;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One side of one market, plus the round trip currently in progress.
|
|
22
|
+
*
|
|
23
|
+
* The open position (`size`/`cost`) and the round trip (`entrySize`/
|
|
24
|
+
* `entryNotional`/`exitSize`/`exitNotional`) are tracked separately because
|
|
25
|
+
* they answer different questions: the first is what the strategy is carrying
|
|
26
|
+
* right now, the second is what the per-trade row will say once it closes. A
|
|
27
|
+
* position that is opened, partly closed and topped up again is one trade with
|
|
28
|
+
* a blended entry, and only a separate accumulator can report that honestly.
|
|
29
|
+
*/
|
|
30
|
+
class Leg {
|
|
31
|
+
constructor(side) {
|
|
32
|
+
this.side = side;
|
|
33
|
+
this.size = 0;
|
|
34
|
+
this.cost = 0;
|
|
35
|
+
this.reset();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
reset() {
|
|
39
|
+
this.entrySize = 0;
|
|
40
|
+
this.entryNotional = 0;
|
|
41
|
+
this.exitSize = 0;
|
|
42
|
+
this.exitNotional = 0;
|
|
43
|
+
this.realised = 0;
|
|
44
|
+
this.fees = 0;
|
|
45
|
+
this.entryTs = null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get avgEntry() { return this.size > EPS ? this.cost / this.size : null; }
|
|
49
|
+
|
|
50
|
+
get tradeEntryPx() { return this.entrySize > EPS ? this.entryNotional / this.entrySize : null; }
|
|
51
|
+
|
|
52
|
+
get tradeExitPx() { return this.exitSize > EPS ? this.exitNotional / this.exitSize : null; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The book of positions for one market-day.
|
|
57
|
+
*
|
|
58
|
+
* Scoped to a market on purpose: in the default mode instance state resets per
|
|
59
|
+
* market so runs can be sharded, and a portfolio spanning markets would quietly
|
|
60
|
+
* make that impossible. Session mode uses one of these across the whole range.
|
|
61
|
+
*/
|
|
62
|
+
export class Portfolio {
|
|
63
|
+
/**
|
|
64
|
+
* @param {{feeBps?:number}} opts
|
|
65
|
+
* feeBps applies to notional on entry and on exit. It is a RUN-level
|
|
66
|
+
* setting, not a strategy param: what a venue charges is not something a
|
|
67
|
+
* strategy gets to assume, and a strategy that set it to zero would be
|
|
68
|
+
* reporting its own fee holiday.
|
|
69
|
+
*/
|
|
70
|
+
constructor({ feeBps = 0 } = {}) {
|
|
71
|
+
this.feeBps = Number(feeBps) || 0;
|
|
72
|
+
/** @type {Map<string,{UP:Leg,DOWN:Leg}>} */
|
|
73
|
+
this.legs = new Map();
|
|
74
|
+
this.trades = [];
|
|
75
|
+
this.fills = [];
|
|
76
|
+
/** Realised cash flow, net of fees. Negative while a position is open. */
|
|
77
|
+
this.cash = 0;
|
|
78
|
+
this.feesPaid = 0;
|
|
79
|
+
/** Orders that could not be sized or sided — reported, never silent. */
|
|
80
|
+
this.rejected = 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#legs(marketId) {
|
|
84
|
+
let l = this.legs.get(marketId);
|
|
85
|
+
if (!l) {
|
|
86
|
+
l = { UP: new Leg('UP'), DOWN: new Leg('DOWN') };
|
|
87
|
+
this.legs.set(marketId, l);
|
|
88
|
+
}
|
|
89
|
+
return l;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
sizeOf(marketId, side) { return this.#legs(marketId)[side]?.size ?? 0; }
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What ctx.position() reports: the leg the strategy is actually carrying.
|
|
96
|
+
*
|
|
97
|
+
* When both legs are open — a hedge — the larger is reported, since a
|
|
98
|
+
* strategy asking "am I long?" is asking about exposure. `both` is set so a
|
|
99
|
+
* strategy that does hedge can tell the difference.
|
|
100
|
+
*/
|
|
101
|
+
position(marketId, book = null) {
|
|
102
|
+
const l = this.#legs(marketId);
|
|
103
|
+
const open = [l.UP, l.DOWN].filter((x) => x.size > EPS);
|
|
104
|
+
const realised = l.UP.realised + l.DOWN.realised;
|
|
105
|
+
if (open.length === 0) {
|
|
106
|
+
return { side: null, size: 0, avg_entry: null, unrealised: 0, realised, both: false };
|
|
107
|
+
}
|
|
108
|
+
const lead = open.length === 1 ? open[0] : (l.UP.size >= l.DOWN.size ? l.UP : l.DOWN);
|
|
109
|
+
// Marked against the BID, because the bid is where the position could
|
|
110
|
+
// actually be closed. Marking at the ask reports a profit that cannot be
|
|
111
|
+
// realised, which is how a paper curve beats a real one.
|
|
112
|
+
const mark = book?.bestBid(lead.side) ?? null;
|
|
113
|
+
return {
|
|
114
|
+
side: lead.side,
|
|
115
|
+
size: lead.size,
|
|
116
|
+
avg_entry: lead.avgEntry,
|
|
117
|
+
unrealised: mark == null ? 0 : (mark - lead.avgEntry) * lead.size,
|
|
118
|
+
realised,
|
|
119
|
+
both: open.length === 2,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Apply one order returned by a strategy.
|
|
125
|
+
*
|
|
126
|
+
* Returns the match result, or null when the order was not executable at all.
|
|
127
|
+
*/
|
|
128
|
+
execute({ book, order, ts, marketId, tag = null, how = 'exit' }) {
|
|
129
|
+
if (!isSide(order?.side)) { this.rejected += 1; return null; }
|
|
130
|
+
|
|
131
|
+
const leg = this.#legs(marketId)[order.side];
|
|
132
|
+
let size = Number(order.size);
|
|
133
|
+
if (!(size > 0)) { this.rejected += 1; return null; }
|
|
134
|
+
|
|
135
|
+
// reduce_only is clamped to what is open. A strategy asking to close more
|
|
136
|
+
// than it holds must not accidentally open the other way.
|
|
137
|
+
if (order.reduce_only) {
|
|
138
|
+
size = Math.min(size, leg.size);
|
|
139
|
+
if (!(size > EPS)) { this.rejected += 1; return null; }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const res = matchOrder(book, { ...order, size });
|
|
143
|
+
if (res.filled <= 0) {
|
|
144
|
+
this.fills.push(this.#fillRow({ ts, marketId, order, res, tag, realised: 0, fee: 0 }));
|
|
145
|
+
return res;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const fee = (res.notional * this.feeBps) / 10_000;
|
|
149
|
+
this.feesPaid += fee;
|
|
150
|
+
let realised = 0;
|
|
151
|
+
|
|
152
|
+
if (order.reduce_only) {
|
|
153
|
+
const basis = leg.avgEntry ?? 0;
|
|
154
|
+
realised = res.notional - basis * res.filled - fee;
|
|
155
|
+
leg.size -= res.filled;
|
|
156
|
+
leg.cost -= basis * res.filled;
|
|
157
|
+
if (leg.size <= EPS) { leg.size = 0; leg.cost = 0; }
|
|
158
|
+
leg.realised += realised;
|
|
159
|
+
leg.fees += fee;
|
|
160
|
+
leg.exitSize += res.filled;
|
|
161
|
+
leg.exitNotional += res.notional;
|
|
162
|
+
this.cash += res.notional - fee;
|
|
163
|
+
if (leg.size === 0) this.#closeTrade(marketId, leg, ts, how);
|
|
164
|
+
} else {
|
|
165
|
+
if (leg.size <= EPS && leg.entryTs == null) leg.entryTs = ts;
|
|
166
|
+
leg.size += res.filled;
|
|
167
|
+
leg.cost += res.notional;
|
|
168
|
+
leg.entrySize += res.filled;
|
|
169
|
+
leg.entryNotional += res.notional;
|
|
170
|
+
leg.fees += fee;
|
|
171
|
+
// The ENTRY fee is part of what this round trip cost, so it belongs in
|
|
172
|
+
// the trade's realised PnL. Without this line trade.pnl carried only the
|
|
173
|
+
// exit fee, net_pnl was the sum of those, and the headline figure
|
|
174
|
+
// understated costs by every entry fee in the run — a wrong number, on
|
|
175
|
+
// the first panel a paying customer looks at.
|
|
176
|
+
leg.realised -= fee;
|
|
177
|
+
this.cash -= res.notional + fee;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this.fills.push(this.#fillRow({ ts, marketId, order, res, tag, realised, fee }));
|
|
181
|
+
return res;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#fillRow({ ts, marketId, order, res, tag, realised, fee }) {
|
|
185
|
+
return {
|
|
186
|
+
ts_ms: ts,
|
|
187
|
+
market_id: marketId,
|
|
188
|
+
side: order.side,
|
|
189
|
+
action: order.reduce_only ? 'reduce' : 'open',
|
|
190
|
+
requested: Number(order.size),
|
|
191
|
+
filled: res.filled,
|
|
192
|
+
unfilled: res.unfilled,
|
|
193
|
+
avg_px: res.avgPx,
|
|
194
|
+
worst_px: res.worstPx,
|
|
195
|
+
// The price on the screen when the order was sent. quoted vs avg IS the
|
|
196
|
+
// slippage panel.
|
|
197
|
+
quoted_px: res.quotedPx,
|
|
198
|
+
levels_walked: res.fills.length,
|
|
199
|
+
fee,
|
|
200
|
+
realised,
|
|
201
|
+
tag: tag ?? order.tag ?? null,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#closeTrade(marketId, leg, ts, how, extra = {}) {
|
|
206
|
+
this.trades.push({
|
|
207
|
+
market_id: marketId,
|
|
208
|
+
side: leg.side,
|
|
209
|
+
size: leg.exitSize,
|
|
210
|
+
entry_px: leg.tradeEntryPx,
|
|
211
|
+
exit_px: leg.tradeExitPx,
|
|
212
|
+
pnl: leg.realised,
|
|
213
|
+
fees: leg.fees,
|
|
214
|
+
opened_ms: leg.entryTs,
|
|
215
|
+
closed_ms: ts,
|
|
216
|
+
how,
|
|
217
|
+
...extra,
|
|
218
|
+
});
|
|
219
|
+
leg.reset();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Settle every open leg in a market at the official outcome.
|
|
224
|
+
*
|
|
225
|
+
* Priced at $1/$0 rather than at the last book — a binary market's terminal
|
|
226
|
+
* value is a fact, not a quote. No fee: nothing is traded, the market pays
|
|
227
|
+
* out.
|
|
228
|
+
*/
|
|
229
|
+
settle(marketId, outcome, ts) {
|
|
230
|
+
const legs = this.legs.get(marketId);
|
|
231
|
+
if (!legs) return [];
|
|
232
|
+
const closed = [];
|
|
233
|
+
for (const side of ['UP', 'DOWN']) {
|
|
234
|
+
const leg = legs[side];
|
|
235
|
+
if (leg.size <= EPS) continue;
|
|
236
|
+
const value = contractValue(side, outcome) * leg.size;
|
|
237
|
+
leg.realised += value - leg.cost;
|
|
238
|
+
leg.exitSize += leg.size;
|
|
239
|
+
leg.exitNotional += value;
|
|
240
|
+
this.cash += value;
|
|
241
|
+
|
|
242
|
+
const before = this.trades.length;
|
|
243
|
+
this.#closeTrade(marketId, leg, ts, 'settled', { outcome });
|
|
244
|
+
closed.push(this.trades[before]);
|
|
245
|
+
|
|
246
|
+
leg.size = 0;
|
|
247
|
+
leg.cost = 0;
|
|
248
|
+
}
|
|
249
|
+
return closed;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Force-close every open leg against the book — what hold_s expiry does.
|
|
254
|
+
*
|
|
255
|
+
* A flatten that cannot fill (an empty bid side) leaves the position open and
|
|
256
|
+
* settlement resolves it. Reported rather than forced through at an invented
|
|
257
|
+
* price.
|
|
258
|
+
*/
|
|
259
|
+
flatten(marketId, book, ts, how = 'hold_expired') {
|
|
260
|
+
const legs = this.legs.get(marketId);
|
|
261
|
+
if (!legs) return;
|
|
262
|
+
for (const side of ['UP', 'DOWN']) {
|
|
263
|
+
const leg = legs[side];
|
|
264
|
+
if (leg.size <= EPS) continue;
|
|
265
|
+
this.execute({
|
|
266
|
+
book, ts, marketId, how, tag: how,
|
|
267
|
+
order: { side, size: leg.size, limit: null, reduce_only: true },
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Realised cash plus every open position marked to the bid.
|
|
274
|
+
*
|
|
275
|
+
* `cash` is negative by the cost of anything open, so adding the mark back is
|
|
276
|
+
* what makes this an equity figure rather than a cash figure. With no book to
|
|
277
|
+
* mark against, open positions are held at cost — never at a guess.
|
|
278
|
+
*/
|
|
279
|
+
equity(books = null) {
|
|
280
|
+
let open = 0;
|
|
281
|
+
for (const [marketId, legs] of this.legs) {
|
|
282
|
+
const book = books?.get?.(marketId) ?? null;
|
|
283
|
+
for (const side of ['UP', 'DOWN']) {
|
|
284
|
+
const leg = legs[side];
|
|
285
|
+
if (leg.size <= EPS) continue;
|
|
286
|
+
const mark = book?.bestBid(side) ?? null;
|
|
287
|
+
open += mark == null ? leg.cost : mark * leg.size;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return this.cash + open;
|
|
291
|
+
}
|
|
292
|
+
}
|