Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ impl From<db::MarketGroup> for websocket_api::MarketGroup {
name,
description,
type_id,
pnl: None,
}
}
}
Expand Down
53 changes: 53 additions & 0 deletions backend/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,59 @@ impl DB {
.await
}

/// Get PnL for each group for a specific account.
/// Returns a map of group_id -> pnl.
#[instrument(err, skip(self))]
pub async fn get_group_pnls(&self, account_id: i64) -> SqlxResult<std::collections::HashMap<i64, Decimal>> {
struct GroupPnLRow {
group_id: i64,
pnl: Text<Decimal>,
}

let rows = sqlx::query_as!(
GroupPnLRow,
r#"
WITH trade_costs AS (
SELECT
market.group_id,
trade.market_id,
SUM(CASE WHEN trade.buyer_id = ? THEN CAST(trade.price AS REAL) * CAST(trade.size AS REAL) ELSE 0 END) as buy_cost,
SUM(CASE WHEN trade.seller_id = ? THEN CAST(trade.price AS REAL) * CAST(trade.size AS REAL) ELSE 0 END) as sell_cost,
SUM(CASE WHEN trade.buyer_id = ? THEN CAST(trade.size AS REAL) ELSE 0 END) -
SUM(CASE WHEN trade.seller_id = ? THEN CAST(trade.size AS REAL) ELSE 0 END) as position
FROM trade
JOIN market ON trade.market_id = market.id
WHERE market.group_id IS NOT NULL
AND market.group_id > 0
AND (trade.buyer_id = ? OR trade.seller_id = ?)
GROUP BY market.group_id, trade.market_id
),
market_prices AS (
SELECT
market.id as market_id,
market.group_id,
COALESCE(
market.settled_price,
(SELECT CAST(price AS REAL) FROM trade WHERE trade.market_id = market.id ORDER BY trade.id DESC LIMIT 1)
) as current_price
FROM market
WHERE market.group_id IS NOT NULL AND market.group_id > 0
)
SELECT
tc.group_id as "group_id!",
CAST(SUM((tc.position * COALESCE(mp.current_price, 0)) - (tc.buy_cost - tc.sell_cost)) AS TEXT) as "pnl!: Text<Decimal>"
FROM trade_costs tc
LEFT JOIN market_prices mp ON tc.market_id = mp.market_id
GROUP BY tc.group_id
"#,
account_id, account_id, account_id, account_id, account_id, account_id
)
.fetch_all(&self.pool)
.await?;

Ok(rows.into_iter().map(|r| (r.group_id, r.pnl.0)).collect())
}

#[instrument(err, skip(self))]
pub async fn create_market_group(
&self,
Expand Down
17 changes: 15 additions & 2 deletions backend/src/handle_socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,12 +325,25 @@ async fn send_initial_public_data(
);
socket.send(market_types_msg).await?;

// Send groups
// Send groups with PnL for the acting account
let market_groups = db.get_all_market_groups().await?;
let group_pnls = if let Some(&account_id) = owned_accounts.first() {
db.get_group_pnls(account_id).await.unwrap_or_default()
} else {
std::collections::HashMap::new()
};
let market_groups_msg = encode_server_message(
String::new(),
SM::MarketGroups(MarketGroups {
market_groups: market_groups.into_iter().map(MarketGroup::from).collect(),
market_groups: market_groups
.into_iter()
.map(|g| {
let pnl = group_pnls.get(&g.id).map(|d| (*d).try_into().unwrap());
let mut mg = MarketGroup::from(g);
mg.pnl = pnl;
mg
})
.collect(),
}),
);
socket.send(market_groups_msg).await?;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/components/forms/redeem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@

<form bind:this={formElement} use:enhance class="flex flex-wrap items-center gap-2">
<div class="whitespace-nowrap text-sm text-muted-foreground">
Exchange for {constituentList}
Exchangeable for {constituentList}
</div>
<Form.Field {form} name="amount" class="flex flex-col gap-0 space-y-0">
<Form.Control>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/components/marketHead.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@
Settle Price: {marketDefinition.closed.settlePrice}
</p>
{/if}
{#if isRedeemable}
{#if isRedeemable && !marketDefinition.closed}
<div class="mr-4">
<Redeem marketId={id} disabled={!canPlaceOrders} />
</div>
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/lib/portfolioMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,34 @@ export const computePortfolioMetrics = (
}
};
};

/**
* Calculate subaccount PnL: Current MTM - Net Transfers In
*/
export const calculateSubaccountPnL = (
accountId: number,
transfers: websocket_api.ITransfer[],
markToMarket: number
): number => {
let netTransfersIn = 0;
for (const transfer of transfers) {
if (transfer.toAccountId === accountId) {
netTransfersIn += transfer.amount ?? 0;
}
if (transfer.fromAccountId === accountId) {
netTransfersIn -= transfer.amount ?? 0;
}
}
return markToMarket - netTransfersIn;
};

/**
* Get group PnL from market groups (calculated by backend)
*/
export const getGroupPnL = (
groupId: number,
marketGroups: Map<number, websocket_api.IMarketGroup>
): number => {
const group = marketGroups.get(groupId);
return group?.pnl ?? 0;
};
28 changes: 19 additions & 9 deletions frontend/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<script lang="ts">
import { page } from '$app/stores';
import { serverState } from '$lib/api.svelte';
import { sendClientMessage, serverState } from '$lib/api.svelte';
import { kinde } from '$lib/auth.svelte';
import { universeMode } from '$lib/universeMode.svelte';
import AppSideBar from '$lib/components/appSideBar.svelte';
import { formatBalance } from '$lib/components/marketDataUtils';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { Toaster } from '$lib/components/ui/sonner';
import { computePortfolioMetrics } from '$lib/portfolioMetrics';
import { getGroupPnL, computePortfolioMetrics } from '$lib/portfolioMetrics';
import { cn, formatMarketName } from '$lib/utils';
import { ModeWatcher } from 'mode-watcher';
import { onMount } from 'svelte';
Expand All @@ -20,6 +20,11 @@
? serverState.markets.get(marketId)?.definition?.name
: undefined
);
let currentGroupId = $derived(
marketId !== undefined && !Number.isNaN(marketId)
? serverState.markets.get(marketId)?.definition?.groupId
: undefined
);

