Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Discussion options

Hi,

Currently I play around with the V2 version of this library, to figure out how to propperly work with the helper functions.

I build the following test strategy to try out my assumptions:

// TestStrategy defines a basic test strategy for testing purposes.
type TestStrategy struct{}

func (s *TestStrategy) Name() string {
	return "TestStrategy"
}

func (s *TestStrategy) Compute(c <-chan *asset.Snapshot) <-chan strategy.Action {
	snapshots := helper.Duplicate(c, 1)

	i := 0
	actions := helper.Map(snapshots[0], func(snapshot *asset.Snapshot) strategy.Action {
		action := strategy.Hold

		if i%42 == 20 {
			action = strategy.Buy
		} else if i%42 == 41 {
			action = strategy.Sell
		}

		log.Println("Action: ", action, " i: ", i, " Date: ", snapshot.Date)

		i++

		return action
	})

	return actions
}

// Report processes the provided asset snapshots and generates a report annotated with the recommended actions.
func (s *TestStrategy) Report(c <-chan *asset.Snapshot) *helper.Report {
	snapshots := helper.Duplicate(c, 3)

	dates := asset.SnapshotsAsDates(snapshots[0])

	report := helper.NewReport(s.Name(), dates)
	chart1 := report.AddChart()
	chart2 := report.AddChart()

	closings := asset.SnapshotsAsClosings(snapshots[1])
	closingSplice := helper.Duplicate(closings, 2)

	actions, outcomes := strategy.ComputeWithOutcome(s, snapshots[2])

	annotations := helper.Skip(
		strategy.ActionsToAnnotations(actions),
		0,
	)

	outcomes = helper.MultiplyBy(
		outcomes,
		100,
	)

	// Add the columns to the report for chart 0
	report.AddColumn(helper.NewNumericReportColumn("Close", closingSplice[0]))

	// Add the columns to the report for chart 1
	report.AddColumn(helper.NewNumericReportColumn("Close", closingSplice[1]), chart1)

	// Add the columns to the report for chart 0 and 1
	report.AddColumn(helper.NewAnnotationReportColumn(annotations), 0, chart1)
	report.AddColumn(helper.NewNumericReportColumn("Outcome", outcomes), chart2)

	return report
}

As you can see in the Compute(), I simply duplicate the incoming channel once (even if not necessary, just for testing it).

In this case, it works fine!

For the first question lets assume the following:
Simply adding another consumer (helper.ChanToSlice() producing a slice from the channel) and increasing the duplication to 2

        snapshots := helper.Duplicate(c, 2)

	volumeSlice := helper.ChanToSlice(asset.SnapshotsAsVolumes(snapshots[0]))
	_ = volumeSlice

Question0: Why would this break the strategy? It hangs forever on the helper.ChanToSlice()... Why wouldnt this work?

Question1: I assume the helper.Map method would simply map through all the values in the given channel. If there is this Map function, why is there an Operate and Operate3 needed? Cant the same be acchieved with both?

... will add more questions later. Thanks in advance for clarifying 💯

You must be logged in to vote

Replies: 6 comments · 23 replies

Comment options

Hi! Definitely very much curious to hear your thoughts after using the library. Let me see if I can help answering your questions.

Question 0: Most helper functions, except the Drain and ChanToSlice are async in nature. They strat a go routine, and return a channel for their output. Drain and ChanToSlice are synchronous as they block and consume the given channel until it ends.

In your example, as soon as you put the ChanToSlice, it must be blocking things. As the function won't return, no one can consume from the returned Action channel, the Map function on snapshot won't continue.

Dealing with the channels in this way is a little tricky. I was getting stuck a lot at the beginning also.

Question 1: You are correct. My initial design was Map to act as a mapping function, taking one channel of a type, and returning a new channel with a different type. Like taking a channel of Snapshot and returning a channel of Action.

Operate takes 2 input channels, and return an output channel. It does some operation using the given 2 input channels to produce the output channel.

Operate 3 takes input channels.

I want to merge them all under a single helper function but I couldn't find a nice way of doing so yet.

I hope these helps you to proceed further.

You must be logged in to vote
3 replies
@heiningair
Comment options

Hi @cinar,

Thanks for the extensive response. I really apprechiate it.

