You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Epic: part of #387 · Depends on: none (dependency root)
Summary
This is the dependency root for the Kalshi PERPS (margin) API implementation. It introduces standalone PerpsClient / AsyncPerpsClient classes (NOT a namespace on KalshiClient) targeting the separate perps host, a new PerpsConfig, the kalshi/perps/ package skeleton with empty resource stubs, the shared common models module, the three committed perps spec snapshots, weekly spec-sync coverage for them, and — critically — a parameterized contract-test harness so perps endpoints validate against specs/perps_openapi.yaml instead of the existing specs/openapi.yaml. This issue owns no business endpoints; it unblocks every per-resource perps issue.
The decisive reason PerpsConfig is a new class (not KalshiConfig with perps defaults): KalshiConfig.__post_init__ hard-fails on hosts outside _KNOWN_HOSTS = {"api.elections.kalshi.com", "demo-api.kalshi.co"} (kalshi/config.py:27, raises in _validate_url). The perps hosts external-api.kalshi.com / external-api.demo.kalshi.co are not in that set, so reusing KalshiConfig would raise at construction. The /trade-api/v2 path component is identical, so that check is reused unchanged.
Endpoints / scope
This issue implements no REST/WS operations. It builds infrastructure only. The per-resource issues (markets, orders, order_groups, portfolio, funding, risk, exchange, transfers, subaccounts, fcm, fills, positions, trades) depend on this one.
Method
Path
operationId
Notes
—
—
—
None. Foundation only. Resource attributes are wired as empty stubs to be filled by dependent issues.
Models
kalshi/perps/models/common.py — shared enums and value-objects
Mirror the alias/DollarDecimal/FixedPointCount conventions in kalshi/models/common.py. Response models use model_config = ConfigDict(extra="allow", populate_by_name=True) (so server-added fields don't break parsing); enums are plain str, Enum. Confirm DollarDecimal and FixedPointCount from kalshi/types.py are reused as-is — the perps spec defines FixedPointDollars (string, up to 6 decimals; spec line 1464) and FixedPointCount (string, 2 decimals, "fp" suffix; spec line 1453) with identical semantics to the main SDK, so no new custom type is needed.
Enums (cite exact spec enum values):
BookSide (str, Enum) — spec BookSide, values bid, ask. Perps uses bid/ask (NOT the main SDK's yes/no); do not alias to the main Side.
LastUpdateReason (str, Enum) — spec LastUpdateReason, values: "" (empty string — define as NONE = ""), Decrease, Amend, MarginCancel, SelfTradeCancel, ExpiryCancel, Trade, PostOnlyCrossCancel. Note the empty-string member and the PascalCase wire values (no snake_case rename — these are the literal wire strings).
ExchangeIndex — spec ExchangeIndex is type: integer, default 0, "only 0 currently supported". Model as a plain int type alias (or bare field default 0); not an enum.
PriceLevelDollarsCountFp — spec PriceLevelDollarsCountFp is a positional 2-tuple [dollars_string, fp] where element 0 is a FixedPointDollars price string and element 1 is a FixedPointCount contract-count string. Model as tuple[DollarDecimal, FixedPointCount]. (The existing _get_schema_fields returns {} for non-object schemas, so this positional shape has no field-drift comparison — matches main SDK treatment.)
EmptyResponse — spec EmptyResponse, an empty object body. A model with model_config = ConfigDict(extra="allow") and no required fields. Used as the success response for DeleteMarginOrderGroup, ResetMarginOrderGroup, TriggerMarginOrderGroup, UpdateMarginOrderGroupLimit.
ErrorResponse — spec ErrorResponse, optional fields code: str, message: str, details: str, service: str. Parsed by the transport's _map_error path already; included here for completeness/typed access.
Page/cursor helper: the perps list responses carry a top-level cursor: str field on the response wrapper (e.g. GetMarginOrdersResponse.cursor, GetMarginFillsResponse.cursor, GetMarginTradesResponse.cursor). The main SDK's list_all pagination reads a cursor off the response. Confirm whether the existing paginator helper in kalshi/resources/_base.py can be reused directly by perps resources or whether a thin perps-side wrapper is needed; document the decision here so dependent issues (orders/fills/trades) inherit it. Do NOT build a speculative new paginator if the existing one fits.
No request-body models live in this issue (no endpoints). Per-resource issues create their own Create*Request / Amend*Request / etc. models with model_config = {"extra": "forbid"} and serialization_alias for wire-name mismatches (e.g. count → count_fp).
Resource methods
kalshi/perps/client.py — PerpsClient
Mirror KalshiClient.__init__ (kalshi/client.py:73-166) exactly: same constructor keyword surface (key_id, private_key_path, private_key, auth, config, demo, base_url, timeout, max_retries, transport), same _auth / _auth_owned ownership rules, same from_env / ClientInitKwargs TypedDict, same close() / __enter__ / __exit__. Reuse KalshiAuth and SyncTransport unchanged — the RSA-PSS signer and transport are environment-agnostic (they sign str(ts_ms) + METHOD + path; the path component /trade-api/v2/... is identical for perps).
Build a PerpsConfig (not KalshiConfig) when config is None; default base_url to PERPS_PRODUCTION_BASE_URL, demo to PERPS_DEMO_BASE_URL.
from_env reads a distinct env var set so perps credentials are separate from main-SDK credentials per Kalshi's "separate host + separate API keys" recommendation: KALSHI_PERPS_KEY_ID, KALSHI_PERPS_PRIVATE_KEY / KALSHI_PERPS_PRIVATE_KEY_PATH, KALSHI_PERPS_API_BASE_URL, KALSHI_PERPS_DEMO. Document in the docstring that perps requires keys issued for the perps exchange.
No ws_base_url plumbing in this issue (perps WS is a later issue); PerpsConfig may carry a perps WS URL field but the client need not expose a WS client yet.
Wire resource attributes as empty stubs (placeholder classes constructed with self._transport, to be implemented by dependent issues). The attribute names, matching the spec tags and locked decision list:
Each stub is a minimal class PerpsXResource(SyncResource): ... (and AsyncPerpsXResource(AsyncResource)) with pass or a single __init__. Use builtins.list[T] annotations inside any resource method added later. The stub files live at kalshi/perps/resources/<area>.py; this issue creates them empty/minimal so the client imports resolve and mypy --strict passes.
kalshi/perps/async_client.py — AsyncPerpsClient
Mirror AsyncKalshiClient (kalshi/async_client.py): same surface, AsyncTransport, Async* resource stubs, async/awaitclose().
Contract-test wiring
This is the enabling change every perps REST issue depends on. Today tests/test_contracts.py hard-codes a single SPEC_FILE = .../specs/openapi.yaml (line 54) and ASYNCAPI_FILE (line 481); _load_spec() (line 253) loads only that file; METHOD_ENDPOINT_MAP / BODY_MODEL_MAP (tests/_contract_support.py:100, tests/test_contracts.py:1156) assume one spec.
Approach — parallel perps map + a second drift-test class (lower-risk than threading a per-entry spec selector through every existing assertion):
Add to tests/test_contracts.py:
PERPS_SPEC_FILE=Path(__file__).parent.parent/"specs"/"perps_openapi.yaml"PERPS_SCM_SPEC_FILE=Path(__file__).parent.parent/"specs"/"perps_scm_openapi.yaml"def_load_perps_spec() ->dict[str, Any]: ... # same body as _load_spec, PERPS_SPEC_FILE
Add PERPS_METHOD_ENDPOINT_MAP: list[MethodEndpointEntry] and PERPS_BODY_MODEL_MAP: dict[str, str] to tests/_contract_support.py / tests/test_contracts.py respectively (empty in this issue — the per-resource issues append their entries). Add a parallel PERPS_EXCLUSIONS allowlist with the same Exclusion(reason, kind) shape.
Add parametrized drift-test classes TestPerpsRequestParamDrift, TestPerpsRequestBodyDrift, TestPerpsSpecDrift that consume PERPS_METHOD_ENDPOINT_MAP + PERPS_BODY_MODEL_MAP + _load_perps_spec(), structurally identical to the existing TestRequestParamDrift / TestRequestBodyDrift / TestSpecDrift. Refactor the shared spec-walking helpers (_resolve_ref, _resolve_schema, _get_schema_fields) to take the spec dict as a parameter (they already do) so both the main and perps test classes reuse them — no logic duplication.
SCM ("Klear") endpoints validate against specs/perps_scm_openapi.yaml via PERPS_SCM_SPEC_FILE / _load_perps_scm_spec(). Provide the loader + a TestPerpsScmSpecDrift skeleton; the SCM per-resource issue populates its map.
Guard with pytest.skip when the perps spec files are absent (mirror _load_spec's if not SPEC_FILE.exists(): pytest.skip(...)).
METHOD_ENDPOINT_MAP entries to add in THIS issue: none (no endpoints). The structure and an empty PERPS_METHOD_ENDPOINT_MAP = [] are what this issue ships, plus a doc comment showing dependent issues the exact entry shape, e.g.:
BODY_MODEL_MAP entries: none. Ship PERPS_BODY_MODEL_MAP: dict[str, str] = {} with a comment showing the spec-ref → model-FQN shape, e.g. "#/components/schemas/CreateMarginOrderRequest": "kalshi.perps.models.orders.CreateMarginOrderRequest".
EXCLUSIONS: none required by this issue.
Spec snapshots & weekly sync
Commit the three downloaded snapshots to specs/perps_openapi.yaml, specs/perps_asyncapi.yaml, specs/perps_scm_openapi.yaml (copy from /tmp/perps_openapi.yaml, /tmp/perps_asyncapi.yaml, /tmp/perps_scm_openapi.yaml). Match exactly how specs/openapi.yaml is handled — same directory, raw upstream bytes, no reformatting.
Extend scripts/sync_spec.py's SPECS dict to fetch the three new URLs:
The existing sync_specs() loop already hashes each file and reports {filename: changed}, so the three new specs are covered with no further code change.
Extend .github/workflows/spec-sync.yml to snapshot/diff/checksum the three perps specs alongside the existing two: add had_old_perps_openapi etc. snapshot guards, extend the cmp -s diff step, and fold the perps sha256s into the drift fingerprint so a perps-spec change opens a spec-drift issue. Preserve the workflow's contents: read + issues: write security model — do NOT add write/push scope.
Tests
tests/perps/test_client.py and tests/perps/test_config.py (new test package mirroring tests/). Reuse the conftest RSA-key fixtures from tests/conftest.py. Use respx.mock for any HTTP assertions.
PerpsConfig
happy: PerpsConfig.production() / .demo() yield the correct perps base URLs; trailing slash stripped; /trade-api/v2 path accepted.
error: a non-perps host (e.g. api.elections.kalshi.com) raises unless allow_unknown_host=True; a missing /trade-api/v2 path component raises; http:// to a remote perps host raises (plaintext-to-remote guard); KALSHI-ACCESS-* in extra_headers raises.
edge: allow_unknown_host=True / KALSHI_PERPS_ALLOW_UNKNOWN_HOST=1 env escape hatch permits a mock host with only a warning.
PerpsClient / AsyncPerpsClient construction
happy: key_id + private_key_path builds an owned KalshiAuth; auth= is caller-owned; no creds → unauthenticated client; all resource stub attributes (markets, orders, order_groups, portfolio, funding, risk, exchange, transfers, subaccounts, fcm) exist and are non-None.
error: passing both private_key_path and private_key raises; empty key_id raises; demo=True with a conflicting explicit non-demo base_url raises (mirror kalshi/client.py:124).
edge: from_env reads KALSHI_PERPS_* vars and sets _auth_owned=True when it built the auth; close() closes the transport and owned auth; context-manager enter/exit; async aclose path.
common models (tests/perps/test_models_common.py)
happy: each enum parses its exact wire values (incl. LastUpdateReason("") == LastUpdateReason.NONE and PascalCase members; BookSidebid/ask); PriceLevelDollarsCountFp parses ["0.1500", "100.00"] into (Decimal("0.1500"), Decimal("100.00")); EmptyResponse parses {}; ErrorResponse parses a full error body.
error: an unknown enum value raises ValidationError.
edge: confirm DollarDecimal/FixedPointCount round-trip (parse "0.560000" and "10.00", serialize back via mode="json").
contract harness (tests/perps/test_contract_harness.py or extend tests/test_contracts.py)
_load_perps_spec() loads specs/perps_openapi.yaml and parses; _load_perps_scm_spec() loads the SCM spec.
PERPS_METHOD_ENDPOINT_MAP is empty-but-defined; the new TestPerps*Drift classes collect and pass (vacuously) so dependent PRs can append entries; skip cleanly when spec files are absent.
Acceptance criteria
PerpsClient and AsyncPerpsClient implemented in kalshi/perps/client.py / kalshi/perps/async_client.py, mirroring KalshiClient construction and reusing KalshiAuth + SyncTransport/AsyncTransport unchanged.
PerpsConfig implemented with perps prod/demo base URLs (https://external-api.kalshi.com/trade-api/v2, https://external-api.demo.kalshi.co/trade-api/v2), its own known-hosts set, .production() / .demo() factories, and the same security guards as KalshiConfig.
from_env reads the separate KALSHI_PERPS_* credential env vars; docstrings document Kalshi's separate-host + separate-keys recommendation.
Package skeleton created: kalshi/perps/__init__.py, kalshi/perps/models/__init__.py, kalshi/perps/models/common.py, kalshi/perps/resources/__init__.py, plus empty/minimal resource stub modules (markets, orders, order_groups, portfolio, funding, risk, exchange, transfers, subaccounts, fcm) with both sync and async stub classes wired onto the clients.
Common enums/value-objects implemented in kalshi/perps/models/common.py: BookSide, SelfTradePreventionType, LastUpdateReason, OrderSource, MarginMarketStatus, ExchangeInstance, ExchangeIndex, PriceLevelDollarsCountFp, EmptyResponse, ErrorResponse; DollarDecimal + FixedPointCount confirmed reusable from kalshi/types.py.
PerpsClient, AsyncPerpsClient, PerpsConfig exported from kalshi/perps/__init__.py and re-exported from kalshi/__init__.py (__all__ updated).
Three perps specs committed under specs/perps_openapi.yaml, specs/perps_asyncapi.yaml, specs/perps_scm_openapi.yaml; scripts/sync_spec.pySPECS extended; .github/workflows/spec-sync.yml snapshots/diffs/checksums them while preserving contents: read + issues: write.
Summary
This is the dependency root for the Kalshi PERPS (margin) API implementation. It introduces standalone
PerpsClient/AsyncPerpsClientclasses (NOT a namespace onKalshiClient) targeting the separate perps host, a newPerpsConfig, thekalshi/perps/package skeleton with empty resource stubs, the shared common models module, the three committed perps spec snapshots, weekly spec-sync coverage for them, and — critically — a parameterized contract-test harness so perps endpoints validate againstspecs/perps_openapi.yamlinstead of the existingspecs/openapi.yaml. This issue owns no business endpoints; it unblocks every per-resource perps issue.The decisive reason
PerpsConfigis a new class (notKalshiConfigwith perps defaults):KalshiConfig.__post_init__hard-fails on hosts outside_KNOWN_HOSTS = {"api.elections.kalshi.com", "demo-api.kalshi.co"}(kalshi/config.py:27, raises in_validate_url). The perps hostsexternal-api.kalshi.com/external-api.demo.kalshi.coare not in that set, so reusingKalshiConfigwould raise at construction. The/trade-api/v2path component is identical, so that check is reused unchanged.Endpoints / scope
This issue implements no REST/WS operations. It builds infrastructure only. The per-resource issues (markets, orders, order_groups, portfolio, funding, risk, exchange, transfers, subaccounts, fcm, fills, positions, trades) depend on this one.
Models
kalshi/perps/models/common.py— shared enums and value-objectsMirror the alias/
DollarDecimal/FixedPointCountconventions inkalshi/models/common.py. Response models usemodel_config = ConfigDict(extra="allow", populate_by_name=True)(so server-added fields don't break parsing); enums are plainstr, Enum. ConfirmDollarDecimalandFixedPointCountfromkalshi/types.pyare reused as-is — the perps spec definesFixedPointDollars(string, up to 6 decimals; spec line 1464) andFixedPointCount(string, 2 decimals, "fp" suffix; spec line 1453) with identical semantics to the main SDK, so no new custom type is needed.Enums (cite exact spec enum values):
BookSide(str, Enum) — specBookSide, valuesbid,ask. Perps usesbid/ask(NOT the main SDK'syes/no); do not alias to the mainSide.SelfTradePreventionType(str, Enum) — specSelfTradePreventionType, valuestaker_at_cross,maker.LastUpdateReason(str, Enum) — specLastUpdateReason, values:""(empty string — define asNONE = ""),Decrease,Amend,MarginCancel,SelfTradeCancel,ExpiryCancel,Trade,PostOnlyCrossCancel. Note the empty-string member and the PascalCase wire values (no snake_case rename — these are the literal wire strings).OrderSource(str, Enum) — specOrderSource, valuesuser,system(system= system-generated liquidation order).MarginMarketStatus(str, Enum) — specMarginMarketStatus, valuesinactive,active,closed.ExchangeInstance(str, Enum) — specExchangeInstance, valuesevent_contract,margined.Value-objects / type aliases:
ExchangeIndex— specExchangeIndexistype: integer, default0, "only 0 currently supported". Model as a plaininttype alias (or bare field default0); not an enum.PriceLevelDollarsCountFp— specPriceLevelDollarsCountFpis a positional 2-tuple[dollars_string, fp]where element 0 is aFixedPointDollarsprice string and element 1 is aFixedPointCountcontract-count string. Model astuple[DollarDecimal, FixedPointCount]. (The existing_get_schema_fieldsreturns{}for non-object schemas, so this positional shape has no field-drift comparison — matches main SDK treatment.)EmptyResponse— specEmptyResponse, an empty object body. A model withmodel_config = ConfigDict(extra="allow")and no required fields. Used as the success response forDeleteMarginOrderGroup,ResetMarginOrderGroup,TriggerMarginOrderGroup,UpdateMarginOrderGroupLimit.ErrorResponse— specErrorResponse, optional fieldscode: str,message: str,details: str,service: str. Parsed by the transport's_map_errorpath already; included here for completeness/typed access.Page/cursor helper: the perps list responses carry a top-level
cursor: strfield on the response wrapper (e.g.GetMarginOrdersResponse.cursor,GetMarginFillsResponse.cursor,GetMarginTradesResponse.cursor). The main SDK'slist_allpagination reads a cursor off the response. Confirm whether the existing paginator helper inkalshi/resources/_base.pycan be reused directly by perps resources or whether a thin perps-side wrapper is needed; document the decision here so dependent issues (orders/fills/trades) inherit it. Do NOT build a speculative new paginator if the existing one fits.No request-body models live in this issue (no endpoints). Per-resource issues create their own
Create*Request/Amend*Request/ etc. models withmodel_config = {"extra": "forbid"}andserialization_aliasfor wire-name mismatches (e.g.count→count_fp).Resource methods
kalshi/perps/client.py—PerpsClientMirror
KalshiClient.__init__(kalshi/client.py:73-166) exactly: same constructor keyword surface (key_id,private_key_path,private_key,auth,config,demo,base_url,timeout,max_retries,transport), same_auth/_auth_ownedownership rules, samefrom_env/ClientInitKwargsTypedDict, sameclose()/__enter__/__exit__. ReuseKalshiAuthandSyncTransportunchanged — the RSA-PSS signer and transport are environment-agnostic (they signstr(ts_ms) + METHOD + path; the path component/trade-api/v2/...is identical for perps).Differences from
KalshiClient:PerpsConfig(notKalshiConfig) whenconfig is None; defaultbase_urltoPERPS_PRODUCTION_BASE_URL, demo toPERPS_DEMO_BASE_URL.from_envreads a distinct env var set so perps credentials are separate from main-SDK credentials per Kalshi's "separate host + separate API keys" recommendation:KALSHI_PERPS_KEY_ID,KALSHI_PERPS_PRIVATE_KEY/KALSHI_PERPS_PRIVATE_KEY_PATH,KALSHI_PERPS_API_BASE_URL,KALSHI_PERPS_DEMO. Document in the docstring that perps requires keys issued for the perps exchange.ws_base_urlplumbing in this issue (perps WS is a later issue);PerpsConfigmay carry a perps WS URL field but the client need not expose a WS client yet.Wire resource attributes as empty stubs (placeholder classes constructed with
self._transport, to be implemented by dependent issues). The attribute names, matching the spectagsand locked decision list:Each stub is a minimal
class PerpsXResource(SyncResource): ...(andAsyncPerpsXResource(AsyncResource)) withpassor a single__init__. Usebuiltins.list[T]annotations inside any resource method added later. The stub files live atkalshi/perps/resources/<area>.py; this issue creates them empty/minimal so the client imports resolve andmypy --strictpasses.kalshi/perps/async_client.py—AsyncPerpsClientMirror
AsyncKalshiClient(kalshi/async_client.py): same surface,AsyncTransport,Async*resource stubs,async/awaitclose().Contract-test wiring
This is the enabling change every perps REST issue depends on. Today
tests/test_contracts.pyhard-codes a singleSPEC_FILE = .../specs/openapi.yaml(line 54) andASYNCAPI_FILE(line 481);_load_spec()(line 253) loads only that file;METHOD_ENDPOINT_MAP/BODY_MODEL_MAP(tests/_contract_support.py:100, tests/test_contracts.py:1156) assume one spec.Approach — parallel perps map + a second drift-test class (lower-risk than threading a per-entry spec selector through every existing assertion):
tests/test_contracts.py:PERPS_METHOD_ENDPOINT_MAP: list[MethodEndpointEntry]andPERPS_BODY_MODEL_MAP: dict[str, str]totests/_contract_support.py/tests/test_contracts.pyrespectively (empty in this issue — the per-resource issues append their entries). Add a parallelPERPS_EXCLUSIONSallowlist with the sameExclusion(reason, kind)shape.TestPerpsRequestParamDrift,TestPerpsRequestBodyDrift,TestPerpsSpecDriftthat consumePERPS_METHOD_ENDPOINT_MAP+PERPS_BODY_MODEL_MAP+_load_perps_spec(), structurally identical to the existingTestRequestParamDrift/TestRequestBodyDrift/TestSpecDrift. Refactor the shared spec-walking helpers (_resolve_ref,_resolve_schema,_get_schema_fields) to take the spec dict as a parameter (they already do) so both the main and perps test classes reuse them — no logic duplication.specs/perps_scm_openapi.yamlviaPERPS_SCM_SPEC_FILE/_load_perps_scm_spec(). Provide the loader + aTestPerpsScmSpecDriftskeleton; the SCM per-resource issue populates its map.pytest.skipwhen the perps spec files are absent (mirror_load_spec'sif not SPEC_FILE.exists(): pytest.skip(...)).METHOD_ENDPOINT_MAPentries to add in THIS issue: none (no endpoints). The structure and an emptyPERPS_METHOD_ENDPOINT_MAP = []are what this issue ships, plus a doc comment showing dependent issues the exact entry shape, e.g.:BODY_MODEL_MAPentries: none. ShipPERPS_BODY_MODEL_MAP: dict[str, str] = {}with a comment showing thespec-ref → model-FQNshape, e.g."#/components/schemas/CreateMarginOrderRequest": "kalshi.perps.models.orders.CreateMarginOrderRequest".EXCLUSIONS: none required by this issue.
Spec snapshots & weekly sync
specs/perps_openapi.yaml,specs/perps_asyncapi.yaml,specs/perps_scm_openapi.yaml(copy from/tmp/perps_openapi.yaml,/tmp/perps_asyncapi.yaml,/tmp/perps_scm_openapi.yaml). Match exactly howspecs/openapi.yamlis handled — same directory, raw upstream bytes, no reformatting.scripts/sync_spec.py'sSPECSdict to fetch the three new URLs:sync_specs()loop already hashes each file and reports{filename: changed}, so the three new specs are covered with no further code change..github/workflows/spec-sync.ymlto snapshot/diff/checksum the three perps specs alongside the existing two: addhad_old_perps_openapietc. snapshot guards, extend thecmp -sdiff step, and fold the perps sha256s into the drift fingerprint so a perps-spec change opens aspec-driftissue. Preserve the workflow'scontents: read+issues: writesecurity model — do NOT add write/push scope.Tests
tests/perps/test_client.pyandtests/perps/test_config.py(new test package mirroringtests/). Reuse the conftest RSA-key fixtures fromtests/conftest.py. Userespx.mockfor any HTTP assertions.PerpsConfigPerpsConfig.production()/.demo()yield the correct perps base URLs; trailing slash stripped;/trade-api/v2path accepted.api.elections.kalshi.com) raises unlessallow_unknown_host=True; a missing/trade-api/v2path component raises;http://to a remote perps host raises (plaintext-to-remote guard);KALSHI-ACCESS-*inextra_headersraises.allow_unknown_host=True/KALSHI_PERPS_ALLOW_UNKNOWN_HOST=1env escape hatch permits a mock host with only a warning.PerpsClient/AsyncPerpsClientconstructionkey_id+private_key_pathbuilds an ownedKalshiAuth;auth=is caller-owned; no creds → unauthenticated client; all resource stub attributes (markets,orders,order_groups,portfolio,funding,risk,exchange,transfers,subaccounts,fcm) exist and are non-None.private_key_pathandprivate_keyraises; emptykey_idraises;demo=Truewith a conflicting explicit non-demobase_urlraises (mirror kalshi/client.py:124).from_envreadsKALSHI_PERPS_*vars and sets_auth_owned=Truewhen it built the auth;close()closes the transport and owned auth; context-manager enter/exit; asyncaclosepath.tests/perps/test_models_common.py)LastUpdateReason("") == LastUpdateReason.NONEand PascalCase members;BookSidebid/ask);PriceLevelDollarsCountFpparses["0.1500", "100.00"]into(Decimal("0.1500"), Decimal("100.00"));EmptyResponseparses{};ErrorResponseparses a full error body.ValidationError.DollarDecimal/FixedPointCountround-trip (parse"0.560000"and"10.00", serialize back viamode="json").tests/perps/test_contract_harness.pyor extendtests/test_contracts.py)_load_perps_spec()loadsspecs/perps_openapi.yamland parses;_load_perps_scm_spec()loads the SCM spec.PERPS_METHOD_ENDPOINT_MAPis empty-but-defined; the newTestPerps*Driftclasses collect and pass (vacuously) so dependent PRs can append entries; skip cleanly when spec files are absent.Acceptance criteria
PerpsClientandAsyncPerpsClientimplemented inkalshi/perps/client.py/kalshi/perps/async_client.py, mirroringKalshiClientconstruction and reusingKalshiAuth+SyncTransport/AsyncTransportunchanged.PerpsConfigimplemented with perps prod/demo base URLs (https://external-api.kalshi.com/trade-api/v2,https://external-api.demo.kalshi.co/trade-api/v2), its own known-hosts set,.production()/.demo()factories, and the same security guards asKalshiConfig.from_envreads the separateKALSHI_PERPS_*credential env vars; docstrings document Kalshi's separate-host + separate-keys recommendation.kalshi/perps/__init__.py,kalshi/perps/models/__init__.py,kalshi/perps/models/common.py,kalshi/perps/resources/__init__.py, plus empty/minimal resource stub modules (markets,orders,order_groups,portfolio,funding,risk,exchange,transfers,subaccounts,fcm) with both sync and async stub classes wired onto the clients.kalshi/perps/models/common.py:BookSide,SelfTradePreventionType,LastUpdateReason,OrderSource,MarginMarketStatus,ExchangeInstance,ExchangeIndex,PriceLevelDollarsCountFp,EmptyResponse,ErrorResponse;DollarDecimal+FixedPointCountconfirmed reusable fromkalshi/types.py.PerpsClient,AsyncPerpsClient,PerpsConfigexported fromkalshi/perps/__init__.pyand re-exported fromkalshi/__init__.py(__all__updated).specs/perps_openapi.yaml,specs/perps_asyncapi.yaml,specs/perps_scm_openapi.yaml;scripts/sync_spec.pySPECSextended;.github/workflows/spec-sync.ymlsnapshots/diffs/checksums them while preservingcontents: read+issues: write._load_perps_spec()/_load_perps_scm_spec(), empty-but-definedPERPS_METHOD_ENDPOINT_MAP/PERPS_BODY_MODEL_MAP/PERPS_EXCLUSIONS, andTestPerps*Driftclasses that dependent issues populate; shared spec-walk helpers reused (not duplicated).mypy kalshi/(strict) clean —builtins.list[T]used inside any resource class; noAnyleaks.ruff check .clean.pytest tests/ -vgreen, including the existing drift suites and the new vacuous perps drift classes.Dependencies
None. This is the dependency root for all perps issues.