//+------------------------------------------------------------------+
//|                                              SymbolProfile.mqh   |
//|                              Copyright 2026, Jorge Eliecer       |
//|  Symbol-relative trading math: price and volume rounding,        |
//|  broker distance floors and money per lot without assuming       |
//|  the quote currency or the contract size of the symbol.          |
//+------------------------------------------------------------------+
#ifndef PORTABLE_TRADE_SYMBOL_PROFILE_MQH
#define PORTABLE_TRADE_SYMBOL_PROFILE_MQH

//+------------------------------------------------------------------+
//| Static and semi-static properties of one trading symbol          |
//+------------------------------------------------------------------+
class CSymbolProfile
  {
private:
   string            m_symbol;
   double            m_point;
   double            m_tick_size;
   int               m_digits;
   double            m_vol_min;
   double            m_vol_max;
   double            m_vol_step;
   double            m_vol_limit;
   int               m_vol_digits;

public:
                     CSymbolProfile(void);
   bool              Init(const string symbol);
   //--- accessors
   string            Name(void)       const { return(m_symbol);    }
   double            Point(void)      const { return(m_point);     }
   double            TickSize(void)   const { return(m_tick_size); }
   double            VolumeMin(void)  const { return(m_vol_min);   }
   //--- rounding
   double            RoundPrice(const double price) const;
   double            RoundAway(const double price, const bool up) const;
   double            FloorVolume(const double lots) const;
   //--- broker constraints
   double            MinStopDistance(const double spread_mult, const double buffer) const;
   double            FreezeDistance(void) const;
   double            VolumeLimitRoom(const ENUM_ORDER_TYPE type) const;
   bool              DirectionAllowed(const ENUM_ORDER_TYPE type) const;
   bool              SessionOpen(const int margin_sec) const;
   //--- money
   bool              LossPerLot(const ENUM_ORDER_TYPE type, const double entry,
                                const double sl, double &loss) const;
  };
//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CSymbolProfile::CSymbolProfile(void) : m_symbol(""),
                                       m_point(0.0),
                                       m_tick_size(0.0),
                                       m_digits(0),
                                       m_vol_min(0.0),
                                       m_vol_max(0.0),
                                       m_vol_step(0.0),
                                       m_vol_limit(0.0),
                                       m_vol_digits(2)
  {
  }
//+------------------------------------------------------------------+
//| Reads the specification once; returns false on unusable data     |
//+------------------------------------------------------------------+
bool CSymbolProfile::Init(const string symbol)
  {
   m_symbol=symbol;
   if(!SymbolSelect(m_symbol,true))
     {
      PrintFormat("SymbolProfile: cannot select %s, error %d",m_symbol,GetLastError());
      return(false);
     }
   m_point    =SymbolInfoDouble(m_symbol,SYMBOL_POINT);
   m_tick_size=SymbolInfoDouble(m_symbol,SYMBOL_TRADE_TICK_SIZE);
   m_digits   =(int)SymbolInfoInteger(m_symbol,SYMBOL_DIGITS);
   m_vol_min  =SymbolInfoDouble(m_symbol,SYMBOL_VOLUME_MIN);
   m_vol_max  =SymbolInfoDouble(m_symbol,SYMBOL_VOLUME_MAX);
   m_vol_step =SymbolInfoDouble(m_symbol,SYMBOL_VOLUME_STEP);
   m_vol_limit=SymbolInfoDouble(m_symbol,SYMBOL_VOLUME_LIMIT);
//--- some symbols publish a zero tick size; the point is the safe fallback
   if(m_tick_size<=0.0)
      m_tick_size=m_point;
//--- every later division depends on these two values
   if(m_tick_size<=0.0 || m_vol_step<=0.0 || m_vol_min<=0.0)
     {
      PrintFormat("SymbolProfile: invalid specification for %s (tick=%.10f step=%.4f min=%.4f)",
                  m_symbol,m_tick_size,m_vol_step,m_vol_min);
      return(false);
     }
//--- decimals of the volume step: 0.01 -> 2, 0.1 -> 1, 1 -> 0
   m_vol_digits=(int)MathMax(0.0,MathCeil(-MathLog10(m_vol_step)-1e-9));
   return(true);
  }
