text:
import Pkg; Pkg.add(Pkg.PackageSpec(url="https://github.com/JuliaComputing/JuliaAcademyData.jl"))
using JuliaAcademyData; activate("Foundations of machine learning")

# ## Intro to neurons
#
# At this point, we know how to build models and have a computer automatically learn how to match the model to data. This is the core of how any machine learning method works.
#
# Now, let's narrow our focus and look at **neural networks**. Neural networks (or "neural nets", for short) are a specific choice of a **model**. It's a network made up of **neurons**; this, in turn, leads to the question, "what is a neuron?"

#-

# ### Models with multiple inputs
#
# So far, we have been using the sigmoid function as our model. One of the forms of the sigmoid function we've used is
#
# $$\sigma_{w, b}(x) = \frac{1}{1 + \exp(-wx + b)},$$
#
# where `x` and `w` are both single numbers. We have been using this function to model how the amount of the color green in an image (`x`) can be used to determine if an image shows an apple or a banana.
#
# But what if we had multiple data features we wanted to fit?
#
# We could then extend our model to include multiple features like
#
# $$\sigma_{\mathbf{w},b}(\mathbf{x}) = \frac{1}{1 + \exp(-w_1 x_1 - w_2 x_2 - \cdots - w_n x_n + b)}$$
#
# Note that now $\mathbf{x}$ and $\mathbf{w}$ are vectors with many components, rather than a single number.
#
# For example, $x_1$ might be the amount of the color green in an image, $x_2$ could be the height of the object in the picture, $x_3$ could be the width, and so forth. We can add information for as many features as we have! Our model now has more parameters, but the same idea of gradient descent ("rolling the ball downhill") will still work to train our model.
#
# This version of the sigmoid model that takes multiple inputs is an example of a **neuron**.

#-

# In the video, we see that one huge class of learning techniques is based around neurons, that is, *artificial neurons*. These are caricatures of real, biological neurons. Both *artificial* and *biological* neurons have several inputs $x_1, \ldots, x_n$, and a single output, $y$. Schematically they look like this:

include(datapath("scripts/draw_neural_net.jl"))

number_inputs, number_neurons = 4, 1

draw_network([number_inputs, number_neurons])

# We should read this as showing how information flows from left to right:
# - 4 pieces of input information arrive (shown in green on the left);
#
# - the neuron (shown in red on the right) receives all the inputs, processes them, and outputs a single number to the right.

#-

# In other words, a neuron is just a type of function that takes multiple inputs and returns a single output.
#
# The simplest interesting case that we will look at in this notebook is when there are just two pieces of input data:

draw_network([2, 1])

# Each link between circles above represents a **weight** $w$ that can be modified to allow the neuron to learn, so in this case the neuron has two weights, $w_1$ and $w_2$.
#
# The neuron also has a single bias $b$, and an **activation function**, which we will take to be the $\sigma$ sigmoidal function that we have been using. (Note that other activation functions can be used!)
#
# Let's call our neuron $f_{w_1,w_2, b}(x_1, x_2)$, where
#
# $$f_{w_1,w_2, b}(x_1, x_2) := \sigma(w_1 x_1 + w_2 x_2 + b).$$
#
# Note that $f_{w_1,w_2, b}(x_1, x_2)$ has 3 parameters: two weights and a bias.
#
# To simplify the notation, and to prepare for more complicated scenarios later, we put the two weights into a vector, and the two data values into another vector:
#
# $$
# \mathbf{w} = \begin{pmatrix} w_1 \\ w_2 \end{pmatrix};
# \qquad
# \mathbf{x} = \begin{pmatrix} x_1 \\ x_2 \end{pmatrix}.
# $$
#
# We thus have
#
# $$f_{\mathbf{w}, b}(\mathbf{x}) = \sigma(\mathbf{w} \cdot \mathbf{x} + b),$$
#
# where the dot product $\mathbf{w} \cdot \mathbf{x}$ is an abbreviated syntax for $w_1 x_1 + w_2 x_2$.

#-

# #### Exercise 1
#
# Declare the function `f(x, w, b)` in Julia. `f` should take vectors `x` and `w` as vectors and `b` as a scalar. Furthermore `f` should call
#
# ```julia
# σ(x) = 1 / (1 + exp(-x))
# ```
#
# What output do you get for
#
# ```julia
# f(3, 4, 5)
# ```
# ?

#-

# #### Solution

σ(x) = 1 / (1 + exp(-x))
f(x, w, b) = σ(w ⋅ x + b)   # use \cdot<TAB> to get "⋅"

## or alternatively, use juxtaposed multiplication with the transpose w'
## f(x, w, b) = σ(w'x + b)   # use \cdot<TAB> to get "⋅"

# **Tests**

@assert f(3, 4, 5) ≈ 0.99999996

max_stars_repo_path: Courses/Foundations of machine learning/1100.Intro-to-neurons.jl

max_stars_repo_name: fercarozzi/JuliaAcademyMaterials

max_issues_repo_name: fercarozzi/JuliaAcademyMaterials

max_forks_repo_name: fercarozzi/JuliaAcademyMaterials


text:
module MIToS

  export Utils, MSA, Information, PDB, SIFTS, Pfam

  include("Utils/Utils.jl")
  include("MSA/MSA.jl")
  include("Information/Information.jl")
  include("PDB/PDB.jl")
  include("SIFTS/SIFTS.jl")
  include("Pfam/Pfam.jl")

end # module

ext: jl


text:
module TestTransformer

using Test, MLJBase, MLJModels
using Tables, CategoricalArrays, Random

#### STATIC TRANSFORMER ####

t  = StaticTransformer(f=log)
f, = fit(t, 1, nothing)
@test transform(t, f, 5) ≈ log(5)

infos = info_dict(t)
@test infos[:input_scitype]  == MLJBase.Table(MLJBase.Scientific)
@test infos[:output_scitype] == MLJBase.Table(Scientific)

#### FEATURE SELECTOR ####

N = 100
X = (Zn   = rand(N),
     Crim = rand(N),
     x3   = categorical(rand("YN", N)),
     x4   = categorical(rand("YN", N)))

namesX   = Tables.schema(X).names |> collect
selector = FeatureSelector()
f,       = fit(selector, 1, X)

@test f == namesX

Xt = transform(selector, f, selectrows(X, 1:2))

@test Set(Tables.schema(Xt).names) == Set(namesX)
@test length(Xt.Zn) == 2

selector = FeatureSelector(features=[:Zn, :Crim])
f,       = fit(selector, 1, X)

@test transform(selector, f, selectrows(X, 1:2)) ==
        selectcols(selectrows(X, 1:2), [:Zn, :Crim])

infos = info_dict(selector)
@test infos[:input_scitype]  == MLJBase.Table(Scientific)
@test infos[:output_scitype] == MLJBase.Table(Scientific)

#  To be added with FeatureSelectorRule X = (n1=["a", "b", "a"], n2=["g", "g", "g"], n3=[7, 8, 9],
#               n4 =UInt8[3,5,10],  o1=[4.5, 3.6, 4.0], )
# MLJBase.schema(X)
# Xc = coerce(X,  :n1=>Multiclass, :n2=>Multiclass)

# t = Discretizer(features=[:o1, :n3, :n2, :n1])
# @test Xt.features == [:o1, :n3, :n2, :n1]
# @test Xt.is_ordinal == [true, false, false, false]
# @test Xt.A == [512 1 1 1; 1 2 1 2; 256 3 1 1]

#### UNIVARIATE DISCRETIZATION ####

# TODO: move this test to MLJBase:
# test helper function:
v = collect("qwertyuiopasdfghjklzxcvbnm1")
X = reshape(v, (3, 9))
Xcat = categorical(X)
Acat = Xcat[:, 1:4] # cat vector with unseen levels
element = Acat[1]
@test transform(element, X) == Xcat
@test transform(element, X[5]) == Xcat[5]

v = randn(10000)
t = UnivariateDiscretizer(n_classes=100);
result, = fit(t, 1, v)
w = transform(t, result, v)
bad_values = filter(v - MLJBase.inverse_transform(t, result, w)) do x
    abs(x) > 0.05
end
@test length(bad_values)/length(v) < 0.06

# scalars:
@test transform(t, result, v[42]) == w[42]
r =  inverse_transform(t, result, w)[43]
@test inverse_transform(t, result, w[43]) ≈ r

# test of permitted abuses of argument:
@test inverse_transform(t, result, get(w[43])) ≈ r
@test inverse_transform(t, result, map(get, w)) ≈
    inverse_transform(t, result, w)

# all transformed vectors should have an identical pool (determined in
# call to fit):
v2 = v[1:3]
w2 = transform(t, result, v2)
@test levels(w2) == levels(w)

#### UNIVARIATE STANDARDIZER ####

stand = UnivariateStandardizer()
f,    = fit(stand, 1, [0, 2, 4])

@test round.(Int, transform(stand, f, [0,4,8])) == [-1.0,1.0,3.0]
@test round.(Int, inverse_transform(stand, f, [-1, 1, 3])) == [0, 4, 8]

infos = info_dict(stand)

#### STANDARDIZER ####

N = 5
X = (OverallQual  = rand(UInt8, N),
     GrLivArea    = rand(N),
     Neighborhood = categorical(rand("abc", N)),
     x1stFlrSF    = rand(N),
     TotalBsmtSF  = rand(N))

# introduce a field of type `Char`:
x1 = categorical(map(Char, (X.OverallQual |> collect)))

# introduce field of Int type:
x4 = [round(Int, x) for x in X.x1stFlrSF]

X = (x1=x1, x2=X[2], x3=X[3], x4=x4, x5=X[5])

stand = Standardizer()
f,    = fit(stand, 1, X)
Xnew  = transform(stand, f, X)

@test Xnew[1] == X[1]
@test std(Xnew[2]) ≈ 1.0
@test Xnew[3] == X[3]
@test Xnew[4] == X[4]
@test std(Xnew[5]) ≈ 1.0

stand.features = [:x1, :x5]
f,   = fit(stand, 1, X)
Xnew = transform(stand, f, X)
f,   = fit(stand, 1, X)

@test issubset(Set(keys(f)), Set(Tables.schema(X).names[[5,]]))

Xt = transform(stand, f, X)

@test Xnew[1] == X[1]
@test Xnew[2] == X[2]
@test Xnew[3] == X[3]
@test Xnew[4] == X[4]
@test std(Xnew[5]) ≈ 1.0

infos = info_dict(stand)

@test infos[:name] == "Standardizer"
@test infos[:input_scitype] == MLJBase.Table(Scientific)
@test infos[:output_scitype] == MLJBase.Table(Scientific)

#### UNIVARIATE BOX COX TRANSFORMER ####

# create skewed non-negative vector with a zero value:
Random.seed!(1551)
v = abs.(randn(1000))
v = v .- minimum(v)

t  = UnivariateBoxCoxTransformer(shift=true)
f, = fit(t, 2, v)

@test sum(abs.(v - inverse_transform(t, f, transform(t, f, v)))) <= 5000*eps()

infos = info_dict(t)

@test infos[:name] == "UnivariateBoxCoxTransformer"
@test infos[:input_scitype] == AbstractVector{MLJBase.Continuous}
@test infos[:output_scitype] == AbstractVector{MLJBase.Continuous}

#### ONE HOT ENCODER ####

X = (name   = categorical(["Ben", "John", "Mary", "John"], ordered=true),
     height = [1.85, 1.67, 1.5, 1.67],
     favourite_number = categorical([7, 5, 10, 5]),
     age    = [23, 23, 14, 23])

t  = OneHotEncoder()
f, = @test_logs((:info, r"Spawning 3"), (:info, r"Spawning 3"), fit(t, 1, X))

Xt = transform(t, f, X)

@test Xt.name__John == float.([false, true, false, true])
@test Xt.height == X.height
@test Xt.favourite_number__10 == float.([false, false, true, false])
@test Xt.age == X.age
@test schema(Xt).names == (:name__Ben, :name__John, :name__Mary,
                           :height, :favourite_number__5,
                           :favourite_number__7, :favourite_number__10,
                           :age)

# test that *entire* pool of categoricals is used in fit, including
# unseen levels:
f, = @test_logs((:info, r"Spawning 3"), (:info, r"Spawning 3"),
                      fit(t, 1, MLJBase.selectrows(X,1:2)))
Xtsmall = transform(t, f, X)
@test Xt == Xtsmall

# test that transform can be applied to subset of the data:
@test transform(t, f, MLJBase.selectcols(X, [:name, :age])) ==
    MLJBase.selectcols(transform(t, f, X),
                       [:name__Ben, :name__John, :name__Mary, :age])

# test exclusion of ordered factors:
t  = OneHotEncoder(ordered_factor=false)
f, = fit(t, 1, X)
Xt = transform(t, f, X)
@test :name in Tables.schema(Xt).names
@test :favourite_number__5 in Tables.schema(Xt).names

# test that one may not add new columns:
X = (name       = categorical(["Ben", "John", "Mary", "John"], ordered=true),
     height     = [1.85, 1.67, 1.5, 1.67],
     favourite_number = categorical([7, 5, 10, 5]),
     age        = [23, 23, 14, 23],
     gender     = categorical(['M', 'M', 'F', 'M']))
