Added auth. Put together basic pricing and order API calls.
This commit is contained in:
@@ -11,6 +11,10 @@ import Config
|
|||||||
config :traderex, :tradier_api_key, System.get_env("TRADIER_API_KEY")
|
config :traderex, :tradier_api_key, System.get_env("TRADIER_API_KEY")
|
||||||
config :traderex, :tradier_account, System.get_env("TRADIER_ACCOUNT")
|
config :traderex, :tradier_account, System.get_env("TRADIER_ACCOUNT")
|
||||||
|
|
||||||
|
# Dashboard authentication
|
||||||
|
config :traderex, :dashboard_user, System.get_env("DASHBOARD_USER")
|
||||||
|
config :traderex, :dashboard_password, System.get_env("DASHBOARD_PASSWORD")
|
||||||
|
|
||||||
# ## Using releases
|
# ## Using releases
|
||||||
#
|
#
|
||||||
# If you use `mix release`, you need to explicitly enable the server
|
# If you use `mix release`, you need to explicitly enable the server
|
||||||
|
|||||||
89
traderex/lib/core/balance_history.ex
Normal file
89
traderex/lib/core/balance_history.ex
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
defmodule Core.BalanceHistory do
|
||||||
|
@moduledoc """
|
||||||
|
Represents historical balance data for a Tradier account.
|
||||||
|
"""
|
||||||
|
|
||||||
|
defmodule Snapshot do
|
||||||
|
@moduledoc """
|
||||||
|
Represents a single balance snapshot at a point in time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
date: Date.t() | nil,
|
||||||
|
value: float()
|
||||||
|
}
|
||||||
|
|
||||||
|
defstruct [:date, :value]
|
||||||
|
|
||||||
|
@spec from_api(map()) :: t()
|
||||||
|
def from_api(data) when is_map(data) do
|
||||||
|
%__MODULE__{
|
||||||
|
date: parse_date(data["date"]),
|
||||||
|
value: to_float(data["value"])
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_date(nil), do: nil
|
||||||
|
|
||||||
|
defp parse_date(date_string) when is_binary(date_string) do
|
||||||
|
case Date.from_iso8601(date_string) do
|
||||||
|
{:ok, date} -> date
|
||||||
|
{:error, _reason} -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp to_float(value) when is_float(value), do: value
|
||||||
|
defp to_float(value) when is_integer(value), do: value / 1
|
||||||
|
defp to_float(_), do: 0.0
|
||||||
|
end
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
balances: [Snapshot.t()],
|
||||||
|
delta: float(),
|
||||||
|
delta_percent: float()
|
||||||
|
}
|
||||||
|
|
||||||
|
defstruct [
|
||||||
|
:balances,
|
||||||
|
:delta,
|
||||||
|
:delta_percent
|
||||||
|
]
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a BalanceHistory struct from a Tradier API historical-balances response.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> data = %{"historical_balances" => %{"balances" => %{"balance" => [...]}, "delta" => 100.0, "delta_percent" => 1.5}}
|
||||||
|
iex> Core.BalanceHistory.from_api(data)
|
||||||
|
%Core.BalanceHistory{balances: [...], delta: 100.0, delta_percent: 1.5}
|
||||||
|
"""
|
||||||
|
@spec from_api(map()) :: t()
|
||||||
|
def from_api(%{"historical_balances" => data}) do
|
||||||
|
from_api(data)
|
||||||
|
end
|
||||||
|
|
||||||
|
def from_api(data) when is_map(data) do
|
||||||
|
balances =
|
||||||
|
case data do
|
||||||
|
%{"balances" => %{"balance" => list}} when is_list(list) ->
|
||||||
|
Enum.map(list, &Snapshot.from_api/1)
|
||||||
|
|
||||||
|
%{"balances" => %{"balance" => single}} when is_map(single) ->
|
||||||
|
[Snapshot.from_api(single)]
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
%__MODULE__{
|
||||||
|
balances: balances,
|
||||||
|
delta: to_float(data["delta"]),
|
||||||
|
delta_percent: to_float(data["delta_percent"])
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp to_float(value) when is_float(value), do: value
|
||||||
|
defp to_float(value) when is_integer(value), do: value / 1
|
||||||
|
defp to_float(_), do: 0.0
|
||||||
|
end
|
||||||
@@ -66,6 +66,144 @@ defmodule Core.Client do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Fetches account history events and returns a list of Event structs.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
{:ok, events} = Core.Client.get_history("6YA15850")
|
||||||
|
#=> [%Core.Event{type: "trade", symbol: "AAPL", ...}]
|
||||||
|
"""
|
||||||
|
@spec get_history(String.t()) :: {:ok, [Core.Event.t()]} | {:error, term()}
|
||||||
|
def get_history(account_id) do
|
||||||
|
case get("/accounts/#{account_id}/history") do
|
||||||
|
{:ok, body} -> {:ok, Core.Event.list_from_api(body)}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@balance_history_periods ~w(WEEK MONTH YTD YEAR YEAR_3 YEAR_5 ALL)
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the valid period values for balance history.
|
||||||
|
"""
|
||||||
|
def balance_history_periods, do: @balance_history_periods
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Fetches historical balance data for an account.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
* `:period` - Time period for history. One of: `WEEK`, `MONTH`, `YTD`, `YEAR`, `YEAR_3`, `YEAR_5`, `ALL`.
|
||||||
|
Defaults to `MONTH`.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
{:ok, history} = Core.Client.get_balance_history("6YA15850", period: "YEAR")
|
||||||
|
history.delta
|
||||||
|
#=> 2780.66
|
||||||
|
history.balances
|
||||||
|
#=> [%Core.BalanceHistory.Snapshot{date: ~D[2025-07-15], value: 113302.4}, ...]
|
||||||
|
"""
|
||||||
|
@spec get_balance_history(String.t(), keyword()) ::
|
||||||
|
{:ok, Core.BalanceHistory.t()} | {:error, term()}
|
||||||
|
def get_balance_history(account_id, opts \\ []) do
|
||||||
|
period = Keyword.get(opts, :period, "MONTH")
|
||||||
|
query = URI.encode_query(%{period: period})
|
||||||
|
|
||||||
|
case get("/accounts/#{account_id}/historical-balances?#{query}") do
|
||||||
|
{:ok, body} -> {:ok, Core.BalanceHistory.from_api(body)}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Fetches stock quotes for one or more symbols.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
{:ok, quotes} = Core.Client.get_quotes("SPY")
|
||||||
|
#=> [%Core.Quote{symbol: "SPY", last: 273.47, ...}]
|
||||||
|
|
||||||
|
{:ok, quotes} = Core.Client.get_quotes(["SPY", "AAPL"])
|
||||||
|
#=> [%Core.Quote{symbol: "SPY", ...}, %Core.Quote{symbol: "AAPL", ...}]
|
||||||
|
"""
|
||||||
|
@spec get_quotes(String.t() | [String.t()]) :: {:ok, [Core.Quote.t()]} | {:error, term()}
|
||||||
|
def get_quotes(symbols) when is_list(symbols) do
|
||||||
|
symbols_str = Enum.join(symbols, ",")
|
||||||
|
get_quotes(symbols_str)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_quotes(symbol) when is_binary(symbol) do
|
||||||
|
query = URI.encode_query(%{symbols: symbol})
|
||||||
|
|
||||||
|
case get("/markets/quotes?#{query}") do
|
||||||
|
{:ok, body} -> {:ok, Core.Quote.list_from_api(body)}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Places an order for an account.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
* `:symbol` - The stock symbol (required)
|
||||||
|
* `:quantity` - Number of shares (required)
|
||||||
|
* `:side` - Order side: "buy" or "sell" (required)
|
||||||
|
* `:type` - Order type: "market", "limit", "stop", "stop_limit" (required)
|
||||||
|
* `:price` - Limit price (required for limit orders)
|
||||||
|
* `:stop` - Stop price (required for stop orders)
|
||||||
|
* `:duration` - Order duration: "day", "gtc", "pre", "post" (default: "day")
|
||||||
|
* `:class` - Order class: "equity", "option", "multileg", "combo" (default: "equity")
|
||||||
|
* `:preview` - If true, validates order without submitting (default: true)
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
# Preview an order
|
||||||
|
{:ok, order} = Core.Client.place_order("6YA15850",
|
||||||
|
symbol: "SPY",
|
||||||
|
quantity: 10,
|
||||||
|
side: "buy",
|
||||||
|
type: "limit",
|
||||||
|
price: 500,
|
||||||
|
preview: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Submit the order
|
||||||
|
{:ok, order} = Core.Client.place_order("6YA15850",
|
||||||
|
symbol: "SPY",
|
||||||
|
quantity: 10,
|
||||||
|
side: "buy",
|
||||||
|
type: "limit",
|
||||||
|
price: 500,
|
||||||
|
preview: false
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
@spec place_order(String.t(), keyword()) :: {:ok, Core.Order.t()} | {:error, term()}
|
||||||
|
def place_order(account_id, opts) do
|
||||||
|
params =
|
||||||
|
%{
|
||||||
|
class: Keyword.get(opts, :class, "equity"),
|
||||||
|
symbol: Keyword.fetch!(opts, :symbol),
|
||||||
|
quantity: Keyword.fetch!(opts, :quantity),
|
||||||
|
side: Keyword.fetch!(opts, :side),
|
||||||
|
type: Keyword.fetch!(opts, :type),
|
||||||
|
duration: Keyword.get(opts, :duration, "day"),
|
||||||
|
preview: Keyword.get(opts, :preview, true)
|
||||||
|
}
|
||||||
|
|> maybe_put(:price, Keyword.get(opts, :price))
|
||||||
|
|> maybe_put(:stop, Keyword.get(opts, :stop))
|
||||||
|
|
||||||
|
case post("/accounts/#{account_id}/orders", params) do
|
||||||
|
{:ok, body} -> {:ok, Core.Order.from_api(body)}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_put(map, _key, nil), do: map
|
||||||
|
defp maybe_put(map, key, value), do: Map.put(map, key, value)
|
||||||
|
|
||||||
defp get(path) do
|
defp get(path) do
|
||||||
url = @base_url <> path
|
url = @base_url <> path
|
||||||
|
|
||||||
@@ -81,6 +219,21 @@ defmodule Core.Client do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp post(path, params) do
|
||||||
|
url = @base_url <> path
|
||||||
|
|
||||||
|
case Req.post(url, headers: headers(), form: params) do
|
||||||
|
{:ok, %Req.Response{status: 200, body: body}} ->
|
||||||
|
{:ok, body}
|
||||||
|
|
||||||
|
{:ok, %Req.Response{status: status, body: body}} ->
|
||||||
|
{:error, {:http_error, status, body}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp headers do
|
defp headers do
|
||||||
[
|
[
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
|
|||||||
74
traderex/lib/core/event.ex
Normal file
74
traderex/lib/core/event.ex
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
defmodule Core.Event do
|
||||||
|
@moduledoc """
|
||||||
|
Represents an account history event from Tradier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
date: DateTime.t() | nil,
|
||||||
|
type: String.t(),
|
||||||
|
symbol: String.t() | nil,
|
||||||
|
quantity: float() | nil,
|
||||||
|
price: float() | nil,
|
||||||
|
amount: float(),
|
||||||
|
description: String.t(),
|
||||||
|
commission: float()
|
||||||
|
}
|
||||||
|
|
||||||
|
defstruct [
|
||||||
|
:date,
|
||||||
|
:type,
|
||||||
|
:symbol,
|
||||||
|
:quantity,
|
||||||
|
:price,
|
||||||
|
:amount,
|
||||||
|
:description,
|
||||||
|
:commission
|
||||||
|
]
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates an Event struct from a Tradier API event response map.
|
||||||
|
"""
|
||||||
|
@spec from_api(map()) :: t()
|
||||||
|
def from_api(data) when is_map(data) do
|
||||||
|
%__MODULE__{
|
||||||
|
date: parse_datetime(data["date"]),
|
||||||
|
type: data["type"],
|
||||||
|
symbol: data["symbol"],
|
||||||
|
quantity: data["quantity"],
|
||||||
|
price: data["price"],
|
||||||
|
amount: data["amount"],
|
||||||
|
description: data["description"],
|
||||||
|
commission: data["commission"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Parses events from a Tradier API history response.
|
||||||
|
|
||||||
|
The API returns events wrapped in a "history" key, and the "event"
|
||||||
|
value can be either a single map (one event) or a list (multiple events).
|
||||||
|
|
||||||
|
Returns an empty list if there are no events.
|
||||||
|
"""
|
||||||
|
@spec list_from_api(map()) :: [t()]
|
||||||
|
def list_from_api(%{"history" => %{"event" => events}}) when is_list(events) do
|
||||||
|
Enum.map(events, &from_api/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_from_api(%{"history" => %{"event" => event}}) when is_map(event) do
|
||||||
|
[from_api(event)]
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_from_api(%{"history" => "null"}), do: []
|
||||||
|
def list_from_api(%{"history" => nil}), do: []
|
||||||
|
def list_from_api(_), do: []
|
||||||
|
|
||||||
|
defp parse_datetime(nil), do: nil
|
||||||
|
|
||||||
|
defp parse_datetime(datetime_string) when is_binary(datetime_string) do
|
||||||
|
case DateTime.from_iso8601(datetime_string) do
|
||||||
|
{:ok, datetime, _offset} -> datetime
|
||||||
|
{:error, _reason} -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
39
traderex/lib/core/order.ex
Normal file
39
traderex/lib/core/order.ex
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
defmodule Core.Order do
|
||||||
|
@moduledoc """
|
||||||
|
Represents an order response from Tradier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
id: integer() | nil,
|
||||||
|
status: String.t() | nil,
|
||||||
|
partner_id: String.t() | nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defstruct [
|
||||||
|
:id,
|
||||||
|
:status,
|
||||||
|
:partner_id
|
||||||
|
]
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates an Order struct from a Tradier API order response.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> data = %{"order" => %{"id" => 123456, "status" => "ok", "partner_id" => "partner_12345"}}
|
||||||
|
iex> Core.Order.from_api(data)
|
||||||
|
%Core.Order{id: 123456, status: "ok", partner_id: "partner_12345"}
|
||||||
|
"""
|
||||||
|
@spec from_api(map()) :: t()
|
||||||
|
def from_api(%{"order" => order}) when is_map(order) do
|
||||||
|
from_api(order)
|
||||||
|
end
|
||||||
|
|
||||||
|
def from_api(data) when is_map(data) do
|
||||||
|
%__MODULE__{
|
||||||
|
id: data["id"],
|
||||||
|
status: data["status"],
|
||||||
|
partner_id: data["partner_id"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
99
traderex/lib/core/quote.ex
Normal file
99
traderex/lib/core/quote.ex
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
defmodule Core.Quote do
|
||||||
|
@moduledoc """
|
||||||
|
Represents a stock quote from Tradier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
symbol: String.t(),
|
||||||
|
description: String.t() | nil,
|
||||||
|
exch: String.t() | nil,
|
||||||
|
type: String.t() | nil,
|
||||||
|
last: float() | nil,
|
||||||
|
change: float() | nil,
|
||||||
|
change_percentage: float() | nil,
|
||||||
|
volume: integer() | nil,
|
||||||
|
open: float() | nil,
|
||||||
|
high: float() | nil,
|
||||||
|
low: float() | nil,
|
||||||
|
close: float() | nil,
|
||||||
|
bid: float() | nil,
|
||||||
|
ask: float() | nil,
|
||||||
|
prevclose: float() | nil,
|
||||||
|
week_52_high: float() | nil,
|
||||||
|
week_52_low: float() | nil,
|
||||||
|
average_volume: integer() | nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defstruct [
|
||||||
|
:symbol,
|
||||||
|
:description,
|
||||||
|
:exch,
|
||||||
|
:type,
|
||||||
|
:last,
|
||||||
|
:change,
|
||||||
|
:change_percentage,
|
||||||
|
:volume,
|
||||||
|
:open,
|
||||||
|
:high,
|
||||||
|
:low,
|
||||||
|
:close,
|
||||||
|
:bid,
|
||||||
|
:ask,
|
||||||
|
:prevclose,
|
||||||
|
:week_52_high,
|
||||||
|
:week_52_low,
|
||||||
|
:average_volume
|
||||||
|
]
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a Quote struct from a Tradier API quote response map.
|
||||||
|
"""
|
||||||
|
@spec from_api(map()) :: t()
|
||||||
|
def from_api(data) when is_map(data) do
|
||||||
|
%__MODULE__{
|
||||||
|
symbol: data["symbol"],
|
||||||
|
description: data["description"],
|
||||||
|
exch: data["exch"],
|
||||||
|
type: data["type"],
|
||||||
|
last: to_float(data["last"]),
|
||||||
|
change: to_float(data["change"]),
|
||||||
|
change_percentage: to_float(data["change_percentage"]),
|
||||||
|
volume: data["volume"],
|
||||||
|
open: to_float(data["open"]),
|
||||||
|
high: to_float(data["high"]),
|
||||||
|
low: to_float(data["low"]),
|
||||||
|
close: to_float(data["close"]),
|
||||||
|
bid: to_float(data["bid"]),
|
||||||
|
ask: to_float(data["ask"]),
|
||||||
|
prevclose: to_float(data["prevclose"]),
|
||||||
|
week_52_high: to_float(data["week_52_high"]),
|
||||||
|
week_52_low: to_float(data["week_52_low"]),
|
||||||
|
average_volume: data["average_volume"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Parses quotes from a Tradier API quotes response.
|
||||||
|
|
||||||
|
The API returns quotes wrapped in a "quotes" key, and the "quote"
|
||||||
|
value can be either a single map (one symbol) or a list (multiple symbols).
|
||||||
|
|
||||||
|
Returns an empty list if there are no quotes.
|
||||||
|
"""
|
||||||
|
@spec list_from_api(map()) :: [t()]
|
||||||
|
def list_from_api(%{"quotes" => %{"quote" => quotes}}) when is_list(quotes) do
|
||||||
|
Enum.map(quotes, &from_api/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_from_api(%{"quotes" => %{"quote" => quote}}) when is_map(quote) do
|
||||||
|
[from_api(quote)]
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_from_api(%{"quotes" => "null"}), do: []
|
||||||
|
def list_from_api(%{"quotes" => nil}), do: []
|
||||||
|
def list_from_api(_), do: []
|
||||||
|
|
||||||
|
defp to_float(value) when is_float(value), do: value
|
||||||
|
defp to_float(value) when is_integer(value), do: value / 1
|
||||||
|
defp to_float(_), do: nil
|
||||||
|
end
|
||||||
48
traderex/lib/traderex_web/auth.ex
Normal file
48
traderex/lib/traderex_web/auth.ex
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
defmodule TraderexWeb.Auth do
|
||||||
|
@moduledoc """
|
||||||
|
Simple authentication module using environment variables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Verifies the given credentials against the configured environment variables.
|
||||||
|
"""
|
||||||
|
def verify_credentials(username, password) do
|
||||||
|
configured_user = Application.get_env(:traderex, :dashboard_user)
|
||||||
|
configured_password = Application.get_env(:traderex, :dashboard_password)
|
||||||
|
|
||||||
|
if configured_user && configured_password &&
|
||||||
|
Plug.Crypto.secure_compare(username, configured_user) &&
|
||||||
|
Plug.Crypto.secure_compare(password, configured_password) do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
:error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Logs in the user by setting the session.
|
||||||
|
"""
|
||||||
|
def login(conn) do
|
||||||
|
conn
|
||||||
|
|> put_session(:authenticated, true)
|
||||||
|
|> configure_session(renew: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Logs out the user by clearing the session.
|
||||||
|
"""
|
||||||
|
def logout(conn) do
|
||||||
|
conn
|
||||||
|
|> clear_session()
|
||||||
|
|> configure_session(drop: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Checks if the user is authenticated.
|
||||||
|
"""
|
||||||
|
def authenticated?(conn) do
|
||||||
|
get_session(conn, :authenticated) == true
|
||||||
|
end
|
||||||
|
end
|
||||||
23
traderex/lib/traderex_web/controllers/auth_controller.ex
Normal file
23
traderex/lib/traderex_web/controllers/auth_controller.ex
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
defmodule TraderexWeb.AuthController do
|
||||||
|
use TraderexWeb, :controller
|
||||||
|
|
||||||
|
def login(conn, %{"credentials" => %{"username" => username, "password" => password}}) do
|
||||||
|
case TraderexWeb.Auth.verify_credentials(username, password) do
|
||||||
|
:ok ->
|
||||||
|
conn
|
||||||
|
|> TraderexWeb.Auth.login()
|
||||||
|
|> redirect(to: ~p"/")
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Invalid username or password")
|
||||||
|
|> redirect(to: ~p"/login")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def logout(conn, _params) do
|
||||||
|
conn
|
||||||
|
|> TraderexWeb.Auth.logout()
|
||||||
|
|> redirect(to: ~p"/login")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,6 +9,11 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
socket
|
socket
|
||||||
|> assign(:account, nil)
|
|> assign(:account, nil)
|
||||||
|> assign(:positions, [])
|
|> assign(:positions, [])
|
||||||
|
|> assign(:events, [])
|
||||||
|
|> assign(:balance_history, nil)
|
||||||
|
|> assign(:balance_period, "MONTH")
|
||||||
|
|> assign(:balance_history_expanded, false)
|
||||||
|
|> assign(:events_expanded, false)
|
||||||
|> assign(:loading, true)
|
|> assign(:loading, true)
|
||||||
|> assign(:error, nil)
|
|> assign(:error, nil)
|
||||||
|
|
||||||
@@ -22,25 +27,45 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
@impl true
|
@impl true
|
||||||
def handle_info(:load_data, socket) do
|
def handle_info(:load_data, socket) do
|
||||||
account_id = Core.Client.account_id()
|
account_id = Core.Client.account_id()
|
||||||
|
period = socket.assigns.balance_period
|
||||||
|
|
||||||
|
results = {
|
||||||
|
Core.Client.get_account(account_id),
|
||||||
|
Core.Client.get_positions(account_id),
|
||||||
|
Core.Client.get_history(account_id),
|
||||||
|
Core.Client.get_balance_history(account_id, period: period)
|
||||||
|
}
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
case {Core.Client.get_account(account_id), Core.Client.get_positions(account_id)} do
|
case results do
|
||||||
{{:ok, account}, {:ok, positions}} ->
|
{{:ok, account}, {:ok, positions}, {:ok, events}, {:ok, balance_history}} ->
|
||||||
socket
|
socket
|
||||||
|> assign(:account, account)
|
|> assign(:account, account)
|
||||||
|> assign(:positions, positions)
|
|> assign(:positions, positions)
|
||||||
|
|> assign(:events, Enum.take(events, 5))
|
||||||
|
|> assign(:balance_history, balance_history)
|
||||||
|> assign(:loading, false)
|
|> assign(:loading, false)
|
||||||
|> assign(:error, nil)
|
|> assign(:error, nil)
|
||||||
|
|
||||||
{{:error, reason}, _} ->
|
{{:error, reason}, _, _, _} ->
|
||||||
socket
|
socket
|
||||||
|> assign(:loading, false)
|
|> assign(:loading, false)
|
||||||
|> assign(:error, "Failed to load account: #{inspect(reason)}")
|
|> assign(:error, "Failed to load account: #{inspect(reason)}")
|
||||||
|
|
||||||
{_, {:error, reason}} ->
|
{_, {:error, reason}, _, _} ->
|
||||||
socket
|
socket
|
||||||
|> assign(:loading, false)
|
|> assign(:loading, false)
|
||||||
|> assign(:error, "Failed to load positions: #{inspect(reason)}")
|
|> assign(:error, "Failed to load positions: #{inspect(reason)}")
|
||||||
|
|
||||||
|
{_, _, {:error, reason}, _} ->
|
||||||
|
socket
|
||||||
|
|> assign(:loading, false)
|
||||||
|
|> assign(:error, "Failed to load history: #{inspect(reason)}")
|
||||||
|
|
||||||
|
{_, _, _, {:error, reason}} ->
|
||||||
|
socket
|
||||||
|
|> assign(:loading, false)
|
||||||
|
|> assign(:error, "Failed to load balance history: #{inspect(reason)}")
|
||||||
end
|
end
|
||||||
|
|
||||||
schedule_refresh()
|
schedule_refresh()
|
||||||
@@ -51,6 +76,28 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
Process.send_after(self(), :load_data, @refresh_interval)
|
Process.send_after(self(), :load_data, @refresh_interval)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("change_period", %{"period" => period}, socket) do
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:balance_period, period)
|
||||||
|
|> assign(:balance_history, nil)
|
||||||
|
|
||||||
|
send(self(), :load_data)
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("toggle_balance_history", _, socket) do
|
||||||
|
{:noreply,
|
||||||
|
assign(socket, :balance_history_expanded, !socket.assigns.balance_history_expanded)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("toggle_events", _, socket) do
|
||||||
|
{:noreply, assign(socket, :events_expanded, !socket.assigns.events_expanded)}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
@@ -58,6 +105,14 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
<.header>
|
<.header>
|
||||||
Traderex Dashboard
|
Traderex Dashboard
|
||||||
<:subtitle>Account Overview</:subtitle>
|
<:subtitle>Account Overview</:subtitle>
|
||||||
|
<:actions>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<TraderexWeb.Layouts.theme_toggle />
|
||||||
|
<.link href={~p"/auth/logout"} method="delete" class="btn btn-ghost btn-sm">
|
||||||
|
<.icon name="hero-arrow-right-on-rectangle" class="size-4" /> Logout
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<div :if={@loading} class="flex justify-center items-center py-12">
|
<div :if={@loading} class="flex justify-center items-center py-12">
|
||||||
@@ -112,6 +167,71 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex justify-between items-center flex-wrap gap-2">
|
||||||
|
<h2 class="card-title">Balance History</h2>
|
||||||
|
<div class="join">
|
||||||
|
<button
|
||||||
|
:for={period <- Core.Client.balance_history_periods()}
|
||||||
|
class={[
|
||||||
|
"join-item btn btn-sm",
|
||||||
|
@balance_period == period && "btn-active"
|
||||||
|
]}
|
||||||
|
phx-click="change_period"
|
||||||
|
phx-value-period={period}
|
||||||
|
>
|
||||||
|
{format_period_label(period)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :if={@balance_history == nil} class="flex justify-center py-4">
|
||||||
|
<span class="loading loading-spinner"></span>
|
||||||
|
</div>
|
||||||
|
<div :if={@balance_history} class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div class="stat bg-base-100 rounded-box">
|
||||||
|
<div class="stat-title">{format_period_label(@balance_period)} Change</div>
|
||||||
|
<div class={["stat-value text-lg", pl_color(@balance_history.delta)]}>
|
||||||
|
{format_currency(@balance_history.delta)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-base-100 rounded-box">
|
||||||
|
<div class="stat-title">{format_period_label(@balance_period)} Change %</div>
|
||||||
|
<div class={["stat-value text-lg", pl_color(@balance_history.delta_percent)]}>
|
||||||
|
{format_percent(@balance_history.delta_percent)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :if={@balance_history && @balance_history.balances != []} class="mt-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm gap-1"
|
||||||
|
phx-click="toggle_balance_history"
|
||||||
|
>
|
||||||
|
<.icon
|
||||||
|
name={
|
||||||
|
if @balance_history_expanded, do: "hero-chevron-up", else: "hero-chevron-down"
|
||||||
|
}
|
||||||
|
class="size-4"
|
||||||
|
/>
|
||||||
|
{if @balance_history_expanded, do: "Hide", else: "Show"} History
|
||||||
|
</button>
|
||||||
|
<.table
|
||||||
|
:if={@balance_history_expanded}
|
||||||
|
id="balance-history"
|
||||||
|
rows={Enum.take(@balance_history.balances, -10) |> Enum.reverse()}
|
||||||
|
row_id={&"balance-#{&1.date}"}
|
||||||
|
>
|
||||||
|
<:col :let={snapshot} label="Date">
|
||||||
|
{format_date_only(snapshot.date)}
|
||||||
|
</:col>
|
||||||
|
<:col :let={snapshot} label="Value">
|
||||||
|
{format_currency(snapshot.value)}
|
||||||
|
</:col>
|
||||||
|
</.table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-200 shadow-xl">
|
<div class="card bg-base-200 shadow-xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title">Positions</h2>
|
<h2 class="card-title">Positions</h2>
|
||||||
@@ -142,6 +262,51 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
</.table>
|
</.table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="card-title">Recent Activity</h2>
|
||||||
|
<span :if={@events != []} class="text-sm text-base-content/70">
|
||||||
|
{length(@events)} events
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div :if={@events == []} class="text-base-content/70 py-4">
|
||||||
|
No recent activity
|
||||||
|
</div>
|
||||||
|
<div :if={@events != []} class="mt-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm gap-1"
|
||||||
|
phx-click="toggle_events"
|
||||||
|
>
|
||||||
|
<.icon
|
||||||
|
name={if @events_expanded, do: "hero-chevron-up", else: "hero-chevron-down"}
|
||||||
|
class="size-4"
|
||||||
|
/>
|
||||||
|
{if @events_expanded, do: "Hide", else: "Show"} Activity
|
||||||
|
</button>
|
||||||
|
<.table
|
||||||
|
:if={@events_expanded}
|
||||||
|
id="events"
|
||||||
|
rows={@events}
|
||||||
|
row_id={&"event-#{&1.date}"}
|
||||||
|
>
|
||||||
|
<:col :let={event} label="Date">
|
||||||
|
{format_datetime(event.date)}
|
||||||
|
</:col>
|
||||||
|
<:col :let={event} label="Type">
|
||||||
|
<span class="badge badge-ghost">{event.type}</span>
|
||||||
|
</:col>
|
||||||
|
<:col :let={event} label="Description">
|
||||||
|
{event.description}
|
||||||
|
</:col>
|
||||||
|
<:col :let={event} label="Amount">
|
||||||
|
<span class={amount_color(event.amount)}>{format_currency(event.amount)}</span>
|
||||||
|
</:col>
|
||||||
|
</.table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
@@ -166,7 +331,11 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
|
|
||||||
defp format_currency(nil), do: "-"
|
defp format_currency(nil), do: "-"
|
||||||
|
|
||||||
defp format_currency(amount) when is_number(amount) do
|
defp format_currency(amount) when is_integer(amount) do
|
||||||
|
format_currency(amount / 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_currency(amount) when is_float(amount) do
|
||||||
sign = if amount < 0, do: "-", else: ""
|
sign = if amount < 0, do: "-", else: ""
|
||||||
"#{sign}$#{:erlang.float_to_binary(abs(amount), decimals: 2)}"
|
"#{sign}$#{:erlang.float_to_binary(abs(amount), decimals: 2)}"
|
||||||
end
|
end
|
||||||
@@ -189,7 +358,40 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
Calendar.strftime(dt, "%Y-%m-%d")
|
Calendar.strftime(dt, "%Y-%m-%d")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp format_date_only(nil), do: "-"
|
||||||
|
|
||||||
|
defp format_date_only(%Date{} = date) do
|
||||||
|
Calendar.strftime(date, "%Y-%m-%d")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_percent(nil), do: "-"
|
||||||
|
|
||||||
|
defp format_percent(percent) when is_number(percent) do
|
||||||
|
sign = if percent >= 0, do: "+", else: ""
|
||||||
|
"#{sign}#{:erlang.float_to_binary(percent / 1, decimals: 2)}%"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_datetime(nil), do: "-"
|
||||||
|
|
||||||
|
defp format_datetime(%DateTime{} = dt) do
|
||||||
|
Calendar.strftime(dt, "%Y-%m-%d %H:%M")
|
||||||
|
end
|
||||||
|
|
||||||
defp pl_color(nil), do: nil
|
defp pl_color(nil), do: nil
|
||||||
defp pl_color(amount) when amount >= 0, do: "text-success"
|
defp pl_color(amount) when amount >= 0, do: "text-success"
|
||||||
defp pl_color(_amount), do: "text-error"
|
defp pl_color(_amount), do: "text-error"
|
||||||
|
|
||||||
|
defp amount_color(nil), do: nil
|
||||||
|
defp amount_color(amount) when amount > 0, do: "text-success"
|
||||||
|
defp amount_color(amount) when amount < 0, do: "text-error"
|
||||||
|
defp amount_color(_amount), do: nil
|
||||||
|
|
||||||
|
defp format_period_label("WEEK"), do: "1W"
|
||||||
|
defp format_period_label("MONTH"), do: "1M"
|
||||||
|
defp format_period_label("YTD"), do: "YTD"
|
||||||
|
defp format_period_label("YEAR"), do: "1Y"
|
||||||
|
defp format_period_label("YEAR_3"), do: "3Y"
|
||||||
|
defp format_period_label("YEAR_5"), do: "5Y"
|
||||||
|
defp format_period_label("ALL"), do: "All"
|
||||||
|
defp format_period_label(period), do: period
|
||||||
end
|
end
|
||||||
|
|||||||
38
traderex/lib/traderex_web/live/login_live.ex
Normal file
38
traderex/lib/traderex_web/live/login_live.ex
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
defmodule TraderexWeb.LoginLive do
|
||||||
|
use TraderexWeb, :live_view
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
form = to_form(%{"username" => "", "password" => ""}, as: :credentials)
|
||||||
|
{:ok, assign(socket, form: form)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
|
<div class="card bg-base-200 shadow-xl w-full max-w-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl justify-center mb-4">Traderex Login</h2>
|
||||||
|
|
||||||
|
<.flash :if={@flash[:error]} kind={:error} flash={@flash} />
|
||||||
|
|
||||||
|
<.form
|
||||||
|
for={@form}
|
||||||
|
id="login-form"
|
||||||
|
action={~p"/auth/login"}
|
||||||
|
method="post"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<.input field={@form[:username]} type="text" label="Username" required />
|
||||||
|
<.input field={@form[:password]} type="password" label="Password" required />
|
||||||
|
<.button type="submit" variant="primary" class="w-full">
|
||||||
|
Sign In
|
||||||
|
</.button>
|
||||||
|
</.form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
20
traderex/lib/traderex_web/plugs/require_auth.ex
Normal file
20
traderex/lib/traderex_web/plugs/require_auth.ex
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
defmodule TraderexWeb.Plugs.RequireAuth do
|
||||||
|
@moduledoc """
|
||||||
|
Plug that requires authentication to access protected routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.Controller
|
||||||
|
|
||||||
|
def init(opts), do: opts
|
||||||
|
|
||||||
|
def call(conn, _opts) do
|
||||||
|
if TraderexWeb.Auth.authenticated?(conn) do
|
||||||
|
conn
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
|> redirect(to: "/login")
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -14,9 +14,21 @@ defmodule TraderexWeb.Router do
|
|||||||
plug :accepts, ["json"]
|
plug :accepts, ["json"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
pipeline :require_auth do
|
||||||
|
plug TraderexWeb.Plugs.RequireAuth
|
||||||
|
end
|
||||||
|
|
||||||
scope "/", TraderexWeb do
|
scope "/", TraderexWeb do
|
||||||
pipe_through :browser
|
pipe_through :browser
|
||||||
|
|
||||||
|
live "/login", LoginLive
|
||||||
|
post "/auth/login", AuthController, :login
|
||||||
|
delete "/auth/logout", AuthController, :logout
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/", TraderexWeb do
|
||||||
|
pipe_through [:browser, :require_auth]
|
||||||
|
|
||||||
live "/", DashboardLive
|
live "/", DashboardLive
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,38 @@ defmodule TraderexWeb.DashboardLiveTest do
|
|||||||
|
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
test "renders dashboard", %{conn: conn} do
|
setup do
|
||||||
|
# Set test credentials
|
||||||
|
Application.put_env(:traderex, :dashboard_user, "testuser")
|
||||||
|
Application.put_env(:traderex, :dashboard_password, "testpass")
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
Application.delete_env(:traderex, :dashboard_user)
|
||||||
|
Application.delete_env(:traderex, :dashboard_password)
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp log_in(conn) do
|
||||||
|
conn
|
||||||
|
|> Plug.Test.init_test_session(%{})
|
||||||
|
|> Plug.Conn.put_session(:authenticated, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirects to login when not authenticated", %{conn: conn} do
|
||||||
|
assert {:error, {:redirect, %{to: "/login"}}} = live(conn, ~p"/")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders dashboard when authenticated", %{conn: conn} do
|
||||||
|
conn = log_in(conn)
|
||||||
{:ok, view, html} = live(conn, ~p"/")
|
{:ok, view, html} = live(conn, ~p"/")
|
||||||
assert html =~ "Traderex Dashboard"
|
assert html =~ "Traderex Dashboard"
|
||||||
assert has_element?(view, "header", "Account Overview")
|
assert has_element?(view, "header", "Account Overview")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows loading state initially", %{conn: conn} do
|
test "shows loading state initially when authenticated", %{conn: conn} do
|
||||||
|
conn = log_in(conn)
|
||||||
{:ok, _view, html} = live(conn, ~p"/")
|
{:ok, _view, html} = live(conn, ~p"/")
|
||||||
assert html =~ "loading loading-spinner"
|
assert html =~ "loading loading-spinner"
|
||||||
end
|
end
|
||||||
|
|||||||
11
traderex/test/traderex_web/live/login_live_test.exs
Normal file
11
traderex/test/traderex_web/live/login_live_test.exs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
defmodule TraderexWeb.LoginLiveTest do
|
||||||
|
use TraderexWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
test "renders login form", %{conn: conn} do
|
||||||
|
{:ok, view, html} = live(conn, ~p"/login")
|
||||||
|
assert html =~ "Traderex Login"
|
||||||
|
assert has_element?(view, "#login-form")
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user