//+------------------------------------------------------------------+
//| Nearest valid price (multiple of the tick size)                  |
//+------------------------------------------------------------------+
double CSymbolProfile::RoundPrice(const double price) const
  {
   return(NormalizeDouble(MathRound(price/m_tick_size)*m_tick_size,m_digits));
  }
//+------------------------------------------------------------------+
//| Rounds to the tick size moving away from the market.             |
//| up=true rounds upward. Used for stops, so rounding never brings  |
//| a level closer than the distance that was already validated.    |
//+------------------------------------------------------------------+
double CSymbolProfile::RoundAway(const double price,const bool up) const
  {
   double ticks=price/m_tick_size;
//--- the epsilon absorbs representation errors such as 109999.99999998
   ticks=up ? MathCeil(ticks-1e-8) : MathFloor(ticks+1e-8);
   return(NormalizeDouble(ticks*m_tick_size,m_digits));
  }
//+------------------------------------------------------------------+
//| Rounds a volume down to the step and caps it at the maximum.     |
//| Rounding down keeps the real risk at or below the requested one. |
//+------------------------------------------------------------------+
double CSymbolProfile::FloorVolume(const double lots) const
  {
   if(lots<=0.0)
      return(0.0);
   double steps=MathFloor(lots/m_vol_step+1e-8);
   double value=MathMin(steps*m_vol_step,m_vol_max);
   return(NormalizeDouble(value,m_vol_digits));
  }
//+------------------------------------------------------------------+
//| Smallest SL/TP distance the server will accept, in price units.  |
//| It is measured from the closing price (Bid for buys, Ask for     |
//| sells). A stops level of zero means a floating level, so the     |
//| current spread times spread_mult is used as a floor instead.     |
//+------------------------------------------------------------------+
double CSymbolProfile::MinStopDistance(const double spread_mult,const double buffer) const
  {
   long   stops =SymbolInfoInteger(m_symbol,SYMBOL_TRADE_STOPS_LEVEL);
   long   freeze=SymbolInfoInteger(m_symbol,SYMBOL_TRADE_FREEZE_LEVEL);
   double spread=0.0;
   MqlTick tick;
   if(SymbolInfoTick(m_symbol,tick) && tick.ask>tick.bid)
      spread=tick.ask-tick.bid;
   else
      spread=(double)SymbolInfoInteger(m_symbol,SYMBOL_SPREAD)*m_point;
//--- the freeze level also matters: a stop placed inside it can never be moved later
   double broker  =(double)MathMax(stops,freeze)*m_point;
   double floating=spread*spread_mult;
   double dist    =MathMax(MathMax(broker,floating),m_tick_size);
   return(dist*(1.0+buffer));
  }
//+------------------------------------------------------------------+
//| Freeze distance in price units (zero when the broker has none)   |
//+------------------------------------------------------------------+
double CSymbolProfile::FreezeDistance(void) const
  {
   return((double)SymbolInfoInteger(m_symbol,SYMBOL_TRADE_FREEZE_LEVEL)*m_point);
  }
//+------------------------------------------------------------------+
//| Volume still available under SYMBOL_VOLUME_LIMIT in one          |
//| direction (positions plus pending orders of that direction)      |
//+------------------------------------------------------------------+
double CSymbolProfile::VolumeLimitRoom(const ENUM_ORDER_TYPE type) const
  {
   if(m_vol_limit<=0.0)
      return(DBL_MAX);
   const bool is_buy=(type==ORDER_TYPE_BUY);
   double used=0.0;
//--- open positions
   for(int i=PositionsTotal()-1; i>=0; i--)
     {
      if(PositionGetTicket(i)==0)
         continue;
      if(PositionGetString(POSITION_SYMBOL)!=m_symbol)
         continue;
      bool pos_buy=(PositionGetInteger(POSITION_TYPE)==POSITION_TYPE_BUY);
      if(pos_buy==is_buy)
         used+=PositionGetDouble(POSITION_VOLUME);
     }
//--- pending orders
   for(int i=OrdersTotal()-1; i>=0; i--)
     {
      if(OrderGetTicket(i)==0)
         continue;
      if(OrderGetString(ORDER_SYMBOL)!=m_symbol)
         continue;
      ENUM_ORDER_TYPE ot=(ENUM_ORDER_TYPE)OrderGetInteger(ORDER_TYPE);
      bool ord_buy=(ot==ORDER_TYPE_BUY_LIMIT || ot==ORDER_TYPE_BUY_STOP || ot==ORDER_TYPE_BUY_STOP_LIMIT);
      if(ord_buy==is_buy)
         used+=OrderGetDouble(ORDER_VOLUME_CURRENT);
     }
   return(MathMax(0.0,m_vol_limit-used));
  }