@test_throws Exception transform(t, f, X)

infos = info_dict(t)

@test infos[:name] == "OneHotEncoder"
@test infos[:input_scitype] == MLJBase.Table(Scientific)
@test infos[:output_scitype] == MLJBase.Table(Scientific)

#### FILL IMPUTER ####

X = (
    x = [missing,ones(10)...],
    y = [missing,ones(10)...],
    z = [missing,ones(10)...]
    )

imp = FillImputer()
f,  = fit(imp, 1, X)
Xt  = transform(imp, f, X)
@test all(.!ismissing.(Xt.x))
@test Xt.x isa Vector{Float64} # no missing
@test all(Xt.x .== 1.0)

imp = FillImputer(features=[:x,:y])
f,  = fit(imp, 1, X)
@test_throws ErrorException transform(imp, f, X) # in X there's :z which we haven't trained on

X = (x = categorical([missing, missing, missing, missing, "Old", "Young", "Middle", "Young",
                      "Old", "Young", "Middle", "Young"]), )

mode_ = mode(["Old", "Young", "Middle", "Young", "Old", "Young", "Middle", "Young"])

imp = FillImputer()
f,  = fit(imp, 1, X)
Xt  = transform(imp, f, X)
@test all(.!ismissing.(Xt.x))
@test all(Xt.x[ismissing.(X.x)] .== mode_)

X  = (x = [missing, missing, 1, 1, 1, 1, 1, 5], )
f, = fit(imp, 1, X)
Xt = transform(imp, f, X)
@test Xt.x == [1, 1, 1, 1, 1, 1, 1, 5]

X = (x = categorical([missing, missing, missing, missing, "Old", "Young", "Middle", "Young",
                      "Old", "Young", "Middle", "Young"]),
     y = [missing, ones(11)...],
     z = [missing, missing, 1,1,1,1,1,5,1,1,1,1],
     a = rand("abc", 12))

f, = fit(imp, 1, X)
Xt = transform(imp, f, X)

@test all(.!ismissing.(Xt.x))
@test all(.!ismissing.(Xt.y))
@test all(.!ismissing.(Xt.z))
@test all(.!ismissing.(Xt.a))

@test Xt.x[1] == mode_
@test Xt.y[1] == 1
@test Xt.z[1] == 1

end
true


text:
# load all input and result data into a dataframe-----------------------------------------------------------
function load_data(date, h_predict, h_control, h_start, h_end, market_flag, n_peers, n_market, case)
    Input_df = CSV.read("data/200124_datafile_all_details_right_timestamp.csv", DataFrame);
    Flow_df = CSV.read("results/$(date)_results_$(h_predict)_$(h_control)_$(h_start)-$(h_end)"*
                    "_$(market_flag)_$(n_peers)_$(n_market)_$(case).csv", DataFrame);
    # merge on data stamp
    Data_df = leftjoin(Flow_df, Input_df, on=[:month, :day, :hour], makeunique=false,
                        indicator=nothing, validate=(false, false));
    return Data_df
end

# short caller for latest data + standard setup-----------------------------------------------------------
function load_case(case, number_of_peers)
    return load_data(200829, 36, 12, 1, 8760, 1, number_of_peers, 1, case);
end

# bar chart for PV flows----------------------------------------------------------------------------
function bar_PV(Data_df, date, peer, yaxis, title)
        # PV generation
    @df Data_df[(Data_df[:,:day].==Dates.day(date)) .&(Data_df[:,:month].==Dates.month(date)) .&(Data_df[:,:Peer].==peer),:] plot(
        [:PV_generation], xticks=0:6:24, yticks=-4:2:10, tickfontsize=10, ylim=(-4,11), color =[:gold],
        label=["g_e"], legend=false, legendfontsize=8, linewidth= 3.0, title="$(title)", titlefontsize=11,
        titlelocation=:left);

       plot!(0:23, ones(24,1)*10.0, linestyle=:dot, linewidth= 2, color=:purple)

    # bars PV
    @df Data_df[(Data_df[:,:day].==Dates.day(date)) .&(Data_df[:,:month].==Dates.month(date)) .&(Data_df[:,:Peer].==peer),:] groupedbar!(
        [:PV_PM :PV_HP :PV_GR :PV_B :PV_DE], color =[:green :firebrick :grey :purple :orange],
        label=["→ pm" "→ hp" "→ gr" "→ b" "→ de"], legend=false, bar_position = :stack, alpha=0.8);

    # bars B
    @df Data_df[(Data_df[:,:day].==Dates.day(date)) .&(Data_df[:,:month].==Dates.month(date)) .&(Data_df[:,:Peer].==peer),:] groupedbar!((-1) .*[:B_PM :B_HP :B_DE], color =[:green :firebrick :orange],
        label=[], bar_position = :stack, alpha=0.8);

    # battery cap
    @df Data_df[(Data_df[:,:day].==Dates.day(date)) .&(Data_df[:,:month].==Dates.month(date)) .&(Data_df[:,:Peer].==peer),:] plot!(
        [:SOC_B], color =[:purple], xticks=0:6:24,
        label=["SOC_b"], linewidth= 2.0);

    yaxis!("$(yaxis)", font(10, "serif"), tickfontsize=10)
    annotate!(3, 8.5, text("$(Dates.month(date))/$(Dates.day(date))", 10))
end

# bar chart for demand fulfillment-----------------------------------------------------------------
function bar_demand(Data_df, date, peer, yaxis, title)
    # bars
    @df Data_df[(Data_df[:,:day].==Dates.day(date)) .&(Data_df[:,:month].==Dates.month(date)) .&(Data_df[:,:Peer].==peer),:] groupedbar(
        [:PM_DE :B_DE :GR_DE :PV_DE ], color =[:green :purple :grey :gold],
        ylim=(0,2.2), yticks=0:0.5:2, xticks=0:6:24, legendfontsize=6,
        label=["PM_DE" "B_DE" "GR_DE" "PV_DE"], legend=false, bar_position = :stack, alpha=0.8, title="$(title)",
        titlefontsize=11, titlelocation=:left);

    # electricity demand
    @df Data_df[(Data_df[:,:day].==Dates.day(date)) .&(Data_df[:,:month].==Dates.month(date)) .&(Data_df[:,:Peer].==peer),:] plot!(
        [:electkwh], color =[:orange],
        label=["d_e"], linewidth= 2.0);

    yaxis!("$(yaxis)", font(10, "serif"), tickfontsize=10)
    #annotate!(3, 1.8, text("$(Dates.month(date))/$(Dates.day(date))", 10))
end

# chart for heat/ hot water demand fulfillment-----------------------------------------------------------
function bar_heat(Data_df, date, peer, yaxis, title)
    # hot water demand
    @df Data_df[(Data_df[:,:day].==Dates.day(date)) .&(Data_df[:,:month].==Dates.month(date)) .&(Data_df[:,:Peer].==peer),:] plot(
        [:hotwaterkwh]+[:heatingkwh], fillrange=[:heatingkwh], yticks=0:2:10, ylim=(0,11), xticks=0:6:24,
        tickfontsize=10, color =[:steelblue], label=["d_fh"], legend=false, alpha=0.2, title="$(title)",
        titlefontsize=11, titlelocation=:left);
    # heat demand
    @df Data_df[(Data_df[:,:day].==Dates.day(date)) .&(Data_df[:,:month].==Dates.month(date)) .&(Data_df[:,:Peer].==peer),:] plot!(
        [:heatingkwh], fillrange=zeros(24),
        color =[:firebrick], label=["d_fh"], alpha=0.2);
    # bars
    @df Data_df[(Data_df[:,:day].==Dates.day(date)) .&(Data_df[:,:month].==Dates.month(date)) .&(Data_df[:,:Peer].==peer),:] groupedbar!(
        [:PM_HP :B_HP :GR_HP :PV_HP], color =[:green :purple :grey :gold],
        label=["PM_DE" "B_DE" "GR_DE" "PV_DE"], bar_position = :stack, alpha=0.8);

    plot!(0:23, ones(24,1)*3, linestyle=:dot, linewidth= 2, color=:grey)
    yaxis!("$(yaxis)", font(10, "serif"))
end

# chart for comfort ranges for heat/ hot water----------------------------------------------------------
function bar_comfort(Data_df, date, peer, yaxis, title)
    @df Data_df[(Data_df[:,:day].==Dates.day(date)) .&(Data_df[:,:month].==Dates.month(date)) .&(Data_df[:,:Peer].==peer),:] groupedbar(
        ([:HP_FH :HP_HW].>0.005)*200, color =[:firebrick :steelblue],
        ylim=(19,23), yticks=20:1:22, xticks=0:6:24, tickfontsize=10, legendfontsize=6,
        label=["Mod_fh" "Mod_hw"], legend=false, bar_position = :stack, alpha=0.15, title="$(title)", titlefontsize=11,
        titlelocation=:left);

    # state-of-charge fh
    @df Data_df[(Data_df[:,:day].==Dates.day(date)) .&(Data_df[:,:month].==Dates.month(date)) .&(Data_df[:,:Peer].==peer),:] plot!(
        [:Temp_FH],  color =[:firebrick],
        label=["T_fh"], legend=false, linewidth= 2.0);

    plot!(0:23, ones(24,1)*20, linestyle=:dot, linewidth= 2, color=:firebrick)
    plot!(0:23, ones(24,1)*22, linestyle=:dot, linewidth= 2, color=:firebrick)
    yaxis!("$(yaxis)", font(10, "serif"))
    xaxis!("time")

    plt = twinx()
    # state-of-charge hw
    @df Data_df[(Data_df[:,:day].==Dates.day(date)) .&(Data_df[:,:month].==Dates.month(date)) .&(Data_df[:,:Peer].==peer),:] plot!(
        plt, [:Vol_HW], ylim=(10,190), yticks=20:40:180, xticks=[], tickfontsize=10, color =[:steelblue],
        label=["V_fh"], legend=false, linewidth= 2.0, linestyle=:dash, grid=false);

    plot!(plt, 0:23, ones(24,1)*20, linestyle=:dot, linewidth= 2, color=:steelblue)
    plot!(plt, 0:23, ones(24,1)*180, linestyle=:dot, linewidth= 2, color=:steelblue)

end

