//+------------------------------------------------------------------+
//|                                                    moex2mql5.mqh |
//|                                    Copyright (c) 2025, Marketeer |
//|                           https://www.mql5.com/ru/articles/16964 |
//+------------------------------------------------------------------+
#include "moexutils.mqh"
#include <MQL5Book/EnumToArray.mqh>

namespace Moex
{

//+------------------------------------------------------------------+
//|                                                                  |
//|                      Auxiliary functions                         |
//|                                                                  |
//+------------------------------------------------------------------+

// convert rows of columns into objects with properties
void ColumnsToProperties(JsValue *obj, const int max = INT_MAX)
{
   for(int i = 0; i < obj.size(); i++)
   {
      const int c = obj[i]["columns"].length();
      string titles[];
      ArrayResize(titles, c);
      for(int j = 0; j < c; j++)
      {
         string s = obj[i]["columns"][j].s;
         StringToLower(s);
         titles[j] = s;
      }
      
      const int n = fmin(obj[i]["data"].length(), max);
      for(int k = 0; k < n; k++)
      {
         JsValue *index = new JsValue();
         obj[i].put((string)k, index);
         
         for(int j = 0; j < c; j++)
         {
            index.put(titles[j], obj[i]["data"][k][j]);
         }
      }
   }
}

// expand data from 2 specific columns (name, payload) into "key=value" map
JsValue *MapSectionData(const JsValue *box, const string name, const string payload, const bool lower = false)
{
   const JsValue *data = box["data"];
   if(data.t == JS_NULL) return (JsValue *)&JsValue::null;
   const int key = box["columns"].indexOf(name);
   const int value = box["columns"].indexOf(payload);
   if(value < 0 || key < 0) return (JsValue *)&JsValue::null;
   JsValue *obj = new JsValue();
   for(int i = 0; i < data.length(); i++)
   {
      string s = data[i][key].s;
      if(lower) StringToLower(s);
      obj.put(s, data[i][value].s);
   }
   return obj;
}

// create a named property with array per each column
void Transpose(JsValue *obj)
{
   for(int i = 0; i < obj.size(); i++)
   {
      for(int j = 0; j < obj[i]["columns"].length(); j++)
      {
         string s = obj[i]["columns"][j].s;
         StringToLower(s);

         JsValue *index = new JsValue(JS_ARRAY);
         obj[i].put(s, index);
         for(int k = 0; k < obj[i]["data"].length(); k++)
         {
            index.put(obj[i]["data"][k][j]);
         }
      }
   }
}

// find market and engine for given board and fill in their names as strings
template<typename B, typename M, typename E>
bool ResolveMoexNames(B b, M m, E e, string &market, string &engine, const bool autocorrection = false)
{
   m = Moex::GetRelation(autocorrection ? no_markets : m, b);
   e = Moex::GetRelation(autocorrection ? no_engines : e, m);
   if(!m || !e)
   {
      return false;
   }
   market = EnumToMOEXString(m);
   engine = EnumToMOEXString(e);
   return true;
}

//+------------------------------------------------------------------+
//|                                                                  |
//|                            Timeframes                            |
//|                                                                  |
//+------------------------------------------------------------------+

ENUM_TIMEFRAMES Interval2Timeframe(const int interval, const bool verbose = false)
{
   switch(interval)
   {
   case 1: return PERIOD_M1;
   case 10: return PERIOD_M10;
   case 60: return PERIOD_H1;
   case 24: return PERIOD_D1;
   case 7: return PERIOD_W1;
   case 31: return PERIOD_MN1;
   case 4: // platform specific
      if(verbose) PrintFormat("Unsupported timeframe quarterly/Q4");
      return -1;
   default: // error
      PrintFormat("Unknown interval %d", interval);
      return -1;
   }
   return 0;
}

//+------------------------------------------------------------------+
//|                                                                  |
//|           Custom symbol creation according to ISS specs          |
//|                                                                  |
//+------------------------------------------------------------------+

bool CreateCustomSymbol(JsValue *spec, const string symbol, const string market, const string engine, const string currency = NULL, const string about = NULL)
{
   if(spec["securities"]["data"].length() > 1) // this is a list of boards, need to choose one (primary)
   {
      Alert("Choose specific board from the list in the log");
      spec["securities"]["data"].print();
      return false;
   }
   
   ColumnsToProperties(spec);
   
   int d = spec["securities"]["0"]["decimals"].get<int>(true, -1);
   if(d == -1) return false;

   double tick = spec["securities"]["0"]["minstep"].get<double>(true, MathPow(10, -d));
   double stepprice = spec["securities"]["0"]["stepprice"].get<double>(true);
   
   int l = spec["securities"]["0"]["lotvolume"].get<int>(true, -1);
   if(l == -1) l = spec["securities"]["0"]["lotsize"].get<int>(true, -1);
   if(l == -1)
   {
      Print("No lotsize, defaults to 1");
      l = 1; // hack
   }

   int calcmode = (int)SymbolInfoInteger(_Symbol, SYMBOL_TRADE_CALC_MODE);
   int elem[], max = 0, best = -1;
   const int n = EnumToArray<ENUM_SYMBOL_CALC_MODE>(elem, 0, 100);
   for(int i = 0; i < n; ++i)
   {
     string c = EnumToString((ENUM_SYMBOL_CALC_MODE)elem[i]);
     StringToLower(c);
     int mark = (StringFind(c, market) >= 0) + (StringFind(c, engine) >= 0);
     if(mark > max)
     {
       max = mark;
       best = i;
     }
     
   }
   if(best != -1)
   {
      calcmode = elem[best];
   }
   else
   {
      Print("No Calcmode, defaults to current: ", EnumToString((ENUM_SYMBOL_CALC_MODE)calcmode));
   }
   
   // use "SHORTNAME" and "SECNAME" for description
   string desc = StringLen(about) ? about : spec["securities"]["0"]["shortname"].s + "," + spec["securities"]["0"]["secname"].s;
   
   string profit = spec["securities"]["0"]["currencyid"].get<string>(true);
   if(profit == "SUR") profit = "RUB";
   
   datetime start = 0, stop = 0;
   string basis;
   string unit, words[];
   double coef = 1.0;
   if(spec["description"].t != JS_NULL)
   {
      JsValue *props = MapSectionData(spec["description"], "name", "value", true);
      // these columns ("FRSTTRADE", "LSTTRADE", "CONTRACTNAME") are present in 'description' section of iss/securities/XYZ
      start = StringToTime(props["frsttrade"].s);
      stop = StringToTime(props["lsttrade"].s);
      basis = props["contractname"].s;
      unit = props["unit"].s;
      if(StringLen(unit)) // can be specific wording, e.g. "RUB per 10 lots", "USD per 1 MMBtu"
      {
         if(StringSplit(unit, ' ', words) >= 3)
         {
            profit = words[0];
            coef = StringToDouble(words[2]); // NB: no info about if/how to apply coef?
            if(!coef) coef = 1.0;
         }
      }
      delete props;
   }
   else
   {
      // the column "LASTTRADEDATE" is present in the section 'securities' from /iss/engines/futures/markets/forts.json and .../forts/securities/XYZ
      stop = StringToTime(spec["securities"]["0"]["lasttradedate"].s);
   }

   if(!StringLen(profit))
   {
      profit = StringLen(currency) ? currency : "RUB";
   }
   
   // create new custom symbol
   if(CustomSymbolCreate(symbol, "MOEXISS\\" + engine + "\\" + market, NULL))
   {
      CustomSymbolSetInteger(symbol, SYMBOL_TRADE_CALC_MODE, calcmode);
      CustomSymbolSetInteger(symbol, SYMBOL_DIGITS, d);
      CustomSymbolSetDouble(symbol, SYMBOL_VOLUME_MAX, 1000); // hack
      CustomSymbolSetDouble(symbol, SYMBOL_VOLUME_MIN, 1);
      CustomSymbolSetDouble(symbol, SYMBOL_VOLUME_STEP, 1);
      // automatic by tick size and value, otherwise could be:
      // : CustomSymbolSetDouble(symbol, SYMBOL_POINT, MathPow(1, -decimals));
      CustomSymbolSetDouble(symbol, SYMBOL_TRADE_TICK_SIZE, tick);
      CustomSymbolSetDouble(symbol, SYMBOL_TRADE_TICK_VALUE, stepprice ? stepprice : l * tick);
      CustomSymbolSetDouble(symbol, SYMBOL_TRADE_CONTRACT_SIZE, l);
      CustomSymbolSetString(symbol, SYMBOL_DESCRIPTION, desc);
      CustomSymbolSetString(symbol, SYMBOL_CURRENCY_PROFIT, profit);
      CustomSymbolSetString(symbol, SYMBOL_CURRENCY_MARGIN, profit);
      CustomSymbolSetInteger(symbol, SYMBOL_CHART_MODE, SYMBOL_CHART_MODE_LAST);
      if(start) CustomSymbolSetInteger(symbol, SYMBOL_START_TIME, start);
      if(stop) CustomSymbolSetInteger(symbol, SYMBOL_EXPIRATION_TIME, stop);
      if(StringLen(basis)) CustomSymbolSetString(symbol, SYMBOL_BASIS, basis);
      
      SymbolSelect(symbol, true);
      
      return true;
   }
   return false;
}

//+------------------------------------------------------------------+
//|                                                                  |
//|      General helper for checking required set of columns         |
//|      (column set is defined by enum as type parameter E)         |
//+------------------------------------------------------------------+

interface ColumnVerificatorIntf
{
   int operator[](int e) const;
   bool verifyColumns(JsValue *data, const string block);
   bool isVerified() const;
   void reset();
};

template<typename E>
class ColumnVerificator: public ColumnVerificatorIntf
{
   string columns[];
   int indices[];
   bool verified;
   int minimal; // number of columns to match in response
   
public:
   ColumnVerificator(const int min = -1): minimal(min), verified(false)
   {
      for(int i = 0; ; i++)
      {
         string name = EnumToString((E)i);
         StringToLower(name);
         // StringReplace(name, "_", ""); // review for columns with reserved names
         if(StringFind(name, "::") > -1) break; // end of enum
         PUSH(columns, name);
      }
   }
   