//+------------------------------------------------------------------+
//| Trade mode of the symbol allows a new deal in this direction     |
//+------------------------------------------------------------------+
bool CSymbolProfile::DirectionAllowed(const ENUM_ORDER_TYPE type) const
  {
   ENUM_SYMBOL_TRADE_MODE mode=(ENUM_SYMBOL_TRADE_MODE)SymbolInfoInteger(m_symbol,SYMBOL_TRADE_MODE);
   switch(mode)
     {
      case SYMBOL_TRADE_MODE_FULL:
         return(true);
      case SYMBOL_TRADE_MODE_LONGONLY:
         return(type==ORDER_TYPE_BUY);
      case SYMBOL_TRADE_MODE_SHORTONLY:
         return(type==ORDER_TYPE_SELL);
      default:
         return(false);
     }
  }
//+------------------------------------------------------------------+
//| True when the server time falls inside a trade session of the    |
//| symbol, at least margin_sec away from its start and its end.     |
//| OrderCheck does not look at sessions, so without this test a     |
//| bar that opens at 23:30 on Friday ends in retcode 10018.         |
//+------------------------------------------------------------------+
bool CSymbolProfile::SessionOpen(const int margin_sec) const
  {
   MqlDateTime now;
   TimeToStruct(TimeCurrent(),now);
   const int secs=now.hour*3600+now.min*60+now.sec;
   datetime from=0,to=0;
   uint     index=0;
   while(SymbolInfoSessionTrade(m_symbol,(ENUM_DAY_OF_WEEK)now.day_of_week,index,from,to))
     {
      //--- session bounds are seconds from the start of the day (to may be 86400)
      if(secs>=(int)from+margin_sec && secs<(int)to-margin_sec)
         return(true);
      index++;
     }
//--- a symbol without any published session is treated as always open
   if(index==0)
     {
      bool any=false;
      for(int d=0; d<7 && !any; d++)
         any=SymbolInfoSessionTrade(m_symbol,(ENUM_DAY_OF_WEEK)d,0,from,to);
      return(!any);
     }
   return(false);
  }
//+------------------------------------------------------------------+
//| Money lost by 1.0 lot if the stop is hit, in account currency.   |
//| OrderCalcProfit already converts quote and margin currencies,    |
//| which is what makes the same code valid on EURUSD and XAUUSD.    |
//+------------------------------------------------------------------+
bool CSymbolProfile::LossPerLot(const ENUM_ORDER_TYPE type,const double entry,
                                const double sl,double &loss) const
  {
   loss=0.0;
   double profit=0.0;
   if(OrderCalcProfit(type,m_symbol,1.0,entry,sl,profit) && profit<0.0)
     {
      loss=-profit;
      return(true);
     }
//--- fallback through the tick value when the terminal cannot convert
   double tick_value=SymbolInfoDouble(m_symbol,SYMBOL_TRADE_TICK_VALUE_LOSS);
   if(tick_value<=0.0)
      tick_value=SymbolInfoDouble(m_symbol,SYMBOL_TRADE_TICK_VALUE);
   if(tick_value<=0.0)
      return(false);
   loss=MathAbs(entry-sl)/m_tick_size*tick_value;
   return(loss>0.0);
  }

#endif // PORTABLE_TRADE_SYMBOL_PROFILE_MQH
//+------------------------------------------------------------------+