Question 0
I got that there are blocking and non blocking methods and I think in my case it looks like some part of the TestStrategy is not consuming anymore, as soon as i introduce any Indicator or use a blocking function like ChanToSlice.

In general what I do to test all this is:

  • setting up the TestStrategy
  • prepared a Test Repository which populates the snapshots channel initially
  • setup and Run() a backtest aka generate a report

( * additionally I simply created a Operate5 just for testing purposes following the same structure as done for the Operate3)

func Operate5[A any, B any, C any, D any, E any, R any](ac <-chan A, bc <-chan B, cc <-chan C, dc <-chan D, ec <-chan E, o func(A, B, C, D, E) R) <-chan R {
	rc := make(chan R)

	go func() {
		defer close(rc)

		for {
			an, ok := <-ac
			if !ok {
				break
			}

			bn, ok := <-bc
			if !ok {
				break
			}

			cn, ok := <-cc
			if !ok {
				break
			}

			dn, ok := <-dc
			if !ok {
				break
			}

			en, ok := <-ec
			if !ok {
				break
			}

			rc <- o(an, bn, cn, dn, en)
		}

		helper.Drain(ac)
		helper.Drain(bc)
		helper.Drain(cc)
		helper.Drain(dc)
		helper.Drain(ec)
	}()

	return rc
}

when i followed the code correctly it just runs through every strategy and computes the actions and outcomes using the populated snapshots from the repo.

The thing is that depending on what I include in the compute function of the TestStrategy, it either retrieves the snapshots, or it doesnt as soon as I introduce Indicators. (complete test strategy posted below)

// TestStrategy defines a basic test strategy for testing purposes.
type TestStrategy struct {
	Donchian *volatility.DonchianChannel[float64]
}

// NewTestStrategy creates a new TestStrategy instance.
func NewTestStrategy(period int) *TestStrategy {
	return &TestStrategy{
		Donchian: volatility.NewDonchianChannelWithPeriod[float64](period),
	}
}

func (s *TestStrategy) Name() string {
	return "TestStrategy"
}

func (s *TestStrategy) Compute(c <-chan *asset.Snapshot) <-chan strategy.Action {
	snapshots := helper.Duplicate(c, 3)

	volumes := asset.SnapshotsAsVolumes(snapshots[0])
	//_ = volumeSlice

	closings := asset.SnapshotsAsClosings(snapshots[1])

	// Donchian Channels indicator
	u, m, l := s.Donchian.Compute(closings)

	u = helper.Skip(u, s.Donchian.IdlePeriod())
	m = helper.Skip(m, s.Donchian.IdlePeriod())
	l = helper.Skip(l, s.Donchian.IdlePeriod())

	i := 0
	actions := Operate5(snapshots[2], volumes, u, m, l, func(snapshot *asset.Snapshot, volume int64, upperBandValue, middleBandValue, lowerBandValue float64) strategy.Action {
		action := strategy.Hold

		if i%42 == 20 {
			action = strategy.Buy
		} else if i%42 == 41 {
			action = strategy.Sell
		}

		log.Println("Action: ", action, " i: ", i, " Date: ", snapshot.Date, " Volume: ", volume, " Upper Band: ", upperBandValue, " Middle Band: ", middleBandValue, " Lower Band: ", lowerBandValue)

		i++

		return action
	})

	// Shift actions until strategy is ready.
	actions = helper.Shift(actions, s.Donchian.IdlePeriod(), strategy.Hold)

	return actions
}

