Skip to content

Commit bde6d52

Browse files
tdobrowolski1claude
andcommitted
Add thorough unit and integration tests for screener endpoint
Unit tests (18 new): leaf/group filters, AND/OR/nested-AND-in-OR, between/in/is_null/is_not_null operators, cascading expiries/strikes/ contracts filters, formulas (alias + inline), multi-sort, pagination, negative numbers, select=['*'], response structure, and error paths (400 validation_error, formula_error, 403 tier_restricted). Integration tests (8 new): empty request, simple filter, AND group with sort-order verification, between operator, select=['*'], limit enforcement, meta field presence, invalid-field rejection. All verified green against live lab.flashalpha.com/v1/screener/live. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 030eb79 commit bde6d52

2 files changed

Lines changed: 346 additions & 10 deletions

File tree

tests/test_client.py

Lines changed: 263 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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
328334
def 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
365491
def 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 ──────────────────────────────────────────────────

tests/test_integration.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,86 @@ def test_symbols(fa):
219219
result = fa.symbols()
220220
assert "symbols" in result
221221
assert isinstance(result["symbols"], list)
222+
223+
224+
# ── Screener ────────────────────────────────────────────────────────
225+
226+
227+
def test_screener_empty(fa):
228+
"""Empty request returns default universe for the account's tier."""
229+
result = fa.screener()
230+
assert "meta" in result
231+
assert "data" in result
232+
assert result["meta"]["tier"] in ("growth", "alpha")
233+
assert isinstance(result["data"], list)
234+
235+
236+
def test_screener_simple_filter(fa):
237+
"""Leaf filter on a universally-available field."""
238+
result = fa.screener(
239+
filters={"field": "regime", "operator": "in", "value": ["positive_gamma", "negative_gamma"]},
240+
select=["symbol", "price", "regime"],
241+
limit=5,
242+
)
243+
assert "data" in result
244+
for row in result["data"]:
245+
assert row["regime"] in ("positive_gamma", "negative_gamma")
246+
247+
248+
def test_screener_and_group(fa):
249+
result = fa.screener(
250+
filters={
251+
"op": "and",
252+
"conditions": [
253+
{"field": "atm_iv", "operator": "gte", "value": 0},
254+
{"field": "atm_iv", "operator": "lte", "value": 500},
255+
],
256+
},
257+
sort=[{"field": "atm_iv", "direction": "desc"}],
258+
select=["symbol", "atm_iv"],
259+
limit=5,
260+
)
261+
assert result["meta"]["returned_count"] <= 5
262+
# Sorted descending
263+
ivs = [row["atm_iv"] for row in result["data"] if row.get("atm_iv") is not None]
264+
if len(ivs) >= 2:
265+
assert ivs == sorted(ivs, reverse=True)
266+
267+
268+
def test_screener_between_operator(fa):
269+
result = fa.screener(
270+
filters={"field": "atm_iv", "operator": "between", "value": [0, 500]},
271+
limit=3,
272+
)
273+
assert "data" in result
274+
275+
276+
def test_screener_select_star(fa):
277+
result = fa.screener(select=["*"], limit=1)
278+
if result["data"]:
279+
row = result["data"][0]
280+
assert "symbol" in row
281+
# select=["*"] returns the full flat object
282+
assert "price" in row
283+
284+
285+
def test_screener_limit_respected(fa):
286+
result = fa.screener(limit=3)
287+
assert result["meta"]["returned_count"] <= 3
288+
assert len(result["data"]) <= 3
289+
290+
291+
def test_screener_meta_fields_present(fa):
292+
result = fa.screener()
293+
meta = result["meta"]
294+
assert "total_count" in meta
295+
assert "returned_count" in meta
296+
assert "universe_size" in meta
297+
assert "tier" in meta
298+
assert "as_of" in meta
299+
300+
301+
def test_screener_invalid_field_raises(fa):
302+
"""Unknown field should trigger a validation error."""
303+
with pytest.raises(Exception):
304+
fa.screener(filters={"field": "not_a_real_field_xyz", "operator": "eq", "value": 1})

0 commit comments

Comments
 (0)