diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 42e0027..d827568 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -136,11 +136,21 @@ inline double tv_money_round(double value) { return value < 0.0 ? -rounded : rounded; } // The broker's lot floor on ten-digit money (rule 1): the RAW double floor -// of qty / step, no representation nudge — the nudge would promote the -// 918062.29999999992 quotient above to 918062.30, one lot more than TV. +// of the scaled quantity, no representation nudge — a nudge would promote +// the 918062.29999999992 quotient above to918062.30, one lot more than TV. +// R22 cent-lot pins: division by binary64(0.01) can lose a grid point that +// is exactly representable by qty. Try the *100 candidate only when its +// reconstructed grid quantity does not exceed the input. The 1.169 pin +// keeps8595.81; the older 1.085 pin still floors918062.29999999993 to918062.29 +// because the next grid point is above that input. Other scales stay unchanged. inline double tv_money_floor_lot(double qty, double step) { if (!(step > 0.0) || !std::isfinite(qty) || qty <= 0.0) return qty; - const double floored = std::floor(qty / step) * step; + double floored = std::floor(qty / step) * step; + if (step == 0.01) { + const double cent_candidate = std::floor(qty * 100.0) * step; + if (cent_candidate > floored && cent_candidate <= qty) + floored = cent_candidate; + } return floored < qty ? floored : qty; } @@ -2474,11 +2484,11 @@ class BacktestEngine { // Reject (qty 0) on a non-finite / non-positive fill price — a // degenerate $0/NaN print must NOT size as the raw % number. if (!(std::isfinite(basis) && basis > 0)) return 0.0; - // round 8 family R, rule 1 (tv_money_floor_lot above): on - // ten-digit money the broker's lot floor is the raw double - // floor of sig10(E) / tick(close) — the 1e-6 nudge of - // apply_qty_step would hand the everybar 1.085 placement one - // lot more than TradingView filled. + // Rule 1 floors the rounded-equity quantity onto the lot + // grid via tv_money_floor_lot, including its guarded recovery + // of a representable cent-lot point. No representation nudge: + // apply_qty_step's epsilon would still over-size the pinned + // everybar 1.085 placement by one lot. if (tv_money_lot_sizing()) { return tv_money_floor_lot(cash / (basis * syminfo_.pointvalue), qty_step_); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1ff8169..6fe391d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -139,6 +139,7 @@ set(TEST_SOURCES test_fams_same_bar_market_tx test_default_flat_market_gross_admission test_tv_money_precision + test_cent_lot_decimal_scale test_tv_money_band test_famag_close_survives test_close_percent_calltime_basis diff --git a/tests/test_cent_lot_decimal_scale.cpp b/tests/test_cent_lot_decimal_scale.cpp new file mode 100644 index 0000000..e3acaf6 --- /dev/null +++ b/tests/test_cent_lot_decimal_scale.cpp @@ -0,0 +1,67 @@ +// R22 covered TV cent-lot controls: at E10048.501888699982 and price1.169, +// default_entry_qty and actual margin50 fills use8595.81. One step less in +// capital uses8595.80. The raw quotient can multiply by100 to an integer +// while division bybinary64(0.01) lands one ULP below it. No epsilon is added. +#include +#include +#include +#include +#include +using namespace pineforge; +namespace { +constexpr double qnan=std::numeric_limits::quiet_NaN(); +int passed=0,failed=0; +#define CHECK(x) do{if(x)++passed;else{++failed;std::printf("FAIL %d %s\n",__LINE__,#x);}}while(0) +bool near(double a,double b){return std::abs(a-b)<1e-8;} +double tick(double x){return std::floor(x/0.00001+0.5)*0.00001;} +void test_recorded_quantities(){ + const double e=10048.501888699982; + CHECK(near(tv_money_floor_lot(tv_money_round(e)/tick(1.169),0.01),8595.81)); + CHECK(near(tv_money_floor_lot(tv_money_round(e-0.0001)/tick(1.169),0.01),8595.80)); + CHECK(near(tv_money_floor_lot(tv_money_round(e+0.0001)/tick(1.169),0.01),8595.81)); + const double q=tv_money_round(996097.5955029)/tick(1.085); + CHECK(near(tv_money_floor_lot(q,0.01),918062.29)); // existing TV pin + CHECK(std::floor(q/0.01+1e-6)*0.01>tv_money_floor_lot(q,0.01)); + const double grid=859581.0*0.01; + CHECK(tv_money_floor_lot(grid,0.01)==grid); + CHECK(near(tv_money_floor_lot(std::nextafter(grid,0.0),0.01),8595.80)); + CHECK(tv_money_floor_lot(std::nextafter(grid,INFINITY),0.01)==grid); + for(double step:{0.0,0.00001,0.0001,0.02,0.1,1.0}){ + const double x=8595.809999999998; + const double expected=step>0?std::min(std::floor(x/step)*step,x):x; + CHECK(tv_money_floor_lot(x,step)==expected); + } +} +class Reversal : public BacktestEngine { +public: + int side_after=-1;double frozen=qnan; + explicit Reversal(double extra){ + initial_capital_=10029.333566899983+extra; + default_qty_type_=QtyType::PERCENT_OF_EQUITY;default_qty_value_=100; + qty_step_=0.01;syminfo_mintick_=0.00001;commission_value_=0;slippage_=0; + } + void on_bar(const Bar&) override { + if(bar_index_==0)strategy_entry("L",true,qnan,qnan,8595.66); + if(bar_index_==1){ + strategy_entry("S",false); + for(const auto&o:pending_orders_)if(o.id=="S")frozen=o.frozen_default_qty; + } + if(bar_index_==2){side_after=static_cast(position_side_);strategy_close_all();} + } + int exits_at_reversal()const{int n=0;for(const auto&t:trades_)if(t.exit_time==3000)++n;return n;} +}; +void test_close_only_band(){ + const std::vectorb={ + {1.16677,1.16677,1.16677,1.16677,1,1000}, + {1.16677,1.16918,1.16677,1.169,1,2000}, + {1.16901,1.16911,1.16894,1.169,1,3000}, + {1.16884,1.16884,1.16884,1.16884,1,4000}}; + for(double extra:{-0.0001,0.0,0.0001,0.001}){ + Reversal r(extra);r.run(b.data(),static_cast(b.size())); + CHECK(r.side_after==static_cast(extra==0?PositionSide::FLAT:PositionSide::LONG)); + CHECK(r.exits_at_reversal()==(extra==0?1:0)); + CHECK(near(r.frozen,extra<0?8595.8:8595.81)); + } +} +} +int main(){test_recorded_quantities();test_close_only_band();std::printf("%d passed, %d failed\n",passed,failed);return failed?1:0;}