@@ -8,36 +8,43 @@ use serde::{Deserialize, Serialize};
88use sha2:: Sha256 ;
99use tracing:: info;
1010use v_exchanges:: Ticker ;
11- use v_utils:: trades:: Timeframe ;
11+ use v_utils:: { log , trades:: Timeframe } ;
1212
1313use crate :: config:: AppConfig ;
1414
1515type HmacSha256 = Hmac < Sha256 > ;
1616
1717#[ derive( clap:: Args , Debug ) ]
1818#[ command( group(
19- clap:: ArgGroup :: new( "size " )
19+ clap:: ArgGroup :: new( "size_group " )
2020 . required( true )
21- . multiple( true )
22- . args( [ "size_quote" , "size_usd" ] ) ,
21+ . args( [ "quote" , "notional" , "size" ] ) ,
2322) ) ]
2423pub ( crate ) struct AdjustPosArgs {
2524 /// Ticker to adjust position for.
2625 ticker : Ticker ,
2726
2827 /// Size in quote currency.
2928 #[ arg( short = 'q' , long) ]
30- size_quote : Option < f64 > ,
29+ quote : Option < f64 > ,
3130
32- /// Size in USD
31+ /// Size in notional (USD)
32+ #[ arg( short = 'n' , long) ]
33+ notional : Option < f64 > ,
34+
35+ /// Size with suffix inference: "$" for USD, asset name (e.g., "BTC") for that asset, or plain number for quote
3336 #[ arg( short = 's' , long) ]
34- size_usd : Option < f64 > ,
37+ size : Option < String > ,
3538
3639 /// timeframe, in the format of "1m", "1h", "3M", etc.
3740 /// determines the target period for which we expect the edge to persist.
3841 #[ arg( short, long) ]
3942 tf : Option < Timeframe > ,
4043
44+ /// Reduce-only mode: only reduce existing position, don't increase it
45+ #[ arg( long) ]
46+ reduce : bool ,
47+
4148 /// Use testnet instead of mainnet
4249 #[ arg( long) ]
4350 testnet : bool ,
@@ -55,6 +62,8 @@ struct BybitOrderRequest {
5562 time_in_force : String ,
5663 #[ serde( rename = "orderLinkId" ) ]
5764 order_link_id : String ,
65+ #[ serde( rename = "reduceOnly" ) ]
66+ reduce_only : bool ,
5867}
5968
6069#[ derive( Debug , Deserialize ) ]
@@ -79,9 +88,9 @@ fn convert_symbol_to_bybit(symbol: &str) -> String {
7988 without_suffix. replace ( '-' , "" ) . to_uppercase ( )
8089}
8190
82- /// Round quantity to the appropriate step size, rounding up to ensure minimum notional
83- fn round_to_step_ceil ( value : f64 , step : f64 ) -> f64 {
84- ( value / step) . ceil ( ) * step
91+ /// Round quantity to the appropriate step size
92+ fn round_to_step ( value : f64 , step : f64 ) -> f64 {
93+ ( value / step) . round ( ) * step
8594}
8695
8796/// Sign Bybit API request
@@ -95,19 +104,37 @@ fn sign_request(api_secret: &str, timestamp: &str, api_key: &str, recv_window: &
95104pub ( crate ) async fn main ( args : AdjustPosArgs , config : Arc < AppConfig > ) -> Result < ( ) > {
96105 info ! ( "Starting adjust-pos for ticker: {:?}" , args. ticker) ;
97106
98- // Determine the target size in USD
99- let target_usd = if let Some ( usd) = args. size_usd {
100- usd
101- } else if let Some ( quote) = args. size_quote {
102- // For now, assume quote == USD (works for USDT pairs)
103- // TODO: Handle conversion if quote currency != USD
104- quote
107+ // Determine whether we have a quote amount (quantity) or notional amount (USD)
108+ enum SizeType {
109+ Quote ( f64 ) , // Actual quantity of asset
110+ Notional ( f64 ) , // USD value
111+ }
112+
113+ let size_type = if let Some ( notional) = args. notional {
114+ SizeType :: Notional ( notional)
115+ } else if let Some ( quote) = args. quote {
116+ SizeType :: Quote ( quote)
117+ } else if let Some ( size_str) = args. size {
118+ // Parse size with suffix inference
119+ if size_str. ends_with ( '$' ) {
120+ // Strip $ and parse as USD
121+ let usd = size_str. trim_end_matches ( '$' ) . parse :: < f64 > ( ) . context ( "Failed to parse USD amount from --size" ) ?;
122+ SizeType :: Notional ( usd)
123+ } else if let Some ( pos) = size_str. chars ( ) . position ( |c| c. is_alphabetic ( ) ) {
124+ // Has a suffix like "BTC", "ETH", etc.
125+ let ( number_part, asset_part) = size_str. split_at ( pos) ;
126+ let amount = number_part. parse :: < f64 > ( ) . context ( "Failed to parse amount from --size" ) ?;
127+ // TODO: Handle conversion from other assets to USD
128+ bail ! ( "Asset conversion not yet implemented. Got {} {}, need to convert to USD" , amount, asset_part) ;
129+ } else {
130+ // Plain number - treat as quote currency (actual quantity)
131+ let qty = size_str. parse :: < f64 > ( ) . context ( "Failed to parse --size as number" ) ?;
132+ SizeType :: Quote ( qty)
133+ }
105134 } else {
106135 bail ! ( "No size specified" ) ;
107136 } ;
108137
109- info ! ( "Target size: ${} USD" , target_usd) ;
110-
111138 // Get exchange config based on ticker's exchange
112139 let exchange_config = config. get_exchange ( args. ticker . exchange_name ) ?;
113140
@@ -168,28 +195,23 @@ pub(crate) async fn main(args: AdjustPosArgs, config: Arc<AppConfig>) -> Result<
168195 . ok_or_else ( || color_eyre:: eyre:: eyre!( "Failed to get maxOrderQty" ) ) ?
169196 . parse ( ) ?;
170197
171- // Get minimum notional value
172- let min_notional: f64 = instruments_response[ "result" ] [ "list" ] [ 0 ] [ "lotSizeFilter" ] [ "minNotionalValue" ]
173- . as_str ( )
174- . ok_or_else ( || color_eyre:: eyre:: eyre!( "Failed to get minNotionalValue" ) ) ?
175- . parse ( ) ?;
198+ info ! ( "Instrument info - qtyStep: {}, minOrderQty: {}, maxOrderQty: {}" , qty_step, min_order_qty, max_order_qty) ;
176199
177- info ! (
178- "Instrument info - qtyStep: {}, minOrderQty: {}, maxOrderQty: {}, minNotional: ${}" ,
179- qty_step, min_order_qty, max_order_qty, min_notional
180- ) ;
200+ // Calculate quantity based on size type, extracting sign for order side
201+ let ( raw_quantity, side) = match size_type {
202+ SizeType :: Quote ( qty) => ( qty, if qty >= 0.0 { "Buy" } else { "Sell" } ) ,
203+ SizeType :: Notional ( usd) => {
204+ let qty = usd / current_price;
205+ ( qty, if usd >= 0.0 { "Buy" } else { "Sell" } )
206+ }
207+ } ;
181208
182- // Calculate quantity and round UP to step size to ensure we meet minimum notional
183- let raw_quantity = target_usd / current_price ;
184- let quantity = round_to_step_ceil ( raw_quantity , qty_step) . max ( min_order_qty ) ;
209+ // Work with absolute value for rounding
210+ let abs_raw_qty = raw_quantity . abs ( ) ;
211+ let quantity = round_to_step ( abs_raw_qty , qty_step) ;
185212
186- // Verify we meet minimum notional value
187213 let actual_notional = quantity * current_price;
188- if actual_notional < min_notional {
189- bail ! ( "Order value ${:.2} is below minimum notional ${:.2}. Need to increase size." , actual_notional, min_notional) ;
190- }
191-
192- info ! ( "Calculated quantity: {:.6} -> rounded to {:.6} (notional: ${:.2})" , raw_quantity, quantity, actual_notional) ;
214+ log ! ( "{} order: {:.6} -> rounded to {:.6} (notional: ${:.2})" , side, abs_raw_qty, quantity, actual_notional) ;
193215
194216 // Format quantity properly based on step size
195217 let qty_str = if qty_step >= 1.0 {
@@ -206,11 +228,12 @@ pub(crate) async fn main(args: AdjustPosArgs, config: Arc<AppConfig>) -> Result<
206228 let order_request = BybitOrderRequest {
207229 category : "linear" . to_string ( ) ,
208230 symbol : symbol. clone ( ) ,
209- side : "Buy" . to_string ( ) ,
231+ side : side . to_string ( ) ,
210232 order_type : "Market" . to_string ( ) ,
211233 qty : qty_str. clone ( ) ,
212234 time_in_force : "IOC" . to_string ( ) ,
213235 order_link_id : format ! ( "adjust-{}" , uuid:: Uuid :: new_v4( ) ) ,
236+ reduce_only : args. reduce ,
214237 } ;
215238
216239 let params_json = serde_json:: to_string ( & order_request) ?;
0 commit comments