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.
Files changed (25) hide show
  1. {flashalpha-0.3.0 → flashalpha-0.3.2}/.claude/settings.json +8 -1
  2. {flashalpha-0.3.0 → flashalpha-0.3.2}/PKG-INFO +1 -1
  3. {flashalpha-0.3.0 → flashalpha-0.3.2}/pyproject.toml +1 -1
  4. {flashalpha-0.3.0 → flashalpha-0.3.2}/src/flashalpha/__init__.py +1 -1
  5. {flashalpha-0.3.0 → flashalpha-0.3.2}/src/flashalpha/client.py +20 -1
  6. {flashalpha-0.3.0 → flashalpha-0.3.2}/tests/test_client.py +91 -22
  7. {flashalpha-0.3.0 → flashalpha-0.3.2}/tests/test_integration.py +49 -0
  8. {flashalpha-0.3.0 → flashalpha-0.3.2}/.github/workflows/ci.yml +0 -0
  9. {flashalpha-0.3.0 → flashalpha-0.3.2}/.gitignore +0 -0
  10. {flashalpha-0.3.0 → flashalpha-0.3.2}/CHANGELOG.md +0 -0
  11. {flashalpha-0.3.0 → flashalpha-0.3.2}/CONTRIBUTING.md +0 -0
  12. {flashalpha-0.3.0 → flashalpha-0.3.2}/LICENSE +0 -0
  13. {flashalpha-0.3.0 → flashalpha-0.3.2}/README.md +0 -0
  14. {flashalpha-0.3.0 → flashalpha-0.3.2}/article-flashalpha-python-sdk.md +0 -0
  15. {flashalpha-0.3.0 → flashalpha-0.3.2}/awesome-investing/CODE_OF_CONDUCT.md +0 -0
  16. {flashalpha-0.3.0 → flashalpha-0.3.2}/awesome-investing/LICENSE +0 -0
  17. {flashalpha-0.3.0 → flashalpha-0.3.2}/awesome-investing/PULL_REQUEST_TEMPLATE.md +0 -0
  18. {flashalpha-0.3.0 → flashalpha-0.3.2}/awesome-investing/README.md +0 -0
  19. {flashalpha-0.3.0 → flashalpha-0.3.2}/awesome-investing/contributing.md +0 -0
  20. {flashalpha-0.3.0 → flashalpha-0.3.2}/awesome_investing/README.md +0 -0
  21. {flashalpha-0.3.0 → flashalpha-0.3.2}/awesome_investing/src/static/img/.gitkeep +0 -0
  22. {flashalpha-0.3.0 → flashalpha-0.3.2}/awesome_investing/src/static/img/markus-spiske-5gGcn2PRrtc-unsplash.jpg +0 -0
  23. {flashalpha-0.3.0 → flashalpha-0.3.2}/examples/quickstart.py +0 -0
  24. {flashalpha-0.3.0 → flashalpha-0.3.2}/src/flashalpha/exceptions.py +0 -0
  25. {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.0
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.0"
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"
@@ -10,7 +10,7 @@ from .exceptions import (
10
10
  TierRestrictedError,
11
11
  )
12
12
 
13
- __version__ = "0.3.0"
13
+ __version__ = "0.3.2"
14
14
  __all__ = [
15
15
  "FlashAlpha",
16
16
  "FlashAlphaError",
@@ -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/live", body)
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/live", json=payload)
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/live", json={"meta": {}, "data": []})
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/live"
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/live", json={"meta": {}, "data": []})
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/live", json={"meta": {}, "data": []})
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/live", json={"meta": {}, "data": []})
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/live", json={"meta": {}, "data": []})
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/live", json={"meta": {}, "data": []})
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/live", json={"meta": {}, "data": []})
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/live", json={"meta": {}, "data": []})
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/live", json={"meta": {}, "data": []})
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/live", json={"meta": {}, "data": []})
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/live", json={"meta": {}, "data": []})
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/live", json={"meta": {}, "data": []})
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/live", json={"meta": {}, "data": []})
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/live", json={"meta": {}, "data": []})
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/live", json={"meta": {}, "data": []})
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/live", json={"meta": {}, "data": []})
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/live", json=payload)
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/live", json=err_body, status=400)
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/live", json=err_body, status=400)
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/live", json=err_body, status=403)
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