   int operator[](int e) const
   {
      if(e >= 0 && e < ArraySize(indices))
         return indices[e];
      DebugBreak();
      return -1;
   }
   
   bool verifyColumns(JsValue *data, const string block)
   {
      if(verified) return true;
      
      ArrayResize(indices, ArraySize(columns));
      int failed = 0;

      // normalize to lowercase always
      AutoPtr<JsValue> small = JsValue::toUniformCase(data[block]["columns"]);

      for(int i = 0; i < ArraySize(columns); i++)
      {
         indices[i] = small[].indexOf(columns[i]);
         if(indices[i] == -1) failed++;
      }
      
      // exact limit of matches or more than a half of columns must be present
      if(failed > (minimal > -1 ? minimal : ArraySize(columns) / 2))
      {
         string buffer;
         data[block]["columns"].stringify(buffer);
         PrintFormat("Unexpected column set for '%s'\nGot: %s", block, buffer);
         Print("Expected:");
         ArrayPrint(columns);
         verified = false;
         return false;
      }
      verified = true;
      return true;
   }
   
   bool isVerified() const
   {
      return verified;
   }
   
   void reset()
   {
      verified = false;
   }
};

//+------------------------------------------------------------------+
//|                                                                  |
//|        candleborders (history depth per timeframe)               |
//|                                                                  |
//+------------------------------------------------------------------+

struct HistoryRange
{
   datetime begin;
   datetime end;
   ENUM_TIMEFRAMES timeframe;
};

bool GetCandleborders(JsValue *data, HistoryRange &history[], const bool reverify = false)
{
   const static string main = "borders";
   
   if(data[main].t == JS_OBJECT)
   {
      // default columns hardly can change, but
      // lets verify once per session as static or upon request via parameter
      enum Columns { begin, end, interval };
      static ColumnVerificator<Columns> cf;

      if(reverify) cf.reset();
      if(!cf.verifyColumns(data, main)) return false;
      
      JsValue *section = data[main]["data"];
      const int n = section.size();
      if(!n) return true;
      ArrayResize(history, n);
      for(int i = 0; i < n; i++)
      {
         const JsValue *row = section[i];
         HistoryRange range =
         {
            StringToTime(row[cf[begin]].s),
            StringToTime(row[cf[end]].s),
            Interval2Timeframe(row[cf[interval]].get<int>())
         };
         
         history[i] = range;
      }
      return true;
   }
   
   return false;
}

//+------------------------------------------------------------------+
//|                                                                  |
//|                        candles (MqlRates)                        |
//|                                                                  |
//+------------------------------------------------------------------+

bool GetRates(JsValue *data, MqlRates &rates[], const bool reverify = false)
{
   const static string main = "candles";
   
   if(data[main].t == JS_OBJECT)
   {
      enum Columns { open, close, high, low, value, volume, begin, end };
      static ColumnVerificator<Columns> cf;
      
      if(reverify) cf.reset();
      if(!cf.verifyColumns(data, main)) return false;
      
      JsValue *section = data[main]["data"];
      const int n = section.size();
      if(!n) return true; // empty data is not an error of the converter
      ArrayResize(rates, n);
      for(int i = 0; i < n; i++)
      {
         const JsValue *row = section[i];
         MqlRates r =
         {
            StringToTime(row[cf[begin]].s),
            StringToDouble(row[cf[open]].s),
            StringToDouble(row[cf[high]].s),
            StringToDouble(row[cf[low]].s),
            StringToDouble(row[cf[close]].s),
            StringToInteger(row[cf[volume]].s),
            0, // spread
            StringToInteger(row[cf[value]].s),
         };
         
         rates[i] = r;
      }
      return true;
   }
   
   return false;
}

//+------------------------------------------------------------------+
//|                                                                  |
//|                    orderbooks (MqlBookInfo[])                    |
//|     NB: not tested, can contain many symbols if requested        |
//+------------------------------------------------------------------+

bool GetOrderbook(JsValue *data, MqlBookInfo &book[], const bool reverify = false)
{
   const static string main = "orderbook";
   
   if(data[main].t == JS_OBJECT)
   {
      enum Columns { boardid, secid, buysell, price, quantity, seqnum, updatetime, decimals };
      static ColumnVerificator<Columns> cf;
      
      if(reverify) cf.reset();
      if(!cf.verifyColumns(data, main)) return false;
      
      JsValue *section = data[main]["data"];
      const int n = section.size();
      if(!n) return true;
      ArrayResize(book, n);
      ArraySetAsSeries(book, true); // ISS sorts prices increasingly, MQL5 books decreasingly
      const string symbol = section[0][cf[secid]].s;
      for(int i = 0; i < n; i++)
      {
         const JsValue *row = section[i];
         const string s = row[cf[secid]].s;
         if(s != symbol)
         {
            WARN_ONCE("Multisymbol orderbook detected (%s vs %s), not supported", symbol, s);
            ZeroMemory(book[i]);
         }
         else
         {
            MqlBookInfo b =
            {
               row[cf[buysell]].s == "B" ? BOOK_TYPE_BUY : BOOK_TYPE_SELL,
               row[cf[price]].get<double>(),
               row[cf[quantity]].get<long>(),
               row[cf[quantity]].get<double>()
            };
            
            book[i] = b;
         }
      }
      ArraySetAsSeries(book, false);
      return true;
   }
   
   return false;
}

//+------------------------------------------------------------------+
//|                                                                  |
//|                         trades (MqlTicks)                        |
//|                                                                  |
//+------------------------------------------------------------------+
// NB: different markets have different column sets, this is why we use type parametrization
// stocks: tradeno, tradetime, boardid, secid, price, quantity, value, period, tradetime_grp, systime, buysell, decimals, tradingsession
// futures: tradeno, boardname, secid, tradedate, tradetime, price, quantity, systime, recno, openposition, offmarketdeal, buysell
// NB: recno supersedes tradeno

// we check only those columns we need, some columns may be missing
// this is a default, combined set of columns
enum TradesColumns { tradeno, recno, tradetime, tradedate, boardid, boardname, secid, price, quantity, value, period, tradetime_grp, systime, buysell, decimals, tradingsession, openposition, offmarketdeal };

template<typename E>
class Trades2Ticks
{
   ColumnVerificator<E> cv;
   
public:
   int operator[](int e)
   {
      return cv[e];
   }
   
