Next-generation infrastructure for investment management

{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions) field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
endend
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :showend
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :show {:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :show
live "/capital-account", PortalLive.CapitalAccount, :index
end
end
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end {:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :show
live "/capital-account", PortalLive.CapitalAccount, :index
end
end
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end
defmodule Evo.Repo.Migrations.AddLineageToRecords do
use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period), share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end
defmodule Evo.Repo.Migrations.AddLineageToRecords do
use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period), add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed -> add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance() import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do {:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :show
live "/capital-account", PortalLive.CapitalAccount, :index
end
end
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3) {:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :show
live "/capital-account", PortalLive.CapitalAccount, :index
end
end
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)end
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end
defmodule Evo.Repo.Migrations.AddLineageToRecords do
use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
callend
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end
defmodule Evo.Repo.Migrations.AddLineageToRecords do
use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t) use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t) statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
endend
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Workerend
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
Evolutions provides technology solutions and operating expertise to firms across investment management, fund services, financial infrastructure and digital markets.
Evolutions provides technology solutions and operating expertise to firms across investment management, fund services, financial infrastructure and digital markets.
Our services
AI-native technologies we build ourselves
General Ledger
Entry
Account
Description
Amount
JE-1041
1000
Cash
Capital call received
4,250,000.00
JE-1042
1500
Investments
Purchase of portfolio stake
3,800,000.00
JE-1043
4100
Fees
Management fee, Q2
187,500.00
JE-1044
2200
Payables
Admin expense accrued
42,180.00
JE-1045
1000
Cash
Bank interest
3,912.44
JE-1046
1500
Investments
Valuation change, Q2
615,000.00
JE-1047
1000
Cash
Distribution paid to partners
1,200,000.00
JE-1048
2200
Payables
Audit fee accrued
36,000.00
JE-1049
4100
Fees
Management fee, Q3
187,500.00
General ledger reconstruction
Turn fragmented historical records into a coherent, auditable financial history across funds, entities and investors.
General Ledger
Entry
Account
Description
Amount
JE-1041
1000
Cash
Capital call received
4,250,000.00
JE-1042
1500
Investments
Purchase of portfolio stake
3,800,000.00
JE-1043
4100
Fees
Management fee, Q2
187,500.00
JE-1044
2200
Payables
Admin expense accrued
42,180.00
JE-1045
1000
Cash
Bank interest
3,912.44
JE-1046
1500
Investments
Valuation change, Q2
615,000.00
JE-1047
1000
Cash
Distribution paid to partners
1,200,000.00
JE-1048
2200
Payables
Audit fee accrued
36,000.00
JE-1049
4100
Fees
Management fee, Q3
187,500.00
General ledger reconstruction
Turn fragmented historical records into a coherent, auditable financial history across funds, entities and investors.








