diff --git a/package.json b/package.json
index 1f5a517..d9f6bba 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,8 @@
"test": "vitest run",
"lint": "biome check . && tsc",
"format": "biome format --write .",
- "generate-client-factory": "tsx --env-file .env scripts/generate-client-factory.ts"
+ "generate-client-factory": "tsx --env-file .env scripts/generate-client-factory.ts",
+ "generate-routes": "tsr generate"
},
"dependencies": {
"@base-ui/react": "^1.1.0",
@@ -46,6 +47,7 @@
"@biomejs/biome": "2.3.12",
"@effect/language-service": "^0.72.0",
"@tanstack/devtools-vite": "^0.4.1",
+ "@tanstack/router-cli": "^1.157.15",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@tim-smart/openapi-gen": "^0.4.13",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bd3fd7b..bf4f414 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -57,7 +57,7 @@ importers:
version: 1.157.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@tanstack/react-router-devtools':
specifier: ^1.157.8
- version: 1.157.8(@tanstack/react-router@1.157.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.8)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ version: 1.157.8(@tanstack/react-router@1.157.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.15)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@tanstack/react-virtual':
specifier: ^3.13.18
version: 3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -110,6 +110,9 @@ importers:
'@tanstack/devtools-vite':
specifier: ^0.4.1
version: 0.4.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
+ '@tanstack/router-cli':
+ specifier: ^1.157.15
+ version: 1.157.15
'@testing-library/dom':
specifier: ^10.4.0
version: 10.4.1
@@ -1858,6 +1861,15 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ '@tanstack/router-cli@1.157.15':
+ resolution: {integrity: sha512-a3ni8JUh9svDd4IenJx+9za/Gd1gHzYi4iH36Lf/hgf+zPl6ZBK6cGsveVo2xRVz94iWgpDSWOkgXC0d9fuJ7w==}
+ engines: {node: '>=12'}
+ hasBin: true
+
+ '@tanstack/router-core@1.157.15':
+ resolution: {integrity: sha512-KaYz6s+wYcg92kRQ7HXlTJLhBaBXOYiiqRBv5tsRbKRIqqhWNyeGz5+NfDwaYFHg5XLSDs3DvN0elMtxcj4dTg==}
+ engines: {node: '>=12'}
+
'@tanstack/router-core@1.157.8':
resolution: {integrity: sha512-2fxuhHIZ3yBXN9rxcDOgCsezuwk1VoWvprIDMe+HDi6FBdpWgEREMp0CWEYn33Lxla44iwaTpwvVcFDk8QC/rA==}
engines: {node: '>=12'}
@@ -1872,6 +1884,10 @@ packages:
csstype:
optional: true
+ '@tanstack/router-generator@1.157.15':
+ resolution: {integrity: sha512-zGac6tyRFz/X86fk9/CAmS6z8lyZf4p9lhAqLBCKVkFiFPmU4eAJp1ODvs81EtV0uJdRL1/rb+uvgHLGUsmQ0g==}
+ engines: {node: '>=12'}
+
'@tanstack/router-generator@1.157.8':
resolution: {integrity: sha512-P0uvoFkhqrkmn/npgJJ02aGGsw2CMtoEAWQkiJGg00aXyA+026Ny1G/4FPuYsGK11c6yMsOl6FHk/sipNY+2YA==}
engines: {node: '>=12'}
@@ -6301,14 +6317,14 @@ snapshots:
'@tanstack/query-core': 5.90.20
react: 19.2.3
- '@tanstack/react-router-devtools@1.157.8(@tanstack/react-router@1.157.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.8)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ '@tanstack/react-router-devtools@1.157.8(@tanstack/react-router@1.157.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.15)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@tanstack/react-router': 1.157.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
- '@tanstack/router-devtools-core': 1.157.8(@tanstack/router-core@1.157.8)(csstype@3.2.3)
+ '@tanstack/router-devtools-core': 1.157.8(@tanstack/router-core@1.157.15)(csstype@3.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
- '@tanstack/router-core': 1.157.8
+ '@tanstack/router-core': 1.157.15
transitivePeerDependencies:
- csstype
@@ -6336,6 +6352,24 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
+ '@tanstack/router-cli@1.157.15':
+ dependencies:
+ '@tanstack/router-generator': 1.157.15
+ chokidar: 3.6.0
+ yargs: 17.7.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@tanstack/router-core@1.157.15':
+ dependencies:
+ '@tanstack/history': 1.154.14
+ '@tanstack/store': 0.8.0
+ cookie-es: 2.0.0
+ seroval: 1.5.0
+ seroval-plugins: 1.5.0(seroval@1.5.0)
+ tiny-invariant: 1.3.3
+ tiny-warning: 1.0.3
+
'@tanstack/router-core@1.157.8':
dependencies:
'@tanstack/history': 1.154.14
@@ -6346,15 +6380,28 @@ snapshots:
tiny-invariant: 1.3.3
tiny-warning: 1.0.3
- '@tanstack/router-devtools-core@1.157.8(@tanstack/router-core@1.157.8)(csstype@3.2.3)':
+ '@tanstack/router-devtools-core@1.157.8(@tanstack/router-core@1.157.15)(csstype@3.2.3)':
dependencies:
- '@tanstack/router-core': 1.157.8
+ '@tanstack/router-core': 1.157.15
clsx: 2.1.1
goober: 2.1.18(csstype@3.2.3)
tiny-invariant: 1.3.3
optionalDependencies:
csstype: 3.2.3
+ '@tanstack/router-generator@1.157.15':
+ dependencies:
+ '@tanstack/router-core': 1.157.15
+ '@tanstack/router-utils': 1.154.7
+ '@tanstack/virtual-file-routes': 1.154.7
+ prettier: 3.8.1
+ recast: 0.23.11
+ source-map: 0.7.6
+ tsx: 4.21.0
+ zod: 3.25.76
+ transitivePeerDependencies:
+ - supports-color
+
'@tanstack/router-generator@1.157.8':
dependencies:
'@tanstack/router-core': 1.157.8
diff --git a/src/components/modules/Home/index.tsx b/src/components/modules/Home/index.tsx
index 174e35f..cb267be 100644
--- a/src/components/modules/Home/index.tsx
+++ b/src/components/modules/Home/index.tsx
@@ -8,7 +8,10 @@ import {
} from "lucide-react";
import { useState } from "react";
import hyperliquid from "@/assets/hyperliquid.png";
-import { selectedProviderBalancesAtom } from "@/atoms/portfolio-atoms";
+import {
+ positionsAtom,
+ selectedProviderBalancesAtom,
+} from "@/atoms/portfolio-atoms";
import { providersAtom, selectedProviderAtom } from "@/atoms/providers-atoms";
import { walletAtom } from "@/atoms/wallet-atom";
import { AssetList } from "@/components/modules/Home/AssetList";
@@ -49,6 +52,22 @@ const ProviderBalancesDisplay = ({ wallet }: { wallet: WalletConnected }) => {
return null;
};
+const PositionsTabLabel = ({ wallet }: { wallet: WalletConnected }) => {
+ const positionsResult = useAtomValue(
+ positionsAtom(wallet.currentAccount.address),
+ );
+ const positionsCount = positionsResult.pipe(
+ Result.map((positions) => positions.length),
+ Result.getOrElse(() => 0),
+ );
+
+ return (
+
+ Positions ({positionsCount})
+
+ );
+};
+
export const Home = () => {
const [activeTab, setActiveTab] = useState<"trade" | "positions">("trade");
const wallet = useAtomValue(walletAtom).pipe(Result.getOrElse(() => null));
@@ -118,9 +137,13 @@ export const Home = () => {
-
- Positions
-
+ {walletConnected ? (
+
+ ) : (
+
+ Positions (0)
+
+ )}
diff --git a/src/components/modules/Order/Overview/index.tsx b/src/components/modules/Order/Overview/index.tsx
index 703d86a..a7b8bbe 100644
--- a/src/components/modules/Order/Overview/index.tsx
+++ b/src/components/modules/Order/Overview/index.tsx
@@ -207,6 +207,7 @@ function OrderContent({
entryPrice={currentPrice}
currentPrice={currentPrice}
liquidationPrice={calculations.liquidationPrice}
+ side={side}
isLiquidationPriceEstimate
>
- {formatTPOrSLSettings(tpOrSLSettings)}
+ {formatTPOrSLSettings(tpOrSLSettings, side)}
diff --git a/src/components/modules/Order/Overview/leverage-dialog.tsx b/src/components/modules/Order/Overview/leverage-dialog.tsx
index cd16af0..f304b19 100644
--- a/src/components/modules/Order/Overview/leverage-dialog.tsx
+++ b/src/components/modules/Order/Overview/leverage-dialog.tsx
@@ -1,6 +1,7 @@
import type { DialogRootActions } from "@base-ui/react/dialog";
import { X } from "lucide-react";
import { useRef, useState } from "react";
+import { ToggleGroup } from "@/components/molecules/toggle-group";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { Divider } from "@/components/ui/divider";
@@ -33,7 +34,7 @@ export const LeverageDialog = (props: LeverageDialogProps) => {
return (
- {props.children}
+
@@ -93,8 +94,7 @@ export function LeverageDialogContent({
getPriceChangePercentToLiquidation({ currentPrice, liquidationPrice }),
);
- const handleLeverageClick = (leverage: number) => setLocalLeverage(leverage);
- const handleStopClick = (stopValue: number) => setLocalLeverage(stopValue);
+ const leverageButtons = generateLeverageButtons(maxLeverage);
const handleConfirm = () => {
onLeverageChange(localLeverage);
@@ -168,72 +168,30 @@ export function LeverageDialogContent({
{/* Leverage Slider */}
-
-
- {
- const v = Array.isArray(value) ? value[0] : value;
- const leverageValue = Math.round(
- MIN_LEVERAGE + (v / 100) * (maxLeverage - MIN_LEVERAGE),
- );
- setLocalLeverage(leverageValue);
- }}
- min={0}
- max={100}
- stops={leverageStops.map(
- (stop) =>
- ((stop - MIN_LEVERAGE) / (maxLeverage - MIN_LEVERAGE)) * 100,
- )}
- onStopClick={(stopPercent) => {
- const leverageValue = Math.round(
- MIN_LEVERAGE +
- (stopPercent / 100) * (maxLeverage - MIN_LEVERAGE),
- );
- setLocalLeverage(leverageValue);
- }}
- />
-
-
- {/* Leverage Labels */}
-
- {leverageStops.map((stop, index) => (
- handleStopClick(stop)}
- >
- {stop}x
-
- ))}
-
-
-
- {/* Leverage Buttons */}
-
- {generateLeverageButtons(maxLeverage).map((leverage) => (
-
handleLeverageClick(leverage)}
- className={`flex-1 flex items-center justify-center h-9 rounded-[10px] text-sm font-normal tracking-[-0.42px] transition-colors cursor-pointer ${
- localLeverage === leverage
- ? "bg-white text-black"
- : "bg-white/5 text-gray-2 hover:bg-white/10"
- }`}
- >
- {leverage}x
-
- ))}
+
+ {
+ const v = Array.isArray(value) ? value[0] : value;
+ const leverageValue = Math.round(
+ MIN_LEVERAGE + (v / 100) * (maxLeverage - MIN_LEVERAGE),
+ );
+ setLocalLeverage(leverageValue);
+ }}
+ min={0}
+ max={100}
+ stops={leverageStops}
+ />
+
+ {/* Leverage Buttons */}
+ ({
+ value: stop.toString(),
+ label: `${stop}x`,
+ }))}
+ value={localLeverage.toString()}
+ onValueChange={(value) => setLocalLeverage(Number(value))}
+ />
{/* Confirm Button */}
diff --git a/src/components/modules/Order/Overview/limit-price-dialog.tsx b/src/components/modules/Order/Overview/limit-price-dialog.tsx
index 5fd4347..8e87f25 100644
--- a/src/components/modules/Order/Overview/limit-price-dialog.tsx
+++ b/src/components/modules/Order/Overview/limit-price-dialog.tsx
@@ -31,7 +31,7 @@ export const LimitPriceDialog = (props: LimitPriceDialogProps) => {
return (
- {props.children}
+
diff --git a/src/components/modules/Order/Overview/utils.ts b/src/components/modules/Order/Overview/utils.ts
index bb645c5..40534f7 100644
--- a/src/components/modules/Order/Overview/utils.ts
+++ b/src/components/modules/Order/Overview/utils.ts
@@ -1,16 +1,23 @@
import { Option } from "effect";
import type { TPOrSLSettings } from "@/components/molecules/tp-sl-dialog";
-export function formatTPOrSLSettings(settings: TPOrSLSettings): string {
+export function formatTPOrSLSettings(
+ settings: TPOrSLSettings,
+ side: "long" | "short" = "long",
+): string {
const tp = Option.fromNullable(settings.takeProfit.percentage).pipe(
Option.filter((percentage) => percentage !== 0),
- Option.map((percentage) => `TP +${percentage}%`),
+ Option.map((percentage) =>
+ side === "short" ? `TP -${percentage}%` : `TP +${percentage}%`,
+ ),
Option.getOrElse(() => "TP Off"),
);
const sl = Option.fromNullable(settings.stopLoss.percentage).pipe(
Option.filter((percentage) => percentage !== 0),
- Option.map((percentage) => `SL -${percentage}%`),
+ Option.map((percentage) =>
+ side === "short" ? `SL +${percentage}%` : `SL -${percentage}%`,
+ ),
Option.getOrElse(() => "SL Off"),
);
diff --git a/src/components/modules/PositionDetails/Overview/Position/index.tsx b/src/components/modules/PositionDetails/Overview/Position/index.tsx
index 26a6bf8..a102574 100644
--- a/src/components/modules/PositionDetails/Overview/Position/index.tsx
+++ b/src/components/modules/PositionDetails/Overview/Position/index.tsx
@@ -46,11 +46,13 @@ function PositionCardContent({
amount: tpSlOrders.takeProfit?.triggerPrice ?? undefined,
entryPrice: position.entryPrice,
tpOrSl: "takeProfit",
+ side: position.side,
}),
stopLoss: getTPOrSLConfigurationFromPosition({
amount: tpSlOrders.stopLoss?.triggerPrice ?? undefined,
entryPrice: position.entryPrice,
tpOrSl: "stopLoss",
+ side: position.side,
}),
};
@@ -205,6 +207,7 @@ function PositionCardContent({
entryPrice={position.entryPrice}
currentPrice={position.markPrice}
liquidationPrice={position.liquidationPrice}
+ side={position.side}
mode="takeProfit"
>
{
return (
- {props.children}
+
@@ -70,6 +70,7 @@ interface TPOrSLDialogContentProps {
entryPrice: number;
currentPrice: number;
liquidationPrice: number;
+ side?: "long" | "short";
/** When set, dialog shows only TP or SL section */
mode?: TPOrSLOption;
/** When true, shows "Est." prefix for liquidation price */
@@ -83,6 +84,7 @@ function TPOrSLDialogContent({
entryPrice,
currentPrice,
liquidationPrice,
+ side = "long",
mode,
isLiquidationPriceEstimate,
}: TPOrSLDialogContentProps) {
@@ -95,6 +97,11 @@ function TPOrSLDialogContent({
);
const isSingleMode = mode !== undefined;
+ const singleModeDescription = Match.value(mode).pipe(
+ Match.when("takeProfit", () => "gain"),
+ Match.when("stopLoss", () => "loss"),
+ Match.orElse(() => "gain or loss"),
+ );
const calculateTriggerPrice = (
option: TPOrSLPercentageOption,
@@ -102,9 +109,25 @@ function TPOrSLDialogContent({
): TPOrSLConfiguration["triggerPrice"] => {
if (option === null || option === 0) return null;
- return tpOrSl === "takeProfit"
- ? entryPrice * (1 + option / 100)
- : entryPrice * (1 - option / 100);
+ return Match.value({ side, tpOrSl }).pipe(
+ Match.when(
+ { side: "short", tpOrSl: "takeProfit" },
+ () => entryPrice * (1 - option / 100),
+ ),
+ Match.when(
+ { side: "short", tpOrSl: "stopLoss" },
+ () => entryPrice * (1 + option / 100),
+ ),
+ Match.when(
+ { side: "long", tpOrSl: "takeProfit" },
+ () => entryPrice * (1 + option / 100),
+ ),
+ Match.when(
+ { side: "long", tpOrSl: "stopLoss" },
+ () => entryPrice * (1 - option / 100),
+ ),
+ Match.exhaustive,
+ );
};
const handleTPOrSLOptionChange = (
@@ -134,10 +157,25 @@ function TPOrSLDialogContent({
}));
}
- const percentage =
- tpOrSl === "takeProfit"
- ? ((triggerPrice - entryPrice) / entryPrice) * 100
- : ((entryPrice - triggerPrice) / entryPrice) * 100;
+ const percentage = Match.value({ side, tpOrSl }).pipe(
+ Match.when(
+ { side: "short", tpOrSl: "takeProfit" },
+ () => ((entryPrice - triggerPrice) / entryPrice) * 100,
+ ),
+ Match.when(
+ { side: "short", tpOrSl: "stopLoss" },
+ () => ((triggerPrice - entryPrice) / entryPrice) * 100,
+ ),
+ Match.when(
+ { side: "long", tpOrSl: "takeProfit" },
+ () => ((triggerPrice - entryPrice) / entryPrice) * 100,
+ ),
+ Match.when(
+ { side: "long", tpOrSl: "stopLoss" },
+ () => ((entryPrice - triggerPrice) / entryPrice) * 100,
+ ),
+ Match.exhaustive,
+ );
const option = findMatchingOption(percentage);
@@ -162,10 +200,25 @@ function TPOrSLDialogContent({
}));
}
- const triggerPrice =
- tpOrSl === "takeProfit"
- ? entryPrice * (1 + percentage / 100)
- : entryPrice * (1 - percentage / 100);
+ const triggerPrice = Match.value({ side, tpOrSl }).pipe(
+ Match.when(
+ { side: "short", tpOrSl: "takeProfit" },
+ () => entryPrice * (1 - percentage / 100),
+ ),
+ Match.when(
+ { side: "short", tpOrSl: "stopLoss" },
+ () => entryPrice * (1 + percentage / 100),
+ ),
+ Match.when(
+ { side: "long", tpOrSl: "takeProfit" },
+ () => entryPrice * (1 + percentage / 100),
+ ),
+ Match.when(
+ { side: "long", tpOrSl: "stopLoss" },
+ () => entryPrice * (1 - percentage / 100),
+ ),
+ Match.exhaustive,
+ );
const option = findMatchingOption(percentage);
@@ -203,9 +256,17 @@ function TPOrSLDialogContent({
- {isSingleMode
- ? `Pick a percentage ${mode === "takeProfit" ? "gain" : "loss"}, or enter a custom trigger price to automatically close your position.`
- : "Pick a percentage gain or loss, or enter a custom trigger price to automatically close your position."}
+ {Match.value(isSingleMode).pipe(
+ Match.when(
+ true,
+ () =>
+ `Pick a percentage ${singleModeDescription}, or enter a custom trigger price to automatically close your position.`,
+ ),
+ Match.orElse(
+ () =>
+ "Pick a percentage gain or loss, or enter a custom trigger price to automatically close your position.",
+ ),
+ )}
@@ -218,11 +279,10 @@ function TPOrSLDialogContent({
position="middle"
/>
"Est. liquidation price"),
+ Match.orElse(() => "Liquidation price"),
+ )}
value={liquidationPrice}
position="last"
/>
@@ -237,6 +297,7 @@ function TPOrSLDialogContent({
label="Take profit"
percentPlaceholder="% Profit"
configuration={localSettings.takeProfit}
+ side={side}
onOptionChange={(option) =>
handleTPOrSLOptionChange(option, "takeProfit")
}
@@ -256,6 +317,7 @@ function TPOrSLDialogContent({
label="Stop loss"
percentPlaceholder="% Loss"
configuration={localSettings.stopLoss}
+ side={side}
onOptionChange={(option) =>
handleTPOrSLOptionChange(option, "stopLoss")
}
@@ -306,6 +368,7 @@ function TPOrSLSection({
label,
percentPlaceholder,
configuration,
+ side,
onOptionChange,
onTriggerPriceChange,
onPercentChange,
@@ -314,6 +377,7 @@ function TPOrSLSection({
label: string;
percentPlaceholder: string;
configuration: TPOrSLConfiguration;
+ side: "long" | "short";
onOptionChange: (option: TPOrSLPercentageOption) => void;
onTriggerPriceChange: (value: string) => void;
onPercentChange: (value: string) => void;
@@ -331,6 +395,7 @@ function TPOrSLSection({
selectedOption={configuration.option}
onOptionChange={onOptionChange}
tpOrSl={tpOrSl}
+ side={side}
/>
void;
tpOrSl: TPOrSLOption;
+ side: "long" | "short";
}) {
return (
@@ -360,15 +427,29 @@ function OptionButtons({
key={option}
type="button"
onClick={() => onOptionChange(option)}
- className={`flex-1 flex items-center justify-center h-9 rounded-[10px] text-sm font-normal tracking-[-0.42px] transition-colors cursor-pointer ${
- selectedOption === option
- ? "bg-white text-black"
- : "bg-white/5 text-gray-2 hover:bg-white/10"
- }`}
+ className={`flex-1 flex items-center justify-center h-9 rounded-[10px] text-sm font-normal tracking-[-0.42px] transition-colors cursor-pointer ${Match.value(
+ selectedOption === option,
+ ).pipe(
+ Match.when(true, () => "bg-white text-black"),
+ Match.orElse(() => "bg-white/5 text-gray-2 hover:bg-white/10"),
+ )}`}
>
- {option === 0
- ? "Off"
- : `${tpOrSl === "takeProfit" ? "+" : "-"}${option}%`}
+ {Match.value(option === 0).pipe(
+ Match.when(true, () => "Off"),
+ Match.orElse(
+ () =>
+ `${Match.value({ side, tpOrSl }).pipe(
+ Match.when(
+ { side: "short", tpOrSl: "takeProfit" },
+ () => "-",
+ ),
+ Match.when({ side: "short", tpOrSl: "stopLoss" }, () => "+"),
+ Match.when({ side: "long", tpOrSl: "takeProfit" }, () => "+"),
+ Match.when({ side: "long", tpOrSl: "stopLoss" }, () => "-"),
+ Match.exhaustive,
+ )}${option}%`,
+ ),
+ )}
))}
@@ -394,7 +475,10 @@ function TPOrSLInputFields({
""),
+ Match.orElse((value) => value.toFixed(2)),
+ )}
onChange={(e) => onTriggerPriceChange(e.target.value)}
placeholder="Trigger price"
className="w-full h-full bg-transparent text-white text-sm font-normal tracking-[-0.42px] pl-4 pr-10 outline-none placeholder:text-gray-2"
@@ -407,7 +491,11 @@ function TPOrSLInputFields({
""),
+ Match.when(0, () => ""),
+ Match.orElse((value) => value.toFixed(2)),
+ )}
onChange={(e) => onPercentChange(e.target.value)}
placeholder={percentPlaceholder}
className="w-full h-full bg-transparent text-white text-sm font-normal tracking-[-0.42px] pl-4 pr-8 outline-none placeholder:text-gray-2"
@@ -427,17 +515,42 @@ export const getTPOrSLConfigurationFromPosition = ({
amount,
entryPrice,
tpOrSl,
+ side = "long",
}: {
entryPrice: number;
amount: number | undefined;
tpOrSl: TPOrSLOption;
+ side?: "long" | "short";
}): TPOrSLConfiguration => {
- const percentage = amount
- ? tpOrSl === "takeProfit"
- ? ((amount - entryPrice) / entryPrice) * 100
- : ((entryPrice - amount) / entryPrice) * 100
- : null;
- const option = percentage ? findMatchingOption(percentage) : null;
+ const percentage = Match.value(amount).pipe(
+ Match.when(undefined, () => null),
+ Match.orElse((value) =>
+ Match.value({ side, tpOrSl }).pipe(
+ Match.when(
+ { side: "short", tpOrSl: "takeProfit" },
+ () => ((entryPrice - value) / entryPrice) * 100,
+ ),
+ Match.when(
+ { side: "short", tpOrSl: "stopLoss" },
+ () => ((value - entryPrice) / entryPrice) * 100,
+ ),
+ Match.when(
+ { side: "long", tpOrSl: "takeProfit" },
+ () => ((value - entryPrice) / entryPrice) * 100,
+ ),
+ Match.when(
+ { side: "long", tpOrSl: "stopLoss" },
+ () => ((entryPrice - value) / entryPrice) * 100,
+ ),
+ Match.exhaustive,
+ ),
+ ),
+ );
+ const option = Match.value(percentage).pipe(
+ Match.when(null, () => null),
+ Match.when(0, () => null),
+ Match.orElse((value) => findMatchingOption(value)),
+ );
return {
option,
diff --git a/tests/components/leverage-dialog.test.tsx b/tests/components/leverage-dialog.test.tsx
index a702a4c..e6bc2f1 100644
--- a/tests/components/leverage-dialog.test.tsx
+++ b/tests/components/leverage-dialog.test.tsx
@@ -129,33 +129,11 @@ describe("LeverageDialog", () => {
await userEvent.click(screen.getByText("Set Leverage"));
// generateLeverageButtons(40) returns [2, 5, 10, 20, 40]
- await expect.element(screen.getByTestId("leverage-button-2")).toBeVisible();
- await expect.element(screen.getByTestId("leverage-button-5")).toBeVisible();
- await expect
- .element(screen.getByTestId("leverage-button-10"))
- .toBeVisible();
- await expect
- .element(screen.getByTestId("leverage-button-20"))
- .toBeVisible();
- await expect
- .element(screen.getByTestId("leverage-button-40"))
- .toBeVisible();
- });
-
- test("displays leverage stop labels", async () => {
- const screen = await render(
-
- Set Leverage
- ,
- { wrapper: TestWrapper },
- );
-
- await userEvent.click(screen.getByText("Set Leverage"));
-
- // leverageStops = [MIN_LEVERAGE, maxLeverage/2, maxLeverage] = [1, 20, 40]
- await expect.element(screen.getByTestId("leverage-stop-1")).toBeVisible();
- await expect.element(screen.getByTestId("leverage-stop-20")).toBeVisible();
- await expect.element(screen.getByTestId("leverage-stop-40")).toBeVisible();
+ await expect.element(screen.getByLabelText("2x")).toBeVisible();
+ await expect.element(screen.getByLabelText("5x")).toBeVisible();
+ await expect.element(screen.getByLabelText("10x")).toBeVisible();
+ await expect.element(screen.getByLabelText("20x")).toBeVisible();
+ await expect.element(screen.getByLabelText("40x")).toBeVisible();
});
test("displays confirm button with correct leverage", async () => {
@@ -182,7 +160,7 @@ describe("LeverageDialog", () => {
await userEvent.click(screen.getByText("Set Leverage"));
// Click the 20x button
- await userEvent.click(screen.getByTestId("leverage-button-20"));
+ await userEvent.click(screen.getByLabelText("20x"));
// Large display should update
await expect
@@ -192,22 +170,6 @@ describe("LeverageDialog", () => {
await expect.element(screen.getByText("Set 20x")).toBeVisible();
});
- test("clicking stop label updates leverage", async () => {
- const screen = await render(
-
- Set Leverage
- ,
- { wrapper: TestWrapper },
- );
-
- await userEvent.click(screen.getByText("Set Leverage"));
-
- // Click the 1x stop label (first stop)
- await userEvent.click(screen.getByTestId("leverage-stop-1"));
-
- await expect.element(screen.getByText("Set 1x")).toBeVisible();
- });
-
test("confirm button calls onLeverageChange with selected leverage", async () => {
const onLeverageChange = vi.fn();
const screen = await render(
@@ -222,7 +184,7 @@ describe("LeverageDialog", () => {
);
await userEvent.click(screen.getByText("Set Leverage"));
- await userEvent.click(screen.getByTestId("leverage-button-20"));
+ await userEvent.click(screen.getByLabelText("20x"));
await userEvent.click(screen.getByText("Set 20x"));
expect(onLeverageChange).toHaveBeenCalledWith(20);
@@ -276,10 +238,10 @@ describe("LeverageDialog", () => {
await userEvent.click(screen.getByText("Set Leverage"));
- // The 10x button should have the selected style (bg-white text-black)
- const selectedButton = screen.getByTestId("leverage-button-10");
- await expect.element(selectedButton).toHaveClass("bg-white");
- await expect.element(selectedButton).toHaveClass("text-black");
+ const selectedButton = screen.getByLabelText("10x");
+ await expect
+ .element(selectedButton)
+ .toHaveAttribute("aria-pressed", "true");
});
test("different max leverage generates correct buttons", async () => {
@@ -293,14 +255,10 @@ describe("LeverageDialog", () => {
await userEvent.click(screen.getByText("Set Leverage"));
// generateLeverageButtons(20) returns [2, 5, 10, 20]
- await expect.element(screen.getByTestId("leverage-button-2")).toBeVisible();
- await expect.element(screen.getByTestId("leverage-button-5")).toBeVisible();
- await expect
- .element(screen.getByTestId("leverage-button-10"))
- .toBeVisible();
- await expect
- .element(screen.getByTestId("leverage-button-20"))
- .toBeVisible();
+ await expect.element(screen.getByLabelText("2x")).toBeVisible();
+ await expect.element(screen.getByLabelText("5x")).toBeVisible();
+ await expect.element(screen.getByLabelText("10x")).toBeVisible();
+ await expect.element(screen.getByLabelText("20x")).toBeVisible();
});
test("low max leverage shows limited buttons", async () => {
@@ -314,8 +272,8 @@ describe("LeverageDialog", () => {
await userEvent.click(screen.getByText("Set Leverage"));
// generateLeverageButtons(5) returns [2, 5]
- await expect.element(screen.getByTestId("leverage-button-2")).toBeVisible();
- await expect.element(screen.getByTestId("leverage-button-5")).toBeVisible();
+ await expect.element(screen.getByLabelText("2x")).toBeVisible();
+ await expect.element(screen.getByLabelText("5x")).toBeVisible();
});
test("calculates correct liquidation price for long position", async () => {
@@ -379,7 +337,7 @@ describe("LeverageDialog", () => {
await expect.element(screen.getByText("45,000")).toBeVisible();
// Change to 20x leverage
- await userEvent.click(screen.getByTestId("leverage-button-20"));
+ await userEvent.click(screen.getByLabelText("20x"));
// New: 50000 * (1 - 1/20) = 50000 * 0.95 = 47500
await expect.element(screen.getByText("47,500")).toBeVisible();
@@ -401,9 +359,9 @@ describe("LeverageDialog", () => {
await userEvent.click(screen.getByText("Set Leverage"));
// Click multiple leverage buttons
- await userEvent.click(screen.getByTestId("leverage-button-5"));
- await userEvent.click(screen.getByTestId("leverage-button-20"));
- await userEvent.click(screen.getByTestId("leverage-button-2"));
+ await userEvent.click(screen.getByLabelText("5x"));
+ await userEvent.click(screen.getByLabelText("20x"));
+ await userEvent.click(screen.getByLabelText("2x"));
// Confirm
await userEvent.click(screen.getByText("Set 2x"));
@@ -477,15 +435,17 @@ describe("LeverageDialogContent", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
- test("leverage stop labels include min, mid, and max values", async () => {
+ test("renders leverage toggle options", async () => {
const screen = await render(
,
{ wrapper: TestWrapper },
);
- // MIN_LEVERAGE = 1, maxLeverage/2 = 20, maxLeverage = 40
- await expect.element(screen.getByTestId("leverage-stop-1")).toBeVisible();
- await expect.element(screen.getByTestId("leverage-stop-20")).toBeVisible();
- await expect.element(screen.getByTestId("leverage-stop-40")).toBeVisible();
+ // generateLeverageButtons(40) returns [2, 5, 10, 20, 40]
+ await expect.element(screen.getByLabelText("2x")).toBeVisible();
+ await expect.element(screen.getByLabelText("5x")).toBeVisible();
+ await expect.element(screen.getByLabelText("10x")).toBeVisible();
+ await expect.element(screen.getByLabelText("20x")).toBeVisible();
+ await expect.element(screen.getByLabelText("40x")).toBeVisible();
});
});
diff --git a/tests/components/tp-sl-dialog.test.tsx b/tests/components/tp-sl-dialog.test.tsx
index 4e601fe..a79e7b6 100644
--- a/tests/components/tp-sl-dialog.test.tsx
+++ b/tests/components/tp-sl-dialog.test.tsx
@@ -136,6 +136,26 @@ describe("TPOrSLDialog", () => {
await expect.element(screen.getByText("-100%")).toBeVisible();
});
+ test("renders short side options with inverted signs", async () => {
+ const screen = await render(
+
+ Open Dialog
+ ,
+ { wrapper: TestWrapper },
+ );
+
+ await userEvent.click(screen.getByText("Open Dialog"));
+
+ await expect.element(screen.getByText("-10%")).toBeVisible();
+ await expect.element(screen.getByText("-25%")).toBeVisible();
+ await expect.element(screen.getByText("-50%")).toBeVisible();
+ await expect.element(screen.getByText("-100%")).toBeVisible();
+ await expect.element(screen.getByText("+10%")).toBeVisible();
+ await expect.element(screen.getByText("+25%")).toBeVisible();
+ await expect.element(screen.getByText("+50%")).toBeVisible();
+ await expect.element(screen.getByText("+100%")).toBeVisible();
+ });
+
test("selects take profit percentage option and calculates trigger price", async () => {
const onSettingsChange = vi.fn();
const screen = await render(
@@ -160,6 +180,34 @@ describe("TPOrSLDialog", () => {
);
});
+ test("selects short take profit percentage option and calculates trigger price", async () => {
+ const onSettingsChange = vi.fn();
+ const screen = await render(
+
+ Open Dialog
+ ,
+ { wrapper: TestWrapper },
+ );
+
+ await userEvent.click(screen.getByText("Open Dialog"));
+ await userEvent.click(screen.getByText("-25%"));
+ await userEvent.click(screen.getByText("Done"));
+
+ expect(onSettingsChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ takeProfit: {
+ option: 25,
+ triggerPrice: 75,
+ percentage: 25,
+ },
+ }),
+ );
+ });
+
test("selects stop loss percentage option and calculates trigger price", async () => {
const onSettingsChange = vi.fn();
const screen = await render(
@@ -184,6 +232,34 @@ describe("TPOrSLDialog", () => {
);
});
+ test("selects short stop loss percentage option and calculates trigger price", async () => {
+ const onSettingsChange = vi.fn();
+ const screen = await render(
+
+ Open Dialog
+ ,
+ { wrapper: TestWrapper },
+ );
+
+ await userEvent.click(screen.getByText("Open Dialog"));
+ await userEvent.click(screen.getByText("+25%"));
+ await userEvent.click(screen.getByText("Done"));
+
+ expect(onSettingsChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ stopLoss: {
+ option: 25,
+ triggerPrice: 125,
+ percentage: 25,
+ },
+ }),
+ );
+ });
+
test('selects "Off" option to clear configuration', async () => {
const onSettingsChange = vi.fn();
const settingsWithTakeProfit: TPOrSLSettings = {
@@ -362,6 +438,19 @@ describe("getTPOrSLConfigurationFromPosition", () => {
expect(result.option).toBe(25);
});
+ test("calculates short take profit configuration correctly", () => {
+ const result = getTPOrSLConfigurationFromPosition({
+ entryPrice: 100,
+ amount: 75,
+ tpOrSl: "takeProfit",
+ side: "short",
+ });
+
+ expect(result.triggerPrice).toBe(75);
+ expect(result.percentage).toBe(25);
+ expect(result.option).toBe(25);
+ });
+
test("calculates stop loss configuration correctly", () => {
const result = getTPOrSLConfigurationFromPosition({
entryPrice: 100,
@@ -374,6 +463,19 @@ describe("getTPOrSLConfigurationFromPosition", () => {
expect(result.option).toBe(25);
});
+ test("calculates short stop loss configuration correctly", () => {
+ const result = getTPOrSLConfigurationFromPosition({
+ entryPrice: 100,
+ amount: 125,
+ tpOrSl: "stopLoss",
+ side: "short",
+ });
+
+ expect(result.triggerPrice).toBe(125);
+ expect(result.percentage).toBe(25);
+ expect(result.option).toBe(25);
+ });
+
test("returns null option for non-matching percentage", () => {
const result = getTPOrSLConfigurationFromPosition({
entryPrice: 100,