@@ -323,6 +323,12 @@ def test_health(fa):
323323
324324# ── Screener ───────────────────────────────────────────────────────
325325
326+ import json as _json
327+
328+
329+ def _screener_body (call ):
330+ return _json .loads (call .request .body )
331+
326332
327333@responses .activate
328334def test_screener_empty (fa ):
@@ -334,12 +340,34 @@ def test_screener_empty(fa):
334340 result = fa .screener ()
335341 assert result ["meta" ]["tier" ] == "growth"
336342 assert result ["data" ][0 ]["symbol" ] == "SPY"
343+ # Empty body
344+ assert _screener_body (responses .calls [0 ]) == {}
337345
338346
339347@responses .activate
340- def test_screener_with_filters (fa ):
341- payload = {"meta" : {"total_count" : 2 , "tier" : "alpha" }, "data" : []}
342- responses .post (f"{ BASE } /v1/screener/live" , json = payload )
348+ def test_screener_sends_post_with_json_content_type (fa ):
349+ responses .post (f"{ BASE } /v1/screener/live" , json = {"meta" : {}, "data" : []})
350+ fa .screener (limit = 5 )
351+ req = responses .calls [0 ].request
352+ assert req .method == "POST"
353+ assert req .url == f"{ BASE } /v1/screener/live"
354+ assert req .headers .get ("Content-Type" ) == "application/json"
355+ assert req .headers .get ("X-Api-Key" ) == "test-key"
356+
357+
358+ @responses .activate
359+ def test_screener_leaf_filter (fa ):
360+ responses .post (f"{ BASE } /v1/screener/live" , json = {"meta" : {}, "data" : []})
361+ fa .screener (filters = {"field" : "regime" , "operator" : "eq" , "value" : "positive_gamma" })
362+ body = _screener_body (responses .calls [0 ])
363+ assert body ["filters" ]["field" ] == "regime"
364+ assert body ["filters" ]["operator" ] == "eq"
365+ assert body ["filters" ]["value" ] == "positive_gamma"
366+
367+
368+ @responses .activate
369+ def test_screener_and_group (fa ):
370+ responses .post (f"{ BASE } /v1/screener/live" , json = {"meta" : {}, "data" : []})
343371 fa .screener (
344372 filters = {
345373 "op" : "and" ,
@@ -352,28 +380,253 @@ def test_screener_with_filters(fa):
352380 select = ["symbol" , "price" , "harvest_score" ],
353381 limit = 20 ,
354382 )
355- # Verify request body
356- import json as _json
357- body = _json .loads (responses .calls [0 ].request .body )
383+ body = _screener_body (responses .calls [0 ])
358384 assert body ["filters" ]["op" ] == "and"
359385 assert len (body ["filters" ]["conditions" ]) == 2
360386 assert body ["limit" ] == 20
361387 assert body ["select" ] == ["symbol" , "price" , "harvest_score" ]
388+ assert body ["sort" ] == [{"field" : "harvest_score" , "direction" : "desc" }]
389+
390+
391+ @responses .activate
392+ def test_screener_or_group (fa ):
393+ responses .post (f"{ BASE } /v1/screener/live" , json = {"meta" : {}, "data" : []})
394+ fa .screener (
395+ filters = {
396+ "op" : "or" ,
397+ "conditions" : [
398+ {"field" : "vrp_regime" , "operator" : "eq" , "value" : "toxic_short_vol" },
399+ {"field" : "vrp_regime" , "operator" : "eq" , "value" : "event_only" },
400+ ],
401+ },
402+ )
403+ body = _screener_body (responses .calls [0 ])
404+ assert body ["filters" ]["op" ] == "or"
405+
406+
407+ @responses .activate
408+ def test_screener_nested_and_inside_or (fa ):
409+ responses .post (f"{ BASE } /v1/screener/live" , json = {"meta" : {}, "data" : []})
410+ fa .screener (
411+ filters = {
412+ "op" : "or" ,
413+ "conditions" : [
414+ {
415+ "op" : "and" ,
416+ "conditions" : [
417+ {"field" : "regime" , "operator" : "eq" , "value" : "positive_gamma" },
418+ {"field" : "harvest_score" , "operator" : "gte" , "value" : 70 },
419+ ],
420+ },
421+ {
422+ "op" : "and" ,
423+ "conditions" : [
424+ {"field" : "regime" , "operator" : "eq" , "value" : "negative_gamma" },
425+ {"field" : "atm_iv" , "operator" : "gte" , "value" : 50 },
426+ ],
427+ },
428+ ],
429+ },
430+ )
431+ body = _screener_body (responses .calls [0 ])
432+ assert body ["filters" ]["op" ] == "or"
433+ assert body ["filters" ]["conditions" ][0 ]["op" ] == "and"
434+ assert body ["filters" ]["conditions" ][1 ]["op" ] == "and"
435+
436+
437+ @responses .activate
438+ def test_screener_between_operator (fa ):
439+ responses .post (f"{ BASE } /v1/screener/live" , json = {"meta" : {}, "data" : []})
440+ fa .screener (filters = {"field" : "atm_iv" , "operator" : "between" , "value" : [15 , 25 ]})
441+ body = _screener_body (responses .calls [0 ])
442+ assert body ["filters" ]["operator" ] == "between"
443+ assert body ["filters" ]["value" ] == [15 , 25 ]
444+
445+
446+ @responses .activate
447+ def test_screener_in_operator (fa ):
448+ responses .post (f"{ BASE } /v1/screener/live" , json = {"meta" : {}, "data" : []})
449+ fa .screener (filters = {"field" : "term_state" , "operator" : "in" , "value" : ["contango" , "mixed" ]})
450+ body = _screener_body (responses .calls [0 ])
451+ assert body ["filters" ]["operator" ] == "in"
452+ assert body ["filters" ]["value" ] == ["contango" , "mixed" ]
453+
454+
455+ @responses .activate
456+ def test_screener_null_operators (fa ):
457+ responses .post (f"{ BASE } /v1/screener/live" , json = {"meta" : {}, "data" : []})
458+ fa .screener (filters = {"field" : "vrp_regime" , "operator" : "is_not_null" })
459+ body = _screener_body (responses .calls [0 ])
460+ assert body ["filters" ]["operator" ] == "is_not_null"
461+ assert "value" not in body ["filters" ]
462+
463+
464+ @responses .activate
465+ def test_screener_cascading_filters (fa ):
466+ """Cascading filters on expiries/strikes/contracts levels."""
467+ responses .post (f"{ BASE } /v1/screener/live" , json = {"meta" : {}, "data" : []})
468+ fa .screener (
469+ filters = {
470+ "op" : "and" ,
471+ "conditions" : [
472+ {"field" : "regime" , "operator" : "eq" , "value" : "positive_gamma" },
473+ {"field" : "expiries.days_to_expiry" , "operator" : "lte" , "value" : 14 },
474+ {"field" : "strikes.call_oi" , "operator" : "gte" , "value" : 2000 },
475+ {"field" : "contracts.type" , "operator" : "eq" , "value" : "C" },
476+ {"field" : "contracts.delta" , "operator" : "gte" , "value" : 0.3 },
477+ ],
478+ },
479+ select = ["*" ],
480+ )
481+ body = _screener_body (responses .calls [0 ])
482+ fields = [c ["field" ] for c in body ["filters" ]["conditions" ]]
483+ assert "expiries.days_to_expiry" in fields
484+ assert "strikes.call_oi" in fields
485+ assert "contracts.type" in fields
486+ assert "contracts.delta" in fields
487+ assert body ["select" ] == ["*" ]
362488
363489
364490@responses .activate
365491def test_screener_with_formulas (fa ):
366- payload = {"meta" : {"total_count" : 1 , "tier" : "alpha" }, "data" : []}
367- responses .post (f"{ BASE } /v1/screener/live" , json = payload )
492+ responses .post (f"{ BASE } /v1/screener/live" , json = {"meta" : {}, "data" : []})
368493 fa .screener (
369494 formulas = [{"alias" : "vrp_ratio" , "expression" : "atm_iv / rv_20d" }],
370495 filters = {"formula" : "vrp_ratio" , "operator" : "gte" , "value" : 1.2 },
371496 sort = [{"formula" : "vrp_ratio" , "direction" : "desc" }],
372497 )
373- import json as _json
374- body = _json .loads (responses .calls [0 ].request .body )
498+ body = _screener_body (responses .calls [0 ])
375499 assert body ["formulas" ][0 ]["alias" ] == "vrp_ratio"
500+ assert body ["formulas" ][0 ]["expression" ] == "atm_iv / rv_20d"
376501 assert body ["filters" ]["formula" ] == "vrp_ratio"
502+ assert body ["sort" ][0 ]["formula" ] == "vrp_ratio"
503+
504+
505+ @responses .activate
506+ def test_screener_inline_formula (fa ):
507+ responses .post (f"{ BASE } /v1/screener/live" , json = {"meta" : {}, "data" : []})
508+ fa .screener (
509+ filters = {"formula" : "atm_iv - rv_20d" , "operator" : "gt" , "value" : 6 },
510+ )
511+ body = _screener_body (responses .calls [0 ])
512+ assert body ["filters" ]["formula" ] == "atm_iv - rv_20d"
513+
514+
515+ @responses .activate
516+ def test_screener_multi_sort (fa ):
517+ responses .post (f"{ BASE } /v1/screener/live" , json = {"meta" : {}, "data" : []})
518+ fa .screener (
519+ sort = [
520+ {"field" : "dealer_flow_risk" , "direction" : "asc" },
521+ {"field" : "harvest_score" , "direction" : "desc" },
522+ ],
523+ select = ["symbol" , "dealer_flow_risk" , "harvest_score" ],
524+ )
525+ body = _screener_body (responses .calls [0 ])
526+ assert len (body ["sort" ]) == 2
527+ assert body ["sort" ][0 ]["direction" ] == "asc"
528+ assert body ["sort" ][1 ]["direction" ] == "desc"
529+
530+
531+ @responses .activate
532+ def test_screener_pagination (fa ):
533+ responses .post (f"{ BASE } /v1/screener/live" , json = {"meta" : {}, "data" : []})
534+ fa .screener (limit = 10 , offset = 10 )
535+ body = _screener_body (responses .calls [0 ])
536+ assert body ["limit" ] == 10
537+ assert body ["offset" ] == 10
538+
539+
540+ @responses .activate
541+ def test_screener_negative_number (fa ):
542+ responses .post (f"{ BASE } /v1/screener/live" , json = {"meta" : {}, "data" : []})
543+ fa .screener (filters = {"field" : "net_gex" , "operator" : "lt" , "value" : - 500000 })
544+ body = _screener_body (responses .calls [0 ])
545+ assert body ["filters" ]["value" ] == - 500000
546+
547+
548+ @responses .activate
549+ def test_screener_select_star (fa ):
550+ responses .post (f"{ BASE } /v1/screener/live" , json = {"meta" : {}, "data" : []})
551+ fa .screener (select = ["*" ])
552+ body = _screener_body (responses .calls [0 ])
553+ assert body ["select" ] == ["*" ]
554+
555+
556+ @responses .activate
557+ def test_screener_select_star_with_formula (fa ):
558+ responses .post (f"{ BASE } /v1/screener/live" , json = {"meta" : {}, "data" : []})
559+ fa .screener (
560+ formulas = [{"alias" : "ratio" , "expression" : "call_wall / (put_wall + 30)" }],
561+ select = ["*" , "ratio" ],
562+ )
563+ body = _screener_body (responses .calls [0 ])
564+ assert body ["select" ] == ["*" , "ratio" ]
565+
566+
567+ @responses .activate
568+ def test_screener_returns_response_structure (fa ):
569+ payload = {
570+ "meta" : {
571+ "total_count" : 7 ,
572+ "returned_count" : 7 ,
573+ "universe_size" : 250 ,
574+ "offset" : 0 ,
575+ "limit" : 50 ,
576+ "tier" : "alpha" ,
577+ "as_of" : "2026-04-05T10:30:00Z" ,
578+ },
579+ "data" : [
580+ {"symbol" : "SPY" , "price" : 656.01 , "regime" : "positive_gamma" , "atm_iv" : 20.7 }
581+ ],
582+ }
583+ responses .post (f"{ BASE } /v1/screener/live" , json = payload )
584+ result = fa .screener ()
585+ assert result ["meta" ]["tier" ] == "alpha"
586+ assert result ["meta" ]["universe_size" ] == 250
587+ assert result ["data" ][0 ]["price" ] == 656.01
588+
589+
590+ @responses .activate
591+ def test_screener_tier_restricted_alpha_field (fa ):
592+ err_body = {
593+ "status" : "ERROR" ,
594+ "error" : "validation_error" ,
595+ "message" : "Field 'harvest_score' requires the Alpha plan or higher." ,
596+ }
597+ responses .post (f"{ BASE } /v1/screener/live" , json = err_body , status = 400 )
598+ with pytest .raises (FlashAlphaError ) as exc :
599+ fa .screener (filters = {"field" : "harvest_score" , "operator" : "gte" , "value" : 65 })
600+ assert exc .value .status_code == 400
601+ assert "Alpha" in str (exc .value )
602+
603+
604+ @responses .activate
605+ def test_screener_formula_error (fa ):
606+ err_body = {
607+ "status" : "ERROR" ,
608+ "error" : "formula_error" ,
609+ "message" : "Unexpected token '+' at position 5" ,
610+ }
611+ responses .post (f"{ BASE } /v1/screener/live" , json = err_body , status = 400 )
612+ with pytest .raises (FlashAlphaError ):
613+ fa .screener (formulas = [{"alias" : "bad" , "expression" : "+ atm_iv" }])
614+
615+
616+ @responses .activate
617+ def test_screener_tier_restricted_403 (fa ):
618+ err_body = {
619+ "status" : "ERROR" ,
620+ "error" : "tier_restricted" ,
621+ "message" : "Screener requires Growth plan or higher." ,
622+ "current_plan" : "Free" ,
623+ "required_plan" : "Growth" ,
624+ }
625+ responses .post (f"{ BASE } /v1/screener/live" , json = err_body , status = 403 )
626+ with pytest .raises (TierRestrictedError ) as exc :
627+ fa .screener ()
628+ assert exc .value .current_plan == "Free"
629+ assert exc .value .required_plan == "Growth"
377630
378631
379632# ── Client config ──────────────────────────────────────────────────
0 commit comments