// Report processes the provided asset snapshots and generates a report annotated with the recommended actions.
func (s *TestStrategy) Report(c <-chan *asset.Snapshot) *helper.Report {
	snapshots := helper.Duplicate(c, 3)

	//prepare dates
	dates := asset.SnapshotsAsDates(snapshots[0])
	dates = helper.Skip(dates, s.Donchian.IdlePeriod())

	// prepare closing prices
	closings := asset.SnapshotsAsClosings(snapshots[1])
	closings = helper.Skip(closings, s.Donchian.IdlePeriod())
	closingSplice := helper.Duplicate(closings, 2)

	// compute the actions and outcomes
	actions, outcomes := strategy.ComputeWithOutcome(s, snapshots[2])
	// create annotations
	annotations := helper.Skip(
		strategy.ActionsToAnnotations(actions),
		s.Donchian.IdlePeriod(),
	)
	// shift the outcomes
	outcomes = helper.Skip(helper.MultiplyBy(
		outcomes,
		100,
	), s.Donchian.IdlePeriod())

	// create the report and the charts
	report := helper.NewReport(s.Name(), dates)
	chart1 := report.AddChart()
	chart2 := report.AddChart()

	// Add the columns to the report for chart 0
	report.AddColumn(helper.NewNumericReportColumn("Close", closingSplice[0]))

	// Add the columns to the report for chart 1
	report.AddColumn(helper.NewNumericReportColumn("Close", closingSplice[1]), chart1)

	// Add the columns to the report for chart 0 and 1
	report.AddColumn(helper.NewAnnotationReportColumn(annotations), 0, chart1)
	report.AddColumn(helper.NewNumericReportColumn("Outcome", outcomes), chart2)

	return report
}

Can you see anything strange in here that would lead to a blocking aka not consuming issue?

As soon as I remove the indicator out of the equasion like in the initial example or only passing one snapshot chan & the volumes to a helper.Operate() (or newly created Operate4/5/6/7) , it just works (=snapshots are there within the compute and it runs through the Operate). It is pretty hard to debug since it just does nothing (treats the snapshots as empty) in the incorrect version.

edit: I just reconfirmed, that when including "any" indicator and passing its results to an OperateN() function, it doesnt block, but it also doesnt consume the snapshots populated or duplicated on the way there...

@heiningair
Comment options

One more example....

WORKING

func (s *TestStrategy) Compute(c <-chan *asset.Snapshot) <-chan strategy.Action {
	snapshots := helper.Duplicate(c, 5)

	volumes := helper.Duplicate(asset.SnapshotsAsVolumes(snapshots[0]), 1)
	//_ = volumeSlice

	// 5 closings needed
	closings := helper.Duplicate(asset.SnapshotsAsClosings(snapshots[1]), 1)
	highPrices := asset.SnapshotsAsHighs(snapshots[2])
	lowPrices := asset.SnapshotsAsLows(snapshots[3])

	// // MACD indicator
	//macdLine, signalLine := s.Macd.Compute(closings[1])

	//macdLine = helper.Skip(macdLine, s.Macd.IdlePeriod())
	//signalLine = helper.Skip(signalLine, s.Macd.IdlePeriod())

	// // VWMA indicator
	// vwma := s.Vwma.Compute(closings[0], TransformChannel(volumes[0], func(i int64) float64 {
	// 	return float64(i)
	// }))
	//vwma = helper.Skip(vwma, s.Vwma.IdlePeriod())

	// // Donchian Channels indicator
	// u, m, l := s.Donchian.Compute(closings)

	// u = helper.Skip(u, s.Donchian.IdlePeriod())
	// m = helper.Skip(m, s.Donchian.IdlePeriod())
	// l = helper.Skip(l, s.Donchian.IdlePeriod())

	i := 0
	actions := Operate5(snapshots[4], volumes[0], highPrices, lowPrices, closings[0], func(snapshot *asset.Snapshot, volume int64, highValue, lowValue float64, closingValue float64) strategy.Action {
		action := strategy.Hold

		if i%42 == 20 {
			action = strategy.Buy
		} else if i%42 == 41 {
			action = strategy.Sell
		}

		log.Println("Action: ", action, " i: ", i, " Date: ", snapshot.Date, " Volume: ", volume, " High: ", highValue, " Low: ", lowValue, " Closing: ", closingValue)

		i++

		return action
	})

	// Shift actions until strategy is ready.
	//actions = helper.Shift(actions, s.Macd.IdlePeriod(), strategy.Hold)

	return actions
}

NOT WORKING