function plot_legend(plotfunction)
    if plotfunction == bar_PV
        scatter((1:5)', xlim=(4,8), color =[:green :firebrick :grey :purple :orange], legend=(-.5,.9),
            label=["  →pm" "  →hp" "  →gr" "  →b" "  →d_e"], framestyle= :none, marker= (:rect, stroke(0)),
            legendfontsize=10)
        plot!((1:3)', xlim=(4,8), linestyle=[:solid :solid :dot], color=[:gold :purple :purple],
            label=["  ge" "  SOC_b" "  b_max"])

        elseif plotfunction == bar_demand
        scatter((1:4)', xlim=(4,8), color =[:green :grey :purple :gold], legend=(-.5,.5),
            label=["  pm→d_e" "  gr→d_e" "  b→d_e" "  pv→d_e"], framestyle= :none, marker= (:rect, stroke(0)),
            legendfontsize=10)
        plot!((1:1)', xlim=(4,8), linestyle=[:solid], color=:orange, label="  de")

        elseif plotfunction == bar_heat
        scatter((1:6)', xlim=(4,8), color =[:green :grey :purple :gold :firebrick :steelblue], legend=(-.5,.76),
            label=["  pm→hp" "  gr→hp" "  b→hp" "  pv→hp" "  d_fh" "  d_hw"], framestyle= :none, marker= (:rect,
                stroke(0)),
            legendfontsize=10, alpha=[1 1 1 1 .2 .2])
        plot!((1:1)', xlim=(4,8), linestyle=[:dot], color=:grey, label="  hp_max")

        elseif plotfunction == bar_comfort
        scatter((1:2)', xlim=(4,8), color =[:firebrick :steelblue], legend=(-.3,.64),
            label=["  mod_fh" "  mod_hw"], framestyle= :none, marker= (:rect, stroke(0)),
            legendfontsize=10, alpha=[.2 .2])
        plot!((1:4)', xlim=(4,8), linestyle=[:dot :dot :solid :dash],
            color=[:firebrick :steelblue :firebrick :steelblue], label=["  range_fh" "  range_hw" "  T_fh" "  V_hw"])
    end
end

# combine plots in rows-----------------------------------------------------------------------------
function bar_row(plotfunction, Data_df, date, peer, yaxis, title, length)
    plot(plotfunction(Data_df, date, peer, yaxis, title), [plotfunction(Data_df, (date+Day(i-1)), peer, "", "")
            for i in 2:length]..., plot_legend(plotfunction),layout=grid(1,length+1,
            widths=vcat([((length-.28)/length/length) for i in 1:length], [.28*length/length/length])),
            size=(300*(length+1),200), foreground_color_legend = :transparent, background_color_legend= :transparent);
end

#KPI functions-----------------------------------------------------------------------------------------
function calc_energy_use(Data_df, n_peers)
    return convert.(Int64, round.([ sum(sum(groupby(Data_df, "Peer")[i][!,j] for j in ["PV_DE", "B_DE", "GR_DE", "HP_FH", "HP_HW", "PM_DE"])) for i in 1:n_peers], digits=0))
end

function peer_cons(n_peers)
    multiplier = [100, 100]
    if n_peers >= 2
        for i in 1:(n_peers-2)
            append!(multiplier, 0)
        end
    end
    return multiplier
end

function calc_self_consumption(Data_df, n_peers)
    return convert.(Int64, round.(peer_cons(n_peers) .*[1 - (sum(sum(groupby(Data_df, "Peer")[i][!,j] for j in ["PV_GR", "PV_PM", "B_PM"])) / sum(groupby(Data_df, "Peer")[i][!,"PV_generation"])) for i in 1:n_peers], digits=0))
end

function calc_self_sufficiency(Data_df, n_peers)
    return convert.(Int64, round.(100 .*(1 .- [sum(sum(groupby(Data_df, "Peer")[i][!,j] for j in ["GR_DE", "GR_HP", "PM_DE", "PM_HP"])) for i in 1:n_peers] ./ calc_energy_use(Data_df, n_peers)), digits=0))
end

function calc_comfort_violations(Data_df, n_peers)
    return convert.(Int64, round.([sum(sum(groupby(Data_df, "Peer")[i][!,j] for j in ["V_HW_plus", "V_HW_minus", "T_FH_plus", "T_FH_minus"])) for i in 1:n_peers], digits=0))
end

function calc_profit(Data_df, n_peers)
    return convert.(Int64, round.([sum(groupby(Data_df, "Peer")[i][!,"profits"]) for i in 1:n_peers]./100, digits=0))
end

function calc_levies(case, n_peers)
    return hcat([collect( sum([9.87, 12.19,  24.39, 7.71, 2.05, 6.756, 2.667] .*Matrix(CSV.read("data/tariff_matrix_case_$(case).csv")) *Matrix(groupby(load_case(case, n_peers), "Peer")[i][!,13:26])', dims=2)./100) for i in 1:n_peers]...)
end

function calc_shares(case, n_peers)
    return hcat([collect( sum(Matrix(CSV.read("data/flow_matrix.csv")) *Matrix(groupby(load_case(case, n_peers), "Peer")[i][!,13:26])', dims=2)) for i in 1:n_peers]...)
end

max_stars_repo_name: lilanger/PEERS

max_issues_repo_name: lilanger/PEERS

max_forks_repo_name: lilanger/PEERS


text:
# --- PRIVATE METHODS ---------------------------------------------------------- #
function check_stoichiometric_matrix(original_dictionary,generated_dictionary)

    # get the STMs -
    old_stm = original_dictionary["S"]
    new_stm = generated_dictionary["S"]

    (NRO,NCO) = size(old_stm)
    (NRG,NCG) = size(new_stm)
    if (NRO != NRG || NCO != NCG)
        return false
    end

    # element by element comparision -
    for row_index = 1:NRO
        for col_index = 1:NCO

            if (old_stm[row_index,col_index] != new_stm[row_index, col_index])
                return false
            end
        end
    end

    return true
end

function check_metabolite_order(original_dictionary,generated_dictionary)

    # get mets -
    mets_old = original_dictionary["mets"]
    mets_new = generated_dictionary["mets"]

    if (length(mets_old) != length(mets_new))
        return false
    end

    N = length(mets_old)
    for index = 1:N

        msg = "comparing: $(mets_old[index]) == $(mets_new[index])"
        println(msg)

        if (mets_old[index] != mets_new[index])
            return false
        end
    end

    return true
end

function check_reaction_order(original_dictionary,generated_dictionary)

    # get rxns -
    rxns_old = original_dictionary["rxns"]
    rxns_new = generated_dictionary["rxns"]

    if (length(rxns_old) != length(rxns_new))
        return false
    end

    N = length(rxns_old)
    for index = 1:N

        msg = "comparing: $(rxns_old[index]) == $(rxns_new[index])"
        println(msg)

        if (rxns_old[index] != rxns_new[index])
            return false
        end
    end

    return true
end

function check_reaction_reversibility(original_dictionary,generated_dictionary)

    # get rxns -
    rev_old = original_dictionary["rev"]
    rev_new = generated_dictionary["rev"]

    if (length(rev_old) != length(rev_new))
        return false
    end

    N = length(rev_old)
    for index = 1:N

        msg = "comparing: $(rev_old[index]) == $(rev_new[index])"
        println(msg)

        if (rev_old[index] != rev_new[index])
            return false
        end
    end

    return true
end

# --- PUBLIC METHODS ----------------------------------------------------------- #
function is_generated_cobra_mat_equal(path_to_original_cobra_mat_file::String,path_to_generated_cobra_mat_file::String)::Bool

    # load the original file -
    original_model_name = splitdir(splitext(path_to_original_cobra_mat_file)[1])[2]
    original_dictionary = load_cobra_model_file(path_to_original_cobra_mat_file,original_model_name)

    # load generated file -
    generated_model_name = splitdir(splitext(path_to_generated_cobra_mat_file)[1])[2]
    generated_dictionary = load_cobra_model_file(path_to_generated_cobra_mat_file,generated_model_name)

    # checks -
    if (check_stoichiometric_matrix(original_dictionary,generated_dictionary) == false)
        msg = "Ooops! stoichiometric matrix check failed ..."
        println(msg)
        return false
    end

    if (check_metabolite_order(original_dictionary, generated_dictionary) == false)
        msg = "Ooops! metabolite order check failed ..."
        println(msg)
        return false
    end

    if (check_reaction_order(original_dictionary, generated_dictionary) == false)
        msg = "Ooops! reaction order check failed ..."
        println(msg)
        return false
    end

    if (check_reaction_reversibility(original_dictionary, generated_dictionary) == false)
        msg = "Ooops! reaction reversibility check failed ..."
        println(msg)
        return false
    end

    return true
end

max_stars_repo_name: varnerlab/CBModelTools

max_issues_repo_name: varnerlab/CBModelTools

max_forks_repo_name: varnerlab/CBModelTools


text:
@inline function C_e(Cell,s,z,tf,D,res0)
"""
Electrolyte Concentration Transfer Function

C_e(Cell,s,z)

"""

ζ = (1-Cell.Const.t_plus)/F    #Simplifying Variable
κ_eff_Neg = Cell.Const.κ*Cell.Neg.ϵ_e^Cell.Neg.κ_brug
κ_eff_Pos = Cell.Const.κ*Cell.Pos.ϵ_e^Cell.Pos.κ_brug
σ_eff_Neg = Cell.Neg.σ*Cell.Neg.ϵ_s^Cell.Neg.σ_brug #Effective Conductivity Neg
σ_eff_Pos = Cell.Pos.σ*Cell.Pos.ϵ_s^Cell.Pos.σ_brug #Effective Conductivity Pos

#Defining SOC
θ_neg = Cell.Const.SOC * (Cell.Neg.θ_100-Cell.Neg.θ_0) + Cell.Neg.θ_0
θ_pos = Cell.Const.SOC * (Cell.Pos.θ_100-Cell.Pos.θ_0) + Cell.Pos.θ_0

#Prepare for j0
cs0_neg = Cell.Neg.cs_max * θ_neg
cs0_pos = Cell.Pos.cs_max * θ_pos

#Current Flux Density

if Cell.Const.CellTyp == "Doyle_94"
    κ_pos = Cell.Pos.k_norm/Cell.Pos.cs_max/Cell.Const.ce0^(1-Cell.Pos.α)
    κ_neg = Cell.Neg.k_norm/Cell.Neg.cs_max/Cell.Const.ce0^(1-Cell.Neg.α)
    j0_neg = κ_neg*(Cell.Const.ce0*(Cell.Neg.cs_max-cs0_neg))^(1-Cell.Neg.α)*cs0_neg^Cell.Neg.α
    j0_pos = κ_pos*(Cell.Const.ce0*(Cell.Pos.cs_max-cs0_pos))^(1-Cell.Pos.α)*cs0_pos^Cell.Pos.α
 else
    j0_neg = Cell.Neg.k_norm*(Cell.Const.ce0*cs0_neg*(Cell.Neg.cs_max-cs0_neg))^(1-Cell.Neg.α)
    j0_pos = Cell.Pos.k_norm*(Cell.Const.ce0*cs0_pos*(Cell.Pos.cs_max-cs0_pos))^(1-Cell.Pos.α)
 end

#Resistances
Rtot_neg = R*Cell.Const.T/(j0_neg*F^2) + Cell.Neg.RFilm
Rtot_pos = R*Cell.Const.T/(j0_pos*F^2) + Cell.Pos.RFilm

#OCP derivative
∂Uocp_pos = Cell.Const.∂Uocp("Pos",θ_pos)/Cell.Pos.cs_max
∂Uocp_neg = Cell.Const.∂Uocp("Neg",θ_neg)/Cell.Neg.cs_max

#Condensing Variable
ν_n =  @. Cell.Neg.L*sqrt((Cell.Neg.as/σ_eff_Neg+Cell.Neg.as/κ_eff_Neg)/(Rtot_neg+∂Uocp_neg*(Cell.Neg.Rs/(F*Cell.Neg.Ds))*(tanh(Cell.Neg.β)/(tanh(Cell.Neg.β)-Cell.Neg.β))))
ν_p =  @. Cell.Pos.L*sqrt((Cell.Pos.as/σ_eff_Pos+Cell.Pos.as/κ_eff_Pos)/(Rtot_pos+∂Uocp_pos*(Cell.Pos.Rs/(F*Cell.Pos.Ds))*(tanh(Cell.Pos.β)/(tanh(Cell.Pos.β)-Cell.Pos.β))))
# ν_n =  @. Cell.Neg.L*sqrt((Cell.Neg.as/σ_eff_Neg+Cell.Neg.as/κ_eff_Neg)/(Rtot_neg+∂Uocp_neg*(Cell.Neg.Rs/(F*Cell.Neg.Ds*(1-Cell.Neg.β*coth(Cell.Neg.β))))))

R_ce = find_zeros(x->flambda(Cell,x),0.00,Cell.Const.CeRootRange)
if size(R_ce,1) >= Cell.Const.Ce_M+1
    λ = R_ce[2:Cell.Const.Ce_M+1]
else
    throw(DomainError(R_ce, "Ce roots 'R_ce' does not contain enough values, increase Cell.Const.CeRootRange"))
end

#Create all k's
in1 = @. sqrt(λ*Cell.Neg.ϵ_e/Cell.Const.D1)
in2 = @. sqrt(λ*Cell.Sep.ϵ_e/Cell.Const.D2)
in3 = @. sqrt(λ*Cell.Pos.ϵ_e/Cell.Const.D3)

Bound_Neg_1 = Cell.Neg.L * in1
Bound_Sep_0 = Cell.Neg.L * in2
Bound_Sep_1 = (Cell.Neg.L+Cell.Sep.L) * in2
Bound_Pos_0 = (Cell.Neg.L+Cell.Sep.L) * in3
Bound_Pos_1 = (Cell.Neg.L+Cell.Sep.L+Cell.Pos.L) * in3
Bound_Pos_2 = Cell.Pos.L * in3

#Scaled coefficients
k3_s = @. cos(Bound_Neg_1)*cos(Bound_Sep_0) + Cell.Const.D1*in1*sin(Bound_Neg_1)*sin(Bound_Sep_0)/(Cell.Const.D2*in2)
k4_s = @. cos(Bound_Neg_1)*sin(Bound_Sep_0) - Cell.Const.D1*in1*cos(Bound_Sep_0)*sin(Bound_Neg_1)/(Cell.Const.D2*in2)
k5_s = @. k3_s*(cos(Bound_Sep_1)*cos(Bound_Pos_0)+Cell.Const.D2*in2*sin(Bound_Sep_1)*sin(Bound_Pos_0)/(Cell.Const.D3*in3))+k4_s*(sin(Bound_Sep_1)*cos(Bound_Pos_0)-Cell.Const.D2*in2*cos(Bound_Sep_1)*sin(Bound_Pos_0)/(Cell.Const.D3*in3))
k6_s = @. k3_s*(cos(Bound_Sep_1)*sin(Bound_Pos_0)-Cell.Const.D2*in2*sin(Bound_Sep_1)*cos(Bound_Pos_0)/(Cell.Const.D3*in3))+k4_s*(sin(Bound_Sep_1)*sin(Bound_Pos_0)+Cell.Const.D2*in2*cos(Bound_Sep_1)*cos(Bound_Pos_0)/(Cell.Const.D3*in3))

#Solving for k1:
Int_ψ1 = @. Cell.Neg.ϵ_e*(2*Bound_Neg_1+sin(2*Bound_Neg_1))/(4*in1)
Int_ψ2 = @. Cell.Sep.ϵ_e/(4*in2)*(2*(k3_s^2+k4_s^2)*Cell.Sep.L*in2+2*k3_s*k4_s*cos(2*Bound_Sep_0)-2*k3_s*k4_s*cos(2*Bound_Sep_1)-(k3_s-k4_s)*(k3_s+k4_s)*(sin(2*Bound_Sep_0)-sin(2*Bound_Sep_1)))
Int_ψ3 = @. Cell.Pos.ϵ_e/(4*in3)*(2*(k5_s^2+k6_s^2)*Cell.Pos.L*in3+2*k5_s*k6_s*cos(2*Bound_Pos_0)-2*k5_s*k6_s*cos(2*Bound_Pos_1)-(k5_s-k6_s)*(k5_s+k6_s)*(sin(2*Bound_Pos_0)-sin(2*Bound_Pos_1)))

k1 = @. 1/(sqrt(Int_ψ1+Int_ψ2+Int_ψ3))
k3 = @. k1*k3_s
k4 = @. k1*k4_s
k5 = @. k1*k5_s
k6 = @. k1*k6_s

j_Neg = @. k1*ζ*ν_n*(Bound_Neg_1*(κ_eff_Neg+σ_eff_Neg*cosh(ν_n))*sin(Bound_Neg_1)+(κ_eff_Neg+σ_eff_Neg*cos(Bound_Neg_1))*sinh(ν_n)*ν_n)/(Cell.Const.CC_A*(κ_eff_Neg+σ_eff_Neg)*(Bound_Neg_1^2+ν_n^2)*sinh(ν_n))
zero_tf_neg = @. k1*ζ*sin(Bound_Neg_1)/(Cell.Const.CC_A*Bound_Neg_1)
j_Neg[:,findall(s.==0)] .= zero_tf_neg[:,findall(s.==0)]

j_Pos = @. -ζ*ν_p/(Cell.Const.CC_A*(κ_eff_Pos+σ_eff_Pos)*(Bound_Pos_2^2+ν_p^2)*sinh(ν_p))*(-k6*Bound_Pos_2*cos(Bound_Pos_1)*(σ_eff_Pos+κ_eff_Pos*cosh(ν_p))+Bound_Pos_2*(κ_eff_Pos+σ_eff_Pos*cosh(ν_p))*(k6*cos(Bound_Pos_0)-k5*sin(Bound_Pos_0))+k5*Bound_Pos_2*(σ_eff_Pos+ κ_eff_Pos*cosh(ν_p))*sin(Bound_Pos_1)+sinh(ν_p)*(k5*σ_eff_Pos*cos(Bound_Pos_0)+k5*κ_eff_Pos*cos(Bound_Pos_1)+k6*σ_eff_Pos*sin(Bound_Pos_0)+k6*κ_eff_Pos*sin(Bound_Pos_1))*ν_p)
zero_tf = @. -ζ*(k6*(cos(Bound_Pos_0)-cos(Bound_Pos_1))+k5*(sin(Bound_Pos_1)-sin(Bound_Pos_0)))/(Cell.Const.CC_A*Bound_Pos_2)
j_Pos[:,findall(s.==0)] .= zero_tf[:,findall(s.==0)]

i=Int64(1)
ψ = fill(0.0,length(z),length(λ))
for loop in 1:length(λ)
    i=1
    for x in z #Eigen Weighting
        if x < Cell.Neg.L+eps()
            ψ[i,loop] = k1[loop]*cos(in1[loop]*x) #negative electrode
        elseif x > (Cell.Neg.L+Cell.Sep.L)-eps()
            ψ[i,loop] = k5[loop]*cos(in3[loop]*x)+k6[loop]*sin(in3[loop]*x) # postive electrode
        else
            ψ[i,loop] = k3[loop]*cos(in2[loop]*x)+k4[loop]*sin(in2[loop]*x) # separator
        end
    i = i+1
    end
end

tf .= ψ*((j_Neg .+ j_Pos)./(s.+λ))
D .=  zeros(length(z))
res0 .= zeros(length(z))
end

function flambda(Cell,λ)
    ϵ1 = Cell.Neg.ϵ_e
    ϵ2 = Cell.Sep.ϵ_e
    ϵ3 = Cell.Pos.ϵ_e
    D1 = Cell.Const.D1
    D2 = Cell.Const.D2
    D3 = Cell.Const.D3
    Lneg = Cell.Neg.L
    Lnegsep = Cell.Const.Lnegsep
    Ltot = Cell.Neg.L+Cell.Sep.L+Cell.Pos.L
    k1 = Float64(1)
    sle1 = sqrt(λ*ϵ1/D1)
    sle2 = sqrt(λ*ϵ2/D2)
    sle3 = sqrt(λ*ϵ3/D3)
    k3 = @. k1*(cos(sle1*Lneg)*cos(sle2*Lneg) + D1*sle1*sin(sle1*Lneg)*sin(sle2*Lneg)/(D2*sle2))
    k4 = @. k1*(cos(sle1*Lneg)*sin(sle2*Lneg) - D1*sle1*cos(sle2*Lneg)*sin(sle1*Lneg)/(D2*sle2))
    k5 = @. k3*(cos(sle2*Lnegsep)*cos(sle3*Lnegsep) + D2*sle2*sin(sle2*Lnegsep)*sin(sle3*Lnegsep)/(D3*sle3))+k4*(sin(sle2*Lnegsep)*cos(sle3*Lnegsep) - D2*sle2*cos(sle2*Lnegsep)*sin(sle3*Lnegsep)/(D3*sle3))
    k6 = @. k3*(cos(sle2*Lnegsep)*sin(sle3*Lnegsep) - D2*sle2*sin(sle2*Lnegsep)*cos(sle3*Lnegsep)/(D3*sle3))+k4*(sin(sle2*Lnegsep)*sin(sle3*Lnegsep) + D2*sle2*cos(sle2*Lnegsep)*cos(sle3*Lnegsep)/(D3*sle3))
    Psiprime = @. -k5*sle3*sin(sle3*Ltot) + k6*sle3*cos(sle3*Ltot)
    if λ == 0
        Psiprime = 0
    end
    return Psiprime
end

max_stars_repo_name: BradyPlanden/LiBRA

max_forks_repo_name: BradyPlanden/LiBRA


text:
module Backtest
  module Ids
    const AssetId = String
    const FieldId = String
    const OrderId = String
  end # module

  module Exceptions
    import Dates: TimeType
    import ..Ids: AssetId

    struct AbstractMethodError <: Exception
      msg::String
      AbstractMethodError(cls::Type, method::Function) = new(
        string("Cannot run  method ", method, " with abstract class ", cls, ".")
      )
    end

    struct DateTooEarlyError <: Exception
      msg::String
      function DateTooEarlyError(requestedtime::T, assetid::AssetId ) where {T<:TimeType}
        return new( string("The requested time, ", requestedtime, ", is before the first time", " in ", assetid, "."))
      end
    end

    struct DateTooFarOutError <: Exception
      msg::String
      DateTooFarOutError(requestedtime::T, assetid::AssetId) where {T<:TimeType} = new(
        string("There is not enough data in ", assetid, " to access ", requestedtime, ".")
      )
    end
  end # module

  module AbstractFields
    import ..Ids: AssetId, FieldId
    import ..Exceptions: AbstractMethodError

    """
      `AbstractFieldOperation` is the mother of all field operations.
      Required subtypes:
      1. name: fieldid; type: FieldId; description: the name of the field!
    """
    abstract type AbstractFieldOperation end

    """
      `AbstractGenesisFieldOperation` is the type of a field operation
      that relies on no other data. This can be thought of as an identity
      operation, since there is actually no operation applied to the data
      in this field. This FieldOperation is used for data that is generated
      from outside of the backtest, such as market data.

      Required subtypes:
      1. All subtypes for `AbstractFieldOperation`.
    """
    abstract type AbstractGenesisFieldOperation <: AbstractFieldOperation end

    """
      `AbstractDownstreamFieldOperation` is the type shared by all field operations
      that depend on upstream data before being calculated. An example of this would
      be any window or cross sectional field operation, since they depend on some other
      source of data to exist before they can be calculated.

      Required subtypes:
      1. All subtypes needed for `AbstractFieldOperation`.
      2. name: `upstreamfieldid`; type: FieldId; description: field id associated with the upstream dependency
    """
    abstract type AbstractDownstreamFieldOperation <: AbstractFieldOperation end

    """
      `AbstractWindowFieldOperation` is the mother of all concrete WindowFieldOperation
      types. All child types are expected to have an integer `window` subtype.

      Required subtypes:
      1. All subtypes required by `AbstractDownstreamFieldOperation`.
      2. name: `window`; type: Integer; description: number of bars of data over which to make the calculation.
    """
    abstract type AbstractWindowFieldOperation <: AbstractDownstreamFieldOperation end

    function dofieldop(windowfieldoperation::AbstractWindowFieldOperation, data::Vector)
      throw(AbstractMethodError(AbstractWindowFieldOperation, dofieldop))
    end

    """
      `AbstractCrossSectionalFieldOperation` is the mother of all concrete
      CrossSectionalFieldOperation types.

      Required subtypes:
      1. All subtypes needed for `AbstractDownstreamFieldOperation`.
    """
    abstract type AbstractCrossSectionalFieldOperation <: AbstractDownstreamFieldOperation end

    function dofieldop(abstractcrosssectionalfieldoperation::AbstractCrossSectionalFieldOperation, data::Dict{AssetId, Any})::Dict{AssetId, Any}
      throw(AbstractMethodError(AbstractCrossSectionalFieldOperation, dofieldop))
    end

    export AbstractFieldOperation, AbstractGenesisFieldOperation, AbstractWindowFieldOperation, AbstractCrossSectionalFieldOperation
  end # module

  module ConcreteFields
    import ..Ids: AssetId, FieldId
    import ..AbstractFields: AbstractGenesisFieldOperation, AbstractWindowFieldOperation, AbstractCrossSectionalFieldOperation
    import Statistics: mean, std
    import NamedArrays: NamedArray, names

    ## Common genesis field operations ##
    struct Open <: AbstractGenesisFieldOperation
      fieldid::FieldId
      Open()=new(FieldId("Open"))
      Open(fieldid::FieldId) = new(fieldid)
    end

    struct High <: AbstractGenesisFieldOperation
      fieldid::FieldId
      High()=new(FieldId("High"))
      High(fieldid::FieldId) = new(fieldid)
    end

    struct Low <: AbstractGenesisFieldOperation
      fieldid::FieldId
      Low()=new(FieldId("Low"))
      Low(fieldid::FieldId) = new(fieldid)
    end

    struct Close <: AbstractGenesisFieldOperation
      fieldid::FieldId
      Close()=new(FieldId("Close"))
      Close(fieldid::FieldId) = new(fieldid)
    end

    struct Volume <: AbstractGenesisFieldOperation
      fieldid::FieldId
      Volume()=new(FieldId("Volume"))
      Volume(fieldid::FieldId) = new(fieldid)
    end

    ## Common window field operations ##
    struct Returns <: AbstractWindowFieldOperation
      fieldid::FieldId
      upstreamfieldid::FieldId
      window::Integer
    end
    Returns(fieldid::FieldId, upstreamfieldid::FieldId) = Returns(fieldid, upstreamfieldid, 2)
    function dofieldop(returnsfieldop::Returns, data::Vector{T}) where {T<:Number}
      if length(data) < returnsfieldop.window
        return missing
      else
        return (data[returnsfieldop.window]-data[1])/data[1]
      end
    end

    struct LogReturns <: AbstractWindowFieldOperation
      fieldid::FieldId
      upstreamfieldid::FieldId
      window::Integer
    end
    LogReturns(fieldid::FieldId, upstreamfieldid::FieldId) = LogReturns(fieldid, upstreamfieldid, 2)
    function dofieldop(logreturnsfieldop::LogReturns, data::Vector{T}) where {T<:Number}
      if length(data) < logreturnsfieldop.window
        return missing
      else
        return log(data[logreturnsfieldop.window]/data[1])
      end
    end

    struct SMA <: AbstractWindowFieldOperation
      fieldid::FieldId
      upstreamfieldid::FieldId
      window::Integer
    end
    function dofieldop(windowfieldoperation::SMA, data::Vector{T}) where {T<:Number}
      return sum(data)/length(data)
    end

    ## Common cross sectional field operations ##
    struct ZScore <: AbstractCrossSectionalFieldOperation
      fieldid::FieldId
      upstreamfieldid::FieldId
    end
    function dofieldop(crosssectionalfieldoperation::ZScore, assetdata::NamedArray)::NamedArray
      mu = mean(assetdata)
      sigma = std(assetdata)
      zscores = [(x-mu)/sigma for x in assetdata]
      zscores = NamedArray(zscores, (names(assetdata, 1),))
      return zscores
    end

    struct Rank <: AbstractCrossSectionalFieldOperation
      fieldid::FieldId
      upstreamfieldid::FieldId
    end
    function dofieldop(crosssectionalfieldoperation::Rank, assetdata::NamedArray)::NamedArray
      result = NamedArray(Vector{Union{Integer, Nothing}}(nothing, length(assetdata)), names(assetdata, 1))
      sort!(assetdata, rev=true)
      for (i, assetid) in enumerate(names(assetdata, 1))
        result[assetid] = i
      end
      return result
    end
  end # module

  module Engine
    import NamedArrays: NamedArray
    import ..Ids: AssetId, FieldId
    import ..AbstractFields: AbstractFieldOperation, AbstractGenesisFieldOperation, AbstractWindowFieldOperation, AbstractCrossSectionalFieldOperation
    import ..ConcreteFields: dofieldop
    ## BarLayer definition and related functions ##
    struct BarLayer
      bardata::NamedArray{T, 2} where {T<:Any}
    end

    function BarLayer(assetids::Vector{AssetId}, fieldids::Vector{FieldId})
      unnamedarray = Array{Any, 2}(undef, length(assetids), length(fieldids))
      return BarLayer(NamedArray(unnamedarray, (assetids, fieldids)))
    end

    function getvalue(barlayer::BarLayer, assetid::AssetId, fieldid::FieldId)
      return barlayer.bardata[assetid, fieldid]
    end

    function insertvalue!(barlayer::BarLayer, assetid::AssetId, fieldid::FieldId, value)
      barlayer.bardata[assetid, fieldid] = value
    end

    function getallvalues(barlayer::BarLayer)
      return barlayer.bardata
    end

    ## Counter definition (util) and related functions ##
    struct Counter{T}
      data::Dict{T, Integer}
      Counter{T}() where {T <: Any} = new{T}(Dict{T, Integer}())
    end

    function hitcounter!(counter::Counter, key)
      # WARNING: If this ends up being parallelized, consider placing a lock here to make Counter thread-safe
      if !haskey(counter.data, key)
        counter.data[key] = 0
      end
      counter.data[key] += 1;
    end

    function getcount(counter::Counter, key)
      # NOTE: Consider placing a lock here to make counter thread-safe
      return counter.data[key]
    end

    ## CalcLattice definition and related methods ##
    export CalcLattice
    mutable struct CalcLattice
      # Attributes that remain constant
      assetids::Vector{AssetId}
      fieldids::Vector{FieldId} # TODO: consider making a CalcLatticeFieldInfo struct that's created before CalcLattice
      numbarsstored::Integer

      # Attributes maintaining storage and access of bars
      recentbars::Vector{BarLayer} # most recent -> least recent
      curbarindex::Integer

      # Attributes that change when new bar propagation occurs
      numcompletedassets::Counter{FieldId}

      # Attributes changed when fields are added
      windowdependentfields::Dict{FieldId, Set{FieldId}}
      crosssectionaldependentfields::Dict{FieldId, Set{FieldId}}
      fieldids_to_ops::Dict{FieldId, AbstractFieldOperation}
      genesisfieldids::Set{FieldId}
    end

    function CalcLattice(nbarsstored::Integer, assetids::Vector{AssetId})::CalcLattice
      return CalcLattice(
        assetids,             # asset ids
        Vector{FieldId}(),        # field ids
        nbarsstored,          # num bars stored; if -1, then store all bars
        Array{BarLayer, 1}(undef, 0), # recent bars
        0,                # initialize curbarindex (one too small, since incrementing occurs on newbar addition)
        Counter{FieldId}(),       # number of completed assets for each field on this bar
        Dict{FieldId, Set{FieldId}}(),  # window dependent fields
        Dict{FieldId, Set{FieldId}}(),  # cross sectional dependent fields
        Dict{FieldId, AbstractFieldOperation}(),     # field ids to field ops
        Set()               # genesis field ids
      )
    end

    function tryaccessago(lattice::CalcLattice, ago::Integer)
      if (ago > numbarsavailable(lattice)) || (ago < 0) || ((lattice.numbarsstored!=-1) && (ago > lattice.numbarsstored))
        throw("Invalid `ago`: $ago, while lattice is only on the $(lattice.curbarindex), index.")
      end
    end

    function getnbaragodata(lattice::CalcLattice, ago::Integer)::BarLayer
      tryaccessago(lattice, ago)
      mostrecentbarindex = length(lattice.recentbars)
      return lattice.recentbars[mostrecentbarindex-ago]
    end

    function getcurrentbar(lattice::CalcLattice)::BarLayer
      return getnbaragodata(lattice, 0)
    end

    function updatebars!(lattice::CalcLattice)
      """Update the `recentbars` field in preparation for the next bar."""
      # see documentation for `CalcLattice.numbarsstored`
      if (lattice.numbarsstored!=-1) && (length(lattice.recentbars) >= lattice.numbarsstored)
        deleteat!(lattice.recentbars, 1) # delete least recent bar
      end

      barlayer = BarLayer(lattice.assetids, lattice.fieldids)
      push!(lattice.recentbars, barlayer)
    end

    function insertnode!(lattice::CalcLattice, assetid::AssetId, fieldid::FieldId, value)
      currentbar = getcurrentbar(lattice)
      # TODO: Add functionality to check if this value already exists
      hitcounter!(lattice.numcompletedassets, fieldid)
      insertvalue!(currentbar, assetid, fieldid, value)
    end

    function getwindowdata(lattice::CalcLattice, windowfieldoperation::AbstractWindowFieldOperation, assetid::AssetId)::Vector
      # Get information about the operation
      upstreamfieldid = windowfieldoperation.upstreamfieldid
      window = windowfieldoperation.window
      inputdatatype = typeof( getvalue(lattice.recentbars[1], assetid, upstreamfieldid) )

      # Set looping parameters
      numdefinedentries = length(lattice.recentbars)
      lower = max(1, numdefinedentries-window+1)
      upper = numdefinedentries

      # Fill window data
      windowdata = Vector{inputdatatype}(undef, upper-lower+1)
      for i in lower:upper
        value = getvalue(lattice.recentbars[i], assetid, upstreamfieldid)
        windowdata[i-lower+1] = value
      end
      return windowdata
    end

    function getcrosssectionaldata(lattice::CalcLattice, crosssectionalfieldoperation::AbstractCrossSectionalFieldOperation)::NamedArray
      upstreamfieldid = crosssectionalfieldoperation.upstreamfieldid
      currentbar = getcurrentbar(lattice)
      emptyassets = Vector(undef, length(lattice.assetids))
      assetdata = NamedArray(emptyassets, lattice.assetids)
      for assetid in lattice.assetids
        assetdata[assetid] = getvalue(currentbar, assetid, upstreamfieldid)
      end
      return assetdata
    end

    function compute!(lattice::CalcLattice, fieldid::FieldId)
      """Computes the node for all of the assetes of a cross sectional field.
      """
      crosssectionalfieldoperation = lattice.fieldids_to_ops[fieldid]
      # Get data for the cross sectional operation (asset is the free variable)
      assetdata = getcrosssectionaldata(lattice, crosssectionalfieldoperation)

      # Perform the cross sectional operation
      asset_results::NamedArray = dofieldop(crosssectionalfieldoperation, assetdata)

      # Set value for all assets on this bar and field
      for assetid in lattice.assetids
        insertnode!(lattice, assetid, fieldid, asset_results[assetid])
      end
    end

    function compute!(lattice::CalcLattice, assetid::AssetId, fieldid::FieldId)
      """Computes a window operation node.
      """
      windowfieldoperation = lattice.fieldids_to_ops[fieldid]
      # Get data for the window operation (time is the free variable)
      windowdata = getwindowdata(lattice, windowfieldoperation, assetid)

      # Perform the window operation
      value = dofieldop(windowfieldoperation, windowdata)

      # Set value for this node
      insertnode!(lattice, assetid, fieldid, value)
    end

    function propagate!(lattice::CalcLattice, assetid::AssetId, fieldid::FieldId)
      """Given that this node has a value already, propagate forward
      through its dependent nodes.
      """

      # Propagate over all window fields that depend on this field
      if haskey(lattice.windowdependentfields, fieldid)
        for windowdependentfieldid in lattice.windowdependentfields[fieldid]
          compute!(lattice, assetid, windowdependentfieldid)
          propagate!(lattice, assetid, windowdependentfieldid)
        end
      end

      # Propagate over all cross sectional fields that depend on this field
      hascrosssectionaldependencies = haskey(lattice.crosssectionaldependentfields, fieldid)
      allassetscompleted = (getcount(lattice.numcompletedassets, fieldid) == length(lattice.assetids))
      if hascrosssectionaldependencies && allassetscompleted
        for crosssectionaldependentfieldid in lattice.crosssectionaldependentfields[fieldid]
          compute!(lattice, crosssectionaldependentfieldid)
          for assetid_ in lattice.assetids
            propagate!(lattice, assetid_, crosssectionaldependentfieldid)
          end
        end
      end
    end

    # Interface functions
    function newbar!(lattice::CalcLattice, newbardata::Dict{AssetId, Dict{FieldId, T}}) where {T}

      lattice.numcompletedassets = Counter{FieldId}()
      lattice.curbarindex += 1
      updatebars!(lattice)
      for assetid in lattice.assetids
        if !(assetid in keys(newbardata))
          throw("Asset id ``$assetid` was expected, but is not among the asset ids in the new bar data.")
        end
        for fieldid in lattice.genesisfieldids
          if !(fieldid in keys(newbardata[assetid]))
            throw("Field id ``$fieldid` was expected, but is not among the field ids in the new bar data.")
          end

          value = newbardata[assetid][fieldid]
          insertnode!(lattice, assetid, fieldid, value)
          propagate!(lattice, assetid, fieldid)
        end
      end
    end

    function addfield!(lattice::CalcLattice, newfieldoperation::AbstractFieldOperation)
      if lattice.curbarindex != 0
        throw("Cannot add a field after bar data has been added.")
      end

      if newfieldoperation.fieldid in lattice.fieldids
        throw("Cannot have two fields with the same `fieldid`!")
      end

      function appendtodict!(dict::Dict{FieldId, Set{FieldId}}, key::FieldId, value::FieldId)
        if !(key in keys(dict))
          dict[key] = Set()
        end
        push!(dict[key], value)
      end

      push!(lattice.fieldids, newfieldoperation.fieldid)

      # Add the upstreamfieldid -> fieldid relationship
      if isa(newfieldoperation, AbstractGenesisFieldOperation)
        push!(lattice.genesisfieldids, newfieldoperation.fieldid)
      elseif isa(newfieldoperation, AbstractWindowFieldOperation)
        appendtodict!(lattice.windowdependentfields, newfieldoperation.upstreamfieldid, newfieldoperation.fieldid)
      elseif isa(newfieldoperation, AbstractCrossSectionalFieldOperation)
        appendtodict!(lattice.crosssectionaldependentfields, newfieldoperation.upstreamfieldid, newfieldoperation.fieldid)
      else
        throw("Currently, the only supported field operations are subtypes of `AbstractGenesisFieldOperation`, `AbstractWindowFieldOperation`, or `AbstractCrossSectionalFieldOperation`.")
      end
      lattice.fieldids_to_ops[newfieldoperation.fieldid] = newfieldoperation
    end

    function addfields!(lattice::CalcLattice, newfieldoperations::Vector{<:AbstractFieldOperation})
      for newfieldoperation in newfieldoperations
        addfield!(lattice, newfieldoperation)
      end
    end

    function numbarsavailable(lattice::CalcLattice)
      return lattice.recentbars |> length
    end

    function getalldata(lattice::CalcLattice)::Vector{NamedArray}
      """Returns a vector of (assetid, fieldid)->value pairs. The entries in the vector are in
      chronological order, meaning that the last entry will represent the most recent bar."""
      return map((barlayer -> barlayer.bardata), lattice.recentbars)
    end

    function data(lattice::CalcLattice, ago::Integer)::NamedArray
      """Gets (assetid, fieldid)->value pairs for `ago` bars ago; if `ago=0`,
      this gets the previous bar's data."""
      tryaccessago(lattice, ago)
      return lattice.recentbars[numbarsavailable(lattice) - ago].bardata
    end

    function data(lattice::CalcLattice, ago::Integer, fieldid::FieldId)::NamedArray
      """Gets (assetid)->value pairs for `ago` bars ago for a particular field;
      if ago=0, then this is equivalent to data(strat, fieldid)."""
      tryaccessago(lattice, ago)
      return data(lattice, ago)[:, fieldid]
    end

    function data(lattice::CalcLattice, ago::Integer, assetid::AssetId, fieldid::FieldId)
      """Gets the value for a particular field for a particular asset on a particular bar; if
      ago=0, then this is equivalent to data(strat, assetid, fieldid)."""
      tryaccessago(lattice, ago)
      return data(lattice, ago)[assetid, fieldid]
    end

    function data(lattice::CalcLattice)::NamedArray
      """Gets (assetid, fieldid)->value pairs for the previous bar.
      For example, if the time between bars is 1 minute, and the current time
      is 11:33:25 (HH:MM:SS), then this would give OHLCV data from
      11:32:00-11:32:59.999... . WE CANNOT ACCESS DATA FOR THE CURRENT BAR,
      SINCE IT IS NOT COMPLETED YET (e.g. cannot access this bar's open price)."""
      return data(lattice, 0)
    end

    function data(lattice::CalcLattice, fieldid::FieldId)::NamedArray
      """Gets assetid->value pairs for the previous bar."""
      return data(lattice, 0, fieldid)
    end

    function data(lattice::CalcLattice, assetid::AssetId, fieldid::FieldId)
      """Gets value for a particular field for a particular asset on the previous bar."""
      return data(lattice, 0, assetid, fieldid)
    end

    export newbar!, addfields!, getalldata, data, numbarsavailable
  end # module

  module DataReaders
    import Dates: TimeType, DateTime, DateFormat
    import CSV: File
    import DataFrames: DataFrame, nrow, DataFrame!
    import ..Exceptions: AbstractMethodError, DateTooEarlyError, DateTooFarOutError
    import ..Ids: AssetId, FieldId
    import Base.copy

    abstract type AbstractDataReader end

    mutable struct InMemoryDataReader <: AbstractDataReader
      """Stores all asset data in memory."""
      assetid::AssetId
      data::DataFrame
      datetimecol::AS where {AS<:AbstractString}
    end

    mutable struct PerFileDataReader <: AbstractDataReader
      """Stores a single file in memory at a time."""
      assetid::AssetId
      sources::Vector{ST} where {ST<:AbstractString}
      datetimecol::DTCT where {DTCT<:AbstractString}
      dtfmt::FT where {FT<:AbstractString}
      delim::Char
      curfiledata::DataFrame
    end

    module Internal
      using ...Ids: FieldId
      using ..DataReaders: InMemoryDataReader, PerFileDataReader
      using DataFrames: DataFrame, nrow, DataFrame!
      using CSV: File
      function step!(datareader::InMemoryDataReader)
        datareader.data = datareader.data[2:nrow(datareader.data), :]
      end

      function step!(datareader::PerFileDataReader)
        prepfilefornext!(datareader)
        datareader.curfiledata = datareader.curfiledata[2:nrow(datareader.curfiledata), :]
      end

      function prepfilefornext!(datareader::PerFileDataReader)
        """Since the PerFileDataReader iterates over a particular file
        (`curfiledata`) and potentially many files (`sources`), we must
        ensure that if we've complete a single file, we update `curfiledata`
        with the data from the next file."""
        if isempty(datareader.curfiledata) && isempty(datareader.sources)
          throw("Attempted to read data from an empty datareader.")
        elseif isempty(datareader.curfiledata)
          # load next file of data
          nextfile = popfirst!(datareader.sources)
          datareader.curfiledata = File(nextfile, delim=datareader.delim) |> DataFrame!
        end
      end

      function peek(df::DataFrame)
        """Return the top row of a dataframe in Dict format."""
        toprow::NamedTuple = df[1, :]
        nametovalues::Dict{FieldId, Any} = Dict{FieldId, Any}()
        for kvpair in zip(fieldnames(typeof(toprow)), toprow)
          nametovalues[string(kvpair[1])] = kvpair[2]
        end
        return nametovalues
      end

    end # module

    using .Internal

    function InMemoryDataReader(assetid::String, sources::Vector{S};
        datetimecol::String="datetime",
        dtfmt::String="yyyy-mm-ddTHH:MM:SS",
        delim::Char=',')::InMemoryDataReader where {S<:AbstractString}
      if length(sources) == 0
        throw("`sources` must have at least one element.")
      end

      # Read first source, then append all others to the first.
      # alldata = read(sources[1], delim=delim, copycols=true)
      alldata = File(sources[1], delim=delim) |> DataFrame!
      for i = 2:length(sources)
        # append!(alldata, read(sources[i], delim=delim), copycols=true)
        append!(alldata, File(sources[i], delim=delim) |> DataFrame!)
      end

      if size(alldata)[1] == 0
        throw("No data was parsed!")
      elseif isa(alldata[1, datetimecol], AbstractString)
        alldata[!, datetimecol] = DateTime.(alldata[:, datetimecol], DateFormat(dtfmt))
      elseif !isa(alldata[1, datetimecol], TimeType)
        throw("The given datetime column, $datetimecol, has values with invalid type '$(typeof(alldata[1, datetimecol]))'.
            A valid type for this column, 'T', would satisfy {T<:Union{AbstractString, Dates.TimeType}}.")
      end

      return InMemoryDataReader(assetid, alldata, datetimecol)
    end

    function PerFileDataReader(assetid::String, sources::Vector{S};
        datetimecol::String="datetime",
        dtfmt::String="yyyy-mm-ddTHH:MM:SS",
        delim::Char=',')::PerFileDataReader where {S<:AbstractString}
      if length(sources) == 0
        throw("`sources` must have at least one element.")
      end
      curfiledata = DataFrame()

      return PerFileDataReader(assetid, Base.copy(sources), datetimecol, dtfmt, delim, curfiledata)
    end

    function InMemoryDataReader(assetid::String, source::String;
        datetimecol::String="datetime",
        dtfmt::String="yyyy-mm-ddTHH:MM:SS",
        delim::Char=',')

        return InMemoryDataReader(assetid, [source];
          datetimecol=datetimecol, dtfmt=dtfmt, delim=delim)
      end

    function PerFileDataReader(assetid::String, source::S;
        datetimecol::String="datetime",
        dtfmt::String="yyyy-mm-ddTHH:MM:SS",
        delim::Char=',')::PerFileDataReader where {S<:AbstractString}
      return PerFileDataReader(assetid, [source]; datetimecol=datetimecol, dtfmt=dtfmt, delim=delim)
    end

    function copy(dr::InMemoryDataReader)
      return InMemoryDataReader(dr.assetid, dr.data, dr.datetimecol)
    end

    function copy(dr::PerFileDataReader)
      return PerFileDataReader(dr.assetid, Base.copy(dr.sources), dr.datetimecol, dr.dtfmt, dr.delim, dr.curfiledata)
    end

    function fastforward!(datareader::DRT, time::T) where {DRT<:AbstractDataReader, T<:TimeType}
      curtime() = peek(datareader)[datareader.datetimecol]
      if empty(datareader)
        throw("`DataReader` has no data.")
      elseif curtime() > time
        throw(DateTooEarlyError(time, datareader.assetid))
      end
      while !empty(datareader) && curtime() < time
        Internal.step!(datareader)
      end

      if empty(datareader)
        throw(DateTooFarOutError(time, datareader.assetid))
      end
    end

    function peek(datareader::InMemoryDataReader)::Dict{FieldId, Any}
      return Internal.peek(datareader.data)
    end

    function peek(datareader::PerFileDataReader)::Dict{FieldId, Any}
      Internal.prepfilefornext!(datareader)
      return Internal.peek(datareader.curfiledata)
    end

    function popfirst!(datareader::InMemoryDataReader)::Dict{FieldId, Any}
      fieldtovalues = peek(datareader)
      delete!(datareader.data, 1)
      return fieldtovalues
    end

    function popfirst!(datareader::PerFileDataReader)::Dict{FieldId, Any}
      fieldtovalues = peek(datareader)
      delete!(datareader.curfiledata, 1)
      return fieldtovalues
    end

    empty(datareader::InMemoryDataReader) = isempty(datareader.data)

    function empty(datareader::PerFileDataReader)
      return isempty(datareader.curfiledata) && isempty(datareader.sources)
    end

    export copy
  end # module

  module Orders
    import ..Ids: AssetId

    abstract type AbstractOrder end

    struct MarketOrder <: AbstractOrder
      assetid::AssetId
      size::NT where {NT<:Number}
    end

    struct LimitOrder <: AbstractOrder
      assetid::AssetId
      size::ST where {ST<:Number}
      extremum::ET where {ET<:Number}
    end
  end # module

  module Events
    import Dates: TimeType
    import ..Ids: AssetId, FieldId, OrderId
    import ..Orders: AbstractOrder

    # Event definitions #
    """ `AbstractEvent` is the ancestor to all concrete events.
    Required subtypes:
    1. name: time; type: datetime; description: the time at which the event should be fired (with respect to the time of the backtest, not the real datetime)
    """
    abstract type AbstractEvent end

    struct NewBarEvent <: AbstractEvent
      time::T where {T<:TimeType}
      genesisdata::Dict{AssetId, Dict{FieldId, A}} where {A<:Any}
    end

    struct FieldCompletedProcessingEvent <: AbstractEvent
      time::T where {T<:TimeType}
    end

    abstract type AbstractOrderEvent <: AbstractEvent end

    struct OrderAckEvent <: AbstractOrderEvent
      time::T where {T<:TimeType} # when the ack is received on our end
      orderid::OrderId
    end

    struct OrderFillEvent <: AbstractOrderEvent
      time::T where {T<:TimeType} # when the ack is received on our end
      order::O where {O<:AbstractOrder}
      deltacash::Number # change in cash as a result of this order being filled
      deltaequity::Number # change in our equity in this asset
    end

    # Event queue type and related definitions #
    struct EventQueue
      events::Vector{T} where {T<:AbstractEvent} # all events, stored in chronological order (closest in the future -> furthest in the future)
      EventQueue() = new(Vector{AbstractEvent}(undef, 0))
    end

    function peek(eventq::EventQueue)
      return eventq.events[1]
    end

    function pop!(eventq::EventQueue)
      return popfirst!(eventq.events)
    end

    function push!(eventq::EventQueue, event::T) where {T<:AbstractEvent}
      index = searchsortedfirst(eventq.events, event, by=(event->event.time))
      insert!(eventq.events, index, event)
    end

    function empty(eventq::EventQueue)
      return length(eventq.events) == 0
    end
  end # module

  ## Imports
  import Dates: DateTime, TimeType, Minute, Millisecond, Period,
                TimePeriod, format
  import UUIDs: uuid4
  import NamedArrays: NamedArray
  import .Ids: AssetId, FieldId, OrderId
  import .AbstractFields: AbstractFieldOperation
  import .Events: EventQueue, FieldCompletedProcessingEvent, OrderAckEvent, push!, empty

  using .Orders
  import .Engine: CalcLattice, getalldata, data, numbarsavailable
  import .DataReaders: AbstractDataReader
  import .DataReaders # required in order to resolve `peek` collision

  ## Type Declarations ##
  """Top of the verbosity type hierarchy. In practice, this is not intended to be a
  user-facing type, however it can be used if a user wants to log every possible
  message in the backtest."""
  abstract type AbstractVerbosity end

  """Log all messages at the INFO level or below. This is a lot of verbosity.
  Supertype: `AbstractVerbosity`"""
  abstract type INFO <: AbstractVerbosity end

  """Log all messages related to transactions, and all messages at a lower level.
  Supertype: `INFO`"""
  abstract type TRANSACTIONS <: INFO end

  """Log all messages that give warnings or important information. This is very low verbosity.
  Supertype: `TRANSACTIONS`"""
  abstract type WARNING <: TRANSACTIONS end

  """Log no messages at all.
  Supertype: `WARNING`"""
  abstract type NOVERBOSITY <: WARNING end

  """Options for running a backtest.
  """
  struct StrategyOptions
    datareaders::Dict{AssetId, DR} where {DR<:AbstractDataReader}
    fieldoperations::Vector{FO} where {FO<:AbstractFieldOperation}
    numlookbackbars::Integer
    start::ST where {ST<:TimeType}
    endtime::ET where {ET<:TimeType}
    tradinginterval::TTI where {TTI<:Period}
    verbosity::DataType
    datadelay::DDT where {DDT<:Period}
    messagelatency::ODT where {ODT<:Period}
    fieldoptimeout::FOTOT where {FOTOT<:Period}
    datetimecol::String
    opencol::String
    highcol::String
    lowcol::String
    closecol::String
    volumecol::String
    ondataevent!::TODE where {TODE<:Function}
    onorderevent!::TOE where {TOE<:Function}
    principal::PT where {PT<:Number}
  end

  """Information about the portfolio."""
  mutable struct Portfolio
    equity::Dict{AssetId, EN} where {EN<:Number} # amount of equity in each asset
    buyingpower::CN where {CN<:Number}
    value::VN where {VN<:Number} # total value of the portfolio
    Portfolio(buyingpower::N) where {N<:Number} = new(Dict{AssetId, Number}(), buyingpower, buyingpower)
  end

  """Container for all backtest run-time state. `Strategy`
  objects should __not__ be modified within user-provided
  functions."""
  mutable struct Strategy
    options::StrategyOptions
    events::EventQueue
    orders::Dict{OrderId, Orders.AbstractOrder}
    openorderids::Vector{OrderId}
    assetids::Vector{AssetId}
    portfolio::Portfolio
    lattice::CalcLattice
    barstarttimes::Vector{DateTime}
    curbarstarttime::DateTime
    curtime::DateTime
    curbarindex::Integer
  end

  ## Interface Functions (pt. 1) ##
  """
      getalldata(strat::Strategy)::Vector{NamedArray}

  # Arguments
  - `strat::Strategy`: the strategy object running when this method is invoked

  # Returns
  - `Vector{NamedArray}`: Vector of [assetid, fieldid]->value pairs for all of the stored bars.

  # Note
  This function retrieves all of the available bars of data, so t is unwise to call it
    unless necessary. For example, if one just wants the past 5 bars, do not call
    `getalldata`, but instead call [data(strat, ago) for ago = 0:4].
  """
  function getalldata(strat::Strategy)::Vector{NamedArray}
    Engine.getalldata(strat.lattice)
  end

  """
      data(strat::Strategy, ago::Integer)::NamedArray

  # Arguments
  - `strat::Strategy`: the strategy object running when this method is invoked
  - `ago::Integer`: the number of bars before the previous bar; if `ago` = 0, this accesses
      the previous bar's data

  # Returns
  - `NamedArray`: [assetid, fieldid]->value pairs for `ago` bars ago.
  """
  function data(strat::Strategy, ago::Integer)::NamedArray
    return data(strat.lattice, ago)
  end

  """
      data(strat::Strategy, ago::Integer, fieldid::FieldId)::NamedArray

  # Arguments
  - `strat::Strategy`: the strategy object running when this method is invoked
  - `ago::Integer`: the number of bars before the previous bar; if `ago` = 0, this accesses
      the previous bar's data
  - `fieldid::FieldId`: Note: `FieldId` is an alias for `String`

  # Returns
  - `NamedArray`: [fieldid]->value pairs for `ago` bars ago.
  """
  function data(strat::Strategy, ago::Integer, fieldid::FieldId)::NamedArray
    return data(strat.lattice, ago, fieldid)
  end

  """
      data(strat::Strategy, ago::Integer, assetid::AssetId, fieldid::FieldId)

  # Arguments
  - `strat::Strategy`: the strategy object running when this method is invoked
  - `ago::Integer`: the number of bars before the previous bar; if `ago` = 0, this accesses
      the previous bar's data
  - `assetid::AssetId`: Note: `AssetId` is an alias for `String`
  - `fieldid::FieldId`: Note: `FieldId` is an alias for `String`

  # Returns
  - `{<:Any}`: Object associated `ago` bars ago with this `assetid` and `fieldid`
  """
  function data(strat::Strategy, ago::Integer, assetid::AssetId, fieldid::FieldId)
    return data(strat.lattice, ago, assetid, fieldid)
  end

  """
      data(strat::Strategy)::NamedArray

  # Arguments
  - `strat::Strategy`: the strategy object running when this method is invoked

  # Returns
  - `NamedArray`: [assetid, fieldid]->value pairs for the previous bar
  """
  function data(strat::Strategy)::NamedArray
    return data(strat.lattice)
  end

  """
      data(strat::Strategy, fieldid::FieldId)::NamedArray

  # Arguments
  - `strat::Strategy`: the strategy object running when this method is invoked
  - `fieldid::FieldId`: Note: `FieldId` is an alias for `String`

  # Returns:
  - `NamedArray`: assetid->value pairs for the previous bar.
  """
  function data(strat::Strategy, fieldid::FieldId)::NamedArray
    return data(strat.lattice, fieldid)
  end

  """
      data(strat::Strategy, assetid::AssetId, fieldid::FieldId)

  # Arguments
  - `strat::Strategy`: the strategy object running when this method is invoked
  - `assetid::AssetId`: Note: `AssetId` is an alias for `String`
  - `fieldid::FieldId`: Note: `FieldId` is an alias for `String`

  # Returns
  - `{<:Any}`: The object associated with `assetid` and `fieldid` for the previous bar.

  """
  function data(strat::Strategy, assetid::AssetId, fieldid::FieldId)
    return data(strat.lattice, assetid, fieldid)
  end

  """
      numbarsavailable(strat::Strategy)::Integer

  # Arguments
  - `strat::Strategy`: the strategy object running that invokes this method

  # Returns
  - `Integer`: the number of bars available from which we can retrieve data.
  """
  function numbarsavailable(strat::Strategy)::Integer
    return numbarsavailable(strat.lattice)
  end

  ### Etc. Function(s) ###
  """
      log(strat::Strategy, message::String, verbosity::Type)

  Write `message` to the console.

  # Arguments
  - `strat::Strategy`: the strategy object running that invokes this method
  - `message::String`: message to be written to console
  - `verbosity::Type`: the verbosity level to be used; see `Verbosity Levels`
  """
  function log(strat::Strategy, message::String, verbosity::Type)
    if verbosity <: strat.options.verbosity
      time = format(strat.curtime, "yyyy-mm-dd HH:MM:SS.sss")
      println(string(time, " ~~~~ ", message))
    end
  end

  ## End interface functions (pt. 1) ##

  module Internals
    using ...Ids: AssetId, FieldId, OrderId
    using ...AbstractFields: AbstractFieldOperation
    using ...ConcreteFields: Open, High, Low, Close, Volume
    using ...Events: AbstractEvent, NewBarEvent, push!, peek, pop!,
      FieldCompletedProcessingEvent, AbstractOrderEvent, OrderFillEvent,
      EventQueue
    using ...Events
    using ...DataReaders: AbstractDataReader, popfirst!, fastforward!
    import ...DataReaders
    using ...Engine: CalcLattice, newbar!, addfields!
    using ...Orders: AbstractOrder, MarketOrder
    using ....Backtest: INFO, TRANSACTIONS, Strategy, StrategyOptions, Portfolio, log
    using ...Backtest
    using Dates: DateTime, TimeType, datetime2epochms, epochms2datetime, now
    default_ondataevent(strat, event) = nothing
    default_onorderevent(strat, event) = nothing

    function Strategy(options::StrategyOptions)
      """User-facing constructor."""
      # Prepare the data readers
      preparedatareaders!(options.datareaders, options.start, options.datetimecol)

      # Prepare the lattice
      assetids = [assetid for assetid in keys(options.datareaders)]
      lattice = CalcLattice(options.numlookbackbars, assetids)
      allfields = Vector{AbstractFieldOperation}(
        [
          Open(options.opencol), High(options.highcol), Low(options.lowcol),
          Close(options.closecol), Volume(options.volumecol)
        ]
      )
      append!(allfields, options.fieldoperations)
      addfields!(lattice, allfields)
      return Strategy(
        options,
        EventQueue(),
        Dict{OrderId, AbstractOrder}(),
        Vector{OrderId}(),
        assetids,
        Portfolio(options.principal),
        lattice,
        Vector{DateTime}(),
        options.start,
        options.start,
        0
      )
    end

    function preparedatareaders!(datareaders::Dict{AssetId, DR}, starttime::DateTime, datetimecol::DTCT) where {DR<:AbstractDataReader, DTCT<:AbstractString}
      """Fast forward each data reader, so that they're all able to read bars from
      the same starting point."""
      if length(keys(datareaders)) == 0
        throw(string("No datareaders specified. At least one datareader must be",
        " specified in order to run a backtest."))
      end

      for assetid in keys(datareaders)
        fastforward!(datareaders[assetid], starttime)
      end
    end

    function runnextbar!(strat::Strategy, genesisfielddata::Dict{AssetId, Dict{FieldId, T}}) where {T<:Any}

      # Account for new bar
      strat.curbarindex += 1
      strat.curbarstarttime = DateTime(genesisfielddata[strat.assetids[1]][strat.options.datetimecol])
      strat.curtime = strat.curbarstarttime

      # Fill all of the orders for the last bar; NOTE: this works due to a weird
      # loophole from the state of the program. `runnextbar` is only invoked from
      # inside `run`. Before calling `runnextbar!`, `run` will call `loadgenesisdata!`,
      # which moves the datareaders forward a bar. This means that the current
      # `peeking` bar is in fact the bar we are on. For example, if we peek into
      # a minute-level datareader at 11:33 AM, we will see the bar data for
      # 11:33-11:34 AM. Thus, we can call `tryfillorders!` at this point.
      tryfillorders!(strat)

      # Push the data to the queue
      push!(strat.events, NewBarEvent(
        strat.curtime + strat.options.datadelay,
        genesisfielddata
      ))

      while !Events.empty(strat.events) && peek(strat.events).time < curbarendtime(strat)
        event = pop!(strat.events)
        processevent!(strat, event)
      end
      Backtest.log(strat, string("Finished running bar #", strat.curbarindex, "."), INFO)
    end

    function loadgenesisdata!(strat::Strategy)::Dict{AssetId, Dict{FieldId, Any}}
      # Initialize genesis field data array
      uniquedatetimes = Set([])
      genesisfielddata = Dict{AssetId, Dict{FieldId, Any}}()
      for assetid in strat.assetids
        genesisassetfielddata = popfirst!(strat.options.datareaders[assetid])
        datetime = genesisassetfielddata[strat.options.datetimecol]
        union!(uniquedatetimes, [datetime])
        if length(uniquedatetimes) != 1
          errormessage = "Not all datetimes are unique; consider investigating the data sources. Bear in mind that all data sources must have the same bar start times after the given backtest start time."
          Backtest.log(strat, "Error occurred: $message", WARNING)
          throw(message)
        else
          genesisfielddata[assetid] = genesisassetfielddata
        end
      end
      return genesisfielddata
    end

    curbarendtime(strat::Strategy) = strat.curbarstarttime + strat.options.tradinginterval

    function randomtimeininterval(left::LT, right::RT) where {LT<:TimeType, RT<:TimeType}
      leftms = datetime2epochms(left)
      rightms = datetime2epochms(right)
      randms = rand(leftms:rightms)
      return epochms2datetime(randms)
    end

    ### Methods related to event handling ###
    function onnewbarevent!(strat::Strategy, event::NewBarEvent)
      realstart = now()
      newbar!(strat.lattice, event.genesisdata) # run a bar on the CalcLattice
      realend = now()

      computationtime = realend - realstart
      if computationtime > strat.options.fieldoptimeout # TODO: add test that uses 0 second `fieldoptimeout` and ensures that it throws an error
        throw(string("Field operation computations (i.e. computations on pre-defined",
        " fields) took more than the allowed time; consider increasing `fieldoptimeout`",
        " from its current value of $(strat.options.fieldoptimeout)."))
      end

      push!(strat.events, FieldCompletedProcessingEvent(strat.curtime+computationtime))
    end

    function processevent!(strat::Strategy, event::T) where {T<:AbstractEvent}
      """Delegate particular events to their relevant event handlers."""
      strat.curtime = event.time
      Backtest.log(strat, "Processing `$(typeof(event))` event.", INFO)

      if isa(event, NewBarEvent)
        onnewbarevent!(strat, event)
      elseif isa(event, FieldCompletedProcessingEvent)
        strat.options.ondataevent!(strat, event)
      elseif isa(event, AbstractOrderEvent)
        Backtest.log(strat, "Order Event: $event.", TRANSACTIONS)
        updateportfolio!(strat, event) # update the portfolio as soon as we see an order event
        strat.options.onorderevent!(strat, event)
      end
    end

    function tryfillorder!(strat::Strategy, order::OT)::Bool where {OT<:AbstractOrder}
      """NOTE: ASSUMES THAT WE ARE TRYING TO FILL THE ORDER AT THE BEGINNING OF A BAR!"""
      """Returns true iff the order is filled before then endo of the current bar."""
      # TODO: account for transaction cost
      if order.size == 0
        throw("Cannot process an order with `size`=0.")
      end

      genesisdata = DataReaders.peek(strat.options.datareaders[order.assetid])
      open = genesisdata[strat.options.opencol]
      low = genesisdata[strat.options.lowcol]
      high = genesisdata[strat.options.highcol]
      if isa(order, MarketOrder)
        mid = (low+high)/2
        deltacash = -order.size*mid
        if strat.portfolio.buyingpower + deltacash < 0
          throw(string("Tried to place order $order at price $mid, but there is only $(strat.portfolio.buyingpower) of buying power."))
        end
        push!(strat.events, OrderFillEvent(
          strat.curtime + strat.options.messagelatency, # fills as soon as it gets to the exchange
          order,
          deltacash,
          order.size
        ))
        return true
      elseif isa(order, Orders.LimitOrder)
        executionprice = 0
        limitbuyfills = order.size > 0 && order.extremum >= low
        limitsellfills = order.size < 0 && order.extremum <= high
        if limitbuyfills
          executionprice = min(open, order.extremum)
        elseif limitsellfills
          executionprice = max(open, order.extremum)
        end

        if limitbuyfills || limitsellfills
          deltacash = -order.size*executionprice
          if strat.portfolio.buyingpower + deltacash < 0
            throw(string("Tried to place order $order at price $mid, but there is only $(strat.portfolio.buyingpower) of buying power."))
          end
          # say it executes at a random time within the bar
          executiontime = randomtimeininterval(strat.curtime+strat.options.messagelatency, curbarendtime(strat)+strat.options.messagelatency) #
          push!(strat.events, OrderFillEvent(
            executiontime,
            order,
            deltacash,
            order.size
          ))
          return true
        else
          return false
        end
      else
        throw(string("Cannot recognize order of type, `$(typeof(order))`."))
        return false
      end
    end

    function tryfillorders!(strat::Strategy)
      """Function invoked at the beginning of a bar to fill orders on assets
      that will fill during the bar."""
      numorderstocheck = length(strat.openorderids)
      numorderschecked = 0
      while numorderschecked < numorderstocheck
        toporderid = popfirst!(strat.openorderids)
        orderfilled = tryfillorder(strat, strat.orders[toporderid])
        if !orderfilled
          push!(strat.openorderids, toporderid)
        end
        numorderschecked += 1
      end
    end

    function updateportfolio!(strat::Strategy, event::ET) where {ET<:AbstractOrderEvent}
      """Modify the portfolio to reflect an order event. This is handled
      automatically, so users do not need to update the portfolio
      within their own `onorderevent` functions.
      """
      # Check if the portfolio actually needs to be updated
      if isa(event, OrderFillEvent)
        assetid = event.order.assetid
        # Update the equity values
        if !haskey(strat.portfolio.equity, assetid)
          strat.portfolio.equity[assetid] = 0
        end
        strat.portfolio.equity[assetid] += event.deltaequity

        # Update the cash values
        strat.portfolio.buyingpower += event.deltacash

        # Update the value of the portfolio
        # WARNING: uses `data`, so `data` must be above in the current file
        assetprices = data(strat, strat.options.closecol) # get the most recent value for the close of the bar; this isn't a perfect estimator of current value, since there's some lag
        equityvalue = 0
        for assetid in keys(strat.portfolio.equity)
          equityvalue += strat.portfolio.equity[assetid] * assetprices[assetid]
        end
        strat.portfolio.value = strat.portfolio.buyingpower + equityvalue
      end
    end
  end

  # TODO: replace with an arg in `StrategyOptions`
  function onend(strat::Strategy)
    # for ago in length(strat.lattice.recentbars)-1:-1:0
    for ago in 1:-1:0
      println(data(strat, ago))
    end

    message = string(
      "Completed running backtest after ", strat.curbarindex, " bars. ",
      "The final portfolio value is ", strat.portfolio.value, ", with buyingpower=",
      strat.portfolio.buyingpower, ", and the following holdings: ", strat.portfolio.equity, "."
    )

    log(strat, message, NOVERBOSITY)
  end

  ## Main interface functions ##

  using .Internals

  ### Main Function ###
  """
      StrategyOptions(; kwargs...)::StrategyOptions

  # Keyword Arguments

  ## Required
  - datareaders::Dict{AssetId, <:AbstractDataReader}: data source for each asset
  - fieldoperations::Vector{<:AbstractFieldOperation}: field operations to be performed
  - start::{<:Dates.TimeType}: start time for the backtest (this is the DateTime of the first bar of data to be read; actions start one bar later)
  - endtime::{<:Dates.TimeType}: end time for the backtest

  ## Optional
  - `numlookbackbars::Integer`: number of backtest bars to store; if -1, then all data is stored; if space is an issue, this can be changed to a positive #. However, this will limit how much data can be accessed.
  - `tradinginterval::{<:Dates.TimePeriod}`: how much time there is between the start of a bar
  - `verbosity:Type`: how much verbosity the backtest should have; INFO gives the most messages, and NOVERBOSITY gives the fewest
  - `datadelay::{<:Dates.Period}`: how much time transpires at the beginning of a bar before data is received; e.g. if this is 5 seconds, then data will be `received` by the backtest 5 seconds after the bar starts.
  - `messagelatency::{<:Dates.Period}`: how much time it takes to transmit a message to a brokerage/exchange
  - `fieldoptimeout::{<:Dates.Period}`: how much time until the field operation computatio times out; note that field operations are computed before the user receives data
  - `datetimecol::String`: name of datetime column
  - `opencol::String`: name of open column
  - `highcol::String`: name of high column
  - `lowcol::String`: name of low column
  - `closecol::String`: name of close column
  - `volumecol::String`: name of volume column
  - `ondataevent::Function`: user-defined function that performs logic when data is received
  - `onorderevent::Function`: user-defined function that performs logic when an order event is received
  - `principal::{<:Dates.Period}`: starting amount of buying power; in many cases this will be interpreted as a starting cash value

  """
  function StrategyOptions(;
                          datareaders::Dict{AssetId, DR},
                          fieldoperations::Vector{FO},
                          start::ST,                      # start time for the backtest (this is the DateTime of the first bar of data to be read; actions start one bar later)
                          endtime::ET,                    # end time for the backtest
                          numlookbackbars::Integer=-1,    # number of backtest bars to store; if -1, then all data is stored; if space is an issue, this can be changed to a positive #. However, this will limit how much data can be accessed.
                          tradinginterval::TTI=Minute(390), # how much time there is between the start of a bar
                          verbosity::Type=NOVERBOSITY,     # how much verbosity the backtest should have; INFO gives the most messages, and NOVERBOSITY gives the fewest
                          datadelay::DDT=Millisecond(100), # how much time transpires at the beginning of a bar before data is received; e.g. if this is 5 seconds, then data will be `received` by the backtest 5 seconds after the bar starts.
                          messagelatency::ODT=Millisecond(100), # how much time it takes to transmit a message to a brokerage/exchange
                          fieldoptimeout::FOTOT=Millisecond(100), # how much time until the field operation computatio times out; note that field operations are computed before the user receives data
                          datetimecol::String="datetime", # name of datetime column
                          opencol::String="open",         # name of open column
                          highcol::String="high",         # name of high column
                          lowcol::String="low",           # name of low column
                          closecol::String="close",       # name of close column
                          volumecol::String="volume",     # name of volume column
                          ondataevent::Function=Internals.default_ondataevent, # user-defined function that performs logic when data is received
                          onorderevent::Function=Internals.default_onorderevent, # user-defined function that performs logic when an order event is received
                          principal::PT=100_000           # starting amount of buying power; in many cases this will be interpreted as a starting cash value
                          ) where { DR<:AbstractDataReader, FO<:AbstractFieldOperation,
                          ST<:TimeType, ET<:TimeType, TTI<:TimePeriod,
                          DDT<:Period, ODT<:Period, FOTOT<:Period, PT<:Number }
    return StrategyOptions(datareaders, fieldoperations, numlookbackbars,
      start, endtime, tradinginterval, verbosity, datadelay, messagelatency, fieldoptimeout,
      datetimecol, opencol, highcol, lowcol, closecol, volumecol,  ondataevent,
      onorderevent, principal
    )
  end

  """
      run(stratoptions::StrategyOptions)::Strategy

  Run a backtest and obtain the completed strategy object.

  # Arguments
  - `stratoptions::StrategyOptions`: all of the options to be used for the backtest

  # Returns
  - `Strategy`: a completed `Strategy` object

  """
  function run(stratoptions::StrategyOptions)::Strategy
    # Build the strategy
    strat = Strategy(stratoptions)

    # Run the strategy
    while Internals.curbarendtime(strat) < strat.options.endtime
      genesisfielddata = Internals.loadgenesisdata!(strat)
      Internals.runnextbar!(strat, genesisfielddata)
    end

    # Finish the backtest
    onend(strat)

    return strat
  end

  """
      order!(strat::Strategy, order::OT)::OrderId where {OT<:Orders.AbstractOrder}

  Place an order and obtain its `orderid`.

  # Arguments
  - `strat::Strategy`: the strategy object running that invokes this method
  - `order::{<:Orders.AbstractOrder}`:

  # Returns
  - `OrderId`: identifier for the placed order.

  """
  function order!(strat::Strategy, order::OT)::OrderId where {OT<:Orders.AbstractOrder}
    """Under the hood, there's a lot of machinery happening here! In our current workflow,
    we only check to fill orders once per bar. This is a limitation of our backtest's
    interface, and surely one would have a streaming API to receive notifications from their
    brokerage in real life. However, since our backtest is fundamentally synchronous, we
    must check __in this method__ to see if our order would fill during this particular
    bar. If not, then we push it onto a queue of open orders."""
    # Generate an order id; include loop for robustness
    orderid = string(uuid4())
    while orderid in keys(strat.orders)
      orderid = string(uuid4())
    end
    # Store the order
    strat.orders[orderid] = order

    # Push event corresponding to an order ack we would receive from our brokerage/exchange
    push!(
      strat.events,
      OrderAckEvent(
        strat.curtime + 2*strat.options.messagelatency,
        orderid
    ))

    # See if the order would be filled during this bar.
    # If not, then store it as an open order.
    fillsthisbar = Internals.tryfillorder!(strat, order)
    if !fillsthisbar
      push!(strat.openorderids, orderid)
    end

    return orderid
  end

  module Utils
    import ..Ids: AssetId, FieldId
    import ..AbstractFields: AbstractFieldOperation
    import ..DataReaders: fastforward!, popfirst!, AbstractDataReader, peek
    import ..DataReaders
    import ...Backtest: Strategy, numbarsavailable, data, log, NOVERBOSITY, run, StrategyOptions, getalldata
    import JSON: json
    import DataStructures: OrderedDict
    import Dates: TimeType, DateTime, TimePeriod, Second

    # Logic for crossover events
    function _cross(strat::Strategy, assetid::AssetId, fielda::FieldId, fieldb::FieldId, over::Bool)::Bool
      """Determines if field `fielda` has "crossed over" field `fieldb` in the most recent
      bar. If `over`, then this will return true if fields `a` and `b` are equal, and then `a`
      moves higher than `b`. If not `over`, this will return true if fields `a`
      and `b` are equal, and then `a` moves lower than `b`. This ensures that any
      cross over or cross under will be discovered if this function is run on each
      bar."""
      if numbarsavailable(strat) >= 2
        (prepreva, preprevb) = data(strat, 1, assetid, fielda), data(strat, 1, assetid, fieldb)
        (preva, prevb) = data(strat, 0, assetid, fielda), data(strat, 0, assetid, fieldb)
        if over
          return (prepreva <= preprevb) && (preva > prevb)
        else
          return (prepreva >= preprevb) && (preva < prevb)
        end
      else
        return false
      end
    end

    function crossover(strat::Strategy, assetid::AssetId, fielda::FieldId, fieldb::FieldId)
      return _cross(strat, assetid, fielda, fieldb, true)
    end

    function crossunder(strat::Strategy, assetid::AssetId, fielda::FieldId, fieldb::FieldId)
      return _cross(strat, assetid, fielda, fieldb, false)
    end

    # Logic for writing data to a JSON file
    function writejson(outputfile::OFT;
        datareaders::Dict{AssetId, DR}, # data source for each asset
        fieldoperations::Vector{FO},    # field operations to be performed
        start::ST,                      # start time for the backtest (this is the DateTime of the first bar of data to be read; actions start one bar later)
        endtime::ET,                    # end time for the backtest
        tradinginterval::TTI=Minute(390), # how much time there is between the start of a bars
        datetimecol::String="datetime", # name of datetime column
        opencol::String="open",         # name of open column
        highcol::String="high",         # name of high column
        lowcol::String="low",           # name of low column
        closecol::String="close",       # name of close column
        volumecol::String="volume",     # name of volume column
        ) where { OFT<:AbstractString, DR<:AbstractDataReader,
        FO<:AbstractFieldOperation, ST<:TimeType, ET<:TimeType, TTI<:TimePeriod}

      # DataReader copy to be used later
      adatareaderkey = pop!(Set(keys(datareaders)))
      adatareader = DataReaders.copy(datareaders[adatareaderkey])

      # Run the empty backtest with all bars stored, no verbosity, no latency, and no principal
      stratoptions = StrategyOptions(
        datareaders=datareaders,
        fieldoperations=fieldoperations,
        numlookbackbars=-1,
        start=start,
        endtime=endtime,
        tradinginterval=tradinginterval,
        verbosity=NOVERBOSITY,
        datadelay=Second(0),
        messagelatency=Second(0),
        datetimecol=datetimecol,
        opencol=opencol,
        highcol=highcol,
        lowcol=lowcol,
        closecol=closecol,
        volumecol=volumecol,
        principal=0
      )
      completedstrat = run(stratoptions)
      allstratdata = getalldata(completedstrat)

      fastforward!(adatareader, start)

      ## Iterate over each date (a do-while loop)
      alljsondata = OrderedDict{DateTime, Dict{AssetId, Dict{FieldId, Any}}}()
      curbarindex = 1
      for curbarindex in 1:length(allstratdata)
        dt = popfirst!(adatareader)[datetimecol]
        if dt >= endtime
          break
        else
          thisbardata = allstratdata[curbarindex]
          alljsondata[dt] = Dict{AssetId, Dict{FieldId, Any}}()
          for assetid in names(thisbardata, 1)
            alljsondata[dt][assetid] = Dict{FieldId, Any}()
            for fieldid in names(thisbardata, 2)
              alljsondata[dt][assetid][fieldid] = thisbardata[assetid, fieldid]
            end
          end
          curbarindex += 1
        end
      end

      # Write all data JSON object to the given file
      jsonstring = json(alljsondata)
      open(outputfile, "w") do f
        write(f, jsonstring)
      end

      return
    end
  end # module

  export StrategyOptions, run, order!, getalldata, data, numbarsavailable, log
end # module

max_stars_repo_name: maxholloway/backtest

max_issues_repo_name: maxholloway/backtest

max_forks_repo_name: maxholloway/backtest