@oanda/labs-crowd-view-widget 1.0.44 → 1.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +184 -0
  2. package/dist/main/CrowdViewWidget/Main.js +17 -6
  3. package/dist/main/CrowdViewWidget/Main.js.map +1 -1
  4. package/dist/main/CrowdViewWidget/components/Chart/Chart.js +6 -10
  5. package/dist/main/CrowdViewWidget/components/Chart/Chart.js.map +1 -1
  6. package/dist/main/CrowdViewWidget/components/Chart/chartOptions.js +33 -18
  7. package/dist/main/CrowdViewWidget/components/Chart/chartOptions.js.map +1 -1
  8. package/dist/main/CrowdViewWidget/components/Chart/types.js.map +1 -1
  9. package/dist/main/CrowdViewWidget/components/Chart/useCrowdViewData.js +23 -9
  10. package/dist/main/CrowdViewWidget/components/Chart/useCrowdViewData.js.map +1 -1
  11. package/dist/main/CrowdViewWidget/components/Chart/utils/chartUtils.js +39 -2
  12. package/dist/main/CrowdViewWidget/components/Chart/utils/chartUtils.js.map +1 -1
  13. package/dist/main/CrowdViewWidget/constants.js +5 -4
  14. package/dist/main/CrowdViewWidget/constants.js.map +1 -1
  15. package/dist/main/translations/sources/en.json +24 -0
  16. package/dist/module/CrowdViewWidget/Main.js +18 -7
  17. package/dist/module/CrowdViewWidget/Main.js.map +1 -1
  18. package/dist/module/CrowdViewWidget/components/Chart/Chart.js +7 -11
  19. package/dist/module/CrowdViewWidget/components/Chart/Chart.js.map +1 -1
  20. package/dist/module/CrowdViewWidget/components/Chart/chartOptions.js +34 -19
  21. package/dist/module/CrowdViewWidget/components/Chart/chartOptions.js.map +1 -1
  22. package/dist/module/CrowdViewWidget/components/Chart/types.js.map +1 -1
  23. package/dist/module/CrowdViewWidget/components/Chart/useCrowdViewData.js +23 -9
  24. package/dist/module/CrowdViewWidget/components/Chart/useCrowdViewData.js.map +1 -1
  25. package/dist/module/CrowdViewWidget/components/Chart/utils/chartUtils.js +36 -1
  26. package/dist/module/CrowdViewWidget/components/Chart/utils/chartUtils.js.map +1 -1
  27. package/dist/module/CrowdViewWidget/constants.js +5 -4
  28. package/dist/module/CrowdViewWidget/constants.js.map +1 -1
  29. package/dist/module/translations/sources/en.json +24 -0
  30. package/dist/types/CrowdViewWidget/components/Chart/types.d.ts +6 -2
  31. package/dist/types/CrowdViewWidget/components/Chart/utils/chartUtils.d.ts +5 -0
  32. package/dist/types/CrowdViewWidget/constants.d.ts +4 -3
  33. package/lokalise.config.json +1 -1
  34. package/package.json +3 -3
  35. package/src/CrowdViewWidget/Main.tsx +23 -7
  36. package/src/CrowdViewWidget/components/Chart/Chart.tsx +6 -12
  37. package/src/CrowdViewWidget/components/Chart/chartOptions.ts +41 -17
  38. package/src/CrowdViewWidget/components/Chart/types.ts +6 -2
  39. package/src/CrowdViewWidget/components/Chart/useCrowdViewData.ts +34 -9
  40. package/src/CrowdViewWidget/components/Chart/utils/chartUtils.ts +69 -1
  41. package/src/CrowdViewWidget/constants.ts +4 -3
  42. package/src/translations/sources/en.json +24 -0
  43. package/test/Main.test.tsx +72 -26
  44. package/test/components/Chart/utils/chartUtils.test.ts +172 -0
@@ -83,6 +83,25 @@ const processOrderPositionBooks = (
83
83
  return [];
84
84
  }
85
85
 