let { children } = $props();
let scrolled = $state(false);
Expand Down Expand Up @@ -216,24 +221,29 @@
{#if serverState.portfolio}
<!-- Hidden measurement elements (same structure as visible) -->
{@const availableBalance = formatBalance(serverState.portfolio.availableBalance)}
{@const mtmValue = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 0
}).format(portfolioMetrics.totals.markToMarket)}
{@const isGroupView = currentGroupId !== undefined && currentGroupId !== 0}
{@const displayValue = isGroupView
? getGroupPnL(currentGroupId, serverState.marketGroups)
: portfolioMetrics.totals.markToMarket}
{@const formattedValue = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(displayValue)}
{@const fullLabel = isGroupView ? 'Round PnL' : 'Mark to Market'}
{@const shortLabel = isGroupView ? 'Round' : 'MtM'}
{@const valueColor = isGroupView ? (displayValue >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400') : ''}
<div class="pointer-events-none invisible absolute" aria-hidden="true">
<ul bind:this={measureFullEl} class="flex w-fit items-center gap-2 md:gap-8">
<li class={cn('whitespace-nowrap', scrolled ? 'text-base' : 'text-lg')}>
Available Balance: 📎 {availableBalance}
</li>
<li class={cn('whitespace-nowrap', scrolled ? 'text-base' : 'text-lg')}>
Mark to Market: 📎 {mtmValue}
{fullLabel}: 📎 {formattedValue}
</li>
</ul>
<ul bind:this={measureShortEl} class="flex w-fit items-center gap-2 md:gap-8">
<li class={cn('whitespace-nowrap', scrolled ? 'text-base' : 'text-lg')}>
Available: 📎 {availableBalance}
</li>
<li class={cn('whitespace-nowrap', scrolled ? 'text-base' : 'text-lg')}>
MtM: 📎 {mtmValue}
{shortLabel}: 📎 {formattedValue}
</li>
</ul>
<ul bind:this={measureMinimalEl} class="flex w-fit items-center gap-2 md:gap-8">
Expand All @@ -248,8 +258,8 @@
{bannerMode === 'full' ? 'Available Balance' : 'Available'}: 📎 {availableBalance}
</li>
{#if bannerMode !== 'minimal'}
<li class={cn('shrink-0 whitespace-nowrap', scrolled ? 'text-base' : 'text-lg')}>
{bannerMode === 'full' ? 'Mark to Market' : 'MtM'}: 📎 {mtmValue}
<li class={cn('shrink-0 whitespace-nowrap', scrolled ? 'text-base' : 'text-lg', valueColor)}>
{bannerMode === 'full' ? fullLabel : shortLabel}: 📎 {formattedValue}
</li>
{/if}
</ul>
Expand Down
24 changes: 23 additions & 1 deletion frontend/src/routes/home/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import { sendClientMessage, serverState } from '$lib/api.svelte';
import * as Table from '$lib/components/ui/table';
import { computePortfolioMetrics, type PortfolioRow } from '$lib/portfolioMetrics';
import { calculateSubaccountPnL, computePortfolioMetrics, type PortfolioRow } from '$lib/portfolioMetrics';
import Minus from '@lucide/svelte/icons/minus';

type SortKey =
Expand Down Expand Up @@ -58,6 +58,20 @@
rows = metrics.rows;
});

// Subaccount PnL: show when acting as an account with ownerCredits
let isSubaccount = $derived(
(serverState.portfolio?.ownerCredits?.length ?? 0) > 0
);
let subaccountPnL = $derived(
isSubaccount && serverState.actingAs !== undefined
? calculateSubaccountPnL(
serverState.actingAs,
serverState.transfers,
metrics.totals.markToMarket
)
: null
);

// Ensure we have last prices by requesting trade history for relevant markets.
let requestedTrades = new Set<number>();
$effect(() => {
Expand Down Expand Up @@ -325,6 +339,14 @@
📎 {Math.round(metrics.totals.markToMarket).toLocaleString()}
</p>
</div>
{#if isSubaccount && subaccountPnL !== null}
<div class="rounded-md border bg-muted/30 p-4">
<p class="text-sm text-muted-foreground">Account PnL</p>
<p class="text-2xl font-semibold {subaccountPnL >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}">
📎 {Math.round(subaccountPnL).toLocaleString()}
</p>
</div>
{/if}
</div>

<div class="mt-8">
Expand Down
40 changes: 24 additions & 16 deletions frontend/src/routes/market/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { sendClientMessage, serverState } from '$lib/api.svelte';
import { getGroupPnL } from '$lib/portfolioMetrics';
import { scenariosApi } from '$lib/scenariosApi';
import CreateMarket from '$lib/components/forms/createMarket.svelte';
import FormattedName from '$lib/components/formattedName.svelte';
Expand Down Expand Up @@ -394,6 +395,7 @@
sendClientMessage({ deleteMarketType: { marketTypeId } });
}
}

</script>

<div class="w-full py-4">
Expand Down Expand Up @@ -460,26 +462,32 @@

{#each organized as item (item.type === 'group' ? `group-${item.groupId}` : item.key)}
{#if item.type === 'group'}
{@const groupPnL = getGroupPnL(item.groupId, serverState.marketGroups)}
{@const clock = clocksByName.get(item.groupName)}
<div class="mb-4 rounded-lg border-2 border-primary/30 bg-muted/10 p-3">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-xl font-semibold">{item.groupName}</h3>
{#if clock}
<div
class={cn(
'flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium',
clock.is_running
? 'bg-green-500/20 text-green-700 dark:text-green-400'
: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-400'
)}
>
<Clock class="h-4 w-4" />
<span>{formatClockTime(clock)}</span>
{#if !clock.is_running}
<span class="text-xs">(paused)</span>
{/if}
</div>
{/if}
<div class="flex items-center gap-3">
{#if clock}
<div
class={cn(
'flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium',
clock.is_running
? 'bg-green-500/20 text-green-700 dark:text-green-400'
: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-400'
)}
>
<Clock class="h-4 w-4" />
<span>{formatClockTime(clock)}</span>
{#if !clock.is_running}
<span class="text-xs">(paused)</span>
{/if}
</div>
{/if}
<span class="text-sm font-medium {groupPnL >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}">
Round PnL: 📎 {Math.round(groupPnL).toLocaleString()}
</span>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each item.markets as { id, market, starred, pinned } (id)}
Expand Down
9 changes: 9 additions & 0 deletions schema-js/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2548,6 +2548,9 @@ export namespace websocket_api {

/** MarketGroup typeId */
typeId?: (number|Long|null);

/** MarketGroup pnl */
pnl?: (number|null);
}

/** Represents a MarketGroup. */
Expand All @@ -2571,6 +2574,12 @@ export namespace websocket_api {
/** MarketGroup typeId. */
public typeId: (number|Long);

/** MarketGroup pnl. */
public pnl?: (number|null);

/** MarketGroup _pnl. */
public _pnl?: "pnl";

/**
* Creates a new MarketGroup instance using the specified properties.
* @param [properties] Properties to set
Expand Down
Loading
Loading