Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- run: pnpm install --frozen-lockfile

- name: Install Playwright Browsers
run: pnpm dlx playwright install --with-deps --only-shell
run: pnpm exec playwright install --with-deps

- run: pnpm run lint

Expand Down
6 changes: 2 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@
"yieldxyz"
],
"scripts": {
"dev": "turbo dev --filter=@yieldxyz/perps-common --filter=@yieldxyz/perps-widget --filter=@yieldxyz/perps-dashboard",
"dev": "turbo dev",
"dev:widget": "turbo dev --filter=@yieldxyz/perps-common --filter=@yieldxyz/perps-widget",
"dev:dashboard": "turbo dev --filter=@yieldxyz/perps-common --filter=@yieldxyz/perps-dashboard",

"build": "turbo build",
"build:widget": "turbo build --filter=@yieldxyz/perps-widget",
"build:dashboard": "turbo build --filter=@yieldxyz/perps-dashboard",

"build": "turbo build",
"test": "turbo test",
"lint": "turbo lint",
"format": "turbo format",
Expand Down
5 changes: 2 additions & 3 deletions packages/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,12 @@
"dependencies": {
"@base-ui/react": "catalog:",
"@effect-atom/atom-react": "catalog:",
"@effect/experimental": "^0.58.0",
"@effect/experimental": "catalog:",
"@effect/platform": "catalog:",
"@effect/platform-node": "catalog:",
"@ledgerhq/wallet-api-client": "catalog:",
"@lucas-barake/effect-form-react": "catalog:",
"@nktkas/hyperliquid": "catalog:",
"@reown/appkit": "catalog:",
"@reown/appkit-adapter-wagmi": "catalog:",
"@stakekit/common": "catalog:",
Expand All @@ -86,8 +87,6 @@
"devDependencies": {
"@tanstack/devtools-vite": "catalog:",
"@tanstack/router-cli": "catalog:",
"@testing-library/dom": "catalog:",
"@testing-library/react": "catalog:",
"@tim-smart/openapi-gen": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
Expand Down
7 changes: 2 additions & 5 deletions packages/common/src/atoms/actions-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@ import { Reactivity } from "@effect/experimental/Reactivity";
import { Atom, type Result } from "@effect-atom/atom-react";
import { Effect, Stream } from "effect";
import type { SignTransactionsState, WalletConnected } from "../domain/wallet";
import type { ActionDto } from "../services/api-client/api-schemas";
import type { ActionDto } from "../services/api-client/client-factory";
import { runtimeAtom } from "../services/runtime";
import { portfolioReactivityKeysArray } from "./portfolio-atoms";

export const actionAtom = Atom.writable<ActionDto | null, ActionDto | null>(
() => null,
(ctx, value) => ctx.setSelf(value),
);
export const actionAtom = Atom.make<ActionDto | null>(null);

const getActionAtom = Atom.make(
Effect.fn(function* (ctx) {
Expand Down
8 changes: 8 additions & 0 deletions packages/common/src/atoms/hyperliquid-atoms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Stream } from "effect";
import { HyperliquidService, runtimeAtom } from "../services";

export const midPriceAtom = runtimeAtom.atom(
HyperliquidService.use((service) => service.subscribeMidPrice).pipe(
Stream.unwrapScoped,
),
);
1 change: 1 addition & 0 deletions packages/common/src/atoms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from "./actions-atoms";
export * from "./close-position-atoms";
export * from "./config-atom";
export * from "./edit-position-atoms";
export * from "./hyperliquid-atoms";
export * from "./markets-atoms";
export * from "./order-form-atoms";
export * from "./orders-pending-actions-atom";
Expand Down
61 changes: 59 additions & 2 deletions packages/common/src/atoms/markets-atoms.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { Atom, AtomRef } from "@effect-atom/atom-react";
import { Data, Duration, Effect, Record, Schedule, Stream } from "effect";
import {
Array as _Array,
Data,
Duration,
Effect,
pipe,
Record,
Schedule,
Stream,
} from "effect";
import { ApiClientService } from "../services/api-client";
import type { ProviderDto } from "../services/api-client/api-schemas";
import { runtimeAtom } from "../services/runtime";
import { midPriceAtom } from "./hyperliquid-atoms";
import { selectedProviderAtom } from "./providers-atoms";

const DEFAULT_LIMIT = 50;
Expand Down Expand Up @@ -50,6 +60,20 @@ export const marketsAtom = runtimeAtom.atom(
}),
);

export const marketsBySymbolAtom = runtimeAtom.atom(
Effect.fn(function* (ctx) {
const markets = yield* ctx.result(marketsAtom);

return pipe(
Record.values(markets),
_Array.map(
(marketRef) => [marketRef.value.baseAsset.symbol, marketRef] as const,
),
Record.fromEntries,
);
}),
);

export class MarketNotFoundError extends Data.TaggedError(
"MarketNotFoundError",
) {}
Expand All @@ -74,7 +98,7 @@ export const refreshMarketsAtom = runtimeAtom.atom(
const selectedProvider = yield* ctx.result(selectedProviderAtom);

yield* Stream.fromSchedule(
Schedule.forever.pipe(Schedule.addDelay(() => Duration.seconds(10))),
Schedule.forever.pipe(Schedule.addDelay(() => Duration.minutes(1))),
).pipe(
Stream.mapEffect(() => getAllMarkets(selectedProvider)),
Stream.tap((markets) =>
Expand All @@ -96,3 +120,36 @@ export const refreshMarketsAtom = runtimeAtom.atom(
);
}),
);

export const updateMarketsMidPriceAtom = runtimeAtom.atom((ctx) =>
Effect.gen(function* () {
const { mids } = yield* ctx.result(midPriceAtom);

const markets = yield* ctx.result(marketsBySymbolAtom);

Record.toEntries(mids).forEach(([symbol, price]) => {
const parsed = Number(price);

if (!Number.isFinite(parsed)) {
return;
}

const marketRef = Record.get(markets, symbol);

if (marketRef._tag === "None") {
return;
}

const currentMarket = marketRef.value.value;

if (currentMarket.markPrice === parsed) {
return;
}

marketRef.value.update((market) => ({
...market,
markPrice: parsed,
}));
});
}),
);
54 changes: 21 additions & 33 deletions packages/common/src/atoms/order-form-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,18 @@ export const ORDER_SLIDER_STOPS = [0, 25, 50, 75, 100];

// Types
export type OrderType = "market" | "limit";
export type OrderSide = ApiTypes.PositionDtoSide;
export type OrderSide = ApiTypes.PositionSide;

// Schemas
export const LeverageRangesSchema = Schema.Data(
ApiSchemas.MarketDto.fields.leverageRange,
).pipe(Schema.brand("LeverageRange"));

// Order Type Atom
export const orderTypeAtom = Atom.writable<OrderType, OrderType>(
() => "market",
(ctx, value) => ctx.setSelf(value),
);
export const orderTypeAtom = Atom.make<OrderType>("market");

// Order Side Atom
export const orderSideAtom = Atom.writable<OrderSide, OrderSide>(
() => "long",
(ctx, value) => ctx.setSelf(value),
);
export const orderSideAtom = Atom.make<OrderSide>("long");

// Leverage Atom (family keyed by leverage ranges)
export const leverageAtom = Atom.family(
Expand All @@ -67,27 +61,21 @@ export const leverageAtom = Atom.family(
);

// TP/SL Settings Atom
export const tpOrSLSettingsAtom = Atom.writable<TPOrSLSettings, TPOrSLSettings>(
() => ({
takeProfit: {
option: null,
triggerPrice: null,
percentage: null,
},
stopLoss: {
option: null,
triggerPrice: null,
percentage: null,
},
}),
(ctx, value) => ctx.setSelf(value),
);
export const tpOrSLSettingsAtom = Atom.make<TPOrSLSettings>({
takeProfit: {
option: null,
triggerPrice: null,
percentage: null,
},
stopLoss: {
option: null,
triggerPrice: null,
percentage: null,
},
});

// Limit Price Atom
export const limitPriceAtom = Atom.writable<number | null, number | null>(
() => null,
(ctx, value) => ctx.setSelf(value),
);
export const limitPriceAtom = Atom.make<number | null>(null);

// Order Form Atom (family keyed by leverage ranges)
export const orderFormAtom = Atom.family(
Expand Down Expand Up @@ -145,7 +133,7 @@ export const orderFormAtom = Atom.family(
}: {
wallet: WalletConnected;
market: ApiSchemas.MarketDto;
side: ApiTypes.PositionDtoSide;
side: ApiTypes.PositionSide;
},
{ decoded },
) =>
Expand Down Expand Up @@ -183,16 +171,16 @@ export const orderFormAtom = Atom.family(
...(stopLossPrice && { stopLossPrice }),
...(takeProfitPrice && { takeProfitPrice }),
...(leverage && { leverage }),
...(limitPrice && { limitPrice: limitPrice }),
...(limitPrice && { limitPrice }),
},
});

registry.set(actionAtom, action);
}),
});