86
+ return orderPositionData.orderPositionBooks
87
+ .filter((book): book is NonNullable<typeof book> => {
88
+ return book !== null && book.buckets?.length > 0;
89
+ })
90
+ .map((book, index) => {
91
+ const candle = candleMap.get(book.time);
92
+ const price = candle?.high ?? null;
93
+
94
+ return [book.time, price, index] as [string, number, number];
95
+ });
96
+ };
97
+
98
+ const processBuckets = (
99
+ orderPositionData: GetOrderPositionBooksQuery | undefined
100
+ ) => {
101
+ if (!orderPositionData?.orderPositionBooks?.length) {
102
+ return [];
103
+ }
104
+
86
105
  return orderPositionData.orderPositionBooks
87
106
  .filter((book): book is NonNullable<typeof book> => {
88
107
  return book !== null && book.buckets?.length > 0;
@@ -105,14 +124,7 @@ const processOrderPositionBooks = (
105
124
  sentiment: bucket!.sentiment!,
106
125
  }));
107
126
 
108
- const candle = candleMap.get(book.time);
109
- const price = candle?.high ?? null;
110
-
111
- return [book.time, price, JSON.stringify(filteredBuckets)] as [
112
- string,
113
- number,
114
- string,
115
- ];
127
+ return filteredBuckets;
116
128
  });
117
129
  };
118
130
 