func (s *TestStrategy) Compute(c <-chan *asset.Snapshot) <-chan strategy.Action {
	snapshots := helper.Duplicate(c, 5)

	volumes := helper.Duplicate(asset.SnapshotsAsVolumes(snapshots[0]), 1)
	//_ = volumeSlice

	// 5 closings needed
	closings := helper.Duplicate(asset.SnapshotsAsClosings(snapshots[1]), 2)
	highPrices := asset.SnapshotsAsHighs(snapshots[2])
	lowPrices := asset.SnapshotsAsLows(snapshots[3])

	// // MACD indicator
	macdLine, signalLine := s.Macd.Compute(closings[1])

	//macdLine = helper.Skip(macdLine, s.Macd.IdlePeriod())
	//signalLine = helper.Skip(signalLine, s.Macd.IdlePeriod())

	// // VWMA indicator
	// vwma := s.Vwma.Compute(closings[0], TransformChannel(volumes[0], func(i int64) float64 {
	// 	return float64(i)
	// }))
	//vwma = helper.Skip(vwma, s.Vwma.IdlePeriod())

	// // Donchian Channels indicator
	// u, m, l := s.Donchian.Compute(closings)

	// u = helper.Skip(u, s.Donchian.IdlePeriod())
	// m = helper.Skip(m, s.Donchian.IdlePeriod())
	// l = helper.Skip(l, s.Donchian.IdlePeriod())

	i := 0
	actions := Operate7(snapshots[4], volumes[0], highPrices, lowPrices, closings[0], macdLine, signalLine, func(snapshot *asset.Snapshot, volume int64, highValue, lowValue float64, closingValue, macdValue, signalValue float64) strategy.Action {
		action := strategy.Hold

		if i%42 == 20 {
			action = strategy.Buy
		} else if i%42 == 41 {
			action = strategy.Sell
		}

		log.Println("Action: ", action, " i: ", i, " Date: ", snapshot.Date, " Volume: ", volume, " High: ", highValue, " Low: ", lowValue, " Closing: ", closingValue)

		i++

		return action
	})

	// Shift actions until strategy is ready.
	//actions = helper.Shift(actions, s.Macd.IdlePeriod(), strategy.Hold)

	return actions
}
@heiningair
Comment options

Another finding:

It also works if i only compute the macd like so:

func (s *TestStrategy) Compute(c <-chan *asset.Snapshot) <-chan strategy.Action {
	snapshots := helper.Duplicate(c, 1)

	closings := asset.SnapshotsAsClosings(snapshots[0])

	// MACD indicator
	macdLine, signalLine := s.Macd.Compute(closings)

	macdLine = helper.Skip(macdLine, s.Macd.IdlePeriod())
	signalLine = helper.Skip(signalLine, s.Macd.IdlePeriod())

	actions := helper.Operate(macdLine, signalLine, func(macdValue, signalValue float64) strategy.Action {
		action := strategy.Hold

		if macdValue > signalValue {
			action = strategy.Buy
		} else if macdValue < signalValue {
			action = strategy.Sell
		}

		return action
	})

	return actions
}
Comment options

You must be logged in to vote
1 reply
@heiningair
Comment options

thats great, thank you!

Current state to what I could break it down to is, that as soon as I Duplicate snapshots to retrieve highs, lows and closings

        snapshots := helper.Duplicate(c, 3)

	highPrices := asset.SnapshotsAsHighs(snapshots[2])
	lowPrices := asset.SnapshotsAsLows(snapshots[1])

	closings := asset.SnapshotsAsClosings(snapshots[0])

	// // MACD indicator
	macdLine, signalLine := s.Macd.Compute(closings)

and introduce then also pass them on to the Operate4

actions := Operate4(highPrices, lowPrices, macdLine, signalLine, func(high, low, macdValue, signalValue float64) strategy.Action {
		action := strategy.Hold

		if i%42 == 20 {
			action = strategy.Buy
		} else if i%42 == 41 {
			action = strategy.Sell
		}

		log.Println("Action: ", action, " i: ", i)

		i++

		return action
	})

it doesnt work anymore.

It works without the duplication of the snapshots and also when duplication is set to 1.

Comment options

Let me start by answering #159 (reply in thread) first.