   bool GetTicks(JsValue *data, MqlTick &ticks[], const bool reverify = false)
   {
      const static string main = "trades";
      
      if(data[main].t == JS_OBJECT)
      {
         if(reverify) cv.reset();
         if(!cv.verifyColumns(data, main)) return false;
         
         JsValue *section = data[main]["data"];
         const int n = section.size();
         if(!n) return true;
         ArrayResize(ticks, n);
         for(int i = 0; i < n; i++)
         {
            const JsValue *row = section[i];
            const datetime time = StringToTime(row[cv[E::systime]].s);
            const double price = StringToDouble(row[cv[E::price]].s);
            const uint flags = TICK_FLAG_BID | TICK_FLAG_ASK | TICK_FLAG_LAST | TICK_FLAG_VOLUME |
               (row[cv[E::buysell]].s == "B" || row[cv[E::buysell]].s == "b" ? TICK_FLAG_BUY : TICK_FLAG_SELL);
            
            MqlTick t =
            {
               time,
               price, price, price,
               StringToInteger(row[cv[E::quantity]].s),
               time * 1000,
               flags,
               StringToDouble(row[cv[E::quantity]].s)
            };
            
            ticks[i] = t;
         }
         return true;
      }
      
      return false;
   }
};

class Trades2TicksDefault: public Trades2Ticks<TradesColumns>
{
};

}
//+------------------------------------------------------------------+
