From 1b6297b1e28822fc9276c11d1a7a2f618b40e81e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 00:16:50 +0000 Subject: [PATCH] chore: Fix .gitignore - remove test file exclusions, add coverage folder - Removed incorrect exclusion of *.test.ts and *.test.js files - Added coverage/ folder to .gitignore - Removed accidentally committed coverage files Co-authored-by: mindesbunister <32161838+mindesbunister@users.noreply.github.com> --- .gitignore | 9 +- coverage/base.css | 224 ------------- coverage/block-navigation.js | 87 ----- coverage/favicon.png | Bin 445 -> 0 bytes coverage/index.html | 101 ------ coverage/prettify.css | 1 - coverage/prettify.js | 2 - coverage/sort-arrow-sprite.png | Bin 138 -> 0 bytes coverage/sorter.js | 210 ------------ .../position-manager/adx-runner-sl.test.ts | 200 ++++++++++++ .../position-manager/breakeven-sl.test.ts | 155 +++++++++ .../position-manager/decision-helpers.test.ts | 308 ++++++++++++++++++ .../position-manager/edge-cases.test.ts | 241 ++++++++++++++ .../price-verification.test.ts | 197 +++++++++++ .../position-manager/tp1-detection.test.ts | 177 ++++++++++ .../position-manager/trailing-stop.test.ts | 271 +++++++++++++++ 16 files changed, 1552 insertions(+), 631 deletions(-) delete mode 100644 coverage/base.css delete mode 100644 coverage/block-navigation.js delete mode 100644 coverage/favicon.png delete mode 100644 coverage/index.html delete mode 100644 coverage/prettify.css delete mode 100644 coverage/prettify.js delete mode 100644 coverage/sort-arrow-sprite.png delete mode 100644 coverage/sorter.js create mode 100644 tests/integration/position-manager/adx-runner-sl.test.ts create mode 100644 tests/integration/position-manager/breakeven-sl.test.ts create mode 100644 tests/integration/position-manager/decision-helpers.test.ts create mode 100644 tests/integration/position-manager/edge-cases.test.ts create mode 100644 tests/integration/position-manager/price-verification.test.ts create mode 100644 tests/integration/position-manager/tp1-detection.test.ts create mode 100644 tests/integration/position-manager/trailing-stop.test.ts diff --git a/.gitignore b/.gitignore index a5cad6e..95656d6 100644 --- a/.gitignore +++ b/.gitignore @@ -31,12 +31,6 @@ logs/ # Docker .dockerignore -# Test files -*.test.ts -*.test.js -test-*.ts -test-*.js - # Temporary files tmp/ temp/ @@ -45,3 +39,6 @@ temp/ # Build artifacts dist/ .backtester/ + +# Coverage reports +coverage/ diff --git a/coverage/base.css b/coverage/base.css deleted file mode 100644 index f418035..0000000 --- a/coverage/base.css +++ /dev/null @@ -1,224 +0,0 @@ -body, html { - margin:0; padding: 0; - height: 100%; -} -body { - font-family: Helvetica Neue, Helvetica, Arial; - font-size: 14px; - color:#333; -} -.small { font-size: 12px; } -*, *:after, *:before { - -webkit-box-sizing:border-box; - -moz-box-sizing:border-box; - box-sizing:border-box; - } -h1 { font-size: 20px; margin: 0;} -h2 { font-size: 14px; } -pre { - font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; - margin: 0; - padding: 0; - -moz-tab-size: 2; - -o-tab-size: 2; - tab-size: 2; -} -a { color:#0074D9; text-decoration:none; } -a:hover { text-decoration:underline; } -.strong { font-weight: bold; } -.space-top1 { padding: 10px 0 0 0; } -.pad2y { padding: 20px 0; } -.pad1y { padding: 10px 0; } -.pad2x { padding: 0 20px; } -.pad2 { padding: 20px; } -.pad1 { padding: 10px; } -.space-left2 { padding-left:55px; } -.space-right2 { padding-right:20px; } -.center { text-align:center; } -.clearfix { display:block; } -.clearfix:after { - content:''; - display:block; - height:0; - clear:both; - visibility:hidden; - } -.fl { float: left; } -@media only screen and (max-width:640px) { - .col3 { width:100%; max-width:100%; } - .hide-mobile { display:none!important; } -} - -.quiet { - color: #7f7f7f; - color: rgba(0,0,0,0.5); -} -.quiet a { opacity: 0.7; } - -.fraction { - font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; - font-size: 10px; - color: #555; - background: #E8E8E8; - padding: 4px 5px; - border-radius: 3px; - vertical-align: middle; -} - -div.path a:link, div.path a:visited { color: #333; } -table.coverage { - border-collapse: collapse; - margin: 10px 0 0 0; - padding: 0; -} - -table.coverage td { - margin: 0; - padding: 0; - vertical-align: top; -} -table.coverage td.line-count { - text-align: right; - padding: 0 5px 0 20px; -} -table.coverage td.line-coverage { - text-align: right; - padding-right: 10px; - min-width:20px; -} - -table.coverage td span.cline-any { - display: inline-block; - padding: 0 5px; - width: 100%; -} -.missing-if-branch { - display: inline-block; - margin-right: 5px; - border-radius: 3px; - position: relative; - padding: 0 4px; - background: #333; - color: yellow; -} - -.skip-if-branch { - display: none; - margin-right: 10px; - position: relative; - padding: 0 4px; - background: #ccc; - color: white; -} -.missing-if-branch .typ, .skip-if-branch .typ { - color: inherit !important; -} -.coverage-summary { - border-collapse: collapse; - width: 100%; -} -.coverage-summary tr { border-bottom: 1px solid #bbb; } -.keyline-all { border: 1px solid #ddd; } -.coverage-summary td, .coverage-summary th { padding: 10px; } -.coverage-summary tbody { border: 1px solid #bbb; } -.coverage-summary td { border-right: 1px solid #bbb; } -.coverage-summary td:last-child { border-right: none; } -.coverage-summary th { - text-align: left; - font-weight: normal; - white-space: nowrap; -} -.coverage-summary th.file { border-right: none !important; } -.coverage-summary th.pct { } -.coverage-summary th.pic, -.coverage-summary th.abs, -.coverage-summary td.pct, -.coverage-summary td.abs { text-align: right; } -.coverage-summary td.file { white-space: nowrap; } -.coverage-summary td.pic { min-width: 120px !important; } -.coverage-summary tfoot td { } - -.coverage-summary .sorter { - height: 10px; - width: 7px; - display: inline-block; - margin-left: 0.5em; - background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; -} -.coverage-summary .sorted .sorter { - background-position: 0 -20px; -} -.coverage-summary .sorted-desc .sorter { - background-position: 0 -10px; -} -.status-line { height: 10px; } -/* yellow */ -.cbranch-no { background: yellow !important; color: #111; } -/* dark red */ -.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } -.low .chart { border:1px solid #C21F39 } -.highlighted, -.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ - background: #C21F39 !important; -} -/* medium red */ -.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } -/* light red */ -.low, .cline-no { background:#FCE1E5 } -/* light green */ -.high, .cline-yes { background:rgb(230,245,208) } -/* medium green */ -.cstat-yes { background:rgb(161,215,106) } -/* dark green */ -.status-line.high, .high .cover-fill { background:rgb(77,146,33) } -.high .chart { border:1px solid rgb(77,146,33) } -/* dark yellow (gold) */ -.status-line.medium, .medium .cover-fill { background: #f9cd0b; } -.medium .chart { border:1px solid #f9cd0b; } -/* light yellow */ -.medium { background: #fff4c2; } - -.cstat-skip { background: #ddd; color: #111; } -.fstat-skip { background: #ddd; color: #111 !important; } -.cbranch-skip { background: #ddd !important; color: #111; } - -span.cline-neutral { background: #eaeaea; } - -.coverage-summary td.empty { - opacity: .5; - padding-top: 4px; - padding-bottom: 4px; - line-height: 1; - color: #888; -} - -.cover-fill, .cover-empty { - display:inline-block; - height: 12px; -} -.chart { - line-height: 0; -} -.cover-empty { - background: white; -} -.cover-full { - border-right: none !important; -} -pre.prettyprint { - border: none !important; - padding: 0 !important; - margin: 0 !important; -} -.com { color: #999 !important; } -.ignore-none { color: #999; font-weight: normal; } - -.wrapper { - min-height: 100%; - height: auto !important; - height: 100%; - margin: 0 auto -48px; -} -.footer, .push { - height: 48px; -} diff --git a/coverage/block-navigation.js b/coverage/block-navigation.js deleted file mode 100644 index 530d1ed..0000000 --- a/coverage/block-navigation.js +++ /dev/null @@ -1,87 +0,0 @@ -/* eslint-disable */ -var jumpToCode = (function init() { - // Classes of code we would like to highlight in the file view - var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; - - // Elements to highlight in the file listing view - var fileListingElements = ['td.pct.low']; - - // We don't want to select elements that are direct descendants of another match - var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` - - // Selector that finds elements on the page to which we can jump - var selector = - fileListingElements.join(', ') + - ', ' + - notSelector + - missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` - - // The NodeList of matching elements - var missingCoverageElements = document.querySelectorAll(selector); - - var currentIndex; - - function toggleClass(index) { - missingCoverageElements - .item(currentIndex) - .classList.remove('highlighted'); - missingCoverageElements.item(index).classList.add('highlighted'); - } - - function makeCurrent(index) { - toggleClass(index); - currentIndex = index; - missingCoverageElements.item(index).scrollIntoView({ - behavior: 'smooth', - block: 'center', - inline: 'center' - }); - } - - function goToPrevious() { - var nextIndex = 0; - if (typeof currentIndex !== 'number' || currentIndex === 0) { - nextIndex = missingCoverageElements.length - 1; - } else if (missingCoverageElements.length > 1) { - nextIndex = currentIndex - 1; - } - - makeCurrent(nextIndex); - } - - function goToNext() { - var nextIndex = 0; - - if ( - typeof currentIndex === 'number' && - currentIndex < missingCoverageElements.length - 1 - ) { - nextIndex = currentIndex + 1; - } - - makeCurrent(nextIndex); - } - - return function jump(event) { - if ( - document.getElementById('fileSearch') === document.activeElement && - document.activeElement != null - ) { - // if we're currently focused on the search input, we don't want to navigate - return; - } - - switch (event.which) { - case 78: // n - case 74: // j - goToNext(); - break; - case 66: // b - case 75: // k - case 80: // p - goToPrevious(); - break; - } - }; -})(); -window.addEventListener('keydown', jumpToCode); diff --git a/coverage/favicon.png b/coverage/favicon.png deleted file mode 100644 index c1525b811a167671e9de1fa78aab9f5c0b61cef7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 445 zcmV;u0Yd(XP))rP{nL}Ln%S7`m{0DjX9TLF* zFCb$4Oi7vyLOydb!7n&^ItCzb-%BoB`=x@N2jll2Nj`kauio%aw_@fe&*}LqlFT43 z8doAAe))z_%=P%v^@JHp3Hjhj^6*Kr_h|g_Gr?ZAa&y>wxHE99Gk>A)2MplWz2xdG zy8VD2J|Uf#EAw*bo5O*PO_}X2Tob{%bUoO2G~T`@%S6qPyc}VkhV}UifBuRk>%5v( z)x7B{I~z*k<7dv#5tC+m{km(D087J4O%+<<;K|qwefb6@GSX45wCK}Sn*> - - - - Code coverage report for All files - - - - - - - - - -
-
-

