-
Notifications
You must be signed in to change notification settings - Fork 2
/
eth_usdc_analysis.Rmd
888 lines (628 loc) · 33.6 KB
/
eth_usdc_analysis.Rmd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
---
title: "ETH USDC Retroactive Data Analysis"
author: "Charliemarketplace"
date: "`r Sys.Date()`"
output:
html_document:
css: "styles.css"
includes:
in_header: header.html
code_folding: hide
toc: true
toc_float: true
editor_options:
chunk_output_type: console
---
```{r, warning = FALSE, message = FALSE}
library(reactable)
library(ggplot2)
library(plotly)
library(dplyr)
source("key_functions.R")
```
# Data
Using the accounting results detailed in [ETH-USDC Retroactive Data Collection](https://science.flipsidecrypto.xyz/ethusdc_data/) to understand any patterns in Uniswap v3 positions profitability.
- unique_id: The Uniswap v3 unique position, typically an NF_TOKEN_ID but for Vaults that
don't use NF_TOKEN_ID, the concatenation of NF_POSITION_MANAGER--TICK_LOWER--TICK_UPPER.
- pnl_usd_terms: The profit and loss of a position over its lifecycle as measured in USD, i.e.,
did the position increase its value in USD terms after adjusting for USD's price changes (against ETH) and the AMM strategy, including fees accrued.
- pnl_eth_terms: The profit and loss of a position over its lifecycle as measured in ETH, i.e.,
did the position increase its value in ETH terms after adjusting for ETH's price changes (in USD) and the AMM strategy, including fees accrued.
- hodl_usd_terms: The value of the original assets in USD terms at time of position closure.
- hodl_eth_terms: The value of the original assets in ETH terms at time of position closure.
- strat_usd_terms: The value of the assets removed from the position in USD terms at time of position closure including fees accrued.
- strat_eth_terms: The value of the assets removed from the position in ETH terms at time of position closure including fees accrued.
```{r}
lps <- readRDS("lp_actions.rds")
# adding back unique_ids to get tick information
lps$unique_id <- lps$NF_TOKEN_ID
custom_index <- which(is.na(lps$unique_id))
lps[custom_index, "unique_id"] <- paste0(lps[custom_index, "NF_POSITION_MANAGER_ADDRESS"],
"--",
lps[custom_index, "TICK_LOWER"],
"--",
lps[custom_index, "TICK_UPPER"])
ethusdc <- readRDS("ethusdc_results.rds")
ethusdc_accounts <- readRDS("post_accounting.rds")
ethusdc[ , 2:7] <- ethusdc[ , 2:7] %>% round(., 4)
reactable(ethusdc[200, ])
```
# Rates of Profitability
Different positions will have different profit & loss (PnL), different original assets value at position closure (HODL Reference Value, i.e., Opportunity Cost), and different withdrawn assets value at position closure including fees (Strategy Reference Value, i.e., Realized Value).
These assets are volatile, so pure PnL may not be the desired framework for assessing whether
participating in Uniswap v3 was a *good idea*. This is where Economic Profit Percent comes in. Economic Profit is the return on investment above the opportunity cost (here, doing nothing and not having participated in Uniswap v3).
## PnL
Because of how AMMs work, over the lifespan of a position the relative value of ETH against USDC will determine the final makeup of the assets withdrawn. If ETH goes up in value against USDC,
positions will sell their ETH and accumulate USDC. Profit & Loss integrates price changes to identify the change in value over time.
### Example Table of Labels
To better detail the labels, here is a sample table explaining
how Breakeven, Profit, and Loss are applied in nuanced situations.
```{r}
reactable(
data.frame(
pnl_usdc_terms = c(-1, -1, -1, 0, 0, 0, 1, 1),
pnl_eth_terms = c(-1, 0, 1, -1, 0, 1, 1, -1)
) %>% mutate(
pnl = case_when(
pnl_usdc_terms == 0 & pnl_eth_terms == 0 ~ "Breakeven",
pnl_usdc_terms <= 0 & pnl_eth_terms <= 0 ~ "Both PnL Loss",
pnl_usdc_terms > 0 & pnl_eth_terms <= 0 ~ "USD PnL Only",
pnl_usdc_terms <= 0 & pnl_eth_terms > 0 ~ "ETH PnL Only",
pnl_usdc_terms > 0 & pnl_eth_terms > 0 ~ "Both PnL Profit"
)
)
)
```
### Actual Results
```{r}
ethusdc <- ethusdc %>% mutate(
pnl = case_when(
pnl_usdc_terms == 0 & pnl_eth_terms == 0 ~ "Breakeven",
pnl_usdc_terms <= 0 & pnl_eth_terms <= 0 ~ "Both PnL Loss",
pnl_usdc_terms > 0 & pnl_eth_terms <= 0 ~ "USD PnL Only",
pnl_usdc_terms <= 0 & pnl_eth_terms > 0 ~ "ETH PnL Only",
pnl_usdc_terms > 0 & pnl_eth_terms > 0 ~ "Both PnL Profit"
)
)
key_barchart <- function(result, xlab = "Result", ylab = "Count", title){
r <- as.data.frame(table(result))
r$percent <- round(r$Freq/sum(r$Freq) * 100, digits = 2)
plot_ly(r, x = ~result, y = ~Freq, type = "bar", text = ~paste0(percent,"%")) %>%
layout(
title = list(text = title, y = 0.975),
xaxis = list(title = xlab),
yaxis = list(title = ylab)
)
}
```
In general, positions gained in at least 1 form of PnL:
- `r round(sum(ethusdc$pnl == "USD PnL Only")/nrow(ethusdc) * 100, 2)`% of positions net increased their value in *only* USD terms. After accounting for price changes, they effectively sold ETH for USD.
- `r round(sum(ethusdc$pnl == "ETH PnL Only")/nrow(ethusdc) * 100, 2)`% of positions net increased their value in *only* ETH terms. After accounting for price changes, they effectively sold USD for ETH.
- `r round(sum(ethusdc$pnl == "Both PnL Profit")/nrow(ethusdc) * 100, 2)`% of positions net increased their value in *both* ETH and USD terms. They were undoubtedly profitable (although technically the counterfactual of going *all in* on the winning asset is not assessed here).
- `r round(sum(ethusdc$pnl %in% c("Both PnL Loss", "Breakeven"))/nrow(ethusdc) * 100, 2)`% of positions
either broke even (often quickly withdrawing the same position without accruing any fees) or were undoubtedly unprofitable. This worst case scenario would occur from withdrawing assets specifically
after a large price change without accruing fees to make up for the loss from price impact.
```{r}
key_barchart(ethusdc$pnl, title = "Position Resulting Profit & Loss Category")
```
## Strategy > HODL
PnL is useful, but in general what most interests Uniswap v3 participants is
whether participating in the strategy beats the *opportunity cost* of not participating.
If I start with 100,000 USDC and 50 ETH and end up with 150,250 USDC and 40.1 ETH after withdrawing from a strategy and collecting my fees- am I better off?
PnL integrates price changes like traditional accounting. HODL Reference Value and Strategy
Reference Value use a single point in time price (the price as of Position Closure) to assess
whether results from a strategy exceeds opportunity cost.
```{r}
ethusdc <- ethusdc %>% mutate(
strat = case_when(
strat_usdc_terms == hodl_usdc_terms & strat_eth_terms == hodl_eth_terms ~ "Breakeven",
strat_usdc_terms <= hodl_usdc_terms & strat_eth_terms <= hodl_eth_terms ~ "Both Strategy Loss",
strat_usdc_terms > hodl_usdc_terms & strat_eth_terms <= hodl_eth_terms ~ "USD Strat Gain Only",
strat_usdc_terms <= hodl_usdc_terms & strat_eth_terms > hodl_eth_terms ~ "ETH Strat Gain Only",
strat_usdc_terms > hodl_usdc_terms & strat_eth_terms > hodl_eth_terms ~ "Both Strategy Profit"
)
)
```
Because PnL accounts for price changes, middling results are more common. When using a point in
time price like Strategy vs HODL, binary results should be more common. We see:
- `r round(sum(ethusdc$strat == "Both Strategy Profit")/nrow(ethusdc) * 100, 2)`% of positions were in total profit in both terms after accounting for fees relative to simply holding the deposited assets.
- `r round(sum(ethusdc$strat == "Both Strategy Loss")/nrow(ethusdc) * 100, 2)`% of positions were in total loss, they would have been better off in both assets having held their original deposits.
```{r}
key_barchart(ethusdc$strat, xlab = "Result", "Count", title = "Distribution of ETHUSDC Strategy vs HODL")
```
## Economic Profit Percent (Strategy - HODL) / HODL
The distribution of how much profiteers profited is important for understanding how to
replicate these results. In each asset terms the Strategy Value - the HODL Value, divided by the
HODL value, times 100 to get to percentage, is considered the Economic Profit Percent (EPP) which is
measured in both USDC and ETH terms separately.
```{r}
ethusdc <- ethusdc %>% mutate(
usdc_epp = 100*(strat_usdc_terms - hodl_usdc_terms)/hodl_usdc_terms,
eth_epp = 100*(strat_eth_terms - hodl_eth_terms)/hodl_eth_terms
)
# make breakevens 0 in case of divide by 0
ethusdc[is.na(ethusdc)] <- 0
ethusdc_sig_change <- ethusdc %>%
filter(usdc_epp > 1 | usdc_epp < -1 | eth_epp > 1 | eth_epp < -1)
```
- `r sum(ethusdc$usdc_epp > 1 | ethusdc$usdc_epp < -1)` of the `r nrow(ethusdc)` (`r round(sum(ethusdc$usdc_epp > 1 | ethusdc$usdc_epp < -1)/nrow(ethusdc) * 100, 2)`%)
had their USDC denominated Strategy Value within -1% to 1% from their HODL value.
- There is a `r cor(ethusdc$usdc_epp, ethusdc$eth_epp)` correlation between Economic Profit in ETH terms and USD terms. This makes sense as generally a strategy is either net winning or net losing given a single point in time price.
Looking at positions with a significant change (larger than 1% difference between the Strategy Value and HODL value in either direction and either asset) shows a range of `r round(min(ethusdc_sig_change$eth_epp), 2)`% to `r round(max(ethusdc_sig_change$eth_epp),2)`% in ETH Economic Profit with an average `r round(mean(ethusdc_sig_change$eth_epp),2)`%
change in value.
```{r}
key_histogram <- function(tbl, percent_col1, percent_col2,
p1name, p2name, xlab = "Result", ylab = "Count",
title){
plot_ly(alpha = 0.45, data = tbl,
nbinsx = 200,
x = tbl[[percent_col1]],
type = "histogram",
name = p1name) %>%
add_histogram(x = tbl[[percent_col2]], name = p2name) %>%
layout(
barmode = "overlay",
title = list(text = title, y = 0.975),
xaxis = list(title = xlab),
yaxis = list(title = ylab)
)
}
key_histogram(tbl = ethusdc_sig_change,
percent_col1 = "usdc_epp",
percent_col2 = "eth_epp",
p1name = "in USD", p2name = "in ETH",
xlab = "% Economic Profit against HODL",
title = "Distribution of Significant % EPP Change \n (-1,1) removed")
```
# Position Characteristics by Profitability
Moving forward to identify characteristics of positions with (1) Significant Difference
between Strategy and HODL values (1% or more in either direction) specifically in ETH
terms across a variety of measures.
```{r}
get_start <- function(unique_id){
min(ethusdc_accounts[[unique_id]]$BLOCK_NUMBER)
}
get_end <- function(unique_id){
max(ethusdc_accounts[[unique_id]]$BLOCK_NUMBER)
}
ethusdc <- ethusdc %>%
group_by(unique_id) %>%
mutate(
start_block = get_start(unique_id),
end_block = get_end(unique_id)
) %>%
mutate(
lifespan = end_block - start_block + 1
)
ethusdc_sig_change <- ethusdc %>%
filter(usdc_epp > 1 | usdc_epp < -1 | eth_epp > 1 | eth_epp < -1)
```
## Start Block
It takes time to accrue fees, with only `r sum(ethusdc_sig_change$eth_epp >= 10 & ethusdc_sig_change$start_block >= 13917000)` positions starting in 2022 closing with 10%+ in Economic Profit Percentage; and
`r sum(ethusdc_sig_change$eth_epp >= 5 & ethusdc_sig_change$start_block >= 13917000)` getting
at least 5%.
```{r}
key_scatter <- function(tbl, x, y, xlab, ylab, title, name = "Positions"){
plot_ly(tbl, x = tbl[[x]], y = tbl[[y]], type = "scatter", mode = "markers", name = name) %>%
layout(
title = list(text = title, y = 0.975),
xaxis = list(title = xlab),
yaxis = list(title = ylab)
)
}
key_scatter(ethusdc, x = "start_block", y = "eth_epp",
xlab = "Position Opening Block",
ylab = "ETH % Gain over HODL",
title = "Only 50 positions w/ >10% Gains Opened in 2022") %>%
add_lines(x = 13917000, y = c(-30, 30), name = "Jan 1, 2022")
```
## End Block
Again, as expected more time to accrues fees is important. A deeper dive into Lifespans
will follow. There does seem to be a lot of cutting losses and closed positions in 2022.
Likely due to ETH's price falling 70%+ over the period of study. As positions fall (downward) out of range,
they become 100% in ETH and stop accruing fees which can worsen divergent loss.
In conversation with Uniswap's Research team, it is important to note many of these ETH-USDC positions
are done by professional market makers who take the opposite strategy (seeking to be "delta neutral") on central exchanges. So highly
negative ETH % Gain over HODL may be countered by gains elsewhere.
This is out of scope of this analysis since it is hypothetical and needed data would not be available on-chain, but nonetheless
is important context for those interested in participating in Uniswap and using this analysis as information.
```{r}
key_scatter(ethusdc, x = "end_block", y = "eth_epp",
xlab = "Position Closing Block",
ylab = "ETH % Gain over HODL",
title = "Profitability by Position Closing Block") %>%
add_lines(x = 13917000, y = c(-30, 30), name = "Jan 1, 2022")
```
## Lifespan
### Note on Just In Time Liquidity
```{r}
jit <- ethusdc[ethusdc$lifespan == 1, ]
```
For clarity on the phenomenon of Just In Time (JIT) Liquidity: of all positions
`r sum(ethusdc$lifespan == 1)` lasted only a single block. Only `r sum(ethusdc_sig_change$lifespan == 1)`
met the condition for significant change (Strategy was at least 1% different from HODL).
Of these JIT positions, `r sum(jit$strat == "Both Strategy Profit")` (a majority) beat HODL.
JIT represents `r round(100*nrow(jit)/nrow(ethusdc), 2)`% of closed positions, a significant share
worth noting.
### Overall Lifespan
Unlike correlated pools, e.g., ETH-WBTC 0.3%, ETH-USDC 0.05% has no clear cut strategy for beating HODL. Patience isn't enough
when ETH can fall 70% in under a year.
```{r}
key_scatter(ethusdc, x = "lifespan", y = "eth_epp",
xlab = "Lifespan in # Blocks",
ylab = "ETH % Gain over HODL",
title = "ETH-USDC 0.05% Pool by Lifespan")
```
`r sum(ethusdc$eth_epp > 1, ethusdc$lifespan >= 500000)` positions with 500,000+ block lifespans beat HODL
by 1% or more; while `r sum(ethusdc$eth_epp < -1, ethusdc$lifespan >= 500000)` lost to HODL by 1% or more.
## Tick Width
Different pools (more specifically different fee tiers) have a different amount of
minimum tick spacings that can be used when making a position. For ETH-USDC it is 10, thus
all positions are a multiple of 10 ticks apart.
Dividing the tick width by this 10 gets us the tick spacing.
```{r}
ticks <- unique(lps[ , c("unique_id","TICK_LOWER", "TICK_UPPER")])
ticks <- ticks %>% mutate(tick_spacing = (TICK_UPPER - TICK_LOWER)/10)
ethusdc <- merge(ethusdc, ticks, all.x = TRUE, by = "unique_id")
ethusdc_sig_change <- ethusdc %>%
filter(usdc_epp > 1 | usdc_epp < -1 | eth_epp > 1 | eth_epp < -1)
```
Among all positions, 10,000 tick spaces covers `r round(ecdf(ethusdc$tick_spacing)(10000) * 100, 2)`% of positions.
Zooming into this section, narrow ranges of < 2000 tick spaces perform chaotically.
```{r}
key_scatter(tbl = ethusdc, x = "tick_spacing", y = "eth_epp",
xlab = "Tick Spaces",
ylab = "ETH % Gain over HODL",
title = "Fewer Losers after 2000+ Tick Spaces Wide") %>%
layout(
xaxis = list(range = c(0, 10000))
)
```
Note: converting tick spaces to USD/ETH price terms doesn't make intuitive sense due to the geometric nature of how Uniswap
designed ticks to work. The difference between ticks 197340 and 217360 (2002 tick spaces) is 2,328 USD/ETH; while the difference
between 203390 and 223340 (1995 tick spaces) is 1,270 USD/ETH.
It is best to think of tick spacing as the relative "narrowness" around the price at the time the position
was opened.
```{r}
ethusdc <- ethusdc %>% mutate(
lower_tick_price = tick_to_price(ethusdc$TICK_LOWER, decimal_adjustment = 1e12, yx = TRUE),
upper_tick_price = tick_to_price(ethusdc$TICK_UPPER, decimal_adjustment = 1e12, yx = TRUE)
) %>% mutate(
range_in_usd = lower_tick_price - upper_tick_price
)
```
For completeness, here is the same plot with tick_spacing converted to the position specific
range in USD/ETH. Ranges of 1,000 USDC/ETH to 11,000 USDC/ETH are very wide (10,000 Range) and somewhat rare.
They also perform somewhat chaotically.
```{r}
key_scatter(tbl = ethusdc, x = "range_in_usd", y = "eth_epp",
xlab = "Tick Spaces as Range in USD/ETH price",
ylab = "ETH % Gain over HODL",
title = "") %>%
layout(
xaxis = list(range = c(0, 20000))
)
```
Because wide ranges are at a lower risk of ending up 100% in a single asset they tend to have
both lower downside and lower upside risks. Here, positions wider than 2000 tick spaces
broke even with HODL on average and median, but had much higher minimums and lower maximums.
```{r}
reactable(
ethusdc %>% group_by(wider_than_2000 = tick_spacing >= 2000) %>% summarise(
count = n(),
min_gain = min(eth_epp),
median_gain = median(eth_epp),
avg_gain = mean(eth_epp),
max_gain = max(eth_epp)
) %>% round(., 2)
)
```
## Position Size
Looking at Economic Profit as it relates to the size of the position using the HODL ETH
Value, `r round(100*sum(ethusdc_sig_change$hodl_eth_terms < 100)/nrow(ethusdc_sig_change), 2)`% of
positions were < 100 ETH in size.
Splitting by large (100+ ETH) doesn't show much difference in typical gain (both groups lose on average).
But larger positions have a smaller range of gains/losses versus HODL possibly indicating more professionality in position creation.
```{r}
reactable(
ethusdc %>% group_by(large = hodl_eth_terms >= 100) %>% summarise(
count = n(),
min_gain = min(eth_epp),
median_gain = median(eth_epp),
avg_gain = mean(eth_epp),
max_gain = max(eth_epp)
) %>% round(., 2)
)
```
```{r}
key_scatter(tbl = ethusdc, x = "hodl_eth_terms", y = "eth_epp",
xlab = "Position Size in ETH if HODL'd",
ylab = "ETH % Gain over HODL",
title = "") %>%
layout(
xaxis = list(range = c(0, 500))
)
```
## Vault Managed Positions
Do we see Vaults, like Gamma.xyz outperform on profitability over individually managed positions?
A vault is identifiable by it's lack of NF Token ID, making its unique_id a concatenation of
NF_POSITION_MANAGER_ADDRESS--TICK_LOWER--TICK_UPPER.
```{r}
ethusdc$vault <- ifelse(grepl("^0x", ethusdc$unique_id), "Vault", "Individual")
ethusdc_sig_change <- ethusdc %>%
filter(usdc_epp > 1 | usdc_epp < -1 | eth_epp > 1 | eth_epp < -1)
```
Curiously, although there are `r sum(ethusdc$vault == "Vault")` Vaults overall,
only `r sum(ethusdc_sig_change$vault == "Vault")` had significant differences from HODLing the assets
(at least 1% difference in either direction).
```{r}
reactable(
ethusdc %>% group_by(vault == "Vault") %>% summarise(
count = n(),
min_gain = min(eth_epp),
median_gain = median(eth_epp),
avg_gain = mean(eth_epp),
max_gain = max(eth_epp)
) %>% round(., 2)
)
```
Of the vaults that did result in a noticeable difference from HODLing, they lightly beat individual Uniswap v3 NFT Managed Positions (i.e., Individuals) on average without
any of the rare excess wins or losses.
`r sum(ethusdc_sig_change$vault == "Vault" & ethusdc_sig_change$eth_epp >= 5)` of
the positions managed by vaults matched or exceeded +5% of HODL compared to
`r sum(ethusdc_sig_change$vault == "Individual" & ethusdc_sig_change$eth_epp >= 5)` individuals.
```{r}
reactable(
ethusdc_sig_change %>% group_by(vault == "Vault") %>% summarise(
count = n(),
min_gain = min(eth_epp),
median_gain = median(eth_epp),
avg_gain = mean(eth_epp),
max_gain = max(eth_epp)
) %>% round(., 2)
)
```
```{r, warning=FALSE}
plot_ly(ethusdc_sig_change, x = ~eth_epp, color = ~vault, type = "histogram", nbinsx = 200) %>%
layout(
xaxis = list(range = c(-10, 10), title = "ETH % Gain over HODL"),
title = list(text = "Vaults generally match HODL \n (-1%,1%) removed", y = 0.975)
)
```
# Is there a winning Strategy?
What would be most interesting, would be to find a pattern among short lifespan positions that were profitable, since annualized, the same profit percentage is better when it is accumulated in shorter time spans.
Labeling positions with > 1% ETH denominated Economic Profit over HODL with a lifespan under 100,000 blocks (~ 2 weeks)
we can see if there are any discernible differences in this "Elite" population relative to similar "Burst" positions of the same lifespan.
```{r}
key_scatter(ethusdc_sig_change, x = "lifespan", y = "eth_epp",
xlab = "Lifespan in # Blocks",
ylab = "ETH % Gain over HODL",
title = "Short positions generally lose, is there a pattern for winners?",
name = "Burst Positions") %>%
add_trace(data = ethusdc_sig_change %>% filter(lifespan <= 100000 & eth_epp > 1),
x = ~lifespan, y = ~eth_epp, type = "scatter", mode = "markers", name = "Elite Bursts") %>%
layout(
xaxis = list(range = c(0, 100000)),
yaxis = list(range = c(-10, 10))
)
```
# Elite "Burst" Positions
```{r}
ethusdc <- ethusdc %>% mutate(
elite = (lifespan <= 100000 & eth_epp > 1)
)
```
## Start Block
Looking across all positions `r sum(ethusdc$elite)` (`r round(100*sum(ethusdc$elite)/nrow(ethusdc), 2)`%) meet the criteria for elite. One positive is that they have been opened throughout the time period of interest which may indicate it is possible to create such positions regardless of the broader macro environment.
```{r}
key_scatter(ethusdc, x = "start_block", y = "eth_epp",
xlab = "Position Opening Block",
ylab = "ETH % Gain over HODL",
title = "Elite positions found throughout ETHUSDC History") %>%
add_trace(data = ethusdc %>% filter(lifespan <= 100000 & eth_epp > 1),
x = ~start_block, y = ~eth_epp, type = "scatter", mode = "markers", name = "Elite") %>%
add_lines(x = 13917000, y = c(-30, 30), name = "Jan 1, 2022")
```
## Tick Width
Curiously, only `r sum(ethusdc$elite == TRUE & ethusdc$tick_spacing > 2000)` elite positions exceed
2000 tick spacings.
This indicates strategic use of liquidity, by concentrating liquidity they can accrue a larger
share of fees, at the risk of overselling the winning asset over the position lifespan.
```{r}
key_scatter(tbl = ethusdc, x = "tick_spacing", y = "eth_epp",
xlab = "Tick Spaces",
ylab = "ETH % Gain over HODL",
title = "The Elite positions are considerably narrow") %>%
add_trace(data = ethusdc %>% filter(lifespan <= 100000 & eth_epp > 1),
x = ~tick_spacing, y = ~eth_epp, type = "scatter", mode = "markers", name = "Elite") %>%
layout(
xaxis = list(range = c(0, 10000))
)
```
## Position Size
In (HODL) ETH terms, elite positions are typically tightly scoped which may
indicate large market makers are purposefully wide and under-performant to reduce risks of divergent loss.
Making these elite positions high risk, high reward and thus lower in size.
```{r}
reactable(
ethusdc %>% group_by(elite) %>% summarise(
count = n(),
min_size = min(hodl_eth_terms),
median_size = median(hodl_eth_terms),
avg_size = mean(hodl_eth_terms),
max_size = max(hodl_eth_terms)
) %>% round(., 2)
)
```
## Model Differences
Looking at positions under 100,000 block lifespan and 2000 tick spacing ("Burst" positions), is there any discernible, reproducible, know-able difference between the elite burst positions (> 1% economic profit vs HODL) and other burst positions?
```{r}
burst <- ethusdc %>% filter(lifespan <= 100000, tick_spacing <= 2000)
burst_sig_change <- ethusdc_sig_change %>% filter(lifespan <= 100000, tick_spacing <= 2000)
```
Burst positions make up `r round(100*nrow(burst)/nrow(ethusdc), 2)`% of all closed positions historically,
indicating many liquidity providers are making an effort to extract their gains quickly with concentrated positions to accrue more fees.
This may be counter-intuitive. As the prevailing opinion is that ETH and USDC are by definition *un*correlated assets, so their relative values are very volatile. Making concentration risky as your position quickly sells the winner to buy the loser. In conversation with other Uniswap Researchers, they note that the large amount of losers in Uniswap v3 ETH-USDC may in fact be those performing "delta-neutral" strategies between DeFi and Central Exchanges.
The idea being that while you lose against HODL in Uniswap, you keep your total allocation balanced by buying the rising asset on Central Exchanges to counter Uniswap's AMM.
Because this is implicitly off-chain information it is not analyzed further here. But it is useful nuance for understanding why some of the biggest losing positions against HODL made it so far into the negative.
An immediately apparent stat is that these burst positions make up `r round(100*nrow(burst_sig_change)/nrow(ethusdc_sig_change), 2)`% of positions with at least a 1% difference
in Economic Profit (Strategy vs HODL in either direction), indicating they are disproportionately a "wash" in
being almost equal to HODLing.
This would align with the "delta neutral" nuance noted by other researchers, and may be a positive sign if burst positions have a lower downside risk that longer held positions.
```{r}
plot_ly(alpha = 0.45,
data = ethusdc %>% filter(lifespan > 100000 | tick_spacing > 2000),
x = ~eth_epp,
nbinsx = 200,
name = "Non-Burst",
type = "histogram"
) %>%
add_histogram(data = ethusdc %>% filter(lifespan <= 100000 & tick_spacing <= 2000),
nbinsx = 100,
x = ~eth_epp,
name = "Burst Positions") %>%
layout(
barmode = "overlay",
title = list(text = "", y = 0.975),
xaxis = list(title = "ETH % Gain over HODL", range = c(-20,20)),
yaxis = list(title = "# Positions", range = c(0, 2500))
)
```
At a cursory glance burst positions do seem to have higher minimums and lower maximums.
```{r}
reactable(
ethusdc %>% group_by(burst = lifespan <= 100000 & tick_spacing <= 2000) %>% summarise(
count = n(),
min_gain = min(eth_epp),
median_gain = median(eth_epp),
avg_gain = mean(eth_epp),
max_gain = max(eth_epp)
) %>% round(., 2)
)
```
Among the `r nrow(burst)` burst positions `r sum(burst$elite)`,
(`r round(100 * sum(burst$elite)/nrow(burst), 2)`%) are elite. This is highly unbalanced.
It is possible this is pure dumb luck.
Using a simple logistic regression let's see if within this short lifespan and narrow range
of positions, if local differences in lifespan, spacing, and position size are useful in assessing the
probability of a position being elite among the burst positions (<= 100,000 blocks & <= 2000 tick spaces).
```{r}
model1 <- glm(elite ~ lifespan + tick_spacing + hodl_eth_terms,
family = binomial(), data = burst)
summary(model1)
```
The model identifies lifespan, position width, and position size as all "statistically significant"
coefficients for burst positions.
- By default, the odds of a burst position being elite are ~7.4%
- Increasing the lifespan by 1 block adds a very small chance of being elite, this make sense as you need time to accrue fees.
- Increasing the tick spacing (1 unit = 10 tick width in this pool) reduces the marginal probability
of being elite; implying that *reducing* the tick spacing has a small marginal improvement to the chance of being elite. This makes sense as it increases your effective (concentrated) liquidity.
- Having a 1 ETH larger position has a very small reduction in the probability of being elite, which makes sense as we saw elite positions were on average 95%+ smaller than non-elite positions. Probably more of a data artifact than a usable insight though.
```{r}
probability <- function(val){
exp(val)/(1 + exp(val))
}
reactable(
data.frame(
marginal_probability_elite = 100*probability(model1$coefficients)
) %>% round(., 4)
)
```
Loosening the definition of "elite" to just any profitable burst, let's see if a simple
logistic regression can identify any local indicators.
```{r}
burst <- burst %>% mutate(profitable = eth_epp > 0)
```
Of the `r nrow(burst)` burst positions (again, <= 100,000 blocks and <= 2,000 tick spaces), `r sum(burst$profitable)`
(`r round(100 * sum(burst$profitable)/nrow(burst), 2)`)% were profitable over HODL.
This is a large increase in the target data.
```{r}
model2 <- glm(profitable ~ lifespan + tick_spacing + hodl_eth_terms,
family = binomial(), data = burst)
summary(model2)
```
When looking at the looser group of any profitable position over HODL among burst positions
Everything becomes essentially pure luck.
- By default, profitability is 55.9% likely.
- Knowing that 56.21% of positions were profitable in the data, there just isn't much
variance left for lifespan, position width and position size to explain.
```{r}
reactable(
data.frame(
marginal_profitability = 100*probability(model2$coefficients)
) %>% round(., 4)
)
```
## Checking Log Concavity
It may be that 2,000 tick spaces is too large a cutoff. In the ETH-WBTC Analysis,
a (only marginally!) narrower 200 tick spaces was used instead for understanding burst positions.
That pool's minimum tick width is 60 unlike ETH-USDC's 10, and those assets are correlated so a direct comparison of narrowness is not appropriate.
Removing positions that exactly matched HODL (required to log) and reviewing the log of ETH % Gain over HODL there may be some concavity in gains at more narrow tick spacings,
especially around 100 spacings where the LOESS smooth line peaks at a (un-log'd) 0.86% profitable over HODL.
```{r, warning = FALSE, message = FALSE}
ggplotly(
ggplot(data = burst, aes(x = tick_spacing, y = log(eth_epp))) +
geom_point() +
geom_smooth(se = FALSE) +
theme_classic()
) %>%
layout(
xaxis = list(title = "Tick Spacing"),
yaxis = list(title = "LOG ETH % Gain over HODL"),
title = list(text = "Visible Concavity in Very Narrow Positions", y = 0.975)
)
```
# Conclusion
## Very Different than ETH-WBTC
The ETH-WBTC 0.3% pool analysis has much more actionable results. JIT was much less
prevalent and because that pool is more correlated it is more clear that low risk profit is available with patience.
This pool is more chaotic and even position > 2 million blocks (10+ months) have some
losses versus HODL.
```{r}
key_scatter(ethusdc, x = "lifespan", y = "eth_epp",
xlab = "Lifespan in # Blocks",
ylab = "ETH % Gain over HODL",
title = "ETH-USDC 0.05% Pool by Lifespan")
```
## JIT is probably exaggerated
1 Block positions, as expected, generally use the narrowest possible range (1 tick space).
These positions beat HODL in ETH terms `r round(100 * sum(ethusdc[ethusdc$lifespan == 1, ]$eth_epp > 0)/nrow(ethusdc[ethusdc$lifespan == 1, ]), 2)`% of the time.
When annualized, gaining the median `r round(100 * median(ethusdc[ethusdc$lifespan == 1, ]$eth_epp), 2)`% can seem sky high, but curiously the average gain against HODL is actually `r round(100 * mean(ethusdc[ethusdc$lifespan == 1, ]$eth_epp), 2)`%.
JIT is no guarantee, it is probably best to consider these quirky evidence of
on-chain order book filling via the memory pool than a threat to the system.
```{r}
key_scatter(tbl = ethusdc[ethusdc$lifespan == 1, ],
x = "tick_spacing", y = "eth_epp",
xlab = "Tick Spaces of 1-Block Lifespan Positions",
ylab = "ETH % Gain over HODL",
title = "1-Block Positions are not guaranteed to beat HODL") %>%
layout(
xaxis = list(range = c(0, 20))
)
```
## Participants size for risk
The elite positions: over 1% gain against HODL in <= 100,000 blocks with <= 2,000 tick spaces are 50% smaller than non-elite positions at the median.
## Professionals are Active
Going 1 layer behind the Uniswap v3 positions there are some experts who consistently
create elite positions.
```{r}
lps_lp <- lps %>% filter(unique_id %in% ethusdc$unique_id) %>% select(unique_id, LIQUIDITY_PROVIDER) %>% distinct()
all_ <- merge(ethusdc, lps_lp, all.x = TRUE, all.y = TRUE, by = "unique_id")
experts <- all_ %>%
filter(elite == TRUE)
atable <- as.data.frame(table(all_$LIQUIDITY_PROVIDER))
colnames(atable) <- c("individual_lp", "num_positions")
etable <- as.data.frame(table(experts$LIQUIDITY_PROVIDER))
colnames(etable) <- c("individual_lp", "num_elites")
etable <- merge(etable, atable, all.x = TRUE, by = "individual_lp")
etable$elite_percent <- round(100*etable$num_elites/etable$num_positions, 2)
reactable(
etable[order(etable$num_elites, decreasing = TRUE), ]
)
```
The `r length(unique(etable$individual_lp))` individual Liquidity Providers are associated with
`r sum(etable$num_elites)` elite positions, this is because positions in NFT form are transferable, so they may be buying and selling the same `r sum(length(unique(experts$unique_id)))` unique positions.
More interestingly there are `r as.numeric(etable %>% filter(num_elites > 1) %>% summarise(length(unique(individual_lp))))` repeat elite position liquidity providers.
These repeat experts are real outliers with a median of `r as.numeric(etable %>% filter(num_elites > 1) %>% summarise(median(elite_percent)))`% of their positions created being elite (note: there may be a small number of double counting for positions that are transferred).
Comparing this to the 7.4% model intercept for the probability of a position being Elite,
one could argue that `r round(100*as.numeric(etable %>% filter(num_elites > 1) %>% summarise(length(unique(individual_lp))))/length(unique(lps$LIQUIDITY_PROVIDER)), 2)`% of all liquidity providers are ~3x+ as skilled as the average liquidity provider.