flashalpha 0.3.0__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {flashalpha-0.3.0 → flashalpha-0.3.2}/.claude/settings.json +8 -1
- {flashalpha-0.3.0 → flashalpha-0.3.2}/PKG-INFO +1 -1
- {flashalpha-0.3.0 → flashalpha-0.3.2}/pyproject.toml +1 -1
- {flashalpha-0.3.0 → flashalpha-0.3.2}/src/flashalpha/__init__.py +1 -1
- {flashalpha-0.3.0 → flashalpha-0.3.2}/src/flashalpha/client.py +20 -1
- {flashalpha-0.3.0 → flashalpha-0.3.2}/tests/test_client.py +91 -22
- {flashalpha-0.3.0 → flashalpha-0.3.2}/tests/test_integration.py +49 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/.github/workflows/ci.yml +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/.gitignore +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/CHANGELOG.md +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/CONTRIBUTING.md +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/LICENSE +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/README.md +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/article-flashalpha-python-sdk.md +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/awesome-investing/CODE_OF_CONDUCT.md +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/awesome-investing/LICENSE +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/awesome-investing/PULL_REQUEST_TEMPLATE.md +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/awesome-investing/README.md +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/awesome-investing/contributing.md +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/awesome_investing/README.md +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/awesome_investing/src/static/img/.gitkeep +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/awesome_investing/src/static/img/markus-spiske-5gGcn2PRrtc-unsplash.jpg +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/examples/quickstart.py +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/src/flashalpha/exceptions.py +0 -0
- {flashalpha-0.3.0 → flashalpha-0.3.2}/tests/__init__.py +0 -0
|
@@ -38,7 +38,14 @@
|
|
|
38
38
|
"Bash(FLASHALPHA_API_KEY=\"ejbe0oW5f4n7C7drEg7Ggo6bPtFuFpaLPmvmkDrE\" npx jest tests/integration.test.ts -t \"screener\")",
|
|
39
39
|
"Bash(FLASHALPHA_API_KEY=\"ejbe0oW5f4n7C7drEg7Ggo6bPtFuFpaLPmvmkDrE\" \"C:/Users/disea/tools/apache-maven-3.9.14/bin/mvn.cmd\" test -Dtest=\"IntegrationTest#testScreener*\")",
|
|
40
40
|
"Bash(where.exe go:*)",
|
|
41
|
-
"Bash(npm whoami:*)"
|
|
41
|
+
"Bash(npm whoami:*)",
|
|
42
|
+
"Bash(grep -rhE \"oy2[a-z0-9]{40,46}|NUGET_API_KEY|NUGET_TOKEN\" --include=\"*.env\" --include=\"*.env.local\" --include=\"*.env.example\" --include=\".env*\")",
|
|
43
|
+
"Bash(npm profile:*)",
|
|
44
|
+
"Bash(NPM_CONFIG_TOKEN=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 npm publish --//registry.npmjs.org/:_authToken=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)",
|
|
45
|
+
"Bash(npm config:*)",
|
|
46
|
+
"Bash(export PATH=\"$PATH:/c/Program Files/Go/bin\")",
|
|
47
|
+
"Bash(go test:*)",
|
|
48
|
+
"Bash(FLASHALPHA_API_KEY=\"ejbe0oW5f4n7C7drEg7Ggo6bPtFuFpaLPmvmkDrE\" \"C:/Users/disea/tools/apache-maven-3.9.14/bin/mvn.cmd\" test -Dtest=\"IntegrationTest#testMaxPain*\")"
|
|
42
49
|
],
|
|
43
50
|
"additionalDirectories": [
|
|
44
51
|
"C:\\Users\\disea"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flashalpha
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Python SDK for the FlashAlpha options analytics API — live options screener, gamma exposure (GEX), VRP, delta, vanna, charm, greeks, 0DTE analytics, volatility surfaces, and more.
|
|
5
5
|
Project-URL: Homepage, https://flashalpha.com
|
|
6
6
|
Project-URL: Documentation, https://flashalpha.com/docs
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "flashalpha"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "Python SDK for the FlashAlpha options analytics API — live options screener, gamma exposure (GEX), VRP, delta, vanna, charm, greeks, 0DTE analytics, volatility surfaces, and more."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -299,6 +299,25 @@ class FlashAlpha:
|
|
|
299
299
|
"""Currently queried symbols with live data."""
|
|
300
300
|
return self._get("/v1/symbols")
|
|
301
301
|
|
|
302
|
+
# ── Max Pain ────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
def max_pain(self, symbol: str, *, expiration: str | None = None) -> dict:
|
|
305
|
+
"""Max pain analysis with dealer alignment overlay, pain curve, OI
|
|
306
|
+
breakdown, expected move context, pin probability, and multi-expiry
|
|
307
|
+
calendar. Requires Growth+.
|
|
308
|
+
|
|
309
|
+
Parameters
|
|
310
|
+
----------
|
|
311
|
+
symbol : str
|
|
312
|
+
Underlying symbol.
|
|
313
|
+
expiration : str, optional
|
|
314
|
+
Filter to single expiry (YYYY-MM-DD). Omit for full-chain analysis.
|
|
315
|
+
"""
|
|
316
|
+
params: dict[str, Any] = {}
|
|
317
|
+
if expiration:
|
|
318
|
+
params["expiration"] = expiration
|
|
319
|
+
return self._get(f"/v1/maxpain/{symbol}", params or None)
|
|
320
|
+
|
|
302
321
|
# ── Screener ────────────────────────────────────────────────────
|
|
303
322
|
|
|
304
323
|
def screener(
|
|
@@ -375,7 +394,7 @@ class FlashAlpha:
|
|
|
375
394
|
body["limit"] = limit
|
|
376
395
|
if offset is not None:
|
|
377
396
|
body["offset"] = offset
|
|
378
|
-
return self._post("/v1/screener
|
|
397
|
+
return self._post("/v1/screener", body)
|
|
379
398
|
|
|
380
399
|
# ── Account & System ────────────────────────────────────────────
|
|
381
400
|
|
|
@@ -321,6 +321,75 @@ def test_health(fa):
|
|
|
321
321
|
assert result["status"] == "Healthy"
|
|
322
322
|
|
|
323
323
|
|
|
324
|
+
# ── Max Pain ────────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@responses.activate
|
|
328
|
+
def test_max_pain(fa):
|
|
329
|
+
payload = {"symbol": "SPY", "max_pain_strike": 545, "pin_probability": 68}
|
|
330
|
+
responses.get(f"{BASE}/v1/maxpain/SPY", json=payload)
|
|
331
|
+
result = fa.max_pain("SPY")
|
|
332
|
+
assert result["max_pain_strike"] == 545
|
|
333
|
+
assert result["pin_probability"] == 68
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@responses.activate
|
|
337
|
+
def test_max_pain_calls_correct_path(fa):
|
|
338
|
+
responses.get(f"{BASE}/v1/maxpain/AAPL", json={})
|
|
339
|
+
fa.max_pain("AAPL")
|
|
340
|
+
assert "/v1/maxpain/AAPL" in responses.calls[0].request.url
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@responses.activate
|
|
344
|
+
def test_max_pain_with_expiration(fa):
|
|
345
|
+
responses.get(f"{BASE}/v1/maxpain/SPY", json={})
|
|
346
|
+
fa.max_pain("SPY", expiration="2026-04-17")
|
|
347
|
+
assert "expiration=2026-04-17" in responses.calls[0].request.url
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@responses.activate
|
|
351
|
+
def test_max_pain_without_expiration(fa):
|
|
352
|
+
responses.get(f"{BASE}/v1/maxpain/SPY", json={"max_pain_by_expiration": [{"expiration": "2026-04-17"}]})
|
|
353
|
+
result = fa.max_pain("SPY")
|
|
354
|
+
assert "max_pain_by_expiration" in result
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@responses.activate
|
|
358
|
+
def test_max_pain_full_response(fa):
|
|
359
|
+
payload = {
|
|
360
|
+
"symbol": "SPY",
|
|
361
|
+
"underlying_price": 548.32,
|
|
362
|
+
"max_pain_strike": 545,
|
|
363
|
+
"distance": {"absolute": 3.32, "percent": 0.61, "direction": "above"},
|
|
364
|
+
"signal": "neutral",
|
|
365
|
+
"put_call_oi_ratio": 1.284,
|
|
366
|
+
"pain_curve": [{"strike": 545, "total_pain": 3700000}],
|
|
367
|
+
"oi_by_strike": [{"strike": 545, "call_oi": 35000, "put_oi": 42000}],
|
|
368
|
+
"dealer_alignment": {"alignment": "converging", "gamma_flip": 546},
|
|
369
|
+
"regime": "positive_gamma",
|
|
370
|
+
"expected_move": {"max_pain_within_expected_range": True},
|
|
371
|
+
"pin_probability": 68,
|
|
372
|
+
}
|
|
373
|
+
responses.get(f"{BASE}/v1/maxpain/SPY", json=payload)
|
|
374
|
+
result = fa.max_pain("SPY")
|
|
375
|
+
assert result["distance"]["direction"] == "above"
|
|
376
|
+
assert result["dealer_alignment"]["alignment"] == "converging"
|
|
377
|
+
assert result["signal"] == "neutral"
|
|
378
|
+
assert result["regime"] == "positive_gamma"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@responses.activate
|
|
382
|
+
def test_max_pain_403_tier_restricted(fa):
|
|
383
|
+
responses.get(
|
|
384
|
+
f"{BASE}/v1/maxpain/SPY",
|
|
385
|
+
json={"status": "ERROR", "error": "tier_restricted", "message": "Requires Growth plan.", "current_plan": "Free", "required_plan": "Growth"},
|
|
386
|
+
status=403,
|
|
387
|
+
)
|
|
388
|
+
with pytest.raises(TierRestrictedError) as exc:
|
|
389
|
+
fa.max_pain("SPY")
|
|
390
|
+
assert exc.value.current_plan == "Free"
|
|
391
|
+
|
|
392
|
+
|
|
324
393
|
# ── Screener ───────────────────────────────────────────────────────
|
|
325
394
|
|
|
326
395
|
import json as _json
|
|
@@ -336,7 +405,7 @@ def test_screener_empty(fa):
|
|
|
336
405
|
"meta": {"total_count": 10, "returned_count": 10, "tier": "growth"},
|
|
337
406
|
"data": [{"symbol": "SPY", "price": 656.01}],
|
|
338
407
|
}
|
|
339
|
-
responses.post(f"{BASE}/v1/screener
|
|
408
|
+
responses.post(f"{BASE}/v1/screener", json=payload)
|
|
340
409
|
result = fa.screener()
|
|
341
410
|
assert result["meta"]["tier"] == "growth"
|
|
342
411
|
assert result["data"][0]["symbol"] == "SPY"
|
|
@@ -346,18 +415,18 @@ def test_screener_empty(fa):
|
|
|
346
415
|
|
|
347
416
|
@responses.activate
|
|
348
417
|
def test_screener_sends_post_with_json_content_type(fa):
|
|
349
|
-
responses.post(f"{BASE}/v1/screener
|
|
418
|
+
responses.post(f"{BASE}/v1/screener", json={"meta": {}, "data": []})
|
|
350
419
|
fa.screener(limit=5)
|
|
351
420
|
req = responses.calls[0].request
|
|
352
421
|
assert req.method == "POST"
|
|
353
|
-
assert req.url == f"{BASE}/v1/screener
|
|
422
|
+
assert req.url == f"{BASE}/v1/screener"
|
|
354
423
|
assert req.headers.get("Content-Type") == "application/json"
|
|
355
424
|
assert req.headers.get("X-Api-Key") == "test-key"
|
|
356
425
|
|
|
357
426
|
|
|
358
427
|
@responses.activate
|
|
359
428
|
def test_screener_leaf_filter(fa):
|
|
360
|
-
responses.post(f"{BASE}/v1/screener
|
|
429
|
+
responses.post(f"{BASE}/v1/screener", json={"meta": {}, "data": []})
|
|
361
430
|
fa.screener(filters={"field": "regime", "operator": "eq", "value": "positive_gamma"})
|
|
362
431
|
body = _screener_body(responses.calls[0])
|
|
363
432
|
assert body["filters"]["field"] == "regime"
|
|
@@ -367,7 +436,7 @@ def test_screener_leaf_filter(fa):
|
|
|
367
436
|
|
|
368
437
|
@responses.activate
|
|
369
438
|
def test_screener_and_group(fa):
|
|
370
|
-
responses.post(f"{BASE}/v1/screener
|
|
439
|
+
responses.post(f"{BASE}/v1/screener", json={"meta": {}, "data": []})
|
|
371
440
|
fa.screener(
|
|
372
441
|
filters={
|
|
373
442
|
"op": "and",
|
|
@@ -390,7 +459,7 @@ def test_screener_and_group(fa):
|
|
|
390
459
|
|
|
391
460
|
@responses.activate
|
|
392
461
|
def test_screener_or_group(fa):
|
|
393
|
-
responses.post(f"{BASE}/v1/screener
|
|
462
|
+
responses.post(f"{BASE}/v1/screener", json={"meta": {}, "data": []})
|
|
394
463
|
fa.screener(
|
|
395
464
|
filters={
|
|
396
465
|
"op": "or",
|
|
@@ -406,7 +475,7 @@ def test_screener_or_group(fa):
|
|
|
406
475
|
|
|
407
476
|
@responses.activate
|
|
408
477
|
def test_screener_nested_and_inside_or(fa):
|
|
409
|
-
responses.post(f"{BASE}/v1/screener
|
|
478
|
+
responses.post(f"{BASE}/v1/screener", json={"meta": {}, "data": []})
|
|
410
479
|
fa.screener(
|
|
411
480
|
filters={
|
|
412
481
|
"op": "or",
|
|
@@ -436,7 +505,7 @@ def test_screener_nested_and_inside_or(fa):
|
|
|
436
505
|
|
|
437
506
|
@responses.activate
|
|
438
507
|
def test_screener_between_operator(fa):
|
|
439
|
-
responses.post(f"{BASE}/v1/screener
|
|
508
|
+
responses.post(f"{BASE}/v1/screener", json={"meta": {}, "data": []})
|
|
440
509
|
fa.screener(filters={"field": "atm_iv", "operator": "between", "value": [15, 25]})
|
|
441
510
|
body = _screener_body(responses.calls[0])
|
|
442
511
|
assert body["filters"]["operator"] == "between"
|
|
@@ -445,7 +514,7 @@ def test_screener_between_operator(fa):
|
|
|
445
514
|
|
|
446
515
|
@responses.activate
|
|
447
516
|
def test_screener_in_operator(fa):
|
|
448
|
-
responses.post(f"{BASE}/v1/screener
|
|
517
|
+
responses.post(f"{BASE}/v1/screener", json={"meta": {}, "data": []})
|
|
449
518
|
fa.screener(filters={"field": "term_state", "operator": "in", "value": ["contango", "mixed"]})
|
|
450
519
|
body = _screener_body(responses.calls[0])
|
|
451
520
|
assert body["filters"]["operator"] == "in"
|
|
@@ -454,7 +523,7 @@ def test_screener_in_operator(fa):
|
|
|
454
523
|
|
|
455
524
|
@responses.activate
|
|
456
525
|
def test_screener_null_operators(fa):
|
|
457
|
-
responses.post(f"{BASE}/v1/screener
|
|
526
|
+
responses.post(f"{BASE}/v1/screener", json={"meta": {}, "data": []})
|
|
458
527
|
fa.screener(filters={"field": "vrp_regime", "operator": "is_not_null"})
|
|
459
528
|
body = _screener_body(responses.calls[0])
|
|
460
529
|
assert body["filters"]["operator"] == "is_not_null"
|
|
@@ -464,7 +533,7 @@ def test_screener_null_operators(fa):
|
|
|
464
533
|
@responses.activate
|
|
465
534
|
def test_screener_cascading_filters(fa):
|
|
466
535
|
"""Cascading filters on expiries/strikes/contracts levels."""
|
|
467
|
-
responses.post(f"{BASE}/v1/screener
|
|
536
|
+
responses.post(f"{BASE}/v1/screener", json={"meta": {}, "data": []})
|
|
468
537
|
fa.screener(
|
|
469
538
|
filters={
|
|
470
539
|
"op": "and",
|
|
@@ -489,7 +558,7 @@ def test_screener_cascading_filters(fa):
|
|
|
489
558
|
|
|
490
559
|
@responses.activate
|
|
491
560
|
def test_screener_with_formulas(fa):
|
|
492
|
-
responses.post(f"{BASE}/v1/screener
|
|
561
|
+
responses.post(f"{BASE}/v1/screener", json={"meta": {}, "data": []})
|
|
493
562
|
fa.screener(
|
|
494
563
|
formulas=[{"alias": "vrp_ratio", "expression": "atm_iv / rv_20d"}],
|
|
495
564
|
filters={"formula": "vrp_ratio", "operator": "gte", "value": 1.2},
|
|
@@ -504,7 +573,7 @@ def test_screener_with_formulas(fa):
|
|
|
504
573
|
|
|
505
574
|
@responses.activate
|
|
506
575
|
def test_screener_inline_formula(fa):
|
|
507
|
-
responses.post(f"{BASE}/v1/screener
|
|
576
|
+
responses.post(f"{BASE}/v1/screener", json={"meta": {}, "data": []})
|
|
508
577
|
fa.screener(
|
|
509
578
|
filters={"formula": "atm_iv - rv_20d", "operator": "gt", "value": 6},
|
|
510
579
|
)
|
|
@@ -514,7 +583,7 @@ def test_screener_inline_formula(fa):
|
|
|
514
583
|
|
|
515
584
|
@responses.activate
|
|
516
585
|
def test_screener_multi_sort(fa):
|
|
517
|
-
responses.post(f"{BASE}/v1/screener
|
|
586
|
+
responses.post(f"{BASE}/v1/screener", json={"meta": {}, "data": []})
|
|
518
587
|
fa.screener(
|
|
519
588
|
sort=[
|
|
520
589
|
{"field": "dealer_flow_risk", "direction": "asc"},
|
|
@@ -530,7 +599,7 @@ def test_screener_multi_sort(fa):
|
|
|
530
599
|
|
|
531
600
|
@responses.activate
|
|
532
601
|
def test_screener_pagination(fa):
|
|
533
|
-
responses.post(f"{BASE}/v1/screener
|
|
602
|
+
responses.post(f"{BASE}/v1/screener", json={"meta": {}, "data": []})
|
|
534
603
|
fa.screener(limit=10, offset=10)
|
|
535
604
|
body = _screener_body(responses.calls[0])
|
|
536
605
|
assert body["limit"] == 10
|
|
@@ -539,7 +608,7 @@ def test_screener_pagination(fa):
|
|
|
539
608
|
|
|
540
609
|
@responses.activate
|
|
541
610
|
def test_screener_negative_number(fa):
|
|
542
|
-
responses.post(f"{BASE}/v1/screener
|
|
611
|
+
responses.post(f"{BASE}/v1/screener", json={"meta": {}, "data": []})
|
|
543
612
|
fa.screener(filters={"field": "net_gex", "operator": "lt", "value": -500000})
|
|
544
613
|
body = _screener_body(responses.calls[0])
|
|
545
614
|
assert body["filters"]["value"] == -500000
|
|
@@ -547,7 +616,7 @@ def test_screener_negative_number(fa):
|
|
|
547
616
|
|
|
548
617
|
@responses.activate
|
|
549
618
|
def test_screener_select_star(fa):
|
|
550
|
-
responses.post(f"{BASE}/v1/screener
|
|
619
|
+
responses.post(f"{BASE}/v1/screener", json={"meta": {}, "data": []})
|
|
551
620
|
fa.screener(select=["*"])
|
|
552
621
|
body = _screener_body(responses.calls[0])
|
|
553
622
|
assert body["select"] == ["*"]
|
|
@@ -555,7 +624,7 @@ def test_screener_select_star(fa):
|
|
|
555
624
|
|
|
556
625
|
@responses.activate
|
|
557
626
|
def test_screener_select_star_with_formula(fa):
|
|
558
|
-
responses.post(f"{BASE}/v1/screener
|
|
627
|
+
responses.post(f"{BASE}/v1/screener", json={"meta": {}, "data": []})
|
|
559
628
|
fa.screener(
|
|
560
629
|
formulas=[{"alias": "ratio", "expression": "call_wall / (put_wall + 30)"}],
|
|
561
630
|
select=["*", "ratio"],
|
|
@@ -580,7 +649,7 @@ def test_screener_returns_response_structure(fa):
|
|
|
580
649
|
{"symbol": "SPY", "price": 656.01, "regime": "positive_gamma", "atm_iv": 20.7}
|
|
581
650
|
],
|
|
582
651
|
}
|
|
583
|
-
responses.post(f"{BASE}/v1/screener
|
|
652
|
+
responses.post(f"{BASE}/v1/screener", json=payload)
|
|
584
653
|
result = fa.screener()
|
|
585
654
|
assert result["meta"]["tier"] == "alpha"
|
|
586
655
|
assert result["meta"]["universe_size"] == 250
|
|
@@ -594,7 +663,7 @@ def test_screener_tier_restricted_alpha_field(fa):
|
|
|
594
663
|
"error": "validation_error",
|
|
595
664
|
"message": "Field 'harvest_score' requires the Alpha plan or higher.",
|
|
596
665
|
}
|
|
597
|
-
responses.post(f"{BASE}/v1/screener
|
|
666
|
+
responses.post(f"{BASE}/v1/screener", json=err_body, status=400)
|
|
598
667
|
with pytest.raises(FlashAlphaError) as exc:
|
|
599
668
|
fa.screener(filters={"field": "harvest_score", "operator": "gte", "value": 65})
|
|
600
669
|
assert exc.value.status_code == 400
|
|
@@ -608,7 +677,7 @@ def test_screener_formula_error(fa):
|
|
|
608
677
|
"error": "formula_error",
|
|
609
678
|
"message": "Unexpected token '+' at position 5",
|
|
610
679
|
}
|
|
611
|
-
responses.post(f"{BASE}/v1/screener
|
|
680
|
+
responses.post(f"{BASE}/v1/screener", json=err_body, status=400)
|
|
612
681
|
with pytest.raises(FlashAlphaError):
|
|
613
682
|
fa.screener(formulas=[{"alias": "bad", "expression": "+ atm_iv"}])
|
|
614
683
|
|
|
@@ -622,7 +691,7 @@ def test_screener_tier_restricted_403(fa):
|
|
|
622
691
|
"current_plan": "Free",
|
|
623
692
|
"required_plan": "Growth",
|
|
624
693
|
}
|
|
625
|
-
responses.post(f"{BASE}/v1/screener
|
|
694
|
+
responses.post(f"{BASE}/v1/screener", json=err_body, status=403)
|
|
626
695
|
with pytest.raises(TierRestrictedError) as exc:
|
|
627
696
|
fa.screener()
|
|
628
697
|
assert exc.value.current_plan == "Free"
|
|
@@ -221,6 +221,55 @@ def test_symbols(fa):
|
|
|
221
221
|
assert isinstance(result["symbols"], list)
|
|
222
222
|
|
|
223
223
|
|
|
224
|
+
# ── Max Pain ─────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_max_pain(fa):
|
|
228
|
+
result = fa.max_pain("SPY")
|
|
229
|
+
assert "max_pain_strike" in result
|
|
230
|
+
assert "pain_curve" in result
|
|
231
|
+
assert "dealer_alignment" in result
|
|
232
|
+
assert "pin_probability" in result
|
|
233
|
+
assert isinstance(result["pain_curve"], list)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_max_pain_response_fields(fa):
|
|
237
|
+
result = fa.max_pain("SPY")
|
|
238
|
+
assert "distance" in result
|
|
239
|
+
assert result["distance"]["direction"] in ("above", "below", "at")
|
|
240
|
+
assert result["signal"] in ("bullish", "bearish", "neutral")
|
|
241
|
+
assert result["regime"] in ("positive_gamma", "negative_gamma")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_max_pain_with_expiration(fa):
|
|
245
|
+
# Get first available expiration
|
|
246
|
+
opts = fa.options("SPY")
|
|
247
|
+
if opts.get("expirations") and len(opts["expirations"]) > 0:
|
|
248
|
+
exp = opts["expirations"][0]["expiration"]
|
|
249
|
+
result = fa.max_pain("SPY", expiration=exp)
|
|
250
|
+
assert result["expiration"] == exp
|
|
251
|
+
assert "max_pain_strike" in result
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_max_pain_oi_by_strike(fa):
|
|
255
|
+
result = fa.max_pain("SPY")
|
|
256
|
+
if result.get("oi_by_strike"):
|
|
257
|
+
row = result["oi_by_strike"][0]
|
|
258
|
+
assert "strike" in row
|
|
259
|
+
assert "call_oi" in row
|
|
260
|
+
assert "put_oi" in row
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_max_pain_multi_expiry_calendar(fa):
|
|
264
|
+
"""Without expiration filter, max_pain_by_expiration should be populated."""
|
|
265
|
+
result = fa.max_pain("SPY")
|
|
266
|
+
if result.get("max_pain_by_expiration"):
|
|
267
|
+
entry = result["max_pain_by_expiration"][0]
|
|
268
|
+
assert "expiration" in entry
|
|
269
|
+
assert "max_pain_strike" in entry
|
|
270
|
+
assert "dte" in entry
|
|
271
|
+
|
|
272
|
+
|
|
224
273
|
# ── Screener ────────────────────────────────────────────────────────
|
|
225
274
|
|
|
226
275
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|