All files

-
- -
- Unknown% - Statements - 0/0 -
- - -
- Unknown% - Branches - 0/0 -
- - -
- Unknown% - Functions - 0/0 -
- - -
- Unknown% - Lines - 0/0 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-
- - - - - - - - - - - - - - - - -
FileStatementsBranchesFunctionsLines
-
-
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/prettify.css b/coverage/prettify.css deleted file mode 100644 index b317a7c..0000000 --- a/coverage/prettify.css +++ /dev/null @@ -1 +0,0 @@ -.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/coverage/prettify.js b/coverage/prettify.js deleted file mode 100644 index b322523..0000000 --- a/coverage/prettify.js +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-disable */ -window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/coverage/sort-arrow-sprite.png b/coverage/sort-arrow-sprite.png deleted file mode 100644 index 6ed68316eb3f65dec9063332d2f69bf3093bbfab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^>_9Bd!3HEZxJ@+%Qh}Z>jv*C{$p!i!8j}?a+@3A= zIAGwzjijN=FBi!|L1t?LM;Q;gkwn>2cAy-KV{dn nf0J1DIvEHQu*n~6U}x}qyky7vi4|9XhBJ7&`njxgN@xNA8m%nc diff --git a/coverage/sorter.js b/coverage/sorter.js deleted file mode 100644 index 4ed70ae..0000000 --- a/coverage/sorter.js +++ /dev/null @@ -1,210 +0,0 @@ -/* eslint-disable */ -var addSorting = (function() { - 'use strict'; - var cols, - currentSort = { - index: 0, - desc: false - }; - - // returns the summary table element - function getTable() { - return document.querySelector('.coverage-summary'); - } - // returns the thead element of the summary table - function getTableHeader() { - return getTable().querySelector('thead tr'); - } - // returns the tbody element of the summary table - function getTableBody() { - return getTable().querySelector('tbody'); - } - // returns the th element for nth column - function getNthColumn(n) { - return getTableHeader().querySelectorAll('th')[n]; - } - - function onFilterInput() { - const searchValue = document.getElementById('fileSearch').value; - const rows = document.getElementsByTagName('tbody')[0].children; - - // Try to create a RegExp from the searchValue. If it fails (invalid regex), - // it will be treated as a plain text search - let searchRegex; - try { - searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive - } catch (error) { - searchRegex = null; - } - - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - let isMatch = false; - - if (searchRegex) { - // If a valid regex was created, use it for matching - isMatch = searchRegex.test(row.textContent); - } else { - // Otherwise, fall back to the original plain text search - isMatch = row.textContent - .toLowerCase() - .includes(searchValue.toLowerCase()); - } - - row.style.display = isMatch ? '' : 'none'; - } - } - - // loads the search box - function addSearchBox() { - var template = document.getElementById('filterTemplate'); - var templateClone = template.content.cloneNode(true); - templateClone.getElementById('fileSearch').oninput = onFilterInput; - template.parentElement.appendChild(templateClone); - } - - // loads all columns - function loadColumns() { - var colNodes = getTableHeader().querySelectorAll('th'), - colNode, - cols = [], - col, - i; - - for (i = 0; i < colNodes.length; i += 1) { - colNode = colNodes[i]; - col = { - key: colNode.getAttribute('data-col'), - sortable: !colNode.getAttribute('data-nosort'), - type: colNode.getAttribute('data-type') || 'string' - }; - cols.push(col); - if (col.sortable) { - col.defaultDescSort = col.type === 'number'; - colNode.innerHTML = - colNode.innerHTML + ''; - } - } - return cols; - } - // attaches a data attribute to every tr element with an object - // of data values keyed by column name - function loadRowData(tableRow) { - var tableCols = tableRow.querySelectorAll('td'), - colNode, - col, - data = {}, - i, - val; - for (i = 0; i < tableCols.length; i += 1) { - colNode = tableCols[i]; - col = cols[i]; - val = colNode.getAttribute('data-value'); - if (col.type === 'number') { - val = Number(val); - } - data[col.key] = val; - } - return data; - } - // loads all row data - function loadData() { - var rows = getTableBody().querySelectorAll('tr'), - i; - - for (i = 0; i < rows.length; i += 1) { - rows[i].data = loadRowData(rows[i]); - } - } - // sorts the table using the data for the ith column - function sortByIndex(index, desc) { - var key = cols[index].key, - sorter = function(a, b) { - a = a.data[key]; - b = b.data[key]; - return a < b ? -1 : a > b ? 1 : 0; - }, - finalSorter = sorter, - tableBody = document.querySelector('.coverage-summary tbody'), - rowNodes = tableBody.querySelectorAll('tr'), - rows = [], - i; - - if (desc) { - finalSorter = function(a, b) { - return -1 * sorter(a, b); - }; - } - - for (i = 0; i < rowNodes.length; i += 1) { - rows.push(rowNodes[i]); - tableBody.removeChild(rowNodes[i]); - } - - rows.sort(finalSorter); - - for (i = 0; i < rows.length; i += 1) { - tableBody.appendChild(rows[i]); - } - } - // removes sort indicators for current column being sorted - function removeSortIndicators() { - var col = getNthColumn(currentSort.index), - cls = col.className; - - cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); - col.className = cls; - } - // adds sort indicators for current column being sorted - function addSortIndicators() { - getNthColumn(currentSort.index).className += currentSort.desc - ? ' sorted-desc' - : ' sorted'; - } - // adds event listeners for all sorter widgets - function enableUI() { - var i, - el, - ithSorter = function ithSorter(i) { - var col = cols[i]; - - return function() { - var desc = col.defaultDescSort; - - if (currentSort.index === i) { - desc = !currentSort.desc; - } - sortByIndex(i, desc); - removeSortIndicators(); - currentSort.index = i; - currentSort.desc = desc; - addSortIndicators(); - }; - }; - for (i = 0; i < cols.length; i += 1) { - if (cols[i].sortable) { - // add the click event handler on the th so users - // dont have to click on those tiny arrows - el = getNthColumn(i).querySelector('.sorter').parentElement; - if (el.addEventListener) { - el.addEventListener('click', ithSorter(i)); - } else { - el.attachEvent('onclick', ithSorter(i)); - } - } - } - } - // adds sorting functionality to the UI - return function() { - if (!getTable()) { - return; - } - cols = loadColumns(); - loadData(); - addSearchBox(); - addSortIndicators(); - enableUI(); - }; -})(); - -window.addEventListener('load', addSorting); diff --git a/tests/integration/position-manager/adx-runner-sl.test.ts b/tests/integration/position-manager/adx-runner-sl.test.ts new file mode 100644 index 0000000..8e205b3 --- /dev/null +++ b/tests/integration/position-manager/adx-runner-sl.test.ts @@ -0,0 +1,200 @@ +/** + * ADX-Based Runner Stop Loss Tests + * + * Tests for ADX-based runner SL positioning after TP1 (Pitfall #52). + * + * Runner SL tiers based on ADX at entry: + * - ADX < 20: SL at 0% (breakeven) - Weak trend, preserve capital + * - ADX 20-25: SL at -0.3% - Moderate trend, some room + * - ADX > 25: SL at -0.55% - Strong trend, full retracement room + */ + +import { + createLongTrade, + createShortTrade, + TEST_DEFAULTS, + calculateTargetPrice +} from '../../helpers/trade-factory' + +describe('ADX-Based Runner Stop Loss', () => { + // Extract the ADX-based runner SL logic from Position Manager + function calculateRunnerSLPercent(adx: number): number { + if (adx < 20) { + return 0 // Weak trend: breakeven, preserve capital + } else if (adx < 25) { + return -0.3 // Moderate trend: some room + } else { + return -0.55 // Strong trend: full retracement room + } + } + + function calculatePrice( + entryPrice: number, + percent: number, + direction: 'long' | 'short' + ): number { + if (direction === 'long') { + return entryPrice * (1 + percent / 100) + } else { + return entryPrice * (1 - percent / 100) + } + } + + describe('ADX < 20: Weak trend - breakeven SL', () => { + it('should return 0% SL for ADX 15 (weak trend)', () => { + const runnerSlPercent = calculateRunnerSLPercent(15) + expect(runnerSlPercent).toBe(0) + }) + + it('should return 0% SL for ADX 19.9 (boundary)', () => { + const runnerSlPercent = calculateRunnerSLPercent(19.9) + expect(runnerSlPercent).toBe(0) + }) + + it('LONG: should set runner SL at entry price for ADX 18', () => { + const trade = createLongTrade({ adx: 18, entryPrice: 140 }) + const runnerSlPercent = calculateRunnerSLPercent(trade.adxAtEntry!) + const runnerSL = calculatePrice(trade.entryPrice, runnerSlPercent, 'long') + + expect(runnerSlPercent).toBe(0) + expect(runnerSL).toBe(140.00) // Breakeven + }) + + it('SHORT: should set runner SL at entry price for ADX 18', () => { + const trade = createShortTrade({ adx: 18, entryPrice: 140 }) + const runnerSlPercent = calculateRunnerSLPercent(trade.adxAtEntry!) + const runnerSL = calculatePrice(trade.entryPrice, runnerSlPercent, 'short') + + expect(runnerSlPercent).toBe(0) + expect(runnerSL).toBe(140.00) // Breakeven + }) + }) + + describe('ADX 20-25: Moderate trend - -0.3% SL', () => { + it('should return -0.3% SL for ADX 20 (boundary)', () => { + const runnerSlPercent = calculateRunnerSLPercent(20) + expect(runnerSlPercent).toBe(-0.3) + }) + + it('should return -0.3% SL for ADX 22', () => { + const runnerSlPercent = calculateRunnerSLPercent(22) + expect(runnerSlPercent).toBe(-0.3) + }) + + it('should return -0.3% SL for ADX 24.9 (boundary)', () => { + const runnerSlPercent = calculateRunnerSLPercent(24.9) + expect(runnerSlPercent).toBe(-0.3) + }) + + it('LONG: should set runner SL at -0.3% below entry for ADX 22', () => { + const trade = createLongTrade({ adx: 22, entryPrice: 140 }) + const runnerSlPercent = calculateRunnerSLPercent(trade.adxAtEntry!) + const runnerSL = calculatePrice(trade.entryPrice, runnerSlPercent, 'long') + + expect(runnerSlPercent).toBe(-0.3) + expect(runnerSL).toBeCloseTo(139.58, 2) // 140 * (1 - 0.003) = 139.58 + expect(runnerSL).toBeLessThan(trade.entryPrice) + }) + + it('SHORT: should set runner SL at -0.3% above entry for ADX 22', () => { + const trade = createShortTrade({ adx: 22, entryPrice: 140 }) + const runnerSlPercent = calculateRunnerSLPercent(trade.adxAtEntry!) + const runnerSL = calculatePrice(trade.entryPrice, runnerSlPercent, 'short') + + expect(runnerSlPercent).toBe(-0.3) + expect(runnerSL).toBeCloseTo(140.42, 2) // 140 * (1 + 0.003) = 140.42 + expect(runnerSL).toBeGreaterThan(trade.entryPrice) + }) + }) + + describe('ADX > 25: Strong trend - -0.55% SL', () => { + it('should return -0.55% SL for ADX 25 (boundary)', () => { + const runnerSlPercent = calculateRunnerSLPercent(25) + expect(runnerSlPercent).toBe(-0.55) + }) + + it('should return -0.55% SL for ADX 26.9 (test default)', () => { + const runnerSlPercent = calculateRunnerSLPercent(TEST_DEFAULTS.adx) + expect(runnerSlPercent).toBe(-0.55) + }) + + it('should return -0.55% SL for ADX 35 (very strong)', () => { + const runnerSlPercent = calculateRunnerSLPercent(35) + expect(runnerSlPercent).toBe(-0.55) + }) + + it('LONG: should set runner SL at -0.55% below entry for ADX 26.9', () => { + const trade = createLongTrade({ adx: 26.9, entryPrice: 140 }) + const runnerSlPercent = calculateRunnerSLPercent(trade.adxAtEntry!) + const runnerSL = calculatePrice(trade.entryPrice, runnerSlPercent, 'long') + + expect(runnerSlPercent).toBe(-0.55) + expect(runnerSL).toBeCloseTo(139.23, 2) // 140 * (1 - 0.0055) = 139.23 + expect(runnerSL).toBeLessThan(trade.entryPrice) + }) + + it('SHORT: should set runner SL at -0.55% above entry for ADX 26.9', () => { + const trade = createShortTrade({ adx: 26.9, entryPrice: 140 }) + const runnerSlPercent = calculateRunnerSLPercent(trade.adxAtEntry!) + const runnerSL = calculatePrice(trade.entryPrice, runnerSlPercent, 'short') + + expect(runnerSlPercent).toBe(-0.55) + expect(runnerSL).toBeCloseTo(140.77, 2) // 140 * (1 + 0.0055) = 140.77 + expect(runnerSL).toBeGreaterThan(trade.entryPrice) + }) + }) + + describe('Missing ADX handling', () => { + it('should default to 0% (breakeven) when ADX is 0', () => { + const runnerSlPercent = calculateRunnerSLPercent(0) + expect(runnerSlPercent).toBe(0) // Conservative default + }) + + it('should handle trades with no ADX data', () => { + // When ADX is undefined, the Position Manager uses 0 as default + // According to the logic: ADX < 20 = 0% SL (breakeven) + const adx = undefined + const adxValue = adx || 0 + const runnerSlPercent = calculateRunnerSLPercent(adxValue) + + // No ADX = 0 = weak trend = breakeven + expect(runnerSlPercent).toBe(0) + }) + }) + + describe('Retracement room validation', () => { + it('LONG ADX 26.9: runner can handle -0.55% retracement without stop', () => { + const entryPrice = 140 + const runnerSL = calculatePrice(entryPrice, -0.55, 'long') + + // Price at -0.4% should NOT hit SL + const priceAt0_4PercentDrop = entryPrice * 0.996 // 139.44 + expect(priceAt0_4PercentDrop).toBeGreaterThan(runnerSL) + + // Price at -0.55% should hit SL + const priceAt0_55PercentDrop = runnerSL + expect(priceAt0_55PercentDrop).toBe(runnerSL) + + // Price at -0.6% should definitely hit SL + const priceAt0_6PercentDrop = entryPrice * 0.994 // 139.16 + expect(priceAt0_6PercentDrop).toBeLessThan(runnerSL) + }) + + it('SHORT ADX 26.9: runner can handle +0.55% retracement without stop', () => { + const entryPrice = 140 + const runnerSL = calculatePrice(entryPrice, -0.55, 'short') + + // Price at +0.4% should NOT hit SL + const priceAt0_4PercentRise = entryPrice * 1.004 // 140.56 + expect(priceAt0_4PercentRise).toBeLessThan(runnerSL) + + // Price at +0.55% should hit SL + const priceAt0_55PercentRise = runnerSL + expect(priceAt0_55PercentRise).toBe(runnerSL) + + // Price at +0.6% should definitely hit SL + const priceAt0_6PercentRise = entryPrice * 1.006 // 140.84 + expect(priceAt0_6PercentRise).toBeGreaterThan(runnerSL) + }) + }) +}) diff --git a/tests/integration/position-manager/breakeven-sl.test.ts b/tests/integration/position-manager/breakeven-sl.test.ts new file mode 100644 index 0000000..3151750 --- /dev/null +++ b/tests/integration/position-manager/breakeven-sl.test.ts @@ -0,0 +1,155 @@ +/** + * Breakeven Stop Loss Tests + * + * Tests for SL movement to breakeven after TP1 hit. + * + * Key behaviors tested: + * - SL moves to entry price (breakeven) after TP1 for LONG + * - SL moves to entry price (breakeven) after TP1 for SHORT + * - CRITICAL (Pitfall #45): Must use DATABASE entry price, not Drift recalculated entry + */ + +import { + createLongTrade, + createShortTrade, + createTradeAfterTP1, + TEST_DEFAULTS, + calculateTargetPrice +} from '../../helpers/trade-factory' + +describe('Breakeven Stop Loss', () => { + // Test the calculatePrice logic extracted from Position Manager + function calculatePrice( + entryPrice: number, + percent: number, + direction: 'long' | 'short' + ): number { + if (direction === 'long') { + return entryPrice * (1 + percent / 100) + } else { + return entryPrice * (1 - percent / 100) + } + } + + describe('LONG positions after TP1', () => { + it('should calculate breakeven SL at entry price for LONG', () => { + const trade = createLongTrade({ entryPrice: 140.00 }) + + // After TP1, SL moves to breakeven (0%) + const breakevenSL = calculatePrice(trade.entryPrice, 0, 'long') + + expect(breakevenSL).toBe(140.00) + }) + + it('should protect 60% profit when runner SL at breakeven', () => { + // After TP1 closes 60%, remaining 40% has SL at entry + const trade = createTradeAfterTP1('long') + + expect(trade.tp1Hit).toBe(true) + expect(trade.slMovedToBreakeven).toBe(true) + expect(trade.stopLossPrice).toBe(TEST_DEFAULTS.entry) + + // Runner size should be 40% of original + expect(trade.currentSize).toBe(TEST_DEFAULTS.positionSize * 0.4) + }) + + it('should use DATABASE entry price, NOT Drift recalculated entry (Pitfall #45)', () => { + // CRITICAL: After partial close, Drift recalculates entry price based on remaining position + // This would give wrong breakeven SL. Must use ORIGINAL entry from database. + + const databaseEntryPrice = 140.00 + const driftRecalculatedEntry = 140.50 // Wrong! Drift adjusts after partial close + + // Correct: Use database entry + const correctBreakevenSL = calculatePrice(databaseEntryPrice, 0, 'long') + expect(correctBreakevenSL).toBe(140.00) + + // Wrong: Using Drift entry would give incorrect SL + const wrongBreakevenSL = calculatePrice(driftRecalculatedEntry, 0, 'long') + expect(wrongBreakevenSL).not.toBe(140.00) + expect(wrongBreakevenSL).toBe(140.50) // This would be wrong! + + // The trade factory correctly uses original entry + const trade = createTradeAfterTP1('long', { entryPrice: databaseEntryPrice }) + expect(trade.entryPrice).toBe(databaseEntryPrice) + expect(trade.stopLossPrice).toBe(databaseEntryPrice) + }) + }) + + describe('SHORT positions after TP1', () => { + it('should calculate breakeven SL at entry price for SHORT', () => { + const trade = createShortTrade({ entryPrice: 140.00 }) + + // After TP1, SL moves to breakeven (0%) + const breakevenSL = calculatePrice(trade.entryPrice, 0, 'short') + + expect(breakevenSL).toBe(140.00) + }) + + it('should protect 60% profit when runner SL at breakeven', () => { + const trade = createTradeAfterTP1('short') + + expect(trade.tp1Hit).toBe(true) + expect(trade.slMovedToBreakeven).toBe(true) + expect(trade.stopLossPrice).toBe(TEST_DEFAULTS.entry) + + // Runner size should be 40% of original + expect(trade.currentSize).toBe(TEST_DEFAULTS.positionSize * 0.4) + }) + + it('should use DATABASE entry price, NOT Drift recalculated entry (Pitfall #45)', () => { + const databaseEntryPrice = 140.00 + const driftRecalculatedEntry = 139.50 // Wrong! Drift adjusts after partial close + + // Correct: Use database entry + const correctBreakevenSL = calculatePrice(databaseEntryPrice, 0, 'short') + expect(correctBreakevenSL).toBe(140.00) + + // Wrong: Using Drift entry would give incorrect SL + const wrongBreakevenSL = calculatePrice(driftRecalculatedEntry, 0, 'short') + expect(wrongBreakevenSL).not.toBe(140.00) + + // The trade factory correctly uses original entry + const trade = createTradeAfterTP1('short', { entryPrice: databaseEntryPrice }) + expect(trade.entryPrice).toBe(databaseEntryPrice) + expect(trade.stopLossPrice).toBe(databaseEntryPrice) + }) + }) + + describe('SL direction verification', () => { + it('LONG: breakeven SL should be BELOW entry when negative %', () => { + const entryPrice = 140.00 + + // For LONG: negative % = lower price = valid SL + const slAt0_3PercentLoss = calculatePrice(entryPrice, -0.3, 'long') + expect(slAt0_3PercentLoss).toBe(139.58) // 140 * (1 - 0.003) + expect(slAt0_3PercentLoss).toBeLessThan(entryPrice) + }) + + it('SHORT: breakeven SL should be ABOVE entry when negative %', () => { + const entryPrice = 140.00 + + // For SHORT: negative % = higher price = valid SL + const slAt0_3PercentLoss = calculatePrice(entryPrice, -0.3, 'short') + expect(slAt0_3PercentLoss).toBe(140.42) // 140 * (1 + 0.003) + expect(slAt0_3PercentLoss).toBeGreaterThan(entryPrice) + }) + + it('should verify SL moves in profitable direction', () => { + const longTrade = createLongTrade({ entryPrice: 140 }) + const shortTrade = createShortTrade({ entryPrice: 140 }) + + // Original SLs are at loss levels + expect(longTrade.stopLossPrice).toBe(TEST_DEFAULTS.long.sl) // Below entry + expect(shortTrade.stopLossPrice).toBe(TEST_DEFAULTS.short.sl) // Above entry + + // After TP1, SLs move to breakeven (entry price) + const longAfterTP1 = createTradeAfterTP1('long') + const shortAfterTP1 = createTradeAfterTP1('short') + + // Both should now be at entry price + expect(longAfterTP1.stopLossPrice).toBe(TEST_DEFAULTS.entry) + expect(shortAfterTP1.stopLossPrice).toBe(TEST_DEFAULTS.entry) + }) + }) +}) diff --git a/tests/integration/position-manager/decision-helpers.test.ts b/tests/integration/position-manager/decision-helpers.test.ts new file mode 100644 index 0000000..daac8cc --- /dev/null +++ b/tests/integration/position-manager/decision-helpers.test.ts @@ -0,0 +1,308 @@ +/** + * Decision Helpers Tests + * + * Tests for all decision helper functions in Position Manager: + * - shouldEmergencyStop() - LONG and SHORT + * - shouldStopLoss() - LONG and SHORT + * - shouldTakeProfit1() - LONG and SHORT + * - shouldTakeProfit2() - LONG and SHORT + */ + +import { + createLongTrade, + createShortTrade, + TEST_DEFAULTS +} from '../../helpers/trade-factory' + +describe('Decision Helpers', () => { + // Extract decision helpers from Position Manager + // These are pure functions that test price against targets + + function shouldEmergencyStop(price: number, trade: { direction: 'long' | 'short', emergencyStopPrice: number }): boolean { + if (trade.direction === 'long') { + return price <= trade.emergencyStopPrice + } else { + return price >= trade.emergencyStopPrice + } + } + + function shouldStopLoss(price: number, trade: { direction: 'long' | 'short', stopLossPrice: number }): boolean { + if (trade.direction === 'long') { + return price <= trade.stopLossPrice + } else { + return price >= trade.stopLossPrice + } + } + + function shouldTakeProfit1(price: number, trade: { direction: 'long' | 'short', tp1Price: number }): boolean { + if (trade.direction === 'long') { + return price >= trade.tp1Price + } else { + return price <= trade.tp1Price + } + } + + function shouldTakeProfit2(price: number, trade: { direction: 'long' | 'short', tp2Price: number }): boolean { + if (trade.direction === 'long') { + return price >= trade.tp2Price + } else { + return price <= trade.tp2Price + } + } + + describe('shouldEmergencyStop()', () => { + describe('LONG positions', () => { + it('should trigger emergency stop when price at -2% (emergency level)', () => { + const trade = createLongTrade({ entryPrice: 140 }) + // Emergency stop at -2% = $137.20 + expect(trade.emergencyStopPrice).toBe(TEST_DEFAULTS.long.emergencySl) + + expect(shouldEmergencyStop(137.20, trade)).toBe(true) + }) + + it('should trigger emergency stop when price below emergency level', () => { + const trade = createLongTrade() + + expect(shouldEmergencyStop(136.00, trade)).toBe(true) // Below emergency + expect(shouldEmergencyStop(135.00, trade)).toBe(true) // Well below + }) + + it('should NOT trigger emergency stop above emergency level', () => { + const trade = createLongTrade() + + expect(shouldEmergencyStop(138.00, trade)).toBe(false) // Above emergency but below SL + expect(shouldEmergencyStop(140.00, trade)).toBe(false) // At entry + expect(shouldEmergencyStop(141.00, trade)).toBe(false) // In profit + }) + }) + + describe('SHORT positions', () => { + it('should trigger emergency stop when price at +2% (emergency level)', () => { + const trade = createShortTrade({ entryPrice: 140 }) + // Emergency stop at +2% = $142.80 + expect(trade.emergencyStopPrice).toBe(TEST_DEFAULTS.short.emergencySl) + + expect(shouldEmergencyStop(142.80, trade)).toBe(true) + }) + + it('should trigger emergency stop when price above emergency level', () => { + const trade = createShortTrade() + + expect(shouldEmergencyStop(143.00, trade)).toBe(true) + expect(shouldEmergencyStop(145.00, trade)).toBe(true) + }) + + it('should NOT trigger emergency stop below emergency level', () => { + const trade = createShortTrade() + + expect(shouldEmergencyStop(142.00, trade)).toBe(false) // Below emergency but at SL + expect(shouldEmergencyStop(140.00, trade)).toBe(false) // At entry + expect(shouldEmergencyStop(138.00, trade)).toBe(false) // In profit + }) + }) + }) + + describe('shouldStopLoss()', () => { + describe('LONG positions', () => { + it('should trigger SL when price at stop loss level', () => { + const trade = createLongTrade({ entryPrice: 140 }) + // SL at -0.92% = $138.71 + expect(trade.stopLossPrice).toBe(TEST_DEFAULTS.long.sl) + + expect(shouldStopLoss(138.71, trade)).toBe(true) + }) + + it('should trigger SL when price below stop loss', () => { + const trade = createLongTrade() + + expect(shouldStopLoss(138.00, trade)).toBe(true) + expect(shouldStopLoss(137.50, trade)).toBe(true) + }) + + it('should NOT trigger SL when price above stop loss', () => { + const trade = createLongTrade() + + expect(shouldStopLoss(139.00, trade)).toBe(false) + expect(shouldStopLoss(140.00, trade)).toBe(false) + expect(shouldStopLoss(141.00, trade)).toBe(false) + }) + + it('should work with adjusted SL (breakeven after TP1)', () => { + const trade = createLongTrade({ + entryPrice: 140, + stopLossPrice: 140.00, // Moved to breakeven + tp1Hit: true + }) + + expect(shouldStopLoss(139.99, trade)).toBe(true) // Just below breakeven + expect(shouldStopLoss(140.00, trade)).toBe(true) // At breakeven + expect(shouldStopLoss(140.01, trade)).toBe(false) // Above breakeven + }) + }) + + describe('SHORT positions', () => { + it('should trigger SL when price at stop loss level', () => { + const trade = createShortTrade({ entryPrice: 140 }) + // SL at +0.92% = $141.29 + expect(trade.stopLossPrice).toBe(TEST_DEFAULTS.short.sl) + + expect(shouldStopLoss(141.29, trade)).toBe(true) + }) + + it('should trigger SL when price above stop loss', () => { + const trade = createShortTrade() + + expect(shouldStopLoss(142.00, trade)).toBe(true) + expect(shouldStopLoss(143.00, trade)).toBe(true) + }) + + it('should NOT trigger SL when price below stop loss', () => { + const trade = createShortTrade() + + expect(shouldStopLoss(141.00, trade)).toBe(false) + expect(shouldStopLoss(140.00, trade)).toBe(false) + expect(shouldStopLoss(138.00, trade)).toBe(false) + }) + + it('should work with adjusted SL (breakeven after TP1)', () => { + const trade = createShortTrade({ + entryPrice: 140, + stopLossPrice: 140.00, // Moved to breakeven + tp1Hit: true + }) + + expect(shouldStopLoss(140.01, trade)).toBe(true) // Just above breakeven + expect(shouldStopLoss(140.00, trade)).toBe(true) // At breakeven + expect(shouldStopLoss(139.99, trade)).toBe(false) // Below breakeven (in profit) + }) + }) + }) + + describe('shouldTakeProfit1()', () => { + describe('LONG positions', () => { + it('should trigger TP1 when price at target', () => { + const trade = createLongTrade({ entryPrice: 140 }) + expect(trade.tp1Price).toBe(TEST_DEFAULTS.long.tp1) + + expect(shouldTakeProfit1(141.20, trade)).toBe(true) + }) + + it('should trigger TP1 when price above target', () => { + const trade = createLongTrade() + + expect(shouldTakeProfit1(141.50, trade)).toBe(true) + expect(shouldTakeProfit1(142.00, trade)).toBe(true) + }) + + it('should NOT trigger TP1 when price below target', () => { + const trade = createLongTrade() + + expect(shouldTakeProfit1(141.00, trade)).toBe(false) + expect(shouldTakeProfit1(140.00, trade)).toBe(false) + expect(shouldTakeProfit1(139.00, trade)).toBe(false) + }) + }) + + describe('SHORT positions', () => { + it('should trigger TP1 when price at target', () => { + const trade = createShortTrade({ entryPrice: 140 }) + expect(trade.tp1Price).toBe(TEST_DEFAULTS.short.tp1) + + expect(shouldTakeProfit1(138.80, trade)).toBe(true) + }) + + it('should trigger TP1 when price below target (better for short)', () => { + const trade = createShortTrade() + + expect(shouldTakeProfit1(138.50, trade)).toBe(true) + expect(shouldTakeProfit1(138.00, trade)).toBe(true) + }) + + it('should NOT trigger TP1 when price above target', () => { + const trade = createShortTrade() + + expect(shouldTakeProfit1(139.00, trade)).toBe(false) + expect(shouldTakeProfit1(140.00, trade)).toBe(false) + expect(shouldTakeProfit1(141.00, trade)).toBe(false) + }) + }) + }) + + describe('shouldTakeProfit2()', () => { + describe('LONG positions', () => { + it('should trigger TP2 when price at target', () => { + const trade = createLongTrade({ entryPrice: 140 }) + expect(trade.tp2Price).toBe(TEST_DEFAULTS.long.tp2) + + expect(shouldTakeProfit2(142.41, trade)).toBe(true) + }) + + it('should trigger TP2 when price above target', () => { + const trade = createLongTrade() + + expect(shouldTakeProfit2(143.00, trade)).toBe(true) + expect(shouldTakeProfit2(145.00, trade)).toBe(true) + }) + + it('should NOT trigger TP2 when price below target', () => { + const trade = createLongTrade() + + expect(shouldTakeProfit2(142.00, trade)).toBe(false) + expect(shouldTakeProfit2(141.00, trade)).toBe(false) + expect(shouldTakeProfit2(140.00, trade)).toBe(false) + }) + }) + + describe('SHORT positions', () => { + it('should trigger TP2 when price at target', () => { + const trade = createShortTrade({ entryPrice: 140 }) + expect(trade.tp2Price).toBe(TEST_DEFAULTS.short.tp2) + + expect(shouldTakeProfit2(137.59, trade)).toBe(true) + }) + + it('should trigger TP2 when price below target (better for short)', () => { + const trade = createShortTrade() + + expect(shouldTakeProfit2(137.00, trade)).toBe(true) + expect(shouldTakeProfit2(135.00, trade)).toBe(true) + }) + + it('should NOT trigger TP2 when price above target', () => { + const trade = createShortTrade() + + expect(shouldTakeProfit2(138.00, trade)).toBe(false) + expect(shouldTakeProfit2(140.00, trade)).toBe(false) + expect(shouldTakeProfit2(141.00, trade)).toBe(false) + }) + }) + }) + + describe('Decision order priority', () => { + it('emergency stop should trigger before regular SL', () => { + const trade = createLongTrade({ entryPrice: 140 }) + const price = 136.00 // Below both emergency and SL + + const emergencyTriggered = shouldEmergencyStop(price, trade) + const slTriggered = shouldStopLoss(price, trade) + + expect(emergencyTriggered).toBe(true) + expect(slTriggered).toBe(true) + // In Position Manager, emergency is checked first (higher priority) + }) + + it('SL should NOT be checked if TP already hit the threshold', () => { + const trade = createLongTrade({ entryPrice: 140 }) + const highPrice = 143.00 // Well above TP1 and TP2 + + const tp1Triggered = shouldTakeProfit1(highPrice, trade) + const tp2Triggered = shouldTakeProfit2(highPrice, trade) + const slTriggered = shouldStopLoss(highPrice, trade) + + expect(tp1Triggered).toBe(true) + expect(tp2Triggered).toBe(true) + expect(slTriggered).toBe(false) + // When price is high, TP triggers but not SL + }) + }) +}) diff --git a/tests/integration/position-manager/edge-cases.test.ts b/tests/integration/position-manager/edge-cases.test.ts new file mode 100644 index 0000000..a7e522d --- /dev/null +++ b/tests/integration/position-manager/edge-cases.test.ts @@ -0,0 +1,241 @@ +/** + * Edge Cases Tests + * + * Tests for edge cases and common pitfalls in Position Manager. + * + * Pitfalls tested: + * - #24: Position.size as tokens, not USD + * - #54: MAE/MFE as percentages, not dollars + * - Phantom trade detection (< 50% expected size) + * - Profit percent calculation for LONG and SHORT + */ + +import { + createLongTrade, + createShortTrade, + TEST_DEFAULTS, + calculateExpectedProfitPercent +} from '../../helpers/trade-factory' + +describe('Edge Cases', () => { + describe('Position.size tokens vs USD (Pitfall #24)', () => { + it('should convert token size to USD correctly', () => { + // Drift SDK returns position.size in BASE ASSET TOKENS (e.g., 12.28 SOL) + // NOT in USD ($1,950) + + const positionSizeTokens = 12.28 // SOL tokens + const currentPrice = 159.12 // Current SOL price + + // CORRECT: Convert tokens to USD + const positionSizeUSD = Math.abs(positionSizeTokens) * currentPrice + expect(positionSizeUSD).toBeCloseTo(1953.59, 0) + + // WRONG: Using tokens directly as USD (off by 159x!) + expect(positionSizeTokens).not.toBe(positionSizeUSD) + }) + + it('should detect TP1 using USD values, not token values', () => { + // Bug: Comparing tokens (12.28) to USD ($1,950) caused false TP1 detection + // 12.28 < 1950 * 0.95 was always true! + + const trackedSizeUSD = 1950 + const positionSizeTokens = 12.28 + const currentPrice = 159.12 + + // WRONG: Direct comparison (would always think 95% was reduced) + const wrongComparison = positionSizeTokens < trackedSizeUSD * 0.95 + expect(wrongComparison).toBe(true) // BUG: False positive! + + // CORRECT: Convert to USD first + const positionSizeUSD = Math.abs(positionSizeTokens) * currentPrice + const correctComparison = positionSizeUSD < trackedSizeUSD * 0.95 + expect(correctComparison).toBe(false) // Position is actually ~100%, not reduced + }) + + it('should calculate size reduction correctly using USD', () => { + const originalSizeUSD = 8000 + const tp1SizePercent = 60 + + // After TP1, 60% closed, 40% remaining + const expectedRemainingUSD = originalSizeUSD * (1 - tp1SizePercent / 100) + expect(expectedRemainingUSD).toBe(3200) + + // Token equivalent at $140/SOL + const tokensRemaining = expectedRemainingUSD / 140 + expect(tokensRemaining).toBeCloseTo(22.86, 1) + + // Verify conversion back to USD + const convertedBackUSD = tokensRemaining * 140 + expect(convertedBackUSD).toBeCloseTo(expectedRemainingUSD, 0) + }) + }) + + describe('Phantom trade detection (< 50% expected size)', () => { + it('should detect phantom when actual size < 50% of expected', () => { + const expectedSizeUSD = 8000 + const actualSizeUSD = 1370 // Only 17% filled + + const sizeRatio = actualSizeUSD / expectedSizeUSD + const isPhantom = sizeRatio < 0.5 + + expect(sizeRatio).toBeCloseTo(0.171, 2) + expect(isPhantom).toBe(true) + }) + + it('should NOT detect phantom when size >= 50%', () => { + const expectedSizeUSD = 8000 + const actualSizeUSD = 4500 // 56% filled + + const sizeRatio = actualSizeUSD / expectedSizeUSD + const isPhantom = sizeRatio < 0.5 + + expect(sizeRatio).toBeCloseTo(0.5625, 2) + expect(isPhantom).toBe(false) + }) + + it('should handle exact 50% boundary', () => { + const expectedSizeUSD = 8000 + const actualSizeUSD = 4000 // Exactly 50% + + const sizeRatio = actualSizeUSD / expectedSizeUSD + const isPhantom = sizeRatio < 0.5 + + expect(sizeRatio).toBe(0.5) + expect(isPhantom).toBe(false) // 50% is acceptable + }) + + it('should NOT flag runner after TP1 as phantom', () => { + // Bug: After TP1, currentSize is 40% of original + // This should NOT be flagged as phantom + + const trade = createLongTrade({ tp1Hit: true }) + trade.currentSize = trade.positionSize * 0.4 // 40% remaining + + // Phantom check should ONLY run on initial position, not after TP1 + const isAfterTP1 = trade.tp1Hit + const sizeRatio = trade.currentSize / trade.positionSize + + // Even though size is <50%, this is NOT a phantom - it's a runner + const isPhantom = !isAfterTP1 && sizeRatio < 0.5 + + expect(isAfterTP1).toBe(true) + expect(sizeRatio).toBe(0.4) + expect(isPhantom).toBe(false) // Correctly NOT flagged as phantom + }) + }) + + describe('MAE/MFE as percentages (Pitfall #54)', () => { + it('should track MFE as percentage, not dollars', () => { + const trade = createLongTrade({ entryPrice: 140 }) + const bestPrice = 141.20 // +0.86% + + // CORRECT: Store as percentage + const mfePercent = ((bestPrice - trade.entryPrice) / trade.entryPrice) * 100 + expect(mfePercent).toBeCloseTo(0.857, 1) + + // Database expects small % values like 0.86, not $68 + expect(mfePercent).toBeLessThan(5) // Sanity check: percentage is small + }) + + it('should track MAE as percentage, not dollars', () => { + const trade = createLongTrade({ entryPrice: 140 }) + const worstPrice = 138.60 // -1% + + // CORRECT: Store as percentage (negative for loss) + const maePercent = ((worstPrice - trade.entryPrice) / trade.entryPrice) * 100 + expect(maePercent).toBeCloseTo(-1.0, 1) + + // MAE should be negative for adverse movement + expect(maePercent).toBeLessThan(0) + }) + + it('should update MFE when profit increases', () => { + const trade = createLongTrade({ entryPrice: 140 }) + trade.maxFavorableExcursion = 0 + + // Price moves to +0.5%, then +1% + const prices = [140.70, 141.40] + + for (const price of prices) { + const profitPercent = ((price - trade.entryPrice) / trade.entryPrice) * 100 + if (profitPercent > trade.maxFavorableExcursion) { + trade.maxFavorableExcursion = profitPercent + trade.maxFavorablePrice = price + } + } + + expect(trade.maxFavorableExcursion).toBeCloseTo(1.0, 1) // +1% + expect(trade.maxFavorablePrice).toBe(141.40) + }) + + it('should update MAE when loss increases', () => { + const trade = createLongTrade({ entryPrice: 140 }) + trade.maxAdverseExcursion = 0 + + // Price moves to -0.3%, then -0.5% + const prices = [139.58, 139.30] + + for (const price of prices) { + const profitPercent = ((price - trade.entryPrice) / trade.entryPrice) * 100 + if (profitPercent < trade.maxAdverseExcursion) { + trade.maxAdverseExcursion = profitPercent + trade.maxAdversePrice = price + } + } + + expect(trade.maxAdverseExcursion).toBeCloseTo(-0.5, 1) // -0.5% + expect(trade.maxAdversePrice).toBe(139.30) + }) + + it('SHORT: should calculate MFE correctly (positive when price drops)', () => { + const trade = createShortTrade({ entryPrice: 140 }) + const bestPrice = 138.60 // -1% = good for SHORT + + // For SHORT, profit when price drops + const mfePercent = ((trade.entryPrice - bestPrice) / trade.entryPrice) * 100 + expect(mfePercent).toBeCloseTo(1.0, 1) // +1% profit for short + expect(mfePercent).toBeGreaterThan(0) + }) + + it('SHORT: should calculate MAE correctly (negative when price rises)', () => { + const trade = createShortTrade({ entryPrice: 140 }) + const worstPrice = 141.40 // +1% = bad for SHORT + + // For SHORT, loss when price rises + const maePercent = ((trade.entryPrice - worstPrice) / trade.entryPrice) * 100 + expect(maePercent).toBeCloseTo(-1.0, 1) // -1% loss for short + expect(maePercent).toBeLessThan(0) + }) + }) + + describe('Profit percent calculation', () => { + it('LONG: positive profit when price increases', () => { + const profit = calculateExpectedProfitPercent(140, 141.20, 'long') + expect(profit).toBeCloseTo(0.857, 1) + expect(profit).toBeGreaterThan(0) + }) + + it('LONG: negative profit when price decreases', () => { + const profit = calculateExpectedProfitPercent(140, 138.80, 'long') + expect(profit).toBeCloseTo(-0.857, 1) + expect(profit).toBeLessThan(0) + }) + + it('SHORT: positive profit when price decreases', () => { + const profit = calculateExpectedProfitPercent(140, 138.80, 'short') + expect(profit).toBeCloseTo(0.857, 1) + expect(profit).toBeGreaterThan(0) + }) + + it('SHORT: negative profit when price increases', () => { + const profit = calculateExpectedProfitPercent(140, 141.20, 'short') + expect(profit).toBeCloseTo(-0.857, 1) + expect(profit).toBeLessThan(0) + }) + + it('should return 0 when price equals entry', () => { + expect(calculateExpectedProfitPercent(140, 140, 'long')).toBe(0) + expect(calculateExpectedProfitPercent(140, 140, 'short')).toBe(0) + }) + }) +}) diff --git a/tests/integration/position-manager/price-verification.test.ts b/tests/integration/position-manager/price-verification.test.ts new file mode 100644 index 0000000..5093a3b --- /dev/null +++ b/tests/integration/position-manager/price-verification.test.ts @@ -0,0 +1,197 @@ +/** + * Price Verification Tests + * + * Tests for price verification before setting TP flags. + * + * Key behaviors tested (Pitfall #43): + * - Verify price reached TP1 before setting tp1Hit flag + * - Require BOTH size reduced AND price at target + * - Use tolerance for price target matching (0.2%) + */ + +import { + createLongTrade, + createShortTrade, + TEST_DEFAULTS +} from '../../helpers/trade-factory' + +describe('Price Verification', () => { + // Extract isPriceAtTarget logic from Position Manager + function isPriceAtTarget(currentPrice: number, targetPrice: number, tolerance: number = 0.002): boolean { + if (!targetPrice || targetPrice === 0) return false + const diff = Math.abs(currentPrice - targetPrice) / targetPrice + return diff <= tolerance + } + + function shouldTakeProfit1(price: number, trade: { direction: 'long' | 'short', tp1Price: number }): boolean { + if (trade.direction === 'long') { + return price >= trade.tp1Price + } else { + return price <= trade.tp1Price + } + } + + describe('TP1 verification before setting flag (Pitfall #43)', () => { + it('LONG: should require BOTH size reduction AND price at TP1', () => { + const trade = createLongTrade({ entryPrice: 140, tp1Price: 141.20 }) + + // Scenario: Size reduced but price NOT at TP1 + const sizeReduced = true + const currentPrice = 140.50 // Below TP1 + const priceAtTP1 = isPriceAtTarget(currentPrice, trade.tp1Price) + + // Should NOT set tp1Hit because price hasn't reached target + const shouldSetTP1 = sizeReduced && priceAtTP1 + expect(shouldSetTP1).toBe(false) + + // This was likely a MANUAL CLOSE, not TP1 + }) + + it('LONG: should allow TP1 when both conditions met', () => { + const trade = createLongTrade({ entryPrice: 140, tp1Price: 141.20 }) + + // Scenario: Size reduced AND price at TP1 + const sizeReduced = true + const currentPrice = 141.20 + const priceAtTP1 = isPriceAtTarget(currentPrice, trade.tp1Price) + + const shouldSetTP1 = sizeReduced && priceAtTP1 + expect(shouldSetTP1).toBe(true) + }) + + it('SHORT: should require BOTH size reduction AND price at TP1', () => { + const trade = createShortTrade({ entryPrice: 140, tp1Price: 138.80 }) + + // Scenario: Size reduced but price NOT at TP1 + const sizeReduced = true + const currentPrice = 139.50 // Above TP1 (short profits when price drops) + const priceAtTP1 = isPriceAtTarget(currentPrice, trade.tp1Price) + + const shouldSetTP1 = sizeReduced && priceAtTP1 + expect(shouldSetTP1).toBe(false) + }) + + it('SHORT: should allow TP1 when both conditions met', () => { + const trade = createShortTrade({ entryPrice: 140, tp1Price: 138.80 }) + + const sizeReduced = true + const currentPrice = 138.80 + const priceAtTP1 = isPriceAtTarget(currentPrice, trade.tp1Price) + + const shouldSetTP1 = sizeReduced && priceAtTP1 + expect(shouldSetTP1).toBe(true) + }) + }) + + describe('isPriceAtTarget tolerance (0.2%)', () => { + it('should return true when price exactly at target', () => { + const result = isPriceAtTarget(141.20, 141.20) + expect(result).toBe(true) + }) + + it('should return true when price within 0.2% of target', () => { + // 0.2% of 141.20 = 0.2824 + // So prices from 140.92 to 141.48 should be "at target" + + expect(isPriceAtTarget(141.10, 141.20)).toBe(true) // -0.07% + expect(isPriceAtTarget(141.30, 141.20)).toBe(true) // +0.07% + expect(isPriceAtTarget(141.00, 141.20)).toBe(true) // -0.14% + expect(isPriceAtTarget(141.40, 141.20)).toBe(true) // +0.14% + }) + + it('should return false when price outside 0.2% tolerance', () => { + // More than 0.2% away should NOT be "at target" + expect(isPriceAtTarget(140.70, 141.20)).toBe(false) // -0.35% + expect(isPriceAtTarget(141.60, 141.20)).toBe(false) // +0.28% + expect(isPriceAtTarget(140.00, 141.20)).toBe(false) // -0.85% + }) + + it('should handle edge case of 0 target price', () => { + const result = isPriceAtTarget(141.20, 0) + expect(result).toBe(false) + }) + + it('should handle custom tolerance', () => { + // Use stricter 0.1% tolerance + expect(isPriceAtTarget(141.10, 141.20, 0.001)).toBe(true) // 0.07% < 0.1% + expect(isPriceAtTarget(141.00, 141.20, 0.001)).toBe(false) // 0.14% > 0.1% + + // Use looser 0.5% tolerance + expect(isPriceAtTarget(140.50, 141.20, 0.005)).toBe(true) // 0.5% = 0.5% + expect(isPriceAtTarget(140.00, 141.20, 0.005)).toBe(false) // 0.85% > 0.5% + }) + }) + + describe('shouldTakeProfit1 with price verification', () => { + it('LONG: combined size + price verification', () => { + const trade = createLongTrade({ + entryPrice: 140, + tp1Price: 141.20, + positionSize: 8000 + }) + + // Simulate Position Manager logic + const originalSize = trade.positionSize + const currentSize = 3200 // After 60% close + const sizeMismatch = currentSize < originalSize * 0.9 + + // Case 1: Price at TP1 + const priceAtTP1 = 141.20 + const priceReachedTP1 = shouldTakeProfit1(priceAtTP1, trade) + expect(sizeMismatch && priceReachedTP1).toBe(true) // Valid TP1 + + // Case 2: Price NOT at TP1 + const priceNotAtTP1 = 140.50 + const priceReachedTP1_2 = shouldTakeProfit1(priceNotAtTP1, trade) + expect(sizeMismatch && priceReachedTP1_2).toBe(false) // Invalid - manual close + }) + + it('SHORT: combined size + price verification', () => { + const trade = createShortTrade({ + entryPrice: 140, + tp1Price: 138.80, + positionSize: 8000 + }) + + const originalSize = trade.positionSize + const currentSize = 3200 + const sizeMismatch = currentSize < originalSize * 0.9 + + // Case 1: Price at TP1 (below entry for short) + const priceAtTP1 = 138.80 + const priceReachedTP1 = shouldTakeProfit1(priceAtTP1, trade) + expect(sizeMismatch && priceReachedTP1).toBe(true) + + // Case 2: Price NOT at TP1 (still above target) + const priceNotAtTP1 = 139.50 + const priceReachedTP1_2 = shouldTakeProfit1(priceNotAtTP1, trade) + expect(sizeMismatch && priceReachedTP1_2).toBe(false) + }) + }) + + describe('TP2 price verification', () => { + function shouldTakeProfit2(price: number, trade: { direction: 'long' | 'short', tp2Price: number }): boolean { + if (trade.direction === 'long') { + return price >= trade.tp2Price + } else { + return price <= trade.tp2Price + } + } + + it('LONG: should detect TP2 at correct price', () => { + const trade = createLongTrade({ tp2Price: 142.41 }) + + expect(shouldTakeProfit2(142.00, trade)).toBe(false) // Below + expect(shouldTakeProfit2(142.41, trade)).toBe(true) // At target + expect(shouldTakeProfit2(143.00, trade)).toBe(true) // Above + }) + + it('SHORT: should detect TP2 at correct price', () => { + const trade = createShortTrade({ tp2Price: 137.59 }) + + expect(shouldTakeProfit2(138.00, trade)).toBe(false) // Above (bad for short) + expect(shouldTakeProfit2(137.59, trade)).toBe(true) // At target + expect(shouldTakeProfit2(137.00, trade)).toBe(true) // Below (better for short) + }) + }) +}) diff --git a/tests/integration/position-manager/tp1-detection.test.ts b/tests/integration/position-manager/tp1-detection.test.ts new file mode 100644 index 0000000..3ea903d --- /dev/null +++ b/tests/integration/position-manager/tp1-detection.test.ts @@ -0,0 +1,177 @@ +/** + * TP1 Detection Tests + * + * Tests for Take Profit 1 detection in Position Manager. + * TP1 is calculated as ATR × 2.0 (typically ~0.86% at ATR 0.43) + * + * Key behaviors tested: + * - LONG: TP1 triggers when price >= tp1Price + * - SHORT: TP1 triggers when price <= tp1Price + * - Must NOT trigger below threshold + * - Must NOT trigger when price moves against position + */ + +import { + createLongTrade, + createShortTrade, + TEST_DEFAULTS, + calculateExpectedProfitPercent +} from '../../helpers/trade-factory' + +describe('TP1 Detection', () => { + // Test the shouldTakeProfit1 logic extracted from Position Manager + // We test the pure calculation logic rather than the full Position Manager + + function shouldTakeProfit1(price: number, trade: { direction: 'long' | 'short', tp1Price: number }): boolean { + if (trade.direction === 'long') { + return price >= trade.tp1Price + } else { + return price <= trade.tp1Price + } + } + + describe('LONG positions', () => { + it('should detect TP1 when price reaches +0.86% (ATR 0.43 × 2.0)', () => { + // Test data: entry $140, TP1 at $141.20 (+0.86%) + const trade = createLongTrade() + + expect(trade.tp1Price).toBe(TEST_DEFAULTS.long.tp1) + + // Price at TP1 should trigger + const result = shouldTakeProfit1(141.20, trade) + expect(result).toBe(true) + + // Verify profit calculation + const profitPercent = calculateExpectedProfitPercent(140, 141.20, 'long') + expect(profitPercent).toBeCloseTo(0.857, 1) // ~0.86% + }) + + it('should detect TP1 when price exceeds tp1Price', () => { + const trade = createLongTrade() + + // Price above TP1 should also trigger + const result = shouldTakeProfit1(142.00, trade) + expect(result).toBe(true) + }) + + it('should NOT detect TP1 when price is below threshold (+0.5%)', () => { + const trade = createLongTrade() + + // Price at +0.5% ($140.70) should NOT trigger TP1 + const result = shouldTakeProfit1(140.70, trade) + expect(result).toBe(false) + + // Verify profit percent is below TP1 threshold + const profitPercent = calculateExpectedProfitPercent(140, 140.70, 'long') + expect(profitPercent).toBeCloseTo(0.5, 2) + expect(profitPercent).toBeLessThan(0.86) + }) + + it('should NOT detect TP1 when price moves against position (negative)', () => { + const trade = createLongTrade() + + // Price drop for LONG should NOT trigger TP1 + const result = shouldTakeProfit1(139.00, trade) + expect(result).toBe(false) + + // Verify this is a loss + const profitPercent = calculateExpectedProfitPercent(140, 139.00, 'long') + expect(profitPercent).toBeLessThan(0) + }) + + it('should NOT detect TP1 at entry price', () => { + const trade = createLongTrade() + + const result = shouldTakeProfit1(140.00, trade) + expect(result).toBe(false) + }) + }) + + describe('SHORT positions', () => { + it('should detect TP1 when price reaches -0.86% (ATR 0.43 × 2.0)', () => { + // Test data: entry $140, TP1 at $138.80 (-0.86%) + const trade = createShortTrade() + + expect(trade.tp1Price).toBe(TEST_DEFAULTS.short.tp1) + + // Price at TP1 should trigger + const result = shouldTakeProfit1(138.80, trade) + expect(result).toBe(true) + + // Verify profit calculation (SHORT profits when price drops) + const profitPercent = calculateExpectedProfitPercent(140, 138.80, 'short') + expect(profitPercent).toBeCloseTo(0.857, 1) // ~0.86% + }) + + it('should detect TP1 when price is below tp1Price', () => { + const trade = createShortTrade() + + // Price below TP1 should also trigger (better for short) + const result = shouldTakeProfit1(138.00, trade) + expect(result).toBe(true) + }) + + it('should NOT detect TP1 when price is above threshold (-0.5%)', () => { + const trade = createShortTrade() + + // Price at -0.5% ($139.30) should NOT trigger TP1 + const result = shouldTakeProfit1(139.30, trade) + expect(result).toBe(false) + + // Verify profit percent is below TP1 threshold + const profitPercent = calculateExpectedProfitPercent(140, 139.30, 'short') + expect(profitPercent).toBeCloseTo(0.5, 2) + expect(profitPercent).toBeLessThan(0.86) + }) + + it('should NOT detect TP1 when price moves against position (rises)', () => { + const trade = createShortTrade() + + // Price rise for SHORT should NOT trigger TP1 + const result = shouldTakeProfit1(141.00, trade) + expect(result).toBe(false) + + // Verify this is a loss for SHORT + const profitPercent = calculateExpectedProfitPercent(140, 141.00, 'short') + expect(profitPercent).toBeLessThan(0) + }) + + it('should NOT detect TP1 at entry price', () => { + const trade = createShortTrade() + + const result = shouldTakeProfit1(140.00, trade) + expect(result).toBe(false) + }) + }) + + describe('Edge cases', () => { + it('should handle exact TP1 price (boundary condition)', () => { + const longTrade = createLongTrade() + const shortTrade = createShortTrade() + + // Exact TP1 price should trigger + expect(shouldTakeProfit1(longTrade.tp1Price, longTrade)).toBe(true) + expect(shouldTakeProfit1(shortTrade.tp1Price, shortTrade)).toBe(true) + }) + + it('should handle custom TP1 prices', () => { + const trade = createLongTrade({ tp1Price: 145.00 }) + + expect(trade.tp1Price).toBe(145.00) + expect(shouldTakeProfit1(144.99, trade)).toBe(false) + expect(shouldTakeProfit1(145.00, trade)).toBe(true) + expect(shouldTakeProfit1(145.01, trade)).toBe(true) + }) + + it('should work with different entry prices', () => { + // ETH-PERP style entry at $3500 + const trade = createLongTrade({ + entryPrice: 3500, + tp1Price: 3530, // +0.86% + }) + + expect(shouldTakeProfit1(3529, trade)).toBe(false) + expect(shouldTakeProfit1(3530, trade)).toBe(true) + }) + }) +}) diff --git a/tests/integration/position-manager/trailing-stop.test.ts b/tests/integration/position-manager/trailing-stop.test.ts new file mode 100644 index 0000000..2506b1c --- /dev/null +++ b/tests/integration/position-manager/trailing-stop.test.ts @@ -0,0 +1,271 @@ +/** + * Trailing Stop Tests + * + * Tests for trailing stop functionality after TP2 trigger. + * + * Key behaviors tested: + * - Trailing stop activates after TP2 trigger + * - Calculate ATR-based trailing distance (1.5x ATR) + * - Update peak price tracking as price moves favorably + * - Trigger exit when price falls below trailing stop + */ + +import { + createLongTrade, + createShortTrade, + createTradeAfterTP2, + TEST_DEFAULTS +} from '../../helpers/trade-factory' + +describe('Trailing Stop', () => { + // Extract trailing stop calculation logic from Position Manager + function calculateTrailingStopPrice( + peakPrice: number, + trailingDistancePercent: number, + direction: 'long' | 'short' + ): number { + if (direction === 'long') { + return peakPrice * (1 - trailingDistancePercent / 100) + } else { + return peakPrice * (1 + trailingDistancePercent / 100) + } + } + + function calculateAtrBasedTrailingDistance( + atr: number, + currentPrice: number, + multiplier: number, + minPercent: number, + maxPercent: number + ): number { + const atrPercent = (atr / currentPrice) * 100 + const rawDistance = atrPercent * multiplier + return Math.max(minPercent, Math.min(maxPercent, rawDistance)) + } + + function shouldStopLoss(price: number, trade: { direction: 'long' | 'short', stopLossPrice: number }): boolean { + if (trade.direction === 'long') { + return price <= trade.stopLossPrice + } else { + return price >= trade.stopLossPrice + } + } + + describe('Trailing stop activation', () => { + it('should activate trailing stop after TP2 trigger', () => { + const trade = createTradeAfterTP2('long') + + expect(trade.tp2Hit).toBe(true) + expect(trade.trailingStopActive).toBe(true) + }) + + it('should NOT have trailing stop before TP2', () => { + const trade = createLongTrade() + + expect(trade.tp2Hit).toBe(false) + expect(trade.trailingStopActive).toBe(false) + }) + + it('should NOT have trailing stop after TP1 only', () => { + const trade = createLongTrade({ tp1Hit: true }) + + expect(trade.tp1Hit).toBe(true) + expect(trade.tp2Hit).toBe(false) + expect(trade.trailingStopActive).toBe(false) + }) + }) + + describe('ATR-based trailing distance calculation', () => { + it('should calculate trailing distance as 1.5x ATR', () => { + // ATR 0.43 at price $140 + const atr = TEST_DEFAULTS.atr + const currentPrice = TEST_DEFAULTS.entry + const multiplier = 1.5 + const minPercent = 0.25 + const maxPercent = 0.9 + + const distance = calculateAtrBasedTrailingDistance( + atr, currentPrice, multiplier, minPercent, maxPercent + ) + + // 0.43 / 140 * 100 = 0.307% * 1.5 = 0.46% + expect(distance).toBeCloseTo(0.46, 1) + }) + + it('should clamp trailing distance to minimum', () => { + // Very low ATR should clamp to min + const atr = 0.1 + const currentPrice = 140 + const multiplier = 1.5 + const minPercent = 0.25 + const maxPercent = 0.9 + + const distance = calculateAtrBasedTrailingDistance( + atr, currentPrice, multiplier, minPercent, maxPercent + ) + + // 0.1 / 140 * 100 = 0.071% * 1.5 = 0.107% < min 0.25% + expect(distance).toBe(minPercent) + }) + + it('should clamp trailing distance to maximum', () => { + // Very high ATR should clamp to max + const atr = 2.0 + const currentPrice = 140 + const multiplier = 1.5 + const minPercent = 0.25 + const maxPercent = 0.9 + + const distance = calculateAtrBasedTrailingDistance( + atr, currentPrice, multiplier, minPercent, maxPercent + ) + + // 2.0 / 140 * 100 = 1.43% * 1.5 = 2.14% > max 0.9% + expect(distance).toBe(maxPercent) + }) + }) + + describe('Peak price tracking', () => { + it('LONG: should update peak price when price increases', () => { + const trade = createTradeAfterTP2('long', { peakPrice: 142.41 }) + + const newHighPrice = 143.00 + + // Simulate peak price update logic + if (newHighPrice > trade.peakPrice) { + trade.peakPrice = newHighPrice + } + + expect(trade.peakPrice).toBe(143.00) + }) + + it('LONG: should NOT update peak price when price decreases', () => { + const trade = createTradeAfterTP2('long', { peakPrice: 142.41 }) + + const lowerPrice = 142.00 + + // Simulate peak price update logic + if (lowerPrice > trade.peakPrice) { + trade.peakPrice = lowerPrice + } + + expect(trade.peakPrice).toBe(142.41) // Unchanged + }) + + it('SHORT: should update peak price when price decreases', () => { + const trade = createTradeAfterTP2('short', { peakPrice: 137.59 }) + + const newLowPrice = 137.00 + + // Simulate peak price update logic (for short, lower is better) + if (newLowPrice < trade.peakPrice) { + trade.peakPrice = newLowPrice + } + + expect(trade.peakPrice).toBe(137.00) + }) + + it('SHORT: should NOT update peak price when price increases', () => { + const trade = createTradeAfterTP2('short', { peakPrice: 137.59 }) + + const higherPrice = 138.00 + + // Simulate peak price update logic + if (higherPrice < trade.peakPrice) { + trade.peakPrice = higherPrice + } + + expect(trade.peakPrice).toBe(137.59) // Unchanged + }) + }) + + describe('Trailing stop trigger', () => { + it('LONG: should trigger when price falls below trailing SL', () => { + const peakPrice = 143.00 + const trailingPercent = 0.46 // ~0.46% trail + const trailingSL = calculateTrailingStopPrice(peakPrice, trailingPercent, 'long') + + // Trail SL = 143 * (1 - 0.0046) = 142.34 + expect(trailingSL).toBeCloseTo(142.34, 1) + + // Price above trail should not trigger + expect(shouldStopLoss(142.50, { direction: 'long', stopLossPrice: trailingSL })).toBe(false) + + // Price at trail should trigger + expect(shouldStopLoss(trailingSL, { direction: 'long', stopLossPrice: trailingSL })).toBe(true) + + // Price below trail should trigger + expect(shouldStopLoss(142.00, { direction: 'long', stopLossPrice: trailingSL })).toBe(true) + }) + + it('SHORT: should trigger when price rises above trailing SL', () => { + const peakPrice = 137.00 + const trailingPercent = 0.46 + const trailingSL = calculateTrailingStopPrice(peakPrice, trailingPercent, 'short') + + // Trail SL = 137 * (1 + 0.0046) = 137.63 + expect(trailingSL).toBeCloseTo(137.63, 1) + + // Price below trail should not trigger + expect(shouldStopLoss(137.30, { direction: 'short', stopLossPrice: trailingSL })).toBe(false) + + // Price at trail should trigger + expect(shouldStopLoss(trailingSL, { direction: 'short', stopLossPrice: trailingSL })).toBe(true) + + // Price above trail should trigger + expect(shouldStopLoss(138.00, { direction: 'short', stopLossPrice: trailingSL })).toBe(true) + }) + + it('LONG: trailing SL should move up but never down', () => { + let currentTrailingSL = 142.00 + const trailingPercent = 0.46 + + // Price rises to 143, new trail = 142.34 + const newTrailingSL1 = calculateTrailingStopPrice(143.00, trailingPercent, 'long') + if (newTrailingSL1 > currentTrailingSL) { + currentTrailingSL = newTrailingSL1 + } + expect(currentTrailingSL).toBeCloseTo(142.34, 1) + + // Price drops to 142.50, trail should NOT move down + const newTrailingSL2 = calculateTrailingStopPrice(142.50, trailingPercent, 'long') + if (newTrailingSL2 > currentTrailingSL) { + currentTrailingSL = newTrailingSL2 + } + expect(currentTrailingSL).toBeCloseTo(142.34, 1) // Unchanged + + // Price rises to 144, trail should move up + const newTrailingSL3 = calculateTrailingStopPrice(144.00, trailingPercent, 'long') + if (newTrailingSL3 > currentTrailingSL) { + currentTrailingSL = newTrailingSL3 + } + expect(currentTrailingSL).toBeCloseTo(143.34, 1) // Moved up + }) + + it('SHORT: trailing SL should move down but never up', () => { + let currentTrailingSL = 138.00 + const trailingPercent = 0.46 + + // Price drops to 137, new trail = 137.63 + const newTrailingSL1 = calculateTrailingStopPrice(137.00, trailingPercent, 'short') + if (newTrailingSL1 < currentTrailingSL) { + currentTrailingSL = newTrailingSL1 + } + expect(currentTrailingSL).toBeCloseTo(137.63, 1) + + // Price rises to 137.50, trail should NOT move up + const newTrailingSL2 = calculateTrailingStopPrice(137.50, trailingPercent, 'short') + if (newTrailingSL2 < currentTrailingSL) { + currentTrailingSL = newTrailingSL2 + } + expect(currentTrailingSL).toBeCloseTo(137.63, 1) // Unchanged + + // Price drops to 136, trail should move down + const newTrailingSL3 = calculateTrailingStopPrice(136.00, trailingPercent, 'short') + if (newTrailingSL3 < currentTrailingSL) { + currentTrailingSL = newTrailingSL3 + } + expect(currentTrailingSL).toBeCloseTo(136.63, 1) // Moved down + }) + }) +})