@tradejs/app 2.0.1 → 2.0.3
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/package.json +7 -7
- package/src/app/components/Dashboard/KlineChart/hooks/index.ts +3 -2
- package/src/app/components/Dashboard/KlineChart/hooks/useDashboardSignal.ts +83 -0
- package/src/app/components/Dashboard/KlineChart/hooks/useSetup.ts +38 -23
- package/src/app/components/Dashboard/KlineChart/hooks/useSignal.ts +52 -32
- package/src/app/components/Dashboard/KlineChart/index.tsx +65 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tradejs/app",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "Installable Next.js UI for the TradeJS TypeScript framework: dashboards, backtests, charts, and runtime data.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"tradejs",
|
|
@@ -51,12 +51,12 @@
|
|
|
51
51
|
"@emotion/react": "^11.14.0",
|
|
52
52
|
"@langchain/core": "^1.1.42",
|
|
53
53
|
"@langchain/openai": "^1.4.5",
|
|
54
|
-
"@tradejs/connectors": "^2.0.
|
|
55
|
-
"@tradejs/core": "^2.0.
|
|
56
|
-
"@tradejs/indicators": "^2.0.
|
|
57
|
-
"@tradejs/infra": "^2.0.
|
|
58
|
-
"@tradejs/node": "^2.0.
|
|
59
|
-
"@tradejs/types": "^2.0.
|
|
54
|
+
"@tradejs/connectors": "^2.0.3",
|
|
55
|
+
"@tradejs/core": "^2.0.3",
|
|
56
|
+
"@tradejs/indicators": "^2.0.3",
|
|
57
|
+
"@tradejs/infra": "^2.0.3",
|
|
58
|
+
"@tradejs/node": "^2.0.3",
|
|
59
|
+
"@tradejs/types": "^2.0.3",
|
|
60
60
|
"@types/bcryptjs": "2.4.6",
|
|
61
61
|
"@types/lodash": "4.14.202",
|
|
62
62
|
"@types/node": "24.13.3",
|
|
@@ -7,8 +7,9 @@ export { useVolIndicator } from './useVolIndicator';
|
|
|
7
7
|
export { useBtcIndicator } from './useBtcIndicator';
|
|
8
8
|
export { useBtcCorrelation } from './useBtcCorrelation';
|
|
9
9
|
export { useSpreadIndicator } from './useSpreadIndicator';
|
|
10
|
-
export {
|
|
10
|
+
export { useDashboardSignal } from './useDashboardSignal';
|
|
11
|
+
export { useSignalFigures } from './useSignal';
|
|
11
12
|
export { useBacktest } from './useBacktest';
|
|
12
13
|
export { useSupportResistanceLines } from './useSupportResistanceLines';
|
|
13
14
|
export { useResize } from './useResize';
|
|
14
|
-
export {
|
|
15
|
+
export { useTradeSetup } from './useSetup';
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import { getSignal } from '#actions/signal';
|
|
5
|
+
import type { Signal } from '@tradejs/types';
|
|
6
|
+
|
|
7
|
+
export type DashboardSignalStatus =
|
|
8
|
+
| 'idle'
|
|
9
|
+
| 'loading'
|
|
10
|
+
| 'loaded'
|
|
11
|
+
| 'missing'
|
|
12
|
+
| 'error';
|
|
13
|
+
|
|
14
|
+
type DashboardSignalState = {
|
|
15
|
+
queryKey: string | null;
|
|
16
|
+
signal: Signal | null;
|
|
17
|
+
status: DashboardSignalStatus;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const INITIAL_STATE: DashboardSignalState = {
|
|
21
|
+
queryKey: null,
|
|
22
|
+
signal: null,
|
|
23
|
+
status: 'idle',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const useDashboardSignal = ({
|
|
27
|
+
symbol,
|
|
28
|
+
signalId,
|
|
29
|
+
}: {
|
|
30
|
+
symbol: string;
|
|
31
|
+
signalId: string | null;
|
|
32
|
+
}) => {
|
|
33
|
+
const queryKey = useMemo(
|
|
34
|
+
() => (symbol && signalId ? `${symbol}:${signalId}` : null),
|
|
35
|
+
[signalId, symbol],
|
|
36
|
+
);
|
|
37
|
+
const [state, setState] = useState<DashboardSignalState>(INITIAL_STATE);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
let active = true;
|
|
41
|
+
|
|
42
|
+
if (!queryKey || !signalId) {
|
|
43
|
+
setState(INITIAL_STATE);
|
|
44
|
+
return () => {
|
|
45
|
+
active = false;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
setState({ queryKey, signal: null, status: 'loading' });
|
|
50
|
+
|
|
51
|
+
void getSignal(symbol, signalId)
|
|
52
|
+
.then((signal) => {
|
|
53
|
+
if (!active) return;
|
|
54
|
+
setState({
|
|
55
|
+
queryKey,
|
|
56
|
+
signal,
|
|
57
|
+
status: signal ? 'loaded' : 'missing',
|
|
58
|
+
});
|
|
59
|
+
})
|
|
60
|
+
.catch(() => {
|
|
61
|
+
if (!active) return;
|
|
62
|
+
setState({ queryKey, signal: null, status: 'error' });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
active = false;
|
|
67
|
+
};
|
|
68
|
+
}, [queryKey, signalId, symbol]);
|
|
69
|
+
|
|
70
|
+
if (!queryKey) {
|
|
71
|
+
return INITIAL_STATE;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (state.queryKey !== queryKey) {
|
|
75
|
+
return {
|
|
76
|
+
queryKey,
|
|
77
|
+
signal: null,
|
|
78
|
+
status: 'loading' as const,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return state;
|
|
83
|
+
};
|
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
1
|
import { useEffect, useMemo, useState } from 'react';
|
|
4
|
-
import { useSearchParams } from 'next/navigation';
|
|
5
2
|
import { Chart, registerOverlay } from 'klinecharts';
|
|
6
|
-
import { getSignal } from '#actions/signal';
|
|
7
3
|
import { toMs } from '@tradejs/core/time';
|
|
8
|
-
import { Signal } from '@tradejs/types';
|
|
4
|
+
import type { Signal } from '@tradejs/types';
|
|
9
5
|
import { createTradeZonePointFigure } from '../figures/tradeZonePointFigure';
|
|
10
6
|
|
|
11
7
|
const SETUP = 'Setup';
|
|
@@ -15,20 +11,21 @@ const FALLBACK_WIDTH_MS = 24 * 60 * 60_000;
|
|
|
15
11
|
|
|
16
12
|
type Point = { timestamp: number; value: number };
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const symbol = chart?.getSymbol()?.ticker || '';
|
|
14
|
+
type RenderedTradeSetup = {
|
|
15
|
+
chart: Chart;
|
|
16
|
+
signalId: string;
|
|
17
|
+
};
|
|
24
18
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
19
|
+
export const useTradeSetup = ({
|
|
20
|
+
chart,
|
|
21
|
+
enabled,
|
|
22
|
+
signal,
|
|
23
|
+
}: {
|
|
24
|
+
chart: Chart | null;
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
signal: Signal | null;
|
|
27
|
+
}) => {
|
|
28
|
+
const [rendered, setRendered] = useState<RenderedTradeSetup | null>(null);
|
|
32
29
|
|
|
33
30
|
useEffect(() => {
|
|
34
31
|
registerOverlay({
|
|
@@ -80,10 +77,16 @@ export const useSetup = (chart: Chart | null, enabled: boolean) => {
|
|
|
80
77
|
}, [signal]);
|
|
81
78
|
|
|
82
79
|
useEffect(() => {
|
|
83
|
-
if (!chart || !enabled || !
|
|
80
|
+
if (!chart || !enabled || !signal || !setupPoints) {
|
|
81
|
+
setRendered(null);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
84
|
|
|
85
85
|
const currentSymbol = chart.getSymbol()?.ticker;
|
|
86
|
-
if (signal.symbol !== currentSymbol)
|
|
86
|
+
if (signal.symbol !== currentSymbol) {
|
|
87
|
+
setRendered(null);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
87
90
|
|
|
88
91
|
const tpId = `${signal.signalId}-tp`;
|
|
89
92
|
const slId = `${signal.signalId}-sl`;
|
|
@@ -111,12 +114,24 @@ export const useSetup = (chart: Chart | null, enabled: boolean) => {
|
|
|
111
114
|
points: [setupPoints.start],
|
|
112
115
|
});
|
|
113
116
|
|
|
117
|
+
setRendered((current) =>
|
|
118
|
+
current?.chart === chart && current.signalId === signal.signalId
|
|
119
|
+
? current
|
|
120
|
+
: { chart, signalId: signal.signalId },
|
|
121
|
+
);
|
|
122
|
+
|
|
114
123
|
return () => {
|
|
115
124
|
chart.removeOverlay({ id: tpId, name: SETUP });
|
|
116
125
|
chart.removeOverlay({ id: slId, name: SETUP });
|
|
117
126
|
chart.removeOverlay({ name: SETUP_START });
|
|
118
127
|
};
|
|
119
|
-
}, [chart, enabled,
|
|
120
|
-
|
|
121
|
-
return
|
|
128
|
+
}, [chart, enabled, signal, setupPoints]);
|
|
129
|
+
|
|
130
|
+
return Boolean(
|
|
131
|
+
enabled &&
|
|
132
|
+
chart &&
|
|
133
|
+
signal &&
|
|
134
|
+
rendered?.chart === chart &&
|
|
135
|
+
rendered.signalId === signal.signalId,
|
|
136
|
+
);
|
|
122
137
|
};
|
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
1
|
import { useEffect, useState } from 'react';
|
|
4
|
-
import { useSearchParams } from 'next/navigation';
|
|
5
|
-
import _ from 'lodash';
|
|
6
2
|
import { Chart } from 'klinecharts';
|
|
7
|
-
import {
|
|
8
|
-
import { Signal } from '@tradejs/types';
|
|
3
|
+
import type { Signal } from '@tradejs/types';
|
|
9
4
|
import {
|
|
10
5
|
drawSignalFigures,
|
|
11
6
|
ensureBaseFigureOverlaysRegistered,
|
|
@@ -37,49 +32,74 @@ const fitKeepRightZoom = (chart: Chart, lastDataTsMs: number) => {
|
|
|
37
32
|
chart.setOffsetRightDistance?.(rightOffsetPx);
|
|
38
33
|
};
|
|
39
34
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const autoZoom = Boolean(searchParams.get('autoZoom')) ?? false;
|
|
35
|
+
type RenderedSignalFigures = {
|
|
36
|
+
chart: Chart;
|
|
37
|
+
signalId: string;
|
|
38
|
+
};
|
|
45
39
|
|
|
46
|
-
|
|
47
|
-
|
|
40
|
+
export const useSignalFigures = ({
|
|
41
|
+
chart,
|
|
42
|
+
lastDataTimestamp,
|
|
43
|
+
enabled,
|
|
44
|
+
signal,
|
|
45
|
+
autoZoom,
|
|
46
|
+
}: {
|
|
47
|
+
chart: Chart | null;
|
|
48
|
+
lastDataTimestamp: number | null;
|
|
49
|
+
enabled: boolean;
|
|
50
|
+
signal: Signal | null;
|
|
51
|
+
autoZoom: boolean;
|
|
52
|
+
}) => {
|
|
53
|
+
const [rendered, setRendered] = useState<RenderedSignalFigures | null>(null);
|
|
48
54
|
|
|
49
55
|
useEffect(() => {
|
|
50
|
-
if (!
|
|
51
|
-
|
|
56
|
+
if (!chart || !enabled || lastDataTimestamp == null || !signal) {
|
|
57
|
+
setRendered(null);
|
|
52
58
|
return;
|
|
53
59
|
}
|
|
54
|
-
getSignal(symbol, signalId).then(setSignal);
|
|
55
|
-
}, [signalId, symbol]);
|
|
56
|
-
|
|
57
|
-
useEffect(() => {
|
|
58
|
-
if (!chart || !enabled || !data || _.isEmpty(data) || !signal) return;
|
|
59
60
|
|
|
60
61
|
const currentSymbol = chart.getSymbol()?.ticker;
|
|
61
|
-
if (signal.symbol !== currentSymbol)
|
|
62
|
+
if (signal.symbol !== currentSymbol) {
|
|
63
|
+
setRendered(null);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
62
66
|
|
|
63
67
|
const normalized = normalizeSignalFigures(signal);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
const overlays = normalized
|
|
69
|
+
? (() => {
|
|
70
|
+
ensureBaseFigureOverlaysRegistered();
|
|
71
|
+
|
|
72
|
+
return drawSignalFigures({
|
|
73
|
+
chart,
|
|
74
|
+
idPrefix: `signal-${signal.signalId}`,
|
|
75
|
+
figures: normalized,
|
|
76
|
+
});
|
|
77
|
+
})()
|
|
78
|
+
: [];
|
|
73
79
|
|
|
74
80
|
if (autoZoom) {
|
|
75
|
-
const lastDataTsMs = toMs(
|
|
81
|
+
const lastDataTsMs = toMs(lastDataTimestamp);
|
|
76
82
|
if (Number.isFinite(lastDataTsMs)) {
|
|
77
83
|
fitKeepRightZoom(chart, lastDataTsMs);
|
|
78
84
|
}
|
|
79
85
|
}
|
|
80
86
|
|
|
87
|
+
setRendered((current) =>
|
|
88
|
+
current?.chart === chart && current.signalId === signal.signalId
|
|
89
|
+
? current
|
|
90
|
+
: { chart, signalId: signal.signalId },
|
|
91
|
+
);
|
|
92
|
+
|
|
81
93
|
return () => {
|
|
82
94
|
removeSignalFigures(chart, overlays);
|
|
83
95
|
};
|
|
84
|
-
}, [autoZoom, chart,
|
|
96
|
+
}, [autoZoom, chart, enabled, lastDataTimestamp, signal]);
|
|
97
|
+
|
|
98
|
+
return Boolean(
|
|
99
|
+
enabled &&
|
|
100
|
+
chart &&
|
|
101
|
+
signal &&
|
|
102
|
+
rendered?.chart === chart &&
|
|
103
|
+
rendered.signalId === signal.signalId,
|
|
104
|
+
);
|
|
85
105
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useEffect, useRef } from 'react';
|
|
3
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useSearchParams } from 'next/navigation';
|
|
4
5
|
import _ from 'lodash';
|
|
5
6
|
import {
|
|
6
7
|
init,
|
|
@@ -20,11 +21,12 @@ import {
|
|
|
20
21
|
useBtcIndicator,
|
|
21
22
|
useBtcCorrelation,
|
|
22
23
|
useSpreadIndicator,
|
|
23
|
-
|
|
24
|
+
useDashboardSignal,
|
|
25
|
+
useSignalFigures,
|
|
24
26
|
useBacktest,
|
|
25
27
|
useSupportResistanceLines,
|
|
26
28
|
useResize,
|
|
27
|
-
|
|
29
|
+
useTradeSetup,
|
|
28
30
|
} from './hooks';
|
|
29
31
|
import { usePluginIndicators } from './hooks/usePluginIndicators';
|
|
30
32
|
import { IndicatorRendererConfig, useData } from '#store';
|
|
@@ -45,31 +47,49 @@ export const KlineChart = ({
|
|
|
45
47
|
indicatorRenderers,
|
|
46
48
|
live = true,
|
|
47
49
|
}: KlineChartProps) => {
|
|
48
|
-
const
|
|
50
|
+
const [chart, setChart] = useState<Chart | null>(null);
|
|
51
|
+
const [initializedHistory, setInitializedHistory] = useState<{
|
|
52
|
+
chart: Chart;
|
|
53
|
+
key: string;
|
|
54
|
+
} | null>(null);
|
|
49
55
|
const { data, key, fulfilled } = useData(filters, live);
|
|
56
|
+
const searchParams = useSearchParams();
|
|
57
|
+
const signalId = searchParams.get('signalId');
|
|
58
|
+
const autoZoom = searchParams.get('autoZoom') === 'true';
|
|
59
|
+
const dashboardSignal = useDashboardSignal({
|
|
60
|
+
symbol: filters.symbol,
|
|
61
|
+
signalId,
|
|
62
|
+
});
|
|
50
63
|
const updateDataCallback = useRef<
|
|
51
64
|
DataLoaderSubscribeBarParams['callback'] | null
|
|
52
65
|
>(null);
|
|
53
66
|
const RIGHT_EDGE_EPSILON_BARS = 1;
|
|
67
|
+
const historyKey = `${id}:${key}:${filters.symbol}:${filters.interval}`;
|
|
68
|
+
const historyReady = Boolean(
|
|
69
|
+
chart &&
|
|
70
|
+
fulfilled &&
|
|
71
|
+
!_.isEmpty(data) &&
|
|
72
|
+
initializedHistory?.chart === chart &&
|
|
73
|
+
initializedHistory.key === historyKey,
|
|
74
|
+
);
|
|
54
75
|
|
|
55
76
|
useEffect(() => {
|
|
56
|
-
const
|
|
57
|
-
|
|
77
|
+
const nextChart = init(id) as Chart;
|
|
78
|
+
setChart(nextChart);
|
|
58
79
|
|
|
59
|
-
darkTheme(
|
|
80
|
+
darkTheme(nextChart);
|
|
60
81
|
|
|
61
82
|
return () => {
|
|
62
83
|
dispose(id);
|
|
63
|
-
|
|
84
|
+
setChart((current) => (current === nextChart ? null : current));
|
|
64
85
|
};
|
|
65
86
|
}, [id]);
|
|
66
87
|
|
|
67
88
|
useEffect(() => {
|
|
68
|
-
if (!
|
|
89
|
+
if (!chart || !fulfilled || _.isEmpty(data)) {
|
|
69
90
|
return;
|
|
70
91
|
}
|
|
71
92
|
|
|
72
|
-
const chart = chartRef.current;
|
|
73
93
|
const currentSymbol = chart.getSymbol()?.ticker;
|
|
74
94
|
const currentInterval = chart.getPeriod()?.span;
|
|
75
95
|
const nextInterval = parseInt(filters.interval, 10);
|
|
@@ -77,15 +97,20 @@ export const KlineChart = ({
|
|
|
77
97
|
const intervalChanged = currentInterval !== nextInterval;
|
|
78
98
|
|
|
79
99
|
if (symbolChanged || intervalChanged) {
|
|
80
|
-
|
|
81
|
-
|
|
100
|
+
chart.setSymbol({ ticker: filters.symbol, pricePrecision: 9 });
|
|
101
|
+
chart.setPeriod({
|
|
82
102
|
span: nextInterval,
|
|
83
103
|
type: 'minute',
|
|
84
104
|
});
|
|
85
105
|
|
|
86
|
-
|
|
106
|
+
chart.setDataLoader({
|
|
87
107
|
getBars: ({ callback }) => {
|
|
88
108
|
callback(data);
|
|
109
|
+
setInitializedHistory((current) =>
|
|
110
|
+
current?.chart === chart && current.key === historyKey
|
|
111
|
+
? current
|
|
112
|
+
: { chart, key: historyKey },
|
|
113
|
+
);
|
|
89
114
|
},
|
|
90
115
|
subscribeBar: ({ callback }) => {
|
|
91
116
|
updateDataCallback.current = callback;
|
|
@@ -142,9 +167,12 @@ export const KlineChart = ({
|
|
|
142
167
|
if (!wasPinnedToRightEdge) {
|
|
143
168
|
chart.scrollToDataIndex(dataIndexToKeepVisible);
|
|
144
169
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
170
|
+
setInitializedHistory((current) =>
|
|
171
|
+
current?.chart === chart && current.key === historyKey
|
|
172
|
+
? current
|
|
173
|
+
: { chart, key: historyKey },
|
|
174
|
+
);
|
|
175
|
+
}, [chart, data, filters.interval, filters.symbol, fulfilled, historyKey]);
|
|
148
176
|
|
|
149
177
|
useResize(chart, id);
|
|
150
178
|
useAtrIndicator(chart, indicators.atr.enabled, indicators.atr.periods || []);
|
|
@@ -166,16 +194,34 @@ export const KlineChart = ({
|
|
|
166
194
|
);
|
|
167
195
|
useBacktest(chart, filters.backtestId || undefined);
|
|
168
196
|
useSupportResistanceLines(chart, indicators.resistant?.enabled);
|
|
169
|
-
|
|
170
|
-
|
|
197
|
+
const signalReady =
|
|
198
|
+
dashboardSignal.status === 'loaded' && dashboardSignal.signal != null;
|
|
199
|
+
const signalFiguresReady = useSignalFigures({
|
|
200
|
+
chart,
|
|
201
|
+
lastDataTimestamp: data.at(-1)?.timestamp ?? null,
|
|
202
|
+
enabled: historyReady && signalReady,
|
|
203
|
+
signal: dashboardSignal.signal,
|
|
204
|
+
autoZoom,
|
|
205
|
+
});
|
|
206
|
+
const tradeSetupReady = useTradeSetup({
|
|
207
|
+
chart,
|
|
208
|
+
enabled: historyReady && signalReady,
|
|
209
|
+
signal: dashboardSignal.signal,
|
|
210
|
+
});
|
|
171
211
|
usePluginIndicators(chart, indicators, indicatorRenderers, data);
|
|
172
212
|
|
|
213
|
+
const screenshotReady = signalId
|
|
214
|
+
? historyReady && signalReady && signalFiguresReady && tradeSetupReady
|
|
215
|
+
: historyReady;
|
|
216
|
+
|
|
173
217
|
return (
|
|
174
218
|
<>
|
|
175
219
|
<div
|
|
176
220
|
id={id}
|
|
177
221
|
data-testid="market-chart"
|
|
178
|
-
data-chart-ready={
|
|
222
|
+
data-chart-ready={historyReady ? 'true' : 'false'}
|
|
223
|
+
data-signal-status={dashboardSignal.status}
|
|
224
|
+
data-screenshot-ready={screenshotReady ? 'true' : 'false'}
|
|
179
225
|
/>
|
|
180
226
|
{!fulfilled && <OverlaySpinner />}
|
|
181
227
|
</>
|