Data intelligence
Retrieve, extract and normalize information from documents, chats and handwritten notes into structured records with clear source lineage.
Strategy, technology and implementation
{}}}{{0_-:;/><<<<>=;+*0{}(((}1*+=<){1001})]>=:::;=/<]]]]<>/===/<{}}(}}{0*+:=><]]]<>=:-_1{}((}{0-;>[}{001{([</=;;=/><]]<</=;;;=/<1{}(((}10*+;/>]]]]<>=:-_1}((}{0*:/])}111{})]<>///><]]]<>=;:++;=>0{}(((}{1_-:=><]]]]</;+*01}}}{1_+;>[({{1{}([]<>><]][[]</;+---+;>_0{}(((}{0*+;=><]]]<>/;+*01{{{1_-:/<)(}{}}()[]]][[)))[</;+*_*-:/-_1{}}}}{1_-+;/><]]]<>=:-*01110_-:=>])(((()[[[[[)((}()]/;-_0_*+;+*_1{{}{{10*-:;/><<<<>/;:-*_00__-:=><[))))[[][[)(}{{{([>;-_00_-::+*_011{110_*+:=/>><>>/=;:-*****+:=/<]][[]]]]][(}{001{)<=+_00_*:;:+-*_00000_*-+:;=//>//=;;:+---+:;=/><<]<<>><<[)}10001}]/:*00_*+=;:+--*_____*--+:;=======;::::::;=/><<<<>>///>])}10110{)>;-___*+=;;::++---***---+::;;;;;;;;;;;;=/>><<<<>/==;=/<[}101101(<=:-**-+;;;;;;;::+++-----++++::::;;;;=//><]][]]>/=;::;/](101{10}[/;++++::;;======;;::++--------++::;==/>][[))[[</=:++:=<){01100}[<=;::;=+:;==//////=;::+--******--+:;=><[)((}()]>=:+++;/](10001})<>===//-+:;=/>><<>>/=;:+-**_____*-+:=><[(}{{{([</;+-+:/<){100{([]>>>><<*-+;=/><]]]<<>/;:+-__0000__-+;/<[({111{(]>;+++:=<[}{1{})[<<<<][[_*-:;/><][[[]<>/;:-*_011110_-:=>[({0001})<=:++:=>[(}}()[]<<<[)((0_*+:=/<][[[[]<>/;+-_01{{{10*-;/]({00001(]>=::;=>]))))]<>>>][(}}0_*-+;/><[[))[]<>=:+*01{{{{10*+=<){00100})</===/<][)[]>/=/><[({{0__-+:=/<][[)[[]</=:-_01{}{{1_-;>[}10110{(]>//><][[[]>=;;;/<[}{{__*--:;=><][[[[]<>=;+*_1{{{{10*+=<){0000{([]<<][)))[</;:+:=>[({{----++:;=/><]]]]<>/=:-*01{{{10_-;>[}1000{()[[[)(}}([<=:--+;/](}}:+++++::;=/>><<<>>/=;+-*001110_*:/]({11{}()[)(}{1{}[<=+**-:/<)((=;;::::::;;==/////==;:+-*_0000_*+=<[(}}}()[)(}1000{)<=+**-:=<[[[>>/=;;:::+:::;;;;;;;;::+-***_**-+=>[)))[[][)(101101(<=+---:=><]<[]<>/=;:++-----++:::::::++++-++:;/<][]]<><][}11{{10(]/:++:;/>>>/))[]>/;:+-*_____**--+::::;;;;;==/><<<>/==/>[}01}}10}]>;:;;=/>>/;}()[]>=;+*_0111110_*-+:;==//>><<]]]<>=;::;>]}01{{10}[</=//><>/;+{}()[</;+*_1{}}}}{10*-:;=><][[))[[]>=;+-+:/]}11{101([<<<<]]]</:-{{}([]>=+*01}(()((}10*+;/<[)(}}}()]>;+---:/](10001})[]][)))[</:*}}}([]>=:-_1}())))({0*+;>])(}{{{()</;+--+;/]({11{})[[[)(}{}(]/+_(())[<>=:-_1}())))({0*+;>])}{{{{([</;+++:=>])(}()[]][)({111}[/+_[[[]<>/=:-_0{}((((}{0*+;>])(}{}})]</;;:;=>][))[]<>><])}1000}[/:*>>>>>/=;:+*_01{}}{{1_*+;/<]))())[]>////><][[[]<>===>]){0000}[>:-====;;;:++-*__00000_*-+;=/><]]]<<<>>><][))))]</;::;/]){0001}[>;+++++++++++++---------++:;;===////>><])(}}}()]/;:++:/<){101{)]>=;*____**--+::::;;:::::+++++++++:;=/<[)}1101})</:+-+:/<)}{{}([<//=011110_*-+:;=/////==;:+-**____*+;/<)}10001})<=:++:;/][((()]<>//>1{}}{10_-+;=/><<<<>/=:+*_01111_-:/]({00101}[</;::;/>][[[]<//=/><{}}}}{1_*+;/><][[]<>=:-*0{{}}{0*:/]({0000{([</==//<]]]]</=;:;=>]1{}}}{1_*+;/>]][[]<>=:-_1{}}}{0-:/]({101{})[<<><<][[[]>=:+-+:=>[01{{{10_*+;=><]]]<>/;:-_0{{{{0_-;/])(}}}()[[]][))(()[</:-**-+=>[*_0000_*-+:;=/>>>>/=;+-*_000_*-:=/<][[[[]]]][)({{{{([>;+*__*+=>]+--****--++:;;;=;;;:++-****--+:;=/>>>>>/>><[)}10001}[>;-___*+=><=;;::+++---------------++::;;==///==;;:;;/<[(101100}[>;-*_*-:=/>>>>/==;:+-*__00000__*-+:;=/>>>>>/=;+----:=>[}101101}[>;+--+:;=//[[]]</=;+-_01{}}}{10_-:=/<]][]]>/;+-*__*+;>[}00110{(]>=;;;;=//=;)))[]</;+*0{}()))(}1_-;/<])))[]<=:-*_0_*+=>[}1000{})]>>//>>>>/;+
Ask a question
Technology modernization
Apply AI and modern infrastructure where accuracy, security, privacy, data quality and auditability matter.
Cross-border & regulatory
Plan market entry, licensing and regulatory architecture for funds and entities operating across jurisdictions.
Our mission
Investment management runs on data scattered across custodians, managers, administrators, transfer agents and spreadsheets — each holding its own version of the truth.
This means smart, well-resourced teams spend days reconciling data, custodian to administrator, administrator to manager, manager to spreadsheet.
Evolutions is building a new generation of infrastructure for investment management. We work with managers, digital platforms, administrators and the wider ecosystem to give them a single, traceable record — and, with it, their time back. Not to do less, but to spend it on higher-value work for their clients.