Add more test for the daily volumes and add button to set the UIFee Factor

This commit is contained in:
2025-11-14 18:04:58 +07:00
parent d27df5de51
commit 479fcca662
3 changed files with 453 additions and 1 deletions

View File

@@ -800,4 +800,179 @@ public class PlatformSummaryMetricsTests
result.PositionCountByAsset.Should().HaveCount(1); result.PositionCountByAsset.Should().HaveCount(1);
result.TotalPlatformVolume.Should().BeGreaterThan(0); result.TotalPlatformVolume.Should().BeGreaterThan(0);
} }
[Fact]
public void CalculateDailySnapshotFromPositions_WithDateFiltering_CalculatesCumulativeMetricsCorrectly()
{
// Arrange - Create positions with different dates spanning multiple days
var baseDate = TestDate.Date;
var positions = new List<Position>
{
// Day 1: Two positions (one finished, one filled)
CreateFinishedPosition(50000m, 0.1m, TradeDirection.Long, 1m, 51000m, false),
CreateFilledPosition(40000m, 0.2m, TradeDirection.Short, 1m),
// Day 2: One finished position
CreateFinishedPosition(60000m, 0.05m, TradeDirection.Long, 2m, 61200m, false),
// Day 3: Two positions (future dates - should be excluded)
};
// Set specific dates for positions
positions[0].Date = baseDate.AddHours(10); // Day 1, 10 AM
positions[1].Date = baseDate.AddHours(14); // Day 1, 2 PM
positions[2].Date = baseDate.AddDays(1).AddHours(11); // Day 2, 11 AM
// Future positions (should be excluded)
var futurePositions = new List<Position>
{
CreateFinishedPosition(30000m, 0.15m, TradeDirection.Short, 1m, 29100m, false),
CreateFilledPosition(70000m, 0.08m, TradeDirection.Long, 1m)
};
futurePositions[0].Date = baseDate.AddDays(2).AddHours(9); // Day 3, 9 AM
futurePositions[1].Date = baseDate.AddDays(2).AddHours(16); // Day 3, 4 PM
var allPositions = positions.Concat(futurePositions).ToList();
// Act - Calculate snapshot for Day 2 (should include Day 1 and Day 2 positions only)
var targetDate = baseDate.AddDays(1); // Day 2
var filteredPositions = allPositions.Where(p => p.Date.Date <= targetDate).ToList();
var metrics = TradingBox.CalculatePlatformSummaryMetrics(filteredPositions);
// Assert - Should include positions from Day 1 and Day 2 only
metrics.TotalLifetimePositionCount.Should().Be(3); // 2 from Day 1 + 1 from Day 2
// Calculate expected volume: Day 1 positions + Day 2 position
// Default takeProfitPercentage is 0.04m (4%)
var day1Volume = (50000m * 0.1m * 1m + 52000m * 0.1m * 1m) + // Finished long: 5000 + 5200 = 10200
(40000m * 0.2m * 1m); // Open short: 8000 (not finished, no close volume)
var day2Volume = (60000m * 0.05m * 2m + 62400m * 0.05m * 2m); // Finished long with 2x leverage: 6000 + 6240 = 12240
var expectedTotalVolume = day1Volume + day2Volume; // 10200 + 8000 + 12240 = 30440
metrics.TotalPlatformVolume.Should().Be(expectedTotalVolume);
metrics.OpenInterest.Should().Be(40000m * 0.2m * 1m); // Only the open short position from Day 1
// Should have both BTC and position counts
metrics.VolumeByAsset.Should().ContainKey("BTC");
metrics.PositionCountByAsset["BTC"].Should().Be(3);
}
[Fact]
public void CalculateDailySnapshotFromPositions_WithNoPositionsForDate_ReturnsEmptyMetrics()
{
// Arrange - Create positions all after the target date
var baseDate = TestDate.Date;
var positions = new List<Position>
{
CreateFinishedPosition(50000m, 0.1m, TradeDirection.Long, 1m, 51000m, false),
CreateFilledPosition(40000m, 0.2m, TradeDirection.Short, 1m)
};
// Set dates after target date
positions[0].Date = baseDate.AddDays(1).AddHours(10);
positions[1].Date = baseDate.AddDays(2).AddHours(14);
// Act - Calculate snapshot for a date before all positions
var targetDate = baseDate; // Today, before all positions
var filteredPositions = positions.Where(p => p.Date.Date <= targetDate).ToList();
var metrics = TradingBox.CalculatePlatformSummaryMetrics(filteredPositions);
// Assert - Should have no positions for this date
metrics.TotalLifetimePositionCount.Should().Be(0);
metrics.TotalPlatformVolume.Should().Be(0);
metrics.TotalPlatformFees.Should().Be(0);
metrics.TotalPlatformPnL.Should().Be(0);
metrics.NetPnL.Should().Be(0);
metrics.OpenInterest.Should().Be(0);
metrics.VolumeByAsset.Should().BeEmpty();
metrics.PositionCountByAsset.Should().BeEmpty();
metrics.PositionCountByDirection.Should().BeEmpty();
}
[Fact]
public void CalculateDailySnapshotFromPositions_WithMixedAssetsAndDirections_CalculatesAssetBreakdownsCorrectly()
{
// Arrange - Create positions with different assets and directions over multiple days
var baseDate = TestDate.Date;
var positions = new List<Position>
{
// Day 1: BTC Long (finished) and ETH Short (open)
CreateFinishedPosition(50000m, 0.1m, TradeDirection.Long, 1m, 51000m, false), // BTC finished
CreateFilledPosition(3000m, 1m, TradeDirection.Short, 1m), // ETH open
// Day 2: BTC Short (finished) and ETH Long (finished)
CreateFinishedPosition(40000m, 0.2m, TradeDirection.Short, 2m, 39200m, false), // BTC finished
CreateFinishedPosition(3100m, 0.5m, TradeDirection.Long, 1m, 3180m, false) // ETH finished
};
// Set dates
positions[0].Date = baseDate.AddHours(10); // Day 1 BTC
positions[1].Date = baseDate.AddHours(14); // Day 1 ETH
positions[2].Date = baseDate.AddDays(1).AddHours(11); // Day 2 BTC
positions[3].Date = baseDate.AddDays(1).AddHours(15); // Day 2 ETH
// Set ETH tickers for ETH positions
positions[1].Ticker = Ticker.ETH;
positions[3].Ticker = Ticker.ETH;
// Act - Calculate snapshot for Day 2 (includes all positions)
var targetDate = baseDate.AddDays(1);
var filteredPositions = positions.Where(p => p.Date.Date <= targetDate).ToList();
var metrics = TradingBox.CalculatePlatformSummaryMetrics(filteredPositions);
// Assert - Should include all 4 positions
metrics.TotalLifetimePositionCount.Should().Be(4);
metrics.VolumeByAsset.Should().HaveCount(2); // BTC and ETH
metrics.PositionCountByAsset.Should().HaveCount(2);
metrics.PositionCountByAsset["BTC"].Should().Be(2);
metrics.PositionCountByAsset["ETH"].Should().Be(2);
// Should have only one direction (one open short ETH, rest are finished)
metrics.PositionCountByDirection.Should().HaveCount(1);
metrics.PositionCountByDirection[TradeDirection.Short].Should().Be(1); // Only the open ETH short position
// Open interest should only include the open ETH short position
metrics.OpenInterest.Should().Be(3000m * 1m * 1m); // ETH open short: 3000
}
[Fact]
public void CalculateDailySnapshotFromPositions_WithPositionSpanningMultipleDays_CalculatesVolumePerDayCorrectly()
{
// Arrange - Create positions representing the same position at different stages
var baseDate = TestDate.Date;
// Position as it appears on Day 1 (still open)
var positionDay1 = CreateFilledPosition(50000m, 0.1m, TradeDirection.Long, 1m);
positionDay1.Date = baseDate.AddHours(12); // Day 1, noon
// Position as it appears on Day 2 (now closed)
var positionDay2 = CreateFinishedPosition(50000m, 0.1m, TradeDirection.Long, 1m, 52000m, false);
positionDay2.Date = baseDate.AddHours(12); // Same open date
positionDay2.TakeProfit1.Date = baseDate.AddDays(1).AddHours(10); // Closed on Day 2
// Act - Calculate snapshot for Day 1 (position opened but not closed yet)
var day1Positions = new List<Position> { positionDay1 };
var day1Metrics = TradingBox.CalculatePlatformSummaryMetrics(day1Positions);
// Calculate snapshot for Day 2 (position opened and closed)
var day2Positions = new List<Position> { positionDay2 };
var day2Metrics = TradingBox.CalculatePlatformSummaryMetrics(day2Positions);
// Assert - Day 1: Only opening volume (position still open)
var expectedDay1Volume = 50000m * 0.1m * 1m; // Open: 5000
day1Metrics.TotalPlatformVolume.Should().Be(expectedDay1Volume);
day1Metrics.TotalLifetimePositionCount.Should().Be(1);
day1Metrics.OpenInterest.Should().Be(expectedDay1Volume); // Position still open
// Assert - Day 2: Opening volume + closing volume (position now closed)
var expectedDay2Volume = 50000m * 0.1m * 1m + 52000m * 0.1m * 1m; // Open: 5000 + Close: 5200 = 10200
day2Metrics.TotalPlatformVolume.Should().Be(expectedDay2Volume);
day2Metrics.TotalLifetimePositionCount.Should().Be(1);
day2Metrics.OpenInterest.Should().Be(0m); // Position now closed
// Assert - Volume increased from Day 1 to Day 2 (closing volume added)
day2Metrics.TotalPlatformVolume.Should().BeGreaterThan(day1Metrics.TotalPlatformVolume);
var volumeIncrease = day2Metrics.TotalPlatformVolume - day1Metrics.TotalPlatformVolume;
volumeIncrease.Should().Be(5200m); // The closing volume added on Day 2
}
} }