@@ -212,6 +224,11 @@ export const useCrowdViewData = ({
212
224
  [orderPositionData, candleMap]
213
225
  );
214
226
 
227
+ const buckets = useMemo(
228
+ () => processBuckets(orderPositionData),
229
+ [orderPositionData]
230
+ );
231
+
215
232
  const error = useMemo((): Error | null => {
216
233
  if (priceCandlesError) {
217
234
  return new Error(`Price candles error: ${priceCandlesError.message}`);
@@ -248,12 +265,20 @@ export const useCrowdViewData = ({
248
265
  );
249
266
 
250
267
  return {
268
+ buckets,
251
269
  xAxisData,
252
270
  candlesSeriesData,
253
271
  orderPositionBooks,
254
272
  bucketWidth: orderPositionData.orderPositionBooks?.[0]?.bucketWidth || 0,
255
273
  };
256
- }, [priceCandlesData, orderPositionData, error, candles, orderPositionBooks]);
274
+ }, [
275
+ priceCandlesData,
276
+ orderPositionData,
277
+ error,
278
+ candles,
279
+ orderPositionBooks,
280
+ buckets,
281
+ ]);
257
282
 
258
283
  return {
259
284
  data,
@@ -28,7 +28,7 @@ export const getLabelData = ({
28
28
  })
29
29
  .map((item) => ({
30
30
  name: new Date(item).toLocaleDateString(undefined, {
31
- month: 'short',
31
+ month: isGreaterThanTwoWeeks ? 'short' : 'long',
32
32
  day: isGreaterThanTwoWeeks ? 'numeric' : undefined,
33
33
  }),
34
34
  xAxis: item,
@@ -91,3 +91,71 @@ export const getRectColor = (sentiment: number) =>
91
91
  sentiment < 0
92
92
  ? getGradientColor(sentiment * -1, COLOR_MAP.short[0], COLOR_MAP.short[1])
93
93
  : getGradientColor(sentiment, COLOR_MAP.long[0], COLOR_MAP.long[1]);
94
+
95
+ export const getTooltipFormatter = (
96
+ params: unknown,
97
+ buckets: Array<Array<{ price: number; sentiment: number }>>,
98
+ bucketWidth: number,
99
+ selectedPrice: number,
100
+ labelCallback: (key: string) => string
101
+ ) => {
102
+ const arr = params as unknown as Array<Record<string, unknown>>;
103
+ const candleParam = arr[0] as {
104
+ axisValue: string;
105
+ value: [number, number, number, number, number];
106
+ };
107
+ const booksParam = arr[1] as { value: [string, number, number] };
108
+ const time = new Date(candleParam.axisValue as string);
109
+
110
+ const candleData = candleParam.value;
111
+ const booksData = booksParam?.value ?? [];
112
+ const bucketsIndex = booksData[2];
113
+ const selectedBuckets = buckets[bucketsIndex];
114
+
115
+ const matchedBucket = selectedBuckets?.find(
116
+ ({ price }) => selectedPrice >= price && selectedPrice < price + bucketWidth
117
+ );
118
+ const showCandles =
119
+ !!candleData[1] && !!candleData[2] && !!candleData[3] && !!candleData[4];
120
+
121
+ return `<p>${time.toLocaleString(undefined, {
122
+ hour: '2-digit',
123
+ minute: '2-digit',
124
+ year: 'numeric',
125
+ day: 'numeric',
126
+ month: 'numeric',
127
+ timeZoneName: 'short',
128
+ })}</p><br />
129
+ ${
130
+ showCandles
131
+ ? `<p><b>${labelCallback('candle')}:</b></p>
132
+ <p>${labelCallback('open_price')}: ${candleData[1]} </p>
133
+ <p>${labelCallback('close_price')}: ${candleData[2]} </p>
134
+ <p>${labelCallback('low')}: ${candleData[3]} </p>
135
+ <p>${labelCallback('high')}: ${candleData[4]} </p>
136
+ `
137
+ : ''
138
+ }
139
+ ${
140
+ matchedBucket
141
+ ? `<br /><p><b>${labelCallback('orders')}:</b></p>
142
+ <p>${labelCallback('price_range')}: ${matchedBucket.price} - ${Number(matchedBucket.price + bucketWidth).toFixed(4)} </p>
143
+ <p>${matchedBucket.sentiment < 0 ? labelCallback('sell_advantage') : labelCallback('buy_advantage')}: ${Math.abs(matchedBucket.sentiment)}% </p>`
144
+ : ''
145
+ }`;
146
+ };
147
+
148
+ export const formatXAxisLabel = (
149
+ value: unknown,
150
+ isGreaterThanTwoWeeks: boolean
151
+ ) => {
152
+ const date = new Date(value as string);
153
+ return isGreaterThanTwoWeeks
154
+ ? `${date.toLocaleTimeString(undefined, {
155
+ hour: '2-digit',
156
+ minute: '2-digit',
157
+ })}`
158
+ : `${CHART_CONFIG.X_AXIS_DATE_PADDING}${date.toLocaleDateString(undefined, {
159
+ day: 'numeric',
160
+ })}${CHART_CONFIG.X_AXIS_DATE_PADDING}`;
161
+ };
@@ -17,11 +17,12 @@ export const CHART_CONFIG = {
17
17
  WIDTH: 9999,
18
18
  X_LABEL_SIZE: 40,
19
19
  Y_LABEL_SIZE_DESKTOP: 60,
20
- INITIAL_START_ZOOM: 80,
20
+ INITIAL_START_ZOOM: 0,
21
21
  INITIAL_END_ZOOM: 100,
22
+ X_AXIS_DATE_PADDING: ' ',
22
23
  } as const;
23
24
 
24
25
  export const COLOR_MAP = {
25
- long: ['#ffffff', '#fdb833'],
26
- short: ['#ffffff', '#0096c7'],
26
+ long: ['#fcedca', '#FAB313'],
27
+ short: ['#c8ebfa', '#309DCC'],
27
28
  } as const;
@@ -1,2 +1,26 @@
1
1
  {
2
+ "data_unavailable": "Data unavailable",
3
+ "no_matching_results": "No matching results",
4
+ "pagination_entries_range": "{{firstItemOnPage}}-{{lastItemOnPage}} of {{itemCount}} entries",
5
+ "order_book": "Order book",
6
+ "position_book": "Position book",
7
+ "long": "Long",
8
+ "short": "Short",
9
+ "instrument": "Instrument",
10
+ "granularity": "Granularity",
11
+ "search": "Search",
12
+ "5_minutes": "5 minutes",
13
+ "15_minutes": "15 minutes",
14
+ "1_hour": "1 hour",
15
+ "4_hours": "4 hours",
16
+ "candle": "Candle",
17
+ "open_price": "Open price",
18
+ "close_price": "Close price",
19
+ "low": "Low",
20
+ "high": "High",
21
+ "orders": "Orders",
22
+ "price_range": "Price range",
23
+ "sentiment": "Sentiment",
24
+ "buy_advantage": "Buy advantage",
25
+ "sell_advantage": "Sell advantage"
2
26
  }
@@ -9,46 +9,92 @@ import React from 'react';
9
9
  import { Main } from '../src/CrowdViewWidget/Main';
10
10
  import { InstrumentId } from '../src/CrowdViewWidget/types/instruments';
11
11
  import { getOrderPositionBooks } from '../src/gql/getOrderPositionBooks';
12
- import { BookType, Division } from '../src/gql/types/graphql';
12
+ import { getPriceCandles } from '../src/gql/getPriceCandles';
13
+ import {
14
+ BookType,
15
+ DataSource,
16
+ Division,
17
+ Granularity,
18
+ TimeSpan,
19
+ } from '../src/gql/types/graphql';
20
+
21
+ const instrument = InstrumentId.EUR_AUD;
22
+ const division = Division.Oap;
23
+ const granularity = Granularity.H4;
24
+ const timeSpan = TimeSpan.NinetyDays;
25
+
26
+ const candles = [
27
+ {
28
+ point: '2024-02-21T10:00:00Z',
29
+ high: 1.334,
30
+ low: 1.33,
31
+ open: 1.331,
32
+ close: 1.333,
33
+ },
34
+ {
35
+ point: '2024-02-21T10:30:00Z',
36
+ high: 1.335,
37
+ low: 1.3295,
38
+ open: 1.33,
39
+ close: 1.334,
40
+ },
41
+ {
42
+ point: '2024-02-21T11:00:00Z',
43
+ high: 1.333,
44
+ low: 1.329,
45
+ open: 1.331,
46
+ close: 1.332,
47
+ },
48
+ ];
49
+
50
+ const maxPrice = 1.335;
51
+ const minPrice = 1.329;
52
+ const bucketWidth = 0.0005;
53
+ const maxBookPrice = maxPrice + bucketWidth * 2;
54
+ const minBookPrice = minPrice - bucketWidth * 2;
13
55
 
14
56
  const mocks = [
57
+ {
58
+ request: {
59
+ query: getPriceCandles,
60
+ variables: {
61
+ dataSource: DataSource.Mt5,
62
+ division,
63
+ instrument,
64
+ granularity,
65
+ timeSpan,
66
+ },
67
+ },
68
+ result: {
69
+ data: {
70
+ priceCandles: {
71
+ candle: candles,
72
+ },
73
+ },
74
+ },
75
+ },
15
76
  {
16
77
  request: {
17
78
  query: getOrderPositionBooks,
18
79
  variables: {
19
- instrument: InstrumentId.EUR_AUD,
80
+ instrument,
20
81
  bookType: BookType.Order,
21
- recentHours: 1,
82
+ timeSpan,
83
+ granularity,
84
+ maxBookPrice,
85
+ minBookPrice,
22
86
  },
23
87
  },
24
88
  result: {
25
89
  data: {
26
90
  orderPositionBooks: [
27
91
  {
28
- bucketWidth: 0.0005,
29
- price: 0.8,
92
+ bucketWidth,
93
+ price: maxPrice,
30
94
  time: '2024-02-21T10:30:00Z',
31
95
  buckets: [
32
- {
33
- price: 0.02,
34
- longCountPercent: 0.0582,
35
- shortCountPercent: 0.0,
36
- },
37
- {
38
- price: 0.7,
39
- longCountPercent: 0.0582,
40
- shortCountPercent: 0.0,
41
- },
42
- {
43
- price: 0.9925,
44
- longCountPercent: 0.0582,
45
- shortCountPercent: 0.0,
46
- },
47
- {
48
- price: 1.0,
49
- longCountPercent: 0.1163,
50
- shortCountPercent: 0.0,
51
- },
96
+ { price: 1.3345, sentiment: 0.2 },
97
+ { price: 1.3305, sentiment: -0.3 },
52
98
  ],
53
99
  },
54
100
  ],
@@ -62,7 +108,7 @@ describe('Main component', () => {
62
108
  const { findByTestId } = render(
63
109
  <MockedProvider mocks={mocks}>
64
110
  <MockLayoutProvider>
65
- <Main division={Division.Oap} />
111
+ <Main division={division} />
66
112
  </MockLayoutProvider>
67
113
  </MockedProvider>
68
114
  );
@@ -0,0 +1,172 @@
1
+ import {
2
+ formatXAxisLabel,
3
+ getLabelData,
4
+ getRectColor,
5
+ getTimeSpanForGranularity,
6
+ getTooltipFormatter,
7
+ isDifferenceGreaterThanTwoWeeks,
8
+ } from '../../../../src/CrowdViewWidget/components/Chart/utils/chartUtils';
9
+ import {
10
+ BOOKS_THRESHOLDS,
11
+ COLOR_MAP,
12
+ } from '../../../../src/CrowdViewWidget/constants';
13
+ import { Granularity, TimeSpan } from '../../../../src/gql/types/graphql';
14
+
15
+ describe('chartUtils', () => {
16
+ describe('getTimeSpanForGranularity', () => {
17
+ it('maps granularity to expected TimeSpan', () => {
18
+ expect(getTimeSpanForGranularity(Granularity.M5)).toBe(TimeSpan.TwoDays);
19
+ expect(getTimeSpanForGranularity(Granularity.M15)).toBe(
20
+ TimeSpan.FiveDays
21
+ );
22
+ expect(getTimeSpanForGranularity(Granularity.H1)).toBe(
23
+ TimeSpan.TwentyDays
24
+ );
25
+ expect(getTimeSpanForGranularity(Granularity.H4)).toBe(
26
+ TimeSpan.NinetyDays
27
+ );
28
+ });
29
+ });
30
+
31
+ describe('isDifferenceGreaterThanTwoWeeks', () => {
32
+ // Note: Function returns true when difference is LESS than threshold (14 days)
33
+ it('returns true when time difference is less than 14 days', () => {
34
+ const start = new Date('2025-01-01T00:00:00Z').toISOString();
35
+ const end = new Date('2025-01-05T00:00:00Z').toISOString();
36
+ expect(isDifferenceGreaterThanTwoWeeks(start, end)).toBe(true);
37
+ });
38
+
39
+ it('returns false when time difference is 14 days or more', () => {
40
+ const start = new Date('2025-01-01T00:00:00Z').toISOString();
41
+ const end = new Date('2025-02-10T00:00:00Z').toISOString();
42
+ expect(isDifferenceGreaterThanTwoWeeks(start, end)).toBe(false);
43
+ });
44
+ });
45
+
46
+ describe('getRectColor', () => {
47
+ it('uses long color scale for positive sentiment', () => {
48
+ const color = getRectColor(BOOKS_THRESHOLDS.MAX);
49
+ expect(typeof color).toBe('string');
50
+ // At max threshold, should be at or near target color
51
+ expect(color.toLowerCase()).toContain(
52
+ COLOR_MAP.long[1].slice(1).toLowerCase().substring(0, 3)
53
+ );
54
+ });
55
+
56
+ it('uses short color scale for negative sentiment', () => {
57
+ const color = getRectColor(-BOOKS_THRESHOLDS.MAX);
58
+ expect(typeof color).toBe('string');
59
+ expect(color.toLowerCase()).toContain(
60
+ COLOR_MAP.short[1].slice(1).toLowerCase().substring(0, 3)
61
+ );
62
+ });
63
+ });
64
+
65
+ describe('formatXAxisLabel', () => {
66
+ const sampleIso = '2025-03-15T10:30:00Z';
67
+
68
+ it('formats time when flag is true', () => {
69
+ const result = formatXAxisLabel(sampleIso, true);
70
+ expect(typeof result).toBe('string');
71
+ expect(result).toContain(':');
72
+ });
73
+
74
+ it('formats day when flag is false', () => {
75
+ const result = formatXAxisLabel(sampleIso, false);
76
+ expect(typeof result).toBe('string');
77
+ // Contains day of month (15) with surrounding spaces per implementation
78
+ expect(result).toMatch(/\s15\s/);
79
+ });
80
+ });
81
+
82
+ describe('getLabelData', () => {
83
+ const dates = [
84
+ '2025-03-01T00:00:00Z',
85
+ '2025-03-01T12:00:00Z',
86
+ '2025-03-02T00:00:00Z',
87
+ '2025-03-03T00:00:00Z',
88
+ ];
89
+
90
+ it('emits label when day changes for < two weeks case', () => {
91
+ const labels = getLabelData({
92
+ xAxisData: dates,
93
+ isGreaterThanTwoWeeks: true,
94
+ });
95
+ // First change happens between 1st and 2nd
96
+ expect(labels.length).toBeGreaterThanOrEqual(2);
97
+ expect(labels[0]).toHaveProperty('xAxis', '2025-03-02T00:00:00Z');
98
+ });
99
+
100
+ it('emits label when month changes for >= two weeks case', () => {
101
+ const monthSpanDates = [
102
+ '2025-01-31T00:00:00Z',
103
+ '2025-02-01T00:00:00Z',
104
+ '2025-02-15T00:00:00Z',
105
+ ];
106
+ const labels = getLabelData({
107
+ xAxisData: monthSpanDates,
108
+ isGreaterThanTwoWeeks: false,
109
+ });
110
+ expect(labels.length).toBe(1);
111
+ expect(labels[0]).toHaveProperty('xAxis', '2025-02-01T00:00:00Z');
112
+ });
113
+ });
114
+
115
+ describe('getTooltipFormatter', () => {
116
+ const labelCallback = (k: string) => k;
117
+
118
+ it('renders candle and book details when available', () => {
119
+ const params = [
120
+ {
121
+ axisValue: '2025-03-15T10:30:00Z',
122
+ value: [0, 1.11111, 1.22222, 1.00001, 1.33333],
123
+ },
124
+ { value: ['2025-03-15T10:30:00Z', 1.33333, 0] },
125
+ ];
126
+
127
+ const buckets = [
128
+ [
129
+ { price: 1.33, sentiment: 0.2 },
130
+ { price: 1.3305, sentiment: -0.3 },
131
+ ],
132
+ ];
133
+
134
+ const html = getTooltipFormatter(
135
+ params,
136
+ buckets,
137
+ 0.0005,
138
+ 1.3306,
139
+ labelCallback
140
+ );
141
+ expect(html).toContain('candle');
142
+ expect(html).toContain('open_price');
143
+ expect(html).toContain('close_price');
144
+ expect(html).toContain('low');
145
+ expect(html).toContain('high');
146
+ expect(html).toContain('orders');
147
+ expect(html).toContain('price_range');
148
+ // Selected price 1.3306 falls into second bucket 1.3305 - 1.3310 which has negative sentiment
149
+ expect(html).toContain('sell_advantage');
150
+ });
151
+
152
+ it('omits sections when data is missing', () => {
153
+ const params = [
154
+ {
155
+ axisValue: '2025-03-15T10:30:00Z',
156
+ value: [0, 0, 0, 0, 0], // no candle values
157
+ },
158
+ { value: ['2025-03-15T10:30:00Z', 0, 0] },
159
+ ];
160
+ const buckets: Array<Array<{ price: number; sentiment: number }>> = [[]];
161
+ const html = getTooltipFormatter(
162
+ params,
163
+ buckets,
164
+ 0.0005,
165
+ 0,
166
+ labelCallback
167
+ );
168
+ expect(html).not.toContain('open_price');
169
+ expect(html).not.toContain('orders');
170
+ });
171
+ });
172
+ });