I think the not consuming issue is most likely due to the data that you're using. In my case, I used the brk-b.csv file that is used in most of the tests. It goes until 2023-11-29. However the Backtest by default only checks for the data since Now() - 30 days, so when you run Bactest on this, it doesn't produce any results. (I guess I probably need to use the LastDate() from the Repository, and not the current date. I'll probably fix this, as I agree that it is confusing.)

backtest := strategy.NewBacktest(repository, "output")
backtest.LastDays = 365

I also had to make a few minor changes in your code. Most specifically around not Skipping the results of the Donchian indicator, but Skipping the Volumes and Snapshots[2] to align them correctly with those.

volumes = helper.Skip(volumes, s.Donchian.IdlePeriod())
snapshots[2] = helper.Skip(snapshots[2], s.Donchian.IdlePeriod())

Here is the modified code:
https://replit.com/@onurcinar/Discussion159

Issue: #160 (comment)

You must be logged in to vote
6 replies
@Vextasy
Comment options

I had also hit some issues where things blocked. I love the clean ‘everything is a channel’ design but it does make it quite hard to determine the cause of the blocking.

In my code I had been reporting on 23 strategies for each of about 20 assets and I found that certain strategies would cause the blocking with certain assets. It turned out to be that the assets that caused the problems were ones that didn’t have sufficient data points to satisfy the requirements of one or two strategies.

My rather crude solution was to test each asset/strategy combination for sufficient data before running the strategy report. I’m not proud of the code, but since I introduced it I have had no blocking issues at all. Unfortunately, in order to find out how much data is upstream, it breaks the channel model by converting it to a slice so that the length of data can be determined.

So my function to run a strategy report against an asset looks like this:

func runReport(st strategy.Strategy, assetName string, data <-chan *asset.Snapshot) {
	data, _, datalen := duplicateChan(data)
	// Detect certain strategies that require a minimum amount of data.
	if notEnoughData(st, assetName, datalen) {
		return
	}
	rep := st.Report(data)
	…
}

Where duplicateChan and notEnoughData are:

func duplicateChan(cin <-chan *asset.Snapshot) (<-chan *asset.Snapshot, <-chan *asset.Snapshot, int) {
	slice := helper.ChanToSlice(cin)
	cout0 := helper.SliceToChan(slice)
	cout1 := helper.SliceToChan(slice)
	return cout0, cout1, len(slice)
}

func notEnoughData(st strategy.Strategy, assetName string, datalen int) bool {
	msg := fmt.Sprintf("Ignoring strategy %s for %s due to insufficient data: %s", st.Name(), assetName, st.Name())
	if st.Name()[0:4] == "MACD" {
		if datalen < trend.NewMacdStrategy().Macd.Ema2.Period {
			fmt.Println(msg)
			return true
		}
	}
	if st.Name() == "Awesome Oscillator Strategy" {
		if datalen < momentum.NewAwesomeOscillatorStrategy().AwesomeOscillator.LongSma.Period {
			fmt.Println(msg)
			return true
		}
	}
	return false
}

This was my first time using the indicator package and I was in a hurry to make things work, but after trying lots of things this was the solution to my particular blocking issue. I need to go back to look more closely at it. But it did feel like certain strategies required a minimum number of data points to Compute or Report without blocking. In looking at it now I think that my use of the Period is almost certainly not the right value to use - but it does feel like there is perhaps a minimum count of data below which certain strategies can not be run without blocking.

@cinar
Comment options

Yes, unfortunately, this is a common problem now with the channels. I will think if I can generalize the solution here and see if I can at least find a way to incorporate it to help troubleshooting.

Yes, instead of using the Periods, you can use the IdlePeriod() method. It will give you after how many period the indicator will be generating values. I will most likely rename that method very soon.

@Vextasy
Comment options

I have played around a little further with things and I have one other observation.

If I replace helper.Duplicate with this code:

func Duplicate[T any](input <-chan T, count int) []<-chan T {
	outputs := make([]chan T, count)
	result := make([]<-chan T, count)

	inputSlice := ChanToSlice[T](input)

	for i := range outputs {
		outputs[i] = make(chan T, cap(input))
		result[i] = outputs[i]
	}

	for _, output := range outputs {
		go func() {
			defer close(output)

			for _, n := range inputSlice {
				output <- n
			}
		}()
	}

	return result
}

Then my blocking problems go away and I don't need my duplicateChan and my notEnoughData functions above.
The two main differences between this version of helper.Duplicate and the current one are that a) this one does create a slice from the channel, and b) it creates a goroutine to supply each output channel and so doesn't require that none of the output channels block before we have finished distributing all of the input data.

@cinar
Comment options

Unfortunately, the ChanToSlice there is a blocking function. It will consume the entire input channel first before moving to the rest. So I agree that it will solve the problem if you have a finite data, but this will break the real-time use case. I do like your idea of having separate go routines for each duplicate. Let me see if I can think of a way to do this.