View File

@@ -222,6 +222,106 @@ export const useClaimUiFees = () => {
} }
} }
// ABI for the setUiFeeFactor function
const SET_UI_FEE_FACTOR_ABI = parseAbi([
'function setUiFeeFactor(uint256 uiFeeFactor) external',
])
// UI Fee Receiver contract address on Arbitrum (from Arbiscan)
const UI_FEE_RECEIVER_ADDRESS = '0x602b805EedddBbD9ddff44A7dcBD46cb07849685'
/**
* Custom hook to update UI fee factor on GMX ExchangeRouter contract
*/
export const useUpdateUiFee = () => {
const { address, isConnected } = useAccount()
const chainId = useChainId()
const { switchChainAsync } = useSwitchChain()
const { writeContractAsync } = useWriteContract()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [txHash, setTxHash] = useState<string | null>(null)
/**
* Updates the UI fee factor by calling the ExchangeRouter contract
* @param percentage The fee percentage (e.g., 0.1 for 0.1%)
*/
const updateUiFeeFactor = async (percentage: number) => {
if (!isConnected || !address) {
throw new Error('Wallet not connected')
}
if (percentage < 0 || percentage > 100) {
throw new Error('Fee percentage must be between 0 and 100')
}
setIsLoading(true)
setError(null)
setTxHash(null)
try {
// Switch to Arbitrum if not already on it
if (chainId !== arbitrum.id) {
await switchChainAsync({ chainId: arbitrum.id })
}
// Convert percentage to basis points (1% = 100 basis points)
// GMX typically uses factors with 30 decimal places (10^30 total precision)
const basisPoints = Math.round(percentage * 100) // Convert to basis points
const feeFactor = BigInt(basisPoints) * BigInt(10) ** BigInt(26) // Multiply by 10^26 to get the factor (10^30 - 10^4 for basis points)
console.log('Updating UI fee factor:')
console.log(`Percentage: ${percentage}%`)
console.log(`Basis points: ${basisPoints}`)
console.log(`Fee factor: ${feeFactor.toString()}`)
// Call the setUiFeeFactor function on the contract
const hash = await writeContractAsync({
address: UI_FEE_RECEIVER_ADDRESS,
abi: SET_UI_FEE_FACTOR_ABI,
functionName: 'setUiFeeFactor',
args: [feeFactor],
})
setTxHash(hash)
return hash
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
setError(errorMessage)
throw new Error(`Failed to update UI fee factor: ${errorMessage}`)
} finally {
setIsLoading(false)
}
}
return {
updateUiFeeFactor,
isLoading,
error,
txHash,
isConnected,
address,
}
}
/**
* Hook to monitor UI fee update transaction status
*/
export const useUpdateUiFeeTransaction = (txHash: string | null) => {
const { data, isLoading, isSuccess, isError } = useWaitForTransactionReceipt({
hash: txHash as `0x${string}` | undefined,
chainId: arbitrum.id,
})
return {
transactionData: data,
isConfirming: isLoading,
isConfirmed: isSuccess,
isError,
}
}
// Export the allowed tickers for use in other components // Export the allowed tickers for use in other components
export { ALLOWED_TICKERS, type AllowedTicker } export { ALLOWED_TICKERS, type AllowedTicker }