const setAmountFieldAtom = OrderForm.setValue(OrderForm.fields.Amount);
const amountFieldAtom = OrderForm.getFieldValue(OrderForm.fields.Amount);
const { value: amountFieldAtom, setValue: setAmountFieldAtom } =
OrderForm.getFieldAtoms(OrderForm.fields.Amount);

return Atom.readable(() => ({
form: OrderForm,
Expand All @@ -207,7 +195,7 @@ export const getOrderCalculations = (
amount: number,
leverage: number,
market: ApiSchemas.MarketDto,
side: ApiTypes.PositionDtoSide,
side: ApiTypes.PositionSide,
) => {
const cryptoAmount = calcBaseAmountFromUsd({
usdAmount: amount,
Expand Down
64 changes: 61 additions & 3 deletions packages/common/src/atoms/portfolio-atoms.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Atom } from "@effect-atom/atom-react";
import { Duration, Effect } from "effect";
import { Atom, AtomRef } from "@effect-atom/atom-react";
import { Duration, Effect, Option, Record, Schema } from "effect";
import { WalletAccountAddress } from "../domain";
import type { WalletAccount } from "../domain/wallet";
import { ApiClientService } from "../services/api-client";
import { runtimeAtom, withReactivity } from "../services/runtime";
import { midPriceAtom } from "./hyperliquid-atoms";
import { marketsBySymbolAtom } from "./markets-atoms";
import { providersAtom, selectedProviderAtom } from "./providers-atoms";
import { withRefreshAfter } from "./utils";

Expand All @@ -25,10 +28,15 @@ export const positionsAtom = Atom.family(
const client = yield* ApiClientService;
const selectedProvider = yield* ctx.result(selectedProviderAtom);

return yield* client.PortfolioControllerGetPositions({
const positions = yield* client.PortfolioControllerGetPositions({
address: walletAddress,
providerId: selectedProvider.id,
});

return Record.fromIterableBy(
positions.map((position) => AtomRef.make(position)),
(ref) => ref.value.marketId,
);
}),
)
.pipe(
Expand Down Expand Up @@ -105,3 +113,53 @@ export const selectedProviderBalancesAtom = Atom.family(
Atom.keepAlive,
),
);

/**
* markPrice becomes live, while unrealizedPnl, liquidationPrice, and the other server-derived fields stay stale
* TODO: handle this in the future
*/
export const updatePositionsMidPriceAtom = Atom.family(
(walletAddress: WalletAccount["address"]) =>
runtimeAtom.atom((ctx) =>
Effect.gen(function* () {
const { mids } = yield* ctx.result(midPriceAtom);
const markets = yield* ctx.result(marketsBySymbolAtom);
const positions = yield* ctx.result(positionsAtom(walletAddress));

Record.toEntries(mids).forEach(([symbol, price]) => {
const marketRef = Record.get(markets, symbol);
if (marketRef._tag === "None") return;

const positionRef = Record.get(positions, marketRef.value.value.id);
if (positionRef._tag === "None") return;

positionRef.value.update((position) => ({
...position,
markPrice: Number(price),
}));
});
}),
),
);

export const GetCurrentPositionRefArgs = Schema.Struct({
marketId: Schema.String,
address: WalletAccountAddress,
}).pipe(Schema.Data, Schema.brand("GetCurrentPositionRefArgs"));

export const makeGetCurrentPositionRefArgs = Schema.decodeSync(
GetCurrentPositionRefArgs,
);

export const currentPositionRefAtom = Atom.family(
({ marketId, address }: typeof GetCurrentPositionRefArgs.Type) =>
Atom.make((ctx) => {
const positionsResult = ctx.get(positionsAtom(address));

if (positionsResult._tag !== "Success") return null;

const positionRef = Record.get(positionsResult.value, marketId);

return positionRef.pipe(Option.getOrNull);
}),
);
1 change: 1 addition & 0 deletions packages/common/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from "./molecules/leverage-dialog";
export * from "./molecules/limit-price-dialog";
export * from "./molecules/order-type-dialog";
export * from "./molecules/percentage-slider";
export * from "./molecules/price-flash";
export * from "./molecules/sign-transactions";
export * from "./molecules/toggle-group";
export * from "./molecules/token-icon";
Expand Down
6 changes: 5 additions & 1 deletion packages/common/src/components/molecules/leverage-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type LeverageDialogProps = Pick<
"leverage" | "onLeverageChange" | "currentPrice" | "maxLeverage" | "side"
> & {
children: React.ReactElement;
nativeButton?: boolean;
};

export const LeverageDialog = (props: LeverageDialogProps) => {
Expand All @@ -37,7 +38,10 @@ export const LeverageDialog = (props: LeverageDialogProps) => {

return (
<Dialog.Root actionsRef={actionsRef}>
<Dialog.Trigger render={props.children} />
<Dialog.Trigger
nativeButton={props.nativeButton}
render={props.children}
/>

<Dialog.Portal>
<Dialog.Backdrop />
Expand Down
Loading
Loading