One other suggestion for you is to use the helper.Buffered() function. Ideally, you can buffer the output channels and it should have a similar impact for you.

@heiningair
Comment options

Good day everybody..

Does that all mean that the library like it is right now is not really built to combine multiple indicators into larger custom strategies?
I also really like how this lib is making use of the channels, but the side effects of that (figuring out the correct periods.. not being able to debug or test the channel population..) make it currently unusable for my purpose of combining multiple indicators into one custom strategy.

A reference to what i mean would be this here: https://github.com/twopirllc/pandas-ta
There you can set up cutom strategies by simply defining all the contained indicators for example.

Is this similar to how it was planned for version 2?

Comment options

Regarding #159 (reply in thread)

I was able to also fix it by skipping the highPrices and lowPrices by s.Macd.IdlePeriod() to align with the results of the s.Macd.

// Skipping highPrices and lowPrices to align with the MACD results
highPrices = helper.Skip(highPrices, s.Macd.IdlePeriod())
lowPrices = helper.Skip(lowPrices, s.Macd.IdlePeriod())

Changed code is here:
https://replit.com/@onurcinar/Discussion159#comment2/comment2.go

It sounds like I need to add more documentation, or perhaps a guide for writing the strategies, and emphasis how the IdlePeriod should be used. I see that it is a confusing thing at this point. What do you think?

You must be logged in to vote
0 replies
Comment options

You must be logged in to vote
8 replies
@heiningair
Comment options

Thank you for considering these principles! It sounds exactly like what is needed. An addition like this would improve it significantly 👍🏻

Cant wait to test it.

@cinar
Comment options

Starting to work on it, let's see how it will go.

#192

@cinar
Comment options

I got most of them updated (#193). All strategies are now returning denormalized actions. The reports/charts are still using normalized actions. I updated And, Or, Majority, and Split strategies as well. However, they are currently using the ActionsSource, which does a DenormalizeActions, and it should be replacing Hold with either Buy or Sell currently. I am not fully clear on how to handle that portion yet, so that's the only remaining change right now.

Could you take a quick look and let me know?

@heiningair
Comment options

Super cool thanks, i will take a look and let you know when i went through it. Will happen in the next few hours.

@heiningair
Comment options

Using an AndStrategy does a denormalization on the actions and should cluster them.
-> the denormalization indeed creates these clusters of the same actions

The strategies should produce nonormalized actions, when executed seperately. ( No AndStrategy aka. no denormalization)
-> produced actions are also clustered (reminding me a bit of how the denormalized actions also look like).

All in all things seem to work properly on the strategy composition side.

To me it is not 100% clear though, how the actions (produced when the ActionsSource is NOT used) differ from the the ones produced via Denormalize within the ActionSource. Both seem to cluster the actions together or maybe it is just a "data coincidence".
My expected outcome for running a single strategy would have been "non clustered" actions similar to how the actions were produced in V1. But like mentioned that could also be a coincidence.

Comment options

Hello Cinar,

Is there still the plan to build a helper function to help synchronize multiple indicators within one custom strategy?

You must be logged in to vote
5 replies
@cinar
Comment options

I definitely would like to do that. I just forgot about it. Adding an issue on this #211 . I will try to put together a solution over the weekend.

@heiningair
Comment options

great to hear that! Thx

@cinar
Comment options

It turned out to be much simpler than I anticipated, but not sure if this will work for your use case. Could you take a look at the #213?

@heiningair
Comment options

Ok wow, that was quick! Thanks for looking into that

I was moving on to another topic within my project for a while, so in order to tell you if that would work in my use case, I need a few days and get back into it.. Will answer you that question soon.

@heiningair
Comment options

So I am trying to wrap my head around this..

Could you please provide an example on how one could use this to synchronize multiple indicators?

Maybe the following szenario:

  • One CustomStrategy that combines macd, bollingerbands and vwma indicators and uses an Operate function to operate synced channels which all should have the same length if the sync worked correctly.

Actually this is a simplified version of what I am trying to achieve, but I am not sure how to use the Sync function in this szenario.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
🙏
Q&A
Labels
None yet
3 participants
Morty Proxy This is a proxified and sanitized view of the page, visit original site.