View File

@@ -5,7 +5,13 @@ import LogIn from '../../components/mollecules/LogIn/LogIn'
import useCookie from '../../hooks/useCookie' import useCookie from '../../hooks/useCookie'
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import {useAuthStore} from '../../app/store/accountStore' import {useAuthStore} from '../../app/store/accountStore'
import {ALLOWED_TICKERS, useClaimUiFees, useClaimUiFeesTransaction} from '../../hooks/useClaimUiFees' import {
ALLOWED_TICKERS,
useClaimUiFees,
useClaimUiFeesTransaction,
useUpdateUiFee,
useUpdateUiFeeTransaction
} from '../../hooks/useClaimUiFees'
import Toast from '../../components/mollecules/Toast/Toast' import Toast from '../../components/mollecules/Toast/Toast'
import useApiUrlStore from '../../app/store/apiStore' import useApiUrlStore from '../../app/store/apiStore'
@@ -85,11 +91,19 @@ export const Auth = ({ children }: any) => {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [claimAccountAddress, setClaimAccountAddress] = useState('') const [claimAccountAddress, setClaimAccountAddress] = useState('')
const [showClaimSection, setShowClaimSection] = useState(false) const [showClaimSection, setShowClaimSection] = useState(false)
// Update UI Fee state
const [uiFeePercentage, setUiFeePercentage] = useState('')
const [showUpdateUiFeeSection, setShowUpdateUiFeeSection] = useState(false)
// Claim UI fees hook // Claim UI fees hook
const { claimUiFees, isLoading: isClaimingFees, txHash, error: claimError } = useClaimUiFees() const { claimUiFees, isLoading: isClaimingFees, txHash, error: claimError } = useClaimUiFees()
const { isConfirming, isConfirmed, isError: txError } = useClaimUiFeesTransaction(txHash) const { isConfirming, isConfirmed, isError: txError } = useClaimUiFeesTransaction(txHash)
// Update UI fee hook
const { updateUiFeeFactor, isLoading: isUpdatingFee, txHash: updateTxHash, error: updateFeeError } = useUpdateUiFee()
const { isConfirming: isUpdateConfirming, isConfirmed: isUpdateConfirmed, isError: updateTxError } = useUpdateUiFeeTransaction(updateTxHash)
useEffect(() => { useEffect(() => {
if (ready) { if (ready) {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@@ -122,6 +136,23 @@ export const Auth = ({ children }: any) => {
toast.update('error', 'Transaction failed') toast.update('error', 'Transaction failed')
} }
}, [txError]) }, [txError])
// Handle successful UI fee update
useEffect(() => {
if (isUpdateConfirmed) {
const toast = new Toast('Success')
toast.update('success', 'UI fee factor updated successfully!')
setUiFeePercentage('') // Clear the input
}
}, [isUpdateConfirmed])
// Handle UI fee update transaction error
useEffect(() => {
if (updateTxError) {
const toast = new Toast('Error')
toast.update('error', 'UI fee update transaction failed')
}
}, [updateTxError])
const handleClaimUiFees = async () => { const handleClaimUiFees = async () => {
if (!isConnected) { if (!isConnected) {
@@ -149,6 +180,33 @@ export const Auth = ({ children }: any) => {
} }
} }
const handleUpdateUiFee = async () => {
if (!isConnected) {
const toast = new Toast('Error')
toast.update('error', 'Please connect your wallet first')
return
}
const percentage = parseFloat(uiFeePercentage)
if (isNaN(percentage) || percentage < 0 || percentage > 100) {
const toast = new Toast('Error')
toast.update('error', 'Please enter a valid percentage between 0 and 100')
return
}
const toast = new Toast('Updating UI fee factor...')
try {
toast.update('info', 'Submitting transaction to update UI fee factor...')
await updateUiFeeFactor(percentage)
toast.update('info', 'Transaction submitted, waiting for confirmation...')
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
toast.update('error', `Failed to update UI fee factor: ${errorMessage}`)
console.error('Error updating UI fee factor:', err)
}
}
if (!ready || isLoading) { if (!ready || isLoading) {
return <div>Loading...</div>; return <div>Loading...</div>;
} }
@@ -288,6 +346,125 @@ export const Auth = ({ children }: any) => {
)} )}
</div> </div>
)} )}
{/* Update UI Fee Section */}
<div style={{ textAlign: 'center', marginTop: '20px' }}>
<button
onClick={() => setShowUpdateUiFeeSection(!showUpdateUiFeeSection)}
style={{
padding: '10px 20px',
backgroundColor: showUpdateUiFeeSection ? '#6B7280' : '#8B5CF6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '16px'
}}
>
{showUpdateUiFeeSection ? 'Hide' : 'Update UI Fee'}
</button>
</div>
{showUpdateUiFeeSection && (
<div style={{
padding: '20px',
backgroundColor: '#F3F4F6',
borderRadius: '8px',
maxWidth: '400px',
width: '100%',
marginTop: '20px'
}}>
<h3 style={{ margin: '0 0 15px 0', fontSize: '18px', color: '#111827' }}>
Update GMX UI Fee Factor
</h3>
<p style={{ margin: '0 0 10px 0', fontSize: '14px', color: '#6B7280' }}>
Set the UI fee factor percentage for GMX trading. This will be automatically formatted for the smart contract.
</p>
<details style={{ marginBottom: '15px', fontSize: '12px', color: '#6B7280' }}>
<summary style={{ cursor: 'pointer', fontWeight: '500' }}>
How it works
</summary>
<div style={{
marginTop: '8px',
padding: '8px',
backgroundColor: '#E5E7EB',
borderRadius: '4px',
lineHeight: '1.6'
}}>
Enter a percentage (e.g., 0.1 for 0.1%). This gets converted to basis points and formatted for the GMX contract.
The fee factor determines the percentage of trading fees that go to the UI/interface.
</div>
</details>
{isConnected && walletAddress && (
<div style={{ marginBottom: '15px', fontSize: '12px', color: '#059669' }}>
Wallet connected: {walletAddress.slice(0, 6)}...{walletAddress.slice(-4)}
</div>
)}
{!isConnected && (
<div style={{ marginBottom: '15px', fontSize: '12px', color: '#DC2626' }}>
Please connect your wallet first
</div>
)}
<input
type="number"
placeholder="Enter fee percentage (e.g., 0.1)"
value={uiFeePercentage}
onChange={(e) => setUiFeePercentage(e.target.value)}
min="0"
max="100"
step="0.01"
style={{
width: '100%',
padding: '10px',
marginBottom: '15px',
border: '1px solid #D1D5DB',
borderRadius: '4px',
fontSize: '14px',
boxSizing: 'border-box'
}}
/>
<button
onClick={handleUpdateUiFee}
disabled={!isConnected || isUpdatingFee || isUpdateConfirming || !uiFeePercentage.trim()}
style={{
width: '100%',
padding: '10px 20px',
backgroundColor: (!isConnected || isUpdatingFee || isUpdateConfirming || !uiFeePercentage.trim()) ? '#9CA3AF' : '#8B5CF6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: (!isConnected || isUpdatingFee || isUpdateConfirming || !uiFeePercentage.trim()) ? 'not-allowed' : 'pointer',
fontSize: '16px'
}}
>
{isUpdatingFee ? 'Signing...' : isUpdateConfirming ? 'Confirming...' : isUpdateConfirmed ? 'Updated!' : 'Update UI Fee Factor'}
</button>
{(updateFeeError) && (
<div style={{ marginTop: '10px', fontSize: '12px', color: '#DC2626' }}>
{updateFeeError}
</div>
)}
{updateTxHash && (
<div style={{ marginTop: '10px', fontSize: '12px' }}>
<p style={{ color: '#059669', margin: '0 0 5px 0' }}>Transaction Hash:</p>
<a
href={`https://arbiscan.io/tx/${updateTxHash}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#8B5CF6', wordBreak: 'break-all' }}
>
{updateTxHash}
</a>
</div>
)}
</div>
)}
</div> </div>
) )
} else if (!token) { } else if (!token) {