@quantform/core 0.3.234 → 0.3.241
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/dist/adapter/adapter-aggregate.js +5 -5
- package/dist/adapter/adapter-aggregate.js.map +1 -1
- package/dist/adapter/backtester/backtester-adapter.d.ts +2 -3
- package/dist/adapter/backtester/backtester-adapter.js.map +1 -1
- package/dist/adapter/backtester/backtester-streamer.d.ts +6 -0
- package/dist/adapter/backtester/backtester-streamer.js +10 -7
- package/dist/adapter/backtester/backtester-streamer.js.map +1 -1
- package/dist/adapter/backtester/backtester-streamer.spec.js +9 -7
- package/dist/adapter/backtester/backtester-streamer.spec.js.map +1 -1
- package/dist/adapter/paper/paper-adapter.js +2 -2
- package/dist/adapter/paper/paper-adapter.js.map +1 -1
- package/dist/domain/asset.d.ts +3 -3
- package/dist/domain/asset.js +8 -8
- package/dist/domain/asset.js.map +1 -1
- package/dist/domain/asset.spec.js +4 -4
- package/dist/domain/asset.spec.js.map +1 -1
- package/dist/domain/instrument.d.ts +1 -1
- package/dist/domain/instrument.js +6 -6
- package/dist/domain/instrument.js.map +1 -1
- package/dist/domain/instrument.spec.js +7 -7
- package/dist/domain/instrument.spec.js.map +1 -1
- package/dist/domain/orderbook.d.ts +0 -1
- package/dist/ipc.d.ts +4 -1
- package/dist/ipc.js +50 -17
- package/dist/ipc.js.map +1 -1
- package/dist/ipc.spec.js +28 -0
- package/dist/ipc.spec.js.map +1 -1
- package/dist/session/session.js +7 -7
- package/dist/session/session.js.map +1 -1
- package/dist/store/event/store-instrument.event.js +1 -1
- package/dist/store/event/store-instrument.event.js.map +1 -1
- package/dist/tests/backtester-adapter.spec.js +7 -5
- package/dist/tests/backtester-adapter.spec.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/adapter/adapter-aggregate.ts +5 -5
- package/src/adapter/backtester/backtester-adapter.ts +2 -3
- package/src/adapter/backtester/backtester-streamer.spec.ts +9 -7
- package/src/adapter/backtester/backtester-streamer.ts +36 -7
- package/src/adapter/paper/paper-adapter.ts +2 -2
- package/src/domain/asset.spec.ts +4 -4
- package/src/domain/asset.ts +9 -9
- package/src/domain/instrument.spec.ts +7 -7
- package/src/domain/instrument.ts +6 -6
- package/src/domain/orderbook.ts +1 -1
- package/src/ipc.spec.ts +30 -2
- package/src/ipc.ts +51 -22
- package/src/session/session.ts +7 -7
- package/src/store/event/store-instrument.event.ts +1 -1
- package/src/tests/backtester-adapter.spec.ts +7 -5
|
@@ -43,13 +43,13 @@ export class PaperAdapter extends Adapter {
|
|
|
43
43
|
@handler(AdapterAccountCommand)
|
|
44
44
|
onAccount(event: AdapterAccountCommand, context: AdapterContext) {
|
|
45
45
|
let subscribed = Object.values(this.store.snapshot.subscription.asset).filter(
|
|
46
|
-
it => it.
|
|
46
|
+
it => it.adapter == this.name
|
|
47
47
|
);
|
|
48
48
|
|
|
49
49
|
for (const balance in this.options.balance) {
|
|
50
50
|
const asset = assetOf(balance);
|
|
51
51
|
|
|
52
|
-
if (asset.
|
|
52
|
+
if (asset.adapter != this.name) {
|
|
53
53
|
continue;
|
|
54
54
|
}
|
|
55
55
|
|
package/src/domain/asset.spec.ts
CHANGED
|
@@ -5,7 +5,7 @@ describe('asset tests', () => {
|
|
|
5
5
|
const sut = new Asset('abc', 'xyz', 4);
|
|
6
6
|
|
|
7
7
|
expect(sut.name).toEqual('abc');
|
|
8
|
-
expect(sut.
|
|
8
|
+
expect(sut.adapter).toEqual('xyz');
|
|
9
9
|
expect(sut.scale).toEqual(4);
|
|
10
10
|
expect(sut.tickSize).toEqual(0.0001);
|
|
11
11
|
expect(sut.fixed(1.1234567)).toEqual(1.1234);
|
|
@@ -20,7 +20,7 @@ describe('asset selector tests', () => {
|
|
|
20
20
|
const sut = assetOf('xyz:abc');
|
|
21
21
|
|
|
22
22
|
expect(sut.name).toEqual('abc');
|
|
23
|
-
expect(sut.
|
|
23
|
+
expect(sut.adapter).toEqual('xyz');
|
|
24
24
|
expect(sut.toString()).toEqual('xyz:abc');
|
|
25
25
|
});
|
|
26
26
|
|
|
@@ -28,7 +28,7 @@ describe('asset selector tests', () => {
|
|
|
28
28
|
const sut = assetOf('XYZ:ABC');
|
|
29
29
|
|
|
30
30
|
expect(sut.name).toEqual('abc');
|
|
31
|
-
expect(sut.
|
|
31
|
+
expect(sut.adapter).toEqual('xyz');
|
|
32
32
|
expect(sut.toString()).toEqual('xyz:abc');
|
|
33
33
|
});
|
|
34
34
|
|
|
@@ -56,7 +56,7 @@ describe('asset selector tests', () => {
|
|
|
56
56
|
expect(fn).toThrow(Error);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
test('should throw invalid format message for missing
|
|
59
|
+
test('should throw invalid format message for missing adapter name', () => {
|
|
60
60
|
const fn = () => {
|
|
61
61
|
assetOf(':abc');
|
|
62
62
|
};
|
package/src/domain/asset.ts
CHANGED
|
@@ -7,13 +7,13 @@ export class AssetSelector {
|
|
|
7
7
|
private readonly id: string;
|
|
8
8
|
|
|
9
9
|
readonly name: string;
|
|
10
|
-
readonly
|
|
10
|
+
readonly adapter: string;
|
|
11
11
|
|
|
12
|
-
constructor(name: string,
|
|
12
|
+
constructor(name: string, adapter: string) {
|
|
13
13
|
this.name = name.toLowerCase();
|
|
14
|
-
this.
|
|
14
|
+
this.adapter = adapter.toLowerCase();
|
|
15
15
|
|
|
16
|
-
this.id = `${this.
|
|
16
|
+
this.id = `${this.adapter}:${this.name}`;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -35,13 +35,13 @@ export function assetOf(asset: string): AssetSelector {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
const assetName = section[1];
|
|
38
|
-
const
|
|
38
|
+
const adapterName = section[0];
|
|
39
39
|
|
|
40
|
-
if (assetName.length == 0 ||
|
|
40
|
+
if (assetName.length == 0 || adapterName.length == 0) {
|
|
41
41
|
throw Error('invalid asset format');
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
return new AssetSelector(assetName,
|
|
44
|
+
return new AssetSelector(assetName, adapterName);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
@@ -51,8 +51,8 @@ export function assetOf(asset: string): AssetSelector {
|
|
|
51
51
|
export class Asset extends AssetSelector {
|
|
52
52
|
readonly tickSize: number;
|
|
53
53
|
|
|
54
|
-
constructor(name: string,
|
|
55
|
-
super(name,
|
|
54
|
+
constructor(name: string, adapter: string, public readonly scale: number) {
|
|
55
|
+
super(name, adapter);
|
|
56
56
|
|
|
57
57
|
this.tickSize = 1.0 / Math.pow(10, this.scale);
|
|
58
58
|
}
|
|
@@ -10,9 +10,9 @@ describe('instrument tests', () => {
|
|
|
10
10
|
);
|
|
11
11
|
|
|
12
12
|
expect(sut.base.name).toEqual('abc');
|
|
13
|
-
expect(sut.base.
|
|
13
|
+
expect(sut.base.adapter).toEqual('xyz');
|
|
14
14
|
expect(sut.quote.name).toEqual('def');
|
|
15
|
-
expect(sut.quote.
|
|
15
|
+
expect(sut.quote.adapter).toEqual('xyz');
|
|
16
16
|
expect(sut.toString()).toEqual('xyz:abc-def');
|
|
17
17
|
});
|
|
18
18
|
});
|
|
@@ -22,9 +22,9 @@ describe('instrument selector tests', () => {
|
|
|
22
22
|
const sut = instrumentOf('xyz:abc-def');
|
|
23
23
|
|
|
24
24
|
expect(sut.base.name).toEqual('abc');
|
|
25
|
-
expect(sut.base.
|
|
25
|
+
expect(sut.base.adapter).toEqual('xyz');
|
|
26
26
|
expect(sut.quote.name).toEqual('def');
|
|
27
|
-
expect(sut.quote.
|
|
27
|
+
expect(sut.quote.adapter).toEqual('xyz');
|
|
28
28
|
expect(sut.toString()).toEqual('xyz:abc-def');
|
|
29
29
|
});
|
|
30
30
|
|
|
@@ -32,9 +32,9 @@ describe('instrument selector tests', () => {
|
|
|
32
32
|
const sut = instrumentOf('XYZ:ABC-DEF');
|
|
33
33
|
|
|
34
34
|
expect(sut.base.name).toEqual('abc');
|
|
35
|
-
expect(sut.base.
|
|
35
|
+
expect(sut.base.adapter).toEqual('xyz');
|
|
36
36
|
expect(sut.quote.name).toEqual('def');
|
|
37
|
-
expect(sut.quote.
|
|
37
|
+
expect(sut.quote.adapter).toEqual('xyz');
|
|
38
38
|
expect(sut.toString()).toEqual('xyz:abc-def');
|
|
39
39
|
});
|
|
40
40
|
|
|
@@ -62,7 +62,7 @@ describe('instrument selector tests', () => {
|
|
|
62
62
|
expect(fn).toThrow(Error);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
test('should throw invalid format message for missing
|
|
65
|
+
test('should throw invalid format message for missing adapter name', () => {
|
|
66
66
|
const fn = () => {
|
|
67
67
|
assetOf(':abc-def');
|
|
68
68
|
};
|
package/src/domain/instrument.ts
CHANGED
|
@@ -9,9 +9,9 @@ export class InstrumentSelector {
|
|
|
9
9
|
readonly base: AssetSelector;
|
|
10
10
|
readonly quote: AssetSelector;
|
|
11
11
|
|
|
12
|
-
constructor(base: string, quote: string,
|
|
13
|
-
this.base = new AssetSelector(base.toLowerCase(),
|
|
14
|
-
this.quote = new AssetSelector(quote.toLowerCase(),
|
|
12
|
+
constructor(base: string, quote: string, adapter: string) {
|
|
13
|
+
this.base = new AssetSelector(base.toLowerCase(), adapter.toLowerCase());
|
|
14
|
+
this.quote = new AssetSelector(quote.toLowerCase(), adapter.toLowerCase());
|
|
15
15
|
|
|
16
16
|
this.id = `${this.base.toString()}-${this.quote.name}`;
|
|
17
17
|
}
|
|
@@ -31,10 +31,10 @@ export class Instrument extends InstrumentSelector implements Component {
|
|
|
31
31
|
leverage?: number = null;
|
|
32
32
|
|
|
33
33
|
constructor(readonly base: Asset, readonly quote: Asset, readonly raw: string) {
|
|
34
|
-
super(base.name, quote.name, base.
|
|
34
|
+
super(base.name, quote.name, base.adapter);
|
|
35
35
|
|
|
36
|
-
if (base.
|
|
37
|
-
throw new Error('
|
|
36
|
+
if (base.adapter != quote.adapter) {
|
|
37
|
+
throw new Error('Adapter mismatch!');
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/domain/orderbook.ts
CHANGED
package/src/ipc.spec.ts
CHANGED
|
@@ -3,11 +3,13 @@ import {
|
|
|
3
3
|
Adapter,
|
|
4
4
|
AdapterFeedCommand,
|
|
5
5
|
AdapterAwakeCommand,
|
|
6
|
-
AdapterAccountCommand
|
|
6
|
+
AdapterAccountCommand,
|
|
7
|
+
AdapterSubscribeCommand,
|
|
8
|
+
AdapterDisposeCommand
|
|
7
9
|
} from './adapter';
|
|
8
10
|
import { PaperAdapter, PaperSpotExecutor } from './adapter/paper';
|
|
9
11
|
import { PaperExecutor } from './adapter/paper/executor/paper-executor';
|
|
10
|
-
import {
|
|
12
|
+
import { ipcSub, run } from './ipc';
|
|
11
13
|
import { Feed, InMemoryStorage } from './storage';
|
|
12
14
|
import { instrumentOf } from './domain';
|
|
13
15
|
import { handler } from './shared';
|
|
@@ -26,6 +28,12 @@ class DefaultAdapter extends Adapter {
|
|
|
26
28
|
@handler(AdapterAwakeCommand)
|
|
27
29
|
onAwake(command: AdapterAwakeCommand) {}
|
|
28
30
|
|
|
31
|
+
@handler(AdapterDisposeCommand)
|
|
32
|
+
onDispose(command: AdapterDisposeCommand) {}
|
|
33
|
+
|
|
34
|
+
@handler(AdapterSubscribeCommand)
|
|
35
|
+
onSubscribe(command: AdapterSubscribeCommand) {}
|
|
36
|
+
|
|
29
37
|
@handler(AdapterAccountCommand)
|
|
30
38
|
onAccount(command: AdapterAccountCommand) {}
|
|
31
39
|
|
|
@@ -53,4 +61,24 @@ describe('ipc feed tests', () => {
|
|
|
53
61
|
|
|
54
62
|
expect(session.descriptor).toBeUndefined();
|
|
55
63
|
});
|
|
64
|
+
|
|
65
|
+
test('should dispatch session started event', done => {
|
|
66
|
+
const command = {
|
|
67
|
+
type: 'paper',
|
|
68
|
+
balance: { 'default:usd': 100 }
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
ipcSub.on('message', (message: any) => {
|
|
72
|
+
expect(message.type).toBe('paper:started');
|
|
73
|
+
done();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
run(
|
|
77
|
+
{
|
|
78
|
+
adapter: [new DefaultAdapter()],
|
|
79
|
+
describe: (session: Session) => session.trade(instrumentOf('default:btc-usdt'))
|
|
80
|
+
},
|
|
81
|
+
command
|
|
82
|
+
);
|
|
83
|
+
});
|
|
56
84
|
});
|
package/src/ipc.ts
CHANGED
|
@@ -4,8 +4,12 @@ import { instrumentOf } from './domain';
|
|
|
4
4
|
import { Topic, event, handler } from './shared/topic';
|
|
5
5
|
import { Logger } from './shared';
|
|
6
6
|
import { backtest, idle, live, paper } from './bin';
|
|
7
|
+
import { BacktesterStreamer } from './adapter/backtester';
|
|
8
|
+
import { EventEmitter } from 'events';
|
|
7
9
|
import minimist = require('minimist');
|
|
8
10
|
|
|
11
|
+
export const ipcSub = new EventEmitter();
|
|
12
|
+
|
|
9
13
|
/**
|
|
10
14
|
* Base command/query interface for IPC communication.
|
|
11
15
|
*/
|
|
@@ -75,7 +79,7 @@ export class IpcBacktestCommand implements IpcCommand {
|
|
|
75
79
|
@event
|
|
76
80
|
export class IpcUniverseQuery implements IpcCommand {
|
|
77
81
|
type = 'universe';
|
|
78
|
-
|
|
82
|
+
adapter: string;
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
/**
|
|
@@ -127,6 +131,11 @@ class IpcHandler extends Topic<{ type: string }, IpcSessionAccessor> {
|
|
|
127
131
|
|
|
128
132
|
accessor.session = live(this.descriptor);
|
|
129
133
|
|
|
134
|
+
this.notify({
|
|
135
|
+
type: 'live:started',
|
|
136
|
+
session: accessor.session.descriptor?.id
|
|
137
|
+
});
|
|
138
|
+
|
|
130
139
|
await accessor.session.awake();
|
|
131
140
|
}
|
|
132
141
|
|
|
@@ -143,6 +152,11 @@ class IpcHandler extends Topic<{ type: string }, IpcSessionAccessor> {
|
|
|
143
152
|
balance: command.balance
|
|
144
153
|
});
|
|
145
154
|
|
|
155
|
+
this.notify({
|
|
156
|
+
type: 'paper:started',
|
|
157
|
+
session: accessor.session.descriptor?.id
|
|
158
|
+
});
|
|
159
|
+
|
|
146
160
|
await accessor.session.awake();
|
|
147
161
|
}
|
|
148
162
|
|
|
@@ -156,28 +170,43 @@ class IpcHandler extends Topic<{ type: string }, IpcSessionAccessor> {
|
|
|
156
170
|
from: command.from,
|
|
157
171
|
to: command.to,
|
|
158
172
|
balance: command.balance,
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
listener: {
|
|
174
|
+
onBacktestStarted: (streamer: BacktesterStreamer) => {
|
|
175
|
+
this.notify({
|
|
176
|
+
type: 'backtest:started',
|
|
177
|
+
session: session.descriptor?.id,
|
|
178
|
+
timestamp: streamer.timestamp,
|
|
179
|
+
from: command.from,
|
|
180
|
+
to: command.to
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
onBacktestUpdated: (streamer: BacktesterStreamer) => {
|
|
184
|
+
this.notify({
|
|
185
|
+
type: 'backtest:updated',
|
|
186
|
+
session: session.descriptor?.id,
|
|
187
|
+
timestamp: streamer.timestamp,
|
|
188
|
+
from: command.from,
|
|
189
|
+
to: command.to
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
onBacktestCompleted: async (streamer: BacktesterStreamer) => {
|
|
193
|
+
await accessor.session.dispose();
|
|
194
|
+
|
|
195
|
+
this.notify({
|
|
196
|
+
type: 'backtest:completed',
|
|
197
|
+
session: session.descriptor?.id,
|
|
198
|
+
timestamp: streamer.timestamp,
|
|
199
|
+
from: command.from,
|
|
200
|
+
to: command.to
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
resolve();
|
|
204
|
+
}
|
|
174
205
|
}
|
|
175
206
|
});
|
|
176
207
|
|
|
177
208
|
accessor.session = session;
|
|
178
209
|
|
|
179
|
-
this.notify({ type: 'backtest:started' });
|
|
180
|
-
|
|
181
210
|
await accessor.session.awake();
|
|
182
211
|
await streamer.tryContinue().catch(it => Logger.error(it));
|
|
183
212
|
});
|
|
@@ -204,7 +233,7 @@ class IpcHandler extends Topic<{ type: string }, IpcSessionAccessor> {
|
|
|
204
233
|
this.notify({ type: 'feed:started' });
|
|
205
234
|
|
|
206
235
|
await accessor.session.aggregate.dispatch(
|
|
207
|
-
instrument.base.
|
|
236
|
+
instrument.base.adapter,
|
|
208
237
|
new AdapterFeedCommand(
|
|
209
238
|
instrument,
|
|
210
239
|
command.from,
|
|
@@ -229,11 +258,11 @@ class IpcHandler extends Topic<{ type: string }, IpcSessionAccessor> {
|
|
|
229
258
|
* Sends a message to parent process.
|
|
230
259
|
*/
|
|
231
260
|
private notify(message: any) {
|
|
232
|
-
if (
|
|
233
|
-
|
|
261
|
+
if (process.send) {
|
|
262
|
+
process.send(message);
|
|
234
263
|
}
|
|
235
264
|
|
|
236
|
-
|
|
265
|
+
ipcSub.emit('message', message);
|
|
237
266
|
}
|
|
238
267
|
}
|
|
239
268
|
|
package/src/session/session.ts
CHANGED
|
@@ -151,12 +151,12 @@ export class Session {
|
|
|
151
151
|
const grouped = instrument
|
|
152
152
|
.filter(it => it != null)
|
|
153
153
|
.reduce((aggregate, it) => {
|
|
154
|
-
const
|
|
154
|
+
const adapter = it.base.adapter;
|
|
155
155
|
|
|
156
|
-
if (aggregate[
|
|
157
|
-
aggregate[
|
|
156
|
+
if (aggregate[adapter]) {
|
|
157
|
+
aggregate[adapter].push(it);
|
|
158
158
|
} else {
|
|
159
|
-
aggregate[
|
|
159
|
+
aggregate[adapter] = [it];
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
return aggregate;
|
|
@@ -176,7 +176,7 @@ export class Session {
|
|
|
176
176
|
await Promise.all(
|
|
177
177
|
orders.map(it =>
|
|
178
178
|
this.aggregate.dispatch<AdapterOrderOpenCommand, void>(
|
|
179
|
-
it.instrument.base.
|
|
179
|
+
it.instrument.base.adapter,
|
|
180
180
|
new AdapterOrderOpenCommand(it)
|
|
181
181
|
)
|
|
182
182
|
)
|
|
@@ -188,7 +188,7 @@ export class Session {
|
|
|
188
188
|
*/
|
|
189
189
|
cancel(order: Order): Promise<void> {
|
|
190
190
|
return this.aggregate.dispatch(
|
|
191
|
-
order.instrument.base.
|
|
191
|
+
order.instrument.base.adapter,
|
|
192
192
|
new AdapterOrderCancelCommand(order)
|
|
193
193
|
);
|
|
194
194
|
}
|
|
@@ -375,7 +375,7 @@ export class Session {
|
|
|
375
375
|
switchMap(() =>
|
|
376
376
|
from(
|
|
377
377
|
this.aggregate.dispatch<AdapterHistoryQuery, Candle[]>(
|
|
378
|
-
selector.base.
|
|
378
|
+
selector.base.adapter,
|
|
379
379
|
new AdapterHistoryQuery(selector, timeframe, length)
|
|
380
380
|
)
|
|
381
381
|
)
|
|
@@ -23,7 +23,7 @@ export function InstrumentPatchEventHandler(event: InstrumentPatchEvent, state:
|
|
|
23
23
|
const selector = new InstrumentSelector(
|
|
24
24
|
event.base.name,
|
|
25
25
|
event.quote.name,
|
|
26
|
-
event.base.
|
|
26
|
+
event.base.adapter
|
|
27
27
|
);
|
|
28
28
|
|
|
29
29
|
let instrument = state.universe.instrument[selector.toString()];
|
|
@@ -69,12 +69,14 @@ describe('backtester adapter tests', () => {
|
|
|
69
69
|
},
|
|
70
70
|
from: 0,
|
|
71
71
|
to: 100,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
listener: {
|
|
73
|
+
onBacktestCompleted: () => {
|
|
74
|
+
expect(store.snapshot.timestamp).toEqual(1);
|
|
75
|
+
expect(store.snapshot.trade[instrument.toString()].rate).toEqual(100);
|
|
76
|
+
expect(store.snapshot.trade[instrument.toString()].quantity).toEqual(10);
|
|
76
77
|
|
|
77
|
-
|
|
78
|
+
done();
|
|
79
|
+
}
|
|
78
80
|
}
|
|
79
81
|
});
|
|
80
82
|
|